Support showing promo messages with no end date and no % discount (#329193)

* Support showing promo messages with no end date and no % discount

* Address PR feedback on promo message support

- Tighten promo JSDoc on the metadata contract, notification contribution,
  and helpers to match repository JSDoc limits
- Document the positive/zero/negative discount cases so message-only promos
  are not omitted by agent implementations
- Cover open-ended, message-only promos through the untyped _meta read and
  the CAPI billing normalization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: edb3b42a-1bec-45f8-9453-51ad10583f06

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: edb3b42a-1bec-45f8-9453-51ad10583f06
This commit is contained in:
Logan Ramos
2026-08-05 16:07:14 +00:00
committed by GitHub
co-authored by Copilot
parent f2b63ea6ee
commit d6bfbc2fde
14 changed files with 205 additions and 70 deletions
@@ -105,7 +105,8 @@ export interface IModelTokenPrices {
export interface IModelPromo {
id: string;
discount_percent: number;
ends_at: string;
/** ISO 8601 end date; absent for open-ended promotions. */
ends_at?: string;
message: string;
}
@@ -181,7 +181,7 @@ export class ChatEndpoint implements IChatEndpoint {
public readonly customModel?: CustomModel | undefined;
public readonly maxPromptImages?: number | undefined;
public readonly warningText?: Record<string, string> | undefined;
public readonly promo?: { id: string; discountPercent: number; endsAt: string; message: string } | undefined;
public readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string } | undefined;
private readonly _supportsStreaming: boolean;
@@ -342,7 +342,7 @@ export interface IChatEndpoint extends IEndpoint {
readonly isPremium?: boolean;
readonly degradationReason?: string;
readonly warningText?: Record<string, string>;
readonly promo?: { id: string; discountPercent: number; endsAt: string; message: string };
readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string };
readonly multiplier?: number;
readonly restrictedToSkus?: string[];
/**
@@ -39,11 +39,12 @@ export interface IAgentModelPricingMeta {
readonly category?: string;
/** Whole-number percentage discount (0-100) for the synthetic `auto` model; shown as a "{n}% discount" detail. */
readonly discountPercent?: number;
/** Promotional information when the model is experiencing a discount. */
/** Promotional information for the model. A `discountPercent` of `0` is a valid message-only promo. */
readonly promo?: {
readonly id: string;
readonly discountPercent: number;
readonly endsAt: string;
/** ISO 8601 end date; absent for open-ended promotions. */
readonly endsAt?: string;
readonly message: string;
};
}
@@ -87,8 +88,13 @@ export function readAgentModelPricingMeta(model: IAgentModelInfo | SessionModelI
const rawPromo = meta.promo;
if (rawPromo && typeof rawPromo === 'object' && !Array.isArray(rawPromo)) {
const p = rawPromo as Record<string, unknown>;
if (typeof p.id === 'string' && typeof p.discountPercent === 'number' && typeof p.endsAt === 'string' && typeof p.message === 'string') {
result.promo = { id: p.id, discountPercent: p.discountPercent, endsAt: p.endsAt, message: p.message };
if (typeof p.id === 'string' && typeof p.discountPercent === 'number' && typeof p.message === 'string') {
result.promo = {
id: p.id,
discountPercent: p.discountPercent,
message: p.message,
...(typeof p.endsAt === 'string' ? { endsAt: p.endsAt } : {}),
};
}
}
return result;
@@ -175,8 +181,8 @@ function normalizePromo(billing: Record<string, unknown>): ICAPIModelBilling['pr
: typeof raw.ends_at === 'string' ? raw.ends_at
: undefined;
const message = typeof raw.message === 'string' ? raw.message : undefined;
if (id && typeof discountPercent === 'number' && endsAt && message) {
return { id, discountPercent, endsAt, message };
if (id && typeof discountPercent === 'number' && message) {
return { id, discountPercent, message, ...(endsAt ? { endsAt } : {}) };
}
return undefined;
}
@@ -191,11 +197,12 @@ export interface ICAPIModelBilling {
readonly priceCategory?: string;
/** Whole-number percentage discount (0-100) for the synthetic `auto` model; rendered as a "{n}% discount" detail. */
readonly discountPercent?: number;
/** Promotional info when the model is experiencing a promotional discount. */
/** Promotional information for the model. A `discountPercent` of `0` is a valid message-only promo. */
readonly promo?: {
readonly id: string;
readonly discountPercent: number;
readonly endsAt: string;
/** ISO 8601 end date; absent for open-ended promotions. */
readonly endsAt?: string;
readonly message: string;
};
readonly tokenPrices?: {
@@ -2129,6 +2129,26 @@ suite('CopilotAgent', () => {
}
});
test('models keep an open-ended, message-only promotion', async () => {
const agent = createTestAgent(disposables, {
copilotClient: new TestCopilotClient([], [{
id: 'claude-sonnet',
name: 'Claude Sonnet',
capabilities: { limits: { max_context_window_tokens: 200_000 } },
// No `endsAt` and a zero discount: the promo must survive normalization.
billing: { multiplier: 1, promo: { id: 'featured', discountPercent: 0, message: 'Now available' } },
}]),
});
try {
await agent.authenticate('https://api.github.com', 'token');
const models = await waitForState(agent.models, models => models.length > 0);
assert.deepStrictEqual(models[0]._meta?.promo, { id: 'featured', discountPercent: 0, message: 'Now available' });
} finally {
await disposeAgent(agent);
}
});
test('configSchema emits a thinkingLevel property when the model advertises reasoning efforts', async () => {
const agent = createTestAgent(disposables, {
copilotClient: new TestCopilotClient([], [{
@@ -15,12 +15,8 @@ const PROMO_NOTIFICATION_ID = 'copilot.promoNotification';
const DISMISSED_PROMOS_STORAGE_KEY = 'chat.dismissedPromoIds';
/**
* Watches for models with active promotions and surfaces a chat input
* notification per harness (chat session type) the first time each promo
* appears. Each notification is scoped to the session type of the model that
* carries the promo, so a chat input only advertises a model it can actually
* switch to. Dismissals are persisted by promo id so the same promo is never
* shown again.
* Surfaces a model's promo as a chat input notification, scoped to the harness
* (chat session type) of the model that carries it. Dismissals are persisted by promo id.
*/
export class ChatPromoNotificationContribution extends Disposable implements IWorkbenchContribution {
@@ -50,18 +46,17 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
const dismissed = this._getDismissedPromoIds();
const modelIds = this._languageModelsService.getLanguageModelIds();
// A promo can appear in several harnesses at once (e.g. the same model
// offered in the Local, Copilot, and Codex sessions). Bucket the first
// non-dismissed promo per harness (a model's `targetChatSessionType`,
// or the local pool when unset).
// Bucket one non-dismissed promo per harness (a model's `targetChatSessionType`,
// or the local pool when unset), preferring a discounted promo over a message-only one.
const promoByHarness = new Map<string, ILanguageModelChatMetadataAndIdentifier>();
for (const id of modelIds) {
const meta = this._languageModelsService.lookupLanguageModel(id);
if (!meta || !ILanguageModelChatMetadata.hasPromoDiscount(meta) || dismissed.has(meta.promo.id)) {
if (!meta || !ILanguageModelChatMetadata.hasPromoMessage(meta) || dismissed.has(meta.promo.id)) {
continue;
}
const harness = meta.targetChatSessionType ?? localChatSessionType;
if (!promoByHarness.has(harness)) {
const current = promoByHarness.get(harness);
if (!current || (!ILanguageModelChatMetadata.hasPromoDiscount(current.metadata) && ILanguageModelChatMetadata.hasPromoDiscount(meta))) {
promoByHarness.set(harness, { identifier: id, metadata: meta });
}
}
@@ -82,15 +77,12 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo
}
this._shownNotifications.set(notificationId, { promoId: promo.id, modelIdentifier: model.identifier });
const endsAtDate = new Date(promo.endsAt);
const formattedDate = endsAtDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
this._chatInputNotificationService.setNotification({
id: notificationId,
telemetryId: promo.id,
severity: ChatInputNotificationSeverity.Info,
message: promo.message,
description: localize('chat.promo.endsAt', "Ends {0}.", formattedDate),
description: ILanguageModelChatMetadata.getPromoEndsAtLabel(promo.endsAt),
actions: [{
label: localize('chat.promo.tryModel', "Try {0}", model.metadata.name),
kind: ChatInputNotificationActionKind.SwitchToModel,
@@ -78,9 +78,8 @@ export function getModelHoverContent(
if (promo) {
const promoContainer = dom.$('.chat-model-hover-promo-text');
promoContainer.appendChild(renderIcon(Codicon.info));
const endsAtDate = new Date(promo.endsAt);
const formattedDate = endsAtDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
const promoMessage = promo.message + ' ' + localize('chat.promo.endsAt', "Ends {0}.", formattedDate);
const endsAtLabel = ILanguageModelChatMetadata.getPromoEndsAtLabel(promo.endsAt);
const promoMessage = endsAtLabel ? promo.message + ' ' + endsAtLabel : promo.message;
const promoMd = new MarkdownString(promoMessage, { isTrusted: false, supportThemeIcons: true });
const rendered = disposables.add(renderMarkdown(promoMd, {
actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); },
@@ -86,7 +86,7 @@ export interface IChatSessionProviderOptionModelMetadata {
readonly promo?: {
readonly id: string;
readonly discountPercent: number;
readonly endsAt: string;
readonly endsAt?: string;
readonly message: string;
};
readonly maxInputTokens?: number;
@@ -322,13 +322,15 @@ export interface ILanguageModelChatMetadata {
*/
readonly warningText?: IStringDictionary<string>;
/**
* Optional promotional information for this model. Positive discounts surface
* promotional UI; non-positive discounts only feature the model in the picker.
* Optional promotional information for this model. A positive `discountPercent`
* surfaces the full promotional UI; `0` is a message-only promo that features the
* model without a price change; a negative value is malformed and is ignored.
* `endsAt` is optional — open-ended promos omit it and render no end date.
*/
readonly promo?: {
readonly id: string;
readonly discountPercent: number;
readonly endsAt: string;
readonly endsAt?: string;
readonly message: string;
};
}
@@ -354,6 +356,24 @@ export namespace ILanguageModelChatMetadata {
return !!metadata.promo && metadata.promo.discountPercent > 0;
}
/** Whether the model has a promo message to surface, including message-only (0%) promos. */
export function hasPromoMessage(metadata: ILanguageModelChatMetadata): metadata is ILanguageModelChatMetadata & { readonly promo: NonNullable<ILanguageModelChatMetadata['promo']> } {
return !!metadata.promo && metadata.promo.discountPercent >= 0 && !!metadata.promo.message;
}
/** The localized "Ends {date}." sentence, or `undefined` for a missing or unparsable end date. */
export function getPromoEndsAtLabel(endsAt: string | undefined): string | undefined {
if (!endsAt) {
return undefined;
}
const endsAtDate = new Date(endsAt);
if (isNaN(endsAtDate.getTime())) {
return undefined;
}
const formattedDate = endsAtDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
return localize('chat.promo.endsAt', "Ends {0}.", formattedDate);
}
/**
* Documentation link explaining how Auto model selection works.
* NOTE: Also defined in extensions/copilot/src/extension/conversation/common/languageModelAccess.ts — keep in sync.
@@ -56,32 +56,50 @@ suite('AgentHostLanguageModelProvider', () => {
test('carries picker category, price category, and promo from model metadata', async () => {
const provider = createProvider();
provider.updateModels([makeModel('claude-sonnet', {
category: 'powerful',
priceCategory: 'medium',
promo: {
id: 'summer-sale',
discountPercent: 25,
endsAt: '2026-08-01T00:00:00Z',
message: 'Save on Claude Sonnet',
},
})]);
provider.updateModels([
makeModel('claude-sonnet', {
category: 'powerful',
priceCategory: 'medium',
promo: {
id: 'summer-sale',
discountPercent: 25,
endsAt: '2026-08-01T00:00:00Z',
message: 'Save on Claude Sonnet',
},
}),
// Open-ended, message-only promo: the untyped `_meta` read must keep it
// rather than drop the promo for the missing `endsAt` / zero discount.
makeModel('gpt-5', {
promo: {
id: 'featured',
discountPercent: 0,
message: 'Now available',
},
}),
]);
const metadata = (await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None))[0].metadata;
assert.deepStrictEqual({
category: metadata.category,
priceCategory: metadata.priceCategory,
promo: metadata.promo,
}, {
category: 'powerful',
priceCategory: 'medium',
promo: {
id: 'summer-sale',
discountPercent: 25,
endsAt: '2026-08-01T00:00:00Z',
message: 'Save on Claude Sonnet',
const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None);
assert.deepStrictEqual(infos.map(info => ({
category: info.metadata.category,
priceCategory: info.metadata.priceCategory,
promo: info.metadata.promo,
})), [
{
category: 'powerful',
priceCategory: 'medium',
promo: {
id: 'summer-sale',
discountPercent: 25,
endsAt: '2026-08-01T00:00:00Z',
message: 'Save on Claude Sonnet',
},
},
});
{
category: undefined,
priceCategory: undefined,
promo: { id: 'featured', discountPercent: 0, message: 'Now available' },
},
]);
});
test('derives the picker group from the model-id prefix, not the harness provider', async () => {
@@ -117,6 +117,7 @@ suite('ChatPromoNotificationContribution', () => {
const notification = notifService.getNotification();
assert.ok(notification, 'Expected a notification to be shown');
assert.ok(notification.message.toString().includes('20% off'));
assert.ok(notification.description?.toString().includes('2026'), 'Expected the end date to be rendered');
assert.deepStrictEqual(notification.actions, [{
label: 'Try GPT-5.5',
kind: ChatInputNotificationActionKind.SwitchToModel,
@@ -124,17 +125,30 @@ suite('ChatPromoNotificationContribution', () => {
}]);
});
test('does not show notification for non-positive promo discounts', () => {
test('renders the server message for a 0% promo', () => {
const notifService = createMockNotificationService(disposables);
const { service: lmService } = createMockLanguageModelsService([{
identifier: 'copilot:zero-discount',
metadata: { name: 'Zero Discount', id: 'zero-discount', promo: { id: 'promo-zero', discountPercent: 0, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
}], disposables);
const storageService = disposables.add(new InMemoryStorageService());
disposables.add(new ChatPromoNotificationContribution(
lmService,
notifService.service,
storageService,
));
const notification = notifService.getNotification();
assert.ok(notification, 'Expected a notification for the 0% promo');
assert.strictEqual(notification.message, 'Featured model');
});
test('prefers a discounted promo over a 0% one in the same harness', () => {
const notifService = createMockNotificationService(disposables);
const { service: lmService } = createMockLanguageModelsService([
{
identifier: 'copilot:zero-discount',
metadata: { name: 'Zero Discount', id: 'zero-discount', promo: { id: 'promo-zero', discountPercent: 0, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
},
{
identifier: 'copilot:negative-discount',
metadata: { name: 'Negative Discount', id: 'negative-discount', promo: { id: 'promo-negative', discountPercent: -10, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
},
{ identifier: 'copilot:featured', metadata: { name: 'Featured', id: 'featured', promo: { id: 'promo-zero', discountPercent: 0, message: 'Featured model' } } },
{ identifier: 'copilot:discounted', metadata: { name: 'Discounted', id: 'discounted', promo: { id: 'promo-discount', discountPercent: 20, message: 'Get 20% off' } } },
], disposables);
const storageService = disposables.add(new InMemoryStorageService());
@@ -144,9 +158,51 @@ suite('ChatPromoNotificationContribution', () => {
storageService,
));
const notification = notifService.getNotification();
assert.ok(notification);
assert.strictEqual(notification.message, 'Get 20% off');
});
test('does not show notification for negative promo discounts', () => {
const notifService = createMockNotificationService(disposables);
const { service: lmService } = createMockLanguageModelsService([{
identifier: 'copilot:negative-discount',
metadata: { name: 'Negative Discount', id: 'negative-discount', promo: { id: 'promo-negative', discountPercent: -10, endsAt: '2026-07-20T23:59:59Z', message: 'Featured model' } },
}], disposables);
const storageService = disposables.add(new InMemoryStorageService());
disposables.add(new ChatPromoNotificationContribution(
lmService,
notifService.service,
storageService,
));
assert.strictEqual(notifService.getNotification(), undefined);
});
test('omits the end date when the promo has none', () => {
const notifService = createMockNotificationService(disposables);
const { service: lmService } = createMockLanguageModelsService([
{ identifier: 'local:no-end-date', metadata: { name: 'Open Ended', id: 'no-end-date', promo: { id: 'promo-open', discountPercent: 20, message: 'Get 20% off' } } },
{ identifier: 'copilot:bad-end-date', metadata: { name: 'Bad Date', id: 'bad-end-date', targetChatSessionType: 'copilotcli', promo: { id: 'promo-bad-date', discountPercent: 20, endsAt: 'not a date', message: 'Get 20% off' } } },
], disposables);
const storageService = disposables.add(new InMemoryStorageService());
disposables.add(new ChatPromoNotificationContribution(
lmService,
notifService.service,
storageService,
));
assert.deepStrictEqual(
notifService.getAllNotifications().map(n => ({ message: n.message, description: n.description })),
[
{ message: 'Get 20% off', description: undefined },
{ message: 'Get 20% off', description: undefined },
],
);
});
test('does not show notification for already-dismissed promo', () => {
const notifService = createMockNotificationService(disposables);
const { service: lmService } = createMockLanguageModelsService([{
@@ -54,4 +54,26 @@ suite('ModelPickerHover', () => {
{ discountPercent: -10, category: 'Powerful', badges: ['High cost'], promoText: undefined },
]);
});
test('promo hover text omits the end date when the promo has none', () => {
const results = ['2026-07-20T23:59:59Z', 'not a date', undefined].map(endsAt => {
const model = createModel(`promo-${endsAt}`, `Promo ${endsAt}`);
model.metadata = {
...model.metadata,
promo: { id: `test-promo-${endsAt}`, discountPercent: 20, endsAt, message: 'Limited time offer' },
} as ILanguageModelChatMetadata;
const hover = getModelHoverContent(model, false, undefined, NullOpenerService);
assert.ok(hover);
disposables.add(hover.disposable);
const promoText = hover.element.querySelector('.chat-model-hover-promo-text')?.textContent?.trim();
// The formatted date is locale/timezone dependent, so only assert on the sentence around it.
return promoText?.replace(/Ends .+\.$/, 'Ends <date>.');
});
assert.deepStrictEqual(results, [
'Limited time offer Ends <date>.',
'Limited time offer',
'Limited time offer',
]);
});
});
+2 -2
View File
@@ -105,8 +105,8 @@ declare module 'vscode' {
readonly id: string;
/** The discount percentage (e.g. 20 for 20% off). */
readonly discountPercent: number;
/** ISO 8601 date string indicating when the promotion ends. */
readonly endsAt: string;
/** ISO 8601 date string indicating when the promotion ends. Omit for open-ended promotions. */
readonly endsAt?: string;
/** A human-readable message about the promotion. */
readonly message: string;
};
+1 -1
View File
@@ -717,7 +717,7 @@ declare module 'vscode' {
readonly promo?: {
readonly id: string;
readonly discountPercent: number;
readonly endsAt: string;
readonly endsAt?: string;
readonly message: string;
};
readonly maxInputTokens?: number;