Skip to content
Merged
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
47 changes: 46 additions & 1 deletion packages/types/src/__tests__/provider-settings.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/types/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ export const serviceTiers = [OpenAiServiceTier.Default, OpenAiServiceTier.Flex,
export const serviceTierSchema = z.enum(serviceTiers)
export type ServiceTier = z.infer<typeof serviceTierSchema>

/**
* 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<typeof openAiCodexServiceTierSchema>

/**
* ModelParameter
*/
Expand Down
12 changes: 10 additions & 2 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -38,6 +44,7 @@ import {
*/

export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier"

/**
* DynamicProvider
Expand Down Expand Up @@ -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({
Expand Down
113 changes: 113 additions & 0 deletions src/api/providers/__tests__/openai-codex.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<typeof OpenAiCodexHandler>[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" })

Expand Down Expand Up @@ -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" } }]
Expand Down
15 changes: 15 additions & 0 deletions src/api/providers/openai-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +32,8 @@ import { t } from "../../i18n"

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

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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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"] } : {}),
}

Expand Down
33 changes: 32 additions & 1 deletion src/core/config/__tests__/ProviderSettingsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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({
Expand Down
Loading