Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
15 changes: 12 additions & 3 deletions packages/types/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,21 @@ export const verbosityLevelsSchema = z.enum(verbosityLevels)

export type VerbosityLevel = z.infer<typeof verbosityLevelsSchema>

/** Serialized service tier field used in provider request payloads and responses. */
export const SERVICE_TIER_KEY = "service_tier"

/**
* 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.
Outdated
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
17 changes: 9 additions & 8 deletions src/api/providers/__tests__/bedrock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
BEDROCK_1M_CONTEXT_MODEL_IDS,
BEDROCK_SERVICE_TIER_MODEL_IDS,
bedrockModels,
SERVICE_TIER_KEY,
ApiProviderError,
} from "@roo-code/types"

Expand Down Expand Up @@ -1233,10 +1234,10 @@ describe("AwsBedrockHandler", () => {
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any

// service_tier should be at the top level of the payload
expect(commandArg.service_tier).toBe("PRIORITY")
expect(commandArg[SERVICE_TIER_KEY]).toBe("PRIORITY")
// service_tier should NOT be in additionalModelRequestFields
if (commandArg.additionalModelRequestFields) {
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined()
}
})

Expand All @@ -1263,10 +1264,10 @@ describe("AwsBedrockHandler", () => {
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any

// service_tier should be at the top level of the payload
expect(commandArg.service_tier).toBe("FLEX")
expect(commandArg[SERVICE_TIER_KEY]).toBe("FLEX")
// service_tier should NOT be in additionalModelRequestFields
if (commandArg.additionalModelRequestFields) {
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined()
}
})

Expand Down Expand Up @@ -1294,9 +1295,9 @@ describe("AwsBedrockHandler", () => {
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any

// Service tier should NOT be included for unsupported models (at top level or in additionalModelRequestFields)
expect(commandArg.service_tier).toBeUndefined()
expect(commandArg[SERVICE_TIER_KEY]).toBeUndefined()
if (commandArg.additionalModelRequestFields) {
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined()
}
})

Expand All @@ -1323,9 +1324,9 @@ describe("AwsBedrockHandler", () => {
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any

// Service tier should NOT be included when not specified (at top level or in additionalModelRequestFields)
expect(commandArg.service_tier).toBeUndefined()
expect(commandArg[SERVICE_TIER_KEY]).toBeUndefined()
if (commandArg.additionalModelRequestFields) {
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
expect(commandArg.additionalModelRequestFields[SERVICE_TIER_KEY]).toBeUndefined()
}
})
})
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
259 changes: 257 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, SERVICE_TIER_KEY, serviceTiers } from "@roo-code/types"

import { OpenAiNativeHandler } from "../openai-native"
import { ApiHandlerOptions } from "../../../shared/api"
Expand All @@ -22,6 +22,24 @@ import { Package } from "../../../shared/package"
// Mock OpenAI client - now everything uses Responses API
const mockResponsesCreate = vitest.fn()

const serviceTierPricingCases = [
{
requestedTier: OpenAiServiceTier.Default,
resolvedTier: OpenAiServiceTier.Priority,
expectedCost: 0.00275,
},
{
requestedTier: OpenAiServiceTier.Priority,
resolvedTier: OpenAiServiceTier.Flex,
expectedCost: 0.00055,
},
{
requestedTier: OpenAiServiceTier.Flex,
resolvedTier: OpenAiServiceTier.Default,
expectedCost: 0.0011,
},
]

vitest.mock("openai", () => {
return {
__esModule: true,
Expand Down Expand Up @@ -122,6 +140,156 @@ describe("OpenAiNativeHandler", () => {
})

describe("createMessage", () => {
it.each(serviceTiers)("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_KEY]: serviceTier }),
expect.any(Object),
)
})

it.each(serviceTierPricingCases)(
"prices SDK stream usage using resolved $resolvedTier tier instead of requested $requestedTier tier",
async ({ requestedTier, resolvedTier, expectedCost }) => {
mockResponsesCreate.mockResolvedValue({
async *[Symbol.asyncIterator]() {
yield {
type: "response.done",
response: {
[SERVICE_TIER_KEY]: resolvedTier,
usage: { input_tokens: 100, output_tokens: 20 },
},
}
},
})
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5.6-sol",
openAiNativeServiceTier: requestedTier,
})

const chunks = []
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
chunks.push(chunk)
}

expect(chunks).toContainEqual(
expect.objectContaining({
type: "usage",
inputTokens: 100,
outputTokens: 20,
totalCost: expectedCost,
}),
)
},
)

it.each(serviceTierPricingCases)(
"requests $requestedTier but prices manual SSE fallback usage using OpenAI's resolved $resolvedTier tier",
async ({ requestedTier, resolvedTier, expectedCost }) => {
mockResponsesCreate.mockRejectedValue(new Error("SDK not available"))
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({
type: "response.done",
response: {
[SERVICE_TIER_KEY]: resolvedTier,
usage: { input_tokens: 100, output_tokens: 20 },
},
})}\n\n`,
),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as typeof fetch
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5.6-sol",
openAiNativeServiceTier: requestedTier,
})

const chunks = []
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
chunks.push(chunk)
}

const [, request] = mockFetch.mock.calls[0]
expect(JSON.parse(request.body)).toMatchObject({ [SERVICE_TIER_KEY]: requestedTier })
expect(chunks).toContainEqual(expect.objectContaining({ type: "usage", totalCost: expectedCost }))
},
)

it.each(serviceTierPricingCases)(
"captures resolved $resolvedTier tier from a manual SSE completion event when $requestedTier was requested",
async ({ requestedTier, resolvedTier, expectedCost }) => {
mockResponsesCreate.mockRejectedValue(new Error("SDK not available"))
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({
type: "response.completed",
response: { [SERVICE_TIER_KEY]: resolvedTier },
})}\n\n`,
),
)
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({
type: "response.usage",
usage: { input_tokens: 100, output_tokens: 20 },
})}\n\n`,
),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as typeof fetch
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5.6-sol",
openAiNativeServiceTier: requestedTier,
})

const chunks = []
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
chunks.push(chunk)
}

expect(chunks).toContainEqual(
expect.objectContaining({
type: "usage",
inputTokens: 100,
outputTokens: 20,
totalCost: expectedCost,
}),
)
},
)

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 +389,50 @@ describe("OpenAiNativeHandler", () => {
)
})

it.each(serviceTiers)("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_KEY]: serviceTier,
}),
expect.any(Object),
)
})

it("should omit the service tier when none is configured", async () => {
mockResponsesCreate.mockResolvedValue({ output: [] })

await handler.completePrompt("Test prompt")

const [request] = mockResponsesCreate.mock.calls[0]
expect(request.stream).toBe(false)
expect(request).not.toHaveProperty(SERVICE_TIER_KEY)
})

it("should omit a configured service tier that the model does not support", async () => {
mockResponsesCreate.mockResolvedValue({ output: [] })
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5.6-luna",
openAiNativeServiceTier: OpenAiServiceTier.Priority,
})

await handler.completePrompt("Test prompt")

const [request] = mockResponsesCreate.mock.calls[0]
expect(request.stream).toBe(false)
expect(request).not.toHaveProperty(SERVICE_TIER_KEY)
})

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 +544,55 @@ describe("OpenAiNativeHandler", () => {
expect(modelInfo.info.longContextPricing).toBeUndefined()
expect(modelInfo.info.tiers).toEqual([
expect.objectContaining({
name: "flex",
name: OpenAiServiceTier.Flex,
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 retain standard pricing when no service tier is selected", () => {
const model = handler.getModel()
const applyServiceTierPricing = Reflect.get(handler, "applyServiceTierPricing")

const result = Reflect.apply(applyServiceTierPricing, handler, [model.info])

expect(result).toBe(model.info)
})

it("should retain standard pricing when the resolved service tier has no pricing entry", () => {
const model = handler.getModel()
const infoWithoutTiers = { ...model.info, tiers: undefined }
const applyServiceTierPricing = Reflect.get(handler, "applyServiceTierPricing")

const result = Reflect.apply(applyServiceTierPricing, handler, [
infoWithoutTiers,
OpenAiServiceTier.Priority,
])

expect(result).toBe(infoWithoutTiers)
Comment thread
WebMad marked this conversation as resolved.
Outdated
})

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