diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index 6b61f6fd9f..cd786a6529 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -1,4 +1,49 @@ -import { ANTHROPIC_API_PROTOCOL, getApiProtocol, OPENAI_API_PROTOCOL, providerIdentifiers } from "../index.js" +import { ANTHROPIC_API_PROTOCOL, OPENAI_API_PROTOCOL, providerIdentifiers } from "../index.js" +import { + getApiProtocol, + OPEN_AI_CODEX_SERVICE_TIER_KEY, + PROVIDER_SETTINGS_KEYS, + providerSettingsSchema, + providerSettingsSchemaDiscriminated, +} from "../provider-settings.js" +import { OpenAiCodexServiceTier, OpenAiServiceTier } from "../model.js" + +describe("OpenAI Codex provider settings", () => { + it("preserves the Fast preference in general and provider-specific schemas", () => { + const settings = { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + } + + expect(providerSettingsSchema.parse(settings)).toEqual(settings) + expect(providerSettingsSchemaDiscriminated.parse(settings)).toEqual(settings) + expect(PROVIDER_SETTINGS_KEYS).toContain(OPEN_AI_CODEX_SERVICE_TIER_KEY) + }) + + it.each([undefined, OpenAiCodexServiceTier.Default])( + "accepts %s as the Standard preference", + (openAiCodexServiceTier) => { + const standardSettings = { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + ...(openAiCodexServiceTier ? { [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTier } : {}), + } + + expect(providerSettingsSchemaDiscriminated.parse(standardSettings)).toEqual(standardSettings) + }, + ) + + it("rejects unsupported service tiers", () => { + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiServiceTier.Flex, + }).success, + ).toBe(false) + }) +}) describe("getApiProtocol", () => { it("preserves API protocol wire values", () => { diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 28a32eecb4..9fbf9e358b 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -70,6 +70,18 @@ export const serviceTiers = [OpenAiServiceTier.Default, OpenAiServiceTier.Flex, export const serviceTierSchema = z.enum(serviceTiers) export type ServiceTier = z.infer +/** + * Service tiers for Codex requests authenticated through a ChatGPT subscription. + */ +export const OpenAiCodexServiceTier = { + Default: "default", + Priority: "priority", +} as const + +export const openAiCodexServiceTiers = [OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.Priority] as const +export const openAiCodexServiceTierSchema = z.enum(openAiCodexServiceTiers) +export type OpenAiCodexServiceTier = z.infer + /** * ModelParameter */ diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index c2286c1dfd..e17cd5ddbc 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -1,6 +1,12 @@ import { z } from "zod" -import { modelInfoSchema, reasoningEffortSettingSchema, verbosityLevelsSchema, serviceTierSchema } from "./model.js" +import { + modelInfoSchema, + openAiCodexServiceTierSchema, + reasoningEffortSettingSchema, + verbosityLevelsSchema, + serviceTierSchema, +} from "./model.js" import { codebaseIndexProviderSchema } from "./codebase-index.js" import { providerIdentifiers, @@ -38,6 +44,7 @@ import { */ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 +export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier" /** * DynamicProvider @@ -279,7 +286,8 @@ const geminiCliSchema = apiModelIdProviderModelSchema.extend({ }) const openAiCodexSchema = apiModelIdProviderModelSchema.extend({ - // No additional settings needed - uses OAuth authentication + // Codex "Fast" mode maps to the Responses API priority service tier. + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTierSchema.optional(), }) const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index c4249f4183..dbe13d576b 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -9,6 +9,7 @@ vitest.mock("@roo-code/telemetry", () => ({ })) import { Anthropic } from "@anthropic-ai/sdk" +import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, SERVICE_TIER_KEY } from "@roo-code/types" import { OpenAiCodexHandler, transformLunaResponsesLiteBody } from "../openai-codex" import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" @@ -76,6 +77,84 @@ describe("OpenAiCodexHandler.getModel", () => { }) describe("OpenAiCodexHandler.createMessage", () => { + afterEach(() => { + vitest.restoreAllMocks() + vitest.unstubAllGlobals() + }) + + it("sends the priority service tier in streaming SDK requests when Fast is selected", async () => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream()) + Reflect.set(handler, "client", { responses: { create: mockCreate } }) + + await drainStream(handler.createMessage("System prompt", [])) + + const [body] = mockCreate.mock.calls[0] + expect(body).toMatchObject({ + stream: true, + [SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + }) + + it.each([ + ["an absent preference", {}], + [ + "an explicit Standard preference from an older profile", + { [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Default }, + ], + ])("omits the service tier in streaming SDK requests for %s", async (_description, serviceTierOptions) => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + ...serviceTierOptions, + } as ConstructorParameters[0]) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream()) + Reflect.set(handler, "client", { responses: { create: mockCreate } }) + + await drainStream(handler.createMessage("System prompt", [])) + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty(SERVICE_TIER_KEY) + }) + + it("preserves the priority service tier in the manual streaming fallback", async () => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + Reflect.set(handler, "client", { + responses: { create: vitest.fn().mockRejectedValue(new Error("SDK unavailable")) }, + }) + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n', + ), + ) + controller.close() + }, + }), + }) + vitest.stubGlobal("fetch", mockFetch) + + await drainStream(handler.createMessage("System prompt", [])) + + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toMatchObject({ + stream: true, + [SERVICE_TIER_KEY]: OpenAiCodexServiceTier.Priority, + }) + }) + it("should skip URL-sourced images in formatFullConversation (only base64 emits input_image)", async () => { const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.1-codex" }) @@ -176,6 +255,40 @@ describe("OpenAiCodexHandler.createMessage", () => { }) }) +describe("OpenAiCodexHandler.completePrompt service tier", () => { + afterEach(() => { + vitest.restoreAllMocks() + vitest.unstubAllGlobals() + }) + + it.each<[string, OpenAiCodexServiceTier | undefined, typeof OpenAiCodexServiceTier.Priority | undefined]>([ + ["Fast", OpenAiCodexServiceTier.Priority, OpenAiCodexServiceTier.Priority], + ["Standard", undefined, undefined], + ])("uses the %s preference in non-streaming requests", async (_mode, configuredTier, expectedTier) => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + ...(configuredTier ? { [OPEN_AI_CODEX_SERVICE_TIER_KEY]: configuredTier } : {}), + }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockFetch = vitest.fn().mockResolvedValue({ + ok: true, + json: vitest.fn().mockResolvedValue({ text: "Complete" }), + }) + vitest.stubGlobal("fetch", mockFetch) + + await expect(handler.completePrompt("Hello")).resolves.toBe("Complete") + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.stream).toBe(false) + if (expectedTier) { + expect(body[SERVICE_TIER_KEY]).toBe(expectedTier) + } else { + expect(body).not.toHaveProperty(SERVICE_TIER_KEY) + } + }) +}) + describe("transformLunaResponsesLiteBody", () => { it("creates the exact Responses Lite body while preserving unrelated fields and reasoning", () => { const tools = [{ type: "function", name: "read_file", parameters: { type: "object" } }] diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 27ddbe19e2..e9bc3bbf5d 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -5,9 +5,12 @@ import OpenAI from "openai" import { type ModelInfo, + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier, openAiCodexDefaultModelId, OpenAiCodexModelId, openAiCodexModels, + SERVICE_TIER_KEY, type ReasoningEffort, type ReasoningEffortExtended, ApiProviderError, @@ -29,6 +32,8 @@ import { t } from "../../i18n" export type OpenAiCodexModel = ReturnType +type OpenAiCodexRequestServiceTier = typeof OpenAiCodexServiceTier.Priority + /** * OpenAI Codex base URL for API requests * Per the implementation guide: requests are routed to chatgpt.com/backend-api/codex @@ -37,6 +42,11 @@ const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex" const LUNA_MODEL_ID = "gpt-5.6-luna" const LUNA_CODEX_VERSION = "0.144.0" +const getOpenAiCodexServiceTier = (options: ApiHandlerOptions): OpenAiCodexRequestServiceTier | undefined => + options[OPEN_AI_CODEX_SERVICE_TIER_KEY] === OpenAiCodexServiceTier.Priority + ? OpenAiCodexServiceTier.Priority + : undefined + function stripInputImageDetail(value: any): any { if (Array.isArray(value)) { return value.map(stripInputImageDetail) @@ -365,6 +375,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion model: string input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }> stream: boolean + [SERVICE_TIER_KEY]?: OpenAiCodexRequestServiceTier reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" } temperature?: number store?: boolean @@ -383,12 +394,14 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Per the implementation guide: Codex backend may reject max_output_tokens // and prompt_cache_retention, so we omit them + const serviceTier = getOpenAiCodexServiceTier(this.options) const body: ResponsesRequestBody = { model: model.id, input: formattedInput, stream: true, store: false, instructions: systemPrompt, + ...(serviceTier ? { [SERVICE_TIER_KEY]: serviceTier } : {}), // Only include encrypted reasoning content when reasoning effort is set ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), ...(reasoningEffort @@ -1261,6 +1274,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } const reasoningEffort = this.getReasoningEffort(model) + const serviceTier = getOpenAiCodexServiceTier(this.options) const baseRequestBody: any = { model: model.id, @@ -1272,6 +1286,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion ], stream: false, store: false, + ...(serviceTier ? { [SERVICE_TIER_KEY]: serviceTier } : {}), ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), } diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index c6bd19c0b1..491ef3b18a 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -2,7 +2,12 @@ import { ExtensionContext } from "vscode" -import type { ProviderSettings } from "@roo-code/types" +import { + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier, + providerIdentifiers, + type ProviderSettings, +} from "@roo-code/types" import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" @@ -451,6 +456,32 @@ describe("ProviderSettingsManager", () => { expect(storedConfig).toEqual(expectedConfig) }) + it.each([OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.Priority] as const)( + "should persist the OpenAI Codex %s speed preference", + async (openAiCodexServiceTier) => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: {} }, + modeApiConfigs: {}, + }), + ) + + await providerSettingsManager.saveConfig("codex", { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTier, + }) + + const storedProfiles = JSON.parse(mockSecrets.store.mock.calls.at(-1)?.[1]) + expect(storedProfiles.apiConfigs.codex).toMatchObject({ + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTier, + }) + }, + ) + it("should only save provider relevant settings", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({