From c304268a9ea2405f328b708acc0d2180e0f28814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 09:27:42 +0200 Subject: [PATCH] fix(ai): tag empty-reply and stream-failed provider throws with a sentinel status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four AI clients threw bare Errors on an empty reply (and, for Codex, on a stream-level failure) with no httpStatus, so the provider-chain classifier saw the same null-status shape as a genuine transport failure (ECONNRESET, DNS, timeout) and could not tell them apart in observability. Attach a sentinel `httpStatus: 0` plus a `kind` discriminator (`empty_response` / `stream_failed`) on those throws. The cascade is unchanged: `isHardProviderFailure` already treats a status <= 0 as a hard failure and walks to the next provider — this only sharpens the logs. --- src/lib/ai/__tests__/openai-client.test.ts | 68 ++++++++++++++++++++++ src/lib/ai/anthropic-client.ts | 11 +++- src/lib/ai/codex-client.ts | 41 +++++++++++-- src/lib/ai/local-client.ts | 11 +++- src/lib/ai/openai-client.ts | 14 ++++- 5 files changed, 138 insertions(+), 7 deletions(-) diff --git a/src/lib/ai/__tests__/openai-client.test.ts b/src/lib/ai/__tests__/openai-client.test.ts index 170223801..2a94eed33 100644 --- a/src/lib/ai/__tests__/openai-client.test.ts +++ b/src/lib/ai/__tests__/openai-client.test.ts @@ -290,4 +290,72 @@ describe("OpenAIClient", () => { image_url: { url: "data:image/png;base64,AAAA" }, }); }); + + it("tags an empty-content reply with sentinel httpStatus 0 + kind for the chain classifier", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "" } }], + usage: { total_tokens: 5 }, + }), + }), + ); + + const client = new OpenAIClient({ + apiKey: "sk-test", + model: "gpt-4o-mini", + baseUrl: "https://api.openai.com/v1", + }); + + await client + .generateCompletion(singleUserTurn({ system: "s", user: "u" })) + .then( + () => { + throw new Error("expected an empty-content throw"); + }, + (err: unknown) => { + // The throw distinguishes an empty 200-OK reply from ECONNRESET: + // sentinel httpStatus 0 (still classified as a hard failure, so the + // cascade is unchanged) + a `kind` discriminator for observability. + expect((err as Error).message).toContain("empty content"); + expect((err as { httpStatus?: number }).httpStatus).toBe(0); + expect((err as { kind?: string }).kind).toBe("empty_response"); + }, + ); + }); + + it("threads the caller's abort signal onto the upstream fetch", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "ok" } }], + usage: { total_tokens: 1 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new OpenAIClient({ + apiKey: "sk-test", + model: "gpt-4o-mini", + baseUrl: "https://api.openai.com/v1", + }); + + const ctrl = new AbortController(); + await client.generateCompletion( + singleUserTurn({ system: "s", user: "u", signal: ctrl.signal }), + ); + + // safeFetch composes the caller signal with the timeout via AbortSignal.any, + // so a signal lands on the dispatched fetch init. + const init = mockFetch.mock.calls[0][1]; + expect(init.signal).toBeInstanceOf(AbortSignal); + + // Aborting the caller's controller propagates to the composed signal. + ctrl.abort(); + expect(init.signal.aborted).toBe(true); + }); }); diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 00c704701..349db83d2 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -320,7 +320,16 @@ export class AnthropicClient implements AIProvider { // A tool_use-only reply carries no text — that is valid (F1). Only an empty // reply with neither text NOR a tool call is an error. if (!rawText && !toolCalls) { - throw new Error("Anthropic returned empty content"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // an empty 200-OK reply apart from a transport failure. Cascade unchanged + // (`status <= 0` is already a hard failure). + const err = new Error("Anthropic returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "anthropic", + }); + throw err; } // When we prefilled the assistant turn with `{`, the model continues diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index dcae2020a..4bc717814 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -494,7 +494,15 @@ export class CodexClient implements AIProvider { requestedSlug: string, ): Promise { if (!res.body) { - throw new Error("Codex returned no response body"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // a stream-level failure apart from a connect-time transport error. + const err = new Error("Codex returned no response body"); + Object.assign(err, { + httpStatus: 0, + kind: "stream_failed", + upstream: "codex", + }); + throw err; } const reader = res.body.getReader(); @@ -597,6 +605,10 @@ export class CodexClient implements AIProvider { const e = new Error(message); Object.assign(e, { upstream: "codex", + // v1.20.1 — sentinel httpStatus + kind so the chain classifier + // distinguishes a stream-level failure from a transport error. + httpStatus: 0, + kind: "stream_failed", errorCode: err?.code ?? null, model: serverModel ?? requestedSlug, }); @@ -606,13 +618,25 @@ export class CodexClient implements AIProvider { const reason = parsed.response?.incomplete_details?.reason ?? "incomplete response"; - throw new Error(`Codex stream incomplete: ${reason}`); + const e = new Error(`Codex stream incomplete: ${reason}`); + Object.assign(e, { + httpStatus: 0, + kind: "stream_failed", + upstream: "codex", + }); + throw e; } case "error": case "response.error": { const message = parsed.error?.message ?? "Codex stream returned an error event"; - throw new Error(message); + const e = new Error(message); + Object.assign(e, { + httpStatus: 0, + kind: "stream_failed", + upstream: "codex", + }); + throw e; } default: // Unknown event types are tolerated and ignored, same @@ -629,7 +653,16 @@ export class CodexClient implements AIProvider { // A function-call-only reply carries no text — valid (F1). Only an empty // reply with neither text NOR a tool call is an error. if (!content && toolCalls.length === 0) { - throw new Error("Codex returned empty content"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // an empty reply apart from a transport failure. Cascade unchanged + // (`status <= 0` is already a hard failure). + const err = new Error("Codex returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "codex", + }); + throw err; } return { diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index fa41db7d7..eaf30ceff 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -161,7 +161,16 @@ export class LocalOpenAICompatibleClient implements AIProvider { const content = json.choices?.[0]?.message?.content; if (!content) { - throw new Error("Local AI returned empty content"); + // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell + // an empty 200-OK reply apart from a transport failure. Cascade unchanged + // (`status <= 0` is already a hard failure). + const err = new Error("Local AI returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "local", + }); + throw err; } return { diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index f84cda5f4..26b291296 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -126,7 +126,19 @@ export class OpenAIClient implements AIProvider { // A reply with tool calls and no prose is valid (F1 tool loop); only an // empty reply with neither content NOR tool calls is an error. if (!content && !toolCalls) { - throw new Error("OpenAI returned empty content"); + // v1.20.1 — tag the empty-reply throw with a sentinel `httpStatus: 0` + + // `kind` so the provider-chain classifier can tell an empty 200-OK reply + // apart from a genuine transport failure (ECONNRESET, DNS, timeout) in + // observability. The cascade is unchanged: `isHardProviderFailure` + // already treats `status <= 0` as a hard failure and walks to the next + // provider. + const err = new Error("OpenAI returned empty content"); + Object.assign(err, { + httpStatus: 0, + kind: "empty_response", + upstream: "openai", + }); + throw err; } return {