Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/vs/platform/agentHost/common/agentModelConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ export function createContextSizeConfigSchemaProperty(billing: ICAPIModelBilling
const tokenPrices = billing?.tokenPrices;
const defaultMax = tokenPrices?.contextMax;
const longContextMax = tokenPrices?.longContext?.contextMax;
return createContextSizeConfigSchemaPropertyFromLimits(
defaultMax,
longContextMax,
hasLongContextSurcharge(billing) ? defaultMax : longContextMax,
);
}

/**
* Synthesizes the shared context-size picker property from provider-owned limits.
*/
export function createContextSizeConfigSchemaPropertyFromLimits(defaultMax: number | undefined, longContextMax: number | undefined, selectedDefault = defaultMax): ConfigPropertySchema | undefined {
if (!defaultMax || !longContextMax || defaultMax >= longContextMax) {
return undefined;
}
Expand All @@ -27,7 +38,7 @@ export function createContextSizeConfigSchemaProperty(billing: ICAPIModelBilling
type: 'number',
title: localize('copilot.modelContextSize.title', "Context Size"),
description: localize('copilot.modelContextSize.description', "Selects the context window size for this model."),
default: hasLongContextSurcharge(billing) ? defaultMax : longContextMax,
default: selectedDefault === longContextMax ? longContextMax : defaultMax,
enum: [defaultMax, longContextMax],
enumLabels: [formatTokenCount(defaultMax), formatTokenCount(longContextMax)],
enumDescriptions: [
Expand Down
129 changes: 114 additions & 15 deletions src/vs/platform/agentHost/node/codex/codexAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import type { CCAModel } from '@vscode/copilot-api';
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import { CancellationError } from '../../../../base/common/errors.js';
Expand All @@ -25,7 +25,7 @@ import { ILogService } from '../../../log/common/log.js';
import { IProductService } from '../../../product/common/productService.js';
import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js';
import { createPricingMetaFromBilling, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js';
import { ContextSizeConfigKey, createContextSizeConfigSchemaProperty, getModelContextSize } from '../../common/agentModelConfiguration.js';
import { ContextSizeConfigKey, createContextSizeConfigSchemaProperty, createContextSizeConfigSchemaPropertyFromLimits, getModelContextSize } from '../../common/agentModelConfiguration.js';
import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID, createAgentModelGroupMeta, createAgentModelSourceMeta } from '../../common/agentModelSource.js';
import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js';
import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js';
Expand Down Expand Up @@ -244,6 +244,80 @@ const CODEX_COPILOT_MODEL_PROVIDER = 'vscode-proxy';
const CODEX_COPILOT_MODEL_GROUP = 'copilot';
const CODEX_OPENAI_MODEL_PROVIDER = 'openai';
const CODEX_MODEL_SELECTION_PREFIX = '@provider=';
const CODEX_MODEL_CATALOG_TIMEOUT_MS = 15_000;
const CODEX_MODEL_CATALOG_MAX_BUFFER = 8 * 1024 * 1024;

interface ICodexModelContextWindow {
readonly defaultSize: number;
readonly maxSize: number;
}

interface ICodexRawModelCatalog {
readonly models?: readonly {
readonly slug?: string;
readonly context_window?: number;
readonly max_context_window?: number;
}[];
}

/** Retains only global configuration flags that another Codex subcommand can consume. */
function codexConfigArgs(args: readonly string[]): string[] {
const result: string[] = [];
for (let index = 0; index < args.length; index++) {
const argument = args[index];
if (argument === '-c' || argument === '--config' || argument === '--enable' || argument === '--disable') {
const value = args[index + 1];
if (value !== undefined) {
result.push(argument, value);
index++;
}
} else if (argument.startsWith('--config=') || argument.startsWith('--enable=') || argument.startsWith('--disable=')) {
result.push(argument);
}
}
return result;
}

/**
* Reads context limits from Codex's JSON view of its raw model catalog.
*
* App-server `model/list` remains authoritative for the models this account can
* select, but its public response currently omits `context_window` and
* `max_context_window`. `debug models` exposes those fields from the same Codex
* catalog. It refreshes online only when needed; calling it after
* `model/list` normally reuses the catalog the live app-server just refreshed.
*/
function readCodexModelContextWindows(binaryPath: string, args: readonly string[], env: NodeJS.ProcessEnv): Promise<ReadonlyMap<string, ICodexModelContextWindow>> {
return new Promise((resolvePromise, rejectPromise) => {
execFile(binaryPath, ['debug', 'models', ...codexConfigArgs(args)], {
env,
encoding: 'utf8',
maxBuffer: CODEX_MODEL_CATALOG_MAX_BUFFER,
timeout: CODEX_MODEL_CATALOG_TIMEOUT_MS,
windowsHide: true,
}, (error, stdout) => {
if (error) {
rejectPromise(error);
return;
}
try {
const catalog = JSON.parse(stdout) as ICodexRawModelCatalog;
const result = new Map<string, ICodexModelContextWindow>();
for (const model of catalog.models ?? []) {
if (typeof model.slug !== 'string'
|| typeof model.context_window !== 'number' || !Number.isSafeInteger(model.context_window) || model.context_window <= 0
|| typeof model.max_context_window !== 'number' || !Number.isSafeInteger(model.max_context_window) || model.max_context_window <= 0) {
continue;
}
result.set(model.slug, { defaultSize: model.context_window, maxSize: model.max_context_window });
}
resolvePromise(result);
} catch (error) {
rejectPromise(error);
}
});
});
}

/**
* The Codex harness relies on OpenAI Responses semantics beyond the endpoint
Expand Down Expand Up @@ -796,6 +870,8 @@ interface IConnectionReady {
readonly client: ICodexAppServerClient;
readonly proxyHandle: ICodexProxyHandle;
readonly child: ChildProcessWithoutNullStreams;
/** Reads context limits from the same Codex SDK and configuration as this app-server. */
readonly readModelContextWindows?: () => Promise<ReadonlyMap<string, ICodexModelContextWindow>>;
/** Event/request registrations owned by this particular persistent client. */
readonly subscriptions?: DisposableStore;
}
Expand Down Expand Up @@ -1713,6 +1789,7 @@ export class CodexAgent extends Disposable implements IAgent {
declaredDefault?: string,
modelId?: string,
billing?: ICAPIModelBilling,
contextWindow?: ICodexModelContextWindow,
): ConfigSchema | undefined {
const properties: ConfigSchema['properties'] = {};
if (supportedEfforts?.length) {
Expand All @@ -1727,7 +1804,9 @@ export class CodexAgent extends Disposable implements IAgent {
enumDescriptions: supportedEfforts.map(option => option.description || getReasoningEffortDescription(option.reasoningEffort) || ''),
};
}
const contextSize = createContextSizeConfigSchemaProperty(billing);
const contextSize = contextWindow
? createContextSizeConfigSchemaPropertyFromLimits(contextWindow.defaultSize, contextWindow.maxSize)
: createContextSizeConfigSchemaProperty(billing);
if (contextSize) {
properties[ContextSizeConfigKey] = contextSize;
}
Expand Down Expand Up @@ -2118,19 +2197,34 @@ export class CodexAgent extends Disposable implements IAgent {
data.push(...response.data);
cursor = response.nextCursor;
} while (cursor !== null);
let contextWindows: ReadonlyMap<string, ICodexModelContextWindow> | undefined;
if (usesChatGPTSubscription && connection.readModelContextWindows) {
try {
contextWindows = await connection.readModelContextWindows();
if (!this._isCurrentConnection(connection)) {
return;
}
} catch (error) {
this._logService.warn(`[Codex] Failed to read ChatGPT model context limits: ${error instanceof Error ? error.message : String(error)}`);
}
}
const models = data
.sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
.map((model): IAgentModelInfo => ({
provider: CODEX_AGENT_PROVIDER_ID,
id: toCodexModelSelectionId(modelProvider, model.model),
name: model.displayName,
supportsVision: model.inputModalities.includes('image'),
configSchema: this._createModelConfigSchema(model.supportedReasoningEfforts, model.defaultReasoningEffort, model.model),
_meta: {
...createAgentModelSourceMeta(usesChatGPTSubscription ? CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID : undefined),
...createAgentModelGroupMeta(pickerProvider),
},
}));
.map((model): IAgentModelInfo => {
const contextWindow = contextWindows?.get(model.model);
return {
provider: CODEX_AGENT_PROVIDER_ID,
id: toCodexModelSelectionId(modelProvider, model.model),
name: model.displayName,
maxContextWindow: contextWindow?.maxSize,
supportsVision: model.inputModalities.includes('image'),
configSchema: this._createModelConfigSchema(model.supportedReasoningEfforts, model.defaultReasoningEffort, model.model, undefined, contextWindow),
_meta: {
...createAgentModelSourceMeta(usesChatGPTSubscription ? CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID : undefined),
...createAgentModelGroupMeta(pickerProvider),
},
};
});
if (this._isCurrentConnection(connection)) {
this._codexModels = models;
}
Expand Down Expand Up @@ -2455,7 +2549,12 @@ export class CodexAgent extends Disposable implements IAgent {
throw new CancellationError();
}
client.notify<'initialized'>('initialized', undefined as never);
return { client, proxyHandle, child };
return {
client,
proxyHandle,
child,
readModelContextWindows: () => readCodexModelContextWindows(binaryPath, args, env),
};
} catch (err) {
client?.dispose();
proxyHandle.dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,32 @@ suite('CodexAgent model refresh', () => {
}]);
});

