diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d5668c01..5d342205 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -3,8 +3,15 @@ import * as acp from "@agentclientprotocol/sdk"; import type {CodexAppServerClient} from "./CodexAppServerClient"; import {RequestError} from "@agentclientprotocol/sdk"; import open from "open"; -import type {ClientInfo, ServerNotification} from "./app-server"; +import type { + ClientInfo, + ServerNotification, + SetDefaultModelParams, + SetDefaultModelResponse +} from "./app-server"; import type {JsonValue} from "./app-server/serde_json/JsonValue"; +import type {Model} from "./app-server/v2"; +import {ModelId} from "./ModelId"; /** * API for accessing the Codex App Server using ACP requests. @@ -13,12 +20,12 @@ import type {JsonValue} from "./app-server/serde_json/JsonValue"; export class CodexAcpClient { private readonly codexClient: CodexAppServerClient; - private readonly config: JsonObject | null; + private readonly config: JsonObject; private readonly modelProvider: string | null; constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) { this.codexClient = codexClient; - this.config = codexConfig ?? null; + this.config = codexConfig ?? {}; this.modelProvider = modelProvider ?? null; } @@ -71,8 +78,8 @@ export class CodexAcpClient { /** * Returns a new session ID. */ - async newSession(request: acp.NewSessionRequest): Promise { - const response = await this.codexClient.threadStart({ + async newSession(request: acp.NewSessionRequest): Promise { + const threadStartResponse = await this.codexClient.threadStart({ config: this.config, modelProvider: this.modelProvider, model: null, @@ -82,7 +89,16 @@ export class CodexAcpClient { baseInstructions: null, developerInstructions: null, }); - return response.thread.id; + const codexModels = await this.fetchAvailableModels(); + if (codexModels.length === 0) { + throw new Error("Codex did not return any models"); + } + const currentModelId = ModelId.fromThreadResponse(threadStartResponse).toString(); + return { + sessionId: threadStartResponse.thread.id, + currentModelId: currentModelId, + models: codexModels + }; } async sendPrompt(request: acp.PromptRequest, eventHandler: (result: ServerNotification) => void): Promise { @@ -106,6 +122,28 @@ export class CodexAcpClient { await this.codexClient.awaitTurnCompleted(); } + async setModel(params: SetDefaultModelParams): Promise { + return this.codexClient.setModelRequest(params); + } + + private async fetchAvailableModels(): Promise { + const models: Model[] = []; + let cursor: string | null = null; + + do { + const response = await this.codexClient.listModels({ cursor, limit: null }); + models.push(...response.data); + cursor = response.nextCursor; + } while (cursor); + + return models; + } } -export type JsonObject = { [key in string]?: JsonValue } \ No newline at end of file +export type JsonObject = { [key in string]?: JsonValue } + +export type SessionMetadata = { + sessionId: string, + currentModelId: string, + models: Model[] +} diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 583ce9c2..69b2099f 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,12 +1,15 @@ import * as acp from "@agentclientprotocol/sdk"; import {CodexEventHandler} from "./CodexEventHandler"; import {CodexAuthMethods, type CodexAuthRequest} from "./CodexAuthMethod"; -import {RequestError} from "@agentclientprotocol/sdk"; -import {CodexAcpClient} from "./CodexAcpClient"; +import {type ModelInfo, RequestError, type SessionModelState} from "@agentclientprotocol/sdk"; +import {CodexAcpClient, type SessionMetadata} from "./CodexAcpClient"; +import type {Model} from "./app-server/v2"; +import type {ReasoningEffort} from "./app-server"; +import {ModelId} from "./ModelId"; export interface SessionState { - sessionId: string, + sessionMetadata: SessionMetadata; pendingPrompt: AbortController | null; } @@ -52,14 +55,21 @@ export class CodexAcpServer implements acp.Agent { } } - const sessionId = await this.codexAcpClient.newSession(_params); + const sessionMetadata = await this.codexAcpClient.newSession(_params); + const {sessionId, currentModelId, models} = sessionMetadata; this.sessions.set(sessionId, { - sessionId: sessionId, - pendingPrompt: null, + sessionMetadata: sessionMetadata, + pendingPrompt: null }); + const availableModels = this.buildAvailableModels(models); + const sessionModelState: SessionModelState = { + availableModels: availableModels, + currentModelId: currentModelId, + } return { sessionId: sessionId, + models: sessionModelState, }; } @@ -80,6 +90,55 @@ export class CodexAcpServer implements acp.Agent { return {}; } + async setSessionModel(params: acp.SetSessionModelRequest): Promise { + 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 model = sessionState.sessionMetadata.models.find(m => m.id === requestedModelName); + if (!model) throw new Error(`Unknown model ${params.modelId}`); + + const requestedEffortValue = requestedEffort as ReasoningEffort | undefined; + let reasoningEffort: ReasoningEffort; + if (requestedEffortValue) { + const matchedEffort = model.supportedReasoningEfforts.find( + (option) => option.reasoningEffort === requestedEffortValue + )?.reasoningEffort; + + if (!matchedEffort) { + throw new Error(`Unsupported reasoning effort ${requestedEffortValue} for model ${requestedModelName}`); + } + + reasoningEffort = matchedEffort; + } else { + reasoningEffort = model.defaultReasoningEffort; + } + + + await this.codexAcpClient.setModel({ + model: model.model, + reasoningEffort, + }); + sessionState.sessionMetadata.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString(); + + return {}; + } + + + + private buildAvailableModels(models: Model[]): ModelInfo[] { + return models.flatMap((model) => + model.supportedReasoningEfforts.map((effort) => ({ + modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), + name: `${model.displayName} (${effort.reasoningEffort})`, + description: `${model.description} ${effort.description}`, + })) + ); + } + getSessionState(sessionId: string): SessionState { const sessionState = this.sessions.get(sessionId); if (!sessionState) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index df35aad8..3caccea6 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -1,15 +1,15 @@ -import type {MessageConnection, NotificationMessage} from "vscode-jsonrpc/node"; +import type {MessageConnection} from "vscode-jsonrpc/node"; import type { ClientRequest, - EventMsg, InitializeParams, InitializeResponse, - ServerNotification + ServerNotification, SetDefaultModelParams, SetDefaultModelResponse } from "./app-server"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, GetAccountParams, - GetAccountResponse, LoginAccountParams, LoginAccountResponse, LogoutAccountResponse, + GetAccountResponse, LoginAccountParams, LoginAccountResponse, LogoutAccountResponse, ModelListParams, + ModelListResponse, ThreadStartParams, ThreadStartResponse, TurnCompletedNotification, @@ -86,6 +86,15 @@ export class CodexAppServerClient { }); } + + async setModelRequest(params: SetDefaultModelParams): Promise { + return await this.sendRequest({ method: "setDefaultModel", params }); + } + + async listModels(params: ModelListParams = {cursor: null, limit: null}): Promise { + return await this.sendRequest({ method: "model/list", params }); + } + //TODO support removal (leads to duplicated processing of follow-ups) onServerNotification(callback: (event: ServerNotification) => void){ this.notificationHandlers.push(callback); diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 3e1a02e9..e3038eec 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -26,7 +26,7 @@ export class CodexEventHandler { } async handleNotification(notification: ServerNotification) { - const session = new ACPSessionConnection(this.connection, this.sessionState.sessionId); + const session = new ACPSessionConnection(this.connection, this.sessionState.sessionMetadata.sessionId); const updateEvent = await this.createUpdateEvent(notification); if (updateEvent) { await session.update(updateEvent); diff --git a/src/ModelId.ts b/src/ModelId.ts new file mode 100644 index 00000000..ab1abfc5 --- /dev/null +++ b/src/ModelId.ts @@ -0,0 +1,32 @@ +import type {ReasoningEffort} from "./app-server"; +import type {Model, ThreadStartResponse} from "./app-server/v2"; + +export class ModelId { + private constructor( + public readonly model: string, + public readonly effort: string | null // TODO: ThreadStartResponse + ) {} + + static fromComponents(model: Model, effort: ReasoningEffort): ModelId { + return new ModelId(model.id, effort); + } + + static fromThreadResponse(response: ThreadStartResponse): ModelId { + return new ModelId(response.model, response.reasoningEffort); + } + + static fromString(modelId: string): ModelId { + const parts = modelId.split("/"); + const model = parts[0]; + const effort = parts[1] ?? null; + + if (!model) { + throw new Error(`Invalid modelId format: ${modelId}`); + } + return new ModelId(model, effort); + } + + toString(): string { + return this.effort ? `${this.model}/${this.effort}` : this.model; + } +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 4054d3eb..501ae8ef 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1,4 +1,4 @@ -import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'; +import {describe, expect, it, vi, beforeEach} from 'vitest'; import type {CodexAuthRequest} from "../../CodexAuthMethod"; import {createTestFixture, type TestFixture} from "../acp-test-utils"; import type {ServerNotification} from "../../app-server"; @@ -12,7 +12,7 @@ describe('ACP server test', () => { vi.clearAllMocks(); }); - const ignoredFields = ["thread", "cwd", "id", "createdAt", "path", "threadId", "userAgent", "sandbox", "reasoningEffort"]; + const ignoredFields = ["thread", "cwd", "id", "createdAt", "path", "threadId", "userAgent", "sandbox", "reasoningEffort", "conversationId"]; it('should start conversation', async () => { const codexAcpAgent = fixture.getCodexAcpAgent(); @@ -79,7 +79,15 @@ describe('ACP server test', () => { fixture.getCodexAppServerClient().turnStart = vi.fn().mockResolvedValue(undefined); fixture.getCodexAppServerClient().awaitTurnCompleted = vi.fn().mockResolvedValue(undefined); - fixture.getCodexAcpAgent().getSessionState = vi.fn().mockResolvedValue({ pendingPrompt: null, sessionId: "id" }); + const sessionState: SessionState = { + pendingPrompt: null, + sessionMetadata: { + sessionId: "id", + currentModelId: "model-id", + models: [], + } + }; + vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); await codexAcpAgent.prompt({ sessionId: "id", prompt: [{type: "text", text: ""}] }); diff --git a/src/__tests__/CodexACPAgent/data/auth-with-key.json b/src/__tests__/CodexACPAgent/data/auth-with-key.json index 911630ce..8927999f 100644 --- a/src/__tests__/CodexACPAgent/data/auth-with-key.json +++ b/src/__tests__/CodexACPAgent/data/auth-with-key.json @@ -28,7 +28,7 @@ "eventType": "request", "method": "thread/start", "params": { - "config": null, + "config": {}, "modelProvider": null, "model": null, "cwd": "cwd", @@ -47,4 +47,129 @@ "approvalPolicy": "never", "sandbox": "sandbox", "reasoningEffort": "reasoningEffort" +} +{ + "eventType": "request", + "method": "model/list", + "params": { + "cursor": null, + "limit": null + } +} +{ + "eventType": "notification", + "method": "thread/started", + "params": { + "thread": "thread" + }, + "jsonrpc": "2.0" +} +{ + "eventType": "notification", + "method": "codex/event/mcp_startup_complete", + "params": { + "id": "id", + "msg": { + "type": "mcp_startup_complete", + "ready": [], + "failed": [], + "cancelled": [] + }, + "conversationId": "conversationId" + }, + "jsonrpc": "2.0" +} +{ + "eventType": "response", + "data": [ + { + "id": "id", + "model": "gpt-5.1-codex-max", + "displayName": "gpt-5.1-codex-max", + "description": "Latest Codex-optimized flagship for deep and fast reasoning.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Fast responses with lighter reasoning" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex problems" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Extra high reasoning depth for complex problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": true + }, + { + "id": "id", + "model": "gpt-5.1-codex", + "displayName": "gpt-5.1-codex", + "description": "Optimized for codex.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Fastest responses with limited reasoning" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Dynamically adjusts reasoning based on the task" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex or ambiguous problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": false + }, + { + "id": "id", + "model": "gpt-5.1-codex-mini", + "displayName": "gpt-5.1-codex-mini", + "description": "Optimized for codex. Cheaper, faster, but less capable.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Dynamically adjusts reasoning based on the task" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex or ambiguous problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": false + }, + { + "id": "id", + "model": "gpt-5.1", + "displayName": "gpt-5.1", + "description": "Broad world knowledge with strong general reasoning.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Balances speed with some reasoning; useful for straightforward queries and short explanations" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Provides a solid balance of reasoning depth and latency for general-purpose tasks" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex or ambiguous problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": false + } + ], + "nextCursor": null } \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/output-acp-events.json b/src/__tests__/CodexACPAgent/data/output-acp-events.json index a718947f..efd040d7 100644 --- a/src/__tests__/CodexACPAgent/data/output-acp-events.json +++ b/src/__tests__/CodexACPAgent/data/output-acp-events.json @@ -2,6 +2,7 @@ "method": "sessionUpdate", "args": [ { + "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", "content": { @@ -16,6 +17,7 @@ "method": "sessionUpdate", "args": [ { + "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", "content": { @@ -30,6 +32,7 @@ "method": "sessionUpdate", "args": [ { + "sessionId": "id", "update": { "sessionUpdate": "agent_message_chunk", "content": { diff --git a/src/__tests__/CodexACPAgent/data/start-conversation.json b/src/__tests__/CodexACPAgent/data/start-conversation.json index da837eeb..9c783222 100644 --- a/src/__tests__/CodexACPAgent/data/start-conversation.json +++ b/src/__tests__/CodexACPAgent/data/start-conversation.json @@ -17,7 +17,7 @@ "eventType": "request", "method": "thread/start", "params": { - "config": null, + "config": {}, "modelProvider": null, "model": null, "cwd": "cwd", @@ -37,6 +37,131 @@ "sandbox": "sandbox", "reasoningEffort": "reasoningEffort" } +{ + "eventType": "request", + "method": "model/list", + "params": { + "cursor": null, + "limit": null + } +} +{ + "eventType": "notification", + "method": "thread/started", + "params": { + "thread": "thread" + }, + "jsonrpc": "2.0" +} +{ + "eventType": "notification", + "method": "codex/event/mcp_startup_complete", + "params": { + "id": "id", + "msg": { + "type": "mcp_startup_complete", + "ready": [], + "failed": [], + "cancelled": [] + }, + "conversationId": "conversationId" + }, + "jsonrpc": "2.0" +} +{ + "eventType": "response", + "data": [ + { + "id": "id", + "model": "gpt-5.1-codex-max", + "displayName": "gpt-5.1-codex-max", + "description": "Latest Codex-optimized flagship for deep and fast reasoning.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Fast responses with lighter reasoning" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Balances speed and reasoning depth for everyday tasks" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex problems" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Extra high reasoning depth for complex problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": true + }, + { + "id": "id", + "model": "gpt-5.1-codex", + "displayName": "gpt-5.1-codex", + "description": "Optimized for codex.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Fastest responses with limited reasoning" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Dynamically adjusts reasoning based on the task" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex or ambiguous problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": false + }, + { + "id": "id", + "model": "gpt-5.1-codex-mini", + "displayName": "gpt-5.1-codex-mini", + "description": "Optimized for codex. Cheaper, faster, but less capable.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Dynamically adjusts reasoning based on the task" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex or ambiguous problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": false + }, + { + "id": "id", + "model": "gpt-5.1", + "displayName": "gpt-5.1", + "description": "Broad world knowledge with strong general reasoning.", + "supportedReasoningEfforts": [ + { + "reasoningEffort": "reasoningEffort", + "description": "Balances speed with some reasoning; useful for straightforward queries and short explanations" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Provides a solid balance of reasoning depth and latency for general-purpose tasks" + }, + { + "reasoningEffort": "reasoningEffort", + "description": "Maximizes reasoning depth for complex or ambiguous problems" + } + ], + "defaultReasoningEffort": "medium", + "isDefault": false + } + ], + "nextCursor": null +} { "eventType": "request", "method": "turn/start",