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-codex.ts b/packages/types/src/providers/openai-codex.ts index 2caf00cd09..6aec090fbe 100644 --- a/packages/types/src/providers/openai-codex.ts +++ b/packages/types/src/providers/openai-codex.ts @@ -24,6 +24,22 @@ export const openAiCodexDefaultModelId: OpenAiCodexModelId = "gpt-5.6-sol" * Costs are 0 as they are covered by the subscription. */ export const openAiCodexModels = { + "gpt-6-astra": { + maxTokens: 128000, + contextWindow: 872000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "low", + inputPrice: 0, + outputPrice: 0, + supportsVerbosity: true, + supportsTemperature: false, + description: "GPT-6 Astra: OpenAI's most capable model for complex, demanding work via ChatGPT subscription", + }, "gpt-5.6-sol": { maxTokens: 128000, contextWindow: 372000, 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..cb6b9afe25 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,112 @@ 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("uses Astra's required default when reasoning is disabled", async () => { + handler = new LiteLLMHandler({ + ...mockOptions, + litellmModelId: "gpt-6-astra", + reasoningEffort: "disable", + }) + mockCreate.mockResolvedValue({ choices: [{ message: { content: "Astra response" } }] }) + + await handler.completePrompt("Hello") + + expect(mockCreate.mock.calls[0][0].reasoning_effort).toBe("medium") + }) + + 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__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index e945165732..4de0998afb 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -171,6 +171,39 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("max_completion_tokens") }) + it.each([ + ["max", "max"], + ["none", "medium"], + ] as const)("uses safe Astra request parameters for %s reasoning", async (reasoningEffort, expectedEffort) => { + const modelId = "openai/gpt-6-astra" + vi.mocked(getModels).mockResolvedValue({ + [modelId]: { + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + }, + }) + + await collectStream( + new NanoGptHandler({ nanoGptModelId: modelId, modelTemperature: 0.7, reasoningEffort }).createMessage( + "sys", + messages, + { taskId: "task", parallelToolCalls: true }, + ), + ) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: modelId, + reasoning_effort: expectedEffort, + parallel_tool_calls: false, + }) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + }) + it("keeps Muse Spark tool-result history contiguous across turns", async () => { const modelId = "meta/muse-spark-1.2-contributor" vi.mocked(getModels).mockResolvedValue({ diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index a1f8605abe..35c1f5de60 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -10,7 +10,7 @@ vitest.mock("@roo-code/telemetry", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, SERVICE_TIER_KEY } from "@roo-code/types" -import { OpenAiCodexHandler, transformLunaResponsesLiteBody } from "../openai-codex" +import { OpenAiCodexHandler, transformResponsesLiteBody } from "../openai-codex" import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -67,6 +67,23 @@ describe("OpenAiCodexHandler.getModel", () => { expect(model.id).toBe("gpt-5.4-mini") expect(model.info).toBeDefined() }) + + it("uses the Codex catalog capabilities for GPT-6 Astra", () => { + const model = new OpenAiCodexHandler({ apiModelId: "gpt-6-astra" }).getModel() + + expect(model).toMatchObject({ + id: "gpt-6-astra", + info: { + contextWindow: 872000, + maxTokens: 128000, + supportsImages: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "low", + supportsTemperature: false, + }, + }) + }) }) describe("OpenAiCodexHandler.createMessage", () => { @@ -584,7 +601,7 @@ describe("OpenAiCodexHandler.completePrompt streaming", () => { }) }) -describe("transformLunaResponsesLiteBody", () => { +describe("transformResponsesLiteBody", () => { it("creates the exact Responses Lite body while preserving unrelated fields and reasoning", () => { const tools = [{ type: "function", name: "read_file", parameters: { type: "object" } }] const input = [ @@ -621,7 +638,7 @@ describe("transformLunaResponsesLiteBody", () => { custom_field: { preserved: true }, } - expect(transformLunaResponsesLiteBody(body, "task-123")).toEqual({ + expect(transformResponsesLiteBody(body, "task-123")).toEqual({ model: "gpt-5.6-luna", input: [ { type: "additional_tools", role: "developer", tools }, @@ -663,7 +680,7 @@ describe("transformLunaResponsesLiteBody", () => { const input = [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }] expect( - transformLunaResponsesLiteBody( + transformResponsesLiteBody( { model: "gpt-5.6-luna", input, @@ -684,7 +701,7 @@ describe("transformLunaResponsesLiteBody", () => { }) it("overwrites a pre-existing reasoning context with all_turns", () => { - const result = transformLunaResponsesLiteBody( + const result = transformResponsesLiteBody( { model: "gpt-5.6-luna", input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }], @@ -701,16 +718,53 @@ describe("transformLunaResponsesLiteBody", () => { ["tools", { input: [], tools: {} }, "tools must be an array when provided"], ["instructions", { input: [], instructions: [] }, "instructions must be a string when provided"], ])("rejects malformed %s locally", (_field, body, expectedMessage) => { - expect(() => transformLunaResponsesLiteBody(body, "session-1")).toThrow(expectedMessage) + expect(() => transformResponsesLiteBody(body, "session-1")).toThrow(expectedMessage) }) }) -describe("OpenAiCodexHandler Luna Responses Lite requests", () => { +describe("OpenAiCodexHandler Responses Lite requests", () => { afterEach(() => { vitest.restoreAllMocks() vitest.unstubAllGlobals() }) + it("uses Responses Lite with required reasoning for GPT-6 Astra", async () => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-6-astra", reasoningEffort: "none" }) + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + const mockCreate = vitest.fn().mockResolvedValue(createCompletedStream()) + Reflect.set(handler, "client", { responses: { create: mockCreate } }) + + await collectStream( + handler.createMessage("Astra instructions", [{ role: "user", content: "Hello" }], { + taskId: "task-astra", + tools: [], + tool_choice: "required", + parallelToolCalls: true, + }), + ) + + const [body, options] = mockCreate.mock.calls[0] + expect(body).toMatchObject({ + model: "gpt-6-astra", + prompt_cache_key: "task-astra", + tool_choice: "auto", + parallel_tool_calls: false, + reasoning: { effort: "low", summary: "auto", context: "all_turns" }, + }) + expect(body).not.toHaveProperty("tools") + expect(body).not.toHaveProperty("instructions") + expect(options.headers).toMatchObject({ + originator: "zoo-code", + session_id: "task-astra", + "session-id": "task-astra", + "x-openai-internal-codex-responses-lite": "true", + "ChatGPT-Account-Id": "acct_test", + }) + expect(options.headers).not.toHaveProperty("x-session-affinity") + expect(options.headers).not.toHaveProperty("version") + }) + it("uses a single task session ID in the Luna SDK body and headers", async () => { const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-luna", reasoningEffort: "high" }) vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") @@ -815,7 +869,7 @@ describe("OpenAiCodexHandler Luna Responses Lite requests", () => { it("preserves Luna session affinity while retrying with refreshed authentication", async () => { const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-luna" }) - const transformSpy = vitest.spyOn(handler as any, "buildLunaRequestBody") + const transformSpy = vitest.spyOn(handler as any, "buildResponsesLiteRequestBody") vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("expired-token") vitest.spyOn(openAiCodexOAuthManager, "forceRefreshAccessToken").mockResolvedValue("refreshed-token") vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") 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..8d5975c0d9 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,66 @@ 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([ + ["the configured effort is disabled", { reasoningEffort: "disable" as const }], + ["the reasoning toggle is disabled", { enableReasoningEffort: false }], + ])("uses Astra's required default when %s", (_description, options) => { + const astraHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-6-astra", + ...options, + }) + + expect(astraHandler["getReasoningEffort"](astraHandler.getModel())).toBe("medium") + }) + it.each(serviceTiers)("should include the selected %s service tier", async (serviceTier) => { mockResponsesCreate.mockResolvedValue(asyncStreamFrom([])) handler = new OpenAiNativeHandler({ @@ -170,14 +230,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 +253,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 +322,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 +365,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 +499,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 +1012,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 +1045,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 +1183,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 +1227,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/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 5374879845..268e42a7e2 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -109,6 +109,16 @@ vitest.mock("../fetchers/modelCache", () => ({ cacheReadsPrice: 0.25, description: "GPT-4o", }, + "openai/gpt-6-astra": { + maxTokens: 128000, + contextWindow: 1050000, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + }, }) }), refreshModels: vitest.fn(async (options) => { @@ -398,6 +408,30 @@ describe("VercelAiGatewayHandler", () => { expect(call.max_completion_tokens).toBe(128000) }) + it.each([ + ["max", "max"], + ["none", "medium"], + ] as const)("uses safe Astra request parameters for %s reasoning", async (reasoningEffort, expectedEffort) => { + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + vercelAiGatewayModelId: "openai/gpt-6-astra", + modelTemperature: 0.7, + reasoningEffort, + }), + ) + + await handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]).next() + + const call = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + expect(call).toMatchObject({ + model: "openai/gpt-6-astra", + max_completion_tokens: 128000, + reasoning_effort: expectedEffort, + }) + expect(call.temperature).toBeUndefined() + }) + it("adds cache breakpoints for supported models", async () => { const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway") const handler = new VercelAiGatewayHandler( 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/__tests__/nanogpt.spec.ts b/src/api/providers/fetchers/__tests__/nanogpt.spec.ts index e4673f5a1b..edbe8d1690 100644 --- a/src/api/providers/fetchers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/fetchers/__tests__/nanogpt.spec.ts @@ -140,6 +140,24 @@ describe("NanoGPT model fetcher", () => { ).toEqual(["low", "medium", "high"]) }) + it.each(["openai/gpt-6-astra", "openai/gpt-6-astra-pro"])( + "applies required Astra request constraints to %s", + (id) => { + expect( + parseNanoGptModel({ + id, + capabilities: { reasoning: true, vision: true, tool_calling: true }, + reasoning_efforts: ["low", "medium", "high", "xhigh", "max"], + }), + ).toMatchObject({ + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + }) + }, + ) + it.each([{ data: null }, [], null])("returns no models for invalid top-level data %#", async (data) => { vi.mocked(axios.get).mockResolvedValue({ data }) const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index dafb7a9a0f..5d1f03bb0b 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -266,6 +266,33 @@ describe("OpenRouter API", () => { }) describe("parseOpenRouterModel", () => { + it.each(["openai/gpt-6-astra", "openai/gpt-6-astra-pro"])( + "applies required Astra request constraints to %s", + (id) => { + const result = parseOpenRouterModel({ + id, + model: { + name: "GPT-6 Astra", + description: "Test model", + context_length: 1_050_000, + max_completion_tokens: 128_000, + pricing: { prompt: "0.00001", completion: "0.00005" }, + }, + inputModality: ["text", "image"], + outputModality: ["text"], + maxTokens: 128_000, + supportedParameters: ["reasoning", "reasoning_effort", "tools"], + }) + + expect(result).toMatchObject({ + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + }) + }, + ) + it("sets claude-sonnet-4.6 model to Anthropic max tokens", () => { const mockModel = { name: "Claude Sonnet 4.6", diff --git a/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts index fb90cde4a6..6815cdd7eb 100644 --- a/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts @@ -163,6 +163,31 @@ describe("Vercel AI Gateway Fetchers", () => { }, } + it.each(["openai/gpt-6-astra", "openai/gpt-6-astra-fast"])( + "applies required Astra request constraints to %s", + (id) => { + const result = parseVercelAiGatewayModel({ + id, + model: { + ...baseModel, + id, + name: "GPT-6 Astra", + owned_by: "openai", + context_window: 1_050_000, + max_tokens: 128_000, + tags: ["reasoning", "tool-use", "vision"], + }, + }) + + expect(result).toMatchObject({ + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + supportsTemperature: false, + }) + }, + ) + it("parses basic model info correctly", () => { const result = parseVercelAiGatewayModel({ id: "test/model", 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/fetchers/nanogpt.ts b/src/api/providers/fetchers/nanogpt.ts index 407f2ca85a..1098039072 100644 --- a/src/api/providers/fetchers/nanogpt.ts +++ b/src/api/providers/fetchers/nanogpt.ts @@ -5,6 +5,7 @@ import { NANOGPT_BASE_URL, nanoGptDefaultModelInfo, type ModelInfo, type ModelRe const nanoGptReasoningEfforts: NonNullable = ["low", "medium", "high"] const nanoGptReasoningEffortSchema = z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]) +const nanoGptAstraModelIds = new Set(["openai/gpt-6-astra", "openai/gpt-6-astra-pro"]) const nanoGptPricingSchema = z.object({ prompt: z.number().nonnegative().optional(), @@ -63,6 +64,14 @@ export const parseNanoGptModel = (model: NanoGptModel): ModelInfo => ({ ...(model.pricing?.cacheWriteInputPer1kTokens !== undefined ? { cacheWritesPrice: model.pricing.cacheWriteInputPer1kTokens * 1_000 } : {}), + ...(nanoGptAstraModelIds.has(model.id) + ? { + supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"] as const, + requiredReasoningEffort: true, + reasoningEffort: "medium" as const, + supportsTemperature: false, + } + : {}), }) /** Fetches NanoGPT's public detailed catalog, optionally scoped by a Bearer key. */ diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index 6b281bc27a..debbf76967 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -221,6 +221,13 @@ export const parseOpenRouterModel = ({ supportedParameters: supportedParameters ? supportedParameters.filter(isModelParameter) : undefined, } + if (id === "openai/gpt-6-astra" || id === "openai/gpt-6-astra-pro") { + modelInfo.supportsReasoningEffort = ["low", "medium", "high", "xhigh", "max"] + modelInfo.requiredReasoningEffort = true + modelInfo.reasoningEffort = "medium" + modelInfo.supportsTemperature = false + } + if (OPEN_ROUTER_REASONING_BUDGET_MODELS.has(id)) { modelInfo.supportsReasoningBudget = true } diff --git a/src/api/providers/fetchers/vercel-ai-gateway.ts b/src/api/providers/fetchers/vercel-ai-gateway.ts index 1d666ccf09..36d1a27ea6 100644 --- a/src/api/providers/fetchers/vercel-ai-gateway.ts +++ b/src/api/providers/fetchers/vercel-ai-gateway.ts @@ -116,6 +116,13 @@ export const parseVercelAiGatewayModel = ({ id, model }: { id: string; model: Ve description: model.description ?? model.name, } + if (id === "openai/gpt-6-astra" || id === "openai/gpt-6-astra-fast") { + modelInfo.supportsReasoningEffort = ["low", "medium", "high", "xhigh", "max"] + modelInfo.requiredReasoningEffort = true + modelInfo.reasoningEffort = "medium" + modelInfo.supportsTemperature = false + } + if (id === "anthropic/claude-fable-5.1" || id === "anthropic/claude-fable-5") { modelInfo.supportsTemperature = false } 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/nanogpt.ts b/src/api/providers/nanogpt.ts index 30100711a3..43d4641251 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -7,7 +7,9 @@ import { nanoGptDefaultModelId, nanoGptDefaultModelInfo, providerIdentifiers, + type ModelInfo, type NanoGptRoutingPreference, + type ReasoningEffortExtended, } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -29,21 +31,20 @@ type NanoGptCachingRequest = { caching?: true } const NANO_GPT_MERGED_TOOL_RESULT_MODELS = new Set(["meta/muse-spark-1.2-contributor"]) -const OPENAI_REASONING_EFFORTS = ["low", "medium", "high"] as const -type OpenAiReasoningEffort = (typeof OPENAI_REASONING_EFFORTS)[number] +const NANO_GPT_ASTRA_MODEL_IDS = new Set(["openai/gpt-6-astra", "openai/gpt-6-astra-pro"]) -function getReasoningEffort(options: ApiHandlerOptions, supported: unknown): OpenAiReasoningEffort | undefined { - const effort = options.reasoningEffort - const selectedEffort = OPENAI_REASONING_EFFORTS.find((candidate) => candidate === effort) - if (!selectedEffort) { - return undefined - } +function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): ReasoningEffortExtended | undefined { + const configured = options.reasoningEffort + const reasoningDisabled = + configured === "disable" || configured === "none" || options.enableReasoningEffort === false + const supported = info.supportsReasoningEffort - if (supported === true || (Array.isArray(supported) && supported.includes(selectedEffort))) { - return selectedEffort + if (!reasoningDisabled && configured && configured !== "minimal") { + if (supported === true || (Array.isArray(supported) && supported.includes(configured))) return configured } - return undefined + const fallback = info.reasoningEffort + return info.requiredReasoningEffort && fallback && fallback !== "none" ? fallback : undefined } function mapNanoGptUsage(usage: NanoGptUsage): ApiStreamUsageChunk { @@ -93,6 +94,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: canonicalModelId, info } = await this.fetchModel() + const isAstra = NANO_GPT_ASTRA_MODEL_IDS.has(canonicalModelId) const body: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & NanoGptCachingRequest = { model: this.getRequestModelId(canonicalModelId), messages: [ @@ -106,17 +108,21 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa max_tokens: info.maxTokens ?? undefined, tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + parallel_tool_calls: isAstra ? false : (metadata?.parallelToolCalls ?? true), ...(this.options.nanoGptRoutingPreference === "caching" ? { caching: true } : {}), } - if (this.options.modelTemperature !== undefined && this.supportsTemperature(canonicalModelId)) { + if ( + this.options.modelTemperature !== undefined && + info.supportsTemperature !== false && + this.supportsTemperature(canonicalModelId) + ) { body.temperature = this.options.modelTemperature } - const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + const reasoningEffort = getReasoningEffort(this.options, info) if (reasoningEffort) { - body.reasoning_effort = reasoningEffort + ;(body as { reasoning_effort?: ReasoningEffortExtended }).reasoning_effort = reasoningEffort } try { @@ -161,13 +167,17 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa ...(this.options.nanoGptRoutingPreference === "caching" ? { caching: true } : {}), } - if (this.options.modelTemperature !== undefined && this.supportsTemperature(canonicalModelId)) { + if ( + this.options.modelTemperature !== undefined && + info.supportsTemperature !== false && + this.supportsTemperature(canonicalModelId) + ) { body.temperature = this.options.modelTemperature } - const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + const reasoningEffort = getReasoningEffort(this.options, info) if (reasoningEffort) { - body.reasoning_effort = reasoningEffort + ;(body as { reasoning_effort?: ReasoningEffortExtended }).reasoning_effort = reasoningEffort } try { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index c08dbfd286..1498aafc66 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -41,6 +41,7 @@ type OpenAiCodexRequestServiceTier = typeof OpenAiCodexServiceTier.Priority const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex" const LUNA_MODEL_ID = "gpt-5.6-luna" const LUNA_CODEX_VERSION = "0.144.0" +const RESPONSES_LITE_MODEL_IDS = new Set([LUNA_MODEL_ID, "gpt-6-astra"]) /** * A refusal is streamed as text so the chat still shows why the model declined, but it is not part @@ -70,15 +71,15 @@ function stripInputImageDetail(value: any): any { ) } -export function transformLunaResponsesLiteBody(requestBody: any, effectiveSessionId: string): any { +export function transformResponsesLiteBody(requestBody: any, effectiveSessionId: string): any { if (!Array.isArray(requestBody.input)) { - throw new Error("Invalid gpt-5.6-luna Responses Lite request: input must be an array.") + throw new Error("Invalid Responses Lite request: input must be an array.") } if (requestBody.tools !== undefined && !Array.isArray(requestBody.tools)) { - throw new Error("Invalid gpt-5.6-luna Responses Lite request: tools must be an array when provided.") + throw new Error("Invalid Responses Lite request: tools must be an array when provided.") } if (requestBody.instructions !== undefined && typeof requestBody.instructions !== "string") { - throw new Error("Invalid gpt-5.6-luna Responses Lite request: instructions must be a string when provided.") + throw new Error("Invalid Responses Lite request: instructions must be a string when provided.") } const { tools, instructions, ...rest } = requestBody @@ -103,12 +104,12 @@ export function transformLunaResponsesLiteBody(requestBody: any, effectiveSessio : []), ...transformedInput, ], - // Luna Responses Lite requires these exact values, so they intentionally + // Responses Lite requires these exact values, so they intentionally // override any caller-supplied tool_choice or parallel_tool_calls. tool_choice: "auto", parallel_tool_calls: false, prompt_cache_key: effectiveSessionId, - // Luna Responses Lite requires reasoning context "all_turns"; this intentionally + // Responses Lite requires reasoning context "all_turns"; this intentionally // overwrites any context value already present in the incoming reasoning config. reasoning: { ...reasoning, context: "all_turns" }, } @@ -272,10 +273,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const baseRequestBody = this.buildRequestBody(model, formattedInput, systemPrompt, reasoningEffort, metadata) let requestBody: any try { - requestBody = - model.id === LUNA_MODEL_ID - ? this.buildLunaRequestBody(baseRequestBody, effectiveSessionId) - : baseRequestBody + requestBody = RESPONSES_LITE_MODEL_IDS.has(model.id) + ? this.buildResponsesLiteRequestBody(baseRequestBody, effectiveSessionId) + : baseRequestBody } catch (error) { const message = error instanceof Error ? error.message : String(error) TelemetryService.instance.captureException( @@ -315,8 +315,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } } - private buildLunaRequestBody(baseRequestBody: any, effectiveSessionId: string): any { - return transformLunaResponsesLiteBody(baseRequestBody, effectiveSessionId) + private buildResponsesLiteRequestBody(baseRequestBody: any, effectiveSessionId: string): any { + return transformResponsesLiteBody(baseRequestBody, effectiveSessionId) } private buildRequestBody( @@ -1239,8 +1239,21 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } private getReasoningEffort(model: OpenAiCodexModel): ReasoningEffortExtended | undefined { - const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) - return selected && selected !== "disable" && selected !== "none" ? (selected as any) : undefined + const supported = model.info.supportsReasoningEffort + const configured = this.options.reasoningEffort + const fallback = model.info.reasoningEffort + const reasoningDisabled = + configured === "disable" || configured === "none" || this.options.enableReasoningEffort === false + + if (reasoningDisabled && !model.info.requiredReasoningEffort) return undefined + + if (Array.isArray(supported)) { + if (!reasoningDisabled && configured && supported.includes(configured)) return configured + return fallback && fallback !== "none" && supported.includes(fallback) ? fallback : undefined + } + + const selected = configured === "disable" ? fallback : (configured ?? fallback) + return selected && selected !== "none" ? selected : undefined } private buildCodexHeaders( @@ -1248,17 +1261,22 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion effectiveSessionId: string, accountId?: string | null, ): Record { + const usesResponsesLite = RESPONSES_LITE_MODEL_IDS.has(model.id) return { originator: "zoo-code", session_id: effectiveSessionId, "User-Agent": `zoo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`, ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), - ...(model.id === LUNA_MODEL_ID + ...(usesResponsesLite ? { "session-id": effectiveSessionId, + "x-openai-internal-codex-responses-lite": "true", + } + : {}), + ...(model.id === LUNA_MODEL_ID + ? { "x-session-affinity": effectiveSessionId, version: LUNA_CODEX_VERSION, - "x-openai-internal-codex-responses-lite": "true", } : {}), } 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/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index bf434e5a00..3f4f3af26c 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -7,6 +7,8 @@ import { VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS, providerIdentifiers, + type ModelInfo, + type ReasoningEffortExtended, } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" @@ -24,6 +26,20 @@ interface VercelAiGatewayUsage extends OpenAI.CompletionUsage { cost?: number } +function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): ReasoningEffortExtended | undefined { + const configured = options.reasoningEffort + const reasoningDisabled = + configured === "disable" || configured === "none" || options.enableReasoningEffort === false + const supported = info.supportsReasoningEffort + + if (!reasoningDisabled && configured && configured !== "minimal") { + if (supported === true || (Array.isArray(supported) && supported.includes(configured))) return configured + } + + const fallback = info.reasoningEffort + return info.requiredReasoningEffort && fallback && fallback !== "none" ? fallback : undefined +} + export class VercelAiGatewayHandler extends RouterProvider implements SingleCompletionHandler { constructor(options: ApiHandlerOptions) { super({ @@ -54,6 +70,7 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp } const supportsTemperature = info.supportsTemperature !== false && this.supportsTemperature(modelId) + const reasoningEffort = getReasoningEffort(this.options, info) const body: OpenAI.Chat.ChatCompletionCreateParams = { model: modelId, @@ -68,6 +85,9 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, } + if (reasoningEffort) { + ;(body as { reasoning_effort?: ReasoningEffortExtended }).reasoning_effort = reasoningEffort + } const completion = await this.client.chat.completions.create(body) @@ -122,11 +142,15 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp const { id: modelId, info } = await this.fetchModel() try { + const reasoningEffort = getReasoningEffort(this.options, info) const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { model: modelId, messages: [{ role: "user", content: prompt }], stream: false, } + if (reasoningEffort) { + ;(requestOptions as { reasoning_effort?: ReasoningEffortExtended }).reasoning_effort = reasoningEffort + } if (info.supportsTemperature !== false && this.supportsTemperature(modelId)) { requestOptions.temperature = this.options.modelTemperature ?? VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0706dbe6fb..393e108645 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -391,12 +391,12 @@ }, "api/providers/openai-codex.ts": { "@typescript-eslint/no-explicit-any": { - "count": 34 + "count": 31 } }, "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 (
{ expect(screen.queryByText("OpenRouter unavailable")).not.toBeInTheDocument() expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() }) + + it("hides manual cache controls for Responses-backed models", () => { + mockUseExtensionState.mockReturnValue({ + routerModels: { + [providerIdentifiers.litellm]: { + "gpt-6-astra": { + contextWindow: 1_050_000, + supportsPromptCache: true, + requiresResponsesApi: true, + }, + }, + }, + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + , + ) + + expect(screen.queryByText("settings:providers.enablePromptCaching")).not.toBeInTheDocument() + }) + + it("shows manual cache controls for cache-capable chat completion models", () => { + mockUseExtensionState.mockReturnValue({ + routerModels: { + [providerIdentifiers.litellm]: { + "cache-capable-model": { + contextWindow: 128_000, + supportsPromptCache: true, + }, + }, + }, + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + , + ) + + expect(screen.getByText("settings:providers.enablePromptCaching")).toBeInTheDocument() + }) })