mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(ui): show tiered pricing table for OpenAI Native; hide redundant price rows
This commit is contained in:
parent
59d6e74ec0
commit
7547ff3a4b
8 changed files with 508 additions and 23 deletions
|
|
@ -28,6 +28,13 @@ export const verbosityLevelsSchema = z.enum(verbosityLevels)
|
|||
|
||||
export type VerbosityLevel = z.infer<typeof verbosityLevelsSchema>
|
||||
|
||||
/**
|
||||
* Service tiers (OpenAI Responses API)
|
||||
*/
|
||||
export const serviceTiers = ["default", "flex", "priority"] as const
|
||||
export const serviceTierSchema = z.enum(serviceTiers)
|
||||
export type ServiceTier = z.infer<typeof serviceTierSchema>
|
||||
|
||||
/**
|
||||
* ModelParameter
|
||||
*/
|
||||
|
|
@ -69,6 +76,10 @@ export const modelInfoSchema = z.object({
|
|||
minTokensPerCachePoint: z.number().optional(),
|
||||
maxCachePoints: z.number().optional(),
|
||||
cachableFields: z.array(z.string()).optional(),
|
||||
/**
|
||||
* Deprecated generic tiers (kept for backward compatibility).
|
||||
* Prefer serviceTierPricing which is keyed by the OpenAI service tier names.
|
||||
*/
|
||||
tiers: z
|
||||
.array(
|
||||
z.object({
|
||||
|
|
@ -80,6 +91,36 @@ export const modelInfoSchema = z.object({
|
|||
}),
|
||||
)
|
||||
.optional(),
|
||||
/**
|
||||
* Which OpenAI service tiers this model supports beyond the Default tier.
|
||||
* If undefined or empty, assume only 'default' is available.
|
||||
*/
|
||||
allowedServiceTiers: z.array(serviceTierSchema).optional(),
|
||||
/**
|
||||
* Pricing overrides per OpenAI service tier. The top-level input/output/cache*
|
||||
* fields represent the Default (standard) tier. When a tier is selected, use
|
||||
* these overrides if present.
|
||||
*/
|
||||
serviceTierPricing: z
|
||||
.object({
|
||||
flex: z
|
||||
.object({
|
||||
inputPrice: z.number().optional(),
|
||||
outputPrice: z.number().optional(),
|
||||
cacheWritesPrice: z.number().optional(),
|
||||
cacheReadsPrice: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
priority: z
|
||||
.object({
|
||||
inputPrice: z.number().optional(),
|
||||
outputPrice: z.number().optional(),
|
||||
cacheWritesPrice: z.number().optional(),
|
||||
cacheReadsPrice: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type ModelInfo = z.infer<typeof modelInfoSchema>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { modelInfoSchema, reasoningEffortWithMinimalSchema, verbosityLevelsSchema } from "./model.js"
|
||||
import { modelInfoSchema, reasoningEffortWithMinimalSchema, verbosityLevelsSchema, serviceTierSchema } from "./model.js"
|
||||
import { codebaseIndexProviderSchema } from "./codebase-index.js"
|
||||
import {
|
||||
anthropicModels,
|
||||
|
|
@ -224,6 +224,9 @@ const geminiCliSchema = apiModelIdProviderModelSchema.extend({
|
|||
const openAiNativeSchema = apiModelIdProviderModelSchema.extend({
|
||||
openAiNativeApiKey: z.string().optional(),
|
||||
openAiNativeBaseUrl: z.string().optional(),
|
||||
// OpenAI Responses API service tier for openai-native provider only.
|
||||
// UI should only expose this when the selected model supports flex/priority.
|
||||
openAiNativeServiceTier: serviceTierSchema.optional(),
|
||||
})
|
||||
|
||||
const mistralSchema = apiModelIdProviderModelSchema.extend({
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ export const openAiNativeModels = {
|
|||
// supportsVerbosity is a new capability; ensure ModelInfo includes it
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
allowedServiceTiers: ["flex", "priority"],
|
||||
serviceTierPricing: {
|
||||
flex: { inputPrice: 0.625, outputPrice: 5.0, cacheReadsPrice: 0.0625 },
|
||||
priority: { inputPrice: 2.5, outputPrice: 20.0, cacheReadsPrice: 0.25 },
|
||||
},
|
||||
},
|
||||
"gpt-5-mini-2025-08-07": {
|
||||
maxTokens: 128000,
|
||||
|
|
@ -46,6 +51,11 @@ export const openAiNativeModels = {
|
|||
description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks",
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
allowedServiceTiers: ["flex", "priority"],
|
||||
serviceTierPricing: {
|
||||
flex: { inputPrice: 0.125, outputPrice: 1.0, cacheReadsPrice: 0.0125 },
|
||||
priority: { inputPrice: 0.45, outputPrice: 3.6, cacheReadsPrice: 0.045 },
|
||||
},
|
||||
},
|
||||
"gpt-5-nano-2025-08-07": {
|
||||
maxTokens: 128000,
|
||||
|
|
@ -60,6 +70,10 @@ export const openAiNativeModels = {
|
|||
description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5",
|
||||
supportsVerbosity: true,
|
||||
supportsTemperature: false,
|
||||
allowedServiceTiers: ["flex"],
|
||||
serviceTierPricing: {
|
||||
flex: { inputPrice: 0.025, outputPrice: 0.2, cacheReadsPrice: 0.0025 },
|
||||
},
|
||||
},
|
||||
"gpt-4.1": {
|
||||
maxTokens: 32_768,
|
||||
|
|
@ -70,6 +84,10 @@ export const openAiNativeModels = {
|
|||
outputPrice: 8,
|
||||
cacheReadsPrice: 0.5,
|
||||
supportsTemperature: true,
|
||||
allowedServiceTiers: ["priority"],
|
||||
serviceTierPricing: {
|
||||
priority: { inputPrice: 3.5, outputPrice: 14.0, cacheReadsPrice: 0.875 },
|
||||
},
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
maxTokens: 32_768,
|
||||
|
|
@ -80,6 +98,10 @@ export const openAiNativeModels = {
|
|||
outputPrice: 1.6,
|
||||
cacheReadsPrice: 0.1,
|
||||
supportsTemperature: true,
|
||||
allowedServiceTiers: ["priority"],
|
||||
serviceTierPricing: {
|
||||
priority: { inputPrice: 0.7, outputPrice: 2.8, cacheReadsPrice: 0.175 },
|
||||
},
|
||||
},
|
||||
"gpt-4.1-nano": {
|
||||
maxTokens: 32_768,
|
||||
|
|
@ -90,6 +112,10 @@ export const openAiNativeModels = {
|
|||
outputPrice: 0.4,
|
||||
cacheReadsPrice: 0.025,
|
||||
supportsTemperature: true,
|
||||
allowedServiceTiers: ["priority"],
|
||||
serviceTierPricing: {
|
||||
priority: { inputPrice: 0.2, outputPrice: 0.8, cacheReadsPrice: 0.05 },
|
||||
},
|
||||
},
|
||||
o3: {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -102,6 +128,11 @@ export const openAiNativeModels = {
|
|||
supportsReasoningEffort: true,
|
||||
reasoningEffort: "medium",
|
||||
supportsTemperature: false,
|
||||
allowedServiceTiers: ["flex", "priority"],
|
||||
serviceTierPricing: {
|
||||
flex: { inputPrice: 1.0, outputPrice: 4.0, cacheReadsPrice: 0.25 },
|
||||
priority: { inputPrice: 3.5, outputPrice: 14.0, cacheReadsPrice: 0.875 },
|
||||
},
|
||||
},
|
||||
"o3-high": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -136,6 +167,11 @@ export const openAiNativeModels = {
|
|||
supportsReasoningEffort: true,
|
||||
reasoningEffort: "medium",
|
||||
supportsTemperature: false,
|
||||
allowedServiceTiers: ["flex", "priority"],
|
||||
serviceTierPricing: {
|
||||
flex: { inputPrice: 0.55, outputPrice: 2.2, cacheReadsPrice: 0.138 },
|
||||
priority: { inputPrice: 2.0, outputPrice: 8.0, cacheReadsPrice: 0.5 },
|
||||
},
|
||||
},
|
||||
"o4-mini-high": {
|
||||
maxTokens: 100_000,
|
||||
|
|
@ -232,6 +268,10 @@ export const openAiNativeModels = {
|
|||
outputPrice: 10,
|
||||
cacheReadsPrice: 1.25,
|
||||
supportsTemperature: true,
|
||||
allowedServiceTiers: ["priority"],
|
||||
serviceTierPricing: {
|
||||
priority: { inputPrice: 4.25, outputPrice: 17.0, cacheReadsPrice: 2.125 },
|
||||
},
|
||||
},
|
||||
"gpt-4o-mini": {
|
||||
maxTokens: 16_384,
|
||||
|
|
@ -242,6 +282,10 @@ export const openAiNativeModels = {
|
|||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.075,
|
||||
supportsTemperature: true,
|
||||
allowedServiceTiers: ["priority"],
|
||||
serviceTierPricing: {
|
||||
priority: { inputPrice: 0.25, outputPrice: 1.0, cacheReadsPrice: 0.125 },
|
||||
},
|
||||
},
|
||||
"codex-mini-latest": {
|
||||
maxTokens: 16_384,
|
||||
|
|
|
|||
165
src/api/providers/__tests__/openai-native-service-tier.spec.ts
Normal file
165
src/api/providers/__tests__/openai-native-service-tier.spec.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// npx vitest run api/providers/__tests__/openai-native-service-tier.spec.ts
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { OpenAiNativeHandler } from "../openai-native"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
// Capture request bodies passed to OpenAI.responses.create
|
||||
const calledBodies: any[] = []
|
||||
// Optional forced tier for mocking server-selected tier regardless of request
|
||||
let forcedTier: string | undefined
|
||||
|
||||
// Helper to build a single "response.completed" event with usage and optional service tier
|
||||
function makeCompletedEvent(serviceTier?: string) {
|
||||
return {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_123",
|
||||
service_tier: serviceTier,
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 100,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
// minimal output for completeness (not used in assertions)
|
||||
output: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Mock OpenAI SDK's Responses API
|
||||
vi.mock("openai", () => {
|
||||
const mockConstructor = vi.fn()
|
||||
return {
|
||||
__esModule: true,
|
||||
default: mockConstructor.mockImplementation(() => ({
|
||||
responses: {
|
||||
create: vi.fn(async (body: any) => {
|
||||
calledBodies.push(body)
|
||||
|
||||
// Non-streaming path used by completePrompt()
|
||||
if (body && body.stream === false) {
|
||||
return {
|
||||
id: "non_stream_resp",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "Non-stream response" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming path: yield a single completed event with usage
|
||||
const resolvedTier = forcedTier ?? body?.service_tier ?? undefined
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield makeCompletedEvent(resolvedTier)
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe("OpenAiNativeHandler - service tier + pricing", () => {
|
||||
beforeEach(() => {
|
||||
calledBodies.length = 0
|
||||
})
|
||||
|
||||
const systemPrompt = "You are helpful."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello!" }] as any },
|
||||
]
|
||||
|
||||
it("includes service_tier=priority for gpt-5 and computes priority pricing", async () => {
|
||||
const handler = new OpenAiNativeHandler({
|
||||
apiModelId: "gpt-5-2025-08-07",
|
||||
openAiNativeApiKey: "test",
|
||||
openAiNativeServiceTier: "priority",
|
||||
} as ApiHandlerOptions)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify service_tier sent
|
||||
expect(calledBodies[0].service_tier).toBe("priority")
|
||||
|
||||
// Verify cost uses priority pricing (input $2.50/M, output $20.00/M)
|
||||
// 1000 in, 100 out -> 0.0025 + 0.002 = 0.0045
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk.totalCost).toBeCloseTo(0.0045, 10)
|
||||
})
|
||||
|
||||
it("omits unsupported 'flex' on gpt-4.1 and uses default pricing", async () => {
|
||||
const handler = new OpenAiNativeHandler({
|
||||
apiModelId: "gpt-4.1",
|
||||
openAiNativeApiKey: "test",
|
||||
// gpt-4.1 only supports priority; 'flex' should be omitted by provider
|
||||
openAiNativeServiceTier: "flex",
|
||||
} as ApiHandlerOptions)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// No service_tier should be present in request body for unsupported tier
|
||||
expect(calledBodies[0].service_tier).toBeUndefined()
|
||||
|
||||
// Default pricing for gpt-4.1: input $2.00/M, output $8.00/M
|
||||
// 1000 in, 100 out -> 0.002 + 0.0008 = 0.0028
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk.totalCost).toBeCloseTo(0.0028, 10)
|
||||
})
|
||||
|
||||
it("uses actual service_tier from API response when none requested (e.g., o3 priority)", async () => {
|
||||
// Simulate server selecting 'priority' even though we did not request a tier.
|
||||
forcedTier = "priority"
|
||||
const handler = new OpenAiNativeHandler({
|
||||
apiModelId: "o3",
|
||||
openAiNativeApiKey: "test",
|
||||
// intentionally no openAiNativeServiceTier requested
|
||||
} as ApiHandlerOptions)
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Body should not request a tier
|
||||
expect(calledBodies[0].service_tier).toBeUndefined()
|
||||
|
||||
// But usage should reflect priority prices for o3: input $3.50/M, output $14.00/M
|
||||
// 1000 in, 100 out -> 0.0035 + 0.0014 = 0.0049
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk.totalCost).toBeCloseTo(0.0049, 10)
|
||||
|
||||
// Reset forced tier
|
||||
forcedTier = undefined
|
||||
})
|
||||
|
||||
it("passes service_tier for non-streaming completePrompt()", async () => {
|
||||
const handler = new OpenAiNativeHandler({
|
||||
apiModelId: "o4-mini",
|
||||
openAiNativeApiKey: "test",
|
||||
openAiNativeServiceTier: "flex",
|
||||
} as ApiHandlerOptions)
|
||||
|
||||
const text = await handler.completePrompt("Say hi")
|
||||
expect(text).toBe("Non-stream response")
|
||||
|
||||
// Last call should be non-streaming and include service_tier:flex
|
||||
const lastBody = calledBodies[calledBodies.length - 1]
|
||||
expect(lastBody.stream).toBe(false)
|
||||
expect(lastBody.service_tier).toBe("flex")
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
type ReasoningEffort,
|
||||
type VerbosityLevel,
|
||||
type ReasoningEffortWithMinimal,
|
||||
type ServiceTier,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
|
@ -37,6 +38,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
private lastResponseId: string | undefined
|
||||
private responseIdPromise: Promise<string | undefined> | undefined
|
||||
private responseIdResolver: ((value: string | undefined) => void) | undefined
|
||||
// Resolved service tier from Responses API (actual tier used by OpenAI)
|
||||
private lastServiceTier: ServiceTier | undefined
|
||||
|
||||
// Event types handled by the shared event processor to avoid duplication
|
||||
private readonly coreHandledEventTypes = new Set<string>([
|
||||
|
|
@ -71,8 +74,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? 0
|
||||
|
||||
// Resolve effective tier: prefer actual tier from response; otherwise requested tier
|
||||
const effectiveTier =
|
||||
this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined
|
||||
const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier)
|
||||
|
||||
const totalCost = calculateApiCostOpenAI(
|
||||
model.info,
|
||||
effectiveInfo,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
cacheWriteTokens || 0,
|
||||
|
|
@ -117,6 +125,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
// Reset resolved tier for this request; will be set from response if present
|
||||
this.lastServiceTier = undefined
|
||||
|
||||
// Use Responses API for ALL models
|
||||
const { verbosity, reasoning } = this.getModel()
|
||||
|
||||
|
|
@ -204,8 +215,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
previous_response_id?: string
|
||||
store?: boolean
|
||||
instructions?: string
|
||||
service_tier?: ServiceTier
|
||||
}
|
||||
|
||||
// Validate requested tier against model support; if not supported, omit.
|
||||
const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined
|
||||
const allowedTiers = model.info.allowedServiceTiers || []
|
||||
|
||||
const body: Gpt5RequestBody = {
|
||||
model: model.id,
|
||||
input: formattedInput,
|
||||
|
|
@ -233,6 +249,11 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
// Use the per-request reserved output computed by Roo (params.maxTokens from getModelParams).
|
||||
...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}),
|
||||
...(requestPreviousResponseId && { previous_response_id: requestPreviousResponseId }),
|
||||
// Include tier when selected and supported by the model, or when explicitly "default"
|
||||
...(requestedTier &&
|
||||
(requestedTier === "default" || allowedTiers.includes(requestedTier)) && {
|
||||
service_tier: requestedTier,
|
||||
}),
|
||||
}
|
||||
|
||||
// Include text.verbosity only when the model explicitly supports it
|
||||
|
|
@ -264,15 +285,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
}
|
||||
} catch (sdkErr: any) {
|
||||
// Check if this is a 400 error about previous_response_id not found
|
||||
// Check if this is a 400 error about previous_response_id not found or invalid service_tier
|
||||
const errorMessage = sdkErr?.message || sdkErr?.error?.message || ""
|
||||
const is400Error = sdkErr?.status === 400 || sdkErr?.response?.status === 400
|
||||
const isPreviousResponseError =
|
||||
errorMessage.includes("Previous response") || errorMessage.includes("not found")
|
||||
const isTierError =
|
||||
(requestBody.service_tier &&
|
||||
(/service[_ ]tier/i.test(errorMessage) ||
|
||||
errorMessage.toLowerCase().includes("unsupported service tier") ||
|
||||
errorMessage.toLowerCase().includes("invalid service tier"))) ||
|
||||
false
|
||||
|
||||
if (is400Error && requestBody.previous_response_id && isPreviousResponseError) {
|
||||
// Log the error and retry without the previous_response_id
|
||||
|
||||
// Remove the problematic previous_response_id and retry
|
||||
const retryRequestBody = { ...requestBody }
|
||||
delete retryRequestBody.previous_response_id
|
||||
|
|
@ -305,6 +330,33 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
}
|
||||
}
|
||||
|
||||
if (is400Error && requestBody.service_tier && isTierError) {
|
||||
// Retry without service_tier
|
||||
const retryRequestBody = { ...requestBody }
|
||||
delete retryRequestBody.service_tier
|
||||
|
||||
try {
|
||||
const retryStream = (await (this.client as any).responses.create(
|
||||
retryRequestBody,
|
||||
)) as AsyncIterable<any>
|
||||
|
||||
if (typeof (retryStream as any)[Symbol.asyncIterator] !== "function") {
|
||||
yield* this.makeGpt5ResponsesAPIRequest(retryRequestBody, model, metadata)
|
||||
return
|
||||
}
|
||||
|
||||
for await (const event of retryStream) {
|
||||
for await (const outChunk of this.processEvent(event, model)) {
|
||||
yield outChunk
|
||||
}
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
yield* this.makeGpt5ResponsesAPIRequest(retryRequestBody, model, metadata)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For other errors, fallback to manual SSE via fetch
|
||||
yield* this.makeGpt5ResponsesAPIRequest(requestBody, model, metadata)
|
||||
}
|
||||
|
|
@ -437,8 +489,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
errorDetails.includes("Previous response") || errorDetails.includes("not found")
|
||||
|
||||
if (response.status === 400 && requestBody.previous_response_id && isPreviousResponseError) {
|
||||
// Log the error and retry without the previous_response_id
|
||||
|
||||
// Remove the problematic previous_response_id and retry
|
||||
const retryRequestBody = { ...requestBody }
|
||||
delete retryRequestBody.previous_response_id
|
||||
|
|
@ -473,6 +523,39 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
return
|
||||
}
|
||||
|
||||
// If invalid or unsupported service_tier, retry without it
|
||||
const isTierError =
|
||||
requestBody.service_tier &&
|
||||
(/service[_ ]tier/i.test(errorDetails) ||
|
||||
errorDetails.toLowerCase().includes("unsupported service tier") ||
|
||||
errorDetails.toLowerCase().includes("invalid service tier"))
|
||||
|
||||
if (response.status === 400 && requestBody.service_tier && isTierError) {
|
||||
const retryRequestBody = { ...requestBody }
|
||||
delete retryRequestBody.service_tier
|
||||
|
||||
const retryResponse = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(retryRequestBody),
|
||||
})
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
throw new Error(`Responses API retry failed (${retryResponse.status})`)
|
||||
}
|
||||
|
||||
if (!retryResponse.body) {
|
||||
throw new Error("Responses API error: No response body from retry request")
|
||||
}
|
||||
|
||||
yield* this.handleStreamResponse(retryResponse.body, model)
|
||||
return
|
||||
}
|
||||
|
||||
// Provide user-friendly error messages based on status code
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
|
|
@ -607,6 +690,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
if (parsed.response?.id) {
|
||||
this.resolveResponseId(parsed.response.id)
|
||||
}
|
||||
// Capture resolved service tier if present
|
||||
if (parsed.response?.service_tier) {
|
||||
this.lastServiceTier = parsed.response.service_tier as ServiceTier
|
||||
}
|
||||
|
||||
// Delegate standard event types to the shared processor to avoid duplication
|
||||
if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) {
|
||||
|
|
@ -898,6 +985,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
if (parsed.response?.id) {
|
||||
this.resolveResponseId(parsed.response.id)
|
||||
}
|
||||
// Capture resolved service tier if present
|
||||
if (parsed.response?.service_tier) {
|
||||
this.lastServiceTier = parsed.response.service_tier as ServiceTier
|
||||
}
|
||||
|
||||
// Check if the done event contains the complete output (as a fallback)
|
||||
if (
|
||||
|
|
@ -1022,6 +1113,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
if (event?.response?.id) {
|
||||
this.resolveResponseId(event.response.id)
|
||||
}
|
||||
// Capture resolved service tier when available
|
||||
if (event?.response?.service_tier) {
|
||||
this.lastServiceTier = event.response.service_tier as ServiceTier
|
||||
}
|
||||
|
||||
// Handle known streaming text deltas
|
||||
if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") {
|
||||
|
|
@ -1112,6 +1207,24 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
return info.reasoningEffort as ReasoningEffortWithMinimal | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shallow-cloned ModelInfo with pricing overridden for the given tier, if available.
|
||||
* 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
|
||||
const tierPricing =
|
||||
(tier === "flex" ? info.serviceTierPricing?.flex : info.serviceTierPricing?.priority) || undefined
|
||||
if (!tierPricing) return info
|
||||
return {
|
||||
...info,
|
||||
inputPrice: tierPricing.inputPrice ?? info.inputPrice,
|
||||
outputPrice: tierPricing.outputPrice ?? info.outputPrice,
|
||||
cacheReadsPrice: tierPricing.cacheReadsPrice ?? info.cacheReadsPrice,
|
||||
cacheWritesPrice: tierPricing.cacheWritesPrice ?? info.cacheWritesPrice,
|
||||
}
|
||||
}
|
||||
|
||||
// Removed isResponsesApiModel method as ALL models now use the Responses API
|
||||
|
||||
override getModel() {
|
||||
|
|
@ -1185,6 +1298,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
|
|||
store: false, // Don't store prompt completions
|
||||
}
|
||||
|
||||
// Include service tier if selected and supported
|
||||
const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined
|
||||
const allowedTiers = model.info.allowedServiceTiers || []
|
||||
if (requestedTier && (requestedTier === "default" || allowedTiers.includes(requestedTier))) {
|
||||
requestBody.service_tier = requestedTier
|
||||
}
|
||||
|
||||
// Add reasoning if supported
|
||||
if (reasoningEffort) {
|
||||
requestBody.reasoning = {
|
||||
|
|
|
|||
|
|
@ -496,7 +496,11 @@ const ApiOptions = ({
|
|||
)}
|
||||
|
||||
{selectedProvider === "openai-native" && (
|
||||
<OpenAI apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
<OpenAI
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
selectedModelInfo={selectedModelInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "mistral" && (
|
||||
|
|
|
|||
|
|
@ -25,7 +25,14 @@ export const ModelInfoView = ({
|
|||
}: ModelInfoViewProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const infoItems = [
|
||||
// Show tiered pricing table for OpenAI Native when model supports non-standard tiers
|
||||
const allowedTiers =
|
||||
(modelInfo?.allowedServiceTiers || []).filter((tier) => tier === "flex" || tier === "priority") ?? []
|
||||
const tierPricing = modelInfo?.serviceTierPricing
|
||||
const shouldShowTierPricingTable = apiProvider === "openai-native" && allowedTiers.length > 0 && !!tierPricing
|
||||
const fmt = (n?: number) => (typeof n === "number" ? `${formatPrice(n)}` : "—")
|
||||
|
||||
const baseInfoItems = [
|
||||
typeof modelInfo?.contextWindow === "number" && modelInfo.contextWindow > 0 && (
|
||||
<>
|
||||
<span className="font-medium">{t("settings:modelInfo.contextWindow")}</span>{" "}
|
||||
|
|
@ -53,6 +60,21 @@ export const ModelInfoView = ({
|
|||
supportsLabel={t("settings:modelInfo.supportsPromptCache")}
|
||||
doesNotSupportLabel={t("settings:modelInfo.noPromptCache")}
|
||||
/>,
|
||||
apiProvider === "gemini" && (
|
||||
<span className="italic">
|
||||
{selectedModelId.includes("pro-preview")
|
||||
? t("settings:modelInfo.gemini.billingEstimate")
|
||||
: t("settings:modelInfo.gemini.freeRequests", {
|
||||
count: selectedModelId && selectedModelId.includes("flash") ? 15 : 2,
|
||||
})}{" "}
|
||||
<VSCodeLink href="https://ai.google.dev/pricing" className="text-sm">
|
||||
{t("settings:modelInfo.gemini.pricingDetails")}
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
||||
const priceInfoItems = [
|
||||
modelInfo?.inputPrice !== undefined && modelInfo.inputPrice > 0 && (
|
||||
<>
|
||||
<span className="font-medium">{t("settings:modelInfo.inputPrice")}:</span>{" "}
|
||||
|
|
@ -77,20 +99,10 @@ export const ModelInfoView = ({
|
|||
{formatPrice(modelInfo.cacheWritesPrice || 0)} / 1M tokens
|
||||
</>
|
||||
),
|
||||
apiProvider === "gemini" && (
|
||||
<span className="italic">
|
||||
{selectedModelId.includes("pro-preview")
|
||||
? t("settings:modelInfo.gemini.billingEstimate")
|
||||
: t("settings:modelInfo.gemini.freeRequests", {
|
||||
count: selectedModelId && selectedModelId.includes("flash") ? 15 : 2,
|
||||
})}{" "}
|
||||
<VSCodeLink href="https://ai.google.dev/pricing" className="text-sm">
|
||||
{t("settings:modelInfo.gemini.pricingDetails")}
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
||||
const infoItems = shouldShowTierPricingTable ? baseInfoItems : [...baseInfoItems, ...priceInfoItems]
|
||||
|
||||
return (
|
||||
<>
|
||||
{modelInfo?.description && (
|
||||
|
|
@ -106,6 +118,62 @@ export const ModelInfoView = ({
|
|||
<div key={index}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shouldShowTierPricingTable && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-vscode-descriptionForeground mb-1">
|
||||
Pricing by service tier (price per 1M tokens)
|
||||
</div>
|
||||
<div className="border border-vscode-dropdown-border rounded-xs overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-vscode-dropdown-background">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-1.5">Tier</th>
|
||||
<th className="text-right px-3 py-1.5">Input</th>
|
||||
<th className="text-right px-3 py-1.5">Output</th>
|
||||
<th className="text-right px-3 py-1.5">Cache reads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-vscode-dropdown-border/60">
|
||||
<td className="px-3 py-1.5">Standard</td>
|
||||
<td className="px-3 py-1.5 text-right">{fmt(modelInfo?.inputPrice)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{fmt(modelInfo?.outputPrice)}</td>
|
||||
<td className="px-3 py-1.5 text-right">{fmt(modelInfo?.cacheReadsPrice)}</td>
|
||||
</tr>
|
||||
{allowedTiers.includes("flex") && (
|
||||
<tr className="border-t border-vscode-dropdown-border/60">
|
||||
<td className="px-3 py-1.5">Flex</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{fmt(tierPricing?.flex?.inputPrice ?? modelInfo?.inputPrice)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{fmt(tierPricing?.flex?.outputPrice ?? modelInfo?.outputPrice)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{fmt(tierPricing?.flex?.cacheReadsPrice ?? modelInfo?.cacheReadsPrice)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{allowedTiers.includes("priority") && (
|
||||
<tr className="border-t border-vscode-dropdown-border/60">
|
||||
<td className="px-3 py-1.5">Priority</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{fmt(tierPricing?.priority?.inputPrice ?? modelInfo?.inputPrice)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{fmt(tierPricing?.priority?.outputPrice ?? modelInfo?.outputPrice)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{fmt(tierPricing?.priority?.cacheReadsPrice ?? modelInfo?.cacheReadsPrice)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@ import { useCallback, useState } from "react"
|
|||
import { Checkbox } from "vscrui"
|
||||
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import type { ProviderSettings } from "@roo-code/types"
|
||||
import type { ModelInfo, ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, StandardTooltip } from "@src/components/ui"
|
||||
|
||||
import { inputEventTransform } from "../transforms"
|
||||
|
||||
type OpenAIProps = {
|
||||
apiConfiguration: ProviderSettings
|
||||
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
|
||||
selectedModelInfo?: ModelInfo
|
||||
}
|
||||
|
||||
export const OpenAI = ({ apiConfiguration, setApiConfigurationField }: OpenAIProps) => {
|
||||
export const OpenAI = ({ apiConfiguration, setApiConfigurationField, selectedModelInfo }: OpenAIProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const [openAiNativeBaseUrlSelected, setOpenAiNativeBaseUrlSelected] = useState(
|
||||
|
|
@ -72,6 +74,44 @@ export const OpenAI = ({ apiConfiguration, setApiConfigurationField }: OpenAIPro
|
|||
{t("settings:providers.getOpenAiApiKey")}
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const allowedTiers = (selectedModelInfo?.allowedServiceTiers || []).filter(
|
||||
(t) => t === "flex" || t === "priority",
|
||||
)
|
||||
if (allowedTiers.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mt-2" data-testid="openai-service-tier">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block font-medium mb-1">Service tier</label>
|
||||
<StandardTooltip content="For faster processing of API requests, try the priority processing service tier. For lower prices with higher latency, try the flex processing tier.">
|
||||
<i className="codicon codicon-info text-vscode-descriptionForeground text-xs" />
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={apiConfiguration.openAiNativeServiceTier || "default"}
|
||||
onValueChange={(value) =>
|
||||
setApiConfigurationField(
|
||||
"openAiNativeServiceTier",
|
||||
value as ProviderSettings["openAiNativeServiceTier"],
|
||||
)
|
||||
}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("settings:common.select")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Standard</SelectItem>
|
||||
{allowedTiers.includes("flex") && <SelectItem value="flex">Flex</SelectItem>}
|
||||
{allowedTiers.includes("priority") && (
|
||||
<SelectItem value="priority">Priority</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue