feat(ui): show tiered pricing table for OpenAI Native; hide redundant price rows

This commit is contained in:
Hannes Rudolph 2025-09-03 16:07:43 -06:00
parent 59d6e74ec0
commit 7547ff3a4b
8 changed files with 508 additions and 23 deletions

View file

@ -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>

View file

@ -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({

View file

@ -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,

View 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")
})
})

View file

@ -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 = {

View file

@ -496,7 +496,11 @@ const ApiOptions = ({
)}
{selectedProvider === "openai-native" && (
<OpenAI apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
<OpenAI
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
selectedModelInfo={selectedModelInfo}
/>
)}
{selectedProvider === "mistral" && (

View file

@ -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>
)}
</>
)
}

View file

@ -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>
)
})()}
</>
)
}