From 7547ff3a4bafb8d529856c1ce99a04e8ba0c14f1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 3 Sep 2025 16:07:43 -0600 Subject: [PATCH] feat(ui): show tiered pricing table for OpenAI Native; hide redundant price rows --- packages/types/src/model.ts | 41 +++++ packages/types/src/provider-settings.ts | 5 +- packages/types/src/providers/openai.ts | 44 +++++ .../openai-native-service-tier.spec.ts | 165 ++++++++++++++++++ src/api/providers/openai-native.ts | 132 +++++++++++++- .../src/components/settings/ApiOptions.tsx | 6 +- .../src/components/settings/ModelInfoView.tsx | 94 ++++++++-- .../components/settings/providers/OpenAI.tsx | 44 ++++- 8 files changed, 508 insertions(+), 23 deletions(-) create mode 100644 src/api/providers/__tests__/openai-native-service-tier.spec.ts diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 6786e91583..c26293b976 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -28,6 +28,13 @@ export const verbosityLevelsSchema = z.enum(verbosityLevels) export type VerbosityLevel = z.infer +/** + * Service tiers (OpenAI Responses API) + */ +export const serviceTiers = ["default", "flex", "priority"] as const +export const serviceTierSchema = z.enum(serviceTiers) +export type ServiceTier = z.infer + /** * 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 diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 090dfe6693..ae0d6002e5 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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({ diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index 59e5c481ef..37d6dc800a 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -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, diff --git a/src/api/providers/__tests__/openai-native-service-tier.spec.ts b/src/api/providers/__tests__/openai-native-service-tier.spec.ts new file mode 100644 index 0000000000..5235112581 --- /dev/null +++ b/src/api/providers/__tests__/openai-native-service-tier.spec.ts @@ -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") + }) +}) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 9e6e6192f4..9e5511912a 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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 | 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([ @@ -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 + + 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 = { diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 80ecd75ae4..a147ca1b86 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -496,7 +496,11 @@ const ApiOptions = ({ )} {selectedProvider === "openai-native" && ( - + )} {selectedProvider === "mistral" && ( diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index 5091fb1a68..7705bcf0a1 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -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 && ( <> {t("settings:modelInfo.contextWindow")}{" "} @@ -53,6 +60,21 @@ export const ModelInfoView = ({ supportsLabel={t("settings:modelInfo.supportsPromptCache")} doesNotSupportLabel={t("settings:modelInfo.noPromptCache")} />, + apiProvider === "gemini" && ( + + {selectedModelId.includes("pro-preview") + ? t("settings:modelInfo.gemini.billingEstimate") + : t("settings:modelInfo.gemini.freeRequests", { + count: selectedModelId && selectedModelId.includes("flash") ? 15 : 2, + })}{" "} + + {t("settings:modelInfo.gemini.pricingDetails")} + + + ), + ].filter(Boolean) + + const priceInfoItems = [ modelInfo?.inputPrice !== undefined && modelInfo.inputPrice > 0 && ( <> {t("settings:modelInfo.inputPrice")}:{" "} @@ -77,20 +99,10 @@ export const ModelInfoView = ({ {formatPrice(modelInfo.cacheWritesPrice || 0)} / 1M tokens ), - apiProvider === "gemini" && ( - - {selectedModelId.includes("pro-preview") - ? t("settings:modelInfo.gemini.billingEstimate") - : t("settings:modelInfo.gemini.freeRequests", { - count: selectedModelId && selectedModelId.includes("flash") ? 15 : 2, - })}{" "} - - {t("settings:modelInfo.gemini.pricingDetails")} - - - ), ].filter(Boolean) + const infoItems = shouldShowTierPricingTable ? baseInfoItems : [...baseInfoItems, ...priceInfoItems] + return ( <> {modelInfo?.description && ( @@ -106,6 +118,62 @@ export const ModelInfoView = ({
{item}
))} + + {shouldShowTierPricingTable && ( +
+
+ Pricing by service tier (price per 1M tokens) +
+
+ + + + + + + + + + + + + + + + + {allowedTiers.includes("flex") && ( + + + + + + + )} + {allowedTiers.includes("priority") && ( + + + + + + + )} + +
TierInputOutputCache reads
Standard{fmt(modelInfo?.inputPrice)}{fmt(modelInfo?.outputPrice)}{fmt(modelInfo?.cacheReadsPrice)}
Flex + {fmt(tierPricing?.flex?.inputPrice ?? modelInfo?.inputPrice)} + + {fmt(tierPricing?.flex?.outputPrice ?? modelInfo?.outputPrice)} + + {fmt(tierPricing?.flex?.cacheReadsPrice ?? modelInfo?.cacheReadsPrice)} +
Priority + {fmt(tierPricing?.priority?.inputPrice ?? modelInfo?.inputPrice)} + + {fmt(tierPricing?.priority?.outputPrice ?? modelInfo?.outputPrice)} + + {fmt(tierPricing?.priority?.cacheReadsPrice ?? modelInfo?.cacheReadsPrice)} +
+
+
+ )} ) } diff --git a/webview-ui/src/components/settings/providers/OpenAI.tsx b/webview-ui/src/components/settings/providers/OpenAI.tsx index e2f7857fe0..666d786e1f 100644 --- a/webview-ui/src/components/settings/providers/OpenAI.tsx +++ b/webview-ui/src/components/settings/providers/OpenAI.tsx @@ -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")} )} + + {(() => { + const allowedTiers = (selectedModelInfo?.allowedServiceTiers || []).filter( + (t) => t === "flex" || t === "priority", + ) + if (allowedTiers.length === 0) return null + + return ( +
+
+ + + + +
+ + +
+ ) + })()} ) }