From 153355051981a524da8292dffca71b9f8394879f Mon Sep 17 00:00:00 2001 From: "@taltas" <6816042+taltas@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:19:18 +0000 Subject: [PATCH 1/4] feat(api): add verified GPT-6 Astra support --- .../types/src/__tests__/openai-models.test.ts | 72 +++++++++ packages/types/src/model.ts | 2 + packages/types/src/providers/openai.ts | 140 +++++++++++++++--- src/api/providers/__tests__/lite-llm.spec.ts | 103 +++++++++++++ .../__tests__/openai-native-usage.spec.ts | 63 ++++++++ .../providers/__tests__/openai-native.spec.ts | 94 ++++++++---- .../fetchers/__tests__/litellm.spec.ts | 76 ++++++++++ src/api/providers/fetchers/litellm.ts | 17 +++ src/api/providers/lite-llm.ts | 53 +++++-- src/api/providers/openai-native.ts | 29 +++- src/eslint-suppressions.json | 2 +- .../components/settings/providers/LiteLLM.tsx | 2 +- .../providers/__tests__/LiteLLM.spec.tsx | 27 ++++ 13 files changed, 613 insertions(+), 67 deletions(-) create mode 100644 packages/types/src/__tests__/openai-models.test.ts diff --git a/packages/types/src/__tests__/openai-models.test.ts b/packages/types/src/__tests__/openai-models.test.ts new file mode 100644 index 0000000000..df05fa433f --- /dev/null +++ b/packages/types/src/__tests__/openai-models.test.ts @@ -0,0 +1,72 @@ +import { openAiNativeDefaultModelId, openAiNativeModels } from "../providers/openai.js" + +describe("OpenAI native models", () => { + it("describes GPT-6 Astra without changing the provider default", () => { + expect(openAiNativeDefaultModelId).toBe("gpt-5.6-sol") + expect(openAiNativeModels["gpt-6-astra"]).toMatchObject({ + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + inputPrice: 10, + cacheWritesPrice: 12.5, + cacheReadsPrice: 1, + outputPrice: 50, + longContextPricing: { + thresholdTokens: 272_000, + inputPriceMultiplier: 2, + outputPriceMultiplier: 1.5, + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + appliesToServiceTiers: ["default", "flex", "priority"], + }, + }) + + expect(openAiNativeModels["gpt-6-astra"].tiers).toEqual([ + { + name: "flex", + contextWindow: 1_050_000, + inputPrice: 5, + outputPrice: 25, + cacheWritesPrice: 6.25, + cacheReadsPrice: 0.5, + }, + { + name: "priority", + contextWindow: 1_050_000, + inputPrice: 20, + outputPrice: 100, + cacheWritesPrice: 25, + cacheReadsPrice: 2, + }, + ]) + }) + + it("uses current GPT-5.6 base pricing and context metadata", () => { + expect(openAiNativeModels["gpt-5.6-sol"]).toMatchObject({ + contextWindow: 1_050_000, + inputPrice: 4, + cacheWritesPrice: 5, + cacheReadsPrice: 0.4, + outputPrice: 20, + }) + expect(openAiNativeModels["gpt-5.6-terra"]).toMatchObject({ + contextWindow: 1_050_000, + inputPrice: 2, + cacheWritesPrice: 2.5, + cacheReadsPrice: 0.2, + outputPrice: 12, + }) + expect(openAiNativeModels["gpt-5.6-luna"]).toMatchObject({ + contextWindow: 1_050_000, + inputPrice: 0.2, + cacheWritesPrice: 0.25, + cacheReadsPrice: 0.02, + outputPrice: 1.2, + }) + }) +}) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index cedf2280b3..3f4081e78d 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -127,6 +127,8 @@ export const modelInfoSchema = z.object({ .optional(), requiredReasoningEffort: z.boolean().optional(), preserveReasoning: z.boolean().optional(), + // Some OpenAI-compatible gateways require a Responses-backed route for tool calls. + requiresResponsesApi: z.boolean().optional(), supportedParameters: z.array(modelParametersSchema).optional(), inputPrice: z.number().optional(), outputPrice: z.number().optional(), diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index acf5649624..009ca04494 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -17,6 +17,49 @@ export const OPENAI_API_PROTOCOL = "openai" export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5.6-sol" export const openAiNativeModels = { + "gpt-6-astra": { + maxTokens: 128000, + contextWindow: 1_050_000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + inputPrice: 10.0, + outputPrice: 50.0, + cacheWritesPrice: 12.5, + cacheReadsPrice: 1.0, + longContextPricing: { + thresholdTokens: 272_000, + inputPriceMultiplier: 2, + outputPriceMultiplier: 1.5, + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + appliesToServiceTiers: ["default", "flex", "priority"], + }, + supportsTemperature: false, + tiers: [ + { + name: "flex", + contextWindow: 1_050_000, + inputPrice: 5.0, + outputPrice: 25.0, + cacheWritesPrice: 6.25, + cacheReadsPrice: 0.5, + }, + { + name: "priority", + contextWindow: 1_050_000, + inputPrice: 20.0, + outputPrice: 100.0, + cacheWritesPrice: 25.0, + cacheReadsPrice: 2.0, + }, + ], + description: "GPT-6 Astra: OpenAI's most capable model for complex reasoning and end-to-end agentic work", + }, "gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1_050_000, @@ -26,21 +69,37 @@ export const openAiNativeModels = { supportsPromptCache: true, supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], reasoningEffort: "medium", - inputPrice: 5.0, - outputPrice: 30.0, - cacheWritesPrice: 6.25, - cacheReadsPrice: 0.5, + inputPrice: 4.0, + outputPrice: 20.0, + cacheWritesPrice: 5.0, + cacheReadsPrice: 0.4, longContextPricing: { thresholdTokens: 272_000, inputPriceMultiplier: 2, outputPriceMultiplier: 1.5, - appliesToServiceTiers: ["default", "flex"], + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + appliesToServiceTiers: ["default", "flex", "priority"], }, supportsVerbosity: true, supportsTemperature: false, tiers: [ - { name: "flex", contextWindow: 1_050_000, inputPrice: 2.5, outputPrice: 15.0, cacheReadsPrice: 0.25 }, - { name: "priority", contextWindow: 1_050_000, inputPrice: 12.5, outputPrice: 75.0, cacheReadsPrice: 1.25 }, + { + name: "flex", + contextWindow: 1_050_000, + inputPrice: 2.0, + outputPrice: 10.0, + cacheWritesPrice: 2.5, + cacheReadsPrice: 0.2, + }, + { + name: "priority", + contextWindow: 1_050_000, + inputPrice: 8.0, + outputPrice: 40.0, + cacheWritesPrice: 10.0, + cacheReadsPrice: 0.8, + }, ], description: "GPT-5.6 Sol: OpenAI's flagship model for frontier reasoning, coding, and agentic workflows", }, @@ -53,40 +112,81 @@ export const openAiNativeModels = { supportsPromptCache: true, supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], reasoningEffort: "medium", - inputPrice: 2.5, - outputPrice: 15.0, - cacheWritesPrice: 3.125, - cacheReadsPrice: 0.25, + inputPrice: 2.0, + outputPrice: 12.0, + cacheWritesPrice: 2.5, + cacheReadsPrice: 0.2, longContextPricing: { thresholdTokens: 272_000, inputPriceMultiplier: 2, outputPriceMultiplier: 1.5, - appliesToServiceTiers: ["default", "flex"], + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + appliesToServiceTiers: ["default", "flex", "priority"], }, supportsVerbosity: true, supportsTemperature: false, tiers: [ - { name: "flex", contextWindow: 1_050_000, inputPrice: 1.25, outputPrice: 7.5, cacheReadsPrice: 0.125 }, - { name: "priority", contextWindow: 1_050_000, inputPrice: 6.25, outputPrice: 37.5, cacheReadsPrice: 0.625 }, + { + name: "flex", + contextWindow: 1_050_000, + inputPrice: 1.0, + outputPrice: 6.0, + cacheWritesPrice: 1.25, + cacheReadsPrice: 0.1, + }, + { + name: "priority", + contextWindow: 1_050_000, + inputPrice: 4.0, + outputPrice: 24.0, + cacheWritesPrice: 5.0, + cacheReadsPrice: 0.4, + }, ], description: "GPT-5.6 Terra: Balanced everyday model with GPT-5.5-competitive performance at 2x lower cost", }, "gpt-5.6-luna": { maxTokens: 128000, - contextWindow: 400000, + contextWindow: 1_050_000, includedTools: ["apply_patch"], excludedTools: ["apply_diff", "write_to_file"], supportsImages: true, supportsPromptCache: true, supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh", "max"], reasoningEffort: "medium", - inputPrice: 1.0, - outputPrice: 6.0, - cacheWritesPrice: 1.25, - cacheReadsPrice: 0.1, + inputPrice: 0.2, + outputPrice: 1.2, + cacheWritesPrice: 0.25, + cacheReadsPrice: 0.02, + longContextPricing: { + thresholdTokens: 272_000, + inputPriceMultiplier: 2, + outputPriceMultiplier: 1.5, + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + appliesToServiceTiers: ["default", "flex", "priority"], + }, supportsVerbosity: true, supportsTemperature: false, - tiers: [{ name: "flex", contextWindow: 400000, inputPrice: 0.5, outputPrice: 3.0, cacheReadsPrice: 0.05 }], + tiers: [ + { + name: "flex", + contextWindow: 1_050_000, + inputPrice: 0.1, + outputPrice: 0.6, + cacheWritesPrice: 0.125, + cacheReadsPrice: 0.01, + }, + { + name: "priority", + contextWindow: 1_050_000, + inputPrice: 0.4, + outputPrice: 2.4, + cacheWritesPrice: 0.5, + cacheReadsPrice: 0.04, + }, + ], description: "GPT-5.6 Luna: The fastest, most affordable member of the GPT-5.6 family", }, "gpt-5.1-codex-max": { diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 6cae37afe7..7749fa7e90 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -46,6 +46,16 @@ vi.mock("../fetchers/modelCache", () => ({ "gpt-5o": { ...litellmDefaultModelInfo, maxTokens: 8192 }, "gpt-5.1": { ...litellmDefaultModelInfo, maxTokens: 8192 }, "gpt-5-mini": { ...litellmDefaultModelInfo, maxTokens: 8192 }, + "gpt-6-astra": { + ...litellmDefaultModelInfo, + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + requiresResponsesApi: true, + }, "gpt-4": { ...litellmDefaultModelInfo, maxTokens: 8192 }, "claude-3-opus": { ...litellmDefaultModelInfo, maxTokens: 8192 }, "llama-3": { ...litellmDefaultModelInfo, maxTokens: 8192 }, @@ -387,6 +397,99 @@ describe("LiteLLMHandler", () => { }) }) + describe("GPT-6 Astra handling", () => { + it("triggers LiteLLM's Responses bridge with safe request parameters", async () => { + handler = new LiteLLMHandler({ + ...mockOptions, + litellmModelId: "gpt-6-astra", + reasoningEffort: "none", + modelTemperature: 0.7, + }) + mockCreate.mockReturnValue({ + withResponse: vi.fn().mockResolvedValue({ + data: asyncStreamFrom([ + { + choices: [{ delta: { content: "Astra response" } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }, + ]), + }), + }) + + await collectStream( + handler.createMessage("You are helpful", [{ role: "user", content: "Hello" }], { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + }, + ], + }), + ) + + const request = mockCreate.mock.calls[0][0] + expect(request).toMatchObject({ + model: "gpt-6-astra", + max_completion_tokens: 128_000, + reasoning_effort: "medium", + tools: [{ type: "function", function: { name: "read_file" } }], + }) + expect(request.max_tokens).toBeUndefined() + expect(request.temperature).toBeUndefined() + }) + + it("uses safe Astra parameters for completePrompt", async () => { + handler = new LiteLLMHandler({ + ...mockOptions, + litellmModelId: "gpt-6-astra", + reasoningEffort: "max", + modelTemperature: 0.7, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { content: "Astra response" } }] }) + + await handler.completePrompt("Hello") + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: "gpt-6-astra", + max_completion_tokens: 128_000, + reasoning_effort: "max", + }) + expect(mockCreate.mock.calls[0][0].max_tokens).toBeUndefined() + expect(mockCreate.mock.calls[0][0].temperature).toBeUndefined() + }) + + it("reports nested LiteLLM cache-write tokens", async () => { + handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gpt-6-astra" }) + mockCreate.mockReturnValue({ + withResponse: vi.fn().mockResolvedValue({ + data: asyncStreamFrom([ + { + choices: [{ delta: { content: "Astra response" } }], + usage: { + prompt_tokens: 100, + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 20, cache_write_tokens: 30 }, + }, + }, + ]), + }), + }) + + const chunks = await collectStream( + handler.createMessage("You are helpful", [{ role: "user", content: "Hello" }]), + ) + + expect(chunks).toContainEqual( + expect.objectContaining({ type: "usage", cacheReadTokens: 20, cacheWriteTokens: 30 }), + ) + }) + }) + describe("Gemini thought signature injection", () => { describe("isGeminiModel detection", () => { it("should detect Gemini 3 models", () => { diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 184f04ec80..8922c2cf9c 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -12,6 +12,8 @@ describe("OpenAiNativeHandler - normalizeUsage", () => { id: "gpt-5.4", info: openAiNativeModels["gpt-5.4"], } + const getGpt6AstraModel = () => + new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-6-astra" }).getModel() beforeEach(() => { handler = new OpenAiNativeHandler({ @@ -105,6 +107,45 @@ describe("OpenAiNativeHandler - normalizeUsage", () => { }) }) + it("should bill nested cache write tokens at the Astra rate", () => { + const usage = { + input_tokens: 100_000, + output_tokens: 1_000, + input_tokens_details: { + cached_tokens: 20_000, + cache_write_tokens: 30_000, + cache_miss_tokens: 50_000, + }, + } + + const result = handler["normalizeUsage"](usage, getGpt6AstraModel()) + + expect(result).toMatchObject({ + inputTokens: 100_000, + outputTokens: 1_000, + cacheReadTokens: 20_000, + cacheWriteTokens: 30_000, + }) + if (!result) throw new Error("Expected usage") + expect(result.totalCost).toBeCloseTo(0.945, 6) + }) + + it("should derive totals from nested Astra cache details", () => { + const result = handler["normalizeUsage"]( + { + output_tokens: 0, + input_tokens_details: { + cached_tokens: 20, + cache_write_tokens: 30, + cache_miss_tokens: 50, + }, + }, + getGpt6AstraModel(), + ) + + expect(result).toMatchObject({ inputTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 30 }) + }) + it("should handle reasoning tokens in output details", () => { const usage = { input_tokens: 100, @@ -400,6 +441,28 @@ describe("OpenAiNativeHandler - normalizeUsage", () => { }) describe("cost calculation", () => { + it.each([ + { tier: OpenAiServiceTier.Default, expectedCost: 4.525 }, + { tier: OpenAiServiceTier.Flex, expectedCost: 2.2625 }, + { tier: OpenAiServiceTier.Priority, expectedCost: 9.05 }, + ])("applies Astra long-context cache pricing for the $tier tier", ({ tier, expectedCost }) => { + handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + openAiNativeServiceTier: tier, + }) + const result = handler["normalizeUsage"]( + { + input_tokens: 300_000, + output_tokens: 1_000, + input_tokens_details: { cached_tokens: 100_000, cache_write_tokens: 50_000 }, + }, + getGpt6AstraModel(), + ) + + if (!result) throw new Error("Expected usage") + expect(result.totalCost).toBeCloseTo(expectedCost, 6) + }) + it("should pass total input tokens to calculateApiCostOpenAI", () => { const usage = { input_tokens: 100, diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 8c3398d443..831ea4979b 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -29,17 +29,17 @@ const serviceTierPricingCases = [ { requestedTier: OpenAiServiceTier.Default, resolvedTier: OpenAiServiceTier.Priority, - expectedCost: 0.00275, + expectedCost: 0.0016, }, { requestedTier: OpenAiServiceTier.Priority, resolvedTier: OpenAiServiceTier.Flex, - expectedCost: 0.00055, + expectedCost: 0.0004, }, { requestedTier: OpenAiServiceTier.Flex, resolvedTier: OpenAiServiceTier.Default, - expectedCost: 0.0011, + expectedCost: 0.0008, }, ] @@ -132,6 +132,53 @@ describe("OpenAiNativeHandler", () => { }) describe("createMessage", () => { + it("shapes GPT-6 Astra requests for Responses tool calling", () => { + const astraHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-6-astra", + reasoningEffort: "none", + modelTemperature: 0.7, + }) + const model = astraHandler.getModel() + const reasoningEffort = astraHandler["getReasoningEffort"](model) + const body = astraHandler["buildRequestBody"](model, [], systemPrompt, undefined, reasoningEffort, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + }, + ], + }) + + expect(body).toMatchObject({ + model: "gpt-6-astra", + max_output_tokens: 128_000, + reasoning: { effort: "medium", summary: "auto" }, + tools: [{ type: "function", name: "read_file", strict: true }], + }) + expect(body.temperature).toBeUndefined() + expect(body.top_p).toBeUndefined() + expect(body.logprobs).toBeUndefined() + expect(body.text).toBeUndefined() + }) + + it.each(["low", "medium", "high", "xhigh", "max"] as const)( + "accepts the supported Astra %s reasoning effort", + (reasoningEffort) => { + const astraHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-6-astra", + reasoningEffort, + }) + expect(astraHandler["getReasoningEffort"](astraHandler.getModel())).toBe(reasoningEffort) + }, + ) + it.each(serviceTiers)("should include the selected %s service tier", async (serviceTier) => { mockResponsesCreate.mockResolvedValue(asyncStreamFrom([])) handler = new OpenAiNativeHandler({ @@ -170,14 +217,9 @@ describe("OpenAiNativeHandler", () => { const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) - expect(chunks).toContainEqual( - expect.objectContaining({ - type: "usage", - inputTokens: 100, - outputTokens: 20, - totalCost: expectedCost, - }), - ) + const usage = chunks.find((chunk) => chunk.type === "usage") + expect(usage).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 20 }) + expect(usage?.totalCost).toBeCloseTo(expectedCost, 10) }, ) @@ -198,10 +240,10 @@ describe("OpenAiNativeHandler", () => { }, { name: "a resolved service tier without a pricing entry", - modelId: "gpt-5.6-luna" as const, + modelId: "gpt-5.3-chat-latest" as const, requestedTier: OpenAiServiceTier.Default, resolvedTier: OpenAiServiceTier.Priority, - expectedCost: 0.088, + expectedCost: 0.1575, }, ])("retains standard pricing for $name", async ({ modelId, requestedTier, resolvedTier, expectedCost }) => { mockResponsesCreate.mockResolvedValue( @@ -267,7 +309,8 @@ describe("OpenAiNativeHandler", () => { 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 })) + const usage = chunks.find((chunk) => chunk.type === "usage") + expect(usage?.totalCost).toBeCloseTo(expectedCost, 10) }, ) @@ -309,14 +352,9 @@ describe("OpenAiNativeHandler", () => { const chunks = await collectStream(handler.createMessage(systemPrompt, messages)) - expect(chunks).toContainEqual( - expect.objectContaining({ - type: "usage", - inputTokens: 100, - outputTokens: 20, - totalCost: expectedCost, - }), - ) + const usage = chunks.find((chunk) => chunk.type === "usage") + expect(usage).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 20 }) + expect(usage?.totalCost).toBeCloseTo(expectedCost, 10) }, ) @@ -448,7 +486,7 @@ describe("OpenAiNativeHandler", () => { mockResponsesCreate.mockResolvedValue({ output: [] }) handler = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5.6-luna", + apiModelId: "gpt-5.3-chat-latest", openAiNativeServiceTier: OpenAiServiceTier.Priority, }) @@ -961,7 +999,7 @@ describe("OpenAiNativeHandler", () => { ) }) - it("should support minimal reasoning effort for GPT-5", async () => { + it("should replace an unsupported GPT-5.1 reasoning effort with the model default", async () => { // Mock fetch for Responses API const mockFetch = vitest.fn().mockResolvedValue({ ok: true, @@ -994,11 +1032,11 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // With minimal reasoning effort, the model should pass it through + // GPT-5.1 does not support minimal, so use the catalog's medium default. expect(mockFetch).toHaveBeenCalledWith( "https://api.openai.com/v1/responses", expect.objectContaining({ - body: expect.stringContaining('"effort":"minimal"'), + body: expect.stringContaining('"effort":"medium"'), }), ) }) @@ -1132,7 +1170,7 @@ describe("OpenAiNativeHandler", () => { expect(parsedBody.max_output_tokens).toBeDefined() }) - it("should support both verbosity and reasoning effort together for GPT-5", async () => { + it("should support verbosity while normalizing an unsupported GPT-5 effort", async () => { // Mock fetch for Responses API const mockFetch = vitest.fn().mockResolvedValue({ ok: true, @@ -1176,7 +1214,7 @@ describe("OpenAiNativeHandler", () => { const body3 = (mockFetch.mock.calls[0][1] as any).body as string const parsedBody = JSON.parse(body3) expect(parsedBody.model).toBe("gpt-5.1") - expect(parsedBody.reasoning?.effort).toBe("minimal") + expect(parsedBody.reasoning?.effort).toBe("medium") expect(parsedBody.reasoning?.summary).toBe("auto") expect(parsedBody.text?.verbosity).toBe("high") // GPT-5 models don't include temperature diff --git a/src/api/providers/fetchers/__tests__/litellm.spec.ts b/src/api/providers/fetchers/__tests__/litellm.spec.ts index aa398ca0ce..a13a930b84 100644 --- a/src/api/providers/fetchers/__tests__/litellm.spec.ts +++ b/src/api/providers/fetchers/__tests__/litellm.spec.ts @@ -242,6 +242,82 @@ describe("getLiteLLMModels", () => { }) }) + it("marks GPT-6 Astra aliases for the documented Responses bridge", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + model_name: "astra-for-zoo", + model_info: { + max_output_tokens: 128_000, + max_input_tokens: 922_000, + supports_vision: true, + supports_prompt_caching: true, + input_cost_per_token: 0.00001, + output_cost_per_token: 0.00005, + cache_creation_input_token_cost: 0.0000125, + cache_read_input_token_cost: 0.000001, + }, + litellm_params: { model: "openai/responses/gpt-6-astra" }, + }, + ], + }, + }) + + const result = await getLiteLLMModels("test-api-key", "http://localhost:4000") + + expect(result["astra-for-zoo"]).toMatchObject({ + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + requiresResponsesApi: true, + inputPrice: 10, + outputPrice: 50, + cacheWritesPrice: 12.5, + cacheReadsPrice: 1, + }) + }) + + it("does not mark a Chat Completions Astra route as Responses-backed", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + model_name: "gpt-6-astra", + model_info: { + max_output_tokens: 128_000, + max_input_tokens: 922_000, + supports_vision: true, + supports_prompt_caching: true, + }, + litellm_params: { model: "openai/gpt-6-astra" }, + }, + { + model_name: "astra-uppercase-route", + model_info: { + max_output_tokens: 128_000, + max_input_tokens: 922_000, + supports_vision: true, + supports_prompt_caching: true, + }, + litellm_params: { model: "OpenAI/Responses/gpt-6-astra" }, + }, + ], + }, + }) + + const result = await getLiteLLMModels("test-api-key", "http://localhost:4000") + + expect(result["gpt-6-astra"]).not.toHaveProperty("requiresResponsesApi") + expect(result["gpt-6-astra"]).not.toHaveProperty("supportsReasoningEffort") + expect(result["astra-uppercase-route"]).not.toHaveProperty("requiresResponsesApi") + }) + it("makes request without authorization header when no API key provided", async () => { const mockResponse = { data: { diff --git a/src/api/providers/fetchers/litellm.ts b/src/api/providers/fetchers/litellm.ts index 83373e5163..7ebb754ed6 100644 --- a/src/api/providers/fetchers/litellm.ts +++ b/src/api/providers/fetchers/litellm.ts @@ -41,6 +41,8 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise if (!modelName || !modelInfo || !litellmModelName) continue + const isGpt6Astra = litellmModelName === "openai/responses/gpt-6-astra" + // LiteLLM's /v1/model/info never reports reasoning capability flags, so infer // preserveReasoning from explicit model ids in either the alias or routed model name. const preservesReasoning = @@ -61,6 +63,21 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise cacheReadsPrice: modelInfo.cache_read_input_token_cost ? modelInfo.cache_read_input_token_cost * 1000000 : undefined, + ...(isGpt6Astra && { + contextWindow: 1_050_000, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + requiresResponsesApi: true, + longContextPricing: { + thresholdTokens: 272_000, + inputPriceMultiplier: 2, + outputPriceMultiplier: 1.5, + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + }, + }), ...(preservesReasoning && { preserveReasoning: true }), description: `${modelName} via LiteLLM proxy`, } diff --git a/src/api/providers/lite-llm.ts b/src/api/providers/lite-llm.ts index b2989127c9..4e5721ed59 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -1,7 +1,13 @@ import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only -import { litellmDefaultModelId, litellmDefaultModelInfo, providerIdentifiers } from "@roo-code/types" +import { + type ModelInfo, + type ReasoningEffortExtended, + litellmDefaultModelId, + litellmDefaultModelInfo, + providerIdentifiers, +} from "@roo-code/types" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -42,6 +48,17 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa return /\bgpt-?5(?!\d)/i.test(modelId) } + private getReasoningEffort(info: ModelInfo): ReasoningEffortExtended | undefined { + const supported = info.supportsReasoningEffort + if (!Array.isArray(supported)) return undefined + + const configured = this.options.reasoningEffort + if (configured && configured !== "disable" && supported.includes(configured)) return configured + + const fallback = info.reasoningEffort + return fallback && supported.includes(fallback) ? fallback : undefined + } + /** * Detect if the model is a Gemini model that requires thought signature handling. * Gemini 3 models validate thought signatures for tool/function calling steps. @@ -136,7 +153,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa let systemMessage: OpenAI.Chat.ChatCompletionMessageParam let enhancedMessages: OpenAI.Chat.ChatCompletionMessageParam[] - if (this.options.litellmUsePromptCache && info.supportsPromptCache) { + if (this.options.litellmUsePromptCache && info.supportsPromptCache && !info.requiresResponsesApi) { // Create system message with cache control in the proper format systemMessage = { role: "system", @@ -199,7 +216,8 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa const maxTokens: number | undefined = info.maxTokens ?? undefined // Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens - const isGPT5Model = this.isGpt5(modelId) + const usesMaxCompletionTokens = this.isGpt5(modelId) || info.requiresResponsesApi + const reasoningEffort = this.getReasoningEffort(info) // For Gemini models with native protocol: inject fake reasoning.encrypted block for tool calls // This is required when switching from other models to Gemini to satisfy API validation. @@ -222,15 +240,18 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, } + if (reasoningEffort) { + ;(requestOptions as { reasoning_effort?: ReasoningEffortExtended }).reasoning_effort = reasoningEffort + } - // GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter - if (isGPT5Model && maxTokens) { + // Newer OpenAI models require max_completion_tokens instead of the deprecated max_tokens parameter. + if (usesMaxCompletionTokens && maxTokens) { requestOptions.max_completion_tokens = maxTokens } else if (maxTokens) { requestOptions.max_tokens = maxTokens } - if (this.supportsTemperature(modelId)) { + if (info.supportsTemperature !== false && this.supportsTemperature(modelId)) { requestOptions.temperature = this.options.modelTemperature ?? 0 } @@ -288,7 +309,10 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa // Extract cache-related information if available // LiteLLM may use different field names for cache tokens const cacheWriteTokens = - lastUsage.cache_creation_input_tokens || (lastUsage as any).prompt_cache_miss_tokens || 0 + lastUsage.cache_creation_input_tokens || + lastUsage.prompt_tokens_details?.cache_write_tokens || + (lastUsage as any).prompt_cache_miss_tokens || + 0 const cacheReadTokens = lastUsage.prompt_tokens_details?.cached_tokens || (lastUsage as any).cache_read_input_tokens || @@ -326,20 +350,24 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa const { id: modelId, info } = await this.fetchModel() // Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens - const isGPT5Model = this.isGpt5(modelId) + const usesMaxCompletionTokens = this.isGpt5(modelId) || info.requiresResponsesApi + const reasoningEffort = this.getReasoningEffort(info) try { const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { model: modelId, messages: [{ role: "user", content: prompt }], } + if (reasoningEffort) { + ;(requestOptions as { reasoning_effort?: ReasoningEffortExtended }).reasoning_effort = reasoningEffort + } - if (this.supportsTemperature(modelId)) { + if (info.supportsTemperature !== false && this.supportsTemperature(modelId)) { requestOptions.temperature = this.options.modelTemperature ?? 0 } - // GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter - if (isGPT5Model && info.maxTokens) { + // Newer OpenAI models require max_completion_tokens instead of the deprecated max_tokens parameter. + if (usesMaxCompletionTokens && info.maxTokens) { requestOptions.max_completion_tokens = info.maxTokens } else if (info.maxTokens) { requestOptions.max_tokens = info.maxTokens @@ -359,4 +387,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa // LiteLLM usage may include an extra field for Anthropic use cases. interface LiteLLMUsage extends OpenAI.CompletionUsage { cache_creation_input_tokens?: number + prompt_tokens_details?: OpenAI.CompletionUsage["prompt_tokens_details"] & { + cache_write_tokens?: number + } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index a919e2c82b..e8d23a0c68 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -122,11 +122,17 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const hasCacheMissTokens = typeof inputDetails?.cache_miss_tokens === "number" const cachedFromDetails = hasCachedTokens ? inputDetails.cached_tokens : 0 const missFromDetails = hasCacheMissTokens ? inputDetails.cache_miss_tokens : 0 + const writesFromDetails = + typeof inputDetails?.cache_write_tokens === "number" ? inputDetails.cache_write_tokens : 0 // If total input tokens are missing but we have details, derive from them let totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0 - if (totalInputTokens === 0 && inputDetails && (cachedFromDetails > 0 || missFromDetails > 0)) { - totalInputTokens = cachedFromDetails + missFromDetails + if ( + totalInputTokens === 0 && + inputDetails && + (cachedFromDetails > 0 || missFromDetails > 0 || writesFromDetails > 0) + ) { + totalInputTokens = cachedFromDetails + missFromDetails + writesFromDetails } const totalOutputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0 @@ -134,7 +140,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Note: missFromDetails is NOT used as fallback for cache writes // Cache miss tokens represent tokens that weren't found in cache (part of input) // Cache write tokens represent tokens being written to cache for future use - const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0 + const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? writesFromDetails const cacheReadTokens = usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0 @@ -1386,9 +1392,20 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortExtended | undefined { - // Single source of truth: user setting overrides, else model default (from types). - const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) - return selected && selected !== "disable" ? (selected as any) : undefined + const supported = model.info.supportsReasoningEffort + const configured = this.options.reasoningEffort + const fallback = model.info.reasoningEffort + + if (configured === "disable" && !model.info.requiredReasoningEffort) return undefined + if (this.options.enableReasoningEffort === false && !model.info.requiredReasoningEffort) return undefined + + if (Array.isArray(supported)) { + if (configured && configured !== "disable" && supported.includes(configured)) return configured + return fallback && supported.includes(fallback) ? fallback : undefined + } + + const selected = configured === "disable" ? fallback : (configured ?? fallback) + return selected } /** diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0706dbe6fb..a34d6c8cd1 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -396,7 +396,7 @@ }, "api/providers/openai-native.ts": { "@typescript-eslint/no-explicit-any": { - "count": 31 + "count": 28 } }, "api/providers/openai.ts": { diff --git a/webview-ui/src/components/settings/providers/LiteLLM.tsx b/webview-ui/src/components/settings/providers/LiteLLM.tsx index 5f3b7dc27b..c3cde87ceb 100644 --- a/webview-ui/src/components/settings/providers/LiteLLM.tsx +++ b/webview-ui/src/components/settings/providers/LiteLLM.tsx @@ -190,7 +190,7 @@ export const LiteLLM = ({ {(() => { const selectedModelId = apiConfiguration.litellmModelId || litellmDefaultModelId const selectedModel = routerModels?.litellm?.[selectedModelId] - if (selectedModel?.supportsPromptCache) { + if (selectedModel?.supportsPromptCache && !selectedModel.requiresResponsesApi) { return (