fix(ai): tag empty-reply and stream-failed provider throws with a sentinel status

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 09:27:42 +02:00
1 parent 4c571b0e04
commit c304268a9e
5 files changed
+138 -7

No files matched your search

@@ -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);
});
});
+10 -1
View File
@@ -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
+37 -4
View File
@@ -494,7 +494,15 @@ export class CodexClient implements AIProvider {
requestedSlug: string,
): Promise<CompletionResult> {
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 {
+10 -1
View File
@@ -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 {
+13 -1
View File
@@ -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 {