From 86d819f9c0021016b6728b6a524262c2b05d1b65 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 30 Apr 2026 13:10:00 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20LLM-26878=20Add=20=E2=80=9CFast?= =?UTF-8?q?=E2=80=9D=20mode=20for=20Codex-agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CodexAcpClient.ts | 1 + src/CodexAcpServer.ts | 33 +++++-- src/ModelId.ts | 32 ++++--- .../CodexACPAgent/CodexAcpClient.test.ts | 28 ++++++ .../CodexACPAgent/data/model-filtering.json | 10 +++ .../data/send-attachments-turn-start.json | 3 +- .../CodexACPAgent/model-filtering.test.ts | 86 ++++++++++++++++++- src/__tests__/ModelId.test.ts | 23 +++++ 8 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/ModelId.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d992285f..feaf6189 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -394,6 +394,7 @@ export class CodexAcpClient { cwd: null, effort: effort, model: modelId.model, + serviceTier: modelId.serviceTier, }); // Wait for turn completion diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 1a80d6ca..c26b34ff 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -310,13 +310,16 @@ 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 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); if (!model) throw new Error(`Unknown model ${params.modelId}`); + if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) { + throw new Error(`Unsupported service tier fast for model ${requestedModelName}`); + } const requestedEffortValue = requestedEffort as ReasoningEffort | undefined; let reasoningEffort: ReasoningEffort; @@ -334,7 +337,7 @@ export class CodexAcpServer implements acp.Agent { reasoningEffort = model.defaultReasoningEffort; } - sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString(); + sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort, requestedModelId.serviceTier).toString(); sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; sessionState.supportedInputModalities = model.inputModalities; @@ -353,11 +356,24 @@ export class CodexAcpServer implements acp.Agent { private createModelState(availableModels: Model[], selectedModelId: string): 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}`, - })) + model.supportedReasoningEfforts.flatMap((effort) => { + const standardModel = { + modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), + name: `${model.displayName} (${effort.reasoningEffort})`, + description: `${model.description} ${effort.description}`, + }; + if (!model.additionalSpeedTiers.includes("fast")) { + return [standardModel]; + } + return [ + standardModel, + { + modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(), + name: `${model.displayName} (${effort.reasoningEffort}, fast)`, + description: `${model.description} ${effort.description} Fast service tier.`, + }, + ]; + }) ); return { availableModels: allowedModels, @@ -812,8 +828,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 = ModelId.fromString(sessionState.currentModelId).model; // FIXME: currently all tokens are reported for the current model const modelUsage = (lastTokenUsage != null) diff --git a/src/ModelId.ts b/src/ModelId.ts index 23c45531..b68d6336 100644 --- a/src/ModelId.ts +++ b/src/ModelId.ts @@ -1,42 +1,48 @@ -import type {ReasoningEffort} from "./app-server"; +import type {ReasoningEffort, ServiceTier} from "./app-server"; import type {Model} from "./app-server/v2"; /** - * ACP Model ID, combining the base model ID and its reasoning effort level. + * ACP Model ID, combining the base model ID, reasoning effort level, and optional service tier. * @example * const id = ModelId.fromString("gpt-5.2[high]"); + * const fastId = ModelId.fromString("gpt-5.2[high]@fast"); */ export class ModelId { private constructor( public readonly model: string, - public readonly effort: string + public readonly effort: string, + public readonly serviceTier: ServiceTier | null = null ) {} - static fromComponents(model: Model, effort: ReasoningEffort): ModelId { - return new ModelId(model.id, effort); + static fromComponents(model: Model, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { + return new ModelId(model.id, effort, serviceTier); } - static create(modelId: string, effort: ReasoningEffort): ModelId { - return new ModelId(modelId, effort); + static create(modelId: string, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { + return new ModelId(modelId, effort, serviceTier); } static fromString(modelId: string): ModelId { - const bracketMatch = modelId.match(/^(?[^\[]+?)(?:\[(?[^\]]+)\])?$/); + const bracketMatch = modelId.match(/^(?[^\[]+)\[(?[^\]]+)](?:@(?.+))?$/); const model = bracketMatch?.groups?.["model"]; const effort = bracketMatch?.groups?.["effort"]; + const serviceTier = bracketMatch?.groups?.["serviceTier"] ?? null; if (!model || !effort) { - throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort].`); + throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort] or modelId[effort]@fast.`); } - if (model) { - return new ModelId(model, effort); + // The generated app-server ServiceTier type also includes "flex", but ACP model IDs + // only expose Fast variants for now because model/list advertises Fast support. + if (serviceTier !== null && serviceTier !== "fast") { + throw new Error(`Unsupported service tier ${serviceTier} for modelId: ${modelId}.`); } - throw new Error(`Invalid modelId format: ${modelId}`); + return new ModelId(model, effort, serviceTier); } toString(): string { - return `${this.model}[${this.effort}]`; + const suffix = this.serviceTier === null ? "" : `@${this.serviceTier}`; + return `${this.model}[${this.effort}]${suffix}`; } } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index b0cda9ee..cc636e3d 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -802,6 +802,34 @@ 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[effort]", + }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: "model-id", + effort: "effort", + serviceTier: null, + })); + }); + + it ('should send fast service tier for fast model selections', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ + currentModelId: "model-id[effort]@fast", + }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: "model-id", + effort: "effort", + 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/data/model-filtering.json b/src/__tests__/CodexACPAgent/data/model-filtering.json index 95439da2..03af5012 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -4,11 +4,21 @@ "name": "GPT-5.2 (medium)", "description": "Allowed by id. Default effort." }, + { + "modelId": "gpt-5.2[medium]@fast", + "name": "GPT-5.2 (medium, fast)", + "description": "Allowed by id. Default effort. Fast service tier." + }, { "modelId": "gpt-5.2[low]", "name": "GPT-5.2 (low)", "description": "Allowed by id. Fast effort." }, + { + "modelId": "gpt-5.2[low]@fast", + "name": "GPT-5.2 (low, fast)", + "description": "Allowed by id. Fast effort. Fast service tier." + }, { "modelId": "other-id[medium]", "name": "gpt-5.2 (medium)", diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index 9c685ece..c4ab4c54 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -56,7 +56,8 @@ "personality": null, "cwd": "cwd", "effort": "effort", - "model": "model" + "model": "model", + "serviceTier": null } } { diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 97b658a4..c7b795ec 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: [] }, @@ -95,4 +95,88 @@ describe("Model filtering", () => { "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[medium]", + 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[medium]@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[medium]", + 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[medium]@fast", + }); + + expect(codexAcpAgent.getSessionState("session-id").currentModelId) + .toBe("gpt-5.2[medium]@fast"); + }); }); diff --git a/src/__tests__/ModelId.test.ts b/src/__tests__/ModelId.test.ts new file mode 100644 index 00000000..276a4d50 --- /dev/null +++ b/src/__tests__/ModelId.test.ts @@ -0,0 +1,23 @@ +import {describe, expect, it} from "vitest"; +import {ModelId} from "../ModelId"; + +describe("ModelId", () => { + it("formats and parses normal model IDs", () => { + const modelId = ModelId.create("gpt-5.2", "medium"); + + expect(modelId.toString()).toBe("gpt-5.2[medium]"); + expect(ModelId.fromString("gpt-5.2[medium]")).toEqual(modelId); + }); + + it("formats and parses fast model IDs", () => { + const modelId = ModelId.create("gpt-5.2", "medium", "fast"); + + expect(modelId.toString()).toBe("gpt-5.2[medium]@fast"); + expect(ModelId.fromString("gpt-5.2[medium]@fast")).toEqual(modelId); + }); + + it("rejects unknown service tiers", () => { + expect(() => ModelId.fromString("gpt-5.2[medium]@flex")) + .toThrow("Unsupported service tier flex"); + }); +}); From 9467938d137c9360a11ba8a128ccc7b8d01daf8e Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Wed, 6 May 2026 22:56:22 +0200 Subject: [PATCH 2/5] feat: move model related data to meta --- src/CodexAcpClient.ts | 46 +++++--- src/CodexAcpServer.ts | 106 +++++++++-------- .../CodexACPAgent/CodexAcpClient.test.ts | 37 +++--- .../CodexACPAgent/approval-events.test.ts | 2 +- .../command-action-events.test.ts | 2 +- .../data/command-status-with-rate-limits.json | 2 +- .../CodexACPAgent/data/command-status.json | 2 +- .../CodexACPAgent/data/model-filtering.json | 85 +++++++++----- .../data/send-attachments-turn-start.json | 2 +- .../e2e/acp-e2e-session-persistence.test.ts | 6 +- .../CodexACPAgent/e2e/acp-e2e.test.ts | 1 + .../e2e/spawned-agent-fixture.ts | 20 +++- .../CodexACPAgent/elicitation-events.test.ts | 2 +- .../CodexACPAgent/file-change-events.test.ts | 2 +- .../fuzzy-file-search-events.test.ts | 2 +- .../CodexACPAgent/model-filtering.test.ts | 110 +++++++++++++++++- .../model-rerouted-events.test.ts | 2 +- .../terminal-output-events.test.ts | 2 +- src/__tests__/acp-test-utils.ts | 4 +- 19 files changed, 307 insertions(+), 128 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index feaf6189..01f88dfe 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,10 +346,15 @@ 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 { + createModelSelection( + availableModels: Model[], + modelId: string | null, + reasoningEffort: ReasoningEffort | null, + serviceTier: ServiceTier | null, + ): ModelSelection { const selectedModel = availableModels.find(m => m.id === modelId) ?? availableModels.find(m => m.isDefault); @@ -358,7 +363,11 @@ export class CodexAcpClient { 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); + return { + currentModelId: selectedModel.id, + currentReasoningEffort: reasoningEffort ?? selectedModel.defaultReasoningEffort, + currentServiceTier: serviceTier ?? null, + }; } async subscribeToSessionEvents( @@ -375,12 +384,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,9 +400,9 @@ export class CodexAcpClient { summary: disableSummary ? "none" : null, personality: null, cwd: null, - effort: effort, - model: modelId.model, - serviceTier: modelId.serviceTier, + effort: modelSelection.currentReasoningEffort, + model: modelSelection.currentModelId, + serviceTier: modelSelection.currentServiceTier, }); // Wait for turn completion @@ -591,6 +599,8 @@ export type JsonObject = { [key in string]?: JsonValue } export type SessionMetadata = { sessionId: string, currentModelId: string, + currentReasoningEffort: ReasoningEffort, + currentServiceTier: ServiceTier | null, models: Model[], } @@ -598,6 +608,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 c26b34ff..91ca7421 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,34 +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}`); - if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) { - throw new Error(`Unsupported service tier fast for model ${requestedModelName}`); - } - 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, requestedModelId.serviceTier).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; @@ -349,35 +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.flatMap((effort) => { - const standardModel = { - modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), - name: `${model.displayName} (${effort.reasoningEffort})`, - description: `${model.description} ${effort.description}`, - }; - if (!model.additionalSpeedTiers.includes("fast")) { - return [standardModel]; - } - return [ - standardModel, - { - modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(), - name: `${model.displayName} (${effort.reasoningEffort}, fast)`, - description: `${model.description} ${effort.description} Fast service tier.`, - }, - ]; - }) - ); + .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, + }, } } @@ -401,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(), @@ -430,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 { @@ -767,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"); @@ -784,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") { @@ -828,7 +823,7 @@ export class CodexAcpServer implements acp.Agent { private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; - const modelName = ModelId.fromString(sessionState.currentModelId).model; + const modelName = sessionState.currentModelId; // FIXME: currently all tokens are reported for the current model const modelUsage = (lastTokenUsage != null) @@ -901,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/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index cc636e3d..128129e4 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,21 @@ 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, + }); }); /** @@ -804,28 +811,32 @@ describe('ACP server test', { timeout: 40_000 }, () => { it ('should send null service tier for normal model selections', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id[effort]", + 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: "effort", + effort: "high", serviceTier: null, })); }); it ('should send fast service tier for fast model selections', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id[effort]@fast", + 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: "effort", + effort: "high", serviceTier: "fast", })); }); diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index 7ee43746..cdabba8d 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 b6aa0f7a..d7e1ef60 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 13403fd0..1bd9899d 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 50260582..85143d17 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 03af5012..b5350b53 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -1,37 +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[medium]@fast", - "name": "GPT-5.2 (medium, fast)", - "description": "Allowed by id. Default effort. Fast service tier." + "modelId": "other-id", + "name": "gpt-5.2", + "description": "Allowed", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [] + } }, { - "modelId": "gpt-5.2[low]", - "name": "GPT-5.2 (low)", - "description": "Allowed by id. Fast 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.2[low]@fast", - "name": "GPT-5.2 (low, fast)", - "description": "Allowed by id. Fast effort. Fast service tier." - }, - { - "modelId": "other-id[medium]", - "name": "gpt-5.2 (medium)", - "description": "Allowed Default effort." - }, - { - "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 c4ab4c54..d35f3cc5 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -55,7 +55,7 @@ "summary": null, "personality": null, "cwd": "cwd", - "effort": "effort", + "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 037e3631..f9dc2ef8 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 f625f0df..0b82c96b 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 217c1125..bd590174 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 32548bee..a8510c25 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 b25094f2..df4d99e6 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 02d8561c..563a5502 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 c7b795ec..d7bc43fe 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -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,6 +93,10 @@ 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" ); @@ -123,7 +129,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, "fetchAvailableModels").mockResolvedValue(models); @@ -133,7 +141,8 @@ describe("Model filtering", () => { await expect(codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", - modelId: "gpt-5.2[medium]@fast", + modelId: "gpt-5.2", + _meta: { serviceTier: "fast" }, })).rejects.toThrow("Unsupported service tier fast for model gpt-5.2"); }); @@ -164,7 +173,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, "fetchAvailableModels").mockResolvedValue(models); @@ -173,10 +184,97 @@ describe("Model filtering", () => { await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); await codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", - modelId: "gpt-5.2[medium]@fast", + modelId: "gpt-5.2", + _meta: { serviceTier: "fast" }, }); expect(codexAcpAgent.getSessionState("session-id").currentModelId) - .toBe("gpt-5.2[medium]@fast"); + .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 a037364c..1760bcdd 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 e0e3e661..2b14c015 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 a1629214..a5050974 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, From 3df76f9f4e1ce147ad454e01a750a7227c977621 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 7 May 2026 13:28:49 +0200 Subject: [PATCH 3/5] fix: review --- src/CodexAcpClient.ts | 23 +++++++--- .../CodexACPAgent/CodexAcpClient.test.ts | 45 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 01f88dfe..88c4f817 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -355,18 +355,31 @@ export class CodexAcpClient { reasoningEffort: ReasoningEffort | null, serviceTier: ServiceTier | null, ): ModelSelection { - const selectedModel = - availableModels.find(m => m.id === modelId) ?? - availableModels.find(m => m.isDefault); + 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.`); } + 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: reasoningEffort ?? selectedModel.defaultReasoningEffort, - currentServiceTier: serviceTier ?? null, + currentReasoningEffort: selectedReasoningEffort, + currentServiceTier: selectedServiceTier, }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 128129e4..86336aa6 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -757,6 +757,51 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + 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", + }); + }); + /** * Sets up a mock fixture with turnStart/awaitTurnCompleted spied on, * and a given session state. Returns the fixture and turnStart spy. From 9e25ce008589776190ce510237e1e4ef490c2e3c Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 7 May 2026 13:38:32 +0200 Subject: [PATCH 4/5] feat: add comment related to serviceTier logic --- src/CodexAcpClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 88c4f817..073ce42a 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -415,6 +415,7 @@ export class CodexAcpClient { cwd: null, 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, }); From 3b4188733917a648405063b0ab6aa08c966d5571 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 7 May 2026 13:58:34 +0200 Subject: [PATCH 5/5] cleanup: remove ModelId --- src/ModelId.ts | 48 ----------------------------------- src/__tests__/ModelId.test.ts | 23 ----------------- 2 files changed, 71 deletions(-) delete mode 100644 src/ModelId.ts delete mode 100644 src/__tests__/ModelId.test.ts diff --git a/src/ModelId.ts b/src/ModelId.ts deleted file mode 100644 index b68d6336..00000000 --- a/src/ModelId.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type {ReasoningEffort, ServiceTier} from "./app-server"; -import type {Model} from "./app-server/v2"; - -/** - * ACP Model ID, combining the base model ID, reasoning effort level, and optional service tier. - * @example - * const id = ModelId.fromString("gpt-5.2[high]"); - * const fastId = ModelId.fromString("gpt-5.2[high]@fast"); - */ -export class ModelId { - private constructor( - public readonly model: string, - public readonly effort: string, - public readonly serviceTier: ServiceTier | null = null - ) {} - - static fromComponents(model: Model, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { - return new ModelId(model.id, effort, serviceTier); - } - - static create(modelId: string, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { - return new ModelId(modelId, effort, serviceTier); - } - - static fromString(modelId: string): ModelId { - const bracketMatch = modelId.match(/^(?[^\[]+)\[(?[^\]]+)](?:@(?.+))?$/); - const model = bracketMatch?.groups?.["model"]; - const effort = bracketMatch?.groups?.["effort"]; - const serviceTier = bracketMatch?.groups?.["serviceTier"] ?? null; - - if (!model || !effort) { - throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort] or modelId[effort]@fast.`); - } - - // The generated app-server ServiceTier type also includes "flex", but ACP model IDs - // only expose Fast variants for now because model/list advertises Fast support. - if (serviceTier !== null && serviceTier !== "fast") { - throw new Error(`Unsupported service tier ${serviceTier} for modelId: ${modelId}.`); - } - - return new ModelId(model, effort, serviceTier); - } - - toString(): string { - const suffix = this.serviceTier === null ? "" : `@${this.serviceTier}`; - return `${this.model}[${this.effort}]${suffix}`; - } -} diff --git a/src/__tests__/ModelId.test.ts b/src/__tests__/ModelId.test.ts deleted file mode 100644 index 276a4d50..00000000 --- a/src/__tests__/ModelId.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {describe, expect, it} from "vitest"; -import {ModelId} from "../ModelId"; - -describe("ModelId", () => { - it("formats and parses normal model IDs", () => { - const modelId = ModelId.create("gpt-5.2", "medium"); - - expect(modelId.toString()).toBe("gpt-5.2[medium]"); - expect(ModelId.fromString("gpt-5.2[medium]")).toEqual(modelId); - }); - - it("formats and parses fast model IDs", () => { - const modelId = ModelId.create("gpt-5.2", "medium", "fast"); - - expect(modelId.toString()).toBe("gpt-5.2[medium]@fast"); - expect(ModelId.fromString("gpt-5.2[medium]@fast")).toEqual(modelId); - }); - - it("rejects unknown service tiers", () => { - expect(() => ModelId.fromString("gpt-5.2[medium]@flex")) - .toThrow("Unsupported service tier flex"); - }); -});