From 221960af88f3a7270e18ab6725a84389e2bd2b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 13:01:10 +0200 Subject: [PATCH] fix(ai): ask a thinking model again when it answered with reasoning alone Ollama serves thinking models such as Gemma 4 on /v1/chat/completions with the thought in a separate `reasoning` field. A long briefing can come back with empty `content`: the thought used the whole token budget, or the answer itself was parsed into `reasoning`. The background briefing always asks to reason, so every briefing on such a model ended in the empty-reply sentinel, which the chain logged as status 0 / cause "network" in the same second the server logged 200, and the read path showed as a timeout. The Local client and the OpenAI-compatible gateway now treat a reply with reasoning and no answer as a lesson: the endpoint and model are remembered in the reasoning dialect cache as answering only unreasoned, the call is asked once more with reasoning_effort "none", and later calls ask that way until the lesson expires. A test drives the real client, safeFetch and the pinned dispatcher against a loopback server that answers the way Ollama serves Gemma 4, and reproduces the reported log line without the fix. Failed hops now carry what the call ran into: `failure` (timeout, aborted, refused, reset, socket, parse, empty_response, reasoning_only, ...) and a code-shaped `code` on ai.chain.link_failed, closed vocabulary only. The cause only says "timeout" for a deadline, and the briefing failure marker records the kind so the empty state suggests a longer timeout only when time ran out. Refs #1267 --- src/app/api/insights/generate/route.ts | 5 + src/lib/ai/__tests__/failure-kind.test.ts | 189 +++++++++ src/lib/ai/__tests__/local-gemma-wire.test.ts | 369 ++++++++++++++++++ src/lib/ai/failure-kind.ts | 166 ++++++++ src/lib/ai/local-client.ts | 160 ++++++-- src/lib/ai/openai-client.ts | 61 ++- src/lib/ai/provider-runner.ts | 38 +- src/lib/ai/reasoning/dialect-cache.ts | 9 + .../__tests__/briefing-failure-marker.test.ts | 38 ++ src/lib/insights/briefing-failure-marker.ts | 51 ++- src/lib/insights/comprehensive-generate.ts | 5 + 11 files changed, 1040 insertions(+), 51 deletions(-) create mode 100644 src/lib/ai/__tests__/failure-kind.test.ts create mode 100644 src/lib/ai/__tests__/local-gemma-wire.test.ts create mode 100644 src/lib/ai/failure-kind.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 9827cab62..394be24bb 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -64,6 +64,7 @@ import { resolveUserTimezone } from "@/lib/tz/resolver"; import { insightResultSchema, type InsightResult } from "@/lib/ai/types"; 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, readBriefingFailure, @@ -855,6 +856,10 @@ export const POST = apiHandler((request: NextRequest) => : "provider-error", locale, httpStatus: failureHttpStatus, + failureKind: + e instanceof AllProvidersFailedError + ? (e.attempts[0]?.failureKind ?? null) + : providerFailureKind(e).failure, }); if (e instanceof AllProvidersFailedError) { annotate({ diff --git a/src/lib/ai/__tests__/failure-kind.test.ts b/src/lib/ai/__tests__/failure-kind.test.ts new file mode 100644 index 000000000..dd578f15f --- /dev/null +++ b/src/lib/ai/__tests__/failure-kind.test.ts @@ -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("proxy error"); + 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((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((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 { + const probe = net.createServer(); + await new Promise((r) => probe.listen(0, "127.0.0.1", r)); + const port = (probe.address() as AddressInfo).port; + await new Promise((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, + }); + }); +}); diff --git a/src/lib/ai/__tests__/local-gemma-wire.test.ts b/src/lib/ai/__tests__/local-gemma-wire.test.ts new file mode 100644 index 000000000..f23efd6be --- /dev/null +++ b/src/lib/ai/__tests__/local-gemma-wire.test.ts @@ -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, 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; + 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((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((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> { + return emitSignal.mock.calls + .map((c) => c[0] as { action: string; meta: Record }) + .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"]); + }); +}); diff --git a/src/lib/ai/failure-kind.ts b/src/lib/ai/failure-kind.ts new file mode 100644 index 000000000..421a5da0a --- /dev/null +++ b/src/lib/ai/failure-kind.ts @@ -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 = 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" + ); +} diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index d4ea0fbfb..f81e4494a 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -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; @@ -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 { @@ -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 | 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 `` 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, diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index ce57df3f7..b4d02a07f 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -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; + 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; diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index 3e1bf413c..8bca7cd9a 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -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, }, }); } diff --git a/src/lib/ai/reasoning/dialect-cache.ts b/src/lib/ai/reasoning/dialect-cache.ts index 37c85bf4f..207efada6 100644 --- a/src/lib/ai/reasoning/dialect-cache.ts +++ b/src/lib/ai/reasoning/dialect-cache.ts @@ -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; diff --git a/src/lib/insights/__tests__/briefing-failure-marker.test.ts b/src/lib/insights/__tests__/briefing-failure-marker.test.ts index 3ea4e5ad0..a1f133a0e 100644 --- a/src/lib/insights/__tests__/briefing-failure-marker.test.ts +++ b/src/lib/insights/__tests__/briefing-failure-marker.test.ts @@ -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", diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts index b3a62c25d..bc35be6be 100644 --- a/src/lib/insights/briefing-failure-marker.ts +++ b/src/lib/insights/briefing-failure-marker.ts @@ -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 { 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 }), }; } diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 67a612a62..137f488aa 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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;