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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 46 additions & 19 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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,
};
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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(
Expand All @@ -375,12 +397,11 @@ export class CodexAcpClient {
async sendPrompt(
request: acp.PromptRequest,
agentMode: AgentMode,
modelId: ModelId,
modelSelection: ModelSelection,
disableSummary: boolean,
cwd: string,
): Promise<TurnCompletedNotification> {
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({
Expand All @@ -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
Expand Down Expand Up @@ -590,13 +613,17 @@ export type JsonObject = { [key in string]?: JsonValue }
export type SessionMetadata = {
sessionId: string,
currentModelId: string,
currentReasoningEffort: ReasoningEffort,
currentServiceTier: ServiceTier | null,
models: Model[],
}

export type SessionMetadataWithThread = SessionMetadata & {
thread: Thread,
}

export type ModelSelection = Pick<SessionMetadata, "currentModelId" | "currentReasoningEffort" | "currentServiceTier">;

function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] {
return prompt.map((block): UserInput | null => {
switch (block.type) {
Expand Down
91 changes: 56 additions & 35 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -36,6 +35,8 @@ import {
export interface SessionState {
sessionId: string,
currentModelId: string,
currentReasoningEffort: ReasoningEffort,
currentServiceTier: ServiceTier | null,
supportedReasoningEfforts: Array<ReasoningEffortOption>,
supportedInputModalities: Array<InputModality>,
agentMode: AgentMode,
Expand Down Expand Up @@ -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(),
Expand All @@ -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];
Expand Down Expand Up @@ -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;

Expand All @@ -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,
},
}
}

Expand All @@ -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(),
Expand All @@ -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 {
Expand Down Expand Up @@ -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");

Expand All @@ -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") {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -886,3 +896,14 @@ export class CodexAcpServer implements acp.Agent {
function getRequestedMcpServerNames(mcpServers: Array<acp.McpServer>): Array<string> {
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;
}
42 changes: 0 additions & 42 deletions src/ModelId.ts

This file was deleted.

Loading
Loading