Skip to content

Commit 3bfca2b

Browse files
committed
fix(api): normalize openrouter/requesty timeouts to AbortError + cover new abort paths
1 parent 706d9b1 commit 3bfca2b

5 files changed

Lines changed: 505 additions & 11 deletions

File tree

src/api/providers/__tests__/openrouter.spec.ts

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,224 @@ describe("OpenRouterHandler", () => {
606606
await expect(iteration).rejects.toMatchObject({ name: "AbortError" })
607607
expect(chunks).toContainEqual({ type: "text", text: "first" })
608608
})
609+
it("excludes reasoning for Gemini 2.5 Pro models by default", async () => {
610+
const handler = new OpenRouterHandler(
611+
makeApiHandlerOptions({
612+
...mockOptions,
613+
openRouterModelId: "google/gemini-2.5-pro-preview",
614+
}),
615+
)
616+
const mockCreate = vitest
617+
.fn()
618+
.mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }]))
619+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
620+
client.chat = { completions: { create: mockCreate } }
621+
622+
const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }])
623+
await collectStream(stream)
624+
625+
expect(mockCreate).toHaveBeenCalledWith(
626+
expect.objectContaining({ reasoning: { exclude: true } }),
627+
expect.any(Object),
628+
)
629+
})
630+
631+
it("uses user role for the system prompt with DeepSeek R1 models", async () => {
632+
const handler = new OpenRouterHandler(
633+
makeApiHandlerOptions({
634+
...mockOptions,
635+
openRouterModelId: "deepseek/deepseek-r1",
636+
}),
637+
)
638+
const mockCreate = vitest
639+
.fn()
640+
.mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }]))
641+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
642+
client.chat = { completions: { create: mockCreate } }
643+
644+
const stream = handler.createMessage("system prompt", [{ role: "user" as const, content: "hi" }])
645+
await collectStream(stream)
646+
647+
const params = mockCreate.mock.calls[0][0] as { messages: { role: string; content: unknown }[] }
648+
expect(params.messages[0].role).toBe("user")
649+
expect(params.messages.map((m) => m.role)).not.toContain("system")
650+
})
651+
652+
it("injects a fake encrypted reasoning block for Gemini tool calls without encrypted reasoning", async () => {
653+
const handler = new OpenRouterHandler(
654+
makeApiHandlerOptions({
655+
...mockOptions,
656+
openRouterModelId: "google/gemini-2.5-flash",
657+
}),
658+
)
659+
const mockCreate = vitest
660+
.fn()
661+
.mockResolvedValue(asyncStreamFrom([{ id: "1", choices: [{ delta: { content: "ok" } }] }]))
662+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
663+
client.chat = { completions: { create: mockCreate } }
664+
665+
// reasoning_details is an OpenRouter extension field round-tripped on assistant
666+
// messages; the Anthropic SDK types do not include it, hence the structural cast.
667+
const assistantMessage = {
668+
role: "assistant" as const,
669+
content: [{ type: "tool_use" as const, id: "toolu_01", name: "get_weather", input: { city: "SF" } }],
670+
reasoning_details: [{ type: "reasoning.text", id: "toolu_01", text: "thinking", index: 0 }],
671+
}
672+
const stream = handler.createMessage("system", [
673+
assistantMessage as unknown as Anthropic.Messages.MessageParam,
674+
])
675+
await collectStream(stream)
676+
677+
const params = mockCreate.mock.calls[0][0] as {
678+
messages: {
679+
role: string
680+
tool_calls?: { id: string }[]
681+
reasoning_details?: { type: string; id: string; data: string }[]
682+
}[]
683+
}
684+
const assistant = params.messages.find((m) => m.role === "assistant")
685+
expect(assistant?.tool_calls).toHaveLength(1)
686+
const encrypted = assistant?.reasoning_details?.find((d) => d.type === "reasoning.encrypted")
687+
expect(encrypted).toMatchObject({
688+
id: "toolu_01",
689+
data: "skip_thought_signature_validator",
690+
})
691+
})
692+
693+
it("accumulates and yields reasoning_details from streamed chunks", async () => {
694+
const handler = new OpenRouterHandler(mockOptions)
695+
const mockCreate = vitest.fn().mockResolvedValue(
696+
asyncStreamFrom([
697+
{ id: "1", choices: [{ delta: { reasoning: "top-level thinking" } }] },
698+
{
699+
id: "2",
700+
choices: [
701+
{ delta: { reasoning_details: [{ type: "reasoning.text", index: 0, text: "thinking " }] } },
702+
],
703+
},
704+
{
705+
id: "3",
706+
choices: [
707+
{
708+
delta: {
709+
reasoning_details: [
710+
{
711+
type: "reasoning.text",
712+
index: 0,
713+
text: "more",
714+
id: "r1",
715+
format: "google-gemini-v1",
716+
signature: "sig",
717+
},
718+
],
719+
},
720+
},
721+
],
722+
},
723+
{
724+
id: "4",
725+
choices: [
726+
{ delta: { reasoning_details: [{ type: "reasoning.summary", index: 1, summary: "sum" }] } },
727+
],
728+
},
729+
{ id: "5", choices: [{ delta: { content: "hello" } }] },
730+
{
731+
id: "6",
732+
choices: [
733+
{
734+
delta: {
735+
reasoning_details: [{ type: "reasoning.summary", index: 1, summary: " more" }],
736+
},
737+
},
738+
],
739+
},
740+
{
741+
id: "7",
742+
choices: [
743+
{ delta: { reasoning_details: [{ type: "reasoning.encrypted", index: 2, data: "enc-" }] } },
744+
],
745+
},
746+
{
747+
id: "8",
748+
choices: [
749+
{
750+
delta: {
751+
reasoning_details: [{ type: "reasoning.encrypted", index: 2, data: "rypted" }],
752+
},
753+
},
754+
],
755+
},
756+
]),
757+
)
758+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
759+
client.chat = { completions: { create: mockCreate } }
760+
761+
const chunks = await collectStream(
762+
handler.createMessage("system", [{ role: "user" as const, content: "hi" }]),
763+
)
764+
765+
expect(chunks).toContainEqual({ type: "reasoning", text: "top-level thinking" })
766+
expect(chunks).toContainEqual({ type: "reasoning", text: "thinking " })
767+
expect(chunks).toContainEqual({ type: "reasoning", text: "sum" })
768+
expect(chunks).toContainEqual({ type: "reasoning", text: " more" })
769+
expect(chunks).toContainEqual({ type: "text", text: "hello" })
770+
771+
const details = handler.getReasoningDetails()
772+
expect(details).toHaveLength(3)
773+
expect(details?.find((d) => d.type === "reasoning.summary")?.summary).toBe("sum more")
774+
expect(details?.find((d) => d.type === "reasoning.encrypted")?.data).toBe("enc-rypted")
775+
})
776+
777+
it("rejects with AbortError when the external signal aborts during request creation", async () => {
778+
const handler = new OpenRouterHandler(mockOptions)
779+
const controller = new AbortController()
780+
781+
const mockCreate = vitest
782+
.fn()
783+
.mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => {
784+
// Emulate the OpenAI SDK: the pending request rejects when the signal aborts.
785+
await new Promise<void>((resolve) => {
786+
if (options?.signal?.aborted) {
787+
resolve()
788+
} else {
789+
options?.signal?.addEventListener("abort", () => resolve(), { once: true })
790+
}
791+
})
792+
const abortError = new Error("The user aborted a request")
793+
abortError.name = "AbortError"
794+
throw abortError
795+
})
796+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
797+
client.chat = { completions: { create: mockCreate } }
798+
799+
const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal })
800+
const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }], metadata)
801+
802+
const nextPromise = generator.next()
803+
// Let the generator reach the pending create() call, then abort.
804+
await new Promise((resolve) => setTimeout(resolve, 10))
805+
controller.abort()
806+
807+
await expect(nextPromise).rejects.toMatchObject({ name: "AbortError" })
808+
})
809+
810+
it("reports OpenRouter structured errors in createMessage with telemetry", async () => {
811+
const handler = new OpenRouterHandler(mockOptions)
812+
const mockCreate = vitest.fn().mockRejectedValueOnce({
813+
error: {
814+
message: "Model not found",
815+
code: 404,
816+
metadata: { raw: '{"message":"upstream: model not found"}' },
817+
},
818+
})
819+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
820+
client.chat = { completions: { create: mockCreate } }
821+
822+
const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }])
823+
824+
await expect(generator.next()).rejects.toThrow(/completion error/)
825+
expect(mockCaptureException).toHaveBeenCalledTimes(1)
826+
})
609827
})
610828

