Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
46 changes: 45 additions & 1 deletion packages/types/src/__tests__/provider-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,48 @@
import { getApiProtocol } from "../provider-settings.js"
import {
getApiProtocol,
PROVIDER_SETTINGS_KEYS,
providerSettingsSchema,
providerSettingsSchemaDiscriminated,
} from "../provider-settings.js"
import { OpenAiServiceTier } from "../model.js"
import { providerIdentifiers } from "../provider-identifiers.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",
openAiCodexServiceTier: OpenAiServiceTier.Priority,
}

expect(providerSettingsSchema.parse(settings)).toEqual(settings)
expect(providerSettingsSchemaDiscriminated.parse(settings)).toEqual(settings)
expect(PROVIDER_SETTINGS_KEYS).toContain("openAiCodexServiceTier")
})

it.each([undefined, OpenAiServiceTier.Default])(
"accepts %s as the Standard preference",
(openAiCodexServiceTier) => {
const standardSettings = {
apiProvider: providerIdentifiers.openaiCodex,
apiModelId: "gpt-5.6-sol",
...(openAiCodexServiceTier ? { openAiCodexServiceTier } : {}),
}

expect(providerSettingsSchemaDiscriminated.parse(standardSettings)).toEqual(standardSettings)
},
)

it("rejects unsupported service tiers", () => {
expect(
providerSettingsSchemaDiscriminated.safeParse({
apiProvider: providerIdentifiers.openaiCodex,
apiModelId: "gpt-5.6-sol",
openAiCodexServiceTier: OpenAiServiceTier.Flex,
}).success,
).toBe(false)
})
})

