From cec197e7b09690c0e886ae810d49ff23d2dbaa15 Mon Sep 17 00:00:00 2001 From: Alex Smolya Date: Thu, 3 Sep 2026 11:13:54 +0200 Subject: [PATCH] fix(provider): reject malformed chat completions --- src/providers/openai-compatible-provider.ts | 79 +++++++++++++++++---- tests/openai-compatible-provider.test.ts | 56 +++++++++++++++ 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/providers/openai-compatible-provider.ts b/src/providers/openai-compatible-provider.ts index 38955f7..096f438 100644 --- a/src/providers/openai-compatible-provider.ts +++ b/src/providers/openai-compatible-provider.ts @@ -12,6 +12,17 @@ export interface OpenAICompatibleProviderOptions { headers?: Record | undefined; } +type JsonObject = Record; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getBodyExcerpt(body: string): string { + const excerpt = body.replace(/\s+/g, " ").trim().slice(0, 200); + return excerpt.length > 0 ? excerpt : ""; +} + export class OpenAICompatibleModelProvider implements ModelProvider { public readonly name = "openai-compatible"; private readonly apiKey: string; @@ -98,24 +109,66 @@ export class OpenAICompatibleModelProvider implements ModelProvider { ); } - const data = (await response.json()) as { - choices?: Array<{ - message?: { - content?: string; - }; - finish_reason?: string; - }>; - usage?: Record; - model?: string; - }; + let responseText: string; + try { + responseText = await response.text(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `OpenAI-compatible provider failed to read successful HTTP ${response.status} response: ${message}` + ); + } + + let data: unknown; + try { + data = JSON.parse(responseText); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `OpenAI-compatible provider returned invalid JSON for HTTP ${response.status}: ${message}. Body excerpt: ${getBodyExcerpt(responseText)}` + ); + } + + if (!isJsonObject(data)) { + throw new Error( + `OpenAI-compatible provider returned an invalid response for HTTP ${response.status}: expected a JSON object.` + ); + } + + const choicesValue = data.choices; + if (!Array.isArray(choicesValue) || choicesValue.length === 0) { + throw new Error( + `OpenAI-compatible provider returned an invalid response for HTTP ${response.status}: expected a non-empty choices array.` + ); + } + + const firstChoice = (choicesValue as unknown[])[0]; + if (!isJsonObject(firstChoice)) { + throw new Error( + `OpenAI-compatible provider returned an invalid response for HTTP ${response.status}: expected the first choice to be an object.` + ); + } + + const message = firstChoice.message; + if (!isJsonObject(message)) { + throw new Error( + `OpenAI-compatible provider returned an invalid response for HTTP ${response.status}: expected first choice.message to be an object.` + ); + } + + const outputText = message.content; + if (typeof outputText !== "string") { + throw new Error( + `OpenAI-compatible provider returned an invalid response for HTTP ${response.status}: expected first choice.message.content to be a string.` + ); + } - const outputText = data.choices?.[0]?.message?.content ?? ""; const metadata: Record = { model: data.model ?? this.model }; - if (data.choices?.[0]?.finish_reason !== undefined) { - metadata.finishReason = data.choices[0].finish_reason; + if (firstChoice.finish_reason !== undefined) { + metadata.finishReason = firstChoice.finish_reason; } if (data.usage !== undefined) { metadata.usage = data.usage; diff --git a/tests/openai-compatible-provider.test.ts b/tests/openai-compatible-provider.test.ts index 62d6cac..6d423cd 100644 --- a/tests/openai-compatible-provider.test.ts +++ b/tests/openai-compatible-provider.test.ts @@ -152,6 +152,62 @@ describe("OpenAICompatibleModelProvider", () => { expect(response.outputText).toBe("Custom response"); }); + it.each([ + ["missing choices", {}, "expected a non-empty choices array"], + ["empty choices", { choices: [] }, "expected a non-empty choices array"], + [ + "non-array choices", + { choices: {} }, + "expected a non-empty choices array" + ], + [ + "missing message content", + { choices: [{ message: {} }] }, + "expected first choice.message.content to be a string" + ], + [ + "invalid message content", + { choices: [{ message: { content: 42 } }] }, + "expected first choice.message.content to be a string" + ] + ])( + "rejects successful responses with %s", + async (_description, payload, expectedError) => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify(payload), { status: 200 }) + ); + + const provider = new OpenAICompatibleModelProvider({ + apiKey: "valid-key" + }); + + await expect( + provider.generate({ instructions: "test", input: "test" }) + ).rejects.toThrowError(expectedError); + } + ); + + it("rejects a successful non-JSON response with status and a bounded body excerpt", async () => { + const body = "upstream gateway failure: " + "x".repeat(500); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(body, { status: 200, statusText: "OK" }) + ); + + const provider = new OpenAICompatibleModelProvider({ + apiKey: "valid-key" + }); + + const error = await provider + .generate({ instructions: "test", input: "test" }) + .catch((value: unknown) => value); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("HTTP 200"); + expect(message).toContain(`Body excerpt: ${body.slice(0, 200)}`); + expect(message).not.toContain(body.slice(0, 201)); + }); + it("handles non-2xx HTTP responses with descriptive error", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( new Response(JSON.stringify({ error: "Invalid API key" }), {