mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(ai): learn a refused reasoning field set by the entry itself
A gateway or local entry can carry its own reasoning setting (#1126), sent on every call that has no level of its own. An endpoint that refuses reasoning_effort answered each of those calls with a 400 that was never learned, because only the call's own level counted as sent: every status note, every summary and every other level-less call failed, for as long as the setting stood. The refusal is now learned whichever level put the field on the wire, by the same path as any refused field: the call is asked once more without it inside its two-request budget, later calls send no field for that endpoint and model, a model seen reasoning unasked keeps its thinking room, and one ai.provider.reasoning_field_refused line says so per lesson instead of a failure per call. Refs #1279
This commit is contained in:
1 parent
05cb5cf7e9
commit
8b47d033e7
5 files changed
+221
-46
No files matched your search
@@ -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": 9256,
|
||||
"total": 9252,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -1748,7 +1748,7 @@
|
||||
"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": 16,
|
||||
"lib/ai/local-client.ts": 12,
|
||||
"lib/ai/medical-references.ts": 1,
|
||||
"lib/ai/mock-client.ts": 5,
|
||||
"lib/ai/openai-client.ts": 9,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* #1126 / #1279 — a gateway or local entry whose own reasoning setting the
|
||||
* endpoint refuses.
|
||||
*
|
||||
* The entry's setting (`reasoningEffort`, stamped by the resolver) rides a
|
||||
* call that carries no level of its own. An endpoint that refuses the field
|
||||
* (LiteLLM in front of Ollama) answered every such call with a 400 that was
|
||||
* never learned, because only the call's own level counted as "sent": every
|
||||
* call failed. It is learned now like any refused field: the call is asked
|
||||
* once more without it, inside its budget of two requests, later calls send
|
||||
* no field, and the lesson is reported once.
|
||||
*
|
||||
* Driven through the real clients against a loopback server.
|
||||
*/
|
||||
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),
|
||||
}));
|
||||
|
||||
import { LocalOpenAICompatibleClient } from "../local-client";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import { resetJsonModeDialectCache } from "../json-dialect";
|
||||
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
|
||||
import { singleUserTurn } from "../types";
|
||||
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
|
||||
|
||||
const ANSWER = '{"summary":"Steady."}';
|
||||
|
||||
let server: http.Server;
|
||||
let origin = "";
|
||||
let seen: unknown[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => {
|
||||
const body = JSON.parse(raw) as Record<string, unknown>;
|
||||
seen.push(body.reasoning_effort);
|
||||
if (body.reasoning_effort !== undefined) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"litellm.UnsupportedParamsError: ollama does not support parameters: ['reasoning_effort']",
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: ANSWER },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { total_tokens: 400 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
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;
|
||||
seen = [];
|
||||
emitSignal.mockClear();
|
||||
resetReasoningDialectCache();
|
||||
resetJsonModeDialectCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedOrigins === undefined) delete process.env.AI_PRIVATE_ORIGINS;
|
||||
else process.env.AI_PRIVATE_ORIGINS = savedOrigins;
|
||||
});
|
||||
|
||||
function gateway() {
|
||||
const client = new OpenAIClient({
|
||||
apiKey: "",
|
||||
model: "qwen3",
|
||||
baseUrl: `${origin}/v1`,
|
||||
providerType: "openai-compatible",
|
||||
});
|
||||
client.reasoningEffort = "high";
|
||||
return client;
|
||||
}
|
||||
|
||||
function local() {
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
baseUrl: `${origin}/v1`,
|
||||
model: "qwen3",
|
||||
});
|
||||
client.reasoningEffort = "high";
|
||||
return client;
|
||||
}
|
||||
|
||||
/** A status card call: no level of its own. */
|
||||
const params = () =>
|
||||
singleUserTurn({
|
||||
system: "s",
|
||||
user: "u",
|
||||
maxTokens: 300,
|
||||
timeoutMs: 30_000,
|
||||
responseFormat: "json",
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["the gateway", gateway],
|
||||
["the Local client", local],
|
||||
] as const)("%s, an entry setting the endpoint refuses", (_, make) => {
|
||||
it("answers on the second request, and later calls send no field", async () => {
|
||||
const first = await make().generateCompletion(params());
|
||||
expect(first.content).toBe(ANSWER);
|
||||
expect(seen).toEqual(["high", undefined]);
|
||||
|
||||
seen = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
expect((await make().generateCompletion(params())).content).toBe(ANSWER);
|
||||
}
|
||||
expect(seen).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
it("says so once, not once per call", async () => {
|
||||
for (let i = 0; i < 3; i += 1) await make().generateCompletion(params());
|
||||
const lines = emitSignal.mock.calls
|
||||
.map((c) => c[0] as { action: string })
|
||||
.filter((s) => s.action === "ai.provider.reasoning_field_refused");
|
||||
expect(lines).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+22
-24
@@ -57,12 +57,9 @@ interface LocalClientConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.28.28 (#470) — per-endpoint JSON-mode dialect, learned at runtime.
|
||||
* v1.33.1 — the machinery moved to `json-dialect.ts` when the
|
||||
* OpenAI-compatible gateway provider needed the same learning; the two
|
||||
* clients share one cache keyed by base URL. The names below stay exported
|
||||
* from here because this client's tests and call sites were the first
|
||||
* consumers.
|
||||
* #470 — the per-endpoint JSON-mode dialect, learned at runtime in
|
||||
* `json-dialect.ts` (one cache keyed by base URL, shared with the gateway
|
||||
* client). The names stay exported here for this client's first consumers.
|
||||
*/
|
||||
type LocalJsonDialect = JsonModeDialect;
|
||||
|
||||
@@ -72,10 +69,8 @@ export const resetLocalJsonDialectCache = resetJsonModeDialectCache;
|
||||
export { isResponseFormatRejection };
|
||||
|
||||
/**
|
||||
* v1.22 (#89) — absolute backstop for the streaming path. The real ceiling is
|
||||
* the per-idle-gap timer (`CompletionParams.timeoutMs`); this only stops a
|
||||
* server that streams forever from pinning a worker. Ten minutes is far beyond
|
||||
* any legitimate single Coach turn.
|
||||
* #89 — the streaming path's absolute backstop. The real ceiling is the
|
||||
* per-idle-gap timer; this only stops a server that streams forever.
|
||||
*/
|
||||
const STREAM_ABSOLUTE_CEILING_MS = 10 * 60_000;
|
||||
|
||||
@@ -89,12 +84,10 @@ const STREAM_ABSOLUTE_CEILING_MS = 10 * 60_000;
|
||||
* `LocalJsonDialect` above). The strict-JSON instruction prepended to the
|
||||
* user message stays in both dialects.
|
||||
*
|
||||
* v1.20.0 — tool-calling DEGRADES silently here: most local servers reject an
|
||||
* unknown `tools` field, so the client never forwards it (capability flag
|
||||
* below). F1's tool loop must therefore tolerate a provider that never returns
|
||||
* `toolCalls` and answer from base context. Prompt-caching, where the local
|
||||
* server supports it (vLLM / Ollama prefix cache), is transparent — no flag,
|
||||
* no harm.
|
||||
* Tool calling degrades silently: most local servers reject an unknown
|
||||
* `tools` field, so the client never forwards it (`supportsTools`), and the
|
||||
* Coach's tool loop answers from base context. Prompt caching, where the
|
||||
* server has it (vLLM / Ollama prefix cache), is transparent.
|
||||
*/
|
||||
export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
readonly type = "local" as const;
|
||||
@@ -179,7 +172,11 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// v1.41 — a call that carries its own resolved level wins over it.
|
||||
// #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
|
||||
// #1279 — not once the endpoint refused the field.
|
||||
...(!params.reasoning &&
|
||||
this.reasoningEffort &&
|
||||
callEffort === null &&
|
||||
this.sentReasoningEffort(params) !== null
|
||||
? { reasoning_effort: this.reasoningEffort }
|
||||
: {}),
|
||||
...(callEffort ? { reasoning_effort: callEffort } : {}),
|
||||
@@ -252,13 +249,14 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
|
||||
/**
|
||||
* The `reasoning_effort` this request carries, whichever level decided it:
|
||||
* the call's own (`callReasoningEffort`), else the entry's #1126 setting.
|
||||
* the call's own (`callReasoningEffort`), else the entry's #1126 setting,
|
||||
* unless the endpoint refused the field (#1279).
|
||||
*/
|
||||
private sentReasoningEffort(params: CompletionParams): string | null {
|
||||
return (
|
||||
this.callReasoningEffort(params) ??
|
||||
(params.reasoning ? null : this.reasoningEffort)
|
||||
);
|
||||
const call = this.callReasoningEffort(params);
|
||||
if (call !== null || params.reasoning) return call;
|
||||
const learned = learnedReasoningDialect(this.reasoningDialectKey());
|
||||
return refusesReasoningField(learned) ? null : this.reasoningEffort;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,7 +403,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
|
||||
if (!res.ok) {
|
||||
return this.failed(res, params, hops, {
|
||||
effort: this.callReasoningEffort(params),
|
||||
effort: this.sentReasoningEffort(params),
|
||||
json:
|
||||
params.responseFormat === "json" && jsonDialect === "response_format",
|
||||
streamed: false,
|
||||
@@ -620,7 +618,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
|
||||
if (!res.ok) {
|
||||
return this.failed(res, params, hops, {
|
||||
effort: this.callReasoningEffort(params),
|
||||
effort: this.sentReasoningEffort(params),
|
||||
json:
|
||||
params.responseFormat === "json" && jsonDialect === "response_format",
|
||||
streamed: true,
|
||||
|
||||
+29
-20
@@ -214,10 +214,18 @@ 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`).
|
||||
// #1126 — the gateway entry's own setting, for a call without a level,
|
||||
// unless the wire carries one or the endpoint refused the field (#1279).
|
||||
const entryEffort =
|
||||
!params.reasoning && this.isGateway ? this.reasoningEffort : null;
|
||||
!params.reasoning &&
|
||||
this.isGateway &&
|
||||
this.reasoningEffort &&
|
||||
reasoningWire.kind === "none" &&
|
||||
!this.refusesField()
|
||||
? this.reasoningEffort
|
||||
: null;
|
||||
const sent =
|
||||
reasoningWire.kind !== "none" ? reasoningWire.effort : entryEffort;
|
||||
const thinkingTokens = this.thinkingRoom(params, reasoningWire);
|
||||
const tokenBudget = (params.maxTokens ?? 1000) + thinkingTokens;
|
||||
const capabilityParams = capabilities.supportsSamplingControls
|
||||
@@ -261,16 +269,11 @@ export class OpenAIClient implements AIProvider {
|
||||
? { prompt_cache_key: params.cacheKey }
|
||||
: {}),
|
||||
// #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.
|
||||
// #1267 — once a model answered nothing while reasoning, the wire
|
||||
// nothing. `api.openai.com` and Codex never get it. A call that
|
||||
// carries its own resolved level (`params.reasoning`) wins, and
|
||||
// (#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 }
|
||||
: {}),
|
||||
...(entryEffort ? { reasoning_effort: this.reasoningEffort } : {}),
|
||||
...(reasoningWire.kind === "reasoning_effort"
|
||||
? { reasoning_effort: reasoningWire.effort }
|
||||
: reasoningWire.kind === "openrouter"
|
||||
@@ -299,7 +302,7 @@ export class OpenAIClient implements AIProvider {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return this.failed(res, params, hops, { reasoningWire, sendJsonFormat });
|
||||
return this.failed(res, params, hops, { sent, sendJsonFormat });
|
||||
}
|
||||
|
||||
// v1.33.1 (#470) — the standard `response_format` request succeeded: pin
|
||||
@@ -344,9 +347,6 @@ export class OpenAIClient implements AIProvider {
|
||||
|
||||
const choice = json.choices?.[0];
|
||||
const toolCalls = parseOpenAIToolCalls(choice);
|
||||
// The effort the request carried, whichever level decided it.
|
||||
const sent =
|
||||
reasoningWire.kind !== "none" ? reasoningWire.effort : entryEffort;
|
||||
const reply = this.separateReasoning(choice, toolCalls, {
|
||||
thinkingTokens,
|
||||
sent,
|
||||
@@ -514,19 +514,21 @@ export class OpenAIClient implements AIProvider {
|
||||
res: Response,
|
||||
params: CompletionParams,
|
||||
hops: RequestHops,
|
||||
sent: { reasoningWire: OpenAIReasoningWire; sendJsonFormat: boolean },
|
||||
sent: { sent: string | null; sendJsonFormat: boolean },
|
||||
): Promise<CompletionResult> {
|
||||
const bodyExcerpt = sanitiseBodyExcerpt(await res.text().catch(() => ""));
|
||||
const wire = sent.reasoningWire;
|
||||
// The entry's own setting (#1126) included: a refused field is learned
|
||||
// whichever level put it on the wire.
|
||||
const effort = sent.sent;
|
||||
const lesson = learnFromRefusal({
|
||||
status: res.status,
|
||||
body: bodyExcerpt,
|
||||
model: this.config.model,
|
||||
reasoning:
|
||||
wire.kind === "none"
|
||||
effort === null
|
||||
? null
|
||||
: {
|
||||
sent: wire.effort,
|
||||
sent: effort,
|
||||
key: this.reasoningDialectKey(),
|
||||
provider: this.type,
|
||||
names: /reasoning|thinking/i,
|
||||
@@ -637,6 +639,13 @@ export class OpenAIClient implements AIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/** #1279 — this endpoint and model refused the reasoning field. */
|
||||
private refusesField(): boolean {
|
||||
return refusesReasoningField(
|
||||
learnedReasoningDialect(this.reasoningDialectKey()),
|
||||
);
|
||||
}
|
||||
|
||||
private reasoningDialectKey() {
|
||||
return {
|
||||
provider: "openai",
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
annotateReasoningDowngrade,
|
||||
learnedReasoningDialect,
|
||||
reasoningSeen,
|
||||
refusesReasoningField,
|
||||
rememberReasoningDialect,
|
||||
type ReasoningDialectKey,
|
||||
} from "./dialect-cache";
|
||||
@@ -159,11 +160,21 @@ export function learnReasoningFieldRefused(
|
||||
provider: string,
|
||||
sent: string,
|
||||
): void {
|
||||
const before = learnedReasoningDialect(key);
|
||||
rememberReasoningDialect(
|
||||
key,
|
||||
reasonsUnasked(key) ? FIELD_REFUSED_REASONS_REGARDLESS : "none",
|
||||
);
|
||||
annotateReasoningDowngrade(provider, sent, "none");
|
||||
// One line per lesson, not per call: the operator learns that the
|
||||
// endpoint takes no reasoning field (an entry setting it refuses included).
|
||||
if (!refusesReasoningField(before)) {
|
||||
emitSignal({
|
||||
action: "ai.provider.reasoning_field_refused",
|
||||
level: "warn",
|
||||
meta: { provider, effort: sent },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user