test('publishes context size options for ChatGPT subscription models without Copilot models', async () => {
const agent = createAgent(disposables, async () => []);
agent['_connection'] = {
...createChatGPTConnection(),
readModelContextWindows: async () => new Map([['gpt-5.6-sol', { defaultSize: 272_000, maxSize: 872_000 }]]),
} as never;

await agent.refreshModels();

assert.deepStrictEqual(agent.models.get().map(model => ({
id: model.id,
maxContextWindow: model.maxContextWindow,
contextSize: model.configSchema?.properties.contextSize && {
enum: model.configSchema.properties.contextSize.enum,
default: model.configSchema.properties.contextSize.default,
},
})), [{
id: toCodexModelSelectionId('openai', 'gpt-5.6-sol'),
maxContextWindow: 872_000,
contextSize: {
enum: [272_000, 872_000],
default: 272_000,
},
}]);
});

test('omits the thinking level when a Codex model advertises no reasoning efforts', async () => {
const agent = createAgent(disposables, async () => []);
agent['_connection'] = {
Expand Down
140 changes: 84 additions & 56 deletions src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1924,68 +1924,96 @@ suite('CodexAgent prewarm eviction', () => {
peer.exit();
});

test('applies context size and thinking level to new and resumed threads', async () => {
const agent = await createAgent(disposables);
agent['_schedulePrewarm'] = () => { };
agent['_refreshSkillHookCustomizations'] = async () => { };
agent['_refreshSkillExtraRoots'] = async () => { };
const peer = disposables.add(createTestPeer());
agent['_connection'] = {
kind: 'ready',
client: new CodexAppServerClient(peer.transport),
usageSource: 'github',
child: { kill: () => true },
} as never;
const baseModel = agent.models.get()[0];
agent['_models'].set([{
...baseModel,
configSchema: {
type: 'object',
properties: {
thinkingLevel: { type: 'string', title: 'Thinking Level', enum: ['low', 'high'], default: 'low' },
contextSize: { type: 'number', title: 'Context Size', enum: [272_000, 1_000_000], default: 272_000 },
test('applies context size and thinking level for Copilot and ChatGPT models', async () => {
const runScenario = async (source: 'copilot' | 'chatgpt', selectedModelId: string) => {
const longContextSize = source === 'copilot' ? 1_000_000 : 872_000;
const agent = await createAgent(disposables);
agent['_schedulePrewarm'] = () => { };
agent['_refreshSkillHookCustomizations'] = async () => { };
agent['_refreshSkillExtraRoots'] = async () => { };
const peer = disposables.add(createTestPeer());
agent['_connection'] = {
kind: 'ready',
client: new CodexAppServerClient(peer.transport),
usageSource: source === 'copilot' ? 'github' : 'openai',
child: { kill: () => true },
} as never;
const baseModel = agent.models.get()[0];
agent['_models'].set([{
...baseModel,
id: selectedModelId,
configSchema: {
type: 'object',
properties: {
thinkingLevel: { type: 'string', title: 'Thinking Level', enum: ['low', 'high'], default: 'low' },
contextSize: { type: 'number', title: 'Context Size', enum: [272_000, longContextSize], default: 272_000 },
},
},
},
}], undefined);
}], undefined);

const folder = URI.file('/repo/context-size');
const longContextModel = { id: COPILOT_TEST_MODEL, config: { thinkingLevel: 'low', contextSize: 1_000_000 } };
const created = await createSession(agent, { workingDirectories: [folder], model: longContextModel });
const chat = defaultChatOf(created.session);
const entry = agent['_sessions'].get(AgentSession.id(created.session))!;
const materializing = agent['_materializeIfNeeded'](entry, created.session, false);
const start = await readNextRequest(peer.outbound);
peer.push({ id: start.id, result: { thread: { id: 'context-size-thread', cwd: folder.fsPath } } });
await materializing;
const threadId = `${source}-context-size-thread`;
const folder = URI.file(`/repo/context-size-${source}`);
const longContextModel = { id: selectedModelId, config: { thinkingLevel: 'low', contextSize: longContextSize } };
const created = await createSession(agent, { workingDirectories: [folder], model: longContextModel });
const chat = defaultChatOf(created.session);
const entry = agent['_sessions'].get(AgentSession.id(created.session))!;
const materializing = agent['_materializeIfNeeded'](entry, created.session, false);
const start = await readNextRequest(peer.outbound);
peer.push({ id: start.id, result: { thread: { id: threadId, cwd: folder.fsPath } } });
await materializing;

await agent.chats.changeModel(chat, { id: COPILOT_TEST_MODEL, config: { thinkingLevel: 'high', contextSize: 272_000 } }, chatContext(created.session, chat));
const sending = agent.chats.sendMessage(chat, 'use the shorter window', [folder], undefined, 'turn-1', undefined, undefined, chatContext(created.session, chat));
const unsubscribe = await readNextRequest(peer.outbound);
peer.push({ id: unsubscribe.id, result: {} });
const resume = await readNextRequest(peer.outbound);
peer.push({ id: resume.id, result: { thread: { id: 'context-size-thread', cwd: folder.fsPath }, cwd: folder.fsPath } });
const inventory = await readNextRequest(peer.outbound);
peer.push({ id: inventory.id, result: { data: [], nextCursor: null } });
const turn = await readNextRequest(peer.outbound);
peer.push({ id: turn.id, result: {} });
await sending;
await agent.chats.changeModel(chat, { id: selectedModelId, config: { thinkingLevel: 'high', contextSize: 272_000 } }, chatContext(created.session, chat));
const sending = agent.chats.sendMessage(chat, 'use the shorter window', [folder], undefined, 'turn-1', undefined, undefined, chatContext(created.session, chat));
const unsubscribe = await readNextRequest(peer.outbound);
peer.push({ id: unsubscribe.id, result: {} });
const resume = await readNextRequest(peer.outbound);
peer.push({ id: resume.id, result: { thread: { id: threadId, cwd: folder.fsPath }, cwd: folder.fsPath } });
const inventory = await readNextRequest(peer.outbound);
peer.push({ id: inventory.id, result: { data: [], nextCursor: null } });
const turn = await readNextRequest(peer.outbound);
peer.push({ id: turn.id, result: {} });
await sending;
peer.exit();

assert.deepStrictEqual({
start: { method: start.method, contextSize: start.params.config?.model_context_window },
unsubscribe: { method: unsubscribe.method, threadId: unsubscribe.params.threadId },
resume: { method: resume.method, contextSize: resume.params.config?.model_context_window },
turn: {
method: turn.method,
thinkingLevel: turn.params.effort,
collaborationThinkingLevel: turn.params.collaborationMode?.settings.reasoning_effort,
},
return {
source,
start: {
method: start.method,
model: start.params.model,
modelProvider: start.params.modelProvider,
contextSize: start.params.config?.model_context_window,
},
unsubscribe: { method: unsubscribe.method, threadId: unsubscribe.params.threadId },
resume: {
method: resume.method,
model: resume.params.model,
modelProvider: resume.params.modelProvider,
contextSize: resume.params.config?.model_context_window,
},
turn: {
method: turn.method,
thinkingLevel: turn.params.effort,
collaborationThinkingLevel: turn.params.collaborationMode?.settings.reasoning_effort,
},
};
};

assert.deepStrictEqual([
await runScenario('copilot', COPILOT_TEST_MODEL),
await runScenario('chatgpt', OPENAI_TEST_MODEL),
], [{
source: 'copilot',
start: { method: 'thread/start', model: 'gpt-test', modelProvider: 'vscode-proxy', contextSize: 1_000_000 },
unsubscribe: { method: 'thread/unsubscribe', threadId: 'copilot-context-size-thread' },
resume: { method: 'thread/resume', model: 'gpt-test', modelProvider: 'vscode-proxy', contextSize: 272_000 },
turn: { method: 'turn/start', thinkingLevel: 'high', collaborationThinkingLevel: 'high' },
}, {
start: { method: 'thread/start', contextSize: 1_000_000 },
unsubscribe: { method: 'thread/unsubscribe', threadId: 'context-size-thread' },
resume: { method: 'thread/resume', contextSize: 272_000 },
source: 'chatgpt',
start: { method: 'thread/start', model: 'gpt-5.6-sol', modelProvider: 'openai', contextSize: 872_000 },
unsubscribe: { method: 'thread/unsubscribe', threadId: 'chatgpt-context-size-thread' },
resume: { method: 'thread/resume', model: 'gpt-5.6-sol', modelProvider: 'openai', contextSize: 272_000 },
turn: { method: 'turn/start', thinkingLevel: 'high', collaborationThinkingLevel: 'high' },
});
peer.exit();
}]);
});

test('routes provider-qualified models independently and switches one session', async () => {
Expand Down
Loading