describe("getApiProtocol", () => {
describe("Anthropic-style providers", () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/types/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ export type VerbosityLevel = z.infer<typeof verbosityLevelsSchema>
/**
* Service tiers (OpenAI Responses API)
*/
export const serviceTiers = ["default", "flex", "priority"] as const
export enum OpenAiServiceTier {
Comment thread
WebMad marked this conversation as resolved.
Default = "default",
Flex = "flex",
Priority = "priority",
}

export const serviceTiers = Object.values(OpenAiServiceTier) as [`${OpenAiServiceTier}`, ...`${OpenAiServiceTier}`[]]
export const serviceTierSchema = z.enum(serviceTiers)
export type ServiceTier = z.infer<typeof serviceTierSchema>
export type ServiceTier = `${OpenAiServiceTier}`

/**
* ModelParameter
Expand Down
11 changes: 9 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,
OpenAiServiceTier,
reasoningEffortSettingSchema,
verbosityLevelsSchema,
serviceTierSchema,
} from "./model.js"
import { codebaseIndexProviderSchema } from "./codebase-index.js"
import {
providerIdentifiers,
Expand Down Expand Up @@ -277,7 +283,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.
openAiCodexServiceTier: z.enum([OpenAiServiceTier.Default, OpenAiServiceTier.Priority]).optional(),
})

const openAiNativeSchema = apiModelIdProviderModelSchema.extend({
Expand Down
110 changes: 110 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 { OpenAiServiceTier } from "@roo-code/types"
import { OpenAiCodexHandler, transformLunaResponsesLiteBody } from "../openai-codex"
import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth"

Expand Down Expand Up @@ -76,6 +77,81 @@ 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",
openAiCodexServiceTier: OpenAiServiceTier.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: OpenAiServiceTier.Priority })
})

it.each([
["an absent preference", {}],
[
"an explicit Standard preference from an older profile",
{ openAiCodexServiceTier: OpenAiServiceTier.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")
Comment thread
WebMad marked this conversation as resolved.
Outdated
})

it("preserves the priority service tier in the manual streaming fallback", async () => {
const handler = new OpenAiCodexHandler({
apiModelId: "gpt-5.6-sol",
openAiCodexServiceTier: OpenAiServiceTier.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: OpenAiServiceTier.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 +252,40 @@ describe("OpenAiCodexHandler.createMessage", () => {
})
})

describe("OpenAiCodexHandler.completePrompt service tier", () => {
afterEach(() => {
vitest.restoreAllMocks()
vitest.unstubAllGlobals()
})

it.each<[string, OpenAiServiceTier.Priority | undefined, OpenAiServiceTier.Priority | undefined]>([
["Fast", OpenAiServiceTier.Priority, OpenAiServiceTier.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 ? { openAiCodexServiceTier: 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).toBe(expectedTier)
} else {
expect(body).not.toHaveProperty("service_tier")
Comment thread
WebMad marked this conversation as resolved.
Outdated
}
})
})

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
4 changes: 2 additions & 2 deletions src/api/providers/__tests__/openai-native-usage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest"
import { OpenAiNativeHandler } from "../openai-native"
import { openAiNativeModels } from "@roo-code/types"
import { OpenAiServiceTier, openAiNativeModels } from "@roo-code/types"

describe("OpenAiNativeHandler - normalizeUsage", () => {
let handler: OpenAiNativeHandler
Expand Down Expand Up @@ -468,7 +468,7 @@ describe("OpenAiNativeHandler - normalizeUsage", () => {
it("should not apply GPT-5.4 long-context pricing to priority tier", () => {
handler = new OpenAiNativeHandler({
openAiNativeApiKey: "test-key",
openAiNativeServiceTier: "priority",
openAiNativeServiceTier: OpenAiServiceTier.Priority,
})

const usage = {
Expand Down
4 changes: 2 additions & 2 deletions src/api/providers/__tests__/openai-native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ vitest.mock("@roo-code/telemetry", () => ({
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import { ApiProviderError } from "@roo-code/types"
import { ApiProviderError, OpenAiServiceTier } from "@roo-code/types"

import { OpenAiNativeHandler } from "../openai-native"
import { ApiHandlerOptions } from "../../../shared/api"
Expand Down Expand Up @@ -332,7 +332,7 @@ describe("OpenAiNativeHandler", () => {
expect(modelInfo.info.longContextPricing).toBeUndefined()
expect(modelInfo.info.tiers).toEqual([
expect.objectContaining({
name: "flex",
name: OpenAiServiceTier.Flex,
Comment thread
WebMad marked this conversation as resolved.
outputPrice: 0.625,
}),
])
Expand Down
11 changes: 11 additions & 0 deletions src/api/providers/openai-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import OpenAI from "openai"

import {
type ModelInfo,
OpenAiServiceTier,
openAiCodexDefaultModelId,
OpenAiCodexModelId,
openAiCodexModels,
Expand All @@ -29,6 +30,8 @@ import { t } from "../../i18n"

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

type OpenAiCodexRequestServiceTier = OpenAiServiceTier.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 +40,9 @@ 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.openAiCodexServiceTier === OpenAiServiceTier.Priority ? OpenAiServiceTier.Priority : undefined

function stripInputImageDetail(value: any): any {
if (Array.isArray(value)) {
return value.map(stripInputImageDetail)
Expand Down Expand Up @@ -365,6 +371,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?: OpenAiCodexRequestServiceTier
reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" }
temperature?: number
store?: boolean
Expand All @@ -383,12 +390,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: serviceTier } : {}),
// Only include encrypted reasoning content when reasoning effort is set
...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}),
...(reasoningEffort
Expand Down Expand Up @@ -1261,6 +1270,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 +1282,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
],
stream: false,
store: false,
...(serviceTier ? { service_tier: serviceTier } : {}),
...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}),
}

Expand Down
7 changes: 4 additions & 3 deletions src/api/providers/openai-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type ReasoningEffort,
type VerbosityLevel,
type ReasoningEffortExtended,
OpenAiServiceTier,
type ServiceTier,
ApiProviderError,
} from "@roo-code/types"
Expand Down Expand Up @@ -369,7 +370,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}),
// Include tier when selected and supported by the model, or when explicitly "default"
...(requestedTier &&
(requestedTier === "default" || allowedTierNames.has(requestedTier)) && {
(requestedTier === OpenAiServiceTier.Default || allowedTierNames.has(requestedTier)) && {
service_tier: requestedTier,
}),
// Enable extended prompt cache retention for models that support it.
Expand Down Expand Up @@ -1418,7 +1419,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
* If no tier or no overrides exist, the original ModelInfo is returned.
*/
private applyServiceTierPricing(info: ModelInfo, tier?: ServiceTier): ModelInfo {
if (!tier || tier === "default") return info
if (!tier || tier === OpenAiServiceTier.Default) return info

// Find the tier with matching name in the tiers array
const tierInfo = info.tiers?.find((t) => t.name === tier)
Expand Down Expand Up @@ -1512,7 +1513,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Include service tier if selected and supported
const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined
const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || [])
if (requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier))) {
if (requestedTier && (requestedTier === OpenAiServiceTier.Default || allowedTierNames.has(requestedTier))) {
requestBody.service_tier = requestedTier
}

Expand Down
Loading
Loading