Skip to content

Commit 86d819f

Browse files
feat: LLM-26878 Add “Fast” mode for Codex-agent
1 parent 7475f5a commit 86d819f

8 files changed

Lines changed: 192 additions & 24 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ export class CodexAcpClient {
394394
cwd: null,
395395
effort: effort,
396396
model: modelId.model,
397+
serviceTier: modelId.serviceTier,
397398
});
398399

399400
// Wait for turn completion

src/CodexAcpServer.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -310,13 +310,16 @@ export class CodexAcpServer implements acp.Agent {
310310
const sessionState = this.sessions.get(params.sessionId);
311311
if (!sessionState) throw new Error(`Session ${params.sessionId} not found`);
312312

313-
const requestedModelId= ModelId.fromString(params.modelId);
313+
const requestedModelId = ModelId.fromString(params.modelId);
314314
const requestedModelName = requestedModelId.model;
315315
const requestedEffort = requestedModelId.effort;
316316

317317
const models = await this.codexAcpClient.fetchAvailableModels();
318318
const model = models.find(m => m.id === requestedModelName);
319319
if (!model) throw new Error(`Unknown model ${params.modelId}`);
320+
if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) {
321+
throw new Error(`Unsupported service tier fast for model ${requestedModelName}`);
322+
}
320323

321324
const requestedEffortValue = requestedEffort as ReasoningEffort | undefined;
322325
let reasoningEffort: ReasoningEffort;
@@ -334,7 +337,7 @@ export class CodexAcpServer implements acp.Agent {
334337
reasoningEffort = model.defaultReasoningEffort;
335338
}
336339

337-
sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString();
340+
sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort, requestedModelId.serviceTier).toString();
338341
sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts;
339342
sessionState.supportedInputModalities = model.inputModalities;
340343

@@ -353,11 +356,24 @@ export class CodexAcpServer implements acp.Agent {
353356
private createModelState(availableModels: Model[], selectedModelId: string): SessionModelState {
354357
const allowedModels = availableModels
355358
.flatMap((model) =>
356-
model.supportedReasoningEfforts.map((effort) => ({
357-
modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(),
358-
name: `${model.displayName} (${effort.reasoningEffort})`,
359-
description: `${model.description} ${effort.description}`,
360-
}))
359+
model.supportedReasoningEfforts.flatMap((effort) => {
360+
const standardModel = {
361+
modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(),
362+
name: `${model.displayName} (${effort.reasoningEffort})`,
363+
description: `${model.description} ${effort.description}`,
364+
};
365+
if (!model.additionalSpeedTiers.includes("fast")) {
366+
return [standardModel];
367+
}
368+
return [
369+
standardModel,
370+
{
371+
modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(),
372+
name: `${model.displayName} (${effort.reasoningEffort}, fast)`,
373+
description: `${model.description} ${effort.description} Fast service tier.`,
374+
},
375+
];
376+
})
361377
);
362378
return {
363379
availableModels: allowedModels,
@@ -812,8 +828,7 @@ export class CodexAcpServer implements acp.Agent {
812828
private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } {
813829
const lastTokenUsage = sessionState.lastTokenUsage;
814830

815-
// Remove the "[reasoning-level]" suffix from currentModelId if present
816-
const modelName = sessionState.currentModelId.replace(/\[.*?]$/, '');
831+
const modelName = ModelId.fromString(sessionState.currentModelId).model;
817832

818833
// FIXME: currently all tokens are reported for the current model
819834
const modelUsage = (lastTokenUsage != null)

src/ModelId.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,48 @@
1-
import type {ReasoningEffort} from "./app-server";
1+
import type {ReasoningEffort, ServiceTier} from "./app-server";
22
import type {Model} from "./app-server/v2";
33

44
/**
5-
* ACP Model ID, combining the base model ID and its reasoning effort level.
5+
* ACP Model ID, combining the base model ID, reasoning effort level, and optional service tier.
66
* @example
77
* const id = ModelId.fromString("gpt-5.2[high]");
8+
* const fastId = ModelId.fromString("gpt-5.2[high]@fast");
89
*/
910
export class ModelId {
1011
private constructor(
1112
public readonly model: string,
12-
public readonly effort: string
13+
public readonly effort: string,
14+
public readonly serviceTier: ServiceTier | null = null
1315
) {}
1416

15-
static fromComponents(model: Model, effort: ReasoningEffort): ModelId {
16-
return new ModelId(model.id, effort);
17+
static fromComponents(model: Model, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId {
18+
return new ModelId(model.id, effort, serviceTier);
1719
}
1820

19-
static create(modelId: string, effort: ReasoningEffort): ModelId {
20-
return new ModelId(modelId, effort);
21+
static create(modelId: string, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId {
22+
return new ModelId(modelId, effort, serviceTier);
2123
}
2224

2325
static fromString(modelId: string): ModelId {
24-
const bracketMatch = modelId.match(/^(?<model>[^\[]+?)(?:\[(?<effort>[^\]]+)\])?$/);
26+
const bracketMatch = modelId.match(/^(?<model>[^\[]+)\[(?<effort>[^\]]+)](?:@(?<serviceTier>.+))?$/);
2527
const model = bracketMatch?.groups?.["model"];
2628
const effort = bracketMatch?.groups?.["effort"];
29+
const serviceTier = bracketMatch?.groups?.["serviceTier"] ?? null;
2730

2831
if (!model || !effort) {
29-
throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort].`);
32+
throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort] or modelId[effort]@fast.`);
3033
}
3134

32-
if (model) {
33-
return new ModelId(model, effort);
35+
// The generated app-server ServiceTier type also includes "flex", but ACP model IDs
36+
// only expose Fast variants for now because model/list advertises Fast support.
37+
if (serviceTier !== null && serviceTier !== "fast") {
38+
throw new Error(`Unsupported service tier ${serviceTier} for modelId: ${modelId}.`);
3439
}
3540

36-
throw new Error(`Invalid modelId format: ${modelId}`);
41+
return new ModelId(model, effort, serviceTier);
3742
}
3843

3944
toString(): string {
40-
return `${this.model}[${this.effort}]`;
45+
const suffix = this.serviceTier === null ? "" : `@${this.serviceTier}`;
46+
return `${this.model}[${this.effort}]${suffix}`;
4147
}
4248
}

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,34 @@ describe('ACP server test', { timeout: 40_000 }, () => {
802802
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: null }));
803803
});
804804

805+
it ('should send null service tier for normal model selections', async () => {
806+
const { mockFixture, turnStartSpy } = setupPromptFixture({
807+
currentModelId: "model-id[effort]",
808+
});
809+
810+
await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] });
811+
812+
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({
813+
model: "model-id",
814+
effort: "effort",
815+
serviceTier: null,
816+
}));
817+
});
818+
819+
it ('should send fast service tier for fast model selections', async () => {
820+
const { mockFixture, turnStartSpy } = setupPromptFixture({
821+
currentModelId: "model-id[effort]@fast",
822+
});
823+
824+
await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] });
825+
826+
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({
827+
model: "model-id",
828+
effort: "effort",
829+
serviceTier: "fast",
830+
}));
831+
});
832+
805833
it ('should disable reasoning.summary when model lacks reasoning', async () => {
806834
const { mockFixture, turnStartSpy } = setupPromptFixture({
807835
account: { type: "chatgpt", email: "test@example.com", planType: "pro" },

src/__tests__/CodexACPAgent/data/model-filtering.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,21 @@
44
"name": "GPT-5.2 (medium)",
55
"description": "Allowed by id. Default effort."
66
},
7+
{
8+
"modelId": "gpt-5.2[medium]@fast",
9+
"name": "GPT-5.2 (medium, fast)",
10+
"description": "Allowed by id. Default effort. Fast service tier."
11+
},
712
{
813
"modelId": "gpt-5.2[low]",
914
"name": "GPT-5.2 (low)",
1015
"description": "Allowed by id. Fast effort."
1116
},
17+
{
18+
"modelId": "gpt-5.2[low]@fast",
19+
"name": "GPT-5.2 (low, fast)",
20+
"description": "Allowed by id. Fast effort. Fast service tier."
21+
},
1222
{
1323
"modelId": "other-id[medium]",
1424
"name": "gpt-5.2 (medium)",

src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"personality": null,
5757
"cwd": "cwd",
5858
"effort": "effort",
59-
"model": "model"
59+
"model": "model",
60+
"serviceTier": null
6061
}
6162
}
6263
{

src/__tests__/CodexACPAgent/model-filtering.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("Model filtering", () => {
2525
supportedReasoningEfforts: efforts,
2626
defaultReasoningEffort: "medium",
2727
supportsPersonality: false,
28-
additionalSpeedTiers: [],
28+
additionalSpeedTiers: ["fast"],
2929
isDefault: false,
3030
inputModalities: []
3131
},
@@ -95,4 +95,88 @@ describe("Model filtering", () => {
9595
"data/model-filtering.json"
9696
);
9797
});
98+
99+
it("rejects fast model selections when the model does not support fast", async () => {
100+
const fixture = createCodexMockTestFixture();
101+
const codexAcpAgent = fixture.getCodexAcpAgent();
102+
const codexAcpClient = fixture.getCodexAcpClient();
103+
104+
const models: Model[] = [
105+
{
106+
id: "gpt-5.2",
107+
model: "gpt-5.2",
108+
upgrade: null,
109+
upgradeInfo: null,
110+
availabilityNux: null,
111+
displayName: "GPT-5.2",
112+
description: "No fast tier.",
113+
hidden: false,
114+
supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}],
115+
defaultReasoningEffort: "medium",
116+
supportsPersonality: false,
117+
additionalSpeedTiers: [],
118+
isDefault: true,
119+
inputModalities: ["text"]
120+
},
121+
];
122+
123+
vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false);
124+
vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({
125+
sessionId: "session-id",
126+
currentModelId: "gpt-5.2[medium]",
127+
models,
128+
});
129+
vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models);
130+
vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
131+
132+
await codexAcpAgent.newSession({ cwd: "", mcpServers: [] });
133+
134+
await expect(codexAcpAgent.unstable_setSessionModel({
135+
sessionId: "session-id",
136+
modelId: "gpt-5.2[medium]@fast",
137+
})).rejects.toThrow("Unsupported service tier fast for model gpt-5.2");
138+
});
139+
140+
it("stores fast model selections when the model supports fast", async () => {
141+
const fixture = createCodexMockTestFixture();
142+
const codexAcpAgent = fixture.getCodexAcpAgent();
143+
const codexAcpClient = fixture.getCodexAcpClient();
144+
145+
const models: Model[] = [
146+
{
147+
id: "gpt-5.2",
148+
model: "gpt-5.2",
149+
upgrade: null,
150+
upgradeInfo: null,
151+
availabilityNux: null,
152+
displayName: "GPT-5.2",
153+
description: "Fast tier.",
154+
hidden: false,
155+
supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}],
156+
defaultReasoningEffort: "medium",
157+
supportsPersonality: false,
158+
additionalSpeedTiers: ["fast"],
159+
isDefault: true,
160+
inputModalities: ["text"]
161+
},
162+
];
163+
164+
vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false);
165+
vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({
166+
sessionId: "session-id",
167+
currentModelId: "gpt-5.2[medium]",
168+
models,
169+
});
170+
vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models);
171+
vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
172+
173+
await codexAcpAgent.newSession({ cwd: "", mcpServers: [] });
174+
await codexAcpAgent.unstable_setSessionModel({
175+
sessionId: "session-id",
176+
modelId: "gpt-5.2[medium]@fast",
177+
});
178+
179+
expect(codexAcpAgent.getSessionState("session-id").currentModelId)
180+
.toBe("gpt-5.2[medium]@fast");
181+
});
98182
});

src/__tests__/ModelId.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {describe, expect, it} from "vitest";
2+
import {ModelId} from "../ModelId";
3+
4+
describe("ModelId", () => {
5+
it("formats and parses normal model IDs", () => {
6+
const modelId = ModelId.create("gpt-5.2", "medium");
7+
8+
expect(modelId.toString()).toBe("gpt-5.2[medium]");
9+
expect(ModelId.fromString("gpt-5.2[medium]")).toEqual(modelId);
10+
});
11+
12+
it("formats and parses fast model IDs", () => {
13+
const modelId = ModelId.create("gpt-5.2", "medium", "fast");
14+
15+
expect(modelId.toString()).toBe("gpt-5.2[medium]@fast");
16+
expect(ModelId.fromString("gpt-5.2[medium]@fast")).toEqual(modelId);
17+
});
18+
19+
it("rejects unknown service tiers", () => {
20+
expect(() => ModelId.fromString("gpt-5.2[medium]@flex"))
21+
.toThrow("Unsupported service tier flex");
22+
});
23+
});

0 commit comments

Comments
 (0)