diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index deaf95332..5690381a0 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/lib/ai/__tests__/entry-effort-refused.test.ts b/src/lib/ai/__tests__/entry-effort-refused.test.ts new file mode 100644 index 000000000..caa14e151 --- /dev/null +++ b/src/lib/ai/__tests__/entry-effort-refused.test.ts @@ -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; + 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((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((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); + }); +}); diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 239602e3c..ee5e5b107 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -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, diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index a05a4a913..feb6efd5c 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -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 { 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", diff --git a/src/lib/ai/reasoning/answerless.ts b/src/lib/ai/reasoning/answerless.ts index fdbb86e48..bcf2a2e18 100644 --- a/src/lib/ai/reasoning/answerless.ts +++ b/src/lib/ai/reasoning/answerless.ts @@ -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 }, + }); + } } /**