mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
bb3ee6b855
commit
221960af88
11 files changed
+1040
-51
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user