Skip to content

Commit 82da39f

Browse files
committed
feat(openai-codex): add Fast priority mode
1 parent d27153a commit 82da39f

25 files changed

Lines changed: 410 additions & 3 deletions

File tree

packages/types/src/__tests__/provider-settings.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,43 @@
1-
import { getApiProtocol } from "../provider-settings.js"
1+
import {
2+
getApiProtocol,
3+
PROVIDER_SETTINGS_KEYS,
4+
providerSettingsSchema,
5+
providerSettingsSchemaDiscriminated,
6+
} from "../provider-settings.js"
7+
8+
describe("OpenAI Codex provider settings", () => {
9+
it("preserves the Fast preference in general and provider-specific schemas", () => {
10+
const settings = {
11+
apiProvider: "openai-codex" as const,
12+
apiModelId: "gpt-5.6-sol",
13+
openAiCodexServiceTier: "priority" as const,
14+
}
15+
16+
expect(providerSettingsSchema.parse(settings)).toEqual(settings)
17+
expect(providerSettingsSchemaDiscriminated.parse(settings)).toEqual(settings)
18+
expect(PROVIDER_SETTINGS_KEYS).toContain("openAiCodexServiceTier")
19+
})
20+
21+
it.each([undefined, "default"])("accepts %s as the Standard preference", (openAiCodexServiceTier) => {
22+
const standardSettings = {
23+
apiProvider: "openai-codex" as const,
24+
apiModelId: "gpt-5.6-sol",
25+
...(openAiCodexServiceTier ? { openAiCodexServiceTier } : {}),
26+
}
27+
28+
expect(providerSettingsSchemaDiscriminated.parse(standardSettings)).toEqual(standardSettings)
29+
})
30+
31+
it("rejects unsupported service tiers", () => {
32+
expect(
33+
providerSettingsSchemaDiscriminated.safeParse({
34+
apiProvider: "openai-codex",
35+
apiModelId: "gpt-5.6-sol",
36+
openAiCodexServiceTier: "flex",
37+
}).success,
38+
).toBe(false)
39+
})
40+
})
241

