diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index f12e2c60343..7b8dbf0e901 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -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; } diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index e85ce595135..2c1af5f0201 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -181,7 +181,7 @@ export class ChatEndpoint implements IChatEndpoint { public readonly customModel?: CustomModel | undefined; public readonly maxPromptImages?: number | undefined; public readonly warningText?: Record | 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; diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index e3ab217eb43..9e9dc4951f7 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -342,7 +342,7 @@ export interface IChatEndpoint extends IEndpoint { readonly isPremium?: boolean; readonly degradationReason?: string; readonly warningText?: Record; - 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[]; /** diff --git a/src/vs/platform/agentHost/common/agentModelPricing.ts b/src/vs/platform/agentHost/common/agentModelPricing.ts index cbc6366792b..7a679aaad16 100644 --- a/src/vs/platform/agentHost/common/agentModelPricing.ts +++ b/src/vs/platform/agentHost/common/agentModelPricing.ts @@ -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; - 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): 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?: { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 59718cdeb5a..c14f132e571 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -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([], [{ diff --git a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts index 75876de744e..f8ca63cde6e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts @@ -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(); 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, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts index e6ca6b4128a..dd5a47bff25 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts @@ -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 }); }, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index dbeaad87a44..fd6907770a6 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -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; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 09c4eae57f7..72a299b2a9d 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -322,13 +322,15 @@ export interface ILanguageModelChatMetadata { */ readonly warningText?: IStringDictionary; /** - * 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 } { + 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. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index f87a2d2a7c8..e6116726738 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -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 () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts index 36b869fa175..036f1709936 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts @@ -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([{ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts index 3218f2be697..e2c13a9b66c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerHover.test.ts @@ -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 .'); + }); + + assert.deepStrictEqual(results, [ + 'Limited time offer Ends .', + 'Limited time offer', + 'Limited time offer', + ]); + }); }); diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 653dec5cb33..ab81f4d4d9c 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -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; }; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 1003937f263..844de8f23d9 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -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;