Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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 { OpenAiCodexServiceTier, 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: OpenAiCodexServiceTier.Priority,
}

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

it.each([undefined, OpenAiCodexServiceTier.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
20 changes: 17 additions & 3 deletions packages/types/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,25 @@ export const verbosityLevelsSchema = z.enum(verbosityLevels)
export type VerbosityLevel = z.infer<typeof verbosityLevelsSchema>

/**
* Service tiers (OpenAI Responses API)
* Service tiers for the public 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}`

/**
* Service tiers for Codex requests authenticated through a ChatGPT subscription.
*/
export enum OpenAiCodexServiceTier {
Default = "default",
Priority = "priority",
}

/**
* 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,
OpenAiCodexServiceTier,
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([OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.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 { OpenAiCodexServiceTier } 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: 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: OpenAiCodexServiceTier.Priority })
})

it.each([
["an absent preference", {}],
[
"an explicit Standard preference from an older profile",
{ openAiCodexServiceTier: 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")
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: 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: 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 +252,40 @@ describe("OpenAiCodexHandler.createMessage", () => {
})
})

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

it.each<[string, OpenAiCodexServiceTier.Priority | undefined, 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 ? { 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
70 changes: 68 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 @@ -122,6 +122,29 @@ describe("OpenAiNativeHandler", () => {
})

describe("createMessage", () => {
it.each([OpenAiServiceTier.Default, OpenAiServiceTier.Priority])(
"should include the selected %s service tier",
async (serviceTier) => {
mockResponsesCreate.mockResolvedValue({
async *[Symbol.asyncIterator]() {},
})
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5.6-sol",
openAiNativeServiceTier: serviceTier,
})

for await (const chunk of handler.createMessage(systemPrompt, messages)) {
void chunk
}

expect(mockResponsesCreate).toHaveBeenCalledWith(
expect.objectContaining({ service_tier: serviceTier }),
expect.any(Object),
)
},
)

it("should handle streaming responses via Responses API", async () => {
// Mock fetch for Responses API fallback
const mockFetch = vitest.fn().mockResolvedValue({
Expand Down Expand Up @@ -221,6 +244,28 @@ describe("OpenAiNativeHandler", () => {
)
})

it.each([OpenAiServiceTier.Default, OpenAiServiceTier.Priority])(
"should include the selected %s service tier",
async (serviceTier) => {
mockResponsesCreate.mockResolvedValue({ output: [] })
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5.6-sol",
openAiNativeServiceTier: serviceTier,
})

await handler.completePrompt("Test prompt")

expect(mockResponsesCreate).toHaveBeenCalledWith(
expect.objectContaining({
stream: false,
service_tier: serviceTier,
}),
expect.any(Object),
)
},
)

it("should handle SDK errors in completePrompt", async () => {
// Mock SDK to throw an error
mockResponsesCreate.mockRejectedValue(new Error("API Error"))
Expand Down Expand Up @@ -332,12 +377,33 @@ 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,
}),
])
})

it("should retain standard pricing for an explicitly selected default tier", () => {
const defaultTierHandler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5.4",
openAiNativeServiceTier: OpenAiServiceTier.Default,
})
const model = defaultTierHandler.getModel()
const normalizeUsage = Reflect.get(defaultTierHandler, "normalizeUsage")

const result = Reflect.apply(normalizeUsage, defaultTierHandler, [
{
input_tokens: 100_000,
output_tokens: 1_000,
cache_read_input_tokens: 20_000,
},
model,
]) as { totalCost: number }

expect(result.totalCost).toBeCloseTo(0.22, 6)
})

it("should return GPT-5.3 Chat model info when selected", () => {
const chatHandler = new OpenAiNativeHandler({
...mockOptions,
Expand Down
Loading
Loading