611829
describe("completePrompt", () => {
@@ -858,5 +1076,74 @@ describe("OpenRouterHandler", () => {
8581076

8591077
await expect(promise).rejects.toMatchObject({ name: "AbortError" })
8601078
})
1079+
it("rejects with AbortError when only a timeout is provided and it elapses", async () => {
1080+
// Non-Anthropic model: also exercises the no-beta-header branch of requestOptions.
1081+
const handler = new OpenRouterHandler(
1082+
makeApiHandlerOptions({
1083+
...mockOptions,
1084+
openRouterModelId: "openai/gpt-4o",
1085+
}),
1086+
)
1087+
const mockCreate = vitest
1088+
.fn()
1089+
.mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => {
1090+
// Emulate the OpenAI SDK: the in-flight request rejects when the signal times out.
1091+
await new Promise<void>((resolve) => {
1092+
if (options?.signal?.aborted) {
1093+
resolve()
1094+
} else {
1095+
options?.signal?.addEventListener("abort", () => resolve(), { once: true })
1096+
}
1097+
})
1098+
const timeoutError = new Error("TimeoutError: Request timed out.")
1099+
timeoutError.name = "TimeoutError"
1100+
throw timeoutError
1101+
})
1102+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
1103+
client.chat = { completions: { create: mockCreate } }
1104+
1105+
await expect(handler.completePrompt("test prompt", { timeoutMs: 50 })).rejects.toMatchObject({
1106+
name: "AbortError",
1107+
})
1108+
})
1109+
1110+
it("rejects with AbortError when both an abort signal and a timeout are provided", async () => {
1111+
const handler = new OpenRouterHandler(mockOptions)
1112+
const controller = new AbortController()
1113+
1114+
let requestSignal: AbortSignal | undefined
1115+
const mockCreate = vitest
1116+
.fn()
1117+
.mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => {
1118+
requestSignal = options?.signal
1119+
await new Promise<void>((resolve) => {
1120+
if (options?.signal?.aborted) {
1121+
resolve()
1122+
} else {
1123+
options?.signal?.addEventListener("abort", () => resolve(), { once: true })
1124+
}
1125+
})
1126+
const abortError = new Error("The user aborted a request")
1127+
abortError.name = "AbortError"
1128+
throw abortError
1129+
})
1130+
const client = handler["client"] as unknown as { chat: { completions: { create: typeof mockCreate } } }
1131+
client.chat = { completions: { create: mockCreate } }
1132+
1133+
const promise = handler.completePrompt("test prompt", {
1134+
abortSignal: controller.signal,
1135+
timeoutMs: 100_000,
1136+
})
1137+
controller.abort()
1138+
1139+
await expect(promise).rejects.toMatchObject({ name: "AbortError" })
1140+
// The SDK received a merged signal (not the caller's signal) plus the timeout.
1141+
expect(requestSignal).toBeDefined()
1142+
expect(requestSignal).not.toBe(controller.signal)
1143+
expect(mockCreate).toHaveBeenCalledWith(
1144+
expect.objectContaining({ model: expect.any(String) }),
1145+
expect.objectContaining({ timeout: 100_000 }),
1146+
)
1147+
})
8611148
})
8621149
})

0 commit comments

Comments
 (0)