Skip to content

Commit 9271d62

Browse files
feat: LLM-26878 Add “Fast” mode for Codex-agent
1 parent 2b98246 commit 9271d62

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
@@ -398,6 +398,7 @@ export class CodexAcpClient {
398398
cwd: null,
399399
effort: effort,
400400
model: modelId.model,
401+
serviceTier: modelId.serviceTier,
401402
});
402403

403404
// Wait for turn completion

src/CodexAcpServer.ts

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

299-
const requestedModelId= ModelId.fromString(params.modelId);
299+
const requestedModelId = ModelId.fromString(params.modelId);
300300
const requestedModelName = requestedModelId.model;
301301
const requestedEffort = requestedModelId.effort;
302302

303303
const models = await this.codexAcpClient.fetchAvailableModels();
304304
const model = models.find(m => m.id === requestedModelName);
305305
if (!model) throw new Error(`Unknown model ${params.modelId}`);
306+
if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) {
307+
throw new Error(`Unsupported service tier fast for model ${requestedModelName}`);
308+
}
306309

307310
const requestedEffortValue = requestedEffort as ReasoningEffort | undefined;
308311
let reasoningEffort: ReasoningEffort;
@@ -320,7 +323,7 @@ export class CodexAcpServer implements acp.Agent {
320323
reasoningEffort = model.defaultReasoningEffort;
321324
}
322325

323-
sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString();
326+
sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort, requestedModelId.serviceTier).toString();
324327
sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts;
325328
sessionState.supportedInputModalities = model.inputModalities;
326329

@@ -339,11 +342,24 @@ export class CodexAcpServer implements acp.Agent {
339342
private createModelState(availableModels: Model[], selectedModelId: string): SessionModelState {
340343
const allowedModels = availableModels
341344
.flatMap((model) =>
342-
model.supportedReasoningEfforts.map((effort) => ({
343-
modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(),
344-
name: `${model.displayName} (${effort.reasoningEffort})`,
345-
description: `${model.description} ${effort.description}`,
346-
}))
345+
model.supportedReasoningEfforts.flatMap((effort) => {
346+
const standardModel = {
347+
modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(),
348+
name: `${model.displayName} (${effort.reasoningEffort})`,
349+
description: `${model.description} ${effort.description}`,
350+
};
351+
if (!model.additionalSpeedTiers.includes("fast")) {
352+
return [standardModel];
353+
}
354+
return [
355+
standardModel,
356+
{
357+
modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(),
358+
name: `${model.displayName} (${effort.reasoningEffort}, fast)`,
359+
description: `${model.description} ${effort.description} Fast service tier.`,
360+
},
361+
];
362+
})
347363
);
348364
return {
349365
availableModels: allowedModels,
@@ -798,8 +814,7 @@ export class CodexAcpServer implements acp.Agent {
798814
private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } {
799815
const lastTokenUsage = sessionState.lastTokenUsage;
800816

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

804819
// FIXME: currently all tokens are reported for the current model
805820
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
@@ -738,6 +738,34 @@ describe('ACP server test', { timeout: 40_000 }, () => {
738738
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: null }));
739739
});
740740

741+
it ('should send null service tier for normal model selections', async () => {
742+
const { mockFixture, turnStartSpy } = setupPromptFixture({
743+
currentModelId: "model-id[effort]",
744+
});
745+
746+
await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] });
747+
748+
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({
749+
model: "model-id",
750+
effort: "effort",
751+
serviceTier: null,
752+
}));
753+
});
754+
755+
it ('should send fast service tier for fast model selections', async () => {
756+
const { mockFixture, turnStartSpy } = setupPromptFixture({
757+
currentModelId: "model-id[effort]@fast",
758+
});
759+
760+
await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] });
761+
762+
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({
763+
model: "model-id",
764+
effort: "effort",
765+
serviceTier: "fast",
766+
}));
767+
});
768+
741769
it ('should disable reasoning.summary when model lacks reasoning', async () => {
742770
const { mockFixture, turnStartSpy } = setupPromptFixture({
743771
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
@@ -58,7 +58,8 @@
5858
"collaborationMode": null,
5959
"cwd": "cwd",
6060
"effort": "effort",
61-
"model": "model"
61+
"model": "model",
62+
"serviceTier": null
6263
}
6364
}
6465
{

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)