342
describe("getApiProtocol", () => {
443
describe("Anthropic-style providers", () => {

packages/types/src/provider-settings.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ const geminiCliSchema = apiModelIdProviderModelSchema.extend({
277277
})
278278

279279
const openAiCodexSchema = apiModelIdProviderModelSchema.extend({
280-
// No additional settings needed - uses OAuth authentication
280+
// Codex "Fast" mode maps to the Responses API priority service tier.
281+
openAiCodexServiceTier: z.enum(["default", "priority"]).optional(),
281282
})
282283

283284
const openAiNativeSchema = apiModelIdProviderModelSchema.extend({

src/api/providers/__tests__/openai-codex.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,78 @@ describe("OpenAiCodexHandler.getModel", () => {
7676
})
7777

7878
describe("OpenAiCodexHandler.createMessage", () => {
79+
afterEach(() => {
80+
vitest.restoreAllMocks()
81+
vitest.unstubAllGlobals()
82+
})
83+
84+
it("sends the priority service tier in streaming SDK requests when Fast is selected", async () => {
85+
const handler = new OpenAiCodexHandler({
86+
apiModelId: "gpt-5.6-sol",
87+
openAiCodexServiceTier: "priority",
88+
})
89+
vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
90+
vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
91+
const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream())
92+
Reflect.set(handler, "client", { responses: { create: mockCreate } })
93+
94+
await drainStream(handler.createMessage("System prompt", []))
95+
96+
const [body] = mockCreate.mock.calls[0]
97+
expect(body).toMatchObject({ stream: true, service_tier: "priority" })
98+
})
99+
100+
it.each([
101+
["an absent preference", {}],
102+
["an explicit Standard preference from an older profile", { openAiCodexServiceTier: "default" }],
103+
])("omits the service tier in streaming SDK requests for %s", async (_description, serviceTierOptions) => {
104+
const handler = new OpenAiCodexHandler({
105+
apiModelId: "gpt-5.6-sol",
106+
...serviceTierOptions,
107+
} as ConstructorParameters<typeof OpenAiCodexHandler>[0])
108+
vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
109+
vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
110+
const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream())
111+
Reflect.set(handler, "client", { responses: { create: mockCreate } })
112+
113+
await drainStream(handler.createMessage("System prompt", []))
114+
115+
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("service_tier")
116+
})
117+
118+
it("preserves the priority service tier in the manual streaming fallback", async () => {
119+
const handler = new OpenAiCodexHandler({
120+
apiModelId: "gpt-5.6-sol",
121+
openAiCodexServiceTier: "priority",
122+
})
123+
vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
124+
vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
125+
Reflect.set(handler, "client", {
126+
responses: { create: vitest.fn().mockRejectedValue(new Error("SDK unavailable")) },
127+
})
128+
const mockFetch = vitest.fn().mockResolvedValue({
129+
ok: true,
130+
body: new ReadableStream({
131+
start(controller) {
132+
controller.enqueue(
133+
new TextEncoder().encode(
134+
'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
135+
),
136+
)
137+
controller.close()
138+
},
139+
}),
140+
})
141+
vitest.stubGlobal("fetch", mockFetch)
142+
143+
await drainStream(handler.createMessage("System prompt", []))
144+
145+
expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toMatchObject({
146+
stream: true,
147+
service_tier: "priority",
148+
})
149+
})
150+
79151
it("should skip URL-sourced images in formatFullConversation (only base64 emits input_image)", async () => {
80152
const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.1-codex" })
81153

@@ -176,6 +248,40 @@ describe("OpenAiCodexHandler.createMessage", () => {
176248
})
177249
})
178250

251+
describe("OpenAiCodexHandler.completePrompt service tier", () => {
252+
afterEach(() => {
253+
vitest.restoreAllMocks()
254+
vitest.unstubAllGlobals()
255+
})
256+
257+
it.each([
258+
["Fast", "priority", "priority"],
259+
["Standard", undefined, undefined],
260+
])("uses the %s preference in non-streaming requests", async (_mode, configuredTier, expectedTier) => {
261+
const handler = new OpenAiCodexHandler({
262+
apiModelId: "gpt-5.6-sol",
263+
...(configuredTier ? { openAiCodexServiceTier: configuredTier as "priority" } : {}),
264+
})
265+
vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
266+
vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
267+
const mockFetch = vitest.fn().mockResolvedValue({
268+
ok: true,
269+
json: vitest.fn().mockResolvedValue({ text: "Complete" }),
270+
})
271+
vitest.stubGlobal("fetch", mockFetch)
272+
273+
await expect(handler.completePrompt("Hello")).resolves.toBe("Complete")
274+
275+
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
276+
expect(body.stream).toBe(false)
277+
if (expectedTier) {
278+
expect(body.service_tier).toBe(expectedTier)
279+
} else {
280+
expect(body).not.toHaveProperty("service_tier")
281+
}
282+
})
283+
})
284+
179285
describe("transformLunaResponsesLiteBody", () => {
180286
it("creates the exact Responses Lite body while preserving unrelated fields and reasoning", () => {
181287
const tools = [{ type: "function", name: "read_file", parameters: { type: "object" } }]

src/api/providers/openai-codex.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { t } from "../../i18n"
2929

3030
export type OpenAiCodexModel = ReturnType<OpenAiCodexHandler["getModel"]>
3131

32+
type OpenAiCodexRequestServiceTier = "priority"
33+
3234
/**
3335
* OpenAI Codex base URL for API requests
3436
* Per the implementation guide: requests are routed to chatgpt.com/backend-api/codex
@@ -37,6 +39,10 @@ const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex"
3739
const LUNA_MODEL_ID = "gpt-5.6-luna"
3840
const LUNA_CODEX_VERSION = "0.144.0"
3941

42+
function getOpenAiCodexServiceTier(options: ApiHandlerOptions): OpenAiCodexRequestServiceTier | undefined {
43+
return options.openAiCodexServiceTier === "priority" ? "priority" : undefined
44+
}
45+
4046
function stripInputImageDetail(value: any): any {
4147
if (Array.isArray(value)) {
4248
return value.map(stripInputImageDetail)
@@ -365,6 +371,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
365371
model: string
366372
input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }>
367373
stream: boolean
374+
service_tier?: OpenAiCodexRequestServiceTier
368375
reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" }
369376
temperature?: number
370377
store?: boolean
@@ -383,12 +390,14 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
383390

384391
// Per the implementation guide: Codex backend may reject max_output_tokens
385392
// and prompt_cache_retention, so we omit them
393+
const serviceTier = getOpenAiCodexServiceTier(this.options)
386394
const body: ResponsesRequestBody = {
387395
model: model.id,
388396
input: formattedInput,
389397
stream: true,
390398
store: false,
391399
instructions: systemPrompt,
400+
...(serviceTier ? { service_tier: serviceTier } : {}),
392401
// Only include encrypted reasoning content when reasoning effort is set
393402
...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}),
394403
...(reasoningEffort
@@ -1261,6 +1270,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
12611270
}
12621271

12631272
const reasoningEffort = this.getReasoningEffort(model)
1273+
const serviceTier = getOpenAiCodexServiceTier(this.options)
12641274

12651275
const baseRequestBody: any = {
12661276
model: model.id,
@@ -1272,6 +1282,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
12721282
],
12731283
stream: false,
12741284
store: false,
1285+
...(serviceTier ? { service_tier: serviceTier } : {}),
12751286
...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}),
12761287
}
12771288

src/core/config/__tests__/ProviderSettingsManager.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,32 @@ describe("ProviderSettingsManager", () => {
451451
expect(storedConfig).toEqual(expectedConfig)
452452
})
453453

454+
it.each(["default", "priority"] as const)(
455+
"should persist the OpenAI Codex %s speed preference",
456+
async (openAiCodexServiceTier) => {
457+
mockSecrets.get.mockResolvedValue(
458+
JSON.stringify({
459+
currentApiConfigName: "default",
460+
apiConfigs: { default: {} },
461+
modeApiConfigs: {},
462+
}),
463+
)
464+
465+
await providerSettingsManager.saveConfig("codex", {
466+
apiProvider: "openai-codex",
467+
apiModelId: "gpt-5.6-sol",
468+
openAiCodexServiceTier,
469+
})
470+
471+
const storedProfiles = JSON.parse(mockSecrets.store.mock.calls.at(-1)?.[1])
472+
expect(storedProfiles.apiConfigs.codex).toMatchObject({
473+
apiProvider: "openai-codex",
474+
apiModelId: "gpt-5.6-sol",
475+
openAiCodexServiceTier,
476+
})
477+
},
478+
)
479+
454480
it("should only save provider relevant settings", async () => {
455481
mockSecrets.get.mockResolvedValue(
456482
JSON.stringify({

webview-ui/src/components/settings/providers/OpenAICodex.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ import React from "react"
33
import { type ProviderSettings, openAiCodexDefaultModelId, openAiCodexModels } from "@roo-code/types"
44

55
import { useAppTranslation } from "@src/i18n/TranslationContext"
6-
import { Button } from "@src/components/ui"
6+
import {
7+
Button,
8+
Select,
9+
SelectContent,
10+
SelectItem,
11+
SelectTrigger,
12+
SelectValue,
13+
StandardTooltip,
14+
} from "@src/components/ui"
715
import { vscode } from "@src/utils/vscode"
816

917
import { ModelPicker } from "../ModelPicker"
@@ -66,6 +74,31 @@ export const OpenAICodex: React.FC<OpenAICodexProps> = ({
6674
simplifySettings={simplifySettings}
6775
hidePricing
6876
/>
77+
78+
<div className="flex flex-col gap-1" data-testid="openai-codex-service-tier">
79+
<div className="flex items-center gap-1">
80+
<label className="block font-medium">{t("settings:openAiCodexSpeed.label")}</label>
81+
<StandardTooltip content={t("settings:openAiCodexSpeed.tooltip")}>
82+
<i className="codicon codicon-info text-vscode-descriptionForeground text-xs" />
83+
</StandardTooltip>
84+
</div>
85+
<Select
86+
value={apiConfiguration.openAiCodexServiceTier ?? "default"}
87+
onValueChange={(value) =>
88+
setApiConfigurationField(
89+
"openAiCodexServiceTier",
90+
value as ProviderSettings["openAiCodexServiceTier"],
91+
)
92+
}>
93+
<SelectTrigger className="w-full">
94+
<SelectValue placeholder={t("settings:common.select")} />
95+
</SelectTrigger>
96+
<SelectContent>
97+
<SelectItem value="default">{t("settings:openAiCodexSpeed.standard")}</SelectItem>
98+
<SelectItem value="priority">{t("settings:openAiCodexSpeed.fast")}</SelectItem>
99+
</SelectContent>
100+
</Select>
101+
</div>
69102
</div>
70103
)
71104
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React from "react"
2+
3+
import type { ProviderSettings } from "@roo-code/types"
4+
5+
import { fireEvent, render, screen } from "@/utils/test-utils"
6+
7+
import { OpenAICodex } from "../OpenAICodex"
8+
9+
vi.mock("@src/i18n/TranslationContext", () => ({
10+
useAppTranslation: () => ({
11+
t: (key: string) =>
12+
({
13+
"settings:openAiCodexSpeed.label": "Speed",
14+
"settings:openAiCodexSpeed.tooltip":
15+
"Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.",
16+
"settings:openAiCodexSpeed.standard": "Standard",
17+
"settings:openAiCodexSpeed.fast": "Fast (1.5x speed, increased usage)",
18+
})[key] ?? key,
19+
}),
20+
}))
21+
22+
vi.mock("@src/components/ui", () => ({
23+
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
24+
<button {...props}>{children}</button>
25+
),
26+
Select: ({ children, value, onValueChange }: any) => (
27+
<select aria-label="Speed" value={value} onChange={(event) => onValueChange(event.target.value)}>
28+
{children}
29+
</select>
30+
),
31+
SelectContent: ({ children }: any) => <>{children}</>,
32+
SelectItem: ({ children, value }: any) => <option value={value}>{children}</option>,
33+
SelectTrigger: ({ children }: any) => <>{children}</>,
34+
SelectValue: () => null,
35+
StandardTooltip: ({ children, content }: any) => <span title={content}>{children}</span>,
36+
}))
37+
38+
vi.mock("../../ModelPicker", () => ({
39+
ModelPicker: () => <div data-testid="model-picker" />,
40+
}))
41+
42+
vi.mock("../OpenAICodexRateLimitDashboard", () => ({
43+
OpenAICodexRateLimitDashboard: () => null,
44+
}))
45+
46+
vi.mock("@src/utils/vscode", () => ({
47+
vscode: { postMessage: vi.fn() },
48+
}))
49+
50+
describe("OpenAICodex speed selector", () => {
51+
const renderSelector = (apiConfiguration: ProviderSettings, setApiConfigurationField = vi.fn()) => {
52+
render(<OpenAICodex apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />)
53+
return { setApiConfigurationField, selector: screen.getByRole("combobox", { name: "Speed" }) }
54+
}
55+
56+
it("defaults to Standard and clearly explains the Fast quota trade-off", () => {
57+
const { selector } = renderSelector({ apiProvider: "openai-codex" })
58+
59+
expect(selector).toHaveValue("default")
60+
expect(screen.getByRole("option", { name: "Standard" })).toBeInTheDocument()
61+
expect(screen.getByRole("option", { name: "Fast (1.5x speed, increased usage)" })).toBeInTheDocument()
62+
expect(
63+
screen.getByTitle(
64+
"Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.",
65+
),
66+
).toBeInTheDocument()
67+
})
68+
69+
it("selects Fast from a saved preference and persists changes through the settings callback", () => {
70+
const { selector, setApiConfigurationField } = renderSelector({
71+
apiProvider: "openai-codex",
72+
openAiCodexServiceTier: "priority",
73+
})
74+
75+
expect(selector).toHaveValue("priority")
76+
77+
fireEvent.change(selector, { target: { value: "default" } })
78+
expect(setApiConfigurationField).toHaveBeenLastCalledWith("openAiCodexServiceTier", "default")
79+
80+
fireEvent.change(selector, { target: { value: "priority" } })
81+
expect(setApiConfigurationField).toHaveBeenLastCalledWith("openAiCodexServiceTier", "priority")
82+
})
83+
})

0 commit comments

Comments
 (0)