mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
3984ceb01c
commit
73cee35917
24 files changed
+1058
-296
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user