From 7773d4554395c9ddd2ece907f7722deeec4c776e Mon Sep 17 00:00:00 2001 From: "@navedmerchant" <14171946+navedmerchant@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:35:01 +0000 Subject: [PATCH 1/3] test(api): satisfy Astra mutation coverage --- scripts/stryker-diff.mjs | 3 +- scripts/stryker-diff.test.mjs | 2 + src/api/providers/__tests__/lite-llm.spec.ts | 86 ++++++++++++++++- src/api/providers/__tests__/nanogpt.spec.ts | 92 +++++++++++++++++- .../providers/__tests__/openai-codex.spec.ts | 62 +++++++++++- .../providers/__tests__/openai-native.spec.ts | 45 ++++++++- .../__tests__/vercel-ai-gateway.spec.ts | 95 +++++++++++++++++++ .../fetchers/__tests__/litellm.spec.ts | 7 ++ src/api/providers/fetchers/nanogpt.ts | 5 +- src/api/providers/nanogpt.ts | 5 +- src/api/providers/openai-codex.ts | 8 +- 11 files changed, 398 insertions(+), 12 deletions(-) diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index c0e8a6cd1a..5c38456670 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -298,7 +298,8 @@ export function preferDirectTestFiles(testFiles, sourceFiles) { const testName = path.posix.basename(testFile) return sourceNames.some( (sourceName) => - testName.startsWith(`${sourceName}.`) && /\.(?:test|spec)(?:\.[^.]+)?\.[cm]?[jt]sx?$/.test(testName), + (testName.startsWith(`${sourceName}.`) || testName.startsWith(`${sourceName}-`)) && + /\.(?:test|spec)(?:\.[^.]+)?\.[cm]?[jt]sx?$/.test(testName), ) }) return direct.length > 0 ? direct : testFiles diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs index 0f39dc507f..6710e12edc 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -201,10 +201,12 @@ describe("preferDirectTestFiles", () => { const related = [ "webview-ui/src/__tests__/App.spec.tsx", "webview-ui/src/utils/__tests__/path-mentions.test.ts", + "webview-ui/src/utils/__tests__/path-mentions-edge-cases.spec.ts", "webview-ui/src/components/chat/__tests__/ChatView.spec.tsx", ] assert.deepEqual(preferDirectTestFiles(related, ["webview-ui/src/utils/path-mentions.ts"]), [ "webview-ui/src/utils/__tests__/path-mentions.test.ts", + "webview-ui/src/utils/__tests__/path-mentions-edge-cases.spec.ts", ]) assert.deepEqual(preferDirectTestFiles(related, ["webview-ui/src/utils/unmatched.ts"]), related) }) diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index cb6b9afe25..2d9f35965e 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -3,7 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { LiteLLMHandler } from "../lite-llm" import { ApiHandlerOptions } from "../../../shared/api" -import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" +import { litellmDefaultModelId, litellmDefaultModelInfo, type ModelInfo } from "@roo-code/types" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" @@ -89,6 +89,54 @@ describe("LiteLLMHandler", () => { handler = new LiteLLMHandler(mockOptions) }) + describe("reasoning effort normalization", () => { + const baseInfo: ModelInfo = { + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsPromptCache: false, + } + + it.each([ + ["non-array support", "max", { supportsReasoningEffort: true }, undefined], + [ + "supported configured effort", + "max", + { supportsReasoningEffort: ["low", "medium", "max"], reasoningEffort: "medium" }, + "max", + ], + [ + "unsupported configured effort", + "high", + { supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" }, + "medium", + ], + [ + "disabled configured effort", + "disable", + { supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" }, + "medium", + ], + [ + "unset configured effort", + undefined, + { supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" }, + "medium", + ], + ["missing fallback", undefined, { supportsReasoningEffort: ["low", "medium"] }, undefined], + [ + "unsupported fallback", + undefined, + { supportsReasoningEffort: ["low"], reasoningEffort: "medium" }, + undefined, + ], + ] as const)("handles %s", (_case, reasoningEffort, overrides, expected) => { + const currentHandler = new LiteLLMHandler({ ...mockOptions, reasoningEffort }) + const info = { ...baseInfo, ...overrides } as ModelInfo + + expect(currentHandler["getReasoningEffort"](info)).toBe(expected) + }) + }) + describe("prompt caching", () => { it("should add cache control headers when litellmUsePromptCache is enabled", async () => { const optionsWithCache: ApiHandlerOptions = { @@ -402,6 +450,7 @@ describe("LiteLLMHandler", () => { handler = new LiteLLMHandler({ ...mockOptions, litellmModelId: "gpt-6-astra", + litellmUsePromptCache: true, reasoningEffort: "none", modelTemperature: 0.7, }) @@ -441,6 +490,41 @@ describe("LiteLLMHandler", () => { }) expect(request.max_tokens).toBeUndefined() expect(request.temperature).toBeUndefined() + expect(request.messages[0]).toEqual({ role: "system", content: "You are helpful" }) + }) + + it("keeps manual cache controls for non-Responses models", async () => { + handler = new LiteLLMHandler({ ...mockOptions, litellmUsePromptCache: true }) + vi.spyOn(handler, "fetchModel").mockResolvedValue({ + id: "cache-model", + info: { ...litellmDefaultModelInfo, supportsPromptCache: true, requiresResponsesApi: false }, + }) + mockCreate.mockReturnValue({ withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }) }) + + await collectStream(handler.createMessage("System", [{ role: "user", content: "Hello" }])) + + const request = mockCreate.mock.calls[0][0] + expect(request.messages[0].content).toEqual([ + expect.objectContaining({ type: "text", text: "System", cache_control: { type: "ephemeral" } }), + ]) + }) + + it.each(["streaming", "completion"] as const)("omits temperature for metadata-disabled %s", async (mode) => { + handler = new LiteLLMHandler({ ...mockOptions, modelTemperature: 0.7 }) + vi.spyOn(handler, "fetchModel").mockResolvedValue({ + id: "custom-model", + info: { ...litellmDefaultModelInfo, supportsTemperature: false }, + }) + + if (mode === "streaming") { + mockCreate.mockReturnValue({ withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }) }) + await collectStream(handler.createMessage("System", [])) + } else { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "Response" } }] }) + await handler.completePrompt("Hello") + } + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") }) it("uses safe Astra parameters for completePrompt", async () => { diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 4de0998afb..6bc7dfe95a 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -5,12 +5,13 @@ vi.mock("vscode", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { nanoGptDefaultModelId, providerIdentifiers } from "@roo-code/types" +import { nanoGptDefaultModelId, providerIdentifiers, type ModelInfo } from "@roo-code/types" import { buildApiHandler } from "../../index" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { NanoGptHandler } from "../nanogpt" import { getModels } from "../fetchers/modelCache" +import type { ApiHandlerOptions } from "../../../shared/api" vi.mock("openai") vi.mock("../fetchers/modelCache", () => ({ @@ -204,6 +205,72 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") }) + it.each([ + ["boolean support", { reasoningEffort: "high" }, { supportsReasoningEffort: true }, "high"], + ["array support", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low", "high"] }, "high"], + ["unsupported effort", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low"] }, undefined], + ["disabled effort", { reasoningEffort: "disable" }, { supportsReasoningEffort: ["low"] }, undefined], + ["none effort", { reasoningEffort: "none" }, { supportsReasoningEffort: ["low"] }, undefined], + [ + "disabled toggle", + { reasoningEffort: "high", enableReasoningEffort: false }, + { supportsReasoningEffort: ["low", "high"] }, + undefined, + ], + ["minimal effort", { reasoningEffort: "minimal" }, { supportsReasoningEffort: ["minimal"] }, undefined], + [ + "required fallback", + { reasoningEffort: "high" }, + { supportsReasoningEffort: ["low", "medium"], requiredReasoningEffort: true, reasoningEffort: "medium" }, + "medium", + ], + [ + "required default", + {}, + { supportsReasoningEffort: ["low", "medium"], requiredReasoningEffort: true, reasoningEffort: "medium" }, + "medium", + ], + [ + "invalid required fallback", + {}, + { supportsReasoningEffort: ["low"], requiredReasoningEffort: true, reasoningEffort: "none" }, + undefined, + ], + ["optional fallback", {}, { supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" }, undefined], + ] satisfies ReadonlyArray, Partial, string | undefined]>)( + "normalizes %s", + async (_case, options, modelOverrides, expectedEffort) => { + const modelId = "reasoning-model" + vi.mocked(getModels).mockResolvedValue({ + [modelId]: { + maxTokens: 8_192, + contextWindow: 128_000, + supportsPromptCache: false, + ...modelOverrides, + }, + }) + + await collectStream( + new NanoGptHandler({ nanoGptModelId: modelId, ...options }).createMessage("sys", messages), + ) + + const request = mockCreate.mock.calls[0][0] + if (expectedEffort) expect(request.reasoning_effort).toBe(expectedEffort) + else expect(request).not.toHaveProperty("reasoning_effort") + }, + ) + + it.each([ + [undefined, true], + [false, false], + ] as const)("uses %s parallel-tool preference as %s for ordinary models", async (parallelToolCalls, expected) => { + const metadata = parallelToolCalls === undefined ? undefined : { taskId: "task", parallelToolCalls } + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages, metadata), + ) + expect(mockCreate.mock.calls[0][0].parallel_tool_calls).toBe(expected) + }) + it("keeps Muse Spark tool-result history contiguous across turns", async () => { const modelId = "meta/muse-spark-1.2-contributor" vi.mocked(getModels).mockResolvedValue({ @@ -370,6 +437,29 @@ describe("NanoGptHandler", () => { }) describe("completePrompt", () => { + it.each([ + ["supported model", "temperature-model", undefined, 0.7], + ["metadata-disabled model", "temperature-model", false, undefined], + ["name-disabled model", "openai/o3-mini-test", undefined, undefined], + ] as const)( + "handles configured temperature for a %s", + async (_case, modelId, supportsTemperature, expected) => { + vi.mocked(getModels).mockResolvedValue({ + [modelId]: { + maxTokens: 8_192, + contextWindow: 128_000, + supportsPromptCache: false, + supportsTemperature, + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + await new NanoGptHandler({ nanoGptModelId: modelId, modelTemperature: 0.7 }).completePrompt("prompt") + const request = mockCreate.mock.calls[0][0] + if (expected === undefined) expect(request).not.toHaveProperty("temperature") + else expect(request.temperature).toBe(expected) + }, + ) + it("requests cache-capable routing without changing the completion model ID", async () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) const handler = new NanoGptHandler({ diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 35c1f5de60..165889daa8 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -9,7 +9,12 @@ 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 { + OPEN_AI_CODEX_SERVICE_TIER_KEY, + OpenAiCodexServiceTier, + SERVICE_TIER_KEY, + type ModelInfo, +} from "@roo-code/types" import { OpenAiCodexHandler, transformResponsesLiteBody } from "../openai-codex" import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -84,6 +89,61 @@ describe("OpenAiCodexHandler.getModel", () => { }, }) }) + + it.each([ + ["disable", undefined], + ["none", undefined], + ] as const)("omits optional Codex reasoning for %s", (reasoningEffort, expected) => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", reasoningEffort }) + expect(handler["getReasoningEffort"](handler.getModel())).toBe(expected) + }) + + it("omits optional Codex reasoning when the reasoning toggle is disabled", () => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-5.6-sol", + reasoningEffort: "high", + enableReasoningEffort: false, + }) + expect(handler["getReasoningEffort"](handler.getModel())).toBeUndefined() + }) + + it.each([ + ["high", "high"], + ["minimal", "low"], + ["disable", "low"], + ] as const)("normalizes required Astra reasoning from %s", (reasoningEffort, expected) => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-6-astra", reasoningEffort }) + expect(handler["getReasoningEffort"](handler.getModel())).toBe(expected) + }) + + it("omits an unsupported Codex fallback", () => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-6-astra", reasoningEffort: "high" }) + const model = handler.getModel() + const unsupportedModel = { + ...model, + info: { + ...model.info, + supportsReasoningEffort: ["low"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "medium" as const, + }, + } + expect(handler["getReasoningEffort"](unsupportedModel)).toBeUndefined() + }) + + it.each([ + ["high", "medium", "high"], + [undefined, "medium", "medium"], + ["disable", "medium", "medium"], + ["none", "medium", undefined], + ] as const)("handles scalar Codex reasoning support with %s configured", (reasoningEffort, fallback, expected) => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-6-astra", reasoningEffort }) + const model = handler.getModel() + const scalarModel = { + ...model, + info: { ...model.info, supportsReasoningEffort: true, reasoningEffort: fallback }, + } + expect(handler["getReasoningEffort"](scalarModel)).toBe(expected) + }) }) describe("OpenAiCodexHandler.createMessage", () => { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 8d5975c0d9..4bd7ffb758 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -13,7 +13,7 @@ vitest.mock("@roo-code/telemetry", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiProviderError, OpenAiServiceTier, SERVICE_TIER_KEY, serviceTiers } from "@roo-code/types" +import { ApiProviderError, OpenAiServiceTier, SERVICE_TIER_KEY, serviceTiers, type ModelInfo } from "@roo-code/types" import { OpenAiNativeHandler } from "../openai-native" import { ApiHandlerOptions } from "../../../shared/api" @@ -192,6 +192,49 @@ describe("OpenAiNativeHandler", () => { expect(astraHandler["getReasoningEffort"](astraHandler.getModel())).toBe("medium") }) + it.each([ + ["the configured effort is disabled", { reasoningEffort: "disable" as const }], + ["the reasoning toggle is disabled", { enableReasoningEffort: false }], + ])("omits optional reasoning when %s", (_description, options) => { + const optionalHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.6-sol", + ...options, + }) + + expect(optionalHandler["getReasoningEffort"](optionalHandler.getModel())).toBeUndefined() + }) + + it("omits an unsupported fallback", () => { + const currentHandler = new OpenAiNativeHandler({ ...mockOptions, reasoningEffort: "high" }) + const model = currentHandler.getModel() + const unsupportedModel = { + ...model, + info: { + ...model.info, + supportsReasoningEffort: ["low"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "medium" as const, + }, + } + + expect(currentHandler["getReasoningEffort"](unsupportedModel)).toBeUndefined() + }) + + it.each([ + ["high", "medium", "high"], + [undefined, "medium", "medium"], + ["disable", "medium", undefined], + ] as const)("handles scalar reasoning support with %s configured", (reasoningEffort, fallback, expected) => { + const currentHandler = new OpenAiNativeHandler({ ...mockOptions, reasoningEffort }) + const model = currentHandler.getModel() + const scalarModel = { + ...model, + info: { ...model.info, supportsReasoningEffort: true, reasoningEffort: fallback }, + } + + expect(currentHandler["getReasoningEffort"](scalarModel)).toBe(expected) + }) + it.each(serviceTiers)("should include the selected %s service tier", async (serviceTier) => { mockResponsesCreate.mockResolvedValue(asyncStreamFrom([])) handler = new OpenAiNativeHandler({ diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 268e42a7e2..2c0cf6679a 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -17,6 +17,8 @@ import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" import { vercelAiGatewayDefaultModelId, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types" +import type { ModelInfo } from "@roo-code/types" +import type { ApiHandlerOptions } from "../../../shared/api" // Mock dependencies vitest.mock("openai") @@ -432,6 +434,72 @@ describe("VercelAiGatewayHandler", () => { expect(call.temperature).toBeUndefined() }) + it.each([ + ["boolean support", { reasoningEffort: "high" }, { supportsReasoningEffort: true }, "high"], + ["array support", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low", "high"] }, "high"], + ["unsupported effort", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low"] }, undefined], + ["disabled effort", { reasoningEffort: "disable" }, { supportsReasoningEffort: ["low"] }, undefined], + ["none effort", { reasoningEffort: "none" }, { supportsReasoningEffort: ["low"] }, undefined], + [ + "disabled toggle", + { reasoningEffort: "high", enableReasoningEffort: false }, + { supportsReasoningEffort: ["low", "high"] }, + undefined, + ], + ["minimal effort", { reasoningEffort: "minimal" }, { supportsReasoningEffort: ["minimal"] }, undefined], + [ + "required fallback", + { reasoningEffort: "high" }, + { + supportsReasoningEffort: ["low", "medium"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + }, + "medium", + ], + [ + "required default", + {}, + { + supportsReasoningEffort: ["low", "medium"], + requiredReasoningEffort: true, + reasoningEffort: "medium", + }, + "medium", + ], + [ + "invalid required fallback", + {}, + { supportsReasoningEffort: ["low"], requiredReasoningEffort: true, reasoningEffort: "none" }, + undefined, + ], + [ + "optional fallback", + {}, + { supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" }, + undefined, + ], + ] satisfies ReadonlyArray< + readonly [string, Partial, Partial, string | undefined] + >)("normalizes %s", async (_case, options, modelOverrides, expectedEffort) => { + const handler = new VercelAiGatewayHandler(makeApiHandlerOptions({ ...mockOptions, ...options })) + vi.spyOn(handler, "fetchModel").mockResolvedValue({ + id: "reasoning-model", + info: { + maxTokens: 8_192, + contextWindow: 128_000, + supportsPromptCache: false, + ...modelOverrides, + }, + }) + + await handler.createMessage("system", [{ role: "user", content: "Hello" }]).next() + + const request = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + if (expectedEffort) expect(request.reasoning_effort).toBe(expectedEffort) + else expect(request).not.toHaveProperty("reasoning_effort") + }) + it("adds cache breakpoints for supported models", async () => { const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway") const handler = new VercelAiGatewayHandler( @@ -742,6 +810,33 @@ describe("VercelAiGatewayHandler", () => { ) }) + it.each([ + ["max", "max"], + ["none", "medium"], + ] as const)( + "uses safe Astra completion parameters for %s reasoning", + async (reasoningEffort, expectedEffort) => { + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + vercelAiGatewayModelId: "openai/gpt-6-astra", + modelTemperature: 0.7, + reasoningEffort, + }), + ) + + await handler.completePrompt("Test prompt") + + const request = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0] + expect(request).toMatchObject({ + model: "openai/gpt-6-astra", + max_completion_tokens: 128000, + reasoning_effort: expectedEffort, + }) + expect(request).not.toHaveProperty("temperature") + }, + ) + it("handles completion errors correctly", async () => { const handler = new VercelAiGatewayHandler(mockOptions) const errorMessage = "API error" diff --git a/src/api/providers/fetchers/__tests__/litellm.spec.ts b/src/api/providers/fetchers/__tests__/litellm.spec.ts index a13a930b84..fe2c249d6d 100644 --- a/src/api/providers/fetchers/__tests__/litellm.spec.ts +++ b/src/api/providers/fetchers/__tests__/litellm.spec.ts @@ -276,6 +276,13 @@ describe("getLiteLLMModels", () => { reasoningEffort: "medium", supportsTemperature: false, requiresResponsesApi: true, + longContextPricing: { + thresholdTokens: 272_000, + inputPriceMultiplier: 2, + outputPriceMultiplier: 1.5, + cacheWritesPriceMultiplier: 2, + cacheReadsPriceMultiplier: 2, + }, inputPrice: 10, outputPrice: 50, cacheWritesPrice: 12.5, diff --git a/src/api/providers/fetchers/nanogpt.ts b/src/api/providers/fetchers/nanogpt.ts index 1098039072..d7133e4df9 100644 --- a/src/api/providers/fetchers/nanogpt.ts +++ b/src/api/providers/fetchers/nanogpt.ts @@ -5,7 +5,8 @@ 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 isNanoGptAstra = (modelId: string): boolean => + modelId === "openai/gpt-6-astra" || modelId === "openai/gpt-6-astra-pro" const nanoGptPricingSchema = z.object({ prompt: z.number().nonnegative().optional(), @@ -64,7 +65,7 @@ export const parseNanoGptModel = (model: NanoGptModel): ModelInfo => ({ ...(model.pricing?.cacheWriteInputPer1kTokens !== undefined ? { cacheWritesPrice: model.pricing.cacheWriteInputPer1kTokens * 1_000 } : {}), - ...(nanoGptAstraModelIds.has(model.id) + ...(isNanoGptAstra(model.id) ? { supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"] as const, requiredReasoningEffort: true, diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 43d4641251..815c168c3f 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -31,7 +31,8 @@ type NanoGptCachingRequest = { caching?: true } const NANO_GPT_MERGED_TOOL_RESULT_MODELS = new Set(["meta/muse-spark-1.2-contributor"]) -const NANO_GPT_ASTRA_MODEL_IDS = new Set(["openai/gpt-6-astra", "openai/gpt-6-astra-pro"]) +const isNanoGptAstra = (modelId: string): boolean => + modelId === "openai/gpt-6-astra" || modelId === "openai/gpt-6-astra-pro" function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): ReasoningEffortExtended | undefined { const configured = options.reasoningEffort @@ -94,7 +95,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 isAstra = isNanoGptAstra(canonicalModelId) const body: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & NanoGptCachingRequest = { model: this.getRequestModelId(canonicalModelId), messages: [ diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 1498aafc66..bf376dc913 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -41,7 +41,9 @@ 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"]) + +const isResponsesLiteModel = (modelId: OpenAiCodexModelId): boolean => + modelId === LUNA_MODEL_ID || modelId === "gpt-6-astra" /** * A refusal is streamed as text so the chat still shows why the model declined, but it is not part @@ -273,7 +275,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const baseRequestBody = this.buildRequestBody(model, formattedInput, systemPrompt, reasoningEffort, metadata) let requestBody: any try { - requestBody = RESPONSES_LITE_MODEL_IDS.has(model.id) + requestBody = isResponsesLiteModel(model.id) ? this.buildResponsesLiteRequestBody(baseRequestBody, effectiveSessionId) : baseRequestBody } catch (error) { @@ -1261,7 +1263,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion effectiveSessionId: string, accountId?: string | null, ): Record { - const usesResponsesLite = RESPONSES_LITE_MODEL_IDS.has(model.id) + const usesResponsesLite = isResponsesLiteModel(model.id) return { originator: "zoo-code", session_id: effectiveSessionId, From c673afbc5e9324735ba7a270f34e71f440dfb827 Mon Sep 17 00:00:00 2001 From: "@navedmerchant" <14171946+navedmerchant@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:18:27 +0000 Subject: [PATCH 2/3] test(api): close remaining Astra mutation gaps --- src/api/providers/__tests__/lite-llm.spec.ts | 58 +++++++++++++++++++ src/api/providers/__tests__/nanogpt.spec.ts | 13 +++++ .../providers/__tests__/openai-codex.spec.ts | 19 ++++++ .../__tests__/openai-native-usage.spec.ts | 12 ++++ .../providers/__tests__/openai-native.spec.ts | 32 ++++++++++ .../__tests__/vercel-ai-gateway.spec.ts | 13 +++++ src/api/providers/fetchers/nanogpt.ts | 5 +- src/api/providers/nanogpt.ts | 5 +- src/api/providers/openai-codex.ts | 12 ++-- src/api/providers/openai-native.ts | 6 +- 10 files changed, 155 insertions(+), 20 deletions(-) diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 2d9f35965e..dae6dbe9ea 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -116,6 +116,12 @@ describe("LiteLLMHandler", () => { { supportsReasoningEffort: ["low", "medium"], reasoningEffort: "medium" }, "medium", ], + [ + "listed disable sentinel", + "disable", + { supportsReasoningEffort: ["disable", "medium"], reasoningEffort: "medium" }, + "medium", + ], [ "unset configured effort", undefined, @@ -509,6 +515,26 @@ describe("LiteLLMHandler", () => { ]) }) + it.each([ + [false, true, false], + [true, false, false], + [true, true, true], + ] as const)( + "omits manual cache controls for enabled=%s supported=%s Responses=%s", + async (litellmUsePromptCache, supportsPromptCache, requiresResponsesApi) => { + handler = new LiteLLMHandler({ ...mockOptions, litellmUsePromptCache }) + vi.spyOn(handler, "fetchModel").mockResolvedValue({ + id: "cache-model", + info: { ...litellmDefaultModelInfo, supportsPromptCache, requiresResponsesApi }, + }) + mockCreate.mockReturnValue({ withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }) }) + + await collectStream(handler.createMessage("System", [{ role: "user", content: "Hello" }])) + + expect(mockCreate.mock.calls[0][0].messages[0]).toEqual({ role: "system", content: "System" }) + }, + ) + it.each(["streaming", "completion"] as const)("omits temperature for metadata-disabled %s", async (mode) => { handler = new LiteLLMHandler({ ...mockOptions, modelTemperature: 0.7 }) vi.spyOn(handler, "fetchModel").mockResolvedValue({ @@ -527,6 +553,38 @@ describe("LiteLLMHandler", () => { expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") }) + it.each(["streaming", "completion"] as const)( + "uses standard token, temperature, and reasoning fields for ordinary %s", + async (mode) => { + handler = new LiteLLMHandler({ ...mockOptions }) + vi.spyOn(handler, "fetchModel").mockResolvedValue({ + id: "custom-model", + info: { + ...litellmDefaultModelInfo, + maxTokens: 4_096, + supportsTemperature: true, + supportsReasoningEffort: false, + }, + }) + + if (mode === "streaming") { + mockCreate.mockReturnValue({ + withResponse: vi.fn().mockResolvedValue({ data: asyncStreamFrom([]) }), + }) + await collectStream(handler.createMessage("System", [])) + } else { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "Response" } }] }) + await handler.completePrompt("Hello") + } + + const request = mockCreate.mock.calls[0][0] + expect(request.max_tokens).toBe(4_096) + expect(request).not.toHaveProperty("max_completion_tokens") + expect(request.temperature).toBe(0) + expect(request).not.toHaveProperty("reasoning_effort") + }, + ) + it("uses safe Astra parameters for completePrompt", async () => { handler = new LiteLLMHandler({ ...mockOptions, diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 6bc7dfe95a..94d3fc64a5 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -210,7 +210,14 @@ describe("NanoGptHandler", () => { ["array support", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low", "high"] }, "high"], ["unsupported effort", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low"] }, undefined], ["disabled effort", { reasoningEffort: "disable" }, { supportsReasoningEffort: ["low"] }, undefined], + [ + "listed disable sentinel", + { reasoningEffort: "disable" }, + { supportsReasoningEffort: ["disable", "low"] }, + undefined, + ], ["none effort", { reasoningEffort: "none" }, { supportsReasoningEffort: ["low"] }, undefined], + ["listed none sentinel", { reasoningEffort: "none" }, { supportsReasoningEffort: ["none", "low"] }, undefined], [ "disabled toggle", { reasoningEffort: "high", enableReasoningEffort: false }, @@ -437,6 +444,12 @@ describe("NanoGptHandler", () => { }) describe("completePrompt", () => { + it("omits temperature when it is not configured", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + await new NanoGptHandler({ nanoGptModelId: "model:thinking" }).completePrompt("prompt") + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + }) + it.each([ ["supported model", "temperature-model", undefined, 0.7], ["metadata-disabled model", "temperature-model", false, undefined], diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 165889daa8..d379aefd14 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -130,6 +130,25 @@ describe("OpenAiCodexHandler.getModel", () => { expect(handler["getReasoningEffort"](unsupportedModel)).toBeUndefined() }) + it("omits a required Codex fallback when it is the none sentinel", () => { + const handler = new OpenAiCodexHandler({ + apiModelId: "gpt-6-astra", + reasoningEffort: "high", + enableReasoningEffort: false, + }) + const model = handler.getModel() + const invalidModel = { + ...model, + info: { + ...model.info, + supportsReasoningEffort: ["none", "high"] as ModelInfo["supportsReasoningEffort"], + requiredReasoningEffort: true, + reasoningEffort: "none" as const, + }, + } + expect(handler["getReasoningEffort"](invalidModel)).toBeUndefined() + }) + it.each([ ["high", "medium", "high"], [undefined, "medium", "medium"], diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 8922c2cf9c..88a879bc47 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -146,6 +146,18 @@ describe("OpenAiNativeHandler - normalizeUsage", () => { expect(result).toMatchObject({ inputTokens: 100, cacheReadTokens: 20, cacheWriteTokens: 30 }) }) + it.each([ + [{ cached_tokens: 0, cache_miss_tokens: 7, cache_write_tokens: 0 }, 7], + [{ cached_tokens: 0, cache_miss_tokens: 0, cache_write_tokens: 7 }, 7], + ])("derives input totals when one nested detail is positive", (inputDetails, expected) => { + const result = handler["normalizeUsage"]( + { output_tokens: 0, input_tokens_details: inputDetails }, + getGpt6AstraModel(), + ) + + expect(result?.inputTokens).toBe(expected) + }) + it("should handle reasoning tokens in output details", () => { 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 4bd7ffb758..7b750ab013 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -220,6 +220,38 @@ describe("OpenAiNativeHandler", () => { expect(currentHandler["getReasoningEffort"](unsupportedModel)).toBeUndefined() }) + it("does not send the disable sentinel even if required model metadata lists it", () => { + const currentHandler = new OpenAiNativeHandler({ ...mockOptions, reasoningEffort: "disable" }) + const model = currentHandler.getModel() + const requiredModel = { + ...model, + info: { + ...model.info, + supportsReasoningEffort: ["disable", "medium"] as ModelInfo["supportsReasoningEffort"], + requiredReasoningEffort: true, + reasoningEffort: "medium" as const, + }, + } + + expect(currentHandler["getReasoningEffort"](requiredModel)).toBe("medium") + }) + + it("uses a scalar fallback when required reasoning cannot be disabled", () => { + const currentHandler = new OpenAiNativeHandler({ ...mockOptions, reasoningEffort: "disable" }) + const model = currentHandler.getModel() + const requiredModel = { + ...model, + info: { + ...model.info, + supportsReasoningEffort: true, + requiredReasoningEffort: true, + reasoningEffort: "medium" as const, + }, + } + + expect(currentHandler["getReasoningEffort"](requiredModel)).toBe("medium") + }) + it.each([ ["high", "medium", "high"], [undefined, "medium", "medium"], diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 2c0cf6679a..6bcb72385d 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -439,7 +439,19 @@ describe("VercelAiGatewayHandler", () => { ["array support", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low", "high"] }, "high"], ["unsupported effort", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low"] }, undefined], ["disabled effort", { reasoningEffort: "disable" }, { supportsReasoningEffort: ["low"] }, undefined], + [ + "listed disable sentinel", + { reasoningEffort: "disable" }, + { supportsReasoningEffort: ["disable", "low"] }, + undefined, + ], ["none effort", { reasoningEffort: "none" }, { supportsReasoningEffort: ["low"] }, undefined], + [ + "listed none sentinel", + { reasoningEffort: "none" }, + { supportsReasoningEffort: ["none", "low"] }, + undefined, + ], [ "disabled toggle", { reasoningEffort: "high", enableReasoningEffort: false }, @@ -790,6 +802,7 @@ describe("VercelAiGatewayHandler", () => { max_completion_tokens: 64000, }), ) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") }) it("uses custom temperature for completion", async () => { diff --git a/src/api/providers/fetchers/nanogpt.ts b/src/api/providers/fetchers/nanogpt.ts index d7133e4df9..8193556c69 100644 --- a/src/api/providers/fetchers/nanogpt.ts +++ b/src/api/providers/fetchers/nanogpt.ts @@ -5,9 +5,6 @@ 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 isNanoGptAstra = (modelId: string): boolean => - modelId === "openai/gpt-6-astra" || modelId === "openai/gpt-6-astra-pro" - const nanoGptPricingSchema = z.object({ prompt: z.number().nonnegative().optional(), completion: z.number().nonnegative().optional(), @@ -65,7 +62,7 @@ export const parseNanoGptModel = (model: NanoGptModel): ModelInfo => ({ ...(model.pricing?.cacheWriteInputPer1kTokens !== undefined ? { cacheWritesPrice: model.pricing.cacheWriteInputPer1kTokens * 1_000 } : {}), - ...(isNanoGptAstra(model.id) + ...(model.id === "openai/gpt-6-astra" || model.id === "openai/gpt-6-astra-pro" ? { supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"] as const, requiredReasoningEffort: true, diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 815c168c3f..25bae2d1bd 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -31,9 +31,6 @@ type NanoGptCachingRequest = { caching?: true } const NANO_GPT_MERGED_TOOL_RESULT_MODELS = new Set(["meta/muse-spark-1.2-contributor"]) -const isNanoGptAstra = (modelId: string): boolean => - modelId === "openai/gpt-6-astra" || modelId === "openai/gpt-6-astra-pro" - function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): ReasoningEffortExtended | undefined { const configured = options.reasoningEffort const reasoningDisabled = @@ -95,7 +92,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: canonicalModelId, info } = await this.fetchModel() - const isAstra = isNanoGptAstra(canonicalModelId) + const isAstra = canonicalModelId === "openai/gpt-6-astra" || canonicalModelId === "openai/gpt-6-astra-pro" const body: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & NanoGptCachingRequest = { model: this.getRequestModelId(canonicalModelId), messages: [ diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index bf376dc913..13d3ef377f 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -42,9 +42,6 @@ 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 isResponsesLiteModel = (modelId: OpenAiCodexModelId): boolean => - modelId === LUNA_MODEL_ID || modelId === "gpt-6-astra" - /** * A refusal is streamed as text so the chat still shows why the model declined, but it is not part * of the answer: the Responses API keeps refusals out of `output_text`. `completePrompt` relies on @@ -275,9 +272,10 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const baseRequestBody = this.buildRequestBody(model, formattedInput, systemPrompt, reasoningEffort, metadata) let requestBody: any try { - requestBody = isResponsesLiteModel(model.id) - ? this.buildResponsesLiteRequestBody(baseRequestBody, effectiveSessionId) - : baseRequestBody + requestBody = + model.id === LUNA_MODEL_ID || model.id === "gpt-6-astra" + ? this.buildResponsesLiteRequestBody(baseRequestBody, effectiveSessionId) + : baseRequestBody } catch (error) { const message = error instanceof Error ? error.message : String(error) TelemetryService.instance.captureException( @@ -1263,7 +1261,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion effectiveSessionId: string, accountId?: string | null, ): Record { - const usesResponsesLite = isResponsesLiteModel(model.id) + const usesResponsesLite = model.id === LUNA_MODEL_ID || model.id === "gpt-6-astra" return { originator: "zoo-code", session_id: effectiveSessionId, diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index e8d23a0c68..e4c25802a2 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -127,11 +127,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // 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 || writesFromDetails > 0) - ) { + if (totalInputTokens === 0 && inputDetails) { totalInputTokens = cachedFromDetails + missFromDetails + writesFromDetails } From c59924987937ae3d748857800f0ebd13c808323b Mon Sep 17 00:00:00 2001 From: "@navedmerchant" <14171946+navedmerchant@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:28:42 +0000 Subject: [PATCH 3/3] test(api): cover Astra Pro routing --- src/api/providers/__tests__/nanogpt.spec.ts | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 94d3fc64a5..9bfe358809 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -205,6 +205,30 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") }) + it("uses safe parallel-tool handling for the Astra Pro route", async () => { + const modelId = "openai/gpt-6-astra-pro" + 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 }).createMessage("sys", messages, { + taskId: "task", + parallelToolCalls: true, + }), + ) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ model: modelId, parallel_tool_calls: false }) + }) + it.each([ ["boolean support", { reasoningEffort: "high" }, { supportsReasoningEffort: true }, "high"], ["array support", { reasoningEffort: "high" }, { supportsReasoningEffort: ["low", "high"] }, "high"],