mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge: main (v1.21.2.1 hotfix) into release/v1.21.3
This commit is contained in:
commit
e24e56d436
11 files changed
+337
-43
No files matched your search
@@ -2,6 +2,18 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.21.2.1] — 2026-06-27 — Coach + briefing provider hotfix
|
||||
|
||||
A hotfix. No migrations, no breaking changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Coach and the daily briefing failed for accounts signed in through the ChatGPT provider: the provider rejected the multi-step tool-call request, and the failure surfaced as a server error instead of a graceful message. A provider failure now always degrades to a clear in-chat notice — with the correct reconnect or rate-limit prompt where those apply — rather than a 500, and the provider request follows the documented tool-call wire format so the Coach's multi-round reasoning and the briefing generate again. The provider's own error detail is now captured for diagnosis.
|
||||
|
||||
### Changed
|
||||
|
||||
- While the Coach is thinking, the bubble shows only the three animated dots — the "Thinking…" word is gone (it remains for screen readers).
|
||||
|
||||
## [1.21.2] — 2026-06-27 — Ambient Coach presence
|
||||
|
||||
A patch release. The Coach surfaces what it already computes — at the metric, on the score, in the briefing — instead of waiting to be asked. No migrations, no breaking changes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.21.2
|
||||
version: 1.21.2.1
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.21.2",
|
||||
"version": "1.21.2.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.21.2";
|
||||
/* @sw-version-fallback */ "v1.21.2.1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -261,6 +261,34 @@ describe("coach chat — tool-mode routing (F1)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a graceful provider-error stream (NOT an HTTP 500) when the tool loop throws a tagged provider error", async () => {
|
||||
// v1.21.3 — RCA for the live incident. A Codex 400 raised inside the tool
|
||||
// loop used to bubble out un-wrapped and rethrow as an HTTP 500. The route
|
||||
// must now classify any tagged provider error (`upstream` + `httpStatus`)
|
||||
// and return the graceful 200 SSE error frame the drawer decodes.
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "codex", instance: {} },
|
||||
]);
|
||||
(runCoachToolLoop as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
Object.assign(new Error("Codex request failed (400): {…}"), {
|
||||
httpStatus: 400,
|
||||
upstream: "codex",
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await post(chatReq({ message: "How is my BP?" }));
|
||||
|
||||
// No throw → no 500. The provider-error frame streams over a 200.
|
||||
expect(res.status).toBe(200);
|
||||
// The full reservation is refunded (no tokens billed on a failed turn).
|
||||
expect(reconcileSpend).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
3000,
|
||||
0,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists the tool trace onto provenance", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
|
||||
@@ -672,6 +672,30 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
|
||||
: "coach.provider.unavailable",
|
||||
});
|
||||
}
|
||||
// v1.21.3 — defence in depth. The chain runner wraps every hard provider
|
||||
// failure in `AllProvidersFailedError`, but a provider client can still
|
||||
// throw a tagged wire error that reaches here un-wrapped (e.g. a Codex 400
|
||||
// raised mid tool-loop on a path the chain runner did not catch). Such an
|
||||
// error is a PROVIDER failure, not a server bug — surface the same graceful
|
||||
// `coach.provider.*` frame the chain path uses rather than rethrowing into
|
||||
// an HTTP 500 (the bug that took the live Coach down for codex users). Only
|
||||
// a genuinely unexpected error (no upstream tag, no httpStatus) keeps the
|
||||
// 500 + GlitchTip path so real defects stay visible.
|
||||
const providerError = classifyBubblingProviderError(err);
|
||||
if (providerError) {
|
||||
annotate({
|
||||
action: { name: "insights.coach.providerFailed" },
|
||||
meta: {
|
||||
attempts: 1,
|
||||
firstStatus: providerError.httpStatus,
|
||||
credentialExpired: providerError.code === "credential_expired",
|
||||
unwrapped: true,
|
||||
},
|
||||
});
|
||||
return streamProviderError({
|
||||
code: `coach.provider.${providerError.code}`,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1073,6 +1097,34 @@ async function streamRefusal(args: {
|
||||
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.3 — classify a provider error that bubbled out of the chain runner
|
||||
* un-wrapped (i.e. not an `AllProvidersFailedError`). The provider clients tag
|
||||
* their thrown errors with `upstream` + `httpStatus`; a tagged error is a
|
||||
* provider failure that must surface a graceful `coach.provider.*` frame rather
|
||||
* than rethrow into an HTTP 500. Returns `null` for anything that is NOT a
|
||||
* recognisable provider error (a real server bug), so those keep the 500 +
|
||||
* GlitchTip path. Status mapping mirrors `AllProvidersFailedError`: 401/403 →
|
||||
* credential_expired, 429 → rate_limited, everything else → unavailable.
|
||||
*/
|
||||
function classifyBubblingProviderError(err: unknown): {
|
||||
code: "credential_expired" | "rate_limited" | "unavailable";
|
||||
httpStatus: number | null;
|
||||
} | null {
|
||||
if (err === null || typeof err !== "object") return null;
|
||||
const e = err as { upstream?: unknown; httpStatus?: unknown };
|
||||
const hasUpstreamTag = typeof e.upstream === "string";
|
||||
const status = typeof e.httpStatus === "number" ? e.httpStatus : null;
|
||||
// Require the wire tag — a bare `{ httpStatus }` from unrelated code must not
|
||||
// be swallowed as a provider outage.
|
||||
if (!hasUpstreamTag) return null;
|
||||
if (status === 401 || status === 403) {
|
||||
return { code: "credential_expired", httpStatus: status };
|
||||
}
|
||||
if (status === 429) return { code: "rate_limited", httpStatus: status };
|
||||
return { code: "unavailable", httpStatus: status };
|
||||
}
|
||||
|
||||
function streamProviderError(args: { code: string }): Response {
|
||||
const stream = createSseStream((controller) => {
|
||||
controller.enqueue(
|
||||
|
||||
@@ -951,10 +951,9 @@ export function TypingDots({ label }: { label: string }) {
|
||||
return (
|
||||
<span
|
||||
data-slot="coach-typing-indicator"
|
||||
// v1.18.7 — show the word alongside the dots (Claude/ChatGPT-like
|
||||
// "Thinking…") with a gentle pulse, so the brief pre-stream beat
|
||||
// reads as deliberate rather than a frozen empty bubble. The dots
|
||||
// bounce in sequence; the label keeps the screen-reader text.
|
||||
// v1.21.2.1 — the pre-stream beat shows ONLY the three bouncing dots,
|
||||
// no "Thinking…" word. The label stays in an `sr-only` span so the
|
||||
// screen-reader still announces the thinking state.
|
||||
className="text-muted-foreground inline-flex items-center gap-2 py-0.5"
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
@@ -967,12 +966,6 @@ export function TypingDots({ label }: { label: string }) {
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="animate-pulse text-xs motion-reduce:animate-none"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,11 +314,15 @@ describe("CodexClient", () => {
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
// v1.21.3 — the Responses-API function tool carries an explicit `strict`
|
||||
// field; omitting it is a documented 400 cause on a client-supplied tools
|
||||
// array (the live Coach tool-call regression).
|
||||
expect(body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
name: "fetch_glucose",
|
||||
description: "Fetch glucose readings",
|
||||
strict: false,
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
@@ -332,4 +336,84 @@ describe("CodexClient", () => {
|
||||
expect(result.finishReason).toBe("tool_calls");
|
||||
expect(result.cachedInputTokens).toBe(20);
|
||||
});
|
||||
|
||||
it("replays a prior assistant turn as output_text, not input_text (Responses API multi-round contract)", async () => {
|
||||
// v1.21.3 — assistant message replays MUST use `output_text`. Sending
|
||||
// `input_text` for an assistant turn is a spec violation the Coach tool
|
||||
// loop hit on round 2+, surfacing as a 400. User turns keep input_text.
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
sseResponse([messageDoneEvent("final"), completedEvent(10)]),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
||||
});
|
||||
|
||||
await client.generateCompletion({
|
||||
system: "s",
|
||||
messages: [
|
||||
{ role: "user", content: "how is my bp?" },
|
||||
{ role: "assistant", content: "Let me check." },
|
||||
{ role: "user", content: "thanks" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
const messageItems = body.input.filter(
|
||||
(i: { type: string }) => i.type === "message",
|
||||
);
|
||||
const userItem = messageItems.find(
|
||||
(i: { role: string }) => i.role === "user",
|
||||
);
|
||||
const assistantItem = messageItems.find(
|
||||
(i: { role: string }) => i.role === "assistant",
|
||||
);
|
||||
expect(userItem.content[0].type).toBe("input_text");
|
||||
expect(assistantItem.content[0].type).toBe("output_text");
|
||||
expect(assistantItem.content[0].text).toBe("Let me check.");
|
||||
});
|
||||
|
||||
it("folds the redacted upstream body into the thrown 400 message for diagnosability", async () => {
|
||||
// v1.21.3 — the 400 body names the rejected field/param. It must reach the
|
||||
// error message (the chain runner's summariseError reads err.message) so
|
||||
// the live failure is diagnosable, with bearer/sk- secrets redacted.
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () =>
|
||||
'Bearer secret-token-here {"error":{"message":"Unknown parameter: tools[0].strict"}}',
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u" }),
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
const e = caught as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
expect(e.httpStatus).toBe(400);
|
||||
expect(e.message).toContain("Codex request failed (400)");
|
||||
expect(e.message).toContain("Unknown parameter");
|
||||
// Secret redacted in both the message and the side property.
|
||||
expect(e.message).not.toContain("secret-token-here");
|
||||
expect(e.bodyExcerpt).toContain("Bearer ***redacted***");
|
||||
});
|
||||
});
|
||||
@@ -118,8 +118,23 @@ describe("isHardProviderFailure", () => {
|
||||
expect(isHardProviderFailure(err(503))).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag 4xx validation errors (e.g. 422) as hard — those bubble", () => {
|
||||
expect(isHardProviderFailure(err(422))).toBe(false);
|
||||
it("flags a raw provider-wire 4xx (e.g. a Codex 400 rejecting the tool-call request) as hard so it cascades and is wrapped rather than bubbling into a 500", () => {
|
||||
// v1.21.3 — a 4xx surfaced by a provider wire is a provider failure, not a
|
||||
// caller bug. It must cascade so the chain runner wraps it in
|
||||
// AllProvidersFailedError; the route then surfaces a graceful provider
|
||||
// frame instead of rethrowing into an HTTP 500.
|
||||
expect(isHardProviderFailure(err(400))).toBe(true);
|
||||
expect(isHardProviderFailure(err(422))).toBe(true);
|
||||
expect(isHardProviderFailure(err(404))).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag an InsightSchemaError as hard — the strict JSON 422 surface still bubbles", async () => {
|
||||
const { InsightSchemaError } = await import("../schema");
|
||||
expect(
|
||||
isHardProviderFailure(
|
||||
new InsightSchemaError("bad json", { attempts: 2 }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("flags network errors (no httpStatus) as hard", () => {
|
||||
@@ -445,6 +460,45 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
expect((caught as AllProvidersFailedError).attempts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("wraps a sole-provider Codex 400 (tool-call request rejected) in AllProvidersFailedError instead of bubbling the raw error", async () => {
|
||||
// v1.21.3 — RCA for the live 500. A Codex 400 on the richer tool-call
|
||||
// request used to escape `isHardProviderFailure` (a non-401/403/429/5xx
|
||||
// 4xx) and bubble OUT of the chain runner un-wrapped. The Coach route's
|
||||
// catch only handled AllProvidersFailedError, so the raw error rethrew as
|
||||
// an HTTP 500 — exactly the incident. The 400 must now cascade and, with no
|
||||
// healthy provider left, be wrapped so the route can surface a graceful
|
||||
// provider frame (httpStatus classifies to 503 → coach.provider.unavailable).
|
||||
const codex = new ScriptedProvider({
|
||||
type: "codex",
|
||||
script: [
|
||||
{
|
||||
ok: false,
|
||||
error: Object.assign(new Error("Codex request failed (400): {…}"), {
|
||||
httpStatus: 400,
|
||||
upstream: "codex",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runRawCompletionWithFallback({
|
||||
userId: "u-raw-codex400",
|
||||
providers: [{ providerType: "codex", instance: codex }],
|
||||
params: singleUserTurn({ system: "s", user: "u" }),
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AllProvidersFailedError);
|
||||
const wrapped = caught as AllProvidersFailedError;
|
||||
expect(wrapped.attempts).toHaveLength(1);
|
||||
expect(wrapped.attempts[0].httpStatus).toBe(400);
|
||||
expect(wrapped.primaryCredentialExpired).toBe(false);
|
||||
// No 5xx / auth in the mix → generic unavailability (503) surface.
|
||||
expect(wrapped.httpStatus).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithFallback — empty input", () => {
|
||||
|
||||
+60
-20
@@ -116,15 +116,18 @@ function isSlugRejection(status: number, bodyExcerpt: string): boolean {
|
||||
const ORIGINATOR = "healthlog";
|
||||
const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)";
|
||||
|
||||
/** A Responses-API user/assistant content block. */
|
||||
type CodexContentBlock =
|
||||
| { type: "input_text"; text: string }
|
||||
| { type: "input_image"; image_url: string; detail: "high" }
|
||||
| { type: "output_text"; text: string };
|
||||
|
||||
/** A Responses-API input item. */
|
||||
type CodexInputItem =
|
||||
| {
|
||||
type: "message";
|
||||
role: "user" | "assistant";
|
||||
content: Array<
|
||||
| { type: "input_text"; text: string }
|
||||
| { type: "input_image"; image_url: string; detail: "high" }
|
||||
>;
|
||||
content: CodexContentBlock[];
|
||||
}
|
||||
| {
|
||||
type: "function_call";
|
||||
@@ -138,20 +141,21 @@ type CodexInputItem =
|
||||
output: string;
|
||||
};
|
||||
|
||||
/** Map an `AiContentPart[]` body into Responses input content blocks. */
|
||||
/**
|
||||
* Map an `AiContentPart[]` body into Responses input content blocks. Text parts
|
||||
* on an assistant turn use `output_text` (Responses API requires it for
|
||||
* assistant replays); user turns use `input_text`. Image parts are user-only.
|
||||
*/
|
||||
function mapCodexParts(
|
||||
parts: AiContentPart[],
|
||||
): Array<
|
||||
| { type: "input_text"; text: string }
|
||||
| { type: "input_image"; image_url: string; detail: "high" }
|
||||
> {
|
||||
const out: Array<
|
||||
| { type: "input_text"; text: string }
|
||||
| { type: "input_image"; image_url: string; detail: "high" }
|
||||
> = [];
|
||||
role: "user" | "assistant",
|
||||
): CodexContentBlock[] {
|
||||
const textType: "input_text" | "output_text" =
|
||||
role === "assistant" ? "output_text" : "input_text";
|
||||
const out: CodexContentBlock[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
out.push({ type: "input_text", text: part.text });
|
||||
out.push({ type: textType, text: part.text });
|
||||
} else if (part.type === "image") {
|
||||
out.push({
|
||||
type: "input_image",
|
||||
@@ -181,10 +185,18 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const content =
|
||||
// v1.21.3 — assistant message replays MUST use `output_text`, not
|
||||
// `input_text` (Responses API; docs/codex-protocol-spec.md §2b). The
|
||||
// single-user-turn case never hit this — only the multi-round tool loop
|
||||
// replays a prior assistant turn — so the spec violation surfaced as a 400
|
||||
// exactly on the Coach tool-call path that broke in production. User turns
|
||||
// keep `input_text` / `input_image`.
|
||||
const textType: "input_text" | "output_text" =
|
||||
m.role === "assistant" ? "output_text" : "input_text";
|
||||
const content: CodexContentBlock[] =
|
||||
typeof m.content === "string"
|
||||
? [{ type: "input_text" as const, text: m.content }]
|
||||
: mapCodexParts(m.content);
|
||||
? [{ type: textType, text: m.content }]
|
||||
: mapCodexParts(m.content, m.role);
|
||||
items.push({ type: "message", role: m.role, content });
|
||||
if (m.role === "assistant" && m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
@@ -200,12 +212,23 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Map tool defs into the Responses `function` tool shape. */
|
||||
/**
|
||||
* Map tool defs into the Responses `function` tool shape.
|
||||
*
|
||||
* v1.21.3 — the Responses-API function tool requires an explicit `strict`
|
||||
* field; omitting it is one documented cause of a 400 on a client-supplied
|
||||
* `tools` array. We send `strict: false` because the Coach tool schemas are
|
||||
* not authored to the strict-mode contract (which additionally demands
|
||||
* `additionalProperties: false` and every property in `required`). This is
|
||||
* codex-only — the OpenAI Chat-Completions client nests tools differently and
|
||||
* is untouched.
|
||||
*/
|
||||
function buildCodexTools(tools: AiToolDef[]) {
|
||||
return tools.map((t) => ({
|
||||
type: "function" as const,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
strict: false,
|
||||
parameters: t.parameters,
|
||||
}));
|
||||
}
|
||||
@@ -347,7 +370,18 @@ export class CodexClient implements AIProvider {
|
||||
|
||||
// Non-slug error (5xx, 429, invalid_prompt, etc.) — propagate
|
||||
// immediately. Walking the chain wouldn't help.
|
||||
const err = new Error(`Codex request failed (${firstAttempt.status})`);
|
||||
//
|
||||
// v1.21.3 — fold the redacted upstream body into the message itself, not
|
||||
// just a side property. Codex's 400 body names the exact field/param it
|
||||
// rejected; without it in the message the chain runner's `summariseError`
|
||||
// (which reads `err.message`) logged a bare "Codex request failed (400)"
|
||||
// with no actionable reason — which is what made the live tool-call 400
|
||||
// un-diagnosable. The body is already redacted of bearer/sk- secrets.
|
||||
const err = new Error(
|
||||
bodyExcerpt
|
||||
? `Codex request failed (${firstAttempt.status}): ${bodyExcerpt}`
|
||||
: `Codex request failed (${firstAttempt.status})`,
|
||||
);
|
||||
Object.assign(err, {
|
||||
httpStatus: firstAttempt.status,
|
||||
upstream: "codex",
|
||||
@@ -391,7 +425,13 @@ export class CodexClient implements AIProvider {
|
||||
): Promise<Error> {
|
||||
const rawBody = await res.text().catch(() => "");
|
||||
const bodyExcerpt = redactBody(rawBody);
|
||||
const err = new Error(`${message} (${res.status})`);
|
||||
// v1.21.3 — fold the redacted body into the message (see the non-slug error
|
||||
// path); the chain runner's `summariseError` reads `err.message`.
|
||||
const err = new Error(
|
||||
bodyExcerpt
|
||||
? `${message} (${res.status}): ${bodyExcerpt}`
|
||||
: `${message} (${res.status})`,
|
||||
);
|
||||
Object.assign(err, {
|
||||
httpStatus: res.status,
|
||||
upstream: "codex",
|
||||
|
||||
@@ -120,12 +120,21 @@ function rememberWorkingProvider(
|
||||
* - Network errors (no `httpStatus`) — DNS, ECONNRESET, timeout.
|
||||
*
|
||||
* No:
|
||||
* - 4xx other than 401/403/429 — usually validation; the wrapper's
|
||||
* 422 path already covers schema mismatch and that surface is
|
||||
* valuable for prompt-debugging.
|
||||
* - `InsightSchemaError` — same logic; surfaces the wrapper's
|
||||
* existing 422 so the user sees "model JSON malformed" rather
|
||||
* than getting silent provider drift.
|
||||
* - `InsightSchemaError` — surfaces the wrapper's existing 422 so the
|
||||
* user sees "model JSON malformed" rather than getting silent
|
||||
* provider drift. This is the ONLY non-hard class.
|
||||
*
|
||||
* v1.21.3 — a 4xx that the PROVIDER WIRE returned (tagged `upstream`,
|
||||
* e.g. a Codex `400` rejecting the tool-call / structured request shape)
|
||||
* is now hard. It used to fall through `isHardProviderFailure → false`,
|
||||
* so the raw error bubbled out of the chain runner UNWRAPPED — the Coach
|
||||
* route's catch only handles `AllProvidersFailedError`, so an un-wrapped
|
||||
* provider error rethrew as an HTTP 500. Treating every upstream 4xx as
|
||||
* hard means it cascades to the next provider and, on exhaustion, is
|
||||
* wrapped in `AllProvidersFailedError` so the route surfaces a graceful
|
||||
* `coach.provider.*` frame instead of a 500. `InsightSchemaError`
|
||||
* (no `upstream` tag) is still excluded, so the strict JSON surface keeps
|
||||
* its 422 "model JSON malformed" diagnostic.
|
||||
*/
|
||||
export function isHardProviderFailure(error: unknown): boolean {
|
||||
if (error instanceof InsightSchemaError) return false;
|
||||
@@ -138,6 +147,10 @@ export function isHardProviderFailure(error: unknown): boolean {
|
||||
}
|
||||
if (status === 401 || status === 403 || status === 429) return true;
|
||||
if (status >= 500) return true;
|
||||
// Any other 4xx surfaced by a provider wire (the Codex / OpenAI /
|
||||
// Anthropic clients all tag their thrown errors) is a provider failure,
|
||||
// not a caller bug — cascade rather than bubble raw into a 500.
|
||||
if (status >= 400) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -312,14 +325,30 @@ async function resolveChainOrder(
|
||||
return applyLastWorkingCache(userId, afterSkips);
|
||||
}
|
||||
|
||||
function summariseError(e: unknown): { reason: string; status: number | null } {
|
||||
const err = e as { message?: string; httpStatus?: number };
|
||||
function summariseError(e: unknown): {
|
||||
reason: string;
|
||||
status: number | null;
|
||||
bodyExcerpt: string | null;
|
||||
} {
|
||||
const err = e as {
|
||||
message?: string;
|
||||
httpStatus?: number;
|
||||
bodyExcerpt?: string;
|
||||
};
|
||||
const status = typeof err.httpStatus === "number" ? err.httpStatus : null;
|
||||
const message = err.message ?? "unknown error";
|
||||
// v1.21.3 — surface the provider's redacted response body (the codex client
|
||||
// attaches it) so the wide-event carries the upstream's actual rejection
|
||||
// reason, not just "HTTP 400". Already redacted of secrets at the client.
|
||||
const bodyExcerpt =
|
||||
typeof err.bodyExcerpt === "string" && err.bodyExcerpt.length > 0
|
||||
? err.bodyExcerpt.slice(0, 500)
|
||||
: null;
|
||||
// Cap to keep wide-event payloads bounded.
|
||||
return {
|
||||
reason: status !== null ? `HTTP ${status}: ${message}` : message,
|
||||
status,
|
||||
bodyExcerpt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -379,6 +408,7 @@ export async function runWithFallback(
|
||||
[`ai_chain_hop_${i + 1}_provider`]: candidate.providerType,
|
||||
[`ai_chain_hop_${i + 1}_status`]: summary.status,
|
||||
[`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240),
|
||||
[`ai_chain_hop_${i + 1}_body`]: summary.bodyExcerpt,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -468,6 +498,7 @@ export async function runRawCompletionWithFallback(args: {
|
||||
[`ai_chain_hop_${i + 1}_provider`]: candidate.providerType,
|
||||
[`ai_chain_hop_${i + 1}_status`]: summary.status,
|
||||
[`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240),
|
||||
[`ai_chain_hop_${i + 1}_body`]: summary.bodyExcerpt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user