fix(ai): bound one completion call to two requests

A gateway that refuses reasoning_effort outright (LiteLLM in front of
Ollama: "does not support parameters: ['reasoning_effort']") in front of a
model that reasons without answering sent requests until something outside
stopped it. The answerless rule learned to ask with "none", the refusal of
that "none" was learned as "send no field" over it, the next request came
back answerless again, and each lesson undid the other.

Each call of the Local and the OpenAI-compatible gateway clients now carries
one request budget through every repeat (request-hops.ts): at most two
requests, after which the call ends with the failure it has. A structural
guard holds both clients to sending only through the budget and to handing
it to every repeat.

The refusal is now learned as what it is: an endpoint that refuses the
field, in front of a model already seen reasoning unasked, keeps both facts
(FIELD_REFUSED_REASONS_REGARDLESS), so it is never asked with "none" again
and keeps its thinking room. An answerless reply from an endpoint that
refuses the field is final. When the refused "none" was the repeat of an
answerless reply, the call fails as reasoning_only, which is what it ran
into. The refusal handling of both clients lives in wire-refusal.ts.

A status run that hits its bound now aborts the call it gave up on instead
of leaving it running detached.

Refs #1279
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 00:11:42 +02:00
1 parent 3984ceb01c
commit 73cee35917
24 files changed
+1058 -296

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
/**
* Structural guard (#1279): a provider client that sends a request again on
* its own sends every request through the call's budget, and hands that
* budget to every repeat.
*
* A client that repeats a request (`this.generateCompletion(` or
* `this.generateCompletionStream(` inside its own class) decided each repeat
* from what it had just learned, and two lessons that undid each other made
* one call send requests until something outside stopped it. The budget in
* `src/lib/ai/request-hops.ts` refuses the request past two; it can only do
* that if no request goes around it and no repeat starts a fresh one. So, in
* every such client:
*
* - no request goes out through `safeFetch(` directly, only `hops.fetch(`;
* - every repeat passes `hops` on;
* - each public entry takes the budget as its last parameter, defaulting
* to a fresh one, so a caller outside starts one per call.
*
* `request-hops.test.ts` walks the refusal and answerless combinations
* against a loopback server and counts the requests.
*
* Its limit: it reads source text. A client that repeats through a helper
* that is not a method named `generateCompletion*`, or sends through another
* transport, is not found here.
*
* Mutation check: drop `hops` from one repeat, or put one `safeFetch(` back
* into either client, and this goes red.
*/
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
readSourceText,
requireFloor,
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
const AI = join(process.cwd(), "src/lib/ai");
/** The text of the call that starts at `open` (the index of its `(`). */
function callText(source: string, open: number): string {
let depth = 0;
for (let i = open; i < source.length; i += 1) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") {
depth -= 1;
if (depth === 0) return source.slice(open, i + 1);
}
}
return source.slice(open);
}
const REPEAT = /\bthis\.generateCompletion(?:Stream)?\s*\(/g;
function repeatingClients(): Array<{ rel: string; source: string }> {
const out: Array<{ rel: string; source: string }> = [];
for (const rel of walkSourceFiles(AI, { floor: 40 })) {
if (rel.includes("__tests__/")) continue;
const source = stripComments(readSourceText(join(AI, rel)));
if (REPEAT.test(source)) out.push({ rel, source });
REPEAT.lastIndex = 0;
}
return out;
}
describe("a client's own repeats stay inside the call's request budget", () => {
const clients = repeatingClients();
it("finds the clients that repeat a request", () => {
// The Local client and the OpenAI-compatible gateway client today.
requireFloor("repeating clients", clients, 2);
expect(clients.map((c) => c.rel).sort()).toEqual([
"local-client.ts",
"openai-client.ts",
]);
});
it("sends every request through the budget", () => {
for (const { rel, source } of clients) {
expect({ rel, direct: /\bsafeFetch\s*\(/.test(source) }).toEqual({
rel,
direct: false,
});
expect({ rel, counted: /\bhops\.fetch\s*\(/.test(source) }).toEqual({
rel,
counted: true,
});
}
});
it("hands the budget to every repeat", () => {
const repeats: Array<{ rel: string; text: string }> = [];
for (const { rel, source } of clients) {
for (const m of source.matchAll(REPEAT)) {
const open = (m.index ?? 0) + m[0].length - 1;
repeats.push({ rel, text: callText(source, open) });
}
}
requireFloor("repeats", repeats, 6);
expect(
repeats.filter(({ text }) => !/,\s*hops\s*,?\s*\)$/.test(text)),
).toEqual([]);
});
it("starts one budget per call at each public entry", () => {
const entries: Array<{ rel: string; text: string }> = [];
for (const { rel, source } of clients) {
for (const m of source.matchAll(
/\basync\s+generateCompletion(?:Stream)?\s*\(/g,
)) {
const open = (m.index ?? 0) + m[0].length - 1;
entries.push({ rel, text: callText(source, open) });
}
}
requireFloor("public entries", entries, 3);
expect(
entries.filter(
({ text }) =>
!/hops:\s*RequestHops\s*=\s*new RequestHops\(\)\s*,?\s*\)$/.test(
text,
),
),
).toEqual([]);
});
});
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 87,
"total": 97455,
"total": 97452,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -29,7 +29,7 @@
"lib/ai/coach/stream-events.ts": 817,
"lib/ai/coach/types.ts": 1011,
"lib/ai/codex-client.ts": 1045,
"lib/ai/local-client.ts": 824,
"lib/ai/local-client.ts": 821,
"lib/ai/prompts/insight-generator.ts": 1110,
"lib/ai/provider.ts": 1632,
"lib/analytics/score/reader.ts": 998,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 351,
"total": 113672,
"total": 113581,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -254,8 +254,8 @@
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
"lib/ai/coach/turn/reply-guards.ts :: guardReply": 331,
"lib/ai/codex-client.ts :: parseResponse": 302,
"lib/ai/local-client.ts :: generateCompletionStream": 294,
"lib/ai/openai-client.ts :: generateCompletion": 335,
"lib/ai/local-client.ts :: generateCompletionStream": 263,
"lib/ai/openai-client.ts :: generateCompletion": 276,
"lib/analytics/bp-in-target-fast-path.ts :: computeFromRollups": 203,
"lib/analytics/compliance/display.ts :: calculateCompliance": 212,
"lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256,
@@ -298,7 +298,7 @@
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
"lib/insights/features.ts :: extractFeatures": 796,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
"lib/insights/status-provider.ts :: runStatusCompletion": 226,
"lib/insights/status-provider.ts :: runStatusCompletion": 225,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
"lib/integrations/status.ts :: recordSyncFailure": 292,
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2897,
"total": 9272,
"total": 9267,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1748,10 +1748,10 @@
"lib/ai/insight-interpretation.ts": 1,
"lib/ai/legacy-payload.ts": 2,
"lib/ai/legacy-private-host-report.ts": 1,
"lib/ai/local-client.ts": 18,
"lib/ai/local-client.ts": 16,
"lib/ai/medical-references.ts": 1,
"lib/ai/mock-client.ts": 5,
"lib/ai/openai-client.ts": 16,
"lib/ai/openai-client.ts": 13,
"lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts": 5,
"lib/ai/prompts/__tests__/briefing-personalisation.test.ts": 1,
"lib/ai/prompts/__tests__/coach-prompt-locale.test.ts": 1,