mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user