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
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 13:01:10 +02:00
1 parent bb3ee6b855
commit 221960af88
11 files changed
+1040 -51

No files matched your search

+5
View File
@@ -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({
+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"]);
});
});
+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;
@@ -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;