From 1120a2733cf4e3acb0c342650c0bbef6aa61d1a8 Mon Sep 17 00:00:00 2001 From: ghzhost Date: Thu, 3 Sep 2026 09:24:58 +0000 Subject: [PATCH] fix(providers): fail loudly on malformed chat completion responses (#267) --- src/providers/openai-compatible-provider.ts | 50 +++++++++++-- tests/openai-compatible-provider.test.ts | 81 +++++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/src/providers/openai-compatible-provider.ts b/src/providers/openai-compatible-provider.ts index 38955f7..d6f4863 100644 --- a/src/providers/openai-compatible-provider.ts +++ b/src/providers/openai-compatible-provider.ts @@ -91,17 +91,18 @@ export class OpenAICompatibleModelProvider implements ModelProvider { throw new Error(`OpenAI-compatible provider request failed: ${message}`); } + const rawBody = await response.text().catch(() => ""); + if (!response.ok) { - const errorText = await response.text().catch(() => ""); throw new Error( - `OpenAI-compatible provider returned HTTP ${response.status}: ${errorText}` + `OpenAI-compatible provider returned HTTP ${response.status}: ${rawBody}` ); } - const data = (await response.json()) as { + let data: { choices?: Array<{ message?: { - content?: string; + content?: string | null; }; finish_reason?: string; }>; @@ -109,13 +110,48 @@ export class OpenAICompatibleModelProvider implements ModelProvider { model?: string; }; - const outputText = data.choices?.[0]?.message?.content ?? ""; + try { + data = JSON.parse(rawBody); + } catch { + const bodyExcerpt = rawBody.length > 200 ? `${rawBody.slice(0, 200)}...` : rawBody; + throw new Error( + `OpenAI-compatible provider returned invalid JSON (HTTP ${response.status}): ${bodyExcerpt}` + ); + } + + if (!data || typeof data !== "object") { + throw new Error( + `OpenAI-compatible provider returned malformed response object (HTTP ${response.status}).` + ); + } + + if (!Array.isArray(data.choices) || data.choices.length === 0) { + throw new Error( + `OpenAI-compatible provider response missing non-empty "choices" array.` + ); + } + + const firstChoice = data.choices[0]; + if (!firstChoice || typeof firstChoice !== "object") { + throw new Error( + `OpenAI-compatible provider response contains malformed choice entry.` + ); + } + + const messageContent = firstChoice.message?.content; + if (typeof messageContent !== "string") { + throw new Error( + `OpenAI-compatible provider choice missing string message content.` + ); + } + + const outputText = messageContent; 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..cd11eb0 100644 --- a/tests/openai-compatible-provider.test.ts +++ b/tests/openai-compatible-provider.test.ts @@ -186,4 +186,85 @@ describe("OpenAICompatibleModelProvider", () => { "OpenAI-compatible provider request failed: ECONNREFUSED" ); }); + + it("rejects when response body is not valid JSON and includes HTTP status and body excerpt", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("502 Bad Gateway", { + status: 200, + headers: { "Content-Type": "text/html" } + }) + ); + + const provider = new OpenAICompatibleModelProvider({ + apiKey: "valid-key" + }); + + await expect( + provider.generate({ instructions: "test", input: "test" }) + ).rejects.toThrowError( + "OpenAI-compatible provider returned invalid JSON (HTTP 200): 502 Bad Gateway" + ); + }); + + it("rejects when choices array is empty", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ choices: [] }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + const provider = new OpenAICompatibleModelProvider({ + apiKey: "valid-key" + }); + + await expect( + provider.generate({ instructions: "test", input: "test" }) + ).rejects.toThrowError( + 'OpenAI-compatible provider response missing non-empty "choices" array.' + ); + }); + + it("rejects when choices field is missing", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ model: "gpt-4o-mini" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + const provider = new OpenAICompatibleModelProvider({ + apiKey: "valid-key" + }); + + await expect( + provider.generate({ instructions: "test", input: "test" }) + ).rejects.toThrowError( + 'OpenAI-compatible provider response missing non-empty "choices" array.' + ); + }); + + it("rejects when choice message content is missing or not a string", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ + choices: [{ index: 0, message: { role: "assistant" } }] + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ) + ); + + const provider = new OpenAICompatibleModelProvider({ + apiKey: "valid-key" + }); + + await expect( + provider.generate({ instructions: "test", input: "test" }) + ).rejects.toThrowError( + "OpenAI-compatible provider choice missing string message content." + ); + }); });