diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index ca098af5d3e6d..709d0841732d1 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -701,6 +701,8 @@ export interface IAgentModelInfo { readonly id: string; readonly name: string; readonly maxContextWindow?: number; + readonly maxOutputTokens?: number; + readonly maxPromptTokens?: number; readonly supportsVision: boolean; readonly configSchema?: ConfigSchema; readonly policyState?: PolicyState; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index e9d897bde9ba3..e427455085531 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { ConfigSchema, ProtectedResourceMetadata } from '../common/state.js'; +import type { ConfigSchema, JsonPrimitive, ProtectedResourceMetadata } from '../common/state.js'; import type { TerminalInfo } from '../channels-terminal/state.js'; import type { Customization } from '../channels-session/state.js'; @@ -97,6 +97,10 @@ export interface SessionModelInfo { name: string; /** Maximum context window size */ maxContextWindow?: number; + /** Maximum number of output tokens the model can generate */ + maxOutputTokens?: number; + /** Maximum number of prompt (input) tokens the model accepts */ + maxPromptTokens?: number; /** Whether the model supports vision */ supportsVision?: boolean; /** Policy configuration state */ @@ -126,8 +130,12 @@ export interface SessionModelInfo { export interface ModelSelection { /** Model identifier */ id: string; - /** Model-specific configuration values */ - config?: Record; + /** + * Model-specific configuration values. Values are JSON primitives: most + * pickers produce strings, but some (e.g. a numeric context-size picker) + * produce numbers or booleans, which are carried through as-is. + */ + config?: Record; } // ─── Root Config Types ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index d71968904d5ee..32500adb19665 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -27,6 +27,9 @@ export type URI = string; */ export type StringOrMarkdown = string | { markdown: string }; +/** A primitive JSON value: a string, number, boolean, or `null`. */ +export type JsonPrimitive = string | number | boolean | null; + // ─── Icon ──────────────────────────────────────────────────────────────────── /** @@ -161,8 +164,8 @@ export interface ConfigPropertySchema { description?: string; /** JSON Schema: default value */ default?: unknown; - /** JSON Schema: allowed values (typically used with `string` type) */ - enum?: string[]; + /** JSON Schema: allowed values. May be primitives of any JSON type. */ + enum?: JsonPrimitive[]; /** Display extension: human-readable label per enum value (parallel array) */ enumLabels?: string[]; /** Display extension: description per enum value (parallel array) */ diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index b2d2b66586eaa..8fdc3b425ac98 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -177,6 +177,8 @@ export class AgentSideEffects extends Disposable { provider: m.provider, name: m.name, maxContextWindow: m.maxContextWindow, + maxOutputTokens: m.maxOutputTokens, + maxPromptTokens: m.maxPromptTokens, supportsVision: m.supportsVision, policyState: m.policyState, configSchema: m.configSchema, diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index af965701da13a..ea2cd19f99321 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -107,6 +107,8 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo id: m.id, name: m.name, maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!supports?.vision, ...(configSchema ? { configSchema } : {}), ...(policyState ? { policyState } : {}), diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index bfbe99c606397..6b62c179afb36 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -782,6 +782,8 @@ export class CodexAgent extends Disposable implements IAgent { id: m.id, name: m.name ?? m.id, maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!m.capabilities?.supports?.vision, configSchema, policyState: m.policy?.state as PolicyState | undefined, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 3f88c001a92ae..3480b158447a7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -53,7 +53,7 @@ import { ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextTierConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; @@ -809,14 +809,19 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Synthesize a `contextTier` config property when the model exposes a `long_context` pricing tier with a distinct + * Synthesize a `contextSize` config property when the model exposes a `long_context` pricing tier with a distinct * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. * + * The `enum` values are the two context-window sizes (in tokens), smallest first, so the numeric token counts + * flow to the client. The chosen value comes back in the model's `config` bag and is mapped to the SDK's + * two-valued `contextTier` at the SDK boundary by {@link getCopilotContextTier}, using the model's long-context + * window from {@link _longContextWindowFor}. + * * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK * `ModelBilling` type — narrow through {@link ICAPIModelBilling} until the SDK catches up. */ - private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { + private _createContextSizeConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; @@ -828,21 +833,37 @@ export class CopilotAgent extends Disposable implements IAgent { || typeof tokenPrices?.longContext?.outputPrice === 'number'; return { - type: 'string', - title: localize('copilot.modelContextTier.title', "Context Size"), - description: localize('copilot.modelContextTier.description', "Selects the context window size for this model."), - default: 'default', - enum: ['default', 'long_context'], + type: 'number', + title: localize('copilot.modelContextSize.title', "Context Size"), + description: localize('copilot.modelContextSize.description', "Selects the context window size for this model."), + default: defaultMax, + enum: [defaultMax, longContextMax], enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)], enumDescriptions: [ - localize('copilot.modelContextTier.default', "Default"), + localize('copilot.modelContextSize.default', "Default"), hasLongContextSurcharge - ? localize('copilot.modelContextTier.longerSessions', "Longer sessions") - : localize('copilot.modelContextTier.longerSessionsNoCompaction', "Longer sessions without compaction"), + ? localize('copilot.modelContextSize.longerSessions', "Longer sessions") + : localize('copilot.modelContextSize.longerSessionsNoCompaction', "Longer sessions without compaction"), ], }; } + /** + * The model's long-context window (in tokens): the largest size offered by its "Context Size" picker + * (the max numeric value in the synthesized `contextSize` {@link ConfigPropertySchema.enum}). Used by + * {@link getCopilotContextTier} to decide whether a numeric selection opts into `long_context`. + * Returns `undefined` when the model exposes no such picker (or the model list isn't loaded yet), + * leaving the SDK on its default tier. + */ + private _longContextWindowFor(modelId: string | undefined): number | undefined { + if (!modelId) { + return undefined; + } + const windows = this._models.get().find(m => m.id === modelId)?.configSchema?.properties?.[ContextSizeConfigKey]?.enum; + const numericWindows = windows?.filter((w): w is number => typeof w === 'number'); + return numericWindows && numericWindows.length > 0 ? Math.max(...numericWindows) : undefined; + } + /** * Builds the open `_meta` pricing bag for a model from its billing info so the chat model picker can render its * cost hover. Delegates to the shared {@link createPricingMetaFromBilling} helper. @@ -859,9 +880,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (thinkingLevel) { properties[ThinkingLevelConfigKey] = thinkingLevel; } - const contextTier = this._createContextTierConfigSchemaProperty(m.billing); - if (contextTier) { - properties[ContextTierConfigKey] = contextTier; + const contextSize = this._createContextSizeConfigSchemaProperty(m.billing); + if (contextSize) { + properties[ContextSizeConfigKey] = contextSize; } if (Object.keys(properties).length === 0) { return undefined; @@ -1023,6 +1044,8 @@ export class CopilotAgent extends Disposable implements IAgent { // Synthetic SDK entries like `auto` ship with `capabilities: {}` and // no fixed context window — surface them with maxContextWindow undefined. maxContextWindow: m.capabilities?.limits?.max_context_window_tokens, + maxOutputTokens: m.capabilities?.limits?.max_output_tokens, + maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens, supportsVision: !!m.capabilities?.supports?.vision, configSchema: this._createModelConfigSchema(m), policyState: m.policy?.state as PolicyState | undefined, @@ -1252,6 +1275,7 @@ export class CopilotAgent extends Disposable implements IAgent { shellManager, githubToken: this._githubToken, model: provisional.model, + longContextWindow: this._longContextWindowFor(provisional.model?.id), }; agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); await agentSession.initializeSession(); @@ -1719,6 +1743,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._chatSessions.has(chatKey)) { return; } + const model = options?.model; // Resolve the owning session so the new chat inherits its working // directory scope. The parent may be provisional (no SDK session // yet); in that case use its provisional working directory. @@ -1762,7 +1787,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model: options.model }, + fallback: { model, longContextWindow: this._longContextWindowFor(model?.id) }, }; } else { sdkSessionId = chatSdkId; @@ -1776,7 +1801,8 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - model: options?.model, + model, + longContextWindow: this._longContextWindowFor(model?.id), }; } let agentSession: CopilotAgentSession | undefined; @@ -1790,7 +1816,7 @@ export class CopilotAgent extends Disposable implements IAgent { const parsed = parseChatUri(chat); if (parsed) { const persisted = await this._readPersistedChats(session); - persisted.set(parsed.chatId, { sdkSessionId, ...(options?.model ? { model: options.model } : {}) }); + persisted.set(parsed.chatId, { sdkSessionId, ...(model ? { model } : {}) }); await this._writePersistedChats(session, persisted); } this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`); @@ -1941,7 +1967,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - fallback: { model: info.model }, + fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id) }, }; let agentSession: CopilotAgentSession | undefined; try { @@ -1992,12 +2018,13 @@ export class CopilotAgent extends Disposable implements IAgent { } async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise { + const longContextWindow = this._longContextWindowFor(model.id); // Additional (non-default) chats are backed by their own SDK // conversation tracked in `_chatSessions`; apply the change there and // skip the session-level metadata store (peer chats are not persisted // per-chat). if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow)); return; } const sessionId = AgentSession.id(session); @@ -2008,7 +2035,7 @@ export class CopilotAgent extends Disposable implements IAgent { } const entry = this._sessions.get(sessionId); if (entry) { - await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model)); + await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow)); } await this._storeSessionMetadata(session, model, undefined, undefined, undefined); } @@ -2264,6 +2291,7 @@ export class CopilotAgent extends Disposable implements IAgent { githubToken: this._githubToken, fallback: { model: storedMetadata.model, + longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), }, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 51d53d2dc6ad3..dea8682fbb1c9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -27,6 +27,16 @@ import { describeSystemMessageConfig } from './prompts/systemMessage.js'; import './prompts/allPrompts.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; +/** + * Config key for the numeric "Context Size" selection (a context-window token count). Mapped to the + * SDK's two-valued {@link SessionConfig.contextTier} by {@link getCopilotContextTier}. + */ +export const ContextSizeConfigKey = 'contextSize'; +/** + * @deprecated Legacy config key that stored the resolved tier string (`'default'` / `'long_context'`) + * directly. Replaced by the numeric {@link ContextSizeConfigKey}; still read from persisted sessions + * for backward compatibility. + */ export const ContextTierConfigKey = 'contextTier'; const ReasoningEfforts = ['low', 'medium', 'high', 'xhigh'] as const; @@ -120,6 +130,7 @@ interface ICopilotSessionLaunchBase { export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { readonly kind: 'create'; readonly model: ModelSelection | undefined; + readonly longContextWindow?: number; } export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -127,16 +138,17 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa readonly workingDirectory: URI; readonly fallback: { readonly model: ModelSelection | undefined; + readonly longContextWindow?: number; }; } export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; -function isReasoningEffort(value: string | undefined): value is ReasoningEffort { +function isReasoningEffort(value: unknown): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } -function isContextTier(value: string | undefined): value is ContextTier { +function isContextTier(value: unknown): value is ContextTier { return ContextTiers.some(contextTier => contextTier === value); } @@ -189,9 +201,26 @@ export function getCopilotReasoningEffort(model: ModelSelection | undefined): Se return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } -export function getCopilotContextTier(model: ModelSelection | undefined): SessionConfig['contextTier'] { - const contextTier = model?.config?.[ContextTierConfigKey]; - return isContextTier(contextTier) ? contextTier : undefined; +export function getCopilotContextTier(model: ModelSelection | undefined, longContextWindow?: number): SessionConfig['contextTier'] { + // Legacy persisted selections stored the resolved tier string directly under the deprecated key. + const legacyTier = model?.config?.[ContextTierConfigKey]; + if (isContextTier(legacyTier)) { + return legacyTier; + } + // The "Context Size" picker exposes numeric token-count enum values, so a current selection arrives + // under `contextSize` as a token count. Map it to the SDK's two-valued tier using the model's + // long-context window: only a selection that reaches that window opts into `long_context`. Without + // the window (model exposes no picker, or the model list isn't loaded) leave the SDK on its default + // tier. + const contextSize = model?.config?.[ContextSizeConfigKey]; + if (contextSize === undefined) { + return undefined; + } + const selectedWindow = Number(contextSize); + if (!Number.isFinite(selectedWindow) || typeof longContextWindow !== 'number') { + return undefined; + } + return selectedWindow >= longContextWindow ? 'long_context' : 'default'; } export class CopilotSessionLauncher implements ICopilotSessionLauncher { @@ -236,6 +265,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...plan, kind: 'create', model: plan.fallback.model, + longContextWindow: plan.fallback.longContextWindow, }, config, sandboxConfig); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -249,7 +279,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { streaming: true, model: plan.model?.id, reasoningEffort: getCopilotReasoningEffort(plan.model), - contextTier: getCopilotContextTier(plan.model), + contextTier: getCopilotContextTier(plan.model, plan.longContextWindow), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index b0488ac0f74d1..64baeac299822 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -707,7 +707,7 @@ suite('AgentSideEffects', () => { } return e.action.agents[0]?.models.length === 1; })); - agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, supportsVision: false }]); + agent.setModels([{ provider: 'mock', id: 'mock-model', name: 'mock Model', maxContextWindow: 128000, maxOutputTokens: 16000, maxPromptTokens: 112000, supportsVision: false }]); await envelope; const actions = envelopes.map(e => e.action).filter(action => action.type === ActionType.RootAgentsChanged); @@ -718,6 +718,8 @@ suite('AgentSideEffects', () => { provider: 'mock', name: 'mock Model', maxContextWindow: 128000, + maxOutputTokens: 16000, + maxPromptTokens: 112000, supportsVision: false, policyState: undefined, configSchema: undefined, diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index ff669c2f97db4..913c49a2aa522 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -786,8 +786,8 @@ suite('ClaudeAgent', () => { accepted: true, startCalls: ['tok'], models: [ - { provider: 'claude', id: 'claude-opus-4.6', name: 'Claude Opus 4.6', maxContextWindow: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, - { provider: 'claude', id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', maxContextWindow: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, + { provider: 'claude', id: 'claude-opus-4.6', name: 'Claude Opus 4.6', maxContextWindow: 200_000, maxOutputTokens: 8192, maxPromptTokens: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, + { provider: 'claude', id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6', maxContextWindow: 200_000, maxOutputTokens: 8192, maxPromptTokens: 200_000, supportsVision: false, policyState: 'enabled', _meta: { multiplierNumeric: 1 } }, ], }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index b628237e98e8e..75b4843cdae9f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -33,7 +33,7 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; -import { CustomizationType, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { CustomizationType, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -194,7 +194,7 @@ interface ITestCopilotModelInfo { readonly name: string; readonly capabilities?: { readonly supports?: { readonly vision?: boolean }; - readonly limits?: { readonly max_context_window_tokens?: number }; + readonly limits?: { readonly max_context_window_tokens?: number; readonly max_output_tokens?: number; readonly max_prompt_tokens?: number }; }; readonly policy?: { readonly state?: NonNullable['state'] }; readonly billing?: ModelInfo['billing'] & { @@ -231,7 +231,12 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { }, limits: { max_context_window_tokens: model.capabilities?.limits?.max_context_window_tokens ?? 0, - }, + // `max_output_tokens` is present on the RPC `models.list` shape the + // agent reads but absent from the SDK's `ModelInfo` limits type, so + // widen here to let fixtures exercise the real value. + max_output_tokens: model.capabilities?.limits?.max_output_tokens, + max_prompt_tokens: model.capabilities?.limits?.max_prompt_tokens, + } as ModelInfo['capabilities']['limits'], }, ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), ...(model.billing ? { billing: model.billing } : {}), @@ -956,7 +961,7 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', billing: { multiplier: 1.5 }, - capabilities: { limits: { max_context_window_tokens: 128000 }, supports: { vision: true } }, + capabilities: { limits: { max_context_window_tokens: 128000, max_output_tokens: 16000, max_prompt_tokens: 112000 }, supports: { vision: true } }, }]), }); try { @@ -968,6 +973,8 @@ suite('CopilotAgent', () => { id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, + maxOutputTokens: 16000, + maxPromptTokens: 112000, supportsVision: true, configSchema: undefined, policyState: undefined, @@ -1032,13 +1039,13 @@ suite('CopilotAgent', () => { const schema = models[0].configSchema; assert.deepStrictEqual(schema?.properties.thinkingLevel?.enum, ['low', 'medium', 'high']); assert.strictEqual(schema?.properties.thinkingLevel?.default, 'medium'); - assert.strictEqual(schema?.properties.contextTier, undefined); + assert.strictEqual(schema?.properties.contextSize, undefined); } finally { await disposeAgent(agent); } }); - test('configSchema emits a contextTier property when long_context tier exceeds default', async () => { + test('configSchema emits a numeric contextSize property when long_context tier exceeds default', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', @@ -1057,16 +1064,17 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); - const contextTier = models[0].configSchema?.properties.contextTier; - assert.deepStrictEqual(contextTier?.enum, ['default', 'long_context']); - assert.strictEqual(contextTier?.default, 'default'); - assert.deepStrictEqual(contextTier?.enumLabels, ['200K', '1M']); + const contextSize = models[0].configSchema?.properties.contextSize; + assert.strictEqual(contextSize?.type, 'number'); + assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); + assert.strictEqual(contextSize?.default, 200_000); + assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); } finally { await disposeAgent(agent); } }); - test('configSchema omits contextTier when long_context tier is missing or not larger', async () => { + test('configSchema omits contextSize when long_context tier is missing or not larger', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [ { @@ -1095,6 +1103,72 @@ suite('CopilotAgent', () => { } }); + suite('contextSize to contextTier mapping', () => { + const longContextModel: ITestCopilotModelInfo = { + id: 'claude-sonnet', + name: 'Claude Sonnet', + capabilities: { limits: { max_context_window_tokens: 200_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000, inputPrice: 2 }, + }, + }, + }; + + async function captureSessionConfig(model: ModelSelection | undefined, models: readonly ITestCopilotModelInfo[]): Promise { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([], models); + let capturedConfig: CopilotCreateSessionOptions | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await waitForState(agent.models, m => m.length > 0); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'ctx-session'), + workingDirectory: URI.file('/workspace'), + ...(model ? { model } : {}), + }); + await agent.sendMessage(result.session, 'hello'); + return capturedConfig; + } finally { + await disposeAgent(agent); + } + } + + test('maps the largest numeric context size to long_context', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '1000000' } }, [longContextModel]); + assert.ok(config, 'SDK createSession should be called during materialization'); + assert.strictEqual(config.contextTier, 'long_context'); + }); + + test('maps the default numeric context size to default', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextSize: '200000' } }, [longContextModel]); + assert.ok(config); + assert.strictEqual(config.contextTier, 'default'); + }); + + test('drops a numeric context size the model does not offer', async () => { + const config = await captureSessionConfig( + { id: 'no-context-picker', config: { contextSize: '1000000' } }, + [{ id: 'no-context-picker', name: 'No Picker' }], + ); + assert.ok(config); + assert.strictEqual(config.contextTier, undefined); + }); + + test('passes through a legacy resolved tier string under the deprecated contextTier key', async () => { + const config = await captureSessionConfig({ id: 'claude-sonnet', config: { contextTier: 'long_context' } }, [longContextModel]); + assert.ok(config); + assert.strictEqual(config.contextTier, 'long_context'); + }); + }); + test('agent-created sessions can resolve session-state paths via INativeEnvironmentService', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const { agent, instantiationService } = createTestAgentContext(disposables, { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index bd9d0073ae692..ed80227052433 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -161,7 +161,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { if (!schema || !this._isWellKnownSchema(schema)) { return undefined; } - const enumValues = schema.enum ?? []; + const enumValues = (schema.enum ?? []).map(value => String(value)); const enumLabels = schema.enumLabels ?? []; const enumDescriptions = schema.enumDescriptions ?? []; const items: IAgentHostSessionEnumPickerItem[] = enumValues.map((value, index) => ({ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts index 2f5b97d672cfe..79a1a05c07256 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostPermissionPickerDelegate.ts @@ -38,7 +38,7 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema if (!schema.enum.includes(REQUIRED_AUTO_APPROVE_VALUE)) { return false; } - return schema.enum.every(value => KNOWN_AUTO_APPROVE_VALUES.has(value)); + return schema.enum.every(value => typeof value === 'string' && KNOWN_AUTO_APPROVE_VALUES.has(value)); } /** diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index a615ad71d34ec..a2857f7b19a3e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -481,8 +481,8 @@ export class AgentHostSessionConfigPicker extends Disposable { } return (schema.enum ?? []).map((value, index) => ({ - value, - label: schema.enumLabels?.[index] ?? value, + value: String(value), + label: schema.enumLabels?.[index] ?? String(value), description: schema.enumDescriptions?.[index], })); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 106b00ea24c07..f0170d5462612 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -202,8 +202,8 @@ class MobileChatInputConfigPicker extends Disposable { const modeSchema = config?.schema.properties[SessionConfigKey.Mode]; const modeItems = (modeSchema && isWellKnownModeSchema(modeSchema)) ? (modeSchema.enum ?? []).map((value, index) => ({ - value, - label: modeSchema.enumLabels?.[index] ?? value, + value: String(value), + label: modeSchema.enumLabels?.[index] ?? String(value), description: modeSchema.enumDescriptions?.[index], })) : []; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts index 703a81a9366c2..7ee4331bf1f18 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts @@ -124,8 +124,8 @@ class MobileChatPhoneInputPresenter extends Disposable implements IChatPhonePres const modeSchema = config?.schema.properties[SessionConfigKey.Mode]; const modeItems = (modeSchema && isWellKnownModeSchema(modeSchema)) ? (modeSchema.enum ?? []).map((value, index) => ({ - value, - label: modeSchema.enumLabels?.[index] ?? value, + value: String(value), + label: modeSchema.enumLabels?.[index] ?? String(value), description: modeSchema.enumDescriptions?.[index], })) : []; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 075020321bab4..8cd8831fc651b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -201,7 +201,7 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema if (!schema.enum.includes('default')) { return false; } - return schema.enum.every(value => KNOWN_AUTO_APPROVE_VALUES.has(value)); + return schema.enum.every(value => typeof value === 'string' && KNOWN_AUTO_APPROVE_VALUES.has(value)); } /** @@ -617,8 +617,8 @@ export class AgentHostChatInputPicker extends Disposable { } } return (schema.enum ?? []).map((value, index) => ({ - value, - label: schema.enumLabels?.[index] ?? value, + value: String(value), + label: schema.enumLabels?.[index] ?? String(value), description: schema.enumDescriptions?.[index], })); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 664222d98b3a7..554744369b391 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -86,8 +86,8 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu family: m.id, ...(tooltip !== undefined && { tooltip }), ...(detail !== undefined && { detail }), - maxInputTokens: m.maxContextWindow ?? 0, - maxOutputTokens: 0, + maxInputTokens: m.maxPromptTokens ?? 0, + maxOutputTokens: m.maxOutputTokens ?? 0, isDefaultForLocation: {}, isUserSelectable: true, pricing: multiplierNumeric !== undefined ? `${multiplierNumeric}x` : undefined, @@ -138,7 +138,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu private static _groupForConfigKey(key: string): string | undefined { switch (key) { case 'thinkingLevel': return 'navigation'; - case 'contextTier': return 'tokens'; + case 'contextSize': return 'tokens'; default: return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index a4401af3877fc..32f17a0ee3227 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -28,7 +28,7 @@ import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../.. import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ChatTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, JsonPrimitive, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ChatAction, type ClientChatAction, type ClientSessionAction, type ChatInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, buildChatUri, buildDefaultChatUri, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -3245,9 +3245,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } - const config: Record = {}; + // Forward model-specific config values as-is. Most pickers produce strings, + // but a synthesized numeric picker (e.g. the context-size picker, whose enum + // values are token counts) hands back a number; the protocol `config` bag + // carries JSON primitives, so the selection survives into it (and is mapped + // to the SDK context tier by the agent's `getCopilotContextTier`). + const config: Record = {}; for (const [key, value] of Object.entries(modelConfiguration ?? {})) { - if (typeof value === 'string') { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) { config[key] = value; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 7e4d01efe4669..03474d35088a0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -845,6 +845,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(autorun(reader => { const lm = this._currentLanguageModel.read(reader); this.chatModelIdKey.set(lm?.metadata.id.toLowerCase() ?? ''); + this.contextUsageWidget?.setSelectedModel(lm?.identifier); if (lm?.metadata.name) { this.accessibilityService.alert(lm.metadata.name); } @@ -2863,6 +2864,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Context usage widget — will be positioned in the toolbar after toolbars are created this.contextUsageWidget = this._register(this.instantiationService.createInstance(ChatContextUsageWidget)); this.contextUsageWidget.setChatWidget(widget); + this.contextUsageWidget.setSelectedModel(this._currentLanguageModel.get()?.identifier); this.contextUsageWidget.setModelConfigurationResolver( modelId => this.getModelConfiguration(modelId), this._modelConfigStore.onDidChange, diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index c8fa46a5e79be..94a313ed57072 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -103,6 +103,13 @@ export class ChatContextUsageWidget extends Disposable { private readonly _modelConfigurationListener = this._register(new MutableDisposable()); private _currentResponse: IChatResponseModel | undefined; private _currentModelId: string | undefined; + /** + * The model the user currently has selected in the picker. When set it + * overrides the last request's model for computing the context-window + * denominator, so switching models updates the widget before the next + * request is sent. The usage numerator still comes from the last response. + */ + private _selectedModelId: string | undefined; private _sessionCost: number = 0; private readonly _hoverDisposable = this._register(new MutableDisposable()); private readonly _contextUsageDetails = this._register(new MutableDisposable()); @@ -299,20 +306,42 @@ export class ChatContextUsageWidget extends Disposable { ): void { this._modelConfigurationResolver = resolver; this._modelConfigurationListener.value = onDidChange(modelId => { - if (this._currentResponse && this._currentModelId === modelId) { - this.updateFromResponse(this._currentResponse, modelId); + const affectsDisplayedModel = this._currentModelId === modelId || this._selectedModelId === modelId; + if (this._currentResponse && this._currentModelId && affectsDisplayedModel) { + this.updateFromResponse(this._currentResponse, this._currentModelId); } }); } + /** + * Sets the model the user currently has selected in the picker. The + * context-window denominator then reflects this model immediately, even + * before a request is sent with it. The usage numerator still comes from the + * last completed response. + */ + setSelectedModel(modelId: string | undefined): void { + if (this._selectedModelId === modelId) { + return; + } + this._selectedModelId = modelId; + if (this._currentResponse && this._currentModelId) { + this.updateFromResponse(this._currentResponse, this._currentModelId); + } + } + private updateFromResponse(response: IChatResponseModel, modelId: string): void { const usage = response.usage; // When a meta-model (e.g. "auto") routes to a concrete model, the // usage reports the actual model that served the request. const effectiveModelId = usage?.actualModelId ?? modelId; - const modelMetadata = this.languageModelsService.lookupLanguageModel(effectiveModelId); - const modelConfiguration = this._modelConfigurationResolver?.(effectiveModelId) ?? this.languageModelsService.getModelConfiguration(effectiveModelId); + + // The denominator (context window) follows the currently selected model so + // switching models updates the widget immediately; the numerator (usage) + // still comes from the last response. + const denominatorModelId = this._selectedModelId ?? effectiveModelId; + const modelMetadata = this.languageModelsService.lookupLanguageModel(denominatorModelId); + const modelConfiguration = this._modelConfigurationResolver?.(denominatorModelId) ?? this.languageModelsService.getModelConfiguration(denominatorModelId); const configuredContextSize = typeof modelConfiguration?.contextSize === 'number' ? modelConfiguration.contextSize : undefined; const maxInputTokens = configuredContextSize ?? modelMetadata?.maxInputTokens; const maxOutputTokens = modelMetadata?.maxOutputTokens; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 37b3537ef0610..1e9fe8d71a398 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -2067,13 +2067,13 @@ suite('AgentHostChatContribution', () => { const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi', userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', - modelConfiguration: { thinkingLevel: 'high', ignored: 1 }, + modelConfiguration: { thinkingLevel: 'high', contextSize: 272000 }, }); fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); - assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high' } }); + assert.deepStrictEqual(agentHostService.createSessionCalls[0].model, { id: 'claude-sonnet-4-20250514', config: { thinkingLevel: 'high', contextSize: 272000 } }); })); test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -4129,7 +4129,7 @@ suite('AgentHostChatContribution', () => { test('maps models with correct metadata', async () => { const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); provider.updateModels([ - { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: true, _meta: { multiplierNumeric: 1.5 } }, + { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, maxPromptTokens: 128000, supportsVision: true, _meta: { multiplierNumeric: 1.5 } }, ]); const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None);