diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 27a84363f6..2955c752e0 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -17,14 +17,15 @@ const MOCK_TIMEOUT_MS = 300_000 import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { providerIdentifiers } from "@roo-code/types" +import { providerIdentifiers, type ModelRecord } from "@roo-code/types" import { OpenRouterHandler } from "../openrouter" import { Package } from "../../../shared/package" -import { makeApiHandlerOptions } from "../../../test-utils/api" +import { makeApiHandlerOptions, makeCreateMessageMetadata } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { collectStreamAndParseToolCalls } from "../../../test-utils/native-tool-call-stream" import { clearAllMocks } from "../../../test-utils/reset" +import { settlesWithin } from "../../../test-utils/promise" vitest.mock("openai") vitest.mock("delay", () => ({ @@ -109,6 +110,8 @@ vitest.mock("../fetchers/modelCache", () => ({ }), })) +const ABORT_SETTLE_MS = 150 + describe("OpenRouterHandler", () => { const mockOptions = makeApiHandlerOptions({ openRouterApiKey: "test-key", @@ -294,7 +297,10 @@ describe("OpenRouterHandler", () => { temperature: 0, top_p: undefined, }), - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, + { + headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" }, + signal: expect.any(AbortSignal), + }, ) }) @@ -337,7 +343,10 @@ describe("OpenRouterHandler", () => { }), ]), }), - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, + { + headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" }, + signal: expect.any(AbortSignal), + }, ) }) @@ -680,171 +689,1683 @@ describe("OpenRouterHandler", () => { { type: "tool_call_delta", id: "call_openrouter_b", delta: '{"path":"b' }, ]) }) - }) - describe("completePrompt", () => { - it("returns correct response", async () => { + it("throws an OpenRouter API error for a streamed error chunk and reports telemetry", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockResponse = { choices: [{ message: { content: "test completion" } }] } + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { content: "ok" } }] }, + { + id: "1", + error: { + message: "Upstream failed", + code: 500, + metadata: { raw: '{"message":"upstream: boom"}' }, + }, + }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } - const mockCreate = vitest.fn().mockResolvedValue(mockResponse) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) - const result = await handler.completePrompt("test prompt") + await expect(collectStream(stream)).rejects.toThrow("OpenRouter API Error 500: upstream: boom") + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "upstream: boom", + provider: providerIdentifiers.openrouter, + modelId: "anthropic/claude-sonnet-4", + operation: "createMessage", + }), + ) + }) + it("rejects with AbortError when the external signal is pre-aborted", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // The constructor loads models in the background; the pre-aborted path must fail + // fast without starting a second discovery lookup. + const { getModels } = await import("../fetchers/modelCache") + const lookupsBefore = vitest.mocked(getModels).mock.calls.length + + const controller = new AbortController() + controller.abort() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + await expect( + handler.createMessage("test", [{ role: "user" as const, content: "hi" }], metadata).next(), + ).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + expect(vitest.mocked(getModels).mock.calls.length).toBe(lookupsBefore) + }) - expect(result).toBe("test completion") + it("rejects with AbortError when the external signal aborts during deferred model discovery", async () => { + const handler = new OpenRouterHandler(mockOptions) + const controller = new AbortController() + + const mockCreate = vitest.fn() + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // Model discovery is deferred: capture the resolver and settle it only at the end of + // the test, so the abort deterministically lands while the lookup is still pending. + // The barrier below (instead of a fixed sleep) synchronizes on the lookup starting. + let resolveModelLookup!: (models: ModelRecord) => void + const deferredModelLookup = new Promise((resolve) => { + resolveModelLookup = resolve + }) + let notifyLookupStarted!: () => void + const lookupStarted = new Promise((resolve) => { + notifyLookupStarted = resolve + }) + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockImplementationOnce(() => { + notifyLookupStarted() + return deferredModelLookup + }) - expect(mockCreate).toHaveBeenCalledWith( - { - model: mockOptions.openRouterModelId, - max_tokens: 8192, - temperature: 0, - messages: [{ role: "user", content: "test prompt" }], - stream: false, - }, - { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, - ) + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const generator = handler.createMessage("test", [{ role: "user" as const, content: "hi" }], metadata) + + const nextPromise = generator.next() + await settlesWithin(lookupStarted, ABORT_SETTLE_MS) + controller.abort() + + await expect(settlesWithin(nextPromise, ABORT_SETTLE_MS)).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + + // Settle the abandoned lookup so it cannot outlive the test. + resolveModelLookup({}) }) - it("handles API errors and captures telemetry", async () => { + it("aborts the in-flight stream and rejects with AbortError when the external signal aborts", async () => { const handler = new OpenRouterHandler(mockOptions) - const mockError = { - error: { - message: "API Error", - code: 500, - }, - } + const controller = new AbortController() - const mockCreate = vitest.fn().mockResolvedValue(mockError) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + let requestSignal: AbortSignal | undefined + const mockCreate = vitest + .fn() + .mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + requestSignal = options?.signal + // Emulate the OpenAI SDK: the first chunk arrives, then the in-flight + // response body rejects once the request signal aborts. + return (async function* () { + yield { id: "1", choices: [{ delta: { content: "first" } }] } + await new Promise((resolve) => { + expect(requestSignal).toBeDefined() + if (requestSignal!.aborted) { + resolve() + } else { + requestSignal!.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + })() + }) + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const generator = handler.createMessage("test", [{ role: "user" as const, content: "hi" }], metadata) + + const chunks: unknown[] = [] + const iteration = (async () => { + for await (const chunk of generator) { + chunks.push(chunk) + if (chunk.type === "text") { + // Abort while the stream is still in flight. + controller.abort() + } + } + })() - await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error") + await expect(settlesWithin(iteration, ABORT_SETTLE_MS)).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + expect(chunks).toContainEqual({ type: "text", text: "first" }) + }) + it("cancels the in-flight stream when the consumer abandons the generator", async () => { + const handler = new OpenRouterHandler(mockOptions) - // Verify telemetry was captured - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "API Error", - provider: providerIdentifiers.openrouter, - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", - errorCode: 500, - status: 500, - }), - ) + // Emulate the OpenAI SDK: the first chunk arrives, then the response body + // stalls until the request signal aborts — no further chunk arrives on its own. + let requestSignal: AbortSignal | undefined + const mockCreate = vitest + .fn() + .mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + requestSignal = options?.signal + return (async function* () { + yield { id: "1", choices: [{ delta: { content: "first" } }] } + await new Promise((resolve) => { + expect(requestSignal).toBeDefined() + if (requestSignal!.aborted) { + resolve() + } else { + requestSignal!.addEventListener("abort", () => resolve(), { once: true }) + } + }) + yield { id: "2", choices: [{ delta: { content: "second" } }] } + })() + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const generator = handler.createMessage("test", [{ role: "user" as const, content: "hi" }]) + + const first = await settlesWithin(generator.next(), ABORT_SETTLE_MS) + expect(first.value).toEqual({ type: "text", text: "first" }) + expect(requestSignal?.aborted).toBe(false) + + // Abandon the generator mid-stream: the finally block must abort the per-request + // controller so the in-flight stream is cancelled instead of lingering until + // the client-level timeout. + await settlesWithin(generator.return(undefined), ABORT_SETTLE_MS) + expect(requestSignal?.aborted).toBe(true) }) + it("does not emit buffered chunks after a mid-stream abort (iterator keeps delivering)", async () => { + const handler = new OpenRouterHandler(mockOptions) + const controller = new AbortController() - it("handles unexpected errors and captures telemetry", async () => { + // Simulate openai@5.23.2 delivering a buffered chunk after the abort has already + // fired, then ending the iterator normally (no throw). + let requestSignal: AbortSignal | undefined + const mockCreate = vitest + .fn() + .mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + requestSignal = options?.signal + return (async function* () { + yield { id: "1", choices: [{ delta: { content: "partial" } }] } + // Wait for the abort instead of polling: the buffered chunk is delivered + // once the request signal aborts. + await new Promise((resolve) => { + expect(requestSignal).toBeDefined() + if (requestSignal!.aborted) { + resolve() + } else { + requestSignal!.addEventListener("abort", () => resolve(), { once: true }) + } + }) + yield { id: "2", choices: [{ delta: { content: "after-abort" } }] } + })() + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const generator = handler.createMessage("test", [{ role: "user" as const, content: "hi" }], metadata) + + const first = await generator.next() + expect(first.value).toEqual({ type: "text", text: "partial" }) + // Abort mid-stream, after the first chunk has been yielded. + controller.abort() + + // The buffered second chunk must not be emitted, and the generator must reject + // with the provider AbortError. + await expect(settlesWithin(generator.next(), ABORT_SETTLE_MS)).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + }) + it("registers the external abort listener with { once: true } and removes the exact reference on completion", async () => { const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Unexpected error") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSpy = vi.spyOn(controller.signal, "removeEventListener") + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const stream = handler.createMessage("test", [{ role: "user" as const, content: "hi" }], metadata) + await collectStream(stream) + + // The listener must be registered once with self-removal, and the exact + // registered reference must be removed once the request completes. + const registeredListener = addSpy.mock.calls[0]?.[1] + expect(registeredListener).toBeTypeOf("function") + expect(addSpy).toHaveBeenCalledWith("abort", registeredListener, { once: true }) + expect(removeSpy).toHaveBeenCalledWith("abort", registeredListener) + addSpy.mockRestore() + removeSpy.mockRestore() + }) - await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") + it("resets the reasoning_details accumulator between requests on the same handler", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-pro", + }), + ) + const mockCreate = vitest.fn() + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // Request 1: a Gemini stream with reasoning_details populates the handler's + // accumulator when the stream completes. + mockCreate.mockResolvedValueOnce( + asyncStreamFrom([ + { + id: "1", + choices: [ + { delta: { reasoning_details: [{ index: 0, type: "reasoning.text", text: "thinking" }] } }, + ], + }, + { id: "2", choices: [{ delta: { content: "answer" } }] }, + ]), + ) + await collectStream(handler.createMessage("test", [{ role: "user" as const, content: "hi" }])) + expect(handler.getReasoningDetails()?.length ?? 0).toBeGreaterThan(0) + + // Request 2 on the same handler must start from an empty accumulator so the + // previous request's details cannot leak into the next one. + mockCreate.mockResolvedValueOnce(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + await collectStream(handler.createMessage("test", [{ role: "user" as const, content: "hi" }])) + expect(handler.getReasoningDetails()).toBeUndefined() + }) - // Verify telemetry was captured (filtering now happens inside PostHogTelemetryClient) - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Unexpected error", - provider: providerIdentifiers.openrouter, - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", + it("excludes reasoning for Gemini 2.5 Pro models by default", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-pro-preview", }), ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ reasoning: { exclude: true } }), + expect.any(Object), + ) }) - it("passes SDK exceptions with status 429 to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Rate limit exceeded: free-models-per-day") as any - error.status = 429 - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + it("excludes reasoning for the non-preview Gemini 2.5 Pro model by default", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-pro", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } - await expect(handler.completePrompt("test prompt")).rejects.toThrow("Rate limit exceeded") + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Rate limit exceeded: free-models-per-day", - provider: providerIdentifiers.openrouter, - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ reasoning: { exclude: true } }), + expect.any(Object), + ) + }) + + it("does not inject reasoning exclusion for non-Gemini models without configured reasoning", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "openai/gpt-4o", }), ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { reasoning?: unknown } + expect(params.reasoning).toBeUndefined() }) - it("passes SDK exceptions with 429 in message to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const error = new Error("429 Rate limit exceeded: free-models-per-day") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + it("keeps user-configured reasoning for Gemini 2.5 Pro models instead of overriding it", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-pro", + enableReasoningEffort: true, + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // The constructor already consumed the default model-cache mock; serve a Gemini + // entry that advertises reasoning-budget support to fetchModel so getModelParams + // resolves a user reasoning budget for this request. + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockImplementationOnce(async () => ({ + "google/gemini-2.5-pro": { + maxTokens: 65536, + contextWindow: 1048576, + supportsPromptCache: false, + supportsReasoningBudget: true, + }, + })) - await expect(handler.completePrompt("test prompt")).rejects.toThrow("429 Rate limit exceeded") + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "429 Rate limit exceeded: free-models-per-day", - provider: providerIdentifiers.openrouter, - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", + const params = mockCreate.mock.calls[0][0] as { reasoning?: { max_tokens?: number; exclude?: boolean } } + expect(params.reasoning).toEqual({ max_tokens: 128 }) + }) + + it("omits the anthropic beta header for non-Anthropic models", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "openai/gpt-4o", }), ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { messages: { role: string; content: unknown }[] } + expect(params.messages[0]).toEqual({ role: "system", content: "system" }) + + const options = mockCreate.mock.calls[0][1] as { headers?: Record; signal?: AbortSignal } + expect(options).not.toHaveProperty("headers") + expect(options.signal).toBeInstanceOf(AbortSignal) }) - it("passes SDK exceptions containing 'rate limit' to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const error = new Error("Request failed due to rate limit") - const mockCreate = vitest.fn().mockRejectedValue(error) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + it("uses user role for the system prompt with DeepSeek R1 models", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-r1", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } - await expect(handler.completePrompt("test prompt")).rejects.toThrow("rate limit") + const stream = handler.createMessage("system prompt", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) - // captureException is called, but PostHogTelemetryClient filters out rate limit errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Request failed due to rate limit", - provider: providerIdentifiers.openrouter, - modelId: mockOptions.openRouterModelId, - operation: "completePrompt", + const params = mockCreate.mock.calls[0][0] as { messages: { role: string; content: unknown }[] } + expect(params.messages[0].role).toBe("user") + expect(params.messages.map((m) => m.role)).not.toContain("system") + }) + + it("uses user role for the system prompt with Perplexity Sonar Reasoning", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "perplexity/sonar-reasoning", }), ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("system prompt", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { messages: { role: string; content: unknown }[] } + expect(params.messages[0].role).toBe("user") + expect(params.messages.map((m) => m.role)).not.toContain("system") }) - it("passes 429 rate limit errors from response to telemetry (filtering happens in PostHogTelemetryClient)", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockError = { - error: { - message: "Rate limit exceeded", - code: 429, - }, - } + it("pins provider order and only for a specific openRouterSpecificProvider", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterSpecificProvider: providerIdentifiers.anthropic, + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } - const mockCreate = vitest.fn().mockResolvedValue(mockError) - ;(OpenAI as any).prototype.chat = { - completions: { create: mockCreate }, - } as any + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - "OpenRouter API Error 429: Rate limit exceeded", + const params = mockCreate.mock.calls[0][0] as { + provider?: { order?: string[]; only?: string[]; allow_fallbacks?: boolean } + } + expect(params.provider).toEqual({ + order: [providerIdentifiers.anthropic], + only: [providerIdentifiers.anthropic], + allow_fallbacks: false, + }) + }) + + it("omits the provider option when openRouterSpecificProvider is [default]", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterSpecificProvider: "[default]", + }), ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } - // captureException is called, but PostHogTelemetryClient filters out 429 errors internally - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Rate limit exceeded", - provider: providerIdentifiers.openrouter, + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { provider?: unknown } + expect(params).not.toHaveProperty("provider") + }) + + it("omits the provider option when openRouterSpecificProvider is unset", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { provider?: unknown } + expect(params).not.toHaveProperty("provider") + }) + + it("injects a fake encrypted reasoning block for Gemini tool calls without encrypted reasoning", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-flash", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // reasoning_details is an OpenRouter extension field round-tripped on assistant + // messages; the Anthropic SDK types do not include it, hence the structural cast. + const assistantMessage = { + role: "assistant" as const, + content: [{ type: "tool_use" as const, id: "toolu_01", name: "get_weather", input: { city: "SF" } }], + reasoning_details: [{ type: "reasoning.text", id: "toolu_01", text: "thinking", index: 0 }], + } + const stream = handler.createMessage("system", [ + assistantMessage as unknown as Anthropic.Messages.MessageParam, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { + role: string + tool_calls?: { id: string }[] + reasoning_details?: { type: string; id: string; data: string }[] + }[] + } + const assistant = params.messages.find((m) => m.role === "assistant") + expect(assistant?.tool_calls).toHaveLength(1) + const encrypted = assistant?.reasoning_details?.find((d) => d.type === "reasoning.encrypted") + expect(encrypted).toMatchObject({ + id: "toolu_01", + data: "skip_thought_signature_validator", + format: "google-gemini-v1", + index: 0, + }) + }) + + it("keeps the matching reasoning detail when sanitizing Gemini tool call messages", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-flash", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const assistantMessage = { + role: "assistant" as const, + content: [{ type: "tool_use" as const, id: "toolu_01", name: "get_weather", input: { city: "SF" } }], + reasoning_details: [{ type: "reasoning.text", id: "toolu_01", text: "thinking", index: 0 }], + } + const stream = handler.createMessage("system", [ + assistantMessage as unknown as Anthropic.Messages.MessageParam, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { + role: string + tool_calls?: { id: string }[] + reasoning_details?: { type: string; id?: string; text?: string }[] + }[] + } + const assistant = params.messages.find((m) => m.role === "assistant") + expect(assistant?.tool_calls).toHaveLength(1) + const textDetail = assistant?.reasoning_details?.find((d) => d.type === "reasoning.text") + expect(textDetail).toMatchObject({ id: "toolu_01", text: "thinking" }) + const encrypted = assistant?.reasoning_details?.find((d) => d.type === "reasoning.encrypted") + expect(encrypted).toMatchObject({ id: "toolu_01" }) + }) + + it("drops tool calls without reasoning details but keeps the content for Gemini messages", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-flash", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const assistantMessage = { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "kept content" }, + { type: "tool_use" as const, id: "toolu_02", name: "get_weather", input: { city: "NY" } }, + ], + } + const stream = handler.createMessage("system", [ + assistantMessage as unknown as Anthropic.Messages.MessageParam, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { role: string; content?: unknown; tool_calls?: unknown }[] + } + const assistant = params.messages.find((m) => m.role === "assistant") + expect(assistant).toBeDefined() + expect(assistant?.content).toBe("kept content") + expect(assistant).not.toHaveProperty("tool_calls") + }) + + it("drops tool calls without a matching reasoning detail id for Gemini messages", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-flash", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const assistantMessage = { + role: "assistant" as const, + content: [ + { type: "tool_use" as const, id: "toolu_03", name: "get_weather", input: { city: "SF" } }, + { type: "tool_use" as const, id: "toolu_04", name: "search", input: { q: "x" } }, + ], + reasoning_details: [{ type: "reasoning.text", id: "toolu_03", text: "thinking", index: 0 }], + } + const stream = handler.createMessage("system", [ + assistantMessage as unknown as Anthropic.Messages.MessageParam, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { role: string; tool_calls?: { id: string }[] }[] + } + const assistant = params.messages.find((m) => m.role === "assistant") + expect(assistant?.tool_calls).toHaveLength(1) + expect(assistant?.tool_calls?.[0].id).toBe("toolu_03") + }) + + it("accumulates and yields reasoning_details from streamed chunks", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { reasoning: "top-level thinking" } }] }, + { + id: "2", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.text", index: 0, text: "thinking " }] } }, + ], + }, + { + id: "3", + choices: [ + { + delta: { + reasoning_details: [ + { + type: "reasoning.text", + index: 0, + text: "more", + id: "r1", + format: "google-gemini-v1", + signature: "sig", + }, + ], + }, + }, + ], + }, + { + id: "4", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.summary", index: 1, summary: "sum" }] } }, + ], + }, + { id: "5", choices: [{ delta: { content: "hello" } }] }, + { + id: "6", + choices: [ + { + delta: { + reasoning_details: [{ type: "reasoning.summary", index: 1, summary: " more" }], + }, + }, + ], + }, + { + id: "7", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.encrypted", index: 2, data: "enc-" }] } }, + ], + }, + { + id: "8", + choices: [ + { + delta: { + reasoning_details: [{ type: "reasoning.encrypted", index: 2, data: "rypted" }], + }, + }, + ], + }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + + expect(chunks).toContainEqual({ type: "reasoning", text: "top-level thinking" }) + expect(chunks).toContainEqual({ type: "reasoning", text: "thinking " }) + expect(chunks).toContainEqual({ type: "reasoning", text: "sum" }) + expect(chunks).toContainEqual({ type: "reasoning", text: " more" }) + expect(chunks).toContainEqual({ type: "text", text: "hello" }) + + const details = handler.getReasoningDetails() + expect(details).toHaveLength(3) + expect(details?.find((d) => d.type === "reasoning.summary")?.summary).toBe("sum more") + expect(details?.find((d) => d.type === "reasoning.encrypted")?.data).toBe("enc-rypted") + }) + + it("ignores chunks with empty choices", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [] }, + { id: "1", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + }) + + it("keeps id, format and signature from the first reasoning chunk on updates", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { + id: "1", + choices: [ + { + delta: { + reasoning_details: [ + { + type: "reasoning.text", + index: 0, + id: "det-1", + format: "google-gemini-v1", + signature: "sig-1", + }, + ], + }, + }, + ], + }, + { + id: "1", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.text", index: 0, text: "more" }] } }, + ], + }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await collectStream(handler.createMessage("system", [{ role: "user" as const, content: "hi" }])) + + const details = handler.getReasoningDetails() + expect(details).toHaveLength(1) + expect(details?.[0]).toMatchObject({ + type: "reasoning.text", + text: "more", + id: "det-1", + format: "google-gemini-v1", + signature: "sig-1", + index: 0, + }) + }) + + it("initializes empty accumulator entries without undefined prefixes", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", index: 0 }] } }] }, + { + id: "1", + choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", index: 0, text: "t0" }] } }], + }, + { id: "1", choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", index: 1 }] } }] }, + { + id: "1", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.text", index: 1, summary: "s1" }] } }, + ], + }, + { id: "1", choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", index: 2 }] } }] }, + { + id: "1", + choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", index: 2, data: "d2" }] } }], + }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await collectStream(handler.createMessage("system", [{ role: "user" as const, content: "hi" }])) + + const details = handler.getReasoningDetails() + expect(details).toHaveLength(3) + expect(details?.find((d) => d.index === 0)?.text).toBe("t0") + expect(details?.find((d) => d.index === 1)?.summary).toBe("s1") + expect(details?.find((d) => d.index === 2)?.data).toBe("d2") + }) + + it("groups reasoning details without an explicit index under index 0", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", text: "a" }] } }] }, + { + id: "1", + choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", text: "b", index: 0 }] } }], + }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await collectStream(handler.createMessage("system", [{ role: "user" as const, content: "hi" }])) + + const details = handler.getReasoningDetails() + expect(details).toHaveLength(1) + expect(details?.[0]?.text).toBe("ab") + expect(details?.[0]?.index).toBe(0) + }) + + it("accumulates reasoning text and summary with the same index separately", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { + id: "1", + choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", index: 0, text: "a" }] } }], + }, + { + id: "1", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.summary", index: 0, summary: "s" }] } }, + ], + }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toContainEqual({ type: "reasoning", text: "a" }) + + const details = handler.getReasoningDetails() + expect(details).toHaveLength(1) + expect(details?.[0]?.text).toBe("a") + }) + + it("does not yield displayable reasoning for encrypted-only details", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { + id: "1", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.encrypted", index: 0, data: "enc" }] } }, + ], + }, + { id: "1", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + expect(chunks).not.toContainEqual(expect.objectContaining({ type: "reasoning" })) + + const details = handler.getReasoningDetails() + expect(details).toHaveLength(1) + expect(details?.[0]?.data).toBe("enc") + }) + + it("ignores non-array reasoning_details values", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { reasoning_details: "not-an-array" } }] }, + { id: "1", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + expect(handler.getReasoningDetails()).toBeUndefined() + }) + + it("ignores object reasoning_details values", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { reasoning_details: { bogus: true } } }] }, + { id: "1", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + expect(handler.getReasoningDetails()).toBeUndefined() + }) + + it("rejects with AbortError when the external signal aborts during request creation", async () => { + const handler = new OpenRouterHandler(mockOptions) + const controller = new AbortController() + + // Deterministic synchronization (mirrors the Requesty test): the mock notifies the + // test when the request actually starts, so the abort lands after create() began + // instead of racing a fixed sleep that a slow runner can lose. + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + const mockCreate = vitest + .fn() + .mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + notifyCreateStarted() + // Emulate the OpenAI SDK: the pending request rejects when the signal aborts. + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }], metadata) + + const nextPromise = generator.next() + // Abort only once create() has actually started. + await createStarted + controller.abort() + + await expect(nextPromise).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + }) + + it("normalizes Mistral tool call ids in tool result messages", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "Mistral/mixtral-large", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("system", [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "hi" }, + { type: "tool_result" as const, tool_use_id: "toolu_123456789", content: "tool result ok" }, + ], + }, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { role: string; tool_call_id?: string; content: unknown }[] + } + const toolMessage = params.messages.find((m) => m.role === "tool") + expect(toolMessage).toMatchObject({ role: "tool", tool_call_id: "toolu1234", content: "tool result ok" }) + }) + + it("uses user role for the system prompt with suffixed DeepSeek R1 model ids", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-r1-0528", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("system prompt", [ + { role: "assistant" as const, content: "earlier reply" }, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { messages: { role: string; content: unknown }[] } + expect(params.messages[0]).toMatchObject({ role: "user", content: "system prompt" }) + expect(params.messages[1]).toMatchObject({ role: "assistant", content: "earlier reply" }) + expect(params.messages.some((m) => m.role === "system")).toBe(false) + }) + + it("adds gemini cache breakpoints and the default max tokens for google/gemini models", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-flash", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const stream = handler.createMessage("sys-k1", [{ role: "user" as const, content: "hello" }]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { role: string; content: unknown }[] + max_tokens?: number + } + expect(params.messages[0]).toEqual({ + role: "system", + content: [{ type: "text", text: "sys-k1", cache_control: { type: "ephemeral" } }], + }) + expect(params.messages[1]).toEqual({ role: "user", content: [{ type: "text", text: "hello" }] }) + expect(params.max_tokens).toBe(8192) + }) + + it("skips gemini sanitization for non-google gemini model ids", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "openai/gemini-2.5-flash", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // No reasoning_details at all: gemini sanitization would drop this tool call, so a + // surviving tool call proves the sanitization path was skipped. + const assistantMessage = { + role: "assistant" as const, + content: [{ type: "tool_use" as const, id: "toolu_k2", name: "lookup", input: {} }], + } + const stream = handler.createMessage("system", [ + assistantMessage as unknown as Anthropic.Messages.MessageParam, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { role: string; tool_calls?: unknown[]; reasoning_details?: unknown[] }[] + } + const assistant = params.messages.find((m) => m.role === "assistant") + expect(assistant?.tool_calls).toHaveLength(1) + expect(assistant).not.toHaveProperty("reasoning_details") + }) + + it("does not inject fake encrypted reasoning when the assistant already carries encrypted details", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "google/gemini-2.5-flash", + }), + ) + const mockCreate = vitest + .fn() + .mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }])) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // The first assistant mixes a text detail with an encrypted one; the second carries + // only encrypted details so the every()/==/-!== variants each diverge somewhere. + const assistantOne = { + role: "assistant" as const, + content: [ + { type: "tool_use" as const, id: "toolu_05", name: "lookup_a", input: {} }, + { type: "tool_use" as const, id: "toolu_06", name: "lookup_b", input: {} }, + ], + reasoning_details: [ + { type: "reasoning.text", id: "toolu_05", text: "t", index: 0 }, + { type: "reasoning.encrypted", id: "toolu_06", data: "orig-enc", index: 1 }, + ], + } + const assistantTwo = { + role: "assistant" as const, + content: [ + { type: "tool_use" as const, id: "toolu_07", name: "lookup_c", input: {} }, + { type: "tool_use" as const, id: "toolu_08", name: "lookup_d", input: {} }, + ], + reasoning_details: [ + { type: "reasoning.encrypted", id: "toolu_07", data: "enc-7", index: 0 }, + { type: "reasoning.encrypted", id: "toolu_08", data: "enc-8", index: 1 }, + ], + } + const stream = handler.createMessage("system", [ + assistantOne as unknown as Anthropic.Messages.MessageParam, + assistantTwo as unknown as Anthropic.Messages.MessageParam, + ]) + await collectStream(stream) + + const params = mockCreate.mock.calls[0][0] as { + messages: { + role: string + tool_calls?: { id: string }[] + reasoning_details?: { type: string; data?: string }[] + }[] + } + const assistants = params.messages.filter((m) => m.role === "assistant") + expect(assistants).toHaveLength(2) + expect(assistants[0].tool_calls).toHaveLength(2) + const firstEncrypted = + assistants[0].reasoning_details?.filter((d) => d.type === "reasoning.encrypted") ?? [] + expect(firstEncrypted).toHaveLength(1) + expect(firstEncrypted[0]).toMatchObject({ data: "orig-enc" }) + expect(assistants[1].tool_calls).toHaveLength(2) + const secondEncrypted = + assistants[1].reasoning_details?.filter((d) => d.type === "reasoning.encrypted") ?? [] + expect(secondEncrypted).toHaveLength(2) + }) + + it("keeps the last usage chunk when later chunks carry no usage", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { + id: "1", + choices: [], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + cost: 2, + cost_details: { upstream_inference_cost: 3 }, + }, + }, + { id: "2", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + const usage = chunks.find((c) => c.type === "usage") + expect(usage).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5, totalCost: 5 }) + }) + + it("merges id, format and signature into an existing reasoning detail", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { + id: "1", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.text", index: 0, text: "start" }] } }, + ], + }, + { + id: "2", + choices: [ + { + delta: { + reasoning_details: [ + { + type: "reasoning.text", + index: 0, + text: "more", + id: "det-1", + format: "google-gemini-v1", + signature: "sig-1", + }, + ], + }, + }, + ], + }, + { id: "3", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toContainEqual({ type: "reasoning", text: "start" }) + const details = handler.getReasoningDetails() + expect(details).toHaveLength(1) + expect(details?.[0]).toMatchObject({ + type: "reasoning.text", + text: "startmore", + id: "det-1", + format: "google-gemini-v1", + signature: "sig-1", + index: 0, + }) + }) + + it("ignores non-string text and summary values in reasoning details", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { + id: "1", + choices: [ + { + delta: { + reasoning_details: [ + { type: "reasoning.encrypted", index: 0, text: "secret", data: "enc" }, + { type: "reasoning.text", index: 1, text: 5 }, + { type: "reasoning.text", index: 2, summary: "leak" }, + { type: "reasoning.summary", index: 3, summary: 7 }, + { type: "reasoning.summary", index: 4, summary: "sum-ok" }, + { type: "reasoning.text", index: 5, text: "text-ok" }, + ], + }, + }, + ], + }, + { id: "2", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks.filter((c) => c.type === "reasoning")).toEqual([ + { type: "reasoning", text: "sum-ok" }, + { type: "reasoning", text: "text-ok" }, + ]) + }) + + it("yields top-level reasoning only for non-empty string values", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { reasoning: 5 } }] }, + { id: "2", choices: [{ delta: { reasoning: "" } }] }, + { id: "3", choices: [{ delta: { reasoning: "top" } }] }, + { id: "4", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks.filter((c) => c.type === "reasoning")).toEqual([{ type: "reasoning", text: "top" }]) + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + }) + + it("skips top-level reasoning once reasoning details have yielded displayable text", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { + id: "1", + choices: [ + { delta: { reasoning_details: [{ type: "reasoning.text", index: 0, text: "det" }] } }, + ], + }, + { id: "2", choices: [{ delta: { reasoning: "top" } }] }, + { id: "3", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks.filter((c) => c.type === "reasoning")).toEqual([{ type: "reasoning", text: "det" }]) + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + }) + + it("ignores non-array tool_calls values in stream deltas", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { tool_calls: "nope", content: "x" } }] }, + { id: "2", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks.some((c) => c.type === "tool_call_partial")).toBe(false) + expect(chunks).toContainEqual({ type: "text", text: "x" }) + }) + + it("emits tool_call_partial chunks for tool calls without a function payload", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue( + asyncStreamFrom([ + { id: "1", choices: [{ delta: { tool_calls: [{ index: 0, id: "c1" }], content: "x" } }] }, + { id: "2", choices: [{ delta: { content: "ok" } }] }, + ]), + ) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toContainEqual(expect.objectContaining({ type: "tool_call_partial", id: "c1", index: 0 })) + }) + + it("reports OpenRouter structured errors in createMessage with telemetry", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockRejectedValueOnce({ + error: { + message: "Model not found", + code: 404, + metadata: { raw: '{"message":"upstream: model not found"}' }, + }, + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + + await expect(generator.next()).rejects.toThrow(/completion error/) + expect(mockCaptureException).toHaveBeenCalledTimes(1) + }) + + it("prefers the raw metadata message over the SDK error message in createMessage telemetry", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockRejectedValueOnce({ + error: { + message: "Model not found", + code: 404, + metadata: { raw: '{"message":"upstream: model not found"}' }, + }, + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + + await expect(generator.next()).rejects.toThrow(/completion error/) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "upstream: model not found", + provider: providerIdentifiers.openrouter, + modelId: "anthropic/claude-sonnet-4", + operation: "createMessage", + status: 404, + }), + ) + }) + + it("falls back to the SDK error message in createMessage telemetry when no raw metadata is present", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockRejectedValueOnce({ + error: { + message: "Model not found", + code: 404, + }, + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + + await expect(generator.next()).rejects.toThrow(/completion error/) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Model not found", + provider: providerIdentifiers.openrouter, + modelId: "anthropic/claude-sonnet-4", + operation: "createMessage", + }), + ) + }) + + it("falls back to an unknown error in createMessage telemetry when no message is available", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockRejectedValueOnce({ + error: { + code: 500, + }, + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }]) + + await expect(generator.next()).rejects.toThrow(/completion error/) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + provider: providerIdentifiers.openrouter, + modelId: "anthropic/claude-sonnet-4", + operation: "createMessage", + }), + ) + }) + }) + + describe("completePrompt", () => { + it("returns correct response", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockResponse = { choices: [{ message: { content: "test completion" } }] } + + const mockCreate = vitest.fn().mockResolvedValue(mockResponse) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const result = await handler.completePrompt("test prompt") + + expect(result).toBe("test completion") + + expect(mockCreate).toHaveBeenCalledWith( + { + model: mockOptions.openRouterModelId, + max_tokens: 8192, + temperature: 0, + messages: [{ role: "user", content: "test prompt" }], + stream: false, + }, + { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, + ) + }) + + it("handles API errors and captures telemetry", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockError = { + error: { + message: "API Error", + code: 500, + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockError) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error") + + // Verify telemetry was captured + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API Error", + provider: providerIdentifiers.openrouter, + modelId: mockOptions.openRouterModelId, + operation: "completePrompt", + errorCode: 500, + status: 500, + }), + ) + }) + + it("handles unexpected errors and captures telemetry", async () => { + const handler = new OpenRouterHandler(mockOptions) + const error = new Error("Unexpected error") + const mockCreate = vitest.fn().mockRejectedValue(error) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") + + // Verify telemetry was captured (filtering now happens inside PostHogTelemetryClient) + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unexpected error", + provider: providerIdentifiers.openrouter, + modelId: mockOptions.openRouterModelId, + operation: "completePrompt", + }), + ) + }) + + it("passes SDK exceptions with status 429 to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + const handler = new OpenRouterHandler(mockOptions) + const error = new Error("Rate limit exceeded: free-models-per-day") as any + error.status = 429 + const mockCreate = vitest.fn().mockRejectedValue(error) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("Rate limit exceeded") + + // captureException is called, but PostHogTelemetryClient filters out 429 errors internally + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rate limit exceeded: free-models-per-day", + provider: providerIdentifiers.openrouter, + modelId: mockOptions.openRouterModelId, + operation: "completePrompt", + }), + ) + }) + + it("passes SDK exceptions with 429 in message to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + const handler = new OpenRouterHandler(mockOptions) + const error = new Error("429 Rate limit exceeded: free-models-per-day") + const mockCreate = vitest.fn().mockRejectedValue(error) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("429 Rate limit exceeded") + + // captureException is called, but PostHogTelemetryClient filters out 429 errors internally + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "429 Rate limit exceeded: free-models-per-day", + provider: providerIdentifiers.openrouter, + modelId: mockOptions.openRouterModelId, + operation: "completePrompt", + }), + ) + }) + + it("passes SDK exceptions containing 'rate limit' to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + const handler = new OpenRouterHandler(mockOptions) + const error = new Error("Request failed due to rate limit") + const mockCreate = vitest.fn().mockRejectedValue(error) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("rate limit") + + // captureException is called, but PostHogTelemetryClient filters out rate limit errors internally + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Request failed due to rate limit", + provider: providerIdentifiers.openrouter, + modelId: mockOptions.openRouterModelId, + operation: "completePrompt", + }), + ) + }) + + it("passes 429 rate limit errors from response to telemetry (filtering happens in PostHogTelemetryClient)", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockError = { + error: { + message: "Rate limit exceeded", + code: 429, + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockError) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "OpenRouter API Error 429: Rate limit exceeded", + ) + + // captureException is called, but PostHogTelemetryClient filters out 429 errors internally + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rate limit exceeded", + provider: providerIdentifiers.openrouter, modelId: mockOptions.openRouterModelId, operation: "completePrompt", errorCode: 429, @@ -852,5 +2373,273 @@ describe("OpenRouterHandler", () => { }), ) }) + it("should pass abort signal through to client", async () => { + const handler = new OpenRouterHandler(mockOptions) + const controller = new AbortController() + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ signal: controller.signal }), + ) + }) + + it("should pass timeout through to client", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await handler.completePrompt("test prompt", { timeoutMs: 5000 }) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ timeout: 5000 }), + ) + }) + + it("should work without options (backward compatible)", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const result = await handler.completePrompt("test prompt") + expect(result).toBe("response") + }) + + it("omits the anthropic beta header for non-Anthropic models", async () => { + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "openai/gpt-4o", + }), + ) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await handler.completePrompt("test prompt") + const options = mockCreate.mock.calls[0][1] as { headers?: Record } + expect(options).not.toHaveProperty("headers") + }) + + it("omits the signal and timeout options when timeoutMs is zero and no signal is provided", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + const options = mockCreate.mock.calls[0][1] as { signal?: AbortSignal; timeout?: number } + expect(options).not.toHaveProperty("signal") + expect(options).not.toHaveProperty("timeout") + }) + + it("propagates non-abort model-discovery failures from completePrompt", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // The constructor's background discovery consumed the default mock; make the next + // lookup (completePrompt's own fetchModel) reject with a non-abort failure. + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockRejectedValueOnce(new Error("discovery failed")) + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("discovery failed") + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("maps an abort-named model-discovery failure to a canonical AbortError in completePrompt", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + // The constructor's background discovery consumed the default mock; make the next + // lookup (completePrompt's own fetchModel) reject with an abort-named failure. + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" })) + + await expect(handler.completePrompt("test prompt")).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("rejects with AbortError when the request aborts before a late result lands", async () => { + const handler = new OpenRouterHandler(mockOptions) + const controller = new AbortController() + + // Deterministic synchronization: the mock notifies the test when create() has been + // called, so the abort lands after model discovery instead of racing it. + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + let resolveCreate!: (value: unknown) => void + const mockCreate = vitest.fn().mockImplementation(() => { + notifyCreateStarted() + return new Promise((resolve) => { + resolveCreate = resolve + }) + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + await createStarted + controller.abort() + resolveCreate({ choices: [{ message: { content: "late result" } }] }) + + await expect(promise).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + }) + + it("rejects with AbortError when the signal is pre-aborted", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const controller = new AbortController() + controller.abort() + + await expect( + handler.completePrompt("test prompt", { abortSignal: controller.signal }), + ).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + // The pre-abort guard rejects before any model lookup beyond the constructor's own. + const { getModels } = await import("../fetchers/modelCache") + expect(vitest.mocked(getModels).mock.calls.length).toBe(1) + }) + + it("rejects with AbortError when aborted mid-flight", async () => { + const handler = new OpenRouterHandler(mockOptions) + const controller = new AbortController() + + const mockCreate = vitest + .fn() + .mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + // Emulate the OpenAI SDK: the in-flight request rejects when the signal aborts. + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + }) + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + controller.abort() + + await expect(promise).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + }) + it("rejects with AbortError when only a timeout is provided and it elapses", async () => { + // Non-Anthropic model: also exercises the no-beta-header branch of requestOptions. + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "openai/gpt-4o", + }), + ) + const mockCreate = vitest + .fn() + .mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + // Emulate the OpenAI SDK: the in-flight request rejects when the signal times out. + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const timeoutError = new Error("TimeoutError: Request timed out.") + timeoutError.name = "TimeoutError" + throw timeoutError + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + await expect(handler.completePrompt("test prompt", { timeoutMs: 50 })).rejects.toMatchObject({ + name: "AbortError", + }) + }) + + it("rejects with AbortError when both an abort signal and a timeout are provided", async () => { + const handler = new OpenRouterHandler(mockOptions) + const controller = new AbortController() + + // Deterministic synchronization: the mock notifies the test when the request + // actually starts, so the abort lands mid-flight (after model lookup) instead of + // winning the race at model discovery on a slow runner. + let notifyCreateStarted!: () => void + const createStarted = new Promise((resolve) => { + notifyCreateStarted = resolve + }) + let requestSignal: AbortSignal | undefined + const mockCreate = vitest + .fn() + .mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + notifyCreateStarted() + requestSignal = options?.signal + await new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve() + } else { + options?.signal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The user aborted a request") + abortError.name = "AbortError" + throw abortError + }) + // The auto-mocked OpenAI client is injected via a structural type to avoid `any` casts. + const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } } + client.chat = { completions: { create: mockCreate } } + + const promise = handler.completePrompt("test prompt", { + abortSignal: controller.signal, + timeoutMs: 100_000, + }) + // Abort only once create() has actually started (after model lookup). + await createStarted + controller.abort() + + await expect(promise).rejects.toMatchObject({ + name: "AbortError", + message: "The OpenRouter request was aborted", + }) + // The SDK received a merged signal (not the caller's signal) plus the timeout. + expect(requestSignal).toBeDefined() + expect(requestSignal).not.toBe(controller.signal) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ timeout: 100_000 }), + ) + }) }) }) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index ed53c111b5..2e8649643a 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -36,6 +36,7 @@ import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/error-handler" +import { createAbortError, isRequestAborted, mergeAbortSignalAndTimeout, rejectOnAbort } from "./utils/abort-signal" import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" import { applyRouterToolPreferences } from "./utils/router-tool-preferences" @@ -210,334 +211,421 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): AsyncGenerator { - const model = await this.fetchModel() - - let { id: modelId, maxTokens, temperature, topP, reasoning } = model - - // Reset reasoning_details accumulator for this request - this.currentReasoningDetails = [] - - // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro models - // even if you don't request them. This is not the default for - // other providers (including Gemini), so we need to explicitly disable - // them unless the user has explicitly configured reasoning. - // Note: Gemini 3 models use reasoning_details format with thought signatures, - // but we handle this via skip_thought_signature_validator injection below. - if ( - (modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") && - typeof reasoning === "undefined" - ) { - reasoning = { exclude: true } + // Per-request AbortController: external aborts cancel the in-flight request + // without replacing the client-level timeout, which remains the default safety net. + const controller = new AbortController() + + // Bridge the external abort signal into the per-request controller: + // - pre-aborted guard: abort immediately when the signal is already aborted + // - { once: true }: the listener removes itself after the first abort + // - explicit removal in finally: the listener must not outlive a request that + // completes (or fails) without being aborted + const externalAbortSignal = metadata?.abortSignal + let removeExternalAbortListener: (() => void) | undefined + if (externalAbortSignal) { + if (externalAbortSignal.aborted) { + controller.abort() + } else { + const onExternalAbort = () => controller.abort() + externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) + removeExternalAbortListener = () => externalAbortSignal.removeEventListener("abort", onExternalAbort) + } } - // Convert Anthropic messages to OpenAI format. - // Pass normalization function for Mistral compatibility (requires 9-char alphanumeric IDs) - const isMistral = modelId.toLowerCase().includes("mistral") - let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages( - messages, - isMistral ? { normalizeToolCallId: normalizeMistralToolCallId } : undefined, - ), - ] - - // DeepSeek highly recommends using user instead of system role. - if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") { - openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - } + try { + // The request was already aborted before we started: fail fast without calling the API. + if (controller.signal.aborted) { + throw createAbortError("OpenRouter") + } - // Process reasoning_details when switching models to Gemini. - const isGemini = modelId.startsWith("google/gemini") - - // For Gemini models with native protocol: - // 1. Sanitize messages to handle thought signature validation issues. - // This must happen BEFORE fake encrypted block injection to avoid injecting for - // tool calls that will be dropped due to missing/mismatched reasoning_details. - // 2. Inject fake reasoning.encrypted block for tool calls without existing encrypted reasoning. - // This is required when switching from other models to Gemini to satisfy API validation. - // Per OpenRouter documentation (conversation with Toven, Nov 2025): - // - Create ONE reasoning_details entry per assistant message with tool calls - // - Set `id` to the FIRST tool call's ID from the tool_calls array - // - Set `data` to "skip_thought_signature_validator" to bypass signature validation - // - Set `index` to 0 - // See: https://github.com/cline/cline/issues/8214 - if (isGemini) { - // Step 1: Sanitize messages - filter out tool calls with missing/mismatched reasoning_details - openAiMessages = sanitizeGeminiMessages(openAiMessages, modelId) - - // Step 2: Inject fake reasoning.encrypted block for tool calls that survived sanitization - openAiMessages = openAiMessages.map((msg) => { - if (msg.role === "assistant") { - const toolCalls = (msg as any).tool_calls as any[] | undefined - const existingDetails = (msg as any).reasoning_details as any[] | undefined - - // Only inject if there are tool calls and no existing encrypted reasoning - if (toolCalls && toolCalls.length > 0) { - const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false - - if (!hasEncrypted) { - // Create ONE fake encrypted block with the FIRST tool call's ID - // This is the documented format from OpenRouter for skipping thought signature validation - const fakeEncrypted = { - type: "reasoning.encrypted", - data: "skip_thought_signature_validator", - id: toolCalls[0].id, - format: "google-gemini-v1", - index: 0, - } + // Model discovery is not signal-aware: race it against the per-request signal so an + // abort during the lookup rejects with AbortError instead of calling the API with an + // already-aborted signal. + const model = await rejectOnAbort(this.fetchModel(), controller.signal, this.providerName) + + let { id: modelId, maxTokens, temperature, topP, reasoning } = model + + // Reset reasoning_details accumulator for this request + this.currentReasoningDetails = [] + + // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro models + // even if you don't request them. This is not the default for + // other providers (including Gemini), so we need to explicitly disable + // them unless the user has explicitly configured reasoning. + // Note: Gemini 3 models use reasoning_details format with thought signatures, + // but we handle this via skip_thought_signature_validator injection below. + if ( + (modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") && + typeof reasoning === "undefined" + ) { + reasoning = { exclude: true } + } - return { - ...msg, - reasoning_details: [...(existingDetails ?? []), fakeEncrypted], + // Convert Anthropic messages to OpenAI format. + // Pass normalization function for Mistral compatibility (requires 9-char alphanumeric IDs) + const isMistral = modelId.toLowerCase().includes("mistral") + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages( + messages, + isMistral ? { normalizeToolCallId: normalizeMistralToolCallId } : undefined, + ), + ] + + // DeepSeek highly recommends using user instead of system role. + if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") { + openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) + } + + // Process reasoning_details when switching models to Gemini. + const isGemini = modelId.startsWith("google/gemini") + + // For Gemini models with native protocol: + // 1. Sanitize messages to handle thought signature validation issues. + // This must happen BEFORE fake encrypted block injection to avoid injecting for + // tool calls that will be dropped due to missing/mismatched reasoning_details. + // 2. Inject fake reasoning.encrypted block for tool calls without existing encrypted reasoning. + // This is required when switching from other models to Gemini to satisfy API validation. + // Per OpenRouter documentation (conversation with Toven, Nov 2025): + // - Create ONE reasoning_details entry per assistant message with tool calls + // - Set `id` to the FIRST tool call's ID from the tool_calls array + // - Set `data` to "skip_thought_signature_validator" to bypass signature validation + // - Set `index` to 0 + // See: https://github.com/cline/cline/issues/8214 + if (isGemini) { + // Step 1: Sanitize messages - filter out tool calls with missing/mismatched reasoning_details + openAiMessages = sanitizeGeminiMessages(openAiMessages, modelId) + + // Step 2: Inject fake reasoning.encrypted block for tool calls that survived sanitization + openAiMessages = openAiMessages.map((msg) => { + // Stryker disable next-line ConditionalExpression: only assistant messages carry tool_calls after conversion, so the branch body is a no-op for every other role + if (msg.role === "assistant") { + const toolCalls = (msg as any).tool_calls as any[] | undefined + const existingDetails = (msg as any).reasoning_details as any[] | undefined + + // Only inject if there are tool calls and no existing encrypted reasoning + // Stryker disable next-line ConditionalExpression,EqualityOperator: conversion only sets tool_calls for assistant messages with at least one tool_use, so the empty-array case the variants diverge on is unreachable + if (toolCalls && toolCalls.length > 0) { + // Stryker disable next-line LogicalOperator,OptionalChaining,BooleanLiteral: sanitizeGeminiMessages keeps only tool calls with matching details, so reasoning_details is a defined array here and the some()/?? fallback variants are unobservable + const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false + + if (!hasEncrypted) { + // Create ONE fake encrypted block with the FIRST tool call's ID + // This is the documented format from OpenRouter for skipping thought signature validation + const fakeEncrypted = { + type: "reasoning.encrypted", + data: "skip_thought_signature_validator", + id: toolCalls[0].id, + format: "google-gemini-v1", + index: 0, + } + + return { + ...msg, + // Stryker disable next-line ArrayDeclaration: sanitizeGeminiMessages guarantees reasoning_details is a defined array inside this branch, so the ?? [] fallback never runs + reasoning_details: [...(existingDetails ?? []), fakeEncrypted], + } } } } - } - return msg - }) - } - - // https://openrouter.ai/docs/features/prompt-caching - // TODO: Add a `promptCacheStratey` field to `ModelInfo`. - if (OPEN_ROUTER_PROMPT_CACHING_MODELS.has(modelId)) { - if (modelId.startsWith("google")) { - addGeminiCacheBreakpoints(systemPrompt, openAiMessages) - } else { - addAnthropicCacheBreakpoints(systemPrompt, openAiMessages) + return msg + }) } - } - - // https://openrouter.ai/docs/transforms - const completionParams: OpenRouterChatCompletionParams = { - model: modelId, - ...(maxTokens && maxTokens > 0 && { max_tokens: maxTokens }), - temperature, - top_p: topP, - messages: openAiMessages, - stream: true, - stream_options: { include_usage: true }, - // Only include provider if openRouterSpecificProvider is not "[default]". - ...(this.options.openRouterSpecificProvider && - this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { - provider: { - order: [this.options.openRouterSpecificProvider], - only: [this.options.openRouterSpecificProvider], - allow_fallbacks: false, - }, - }), - ...(reasoning && { reasoning }), - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - } - // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models - const requestOptions = modelId.startsWith("anthropic/") - ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } - : undefined + // https://openrouter.ai/docs/features/prompt-caching + // TODO: Add a `promptCacheStratey` field to `ModelInfo`. + if (OPEN_ROUTER_PROMPT_CACHING_MODELS.has(modelId)) { + if (modelId.startsWith("google")) { + addGeminiCacheBreakpoints(systemPrompt, openAiMessages) + } else { + addAnthropicCacheBreakpoints(systemPrompt, openAiMessages) + } + } - let stream - try { - stream = await this.client.chat.completions.create(completionParams, requestOptions) - } catch (error) { - // Try to parse as OpenRouter error structure using Zod - const parseResult = OpenRouterErrorResponseSchema.safeParse(error) + // https://openrouter.ai/docs/transforms + const completionParams: OpenRouterChatCompletionParams = { + model: modelId, + // Stryker disable next-line ConditionalExpression,LogicalOperator,EqualityOperator: every model-info source (model list or openRouterDefaultModelInfo) supplies a positive maxTokens, so the leading maxTokens guard makes the inner variants unreachable; whole-condition variants remain test-covered + ...(maxTokens && maxTokens > 0 && { max_tokens: maxTokens }), + temperature, + top_p: topP, + messages: openAiMessages, + stream: true, + stream_options: { include_usage: true }, + // Only include provider if openRouterSpecificProvider is not "[default]". + ...(this.options.openRouterSpecificProvider && + this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { + provider: { + order: [this.options.openRouterSpecificProvider], + only: [this.options.openRouterSpecificProvider], + allow_fallbacks: false, + }, + }), + ...(reasoning && { reasoning }), + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + } - if (parseResult.success && parseResult.data.error) { - const openRouterError = parseResult.data - const rawString = openRouterError.error?.metadata?.raw - const parsedError = extractErrorFromMetadataRaw(rawString) - const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" + // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models + // and pass the per-request signal so external aborts cancel the in-flight stream. + const requestOptions: OpenAI.RequestOptions = { + ...(modelId.startsWith("anthropic/") + ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } + : undefined), + signal: controller.signal, + } - const apiError = Object.assign( - new ApiProviderError( - rawErrorMessage, + let stream + try { + stream = await this.client.chat.completions.create(completionParams, requestOptions) + } catch (error) { + // Aborted requests are user-initiated: surface them as AbortError instead of + // a completion error (and keep them out of exception telemetry). + if (controller.signal.aborted) { + throw createAbortError("OpenRouter") + } + // Try to parse as OpenRouter error structure using Zod + const parseResult = OpenRouterErrorResponseSchema.safeParse(error) + + if (parseResult.success && parseResult.data.error) { + const openRouterError = parseResult.data + // Stryker disable next-line OptionalChaining: the parseResult.data.error guard above guarantees .error is defined, so this optional chain is a no-op + const rawString = openRouterError.error?.metadata?.raw + const parsedError = extractErrorFromMetadataRaw(rawString) + // Stryker disable next-line OptionalChaining: the parseResult.data.error guard above guarantees .error is defined, so this optional chain is a no-op + const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" + + const apiError = Object.assign( + new ApiProviderError( + rawErrorMessage, + providerIdentifiers.openrouter, + modelId, + "createMessage", + // Stryker disable next-line OptionalChaining: the parseResult.data.error guard above guarantees .error is defined, so this optional chain is a no-op + openRouterError.error?.code, + ), + { + // Stryker disable next-line OptionalChaining: the parseResult.data.error guard above guarantees .error is defined, so this optional chain is a no-op + status: openRouterError.error?.code, + error: openRouterError.error, + }, + ) + + TelemetryService.instance.captureException(apiError) + throw handleOpenAIError(error, this.providerName) + } else { + // Fallback for non-OpenRouter errors + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError( + errorMessage, providerIdentifiers.openrouter, modelId, "createMessage", - openRouterError.error?.code, - ), - { - status: openRouterError.error?.code, - error: openRouterError.error, - }, - ) - - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } else { - // Fallback for non-OpenRouter errors - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError( - errorMessage, - providerIdentifiers.openrouter, - modelId, - "createMessage", - ) - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } - } - - let lastUsage: CompletionUsage | undefined = undefined - // Accumulator for reasoning_details FROM the API. - // We preserve the original shape of reasoning_details to prevent malformed responses. - const reasoningDetailsAccumulator = new Map< - string, - { - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index: number - } - >() - - // Track whether we've yielded displayable text from reasoning_details. - // When reasoning_details has displayable content (reasoning.text or reasoning.summary), - // we skip yielding the top-level reasoning field to avoid duplicate display. - let hasYieldedReasoningFromDetails = false - const activeToolCallIds = new Set() - - for await (const chunk of stream) { - // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. - if ("error" in chunk) { - this.handleStreamingError(chunk.error as OpenRouterError, modelId, "createMessage") + ) + TelemetryService.instance.captureException(apiError) + throw handleOpenAIError(error, this.providerName) + } } - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - - if (delta) { - // Handle reasoning_details array format (used by Gemini 3, Claude, OpenAI o-series, etc.) - // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks - // Priority: Check for reasoning_details first, as it's the newer format - const deltaWithReasoning = delta as typeof delta & { - reasoning_details?: Array<{ - type: string - text?: string - summary?: string - data?: string - id?: string | null - format?: string - signature?: string - index?: number - }> + let lastUsage: CompletionUsage | undefined = undefined + // Accumulator for reasoning_details FROM the API. + // We preserve the original shape of reasoning_details to prevent malformed responses. + const reasoningDetailsAccumulator = new Map< + string, + { + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + signature?: string + index: number } + >() + + // Track whether we've yielded displayable text from reasoning_details. + // When reasoning_details has displayable content (reasoning.text or reasoning.summary), + // we skip yielding the top-level reasoning field to avoid duplicate display. + let hasYieldedReasoningFromDetails = false + const activeToolCallIds = new Set() + + try { + for await (const chunk of stream) { + // The iterator can keep delivering buffered chunks after the abort has already + // fired (openai@5.23.2 swallows the mid-stream AbortError), so re-check the + // signal before processing each chunk. The yields below are synchronous (there + // is no await between this check and them), so nothing is emitted once the + // signal aborts. + if (controller.signal.aborted) { + break + } - if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { - for (const detail of deltaWithReasoning.reasoning_details) { - const index = detail.index ?? 0 - const key = `${detail.type}-${index}` - const existing = reasoningDetailsAccumulator.get(key) + // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. + if ("error" in chunk) { + this.handleStreamingError(chunk.error as OpenRouterError, modelId, "createMessage") + } - if (existing) { - // Accumulate text/summary/data for existing reasoning detail - if (detail.text !== undefined) { - existing.text = (existing.text || "") + detail.text - } - if (detail.summary !== undefined) { - existing.summary = (existing.summary || "") + detail.summary + const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason + + if (delta) { + // Handle reasoning_details array format (used by Gemini 3, Claude, OpenAI o-series, etc.) + // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks + // Priority: Check for reasoning_details first, as it's the newer format + const deltaWithReasoning = delta as typeof delta & { + reasoning_details?: Array<{ + type: string + text?: string + summary?: string + data?: string + id?: string | null + format?: string + signature?: string + index?: number + }> + } + + if ( + deltaWithReasoning.reasoning_details && + Array.isArray(deltaWithReasoning.reasoning_details) + ) { + for (const detail of deltaWithReasoning.reasoning_details) { + const index = detail.index ?? 0 + const key = `${detail.type}-${index}` + const existing = reasoningDetailsAccumulator.get(key) + + if (existing) { + // Accumulate text/summary/data for existing reasoning detail + if (detail.text !== undefined) { + existing.text = (existing.text || "") + detail.text + } + if (detail.summary !== undefined) { + existing.summary = (existing.summary || "") + detail.summary + } + if (detail.data !== undefined) { + existing.data = (existing.data || "") + detail.data + } + // Update other fields if provided + if (detail.id !== undefined) existing.id = detail.id + if (detail.format !== undefined) existing.format = detail.format + if (detail.signature !== undefined) existing.signature = detail.signature + } else { + // Start new reasoning detail accumulation + reasoningDetailsAccumulator.set(key, { + type: detail.type, + text: detail.text, + summary: detail.summary, + data: detail.data, + id: detail.id, + format: detail.format, + signature: detail.signature, + index, + }) + } + + // Yield text for display (still fragmented for live streaming) + // Only reasoning.text and reasoning.summary have displayable content + // reasoning.encrypted is intentionally skipped as it contains redacted content + let reasoningText: string | undefined + if (detail.type === "reasoning.text" && typeof detail.text === "string") { + reasoningText = detail.text + } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { + reasoningText = detail.summary + } + + if (reasoningText) { + hasYieldedReasoningFromDetails = true + yield { type: "reasoning", text: reasoningText } + } } - if (detail.data !== undefined) { - existing.data = (existing.data || "") + detail.data + } + + // Handle top-level reasoning field for UI display. + // Skip if we've already yielded from reasoning_details to avoid duplicate display. + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + if (!hasYieldedReasoningFromDetails) { + yield { type: "reasoning", text: delta.reasoning } } - // Update other fields if provided - if (detail.id !== undefined) existing.id = detail.id - if (detail.format !== undefined) existing.format = detail.format - if (detail.signature !== undefined) existing.signature = detail.signature - } else { - // Start new reasoning detail accumulation - reasoningDetailsAccumulator.set(key, { - type: detail.type, - text: detail.text, - summary: detail.summary, - data: detail.data, - id: detail.id, - format: detail.format, - signature: detail.signature, - index, - }) } - // Yield text for display (still fragmented for live streaming) - // Only reasoning.text and reasoning.summary have displayable content - // reasoning.encrypted is intentionally skipped as it contains redacted content - let reasoningText: string | undefined - if (detail.type === "reasoning.text" && typeof detail.text === "string") { - reasoningText = detail.text - } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { - reasoningText = detail.summary + // Emit raw tool call chunks - NativeToolCallParser handles state management + if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + activeToolCallIds.add(toolCall.id) + } + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } } - if (reasoningText) { - hasYieldedReasoningFromDetails = true - yield { type: "reasoning", text: reasoningText } + if (delta.content) { + yield { type: "text", text: delta.content } } } - } - // Handle top-level reasoning field for UI display. - // Skip if we've already yielded from reasoning_details to avoid duplicate display. - if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { - if (!hasYieldedReasoningFromDetails) { - yield { type: "reasoning", text: delta.reasoning } + // Process finish_reason to emit tool_call_end events + // This ensures tool calls are finalized even if the stream doesn't properly close + // Process finish_reason to emit tool_call_end events + // This ensures tool calls are finalized even if the stream doesn't properly close + if (finishReason === "tool_calls") { + for (const id of activeToolCallIds) { + yield { type: "tool_call_end", id } + } + activeToolCallIds.clear() } - } - // Emit raw tool call chunks - NativeToolCallParser handles state management - if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - if (toolCall.id) { - activeToolCallIds.add(toolCall.id) - } - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } + if (chunk.usage) { + lastUsage = chunk.usage } } - if (delta.content) { - yield { type: "text", text: delta.content } + // openai@5.23.2's stream iterator swallows a mid-stream AbortError and returns + // normally instead of throwing, so the catch below would never run: without this + // check, createMessage completes silently after yielding partial output. + if (controller.signal.aborted) { + // Stryker disable next-line StringLiteral: the catch below rethrows its own canonical createAbortError, masking the message of this throw + throw createAbortError("OpenRouter") } - } - // Process finish_reason to emit tool_call_end events - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason === "tool_calls") { - for (const id of activeToolCallIds) { - yield { type: "tool_call_end", id } + // After streaming completes, consolidate and store reasoning_details from the API. + // This filters out corrupted encrypted blocks (missing `data`) and consolidates by index. + // Stryker disable next-line ConditionalExpression,EqualityOperator: an empty accumulator consolidates to [] and getReasoningDetails() maps empty to undefined, so the block is unobservable + if (reasoningDetailsAccumulator.size > 0) { + const rawDetails = Array.from(reasoningDetailsAccumulator.values()) + this.currentReasoningDetails = consolidateReasoningDetails(rawDetails) } - activeToolCallIds.clear() - } - - if (chunk.usage) { - lastUsage = chunk.usage - } - } - - // After streaming completes, consolidate and store reasoning_details from the API. - // This filters out corrupted encrypted blocks (missing `data`) and consolidates by index. - if (reasoningDetailsAccumulator.size > 0) { - const rawDetails = Array.from(reasoningDetailsAccumulator.values()) - this.currentReasoningDetails = consolidateReasoningDetails(rawDetails) - } - if (lastUsage) { - yield { - type: "usage", - inputTokens: lastUsage.prompt_tokens || 0, - outputTokens: lastUsage.completion_tokens || 0, - cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, - reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens, - totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0), + if (lastUsage) { + yield { + type: "usage", + inputTokens: lastUsage.prompt_tokens || 0, + outputTokens: lastUsage.completion_tokens || 0, + cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, + reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens, + totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0), + } + } + } catch (error) { + // Normalize abort-driven stream failures (SDK abort or timeout errors) to a + // DOM-standard AbortError so callers can detect the aborted request. + if (controller.signal.aborted) { + throw createAbortError("OpenRouter") + } + throw error } + } finally { + removeExternalAbortListener?.() + // Cancel the in-flight request when the consumer abandons the generator + // (early break / downstream error). No-op once the stream has completed + // or the controller is already aborted. + controller.abort() } } @@ -583,7 +671,27 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } async completePrompt(prompt: string, options?: CompletePromptOptions) { - const { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel() + // Establish the cancellation scope before model lookup: a pre-aborted call, or + // one aborted while model metadata is loading, must reject promptly instead of + // waiting for the lookup to settle. The configured timeoutMs covers the lookup + // as well. + const requestAbortSignal = mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs) + if (requestAbortSignal?.aborted) { + throw createAbortError(this.providerName) + } + + let model: Awaited> + try { + model = requestAbortSignal + ? await rejectOnAbort(this.fetchModel(), requestAbortSignal, this.providerName) + : await this.fetchModel() + } catch (error) { + if (isRequestAborted(error, requestAbortSignal)) { + throw createAbortError(this.providerName) + } + throw error + } + const { id: modelId, maxTokens, temperature, reasoning } = model const completionParams: OpenRouterChatCompletionParams = { model: modelId, @@ -604,15 +712,29 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models - const requestOptions = modelId.startsWith("anthropic/") - ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } - : undefined + // and forward the caller's abort signal / per-request timeout to the SDK. The merged + // signal (established before model lookup, above) aborts when either the caller's signal + // or the timeout fires, so timeouts are normalized to AbortError in the catch below. + // The client-level timeout remains the default safety net; timeoutMs <= 0 disables the + // per-request timeout, and 0 is never passed to the SDK. + const requestOptions: OpenAI.RequestOptions = { + ...(modelId.startsWith("anthropic/") + ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } + : undefined), + ...(requestAbortSignal && { signal: requestAbortSignal }), + ...(typeof options?.timeoutMs === "number" && options.timeoutMs > 0 && { timeout: options.timeoutMs }), + } let response try { response = await this.client.chat.completions.create(completionParams, requestOptions) } catch (error) { + // Aborted requests are user-initiated: surface them as AbortError (this also covers + // timeouts, which abort the same signal) instead of a completion error. + if (requestAbortSignal?.aborted) { + throw createAbortError("OpenRouter") + } // Try to parse as OpenRouter error structure using Zod const parseResult = OpenRouterErrorResponseSchema.safeParse(error) @@ -652,6 +774,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } } + if (requestAbortSignal?.aborted) { + // The response resolved after the request was aborted: do not return the late result. + throw createAbortError("OpenRouter") + } + if ("error" in response) { this.handleStreamingError(response.error as OpenRouterError, modelId, "completePrompt") } diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts index 1692f71e63..1e8b7016cd 100644 --- a/src/api/providers/utils/__tests__/abort-signal.spec.ts +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -3,8 +3,105 @@ import { isRequestAborted, mergeAbortSignalAndTimeout, mergeAbortSignals, + rejectOnAbort, throwIfAborted, } from "../abort-signal" +import { settlesWithin } from "../../../../test-utils/promise" + +const SETTLE_MS = 200 + +describe("rejectOnAbort", () => { + it("resolves with the pending value when it settles before the signal aborts", async () => { + const controller = new AbortController() + + await expect( + settlesWithin(rejectOnAbort(Promise.resolve("done"), controller.signal, "TestProvider"), SETTLE_MS), + ).resolves.toBe("done") + expect(controller.signal.aborted).toBe(false) + }) + + it("rejects with the provider abort error when the signal aborts first", async () => { + const controller = new AbortController() + // Never settles: the race must end purely via the abort. + const pending = new Promise(() => {}) + const race = rejectOnAbort(pending, controller.signal, "TestProvider") + controller.abort() + + await expect(settlesWithin(race, SETTLE_MS)).rejects.toMatchObject({ + name: "AbortError", + message: "The TestProvider request was aborted", + }) + }) + + it("rejects immediately when the signal is already aborted", async () => { + const controller = new AbortController() + controller.abort() + const pending = new Promise(() => {}) + + await expect( + settlesWithin(rejectOnAbort(pending, controller.signal, "TestProvider"), SETTLE_MS), + ).rejects.toMatchObject({ + name: "AbortError", + }) + }) + + it("propagates the pending rejection when the signal stays active", async () => { + const controller = new AbortController() + const boom = new Error("lookup failed") + + await expect( + settlesWithin(rejectOnAbort(Promise.reject(boom), controller.signal, "TestProvider"), SETTLE_MS), + ).rejects.toBe(boom) + }) + + it("detaches the exact abort listener it registered once the pending settles", async () => { + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSpy = vi.spyOn(controller.signal, "removeEventListener") + + await expect( + settlesWithin(rejectOnAbort(Promise.resolve("done"), controller.signal, "TestProvider"), SETTLE_MS), + ).resolves.toBe("done") + + const registeredListener = addSpy.mock.calls[0]?.[1] + expect(registeredListener).toBeTypeOf("function") + expect(removeSpy).toHaveBeenCalledWith("abort", registeredListener) + addSpy.mockRestore() + removeSpy.mockRestore() + }) + + it("detaches the exact abort listener it registered when the pending rejects", async () => { + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") + const removeSpy = vi.spyOn(controller.signal, "removeEventListener") + const lookupError = new Error("lookup failed") + + await expect( + settlesWithin(rejectOnAbort(Promise.reject(lookupError), controller.signal, "TestProvider"), SETTLE_MS), + ).rejects.toBe(lookupError) + + const registeredListener = addSpy.mock.calls[0]?.[1] + expect(registeredListener).toBeTypeOf("function") + expect(removeSpy).toHaveBeenCalledWith("abort", registeredListener) + addSpy.mockRestore() + removeSpy.mockRestore() + }) + + it("registers the abort listener with { once: true }", async () => { + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, "addEventListener") + // Never settles: the race must end purely via the abort. + const pending = new Promise(() => {}) + const race = rejectOnAbort(pending, controller.signal, "TestProvider") + controller.abort() + + await expect(settlesWithin(race, SETTLE_MS)).rejects.toMatchObject({ name: "AbortError" }) + const registeredListener = addSpy.mock.calls[0]?.[1] + expect(registeredListener).toBeTypeOf("function") + expect(addSpy).toHaveBeenCalledWith("abort", registeredListener, { once: true }) + addSpy.mockRestore() + }) +}) describe("abort-signal utilities", () => { describe("mergeAbortSignalAndTimeout", () => { diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts index 26f57c3e9a..8f7359aa9a 100644 --- a/src/api/providers/utils/abort-signal.ts +++ b/src/api/providers/utils/abort-signal.ts @@ -93,3 +93,34 @@ export function createAbortError(providerName: string): Error { abortError.name = "AbortError" return abortError } + +/** + * Await `pending` but reject with the provider's abort error when `signal` + * aborts first. For async phases that have no native signal support (model + * discovery) yet must still settle promptly on cancellation. The underlying + * promise keeps running (its settlement is ignored) — cancellation is + * cooperative at this boundary. + * + * The abort listener is detached once `pending` settles (success or + * failure), so repeated calls on one signal do not accumulate listeners. + */ +export function rejectOnAbort(pending: Promise, signal: AbortSignal, providerName: string): Promise { + if (signal.aborted) { + return Promise.reject(createAbortError(providerName)) + } + + return new Promise((resolve, reject) => { + const onAbort = () => reject(createAbortError(providerName)) + signal.addEventListener("abort", onAbort, { once: true }) + void pending.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener("abort", onAbort) + reject(error) + }, + ) + }) +} diff --git a/src/test-utils/promise.ts b/src/test-utils/promise.ts new file mode 100644 index 0000000000..6d14892e0e --- /dev/null +++ b/src/test-utils/promise.ts @@ -0,0 +1,20 @@ +/** + * Fail fast when a promise never settles. Mutations that break settle or + * reject wiring would otherwise hang the test until Stryker's per-mutant + * timeout, marking the mutant "Timeout" instead of "Killed". + */ +export function settlesWithin(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`operation did not settle within ${ms}ms`)), ms) + void promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +}