diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d992285..073ce42 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -13,10 +13,10 @@ import type {Disposable} from "vscode-jsonrpc"; import type { ClientInfo, ReasoningEffort, - ServerNotification + ServerNotification, + ServiceTier, } from "./app-server"; import type {JsonValue} from "./app-server/serde_json/JsonValue"; -import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import path from "node:path"; import {logger} from "./Logger"; @@ -215,10 +215,10 @@ export class CodexAcpClient { threadId: request.sessionId, }); const codexModels = await this.fetchAvailableModels(); - const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); + const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); return { sessionId: request.sessionId, - currentModelId: currentModelId, + ...modelSelection, models: codexModels, } } @@ -237,10 +237,10 @@ export class CodexAcpClient { threadId: request.sessionId, }); const codexModels = await this.fetchAvailableModels(); - const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); + const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); return { sessionId: request.sessionId, - currentModelId: currentModelId, + ...modelSelection, models: codexModels, thread: response.thread, }; @@ -266,10 +266,10 @@ export class CodexAcpClient { if (codexModels.length === 0) { throw new Error("Codex did not return any models"); } - const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); + const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); return { sessionId: response.thread.id, - currentModelId: currentModelId, + ...modelSelection, models: codexModels, }; } @@ -346,19 +346,41 @@ export class CodexAcpClient { } /** - * Resolves a ModelId using the provided ID and reasoning effort. - * Falls back to model defaults if parameters are missing or unsupported. + * Resolves a model selection using the provided ID and reasoning effort. + * Falls back to model defaults if parameters are missing. */ - createModelId(availableModels: Model[], modelId: string | null, reasoningEffort: ReasoningEffort | null): ModelId { - const selectedModel = - availableModels.find(m => m.id === modelId) ?? - availableModels.find(m => m.isDefault); + createModelSelection( + availableModels: Model[], + modelId: string | null, + reasoningEffort: ReasoningEffort | null, + serviceTier: ServiceTier | null, + ): ModelSelection { + const requestedModel = availableModels.find(m => m.id === modelId); + const selectedModel = requestedModel ?? availableModels.find(m => m.isDefault); if (!selectedModel) { throw new Error(`Model selection failed: No model found for ID "${modelId}" and no default model is defined.`); } - return ModelId.create(selectedModel.id, reasoningEffort ?? selectedModel.defaultReasoningEffort); + const supportedReasoningEfforts = selectedModel.supportedReasoningEfforts ?? []; + const additionalSpeedTiers = selectedModel.additionalSpeedTiers ?? []; + const selectedReasoningEffort = reasoningEffort !== null && supportedReasoningEfforts.some( + option => option.reasoningEffort === reasoningEffort + ) + ? reasoningEffort + : selectedModel.defaultReasoningEffort; + const didSelectRequestedModel = requestedModel !== undefined; + const supportsServiceTier = serviceTier !== null && additionalSpeedTiers.includes(serviceTier); + const selectedServiceTier = + didSelectRequestedModel && supportsServiceTier + ? serviceTier + : null; + + return { + currentModelId: selectedModel.id, + currentReasoningEffort: selectedReasoningEffort, + currentServiceTier: selectedServiceTier, + }; } async subscribeToSessionEvents( @@ -375,12 +397,11 @@ export class CodexAcpClient { async sendPrompt( request: acp.PromptRequest, agentMode: AgentMode, - modelId: ModelId, + modelSelection: ModelSelection, disableSummary: boolean, cwd: string, ): Promise { const input = buildPromptItems(request.prompt); - const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion await this.refreshSkills(cwd, request._meta); await this.codexClient.turnStart({ @@ -392,8 +413,10 @@ export class CodexAcpClient { summary: disableSummary ? "none" : null, personality: null, cwd: null, - effort: effort, - model: modelId.model, + effort: modelSelection.currentReasoningEffort, + model: modelSelection.currentModelId, + // In app-server, explicit null clears the tier; omitting serviceTier would keep the thread's existing tier. + serviceTier: modelSelection.currentServiceTier, }); // Wait for turn completion @@ -590,6 +613,8 @@ export type JsonObject = { [key in string]?: JsonValue } export type SessionMetadata = { sessionId: string, currentModelId: string, + currentReasoningEffort: ReasoningEffort, + currentServiceTier: ServiceTier | null, models: Model[], } @@ -597,6 +622,8 @@ export type SessionMetadataWithThread = SessionMetadata & { thread: Thread, } +export type ModelSelection = Pick; + function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] { return prompt.map((block): UserInput | null => { switch (block.type) { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 1a80d6c..91ca742 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -4,10 +4,10 @@ import {CodexEventHandler} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods} from "./CodexAuthMethod"; -import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; +import {CodexAcpClient, type ModelSelection, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; import type {McpStartupResult} from "./CodexAppServerClient"; import {ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; -import type {InputModality, ReasoningEffort} from "./app-server"; +import type {InputModality, ReasoningEffort, ServiceTier} from "./app-server"; import type { Account, CollabAgentToolCallStatus, @@ -18,7 +18,6 @@ import type { UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; -import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import type {TokenCount} from "./TokenCount"; import {toPromptUsage} from "./TokenCount"; @@ -36,6 +35,8 @@ import { export interface SessionState { sessionId: string, currentModelId: string, + currentReasoningEffort: ReasoningEffort, + currentServiceTier: ServiceTier | null, supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, @@ -160,12 +161,14 @@ export class CodexAcpServer implements acp.Agent { } const account = await this.getActiveAccount(); - const {sessionId, currentModelId, models} = sessionMetadata; + const {sessionId, currentModelId, currentReasoningEffort, currentServiceTier, models} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, "sessionId" in request); const currentModel = this.findCurrentModel(models, currentModelId); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, + currentReasoningEffort: currentReasoningEffort, + currentServiceTier: currentServiceTier, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), @@ -189,7 +192,7 @@ export class CodexAcpServer implements acp.Agent { } this.publishAvailableCommandsAsync(sessionId); - const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); + const sessionModelState: SessionModelState = this.createModelState(models, sessionState); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); return [sessionId, sessionModelState, sessionModeState]; @@ -310,31 +313,32 @@ export class CodexAcpServer implements acp.Agent { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); - const requestedModelId= ModelId.fromString(params.modelId); - const requestedModelName = requestedModelId.model; - const requestedEffort = requestedModelId.effort; - const models = await this.codexAcpClient.fetchAvailableModels(); - const model = models.find(m => m.id === requestedModelName); + const model = models.find(m => m.id === params.modelId); if (!model) throw new Error(`Unknown model ${params.modelId}`); - const requestedEffortValue = requestedEffort as ReasoningEffort | undefined; - let reasoningEffort: ReasoningEffort; - if (requestedEffortValue) { + const requestedEffort = readStringMeta(params._meta, "reasoningEffort") as ReasoningEffort | null; + let reasoningEffort = model.defaultReasoningEffort; + if (requestedEffort !== null) { const matchedEffort = model.supportedReasoningEfforts.find( - (option) => option.reasoningEffort === requestedEffortValue + (option) => option.reasoningEffort === requestedEffort )?.reasoningEffort; if (!matchedEffort) { - throw new Error(`Unsupported reasoning effort ${requestedEffortValue} for model ${requestedModelName}`); + throw new Error(`Unsupported reasoning effort ${requestedEffort} for model ${model.id}`); } reasoningEffort = matchedEffort; - } else { - reasoningEffort = model.defaultReasoningEffort; } - sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString(); + const requestedServiceTier = readStringMeta(params._meta, "serviceTier") as ServiceTier | null; + if (requestedServiceTier !== null && !model.additionalSpeedTiers.includes(requestedServiceTier)) { + throw new Error(`Unsupported service tier ${requestedServiceTier} for model ${model.id}`); + } + + sessionState.currentModelId = model.id; + sessionState.currentReasoningEffort = reasoningEffort; + sessionState.currentServiceTier = requestedServiceTier; sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; sessionState.supportedInputModalities = model.inputModalities; @@ -346,22 +350,28 @@ export class CodexAcpServer implements acp.Agent { } private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { - const modelId = ModelId.fromString(currentModelId); - return models.find(m => m.id === modelId.model); + return models.find(m => m.id === currentModelId); } - private createModelState(availableModels: Model[], selectedModelId: string): SessionModelState { + private createModelState(availableModels: Model[], selection: ModelSelection): SessionModelState { const allowedModels = availableModels - .flatMap((model) => - model.supportedReasoningEfforts.map((effort) => ({ - modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), - name: `${model.displayName} (${effort.reasoningEffort})`, - description: `${model.description} ${effort.description}`, - })) - ); + .map((model) => ({ + modelId: model.id, + name: model.displayName, + description: model.description, + _meta: { + supportedReasoningEfforts: model.supportedReasoningEfforts, + defaultReasoningEffort: model.defaultReasoningEffort, + serviceTiers: model.additionalSpeedTiers, + }, + })); return { availableModels: allowedModels, - currentModelId: selectedModelId, + currentModelId: selection.currentModelId, + _meta: { + currentReasoningEffort: selection.currentReasoningEffort, + currentServiceTier: selection.currentServiceTier, + }, } } @@ -385,12 +395,14 @@ export class CodexAcpServer implements acp.Agent { ); const account = await this.getActiveAccount(); - const {sessionId, currentModelId, models, thread} = sessionMetadata; + const {sessionId, currentModelId, currentReasoningEffort, currentServiceTier, models, thread} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, true); const currentModel = this.findCurrentModel(models, currentModelId); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, + currentReasoningEffort: currentReasoningEffort, + currentServiceTier: currentServiceTier, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), @@ -414,7 +426,7 @@ export class CodexAcpServer implements acp.Agent { } await this.availableCommands.publish(sessionId); - const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); + const sessionModelState: SessionModelState = this.createModelState(models, sessionState); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); return { @@ -751,7 +763,6 @@ export class CodexAcpServer implements acp.Agent { }; } - const modelId = ModelId.fromString(sessionState.currentModelId); const modelLacksReasoning = sessionState.supportedReasoningEfforts.length > 0 && sessionState.supportedReasoningEfforts.every(e => e.reasoningEffort === "none"); @@ -768,7 +779,7 @@ export class CodexAcpServer implements acp.Agent { } const agentMode = sessionState.agentMode; const turnCompleted = await this.runWithProcessCheck( - () => this.codexAcpClient.sendPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd)); + () => this.codexAcpClient.sendPrompt(params, agentMode, sessionState, disableSummary, sessionState.cwd)); // Check if turn was interrupted (cancelled) if (turnCompleted.turn.status === "interrupted") { @@ -812,8 +823,7 @@ export class CodexAcpServer implements acp.Agent { private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; - // Remove the "[reasoning-level]" suffix from currentModelId if present - const modelName = sessionState.currentModelId.replace(/\[.*?]$/, ''); + const modelName = sessionState.currentModelId; // FIXME: currently all tokens are reported for the current model const modelUsage = (lastTokenUsage != null) @@ -886,3 +896,14 @@ export class CodexAcpServer implements acp.Agent { function getRequestedMcpServerNames(mcpServers: Array): Array { return Array.from(new Set(mcpServers.map(server => server.name))); } + +function readStringMeta(meta: { [key: string]: unknown } | null | undefined, key: string): string | null { + const value = meta?.[key]; + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "string") { + throw RequestError.invalidParams(`Expected _meta.${key} to be a string`); + } + return value; +} diff --git a/src/ModelId.ts b/src/ModelId.ts deleted file mode 100644 index 23c4553..0000000 --- a/src/ModelId.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type {ReasoningEffort} from "./app-server"; -import type {Model} from "./app-server/v2"; - -/** - * ACP Model ID, combining the base model ID and its reasoning effort level. - * @example - * const id = ModelId.fromString("gpt-5.2[high]"); - */ -export class ModelId { - private constructor( - public readonly model: string, - public readonly effort: string - ) {} - - static fromComponents(model: Model, effort: ReasoningEffort): ModelId { - return new ModelId(model.id, effort); - } - - static create(modelId: string, effort: ReasoningEffort): ModelId { - return new ModelId(modelId, effort); - } - - static fromString(modelId: string): ModelId { - const bracketMatch = modelId.match(/^(?[^\[]+?)(?:\[(?[^\]]+)\])?$/); - const model = bracketMatch?.groups?.["model"]; - const effort = bracketMatch?.groups?.["effort"]; - - if (!model || !effort) { - throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort].`); - } - - if (model) { - return new ModelId(model, effort); - } - - throw new Error(`Invalid modelId format: ${modelId}`); - } - - toString(): string { - return `${this.model}[${this.effort}]`; - } -} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index b0cda9e..86336aa 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -9,7 +9,6 @@ import type {SessionState} from "../../CodexAcpServer"; import {AgentMode} from "../../AgentMode"; import type {ListMcpServerStatusResponse, Model, SkillsListResponse} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; -import {ModelId} from "../../ModelId"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -422,7 +421,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); const sessionState: SessionState = createTestSessionState({ sessionId: "id", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); @@ -447,7 +446,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionState: SessionState = createTestSessionState({ sessionId: "id", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); @@ -493,12 +492,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionState1: SessionState = createTestSessionState({ sessionId: "session-1", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); const sessionState2: SessionState = createTestSessionState({ sessionId: "session-2", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); @@ -741,13 +740,66 @@ describe('ACP server test', { timeout: 40_000 }, () => { ]; it('should fallback to the default model when modelId is null', () => { - const result = fixture.getCodexAcpClient().createModelId(mockModels, null, 'low'); - expect(result).toEqual(ModelId.create('5.1', 'low')); + const result = fixture.getCodexAcpClient().createModelSelection(mockModels, null, 'low', null); + expect(result).toEqual({ + currentModelId: "5.1", + currentReasoningEffort: "low", + currentServiceTier: null, + }); }); it('should fallback to the model-specific effort when reasoningEffort is null', () => { - const result = fixture.getCodexAcpClient().createModelId(mockModels, '5.2-codex', null); - expect(result).toEqual(ModelId.create('5.2-codex', 'medium')); + const result = fixture.getCodexAcpClient().createModelSelection(mockModels, '5.2-codex', null, null); + expect(result).toEqual({ + currentModelId: "5.2-codex", + currentReasoningEffort: "medium", + currentServiceTier: null, + }); + }); + + it('should fallback to the model-specific effort when reasoningEffort is unsupported', () => { + const result = fixture.getCodexAcpClient().createModelSelection(mockModels, '5.2-codex', 'low', null); + expect(result).toEqual({ + currentModelId: "5.2-codex", + currentReasoningEffort: "medium", + currentServiceTier: null, + }); + }); + + it('should drop stale service tier when falling back to the default model', () => { + const [codexModel, defaultModel] = mockModels as [Model, Model]; + const models = [ + { + ...codexModel, + additionalSpeedTiers: ["fast"], + }, + defaultModel, + ]; + + const result = fixture.getCodexAcpClient().createModelSelection(models, 'unavailable-model', 'high', 'fast'); + expect(result).toEqual({ + currentModelId: "5.1", + currentReasoningEffort: "low", + currentServiceTier: null, + }); + }); + + it('should retain service tier when selected model supports it', () => { + const [codexModel, defaultModel] = mockModels as [Model, Model]; + const models = [ + { + ...codexModel, + additionalSpeedTiers: ["fast"], + }, + defaultModel, + ]; + + const result = fixture.getCodexAcpClient().createModelSelection(models, '5.2-codex', 'high', 'fast'); + expect(result).toEqual({ + currentModelId: "5.2-codex", + currentReasoningEffort: "high", + currentServiceTier: "fast", + }); }); /** @@ -802,6 +854,38 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: null })); }); + it ('should send null service tier for normal model selections', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ + currentModelId: "model-id", + currentReasoningEffort: "high", + currentServiceTier: null, + }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: "model-id", + effort: "high", + serviceTier: null, + })); + }); + + it ('should send fast service tier for fast model selections', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ + currentModelId: "model-id", + currentReasoningEffort: "high", + currentServiceTier: "fast", + }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: "model-id", + effort: "high", + serviceTier: "fast", + })); + }); + it ('should disable reasoning.summary when model lacks reasoning', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index 7ee4374..cdabba8 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -28,7 +28,7 @@ describe('Approval Events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, 'getSessionState').mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/command-action-events.test.ts b/src/__tests__/CodexACPAgent/command-action-events.test.ts index b6aa0f7..d7e1ef6 100644 --- a/src/__tests__/CodexACPAgent/command-action-events.test.ts +++ b/src/__tests__/CodexACPAgent/command-action-events.test.ts @@ -15,7 +15,7 @@ describe('CodexEventHandler - command action events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json index 13403fd..1bd9899 100644 --- a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json +++ b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" + "text": "**Model:** model-id \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" } } } diff --git a/src/__tests__/CodexACPAgent/data/command-status.json b/src/__tests__/CodexACPAgent/data/command-status.json index 5026058..85143d1 100644 --- a/src/__tests__/CodexACPAgent/data/command-status.json +++ b/src/__tests__/CodexACPAgent/data/command-status.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" + "text": "**Model:** model-id \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" } } } diff --git a/src/__tests__/CodexACPAgent/data/model-filtering.json b/src/__tests__/CodexACPAgent/data/model-filtering.json index 95439da..b5350b5 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -1,27 +1,68 @@ [ { - "modelId": "gpt-5.2[medium]", - "name": "GPT-5.2 (medium)", - "description": "Allowed by id. Default effort." + "modelId": "gpt-5.2", + "name": "GPT-5.2", + "description": "Allowed by id.", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + }, + { + "reasoningEffort": "low", + "description": "Fast effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [ + "fast" + ] + } }, { - "modelId": "gpt-5.2[low]", - "name": "GPT-5.2 (low)", - "description": "Allowed by id. Fast effort." + "modelId": "other-id", + "name": "gpt-5.2", + "description": "Allowed", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [] + } }, { - "modelId": "other-id[medium]", - "name": "gpt-5.2 (medium)", - "description": "Allowed Default effort." + "modelId": "gpt-5.1-codex-mini", + "name": "Other", + "description": "Allowed by id.", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [] + } }, { - "modelId": "gpt-5.1-codex-mini[medium]", - "name": "Other (medium)", - "description": "Allowed by id. Default effort." - }, - { - "modelId": "gpt-4o[medium]", - "name": "gpt-4o (medium)", - "description": "Allowed. Default effort." + "modelId": "gpt-4o", + "name": "gpt-4o", + "description": "Allowed.", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [] + } } ] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index 9c685ec..d35f3cc 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -55,8 +55,9 @@ "summary": null, "personality": null, "cwd": "cwd", - "effort": "effort", - "model": "model" + "effort": "medium", + "model": "model", + "serviceTier": null } } { diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts index 037e363..f9dc2ef 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts @@ -22,7 +22,11 @@ describeE2E("E2E session persistence tests", () => { beforeRestartFixture = await createAuthenticatedFixture(); const sessionId = (await beforeRestartFixture.createSession()).sessionId; - await beforeRestartFixture.connection.unstable_setSessionModel({sessionId, modelId: OTHER_TEST_MODEL_ID.toString()}); + await beforeRestartFixture.connection.unstable_setSessionModel({ + sessionId, + modelId: OTHER_TEST_MODEL_ID.toString(), + _meta: { reasoningEffort: OTHER_TEST_MODEL_ID.effort }, + }); const memorizedToken = "token-for-tests-123"; await beforeRestartFixture.expectPromptText( sessionId, diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts index f625f0d..0b82c96 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts @@ -51,6 +51,7 @@ describeE2E("E2E tests", () => { await fixture.connection.unstable_setSessionModel({ sessionId: session.sessionId, modelId: OTHER_TEST_MODEL_ID.toString(), + _meta: { reasoningEffort: OTHER_TEST_MODEL_ID.effort }, }); await fixture.expectStatus(session.sessionId, {Model: OTHER_TEST_MODEL_ID}); }); diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index 217c112..bd59017 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -4,12 +4,26 @@ import fs from "node:fs"; import path from "node:path"; import {Readable, Writable} from "node:stream"; import {expect, vi} from "vitest"; -import {ModelId} from "../../../ModelId"; +import type {ReasoningEffort} from "../../../app-server"; import {removeDirectoryWithRetry, writeCodexHomeConfig} from "../../acp-test-utils"; import type {PermissionResponder} from "./permission-responders"; -export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-5.2", "none"); -export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.3-codex", "low"); +export interface TestModelSelection { + readonly model: string; + readonly effort: ReasoningEffort; + toString(): string; +} + +export const DEFAULT_TEST_MODEL_ID: TestModelSelection = { + model: "gpt-5.2", + effort: "none", + toString: () => "gpt-5.2", +}; +export const OTHER_TEST_MODEL_ID: TestModelSelection = { + model: "gpt-5.3-codex", + effort: "low", + toString: () => "gpt-5.3-codex", +}; export interface TestSkill { readonly name: string; diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index 32548be..a8510c2 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -29,7 +29,7 @@ describe('Elicitation Events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, 'getSessionState').mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index b25094f..df4d99e 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -36,7 +36,7 @@ describe('CodexEventHandler - file change events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts index 02d8561..563a550 100644 --- a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts +++ b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts @@ -15,7 +15,7 @@ describe("CodexEventHandler - fuzzy file search events", () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE, }); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 97b658a..d7bc43f 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -25,7 +25,7 @@ describe("Model filtering", () => { supportedReasoningEfforts: efforts, defaultReasoningEffort: "medium", supportsPersonality: false, - additionalSpeedTiers: [], + additionalSpeedTiers: ["fast"], isDefault: false, inputModalities: [] }, @@ -82,7 +82,9 @@ describe("Model filtering", () => { vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", - currentModelId: "gpt-5.2[medium]", + currentModelId: "gpt-5.2", + currentReasoningEffort: "medium", + currentServiceTier: null, models, }); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); @@ -91,8 +93,188 @@ describe("Model filtering", () => { const sessionModels = newSessionResponse.models; const availableModels = sessionModels?.availableModels; + expect(sessionModels?._meta).toEqual({ + currentReasoningEffort: "medium", + currentServiceTier: null, + }); await expect(JSON.stringify(availableModels, null, 2)).toMatchFileSnapshot( "data/model-filtering.json" ); }); + + it("rejects fast model selections when the model does not support fast", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + + const models: Model[] = [ + { + id: "gpt-5.2", + model: "gpt-5.2", + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: "GPT-5.2", + description: "No fast tier.", + hidden: false, + supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}], + defaultReasoningEffort: "medium", + supportsPersonality: false, + additionalSpeedTiers: [], + isDefault: true, + inputModalities: ["text"] + }, + ]; + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "session-id", + currentModelId: "gpt-5.2", + currentReasoningEffort: "medium", + currentServiceTier: null, + models, + }); + vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + + await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); + + await expect(codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + _meta: { serviceTier: "fast" }, + })).rejects.toThrow("Unsupported service tier fast for model gpt-5.2"); + }); + + it("stores fast model selections when the model supports fast", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + + const models: Model[] = [ + { + id: "gpt-5.2", + model: "gpt-5.2", + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: "GPT-5.2", + description: "Fast tier.", + hidden: false, + supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}], + defaultReasoningEffort: "medium", + supportsPersonality: false, + additionalSpeedTiers: ["fast"], + isDefault: true, + inputModalities: ["text"] + }, + ]; + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "session-id", + currentModelId: "gpt-5.2", + currentReasoningEffort: "medium", + currentServiceTier: null, + models, + }); + vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + + await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); + await codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + _meta: { serviceTier: "fast" }, + }); + + expect(codexAcpAgent.getSessionState("session-id").currentModelId) + .toBe("gpt-5.2"); + expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort) + .toBe("medium"); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier) + .toBe("fast"); + }); + + it("uses the model default effort when _meta.reasoningEffort is omitted", async () => { + const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); + + await codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + }); + + expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort).toBe("medium"); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBeNull(); + }); + + it("stores requested reasoning effort from _meta", async () => { + const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); + + await codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + _meta: { reasoningEffort: "low" }, + }); + + expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("gpt-5.2"); + expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort).toBe("low"); + }); + + it("rejects unsupported reasoning effort selections", async () => { + const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); + + await expect(codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + _meta: { reasoningEffort: "xhigh" }, + })).rejects.toThrow("Unsupported reasoning effort xhigh for model gpt-5.2"); + }); }); + +function createSelectableModel(overrides: Partial = {}): Model { + return { + id: "gpt-5.2", + model: "gpt-5.2", + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: "GPT-5.2", + description: "Selectable model.", + hidden: false, + supportedReasoningEfforts: [ + {reasoningEffort: "low", description: "Fast effort."}, + {reasoningEffort: "medium", description: "Default effort."}, + ], + defaultReasoningEffort: "medium", + supportsPersonality: false, + additionalSpeedTiers: ["fast"], + isDefault: true, + inputModalities: ["text"], + ...overrides, + }; +} + +async function setupModelSelectionTest(models: Model[]) { + const initialModel = models[0]; + if (!initialModel) { + throw new Error("setupModelSelectionTest requires at least one model"); + } + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "session-id", + currentModelId: initialModel.id, + currentReasoningEffort: initialModel.defaultReasoningEffort, + currentServiceTier: null, + models, + }); + vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + + await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); + return {fixture, codexAcpAgent, codexAcpClient}; +} diff --git a/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts b/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts index a037364..1760bcd 100644 --- a/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts +++ b/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts @@ -15,7 +15,7 @@ describe("CodexEventHandler - model rerouted events", () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts index e0e3e66..2b14c01 100644 --- a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts +++ b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts @@ -15,7 +15,7 @@ describe('CodexEventHandler - terminal output events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index a162921..a505097 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -318,7 +318,9 @@ export function createTestSessionState(overrides?: Partial): Sessi account: null, cwd: "/test/cwd", sessionId: "session-id", - currentModelId: "model-id[effort]", + currentModelId: "model-id", + currentReasoningEffort: "medium", + currentServiceTier: null, supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE,