Merge origin/main (v1.44.2) into fix/v145-sheet-motion

next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 16:43:28 +02:00
commit 9c8972bc96
86 files changed
+5051 -487

No files matched your search

@@ -0,0 +1,43 @@
/**
* #1267 — the pinned dispatchers leave the deadline to the caller's signal.
*
* undici's Agent arms its own 300 s header timer and 300 s body timer by
* default. A local model with the response timeout at 600 s answers a
* non-streamed completion only once it is done, so its headers can arrive
* after 300 s: undici then dropped a call the caller still allowed, and the
* failure read as a bare network error. Waiting five minutes in a unit test
* proves nothing faster, so this reads the options the Agent was built with.
*/
import { afterEach, describe, expect, it } from "vitest";
import type { Agent } from "undici";
import {
getPinnedOperatorApprovedDispatcher,
getPinnedPublicDispatcher,
_resetPinnedDispatcherForTests,
} from "../safe-fetch-dispatcher";
function agentOptions(agent: Agent): {
headersTimeout?: number;
bodyTimeout?: number;
} {
const key = Object.getOwnPropertySymbols(agent).find(
(s) => s.description === "options",
);
expect(key).toBeDefined();
return (agent as unknown as Record<symbol, Record<string, number>>)[key!];
}
afterEach(() => {
_resetPinnedDispatcherForTests();
});
describe("pinned dispatcher timers", () => {
it.each([
["public", getPinnedPublicDispatcher],
["operator-approved", getPinnedOperatorApprovedDispatcher],
])("the %s dispatcher arms no header or body timer of its own", (_, get) => {
const options = agentOptions(get());
expect(options.headersTimeout).toBe(0);
expect(options.bodyTimeout).toBe(0);
});
});
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, it } from "vitest";
import {
SHEET_AXIS_SLOP,
SHEET_OVERDRAG,
backdropOpacity,
contentDrags,
dragOffset,
footerLift,
lockAxis,
releaseVelocity,
rubberBand,
settleSheet,
translateYOf,
} from "../sheet-gesture";
describe("lockAxis", () => {
it("waits inside the slop", () => {
expect(lockAxis(0, 0)).toBeNull();
expect(lockAxis(SHEET_AXIS_SLOP - 1, -(SHEET_AXIS_SLOP - 1))).toBeNull();
});
it("reads vertical and sideways once past it", () => {
expect(lockAxis(2, SHEET_AXIS_SLOP)).toBe("y");
expect(lockAxis(2, -20)).toBe("y");
expect(lockAxis(SHEET_AXIS_SLOP, 3)).toBe("x");
expect(lockAxis(-30, 12)).toBe("x");
});
it("gives a diagonal tie to the sideways gesture", () => {
expect(lockAxis(12, 12)).toBe("x");
expect(lockAxis(-12, 12)).toBe("x");
});
});
describe("rubberBand / dragOffset", () => {
it("follows the finger 1:1 below the top", () => {
expect(dragOffset(0, 120)).toBe(120);
expect(dragOffset(300, -200)).toBe(100);
expect(dragOffset(300, -300)).toBe(0);
});
it("resists above the top and never passes the limit", () => {
const small = -dragOffset(0, -4);
expect(small).toBeGreaterThan(3);
expect(small).toBeLessThan(4);
const far = -dragOffset(0, -2000);
expect(far).toBeLessThan(SHEET_OVERDRAG);
expect(far).toBeGreaterThan(SHEET_OVERDRAG * 0.95);
// From a lowered resting place the resistance starts at the top only.
expect(dragOffset(300, -350)).toBeCloseTo(-rubberBand(50), 6);
});
it("is monotonic", () => {
let previous = Infinity;
for (let d = 0; d <= 500; d += 25) {
const y = dragOffset(0, -d);
expect(y).toBeLessThanOrEqual(previous);
previous = y;
}
});
it("has no travel for no distance", () => {
expect(rubberBand(0)).toBe(0);
expect(rubberBand(-5)).toBe(0);
});
});
describe("releaseVelocity", () => {
it("measures px/ms over the samples inside the window", () => {
const samples = [
{ t: 0, y: 0 },
{ t: 50, y: 10 },
{ t: 100, y: 60 },
{ t: 150, y: 110 },
];
// Window 100 ms before t=150 keeps t=50..150: (110-10)/100.
expect(releaseVelocity(samples, 150)).toBeCloseTo(1, 6);
});
it("is zero when the finger rested before letting go", () => {
const samples = [
{ t: 0, y: 0 },
{ t: 40, y: 200 },
];
expect(releaseVelocity(samples, 400)).toBe(0);
});
it("is zero with a single sample", () => {
expect(releaseVelocity([{ t: 10, y: 5 }], 20)).toBe(0);
expect(releaseVelocity([], 20)).toBe(0);
});
it("is negative for an upward flick", () => {
const samples = [
{ t: 0, y: 100 },
{ t: 40, y: 20 },
];
expect(releaseVelocity(samples, 40)).toBeCloseTo(-2, 6);
});
});
describe("settleSheet without a preview step", () => {
const base = { height: 400, previewOffset: null };
it("closes past a quarter of the height", () => {
expect(settleSheet({ ...base, offset: 101, velocity: 0 })).toEqual({
kind: "close",
});
});
it("springs back short of it", () => {
expect(settleSheet({ ...base, offset: 99, velocity: 0 })).toEqual({
kind: "snap",
snap: "full",
});
});
it("closes on a flick down faster than 0.5 px/ms", () => {
expect(settleSheet({ ...base, offset: 30, velocity: 0.6 })).toEqual({
kind: "close",
});
expect(settleSheet({ ...base, offset: 30, velocity: 0.5 })).toEqual({
kind: "snap",
snap: "full",
});
});
it("does not close on a flick down from above the top", () => {
expect(settleSheet({ ...base, offset: -10, velocity: 2 })).toEqual({
kind: "snap",
snap: "full",
});
});
});
describe("settleSheet with a preview step", () => {
// 800 px tall, the preview shows 450: resting offset 350.
const base = { height: 800, previewOffset: 350 };
it("closes a quarter of the visible preview below it", () => {
expect(settleSheet({ ...base, offset: 350 + 113, velocity: 0 })).toEqual({
kind: "close",
});
expect(settleSheet({ ...base, offset: 350 + 112, velocity: 0 })).toEqual({
kind: "snap",
snap: "preview",
});
});
it("goes to the nearer step on a slow release", () => {
expect(settleSheet({ ...base, offset: 100, velocity: 0 })).toEqual({
kind: "snap",
snap: "full",
});
expect(settleSheet({ ...base, offset: 250, velocity: 0 })).toEqual({
kind: "snap",
snap: "preview",
});
});
it("rises to the top on a flick up", () => {
expect(settleSheet({ ...base, offset: 330, velocity: -0.8 })).toEqual({
kind: "snap",
snap: "full",
});
});
it("drops a full sheet to the preview step on a flick down, and closes below it", () => {
expect(settleSheet({ ...base, offset: 60, velocity: 1.2 })).toEqual({
kind: "snap",
snap: "preview",
});
expect(settleSheet({ ...base, offset: 360, velocity: 1.2 })).toEqual({
kind: "close",
});
});
});
describe("backdropOpacity", () => {
it("is whole at and above the lowest resting place", () => {
expect(backdropOpacity(0, 0, 400)).toBe(1);
expect(backdropOpacity(-20, 0, 400)).toBe(1);
expect(backdropOpacity(100, 300, 800)).toBe(1);
});
it("fades in proportion to the way out", () => {
expect(backdropOpacity(200, 0, 400)).toBeCloseTo(0.5, 6);
expect(backdropOpacity(300 + 250, 300, 800)).toBeCloseTo(0.5, 6);
expect(backdropOpacity(900, 0, 400)).toBe(0);
});
});
describe("footerLift", () => {
it("holds the footer on the window edge between the steps", () => {
expect(footerLift(0, 350)).toBe(0);
expect(footerLift(200, 350)).toBe(200);
expect(footerLift(350, 350)).toBe(350);
});
it("lets it leave with the sheet below the preview step", () => {
expect(footerLift(500, 350)).toBe(350);
expect(footerLift(-10, 350)).toBe(0);
});
});
describe("contentDrags", () => {
const top = { scrollTop: 0, hasPreview: false, snap: "full" as const };
it("scrolls first when the content is scrolled", () => {
expect(contentDrags({ ...top, scrollTop: 1, dy: 30 })).toBe(false);
expect(contentDrags({ ...top, scrollTop: 120, dy: -30 })).toBe(false);
});
it("drags down from the very top", () => {
expect(contentDrags({ ...top, dy: 30 })).toBe(true);
});
it("lets an upward swipe scroll, unless it raises a preview sheet", () => {
expect(contentDrags({ ...top, dy: -30 })).toBe(false);
expect(
contentDrags({ ...top, hasPreview: true, snap: "full", dy: -30 }),
).toBe(false);
expect(
contentDrags({ ...top, hasPreview: true, snap: "preview", dy: -30 }),
).toBe(true);
});
});
describe("translateYOf", () => {
it("reads 2D and 3D matrices", () => {
expect(translateYOf("none")).toBe(0);
expect(translateYOf("")).toBe(0);
expect(translateYOf("matrix(1, 0, 0, 1, 0, 123.5)")).toBe(123.5);
expect(
translateYOf("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -7, 0, 1)"),
).toBe(-7);
});
});
+31 -1
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { resolveEffectiveTimeoutMs } from "../effective-timeout";
import {
CLIENT_ABORT_MARGIN_MS,
clientAbortMs,
PROVIDER_DEFAULT_TIMEOUT_MS,
resolveEffectiveTimeoutMs,
} from "../effective-timeout";
const BUDGET = 120_000;
@@ -24,3 +29,28 @@ describe("resolveEffectiveTimeoutMs", () => {
expect(resolveEffectiveTimeoutMs(45, 60_000)).toBe(45_000);
});
});
describe("clientAbortMs (#1267)", () => {
it("waits the published setting per call plus the margin", () => {
expect(clientAbortMs(600_000, 2)).toBe(
2 * 600_000 + CLIENT_ABORT_MARGIN_MS,
);
});
it("never waits less per call than the route's own longer default", () => {
// 60 s is what the payload publishes when nothing is set; the briefing
// route gives each call 180 s then.
expect(clientAbortMs(60_000, 2, 180_000)).toBe(
2 * 180_000 + CLIENT_ABORT_MARGIN_MS,
);
expect(clientAbortMs(600_000, 2, 180_000)).toBe(
2 * 600_000 + CLIENT_ABORT_MARGIN_MS,
);
});
it("reads a missing value as the provider default", () => {
expect(clientAbortMs(undefined, 1)).toBe(
PROVIDER_DEFAULT_TIMEOUT_MS + CLIENT_ABORT_MARGIN_MS,
);
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* #1267 — `providerFailureKind` against the errors the real request path
* throws: the local client, `safeFetch`, the pinned operator-approved
* dispatcher and a loopback server that misbehaves on purpose. Every case
* used to log as `cause: "network"`.
*/
import http from "node:http";
import net from "node:net";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { LocalOpenAICompatibleClient } from "../local-client";
import { providerFailureKind } from "../failure-kind";
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
import { singleUserTurn } from "../types";
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
type Behaviour =
| "slow"
| "drop-mid-body"
| "not-json"
| "http-500"
| "empty"
| "reasoning-only";
let behaviour: Behaviour = "slow";
let server: http.Server;
let origin: string;
beforeAll(async () => {
server = http.createServer((req, res) => {
req.resume();
req.on("end", () => {
switch (behaviour) {
case "slow":
setTimeout(() => res.end("{}"), 1_000);
return;
case "drop-mid-body":
res.writeHead(200, { "Content-Type": "application/json" });
res.write('{"choices":[{"message":{"content":"half');
setTimeout(() => res.socket?.destroy(), 20);
return;
case "not-json":
res.writeHead(200, { "Content-Type": "application/json" });
res.end("<html>proxy error</html>");
return;
case "http-500":
res.writeHead(500, { "Content-Type": "application/json" });
res.end('{"error":"boom"}');
return;
case "empty":
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
choices: [{ message: { content: "" }, finish_reason: "stop" }],
}),
);
return;
case "reasoning-only":
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
choices: [
{
message: { content: "", reasoning: "thinking…" },
finish_reason: "length",
},
],
}),
);
return;
}
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
_resetPinnedDispatcherForTests();
await new Promise<void>((r) => server.close(() => r()));
});
beforeEach(() => {
resetReasoningDialectCache();
});
async function failureFor(
target: string,
opts: { timeoutMs?: number; signal?: AbortSignal; effort?: "none" } = {},
) {
const saved = process.env.AI_PRIVATE_ORIGINS;
process.env.AI_PRIVATE_ORIGINS = new URL(target).origin;
try {
const c = new LocalOpenAICompatibleClient({
baseUrl: `${target}/v1`,
model: "m",
});
if (opts.effort) c.reasoningEffort = opts.effort;
const err = await c
.generateCompletion({
...singleUserTurn({
system: "s",
user: "u",
timeoutMs: opts.timeoutMs ?? 5_000,
}),
...(opts.signal ? { signal: opts.signal } : {}),
})
.then(
() => null,
(e: unknown) => e,
);
expect(err).not.toBeNull();
return providerFailureKind(err);
} finally {
if (saved === undefined) delete process.env.AI_PRIVATE_ORIGINS;
else process.env.AI_PRIVATE_ORIGINS = saved;
}
}
async function closedPortOrigin(): Promise<string> {
const probe = net.createServer();
await new Promise<void>((r) => probe.listen(0, "127.0.0.1", r));
const port = (probe.address() as AddressInfo).port;
await new Promise<void>((r) => probe.close(() => r()));
return `http://127.0.0.1:${port}`;
}
describe("providerFailureKind on the real request path", () => {
it("names our own deadline a timeout", async () => {
behaviour = "slow";
expect(await failureFor(origin, { timeoutMs: 150 })).toEqual({
failure: "timeout",
code: null,
});
});
it("names a caller cancel an abort, not a timeout", async () => {
behaviour = "slow";
const controller = new AbortController();
setTimeout(() => controller.abort(), 100);
expect(
(await failureFor(origin, { signal: controller.signal })).failure,
).toBe("aborted");
});
it("names a refused connection with its code", async () => {
expect(await failureFor(await closedPortOrigin())).toEqual({
failure: "refused",
code: "ECONNREFUSED",
});
});
it("names a connection dropped mid-body a socket failure", async () => {
behaviour = "drop-mid-body";
const out = await failureFor(origin);
expect(out.failure).toBe("socket");
expect(out.code).toMatch(/^UND_ERR_/);
});
it("names a 200 that is not JSON a parse failure", async () => {
behaviour = "not-json";
expect((await failureFor(origin)).failure).toBe("parse");
});
it("names a non-2xx status http", async () => {
behaviour = "http-500";
expect((await failureFor(origin)).failure).toBe("http");
});
it("tells an empty answer from one that only reasoned", async () => {
behaviour = "empty";
expect((await failureFor(origin)).failure).toBe("empty_response");
// Already asked with `none`: nothing left to retry, the reply is final.
behaviour = "reasoning-only";
expect((await failureFor(origin, { effort: "none" })).failure).toBe(
"reasoning_only",
);
});
it("never lets a free-form string through as a code", () => {
const err = Object.assign(new Error("x"), {
code: "see http://user:pass@host/",
});
expect(providerFailureKind(err)).toEqual({
failure: "unknown",
code: null,
});
});
});
@@ -0,0 +1,369 @@
/**
* The local provider against an OpenAI-compatible server that answers the way
* Ollama serves Gemma 4 on `/v1/chat/completions`, driven through the real
* client, the real `safeFetch`, the pinned operator-approved dispatcher and a
* real HTTP server on loopback. Nothing on the request path is mocked; only
* the chain's health ledger and the signal sink are swapped for in-memory
* stand-ins, because they write to Postgres and to the log transport.
*
* Ollama routes a thinking model's output into a separate `reasoning` field.
* When Gemma 4 thinks (any `reasoning_effort` other than `none`) a long
* generation can come back with `content: ""` — the thought used the whole
* token budget (`finish_reason: "length"`), or the answer itself landed in
* `reasoning` (`finish_reason: "stop"`). Asked with `reasoning_effort: "none"`
* the same model answers in `content`. The background briefing always asks to
* reason, so before the fix every briefing on such a model failed with a
* sentinel status 0 that the chain logged as `cause: "network"` and the
* read path showed as a timeout — in the very second the server logged 200.
*
* The transport block at the top rules the transport out: a slow, chunked,
* large, multi-byte, kept-alive response arrives intact through the same
* pinned path, so the failure is not in the bytes but in what the body says.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const emitSignal = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logging/signal", () => ({
emitSignal: (...a: unknown[]) => emitSignal(...a),
}));
vi.mock("../provider-health-ledger", async () => {
const actual = await vi.importActual<
typeof import("../provider-health-ledger")
>("../provider-health-ledger");
return {
...actual,
postgresProviderHealthLedger: {
async getSkipHints() {
return new Map();
},
async recordSuccess() {},
async recordFailure() {},
},
};
});
import {
LocalOpenAICompatibleClient,
resetLocalJsonDialectCache,
} from "../local-client";
import { OpenAIClient } from "../openai-client";
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
import {
AllProvidersFailedError,
clearLastWorkingProviderCache,
runRawCompletionWithFallback,
runStreamingRawCompletionWithFallback,
} from "../provider-runner";
import { singleUserTurn, type CompletionParams } from "../types";
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
const BRIEFING_JSON = JSON.stringify({
dailyBriefing: { headline: "Steady week", body: "Nothing stood out." },
});
type GemmaMode =
/** Thinking spends the whole budget: empty content, `length`. */
| "budget"
/** The answer lands in `reasoning`, content empty, `stop`. */
| "misrouted"
/** Empty content however it is asked. */
| "always-empty";
interface SeenRequest {
reasoningEffort: unknown;
stream: boolean;
responseFormat: unknown;
}
let server: http.Server;
let origin: string;
let mode: GemmaMode = "budget";
let headerDelayMs = 0;
let seen: SeenRequest[] = [];
/** When set, the answer `content` carries this instead of the briefing. */
let bigAnswer: string | null = null;
/** A long thought, with multi-byte characters, the way Gemma writes one. */
const THOUGHT =
"Let me look at the week's readings — Blutdruck, Gewicht… ".repeat(3200);
function ollamaEnvelope(message: Record<string, unknown>, finish: string) {
return {
id: "chatcmpl-417",
object: "chat.completion",
created: 1_760_000_000,
model: "gemma4:12b",
system_fingerprint: "fp_ollama",
choices: [{ index: 0, message, finish_reason: finish }],
usage: {
prompt_tokens: 5200,
completion_tokens: 6120,
total_tokens: 11320,
},
};
}
function gemmaReply(reasoningEffort: unknown) {
const thinks = reasoningEffort !== "none";
if (mode === "always-empty" || (thinks && mode === "budget")) {
return ollamaEnvelope(
{ role: "assistant", content: "", reasoning: THOUGHT },
"length",
);
}
if (thinks && mode === "misrouted") {
return ollamaEnvelope(
{
role: "assistant",
content: "",
reasoning: `${THOUGHT}\n${BRIEFING_JSON}`,
},
"stop",
);
}
return ollamaEnvelope(
{ role: "assistant", content: bigAnswer ?? BRIEFING_JSON },
"stop",
);
}
/** Write `text` as several chunks, splitting inside multi-byte characters. */
async function writeChunked(res: http.ServerResponse, text: string) {
const bytes = Buffer.from(text, "utf8");
const step = 4093; // odd on purpose: cuts through multi-byte sequences
for (let i = 0; i < bytes.length; i += step) {
res.write(bytes.subarray(i, i + step));
await new Promise((r) => setTimeout(r, 2));
}
res.end();
}
beforeAll(async () => {
server = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", async () => {
const body = JSON.parse(raw) as Record<string, unknown>;
seen.push({
reasoningEffort: body.reasoning_effort,
stream: body.stream === true,
responseFormat: body.response_format,
});
await new Promise((r) => setTimeout(r, headerDelayMs));
const reply = gemmaReply(body.reasoning_effort);
if (body.stream === true) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const message = reply.choices[0].message as {
content: string;
reasoning?: string;
};
const frames: string[] = [];
if (message.reasoning) {
frames.push(
`data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "", reasoning: message.reasoning } }] })}\n\n`,
);
}
if (message.content) {
frames.push(
`data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: message.content } }] })}\n\n`,
);
}
frames.push(
`data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: reply.choices[0].finish_reason }] })}\n\n`,
"data: [DONE]\n\n",
);
await writeChunked(res, frames.join(""));
return;
}
// Go's net/http answers a body this size chunked, without a length.
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
await writeChunked(res, JSON.stringify(reply));
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
_resetPinnedDispatcherForTests();
await new Promise<void>((r) => server.close(() => r()));
});
const savedOrigins = process.env.AI_PRIVATE_ORIGINS;
beforeEach(() => {
process.env.AI_PRIVATE_ORIGINS = origin;
mode = "budget";
headerDelayMs = 0;
bigAnswer = null;
seen = [];
emitSignal.mockClear();
clearLastWorkingProviderCache();
resetLocalJsonDialectCache();
resetReasoningDialectCache();
});
afterEach(() => {
if (savedOrigins === undefined) delete process.env.AI_PRIVATE_ORIGINS;
else process.env.AI_PRIVATE_ORIGINS = savedOrigins;
});
function client() {
return new LocalOpenAICompatibleClient({
baseUrl: `${origin}/v1`,
model: "gemma4:12b",
});
}
/** The briefing call exactly as `runBriefingCompletion` shapes it. */
function briefingParams(effort: "low" | "medium" = "medium"): CompletionParams {
return {
...singleUserTurn({
system: "You write the daily briefing.",
user: "Snapshot: …",
temperature: 0.3,
maxTokens: 2500,
timeoutMs: 30_000,
responseFormat: "json",
}),
reasoning: { effort, summaries: false },
};
}
function runChain(params: CompletionParams) {
return runRawCompletionWithFallback({
userId: "user-1",
surface: "job",
providers: [{ providerType: "local", instance: client() }],
params,
});
}
function linkFailedMeta(): Array<Record<string, unknown>> {
return emitSignal.mock.calls
.map((c) => c[0] as { action: string; meta: Record<string, unknown> })
.filter((s) => s.action === "ai.chain.link_failed")
.map((s) => s.meta);
}
describe("transport: the pinned path delivers a slow, large, chunked reply intact", () => {
it("returns a delayed 180 KB multi-byte answer whole, twice in a row", async () => {
bigAnswer = JSON.stringify({ text: THOUGHT });
headerDelayMs = 300;
const c = client();
// The entry's own reasoning setting at "Off": the server answers in
// `content`, which is what this block wants to read back byte for byte.
c.reasoningEffort = "none";
const params = singleUserTurn({
system: "s",
user: "u",
maxTokens: 50,
timeoutMs: 30_000,
});
const first = await c.generateCompletion(params);
const second = await c.generateCompletion(params);
expect(Buffer.byteLength(bigAnswer, "utf8")).toBeGreaterThan(90_000);
expect(first.content).toBe(bigAnswer);
expect(second.content).toBe(bigAnswer);
expect(seen).toHaveLength(2);
});
});
describe("Gemma 4 on Ollama: a reply with reasoning and no answer", () => {
it("answers the briefing when thinking spent the budget (finish_reason length)", async () => {
mode = "budget";
const outcome = await runChain(briefingParams("medium"));
expect(outcome.result.content).toBe(BRIEFING_JSON);
// The first ask reasons; the repeat asks the model not to.
expect(seen.map((s) => s.reasoningEffort)).toEqual(["medium", "none"]);
// The JSON mode stays on for the repeat.
expect(seen[1].responseFormat).toEqual({ type: "json_object" });
expect(linkFailedMeta()).toEqual([]);
});
it("answers the briefing when the answer landed in `reasoning` (finish_reason stop)", async () => {
mode = "misrouted";
const outcome = await runChain(briefingParams("low"));
expect(outcome.result.content).toBe(BRIEFING_JSON);
expect(seen.map((s) => s.reasoningEffort)).toEqual(["low", "none"]);
});
it("remembers the model answers only without reasoning, so the next call asks once", async () => {
mode = "budget";
await runChain(briefingParams("medium"));
seen = [];
const again = await runChain(briefingParams("medium"));
expect(again.result.content).toBe(BRIEFING_JSON);
expect(seen.map((s) => s.reasoningEffort)).toEqual(["none"]);
});
it("does the same on the streaming path the Coach uses", async () => {
mode = "budget";
const deltas: string[] = [];
const outcome = await runStreamingRawCompletionWithFallback({
userId: "user-1",
surface: "coach",
providers: [{ providerType: "local", instance: client() }],
params: briefingParams("medium"),
onDelta: (d: string) => deltas.push(d),
});
expect(outcome.result.content).toBe(BRIEFING_JSON);
expect(deltas.join("")).toBe(BRIEFING_JSON);
expect(seen.map((s) => [s.stream, s.reasoningEffort])).toEqual([
[true, "medium"],
[true, "none"],
]);
});
it("names an empty answer as one, not as a network failure, when the repeat is empty too", async () => {
mode = "always-empty";
const err = await runChain(briefingParams("medium")).catch((e) => e);
expect(err).toBeInstanceOf(AllProvidersFailedError);
const [meta] = linkFailedMeta();
expect(meta).toMatchObject({
provider: "local",
status: 0,
cause: "empty_response",
failure: "reasoning_only",
code: null,
surface: "job",
});
expect((err as AllProvidersFailedError).attempts[0].failureKind).toBe(
"reasoning_only",
);
});
it("does the same through the OpenAI-compatible gateway provider", async () => {
mode = "misrouted";
const gateway = new OpenAIClient({
apiKey: "",
model: "gemma4:12b",
baseUrl: `${origin}/v1`,
providerType: "openai-compatible",
});
const outcome = await runRawCompletionWithFallback({
userId: "user-1",
surface: "job",
providers: [{ providerType: "openai-compatible", instance: gateway }],
params: briefingParams("medium"),
});
expect(outcome.result.content).toBe(BRIEFING_JSON);
expect(seen.map((s) => s.reasoningEffort)).toEqual(["medium", "none"]);
});
});
+9 -1
View File
@@ -78,14 +78,22 @@ export const CLIENT_ABORT_MARGIN_MS = 30_000;
* account payload published. Never recomputes the setting: an absent or
* malformed value (a payload from before the field existed) reads as the
* server's own default.
*
* `surfaceDefaultMs` is the route's own per-call default when it is longer
* than the provider default (the briefing's 180 s). The published value
* cannot say whether the person set it or it is the 60 s default, so the
* browser waits for the longer of the two: it may wait longer than the
* server needs, never shorter (#1267).
*/
export function clientAbortMs(
responseTimeoutMs: number | null | undefined,
modelCalls: number,
surfaceDefaultMs?: number,
): number {
const perCall =
const published =
typeof responseTimeoutMs === "number" && responseTimeoutMs > 0
? responseTimeoutMs
: PROVIDER_DEFAULT_TIMEOUT_MS;
const perCall = Math.max(published, surfaceDefaultMs ?? 0);
return perCall * modelCalls + CLIENT_ABORT_MARGIN_MS;
}
+166
View File
@@ -0,0 +1,166 @@
import { SafeFetchError } from "@/lib/safe-fetch";
/**
* What one failed provider call actually ran into, in our own closed
* vocabulary. The chain used to collapse every failure without an HTTP status
* into "network", so a refused connection, our own deadline, a socket the
* server closed and a 200 that carried no answer all read the same in the log
* and in the briefing's empty state (#1267).
*
* Read from the thrown value only: the `SafeFetchError` kind, the client's own
* `kind` tag, and the Node / undici error `code` somewhere down the `cause`
* chain. Never the message, which can carry a URL.
*/
export type ProviderFailureKind =
/** Our own deadline (the response-timeout setting or the surface's). */
| "timeout"
/** The caller cancelled (the Coach tab closed, a job shut down). */
| "aborted"
/** The destination policy refused the host or its resolved address. */
| "private_host"
| "dns"
| "refused"
| "unreachable"
| "connect_timeout"
/** The connection was reset or closed under a request in flight. */
| "reset"
| "socket"
| "headers_timeout"
| "body_timeout"
| "tls"
/** A 2xx body that was not the JSON it claimed to be. */
| "parse"
/** A 2xx reply with no answer and no reasoning. */
| "empty_response"
/** A 2xx reply that reasoned and never answered. */
| "reasoning_only"
/** A stream that broke before its first token. */
| "stream_aborted"
/** A 2xx body that carried the gateway's own failure. */
| "embedded_error"
/** A non-2xx HTTP status. */
| "http"
/** A transport failure with no code this list knows. */
| "network"
| "unknown";
export interface ProviderFailure {
failure: ProviderFailureKind;
/**
* The Node / undici error code that decided `failure`, when there is one
* (`ECONNRESET`, `UND_ERR_SOCKET`). Only strings shaped like a code pass, so
* nothing free-form reaches a log line.
*/
code: string | null;
}
const CODE_SHAPE = /^[A-Z][A-Z0-9_]{1,47}$/;
const CODE_KINDS: ReadonlyArray<[RegExp, ProviderFailureKind]> = [
[/^(ENOTFOUND|EAI_AGAIN|EAI_NONAME|EAI_FAIL)$/, "dns"],
[/^ECONNREFUSED$/, "refused"],
[/^(EHOSTUNREACH|ENETUNREACH|EHOSTDOWN|ENETDOWN)$/, "unreachable"],
[/^(UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT)$/, "connect_timeout"],
[/^(ECONNRESET|EPIPE|ECONNABORTED)$/, "reset"],
[/^(UND_ERR_SOCKET|UND_ERR_CLOSED)$/, "socket"],
[/^UND_ERR_HEADERS_TIMEOUT$/, "headers_timeout"],
[/^UND_ERR_BODY_TIMEOUT$/, "body_timeout"],
[
/^(ERR_TLS_|ERR_SSL_|CERT_|DEPTH_ZERO_|SELF_SIGNED_|UNABLE_TO_|ERR_OSSL_)/,
"tls",
],
];
const CLIENT_KINDS: ReadonlySet<ProviderFailureKind> = new Set([
"empty_response",
"reasoning_only",
"stream_aborted",
"embedded_error",
]);
/** Every value down the `cause` chain (and an aggregate's `errors`). */
function causeChain(error: unknown): unknown[] {
const out: unknown[] = [];
const queue: unknown[] = [error];
while (queue.length > 0 && out.length < 16) {
const next = queue.shift();
if (next === null || typeof next !== "object" || out.includes(next)) {
continue;
}
out.push(next);
const value = next as { cause?: unknown; errors?: unknown };
if (value.cause !== undefined) queue.push(value.cause);
if (Array.isArray(value.errors)) queue.push(...value.errors);
}
return out;
}
function firstCode(chain: unknown[]): string | null {
for (const value of chain) {
const code = (value as { code?: unknown }).code;
if (typeof code === "string" && CODE_SHAPE.test(code)) return code;
}
return null;
}
function isNamed(value: unknown, name: string): boolean {
return (
value !== null &&
typeof value === "object" &&
(value as { name?: unknown }).name === name
);
}
/** Classify one thrown provider failure. Never throws. */
export function providerFailureKind(error: unknown): ProviderFailure {
try {
const chain = causeChain(error);
const code = firstCode(chain);
const head = (error ?? {}) as { kind?: unknown; httpStatus?: unknown };
if (typeof head.kind === "string" && CLIENT_KINDS.has(head.kind as never)) {
return { failure: head.kind as ProviderFailureKind, code };
}
if (typeof head.httpStatus === "number" && head.httpStatus > 0) {
return { failure: "http", code: null };
}
if (error instanceof SafeFetchError && error.kind === "private_host") {
return { failure: "private_host", code: null };
}
// Our deadline aborts with `TimeoutError`, a caller with `AbortError`;
// `safeFetch` wraps both, a body read after the headers does not.
if (chain.some((v) => isNamed(v, "TimeoutError"))) {
return { failure: "timeout", code: null };
}
if (chain.some((v) => isNamed(v, "AbortError"))) {
return { failure: "aborted", code: null };
}
if (code) {
for (const [pattern, kind] of CODE_KINDS) {
if (pattern.test(code)) return { failure: kind, code };
}
}
if (chain.some((v) => v instanceof SyntaxError)) {
return { failure: "parse", code };
}
if (error instanceof SafeFetchError || code !== null) {
return { failure: "network", code };
}
if (chain.some((v) => v instanceof TypeError)) {
return { failure: "network", code };
}
return { failure: "unknown", code };
} catch {
return { failure: "unknown", code: null };
}
}
/** True for the kinds that mean a deadline ran out. */
export function isTimeoutFailure(kind: ProviderFailureKind | null): boolean {
return (
kind === "timeout" ||
kind === "connect_timeout" ||
kind === "headers_timeout" ||
kind === "body_timeout"
);
}
+127 -33
View File
@@ -25,7 +25,9 @@ import {
type JsonModeDialect,
} from "./json-dialect";
import type { ReasoningEffort } from "./reasoning-effort";
import { emitSignal } from "@/lib/logging/signal";
import {
ANSWERS_ONLY_UNREASONED,
annotateReasoningDowngrade,
isReasoningParameterRejection,
learnedReasoningDialect,
@@ -150,6 +152,25 @@ class ReasoningCollector {
}
}
/**
* The empty-reply error. `httpStatus: 0` is the sentinel the chain classifier
* treats as a hard failure; `kind` says whether the reply held reasoning and
* no answer, or nothing at all, so the log names it instead of "network".
*/
function emptyReplyError(hadReasoning: boolean): Error {
const err = new Error(
hadReasoning
? "Local AI returned reasoning but no answer"
: "Local AI returned empty content",
);
Object.assign(err, {
httpStatus: 0,
kind: hadReasoning ? "reasoning_only" : "empty_response",
upstream: "local",
});
return err;
}
/** The reasoning text a non-streamed message carries, if any. */
function messageReasoning(message: unknown): string | null {
const m = (message ?? {}) as Record<string, unknown>;
@@ -230,10 +251,14 @@ export class LocalOpenAICompatibleClient implements AIProvider {
);
const callEffort = this.callReasoningEffort(params);
const sentEffort = this.sentReasoningEffort(params);
// Reasoning tokens count against `max_tokens` on these servers; the
// thinking budget rides on top of the answer budget (a ceiling only).
const thinkingTokens =
params.reasoning && callEffort && params.reasoning.effort !== "off"
params.reasoning &&
sentEffort &&
sentEffort !== "none" &&
params.reasoning.effort !== "off"
? REASONING_THINKING_BUDGET[params.reasoning.effort]
: 0;
const body = JSON.stringify({
@@ -249,7 +274,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// body of a setup that never touched it stays byte-identical; "Off" is
// `none`, which stops a thinking model spending the answer budget.
// v1.41 — a call that carries its own resolved level wins over it.
...(!params.reasoning && this.reasoningEffort
// #1267 — once a model answered nothing while reasoning, the entry's
// setting gives way to the `none` that `callReasoningEffort` sends.
...(!params.reasoning && this.reasoningEffort && callEffort === null
? { reasoning_effort: this.reasoningEffort }
: {}),
...(callEffort ? { reasoning_effort: callEffort } : {}),
@@ -288,13 +315,66 @@ export class LocalOpenAICompatibleClient implements AIProvider {
* call without one: the entry's #1126 setting applies, exactly as before.
*/
private callReasoningEffort(params: CompletionParams): string | null {
const learned = learnedReasoningDialect(this.reasoningDialectKey());
// #1267 — this endpoint and model answered a reasoning request with
// nothing but reasoning: every call asks with `none` until the lesson
// expires, a call without a level of its own included.
if (learned === ANSWERS_ONLY_UNREASONED) return "none";
if (!params.reasoning) return null;
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
return null;
}
if (learned === "none") return null;
return params.reasoning.effort === "off" ? "none" : params.reasoning.effort;
}
/**
* The `reasoning_effort` this request carries, whichever level decided it:
* the call's own (`callReasoningEffort`), else the entry's #1126 setting.
*/
private sentReasoningEffort(params: CompletionParams): string | null {
return (
this.callReasoningEffort(params) ??
(params.reasoning ? null : this.reasoningEffort)
);
}
/**
* #1267 — a reply with reasoning and no answer. Ollama serves thinking
* models (Gemma 4 among them) on `/v1/chat/completions` with the thought in
* a separate `reasoning` field, and a long thought can leave `content`
* empty: the thought used the whole token budget, or the answer itself was
* parsed into `reasoning`. The same model asked with `reasoning_effort:
* "none"` answers in `content`. So when a request that did not already say
* `none` comes back empty with reasoning (or after asking to reason), learn
* that this endpoint and model answer only unreasoned, and tell the caller
* to ask once more. Returns false when the empty reply is final.
*/
private learnAnswerlessReasoning(
params: CompletionParams,
hadReasoning: boolean,
finishReason: string | undefined,
): boolean {
const sent = this.sentReasoningEffort(params);
if (sent === "none") return false;
if (!hadReasoning && sent === null) return false;
rememberReasoningDialect(
this.reasoningDialectKey(),
ANSWERS_ONLY_UNREASONED,
);
annotateReasoningDowngrade("local", sent ?? "default", "none");
emitSignal({
action: "ai.local.reasoning_answerless",
level: "warn",
meta: {
effort: sent ?? "default",
finish:
finishReason === "length" || finishReason === "stop"
? finishReason
: "other",
reasoning: hadReasoning,
},
});
return true;
}
async generateCompletion(
params: CompletionParams,
): Promise<CompletionResult> {
@@ -442,21 +522,25 @@ export class LocalOpenAICompatibleClient implements AIProvider {
content = split.content;
}
}
const reasoning = collector.result(
json.usage?.completion_tokens_details?.reasoning_tokens ?? null,
);
const finish = json.choices?.[0]?.finish_reason;
if (!content) {
const hadReasoning =
messageReasoning(json.choices?.[0]?.message) !== null;
if (this.learnAnswerlessReasoning(params, hadReasoning, finish)) {
return this.generateCompletion(params);
}
// The reasoning that did arrive still reaches `onReasoning`, as before.
collector.result(
json.usage?.completion_tokens_details?.reasoning_tokens ?? null,
);
// 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;
throw emptyReplyError(hadReasoning);
}
const reasoning = collector.result(
json.usage?.completion_tokens_details?.reasoning_tokens ?? null,
);
return {
content,
@@ -470,7 +554,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
providerType: "local",
// v1.28.28 (#470) — surface why the model stopped so the JSON callers
// can tell a token-ceiling truncation ("length") from bad output.
finishReason: mapFinishReason(json.choices?.[0]?.finish_reason),
finishReason: mapFinishReason(finish),
...(reasoning ? { reasoning } : {}),
};
}
@@ -573,16 +657,22 @@ export class LocalOpenAICompatibleClient implements AIProvider {
buffered = split.content;
}
}
const reasoning = collector.result(null);
if (!buffered) {
const err = new Error("Local AI returned empty content");
Object.assign(err, {
httpStatus: 0,
kind: "empty_response",
upstream: "local",
});
throw err;
const hadReasoning =
messageReasoning(json?.choices?.[0]?.message) !== null;
if (
this.learnAnswerlessReasoning(
params,
hadReasoning,
json?.choices?.[0]?.finish_reason,
)
) {
return this.generateCompletionStream(params, onDelta);
}
collector.result(null);
throw emptyReplyError(hadReasoning);
}
const reasoning = collector.result(null);
onDelta(buffered);
return {
content: buffered,
@@ -654,6 +744,8 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// carries. Remember the first one; it only matters if the stream ends
// without a single token, in which case it is the honest reason.
let streamedError: unknown = null;
// #1267 — whether any reasoning arrived, asked for or not.
let streamedReasoning = false;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const armIdle = () => {
@@ -707,6 +799,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
const choiceDelta = chunk.choices?.[0]?.delta;
const reasoningDelta =
choiceDelta?.reasoning_content ?? choiceDelta?.reasoning;
if (typeof reasoningDelta === "string" && reasoningDelta.trim()) {
streamedReasoning = true;
}
if (params.reasoning && typeof reasoningDelta === "string") {
collector.add(reasoningDelta);
}
@@ -754,8 +849,6 @@ export class LocalOpenAICompatibleClient implements AIProvider {
collector.add(pending);
}
pending = "";
const reasoning = collector.result(reasoningTokens);
if (!content) {
// A stream that ended with a failure frame and no token reports the
// failure, not "empty content".
@@ -764,14 +857,15 @@ export class LocalOpenAICompatibleClient implements AIProvider {
label: "local AI endpoint",
model: this.config.model,
});
const err = new Error("Local AI returned empty content");
Object.assign(err, {
httpStatus: 0,
kind: "empty_response",
upstream: "local",
});
throw err;
// An unclosed inline `<think>` block is reasoning too.
const hadReasoning = streamedReasoning || endState === "inside";
if (this.learnAnswerlessReasoning(params, hadReasoning, finishReason)) {
return this.generateCompletionStream(params, onDelta);
}
collector.result(reasoningTokens);
throw emptyReplyError(hadReasoning);
}
const reasoning = collector.result(reasoningTokens);
return {
content,
+56 -5
View File
@@ -9,6 +9,7 @@ import {
type OpenAIChatCompletionsCapabilities,
} from "./openai-capabilities";
import {
ANSWERS_ONLY_UNREASONED,
annotateReasoningDowngrade,
isReasoningParameterRejection,
learnedReasoningDialect,
@@ -193,6 +194,10 @@ export class OpenAIClient implements AIProvider {
this.config.model,
);
const reasoningWire = this.planReasoning(params, hasTools, capabilities);
// #1126 — the gateway entry's own setting, for a call without a resolved
// level (sent below unless the wire already carries `none`).
const entryEffort =
!params.reasoning && this.isGateway ? this.reasoningEffort : null;
// Reasoning tokens count against the completion budget on every one of
// these wires, so the thinking budget rides on top of the answer budget
// (a ceiling, not a charge). Without it a 600-token Coach round spends
@@ -248,7 +253,12 @@ export class OpenAIClient implements AIProvider {
// #1126 — the gateway entry's reasoning setting; Default sends
// nothing. `api.openai.com` and Codex never get it. v1.41 — a call
// that carries its own resolved level (`params.reasoning`) wins.
...(!params.reasoning && this.isGateway && this.reasoningEffort
// #1267 — once a model answered nothing while reasoning, the wire
// below carries `none` instead (`planReasoning`).
...(!params.reasoning &&
this.isGateway &&
this.reasoningEffort &&
reasoningWire.kind === "none"
? { reasoning_effort: this.reasoningEffort }
: {}),
...(reasoningWire.kind === "reasoning_effort"
@@ -423,6 +433,41 @@ 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) {
// #1267 — a gateway in front of a thinking model (Ollama serving
// Gemma 4, say) can answer with the thought in `reasoning` and no
// content. Asked with `none`, the same model answers; learn that once
// and ask again. Same rule as the Local client.
const sentEffort =
reasoningWire.kind !== "none" ? reasoningWire.effort : entryEffort;
const message = (choice?.message ?? {}) as Record<string, unknown>;
const hadReasoning = [message.reasoning, message.reasoning_content].some(
(v) => typeof v === "string" && v.trim().length > 0,
);
if (
this.isGateway &&
sentEffort !== "none" &&
(hadReasoning || sentEffort !== null)
) {
rememberReasoningDialect(
this.reasoningDialectKey(),
ANSWERS_ONLY_UNREASONED,
);
annotateReasoningDowngrade(this.type, sentEffort ?? "default", "none");
emitSignal({
action: "ai.gateway.reasoning_answerless",
level: "warn",
meta: {
effort: sentEffort ?? "default",
finish:
choice?.finish_reason === "length" ||
choice?.finish_reason === "stop"
? choice.finish_reason
: "other",
reasoning: hadReasoning,
},
});
return this.generateCompletion(params);
}
// 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
@@ -434,7 +479,7 @@ export class OpenAIClient implements AIProvider {
);
Object.assign(err, {
httpStatus: 0,
kind: "empty_response",
kind: hadReasoning ? "reasoning_only" : "empty_response",
upstream: this.isGateway ? "openai-compatible" : "openai",
});
throw err;
@@ -529,10 +574,16 @@ export class OpenAIClient implements AIProvider {
capabilities: OpenAIChatCompletionsCapabilities,
): OpenAIReasoningWire {
const reasoning = params.reasoning;
if (!reasoning) return { kind: "none" };
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
return { kind: "none" };
const learned = learnedReasoningDialect(this.reasoningDialectKey());
// #1267 — a gateway model that answered a reasoning request with
// reasoning alone is asked with `none`, with or without a call level.
if (this.isGateway && learned === ANSWERS_ONLY_UNREASONED) {
return isOpenRouterEndpoint(this.config.baseUrl)
? { kind: "openrouter", effort: "none" }
: { kind: "reasoning_effort", effort: "none" };
}
if (!reasoning) return { kind: "none" };
if (learned === "none") return { kind: "none" };
const level = reasoning.effort;
if (this.isGateway) {
const effort = level === "off" ? "none" : level;
+35 -3
View File
@@ -7,6 +7,12 @@ import {
} from "./provider-health-ledger";
import { annotate } from "@/lib/logging/context";
import { emitSignal } from "@/lib/logging/signal";
import {
isTimeoutFailure,
providerFailureKind,
type ProviderFailure,
type ProviderFailureKind,
} from "./failure-kind";
import {
isOperatorFundedProvider,
readDailySpend,
@@ -174,6 +180,11 @@ export interface FallbackHop {
attempt: number;
failureReason: string;
httpStatus: number | null;
/**
* #1267 — what the call ran into (`failure-kind.ts`). Absent on a hop that
* never called the provider (the operator cost cap).
*/
failureKind?: ProviderFailureKind;
}
/**
@@ -404,6 +415,7 @@ function hopCause(
error: unknown,
status: number | null,
bodyClass: string | null,
failure: ProviderFailureKind,
): string {
const err = error as {
credentialExpired?: unknown;
@@ -414,14 +426,22 @@ function hopCause(
if (bodyClass?.startsWith("classified:")) {
return bodyClass.slice("classified:".length);
}
if (status === null || status <= 0) return "network";
if (status === null || status <= 0) {
// #1267 — a 200 without an answer is not a transport failure, and only a
// deadline is a timeout. `failure` on the same line says which one.
if (failure === "empty_response" || failure === "reasoning_only") {
return "empty_response";
}
if (isTimeoutFailure(failure)) return "timeout";
return "network";
}
if (status === 401 || status === 403) return "auth_rejected";
if (status === 429) return "rate_limited";
if (status >= 500) return "upstream_5xx";
return "upstream_4xx";
}
interface HopSignal {
interface HopSignal extends ProviderFailure {
provider: ProviderChainType;
attempt: number;
status: number | null;
@@ -450,6 +470,9 @@ function signalFailedHops(
attempt: hop.attempt,
status: hop.status,
cause: hop.cause,
// #1267 — closed vocabulary plus a code-shaped string, never a message.
failure: hop.failure,
code: hop.code,
recovered,
surface,
},
@@ -584,11 +607,18 @@ async function runRawChain(
throw error;
}
const summary = summariseError(error);
const failure = providerFailureKind(error);
failedHops.push({
provider: candidate.providerType,
attempt: i + 1,
status: summary.status,
cause: hopCause(error, summary.status, summary.bodyExcerpt),
cause: hopCause(
error,
summary.status,
summary.bodyExcerpt,
failure.failure,
),
...failure,
});
void ledger.recordFailure(userId, candidate.providerType, summary.status);
const hop: FallbackHop = {
@@ -596,6 +626,7 @@ async function runRawChain(
attempt: i + 1,
failureReason: summary.reason,
httpStatus: summary.status,
failureKind: failure.failure,
};
hops.push(hop);
annotate({
@@ -604,6 +635,7 @@ async function runRawChain(
[`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,
[`ai_chain_hop_${i + 1}_failure`]: failure.failure,
},
});
}
+9
View File
@@ -38,6 +38,15 @@ export interface ReasoningDialectKey {
model: string;
}
/**
* #1267 — the learned value for an endpoint and model that answered a
* reasoning request with reasoning alone (Ollama's Gemma 4 on
* `/v1/chat/completions`): ask them with `reasoning_effort: "none"`. Shared by
* the Local and the OpenAI-compatible gateway clients. Distinct from
* `"none"`, which both clients read as "send no reasoning field at all".
*/
export const ANSWERS_ONLY_UNREASONED = "off";
/** How long a learned downgrade holds before the full request is tried again. */
export const DIALECT_TTL_MS = 60 * 60 * 1000;
+58
View File
@@ -186,3 +186,61 @@ export function getBodyFatTargetRange(gender: string | null | undefined): {
// Gender-neutral fallback: midpoint of male/female fitness+acceptable.
return { min: 17, max: 27 };
}
/**
* Chart bands for oxygen saturation (%).
*
* Without a personal target the chart keeps its clinical shading: below 90 %
* the hypoxia floor, 90 to 95 % the watch zone, 95 to 100 % normal. With one
* (a COPD or high-altitude user living at 88 to 92 %, say) the green band is
* the person's own and the wings come from the resolved range, so the zone
* agrees with the `/targets` card. Saturation cannot exceed 100 %, so nothing
* is shaded above it; the floor stays at 80 % unless the person's own wing
* reaches lower.
*/
export function buildOxygenSaturationBands(
ownRange: TrafficRange | null,
): ValueBand[] {
if (!ownRange) {
return [
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
];
}
const floor = Math.min(80, ownRange.orangeMin);
const ceiling = 100;
const bands: ValueBand[] = [
{
min: floor,
max: ownRange.orangeMin,
color: "var(--destructive)",
opacity: 0.18,
},
{
min: ownRange.orangeMin,
max: ownRange.greenMin,
color: "var(--warning)",
opacity: 0.18,
},
{
min: ownRange.greenMin,
max: Math.min(ownRange.greenMax, ceiling),
color: "var(--success)",
opacity: 0.18,
},
{
min: Math.min(ownRange.greenMax, ceiling),
max: Math.min(ownRange.orangeMax, ceiling),
color: "var(--warning)",
opacity: 0.18,
},
{
min: Math.min(ownRange.orangeMax, ceiling),
max: ceiling,
color: "var(--destructive)",
opacity: 0.18,
},
];
return bands.filter((band) => band.max > band.min);
}
@@ -1269,6 +1269,30 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
expect(snap.targetBands.weightRange?.greenMax).toBe(78);
});
it("an own blood-pressure, pulse and body-fat target reach the dashboard bands", async () => {
const snap = await buildDashboardSnapshot(
fakePrisma,
baseUser({
thresholdsJson: {
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
PULSE: { min: 48, max: 58 },
BODY_FAT: { min: 12, max: 16 },
},
}),
{ modules: () => Promise.resolve(moduleMap()) },
);
expect(snap.targetBands.bpTargets).toEqual({
sysLow: 115,
sysHigh: 125,
diaLow: 70,
diaHigh: 78,
});
expect(snap.targetBands.pulseDisplayRange.greenMin).toBe(48);
expect(snap.targetBands.pulseDisplayRange.greenMax).toBe(58);
expect(snap.targetBands.bodyFatRange).toEqual({ min: 12, max: 16 });
});
it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
modules: () => Promise.resolve(moduleMap({ sleep: false })),
+131 -13
View File
@@ -105,28 +105,28 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: Date | null;
gender: "MALE" | "FEMALE" | null;
heightCm: number | null;
weightTargetOverride: { min: number; max: number } | null;
thresholdsJson: unknown;
}> = [
{
name: "male 180cm with DOB",
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
weightTargetOverride: null,
thresholdsJson: null,
},
{
name: "female 165cm with DOB",
dateOfBirth: new Date("1970-01-01T00:00:00.000Z"),
gender: "FEMALE",
heightCm: 165,
weightTargetOverride: null,
thresholdsJson: null,
},
{
name: "no-profile user (null DOB / gender / height)",
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
thresholdsJson: null,
},
];
@@ -146,20 +146,20 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "OTHER",
heightCm: 180,
weightTargetOverride: null,
thresholdsJson: null,
});
const unanswered = buildTargetBands({
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: null,
heightCm: 180,
weightTargetOverride: null,
thresholdsJson: null,
});
expect(other).toEqual(unanswered);
const male = buildTargetBands({
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
weightTargetOverride: null,
thresholdsJson: null,
});
expect(other.bodyFatBands).not.toEqual(male.bodyFatBands);
});
@@ -169,7 +169,7 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
thresholdsJson: null,
});
expect(bands.bpTargets).toBeNull();
expect(bands.bpSysRange).toBeNull();
@@ -198,14 +198,14 @@ describe("buildTargetBands — user weight target", () => {
it("replaces the height-derived band with the user's own", () => {
const withTarget = buildTargetBands({
...profile,
weightTargetOverride: { min: 74, max: 78 },
thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
});
expect(withTarget.weightRange?.greenMin).toBe(74);
expect(withTarget.weightRange?.greenMax).toBe(78);
const heightDerived = buildTargetBands({
...profile,
weightTargetOverride: null,
thresholdsJson: null,
});
expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange);
// The chart bands follow the same range, so tile and chart agree.
@@ -221,7 +221,7 @@ describe("buildTargetBands — user weight target", () => {
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: { min: 60, max: 65 },
thresholdsJson: { WEIGHT: { min: 60, max: 65 } },
});
expect(bands.weightRange?.greenMin).toBe(60);
expect(bands.weightBands?.length).toBeGreaterThan(0);
@@ -230,14 +230,132 @@ describe("buildTargetBands — user weight target", () => {
it("leaves every non-weight band untouched", () => {
const withTarget = buildTargetBands({
...profile,
weightTargetOverride: { min: 74, max: 78 },
thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
});
const without = buildTargetBands({
...profile,
weightTargetOverride: null,
thresholdsJson: null,
});
expect(withTarget.bpTargets).toEqual(without.bpTargets);
expect(withTarget.pulseBands).toEqual(without.pulseBands);
expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands);
});
});
/**
* The other metrics the dashboard shades take the person's own target the
* same way weight does. A blood-pressure, resting-pulse or body-fat target set
* on `/targets` used to leave the chart on the age / sex default, so the band
* under the line contradicted the band the person had just entered.
*/
describe("buildTargetBands — the user's own targets on every shaded metric", () => {
const profile = {
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE" as const,
heightCm: 180,
};
const without = buildTargetBands({ ...profile, thresholdsJson: null });
const greenOf = (bands: Array<{ min: number; max: number; color: string }>) =>
bands.find((band) => band.color === "var(--success)");
it("draws the blood-pressure zones and tile ranges from the own band", () => {
const bands = buildTargetBands({
...profile,
thresholdsJson: {
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
},
});
expect(bands.bpTargets).toEqual({
sysLow: 115,
sysHigh: 125,
diaLow: 70,
diaHigh: 78,
});
expect(bands.bpTargets).not.toEqual(without.bpTargets);
expect(bands.bpSysRange).toEqual(
expect.objectContaining({ greenMin: 115, greenMax: 125 }),
);
expect(bands.bpDiaRange).toEqual(
expect.objectContaining({ greenMin: 70, greenMax: 78 }),
);
});
it("fills a one-sided blood-pressure target with the age default", () => {
const bands = buildTargetBands({
...profile,
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 110, max: 128 } },
});
expect(bands.bpTargets?.sysLow).toBe(110);
expect(bands.bpTargets?.sysHigh).toBe(128);
expect(bands.bpTargets?.diaLow).toBe(without.bpTargets?.diaLow);
expect(bands.bpTargets?.diaHigh).toBe(without.bpTargets?.diaHigh);
expect(bands.bpDiaRange).toEqual(without.bpDiaRange);
});
it("draws a blood-pressure band from a target even without a date of birth", () => {
const bands = buildTargetBands({
dateOfBirth: null,
gender: null,
heightCm: null,
thresholdsJson: {
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
},
});
expect(bands.bpTargets).toEqual({
sysLow: 115,
sysHigh: 125,
diaLow: 70,
diaHigh: 78,
});
});
it("draws the resting-pulse bands and display range from the own band", () => {
const bands = buildTargetBands({
...profile,
thresholdsJson: { PULSE: { min: 48, max: 58 } },
});
expect(bands.pulseDisplayRange).toEqual(
expect.objectContaining({ greenMin: 48, greenMax: 58 }),
);
expect(greenOf(bands.pulseBands)).toEqual(
expect.objectContaining({ min: 48, max: 58 }),
);
expect(bands.pulseBands).not.toEqual(without.pulseBands);
});
it("draws the body-fat bands and range from the own band", () => {
const bands = buildTargetBands({
...profile,
thresholdsJson: { BODY_FAT: { min: 12, max: 16 } },
});
expect(bands.bodyFatRange).toEqual({ min: 12, max: 16 });
expect(greenOf(bands.bodyFatBands)).toEqual(
expect.objectContaining({ min: 12, max: 16 }),
);
expect(bands.bodyFatBands).not.toEqual(without.bodyFatBands);
});
it("ignores a malformed target and keeps the default band", () => {
const bands = buildTargetBands({
...profile,
thresholdsJson: {
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
PULSE: { min: "fast", max: 60 },
BODY_FAT: null,
},
});
expect(bands).toEqual(without);
});
it("leaves the other metrics' bands alone", () => {
const bands = buildTargetBands({
...profile,
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
});
expect(bands.pulseBands).toEqual(without.pulseBands);
expect(bands.bodyFatBands).toEqual(without.bodyFatBands);
expect(bands.weightBands).toEqual(without.weightBands);
});
});
@@ -30,7 +30,7 @@ const DELEGATE_PROFILE = {
dateOfBirth: new Date("1996-04-02T00:00:00Z"),
gender: "FEMALE" as const,
heightCm: 165,
weightTargetOverride: { min: 55, max: 62 },
thresholdsJson: { WEIGHT: { min: 55, max: 62 } },
};
describe("viewerBandProfile", () => {
@@ -45,7 +45,7 @@ describe("viewerBandProfile", () => {
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
thresholdsJson: null,
});
});
});
+86 -93
View File
@@ -15,7 +15,7 @@
* so the band/target math lives in exactly one place. `buildTargetBands`
* is kept as an alias for the snapshot-era import sites.
*/
import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets";
import type { BpTargets } from "@/lib/analytics/bp-targets";
import {
buildBandsFromTrafficRange,
buildTrafficLightBands,
@@ -30,16 +30,27 @@ import {
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
import { getEffectiveRange } from "@/lib/analytics/effective-range";
import {
getEffectiveRange,
resolveEffectiveBpTargets,
resolveThresholdOverride,
type ThresholdMetric,
} from "@/lib/analytics/effective-range";
export interface DashboardTargetBands {
/** Personalised BP target numbers (null when no DOB). */
/**
* BP target numbers: the user's own band where they set one, else the age
* default. Null when neither exists (no target and no DOB).
*/
bpTargets: BpTargets | null;
/** Systolic traffic range (null when no DOB). */
/** Systolic traffic range, same precedence (null without target or DOB). */
bpSysRange: TrafficRange | null;
/** Diastolic traffic range (null when no DOB). */
/** Diastolic traffic range, same precedence (null without target or DOB). */
bpDiaRange: TrafficRange | null;
/** Resting-pulse display range (always present — AHA fallback). */
/**
* Resting-pulse display range: the user's own target, else the
* age / sex percentile band (AHA fallback). Always present.
*/
pulseDisplayRange: {
greenMin: number;
greenMax: number;
@@ -48,7 +59,7 @@ export interface DashboardTargetBands {
};
/** Resting-pulse chart bands (always present — AHA fallback). */
pulseBands: ValueBand[];
/** Body-fat target range (gender-aware; always present). */
/** Body-fat target range: own target, else gender-aware. Always present. */
bodyFatRange: { min: number; max: number };
/** Body-fat chart bands (always present). */
bodyFatBands: ValueBand[];
@@ -66,110 +77,92 @@ export interface DashboardTargetBands {
* DB read. The snapshot builder and the page fallback both call this so
* they produce byte-identical numbers.
*
* v1.34 — `weightTargetOverride` is the user's OWN weight band as stored in
* `User.thresholdsJson.WEIGHT`, in kg. When present it replaces the
* height-derived WHO band on both the weight range and the weight chart bands:
* a user who has answered "this is my target" must not keep reading a shaded
* zone that ignores the answer. `null` keeps the height-derived band. Required
* rather than optional so every call site is named by the compiler.
* `thresholdsJson` is the user's own targets blob as stored in
* `User.thresholdsJson` (`null` when there is none). Every metric the dashboard
* shades and that accepts a personal target reads it: weight, blood pressure,
* resting pulse and body fat. A target the person set replaces the computed
* default on both the tile's range and the chart's bands, so a shaded zone
* never ignores an answer given on `/targets`. Each override goes through
* `getEffectiveRange`, so its orange wings match the `/targets` page and the
* doctor report rather than a second convention. A metric without an override
* keeps exactly the band it drew before. Required rather than optional so
* every call site is named by the compiler.
*
* v1.34 introduced this for weight alone; the other three kept the age / sex
* default regardless of the person's own target until v1.44.1.
*/
export function buildDashboardBands(profile: {
dateOfBirth: Date | null;
gender: ProfileSex;
heightCm: number | null;
weightTargetOverride: { min: number; max: number } | null;
thresholdsJson: unknown;
}): DashboardTargetBands {
const bpTargets = profile.dateOfBirth
? getBpTargets(profile.dateOfBirth)
: null;
const rangeProfile = {
heightCm: profile.heightCm,
dateOfBirth: profile.dateOfBirth,
gender: profile.gender,
};
const ownTarget = (metric: ThresholdMetric) =>
resolveThresholdOverride(profile.thresholdsJson, metric);
const ownRange = (metric: ThresholdMetric): TrafficRange | null => {
const override = ownTarget(metric);
return override
? getEffectiveRange(metric, rangeProfile, { [metric]: override }).range
: null;
};
const bpTargets = resolveEffectiveBpTargets(
rangeProfile,
profile.thresholdsJson,
);
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
// The percentile and body-fat tables carry two sexes and no third row;
// both helpers answer a null with a neutral midpoint rather than a side.
const referenceSex = binaryReferenceSex(profile.gender);
const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex);
const bodyFatRange = getBodyFatTargetRange(referenceSex);
// v1.34 — an explicit target outranks the height-derived WHO band. Without
// this the dashboard kept shading a band the user had already answered, and
// the answer lived on `/targets` where nothing else could see it. The
// override is routed through `getEffectiveRange` rather than widened here,
// so the orange wings match the `/targets` page and the doctor report
// exactly instead of inventing a second convention. No height and no target
// → no band at all, unchanged.
const weightRange = profile.weightTargetOverride
? getEffectiveRange(
"WEIGHT",
{
heightCm: profile.heightCm,
dateOfBirth: profile.dateOfBirth,
gender: profile.gender,
},
{ WEIGHT: profile.weightTargetOverride },
).range
: profile.heightCm
? buildWeightRangeFromHeight(profile.heightCm)
: null;
const defaultPulse = getPersonalizedPulseTarget(pulseAge, referenceSex);
const pulseRange: TrafficRange = ownRange("PULSE") ?? {
greenMin: defaultPulse.greenMin,
greenMax: defaultPulse.greenMax,
orangeMin: defaultPulse.orangeMin,
orangeMax: defaultPulse.orangeMax,
};
const ownBodyFat = ownRange("BODY_FAT");
const bodyFatRange = ownBodyFat
? { min: ownBodyFat.greenMin, max: ownBodyFat.greenMax }
: getBodyFatTargetRange(referenceSex);
// No height and no target → no weight band at all, unchanged.
const weightRange =
ownRange("WEIGHT") ??
(profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null);
const weightBands = weightRange
? buildBandsFromTrafficRange(weightRange, {
lowerBound: 30,
upperBound: 250,
})
: null;
const bpSysRange = bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null;
const bpDiaRange = bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null;
const pulseBands = [
{
min: 30,
max: pulseTarget.orangeMin,
color: "var(--destructive)",
opacity: 0.16,
},
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "var(--warning)",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "var(--success)",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "var(--warning)",
opacity: 0.18,
},
{
min: pulseTarget.orangeMax,
max: 220,
color: "var(--destructive)",
opacity: 0.16,
},
].filter((band) => band.max > band.min);
const bodyFatBands = buildTrafficLightBands(
bodyFatRange.min,
bodyFatRange.max,
{
lowerBound: 2,
upperBound: 55,
},
);
// The tile colours a reading against the same band the chart shades: the
// person's own range with its effective wings, else the age default's.
const bpSysRange =
ownRange("BLOOD_PRESSURE_SYS") ??
(bpTargets ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) : null);
const bpDiaRange =
ownRange("BLOOD_PRESSURE_DIA") ??
(bpTargets ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null);
const pulseBands = buildBandsFromTrafficRange(pulseRange, {
lowerBound: 30,
upperBound: 220,
});
const bodyFatBands = ownBodyFat
? buildBandsFromTrafficRange(ownBodyFat, { lowerBound: 2, upperBound: 55 })
: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
lowerBound: 2,
upperBound: 55,
});
return {
bpTargets,
bpSysRange,
bpDiaRange,
pulseDisplayRange: {
greenMin: pulseTarget.greenMin,
greenMax: pulseTarget.greenMax,
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
},
pulseDisplayRange: pulseRange,
pulseBands,
bodyFatRange,
bodyFatBands,
@@ -187,7 +180,7 @@ export interface BandProfile {
dateOfBirth: Date | null;
gender: ProfileSex;
heightCm: number | null;
weightTargetOverride: { min: number; max: number } | null;
thresholdsJson: unknown;
}
/**
@@ -220,6 +213,6 @@ export function viewerBandProfile(
dateOfBirth: null,
gender: null,
heightCm: null,
weightTargetOverride: null,
thresholdsJson: null,
};
}
+5 -4
View File
@@ -1489,10 +1489,11 @@ export async function buildDashboardSnapshot(
dateOfBirth: user.dateOfBirth,
gender,
heightCm: user.heightCm ?? null,
// v1.34 — the user's own weight target, when they set one, replaces the
// height-derived band on the tile + the chart. Resolved off the same
// `thresholdsJson` blob the health score reads.
weightTargetOverride,
// The user's own targets, where set, replace the computed default on
// every tile and chart band (weight, blood pressure, resting pulse,
// body fat). Resolved off the same `thresholdsJson` blob the health
// score and the tile directions read.
thresholdsJson: user.thresholdsJson,
}),
tiles: {
summaries: slim.summaries,
@@ -148,6 +148,44 @@ describe("classifyBriefingFailure", () => {
).toBe("provider");
});
it("claims a timeout only for a deadline once the failure kind is known (#1267)", () => {
const at = (failureKind: string) =>
classifyBriefingFailure({
reason: "all-providers-failed",
httpStatus: 0,
failureKind,
});
expect(at("timeout")).toBe("timeout");
expect(at("headers_timeout")).toBe("timeout");
// A 200 that reasoned and never answered is the model's output.
expect(at("reasoning_only")).toBe("format");
expect(at("empty_response")).toBe("format");
// A refused or dropped connection is the provider.
expect(at("refused")).toBe("provider");
expect(at("socket")).toBe("provider");
});
it("reads the failure kind back from the marker (#1267)", async () => {
create.mockResolvedValue({});
await recordBriefingFailure({
userId: "u1",
reason: "all-providers-failed",
httpStatus: 0,
failureKind: "reasoning_only",
});
const details = create.mock.calls[0][0].data.details as string;
expect(JSON.parse(details).failureKind).toBe("reasoning_only");
findFirst.mockResolvedValue({
createdAt: new Date("2026-06-28T10:00:00Z"),
details,
});
const result = await readBriefingFailure({
userId: "u1",
since: new Date("2026-06-28T09:00:00Z"),
});
expect(result?.failureClass).toBe("format");
});
it("maps a missing / sentinel-zero status to timeout", () => {
expect(classifyBriefingFailure({ reason: "provider-error" })).toBe(
"timeout",
+41 -10
View File
@@ -1,4 +1,8 @@
import { prisma } from "@/lib/db";
import {
isTimeoutFailure,
type ProviderFailureKind,
} from "@/lib/ai/failure-kind";
/**
* Dated failure marker for the daily-briefing generation.
@@ -22,9 +26,9 @@ export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure";
/**
* Coarse class of a briefing-generation failure, used by the read path to
* point the empty-state hint at the right lever:
* - `timeout` — the generation ran out of time (no upstream HTTP status,
* or a transport/abort error). Lever: raise the AI response
* timeout for a slow local model.
* - `timeout` — the generation ran out of time (a deadline, or a marker
* from before the failure kind was recorded). Lever: raise
* the AI response timeout for a slow local model.
* - `auth` — the provider rejected the request (4xx that is not 429).
* Lever: re-check the provider / API key in Settings.
* - `rate-limit` — the provider returned 429.
@@ -39,14 +43,19 @@ export type BriefingFailureClass =
* Classify a failure from its recorded reason and (optional) upstream HTTP
* status. A reason of `invalid-json` is always a format miss; a 401/403 (or any
* other non-429 4xx) is an auth / configuration problem; 429 is a rate limit; a
* 5xx is a generic provider failure. With no status at all — the timeout /
* transport / abort path, which is the dominant briefing failure on a slow
* self-hosted backend — the most useful lever is the response-timeout, so it
* classifies as `timeout`.
* 5xx is a generic provider failure. With no status at all, the recorded
* failure kind decides (#1267): a deadline is `timeout`, a reply without an
* answer is `format`, any other transport failure is `provider`. A marker
* without a kind keeps the old `timeout` reading.
*/
export function classifyBriefingFailure(args: {
reason: string;
httpStatus?: number | null;
/**
* #1267 — what the first provider call ran into. A marker written before
* the field existed has none and keeps the old reading.
*/
failureKind?: string | null;
}): BriefingFailureClass {
if (args.reason === "invalid-json") return "format";
const status = args.httpStatus;
@@ -55,8 +64,22 @@ export function classifyBriefingFailure(args: {
if (status >= 400 && status < 500) return "auth";
if (status >= 500) return "provider";
}
// No / sentinel-zero upstream status: a transport-level timeout or abort.
return "timeout";
// No / sentinel-zero upstream status. Only a deadline is a timeout: a reply
// that came back without an answer is the model's output, and a refused or
// dropped connection is the provider, and neither is helped by a longer
// response timeout.
const kind = args.failureKind ?? null;
if (kind === null || isTimeoutFailure(kind as ProviderFailureKind)) {
return "timeout";
}
if (
kind === "empty_response" ||
kind === "reasoning_only" ||
kind === "parse"
) {
return "format";
}
return "provider";
}
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
@@ -79,6 +102,8 @@ export async function recordBriefingFailure(args: {
* timeout and point the hint at the right lever.
*/
httpStatus?: number | null;
/** #1267 — the first hop's `FallbackHop.failureKind`, when known. */
failureKind?: ProviderFailureKind | null;
}): Promise<void> {
try {
await prisma.auditLog.create({
@@ -89,6 +114,7 @@ export async function recordBriefingFailure(args: {
dateKey: dateKey(),
reason: args.reason,
httpStatus: args.httpStatus ?? null,
failureKind: args.failureKind ?? null,
locale: args.locale ?? null,
triedAt: new Date().toISOString(),
}),
@@ -128,14 +154,19 @@ export async function readBriefingFailure(args: {
let reason = "unknown";
let httpStatus: number | null = null;
let failureKind: string | null = null;
if (latest.details) {
try {
const parsed = JSON.parse(latest.details) as {
reason?: unknown;
httpStatus?: unknown;
failureKind?: unknown;
};
if (typeof parsed.reason === "string") reason = parsed.reason;
if (typeof parsed.httpStatus === "number") httpStatus = parsed.httpStatus;
if (typeof parsed.failureKind === "string") {
failureKind = parsed.failureKind;
}
} catch {
// Malformed marker payload — still report the failure, generic reason.
}
@@ -143,6 +174,6 @@ export async function readBriefingFailure(args: {
return {
triedAt: latest.createdAt.toISOString(),
reason,
failureClass: classifyBriefingFailure({ reason, httpStatus }),
failureClass: classifyBriefingFailure({ reason, httpStatus, failureKind }),
};
}
@@ -113,6 +113,7 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS, resolveInsightsMaxTokens } from "@/lib/ai/ai-budgets";
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
import { providerFailureKind } from "@/lib/ai/failure-kind";
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload";
import {
@@ -1286,6 +1287,10 @@ export async function generateComprehensiveInsight(
reason,
locale,
httpStatus: typeof err.httpStatus === "number" ? err.httpStatus : null,
failureKind:
e instanceof AllProvidersFailedError
? (e.attempts?.[0]?.failureKind ?? null)
: providerFailureKind(e).failure,
});
const credentialExpired =
e instanceof AllProvidersFailedError && e.primaryCredentialExpired;
+13 -9
View File
@@ -1186,8 +1186,8 @@ export const dashboardSnapshotResponse = z
"Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.",
),
// v1.18.6 — server-computed band / target math (audit finding #3),
// resolved from the user's own profile facts (DOB / gender / height /
// weight-target override). The dashboard reads these instead of
// resolved from the user's own profile facts (DOB / gender / height) and
// their own targets. The dashboard reads these instead of
// recomputing client-side; always present (every sub-field falls back
// to a neutral default rather than being omitted).
targetBands: z
@@ -1201,7 +1201,7 @@ export const dashboardSnapshotResponse = z
})
.nullable()
.describe(
"Personalised blood-pressure target numbers. Null when the profile has no date of birth.",
"Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the age-banded default. Null when there is neither a target nor a date of birth.",
),
bpSysRange: z
.object({
@@ -1212,7 +1212,7 @@ export const dashboardSnapshotResponse = z
})
.nullable()
.describe(
"Systolic traffic-light range. Null when the profile has no date of birth.",
"Systolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
),
bpDiaRange: z
.object({
@@ -1223,7 +1223,7 @@ export const dashboardSnapshotResponse = z
})
.nullable()
.describe(
"Diastolic traffic-light range. Null when the profile has no date of birth.",
"Diastolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
),
pulseDisplayRange: z
.object({
@@ -1233,7 +1233,7 @@ export const dashboardSnapshotResponse = z
orangeMax: z.number(),
})
.describe(
"Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.",
"Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA reference range with no date of birth). Always present.",
),
pulseBands: z
.array(
@@ -1246,11 +1246,13 @@ export const dashboardSnapshotResponse = z
}),
)
.describe(
"Resting-pulse chart bands. Always present — AHA fallback with no date of birth.",
"Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present.",
),
bodyFatRange: z
.object({ min: z.number(), max: z.number() })
.describe("Gender-aware body-fat target range. Always present."),
.describe(
"Body-fat target range: the user's own target where set, else the gender-aware default. Always present.",
),
bodyFatBands: z
.array(
z.object({
@@ -1261,7 +1263,9 @@ export const dashboardSnapshotResponse = z
strokeOpacity: z.number().optional(),
}),
)
.describe("Body-fat chart bands. Always present."),
.describe(
"Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present.",
),
weightRange: z
.object({
greenMin: z.number(),
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { selectOwnTargetRange } from "../use-own-target-range";
const range = { greenMin: 88, greenMax: 92, orangeMin: 87.4, orangeMax: 92.6 };
describe("selectOwnTargetRange", () => {
it("returns the resolved range when the person set the target", () => {
expect(
selectOwnTargetRange(
{ effective: { OXYGEN_SATURATION: { range, isOverride: true } } },
"OXYGEN_SATURATION",
),
).toEqual(range);
});
it("returns null for a default range, so the chart keeps its own band", () => {
expect(
selectOwnTargetRange(
{ effective: { OXYGEN_SATURATION: { range, isOverride: false } } },
"OXYGEN_SATURATION",
),
).toBeNull();
});
it("returns null before the read lands or for an absent metric", () => {
expect(selectOwnTargetRange(null, "OXYGEN_SATURATION")).toBeNull();
expect(selectOwnTargetRange({ effective: {} }, "PULSE")).toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import type { ThresholdMetric } from "@/lib/analytics/effective-range";
import type { TrafficRange } from "@/lib/analytics/value-bands";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
/** The slice of `GET /api/user/thresholds` a chart band needs. */
interface ThresholdsSlice {
effective: Partial<
Record<string, { range: TrafficRange | null; isOverride: boolean }>
>;
}
/**
* The user's own target band for one metric, as the server resolved it, or
* `null` when they have not set one (the chart then keeps its default band).
*
* `/api/user/thresholds` resolves every override through `getEffectiveRange`,
* orange wings included, so the page draws the resolved range rather than
* re-deriving one. Shares the `userThresholds()` cache entry with the target
* editor, which invalidates it on save, so editing a target repaints the
* chart without a reload.
*/
export function useOwnTargetRange(
metric: ThresholdMetric,
): TrafficRange | null {
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: () => apiGet<ThresholdsSlice>("/api/user/thresholds"),
enabled: isAuthenticated,
});
return selectOwnTargetRange(data ?? null, metric);
}
/** Pure projection, exported for the unit test. */
export function selectOwnTargetRange(
data: ThresholdsSlice | null,
metric: ThresholdMetric,
): TrafficRange | null {
const entry = data?.effective?.[metric];
if (!entry?.isOverride) return null;
return entry.range ?? null;
}
+8
View File
@@ -147,6 +147,14 @@ let cachedOperatorApproved: Agent | null = null;
function createPinnedDispatcher(policy: AddressPolicy): Agent {
return new Agent({
// #1267 — undici's own 300 s header and body timers would cut a call the
// caller allowed longer: a local model with a 600 s response timeout
// answers a non-streamed completion only when it is done, so its headers
// can take longer than 300 s, and the failure surfaced as a bare
// "network" error. Every `safeFetch` call carries its own
// `AbortSignal.timeout`, so that signal is the one deadline.
headersTimeout: 0,
bodyTimeout: 0,
connect: {
lookup:
policy === "public" ? pinnedPublicLookup : pinnedOperatorApprovedLookup,
+186
View File
@@ -0,0 +1,186 @@
/**
* The bottom sheet's gesture arithmetic, free of the DOM so every rule can be
* tested on numbers: when a drag counts, how far the sheet follows, how fast
* the finger left, and where the sheet comes to rest.
*
* Offsets are the sheet's `translateY` in px: 0 is fully raised, positive is
* further down. A sheet with a preview step rests at `previewOffset` (its
* full height less the preview height) until it is pulled up to 0.
*/
/** A release this far below the lowest resting place closes the sheet, as a share of the height still visible there. */
export const SHEET_DISMISS_RATIO = 0.25;
/** A downward release faster than this (px/ms) closes the sheet. */
export const SHEET_FLICK_VELOCITY = 0.5;
/** Travel (px) before a touch is read as vertical or sideways. */
export const SHEET_AXIS_SLOP = 8;
/** The most the sheet rises above its top resting place, however far the finger goes. */
export const SHEET_OVERDRAG = 24;
/** Only the movement in this window (ms) before release counts towards the velocity. */
export const SHEET_VELOCITY_WINDOW = 100;
export type SheetAxis = "x" | "y";
export type SheetSnap = "full" | "preview";
export type SheetSettle = { kind: "close" } | { kind: "snap"; snap: SheetSnap };
/**
* Which way a touch is going, once it has gone far enough to tell. Null while
* it is still inside the slop. A tie goes sideways, so a diagonal start never
* takes a slider or a wide table away from its own gesture.
*/
export function lockAxis(
dx: number,
dy: number,
slop: number = SHEET_AXIS_SLOP,
): SheetAxis | null {
if (Math.abs(dx) < slop && Math.abs(dy) < slop) return null;
return Math.abs(dy) > Math.abs(dx) ? "y" : "x";
}
/**
* Travel past the top: 1:1 at first, then ever stiffer, never more than
* `limit`.
*/
export function rubberBand(
distance: number,
limit: number = SHEET_OVERDRAG,
): number {
if (distance <= 0) return 0;
return (distance * limit) / (distance + limit);
}
/**
* Where the sheet sits for a finger `delta` px from where the drag started,
* the sheet then resting at `base`. Down and back up to the top it follows
* the finger exactly; above the top it resists.
*/
export function dragOffset(base: number, delta: number): number {
const raw = base + delta;
return raw >= 0 ? raw : -rubberBand(-raw);
}
export interface SheetSample {
/** ms */
t: number;
/** offset, px */
y: number;
}
/**
* The sheet's speed at release (px/ms, positive downward), from the samples
* inside the velocity window. A finger that came to rest before letting go
* leaves fewer than two samples there and so no speed.
*/
export function releaseVelocity(
samples: readonly SheetSample[],
now: number,
window: number = SHEET_VELOCITY_WINDOW,
): number {
let first: SheetSample | null = null;
let last: SheetSample | null = null;
for (const sample of samples) {
if (now - sample.t > window) continue;
if (first === null) first = sample;
last = sample;
}
if (first === null || last === null || last.t <= first.t) return 0;
return (last.y - first.y) / (last.t - first.t);
}
export interface SheetSettleInput {
offset: number;
velocity: number;
/** The sheet's own height, px. */
height: number;
/** The preview step's resting offset; null for a sheet without one. */
previewOffset: number | null;
}
/**
* Where a released sheet goes.
*
* - Further than a quarter of its visible height below its lowest resting
* place: it closes.
* - Flicked down: it closes once it is below that resting place, otherwise
* it drops to the preview step (or back to the top without one).
* - Flicked up: it rises to the top.
* - Otherwise it goes to the nearer resting place.
*/
export function settleSheet({
offset,
velocity,
height,
previewOffset,
}: SheetSettleInput): SheetSettle {
const lowest = previewOffset ?? 0;
const visible = Math.max(height - lowest, 1);
if (offset - lowest > SHEET_DISMISS_RATIO * visible) return { kind: "close" };
if (velocity > SHEET_FLICK_VELOCITY) {
if (offset > lowest) return { kind: "close" };
return { kind: "snap", snap: previewOffset === null ? "full" : "preview" };
}
if (velocity < -SHEET_FLICK_VELOCITY || previewOffset === null) {
return { kind: "snap", snap: "full" };
}
return {
kind: "snap",
snap: offset < previewOffset / 2 ? "full" : "preview",
};
}
/**
* The backdrop's opacity: whole while the sheet is at or above its lowest
* resting place, fading in step with the sheet on its way out.
*/
export function backdropOpacity(
offset: number,
lowest: number,
height: number,
): number {
const travel = Math.max(height - lowest, 1);
const out = (offset - lowest) / travel;
return Math.min(1, Math.max(0, 1 - out));
}
/**
* How far a preview sheet's footer is lifted against the sheet's own
* offset, so it stays on the bottom edge of the window between the preview
* step and the top, and leaves with the sheet below the preview step.
*/
export function footerLift(offset: number, previewOffset: number): number {
return Math.min(Math.max(offset, 0), previewOffset);
}
/**
* Whether a vertical touch that began inside scrolling content moves the
* sheet. Content that is scrolled at all scrolls first; at the very top a
* downward pull moves the sheet, and an upward one only raises a sheet that
* is still on its preview step (otherwise it scrolls the content).
*/
export function contentDrags({
scrollTop,
dy,
snap,
hasPreview,
}: {
scrollTop: number;
dy: number;
snap: SheetSnap;
hasPreview: boolean;
}): boolean {
if (scrollTop > 0) return false;
if (dy > 0) return true;
return hasPreview && snap === "preview";
}
/** The `translateY` of a computed `transform` value (`none`, `matrix()` or `matrix3d()`). */
export function translateYOf(transform: string): number {
if (!transform || transform === "none") return 0;
const values = transform
.slice(transform.indexOf("(") + 1, transform.lastIndexOf(")"))
.split(",")
.map((part) => Number.parseFloat(part));
if (transform.startsWith("matrix3d(")) return values[13] ?? 0;
if (transform.startsWith("matrix(")) return values[5] ?? 0;
return 0;
}