From 1acd41eb58d87c31a5bbdc0c161964c166e160fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 21:50:19 +0200 Subject: [PATCH] fix(ai): keep tools on the forced final round and narrow reasoning-dialect learning The Coach loop's final round sent toolChoice "none" without the tool definitions. With tool_use/tool_result blocks in the history Anthropic refuses such a request (400), and OpenAI Chat Completions refuses tool_choice without tools, so every budget, time, cap and no-progress stop ended in an error on those wires. The final round now carries the tools with toolChoice "none", which every wire accepts, thinking included. Codex kept reasoning items as provider state even when the request asked for no encrypted reasoning; replaying such an item under store:false is a 400. Items are kept only when the request included reasoning.encrypted_content and the item carries it. The reasoning dialect cache learned from any 4xx that mentioned reasoning or thinking (auth, rate limits, model names such as deepseek-reasoner) and from replay errors, and kept the downgrade for the life of the process. Learning now requires an HTTP 400 that refuses a sent parameter, ignores replay errors and the model name, and a learned downgrade expires after an hour. --- .../ai/__tests__/anthropic-reasoning.test.ts | 81 ++++ src/lib/ai/__tests__/codex-reasoning.test.ts | 154 ++++++++ src/lib/ai/__tests__/local-reasoning.test.ts | 69 ++++ src/lib/ai/__tests__/openai-reasoning.test.ts | 70 ++++ src/lib/ai/anthropic-client.ts | 27 +- .../coach/__tests__/tools-loop-budget.test.ts | 18 +- .../tools-loop-final-round-wire.test.ts | 358 ++++++++++++++++++ src/lib/ai/coach/__tests__/tools-loop.test.ts | 9 +- src/lib/ai/coach/tools/loop.ts | 16 +- src/lib/ai/codex-client.ts | 36 +- src/lib/ai/local-client.ts | 10 +- src/lib/ai/openai-client.ts | 24 +- .../reasoning/__tests__/dialect-cache.test.ts | 99 +++++ src/lib/ai/reasoning/dialect-cache.ts | 61 ++- 14 files changed, 991 insertions(+), 41 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts diff --git a/src/lib/ai/__tests__/anthropic-reasoning.test.ts b/src/lib/ai/__tests__/anthropic-reasoning.test.ts index 5253feb10..5edeb26cd 100644 --- a/src/lib/ai/__tests__/anthropic-reasoning.test.ts +++ b/src/lib/ai/__tests__/anthropic-reasoning.test.ts @@ -437,6 +437,87 @@ describe("Anthropic thinking refusals", () => { }); }); +describe("Anthropic thinking refusals the client must not learn from", () => { + const replay = async () => + new Response( + JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message: + "messages.1.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`. When `thinking` is enabled, a final `assistant` message must start with a thinking block (preceeding the lastmost set of `tool_use` and `tool_result` blocks). We recommend you include thinking blocks from previous turns.", + }, + }), + { status: 400 }, + ); + + it("raises a replay error once and keeps the thinking form", async () => { + const mock = vi + .fn() + .mockImplementationOnce(replay) + .mockImplementation(ok("anthropic-after-tool.json")); + vi.stubGlobal("fetch", mock); + const c = client("my-claude-proxy"); + const wanted = params({ reasoning: { effort: "low", summaries: true } }); + await expect(c.generateCompletion(wanted)).rejects.toMatchObject({ + httpStatus: 400, + upstream: "anthropic", + }); + expect(mock).toHaveBeenCalledTimes(1); + await c.generateCompletion(wanted); + expect(sent(mock, 1).body.thinking.type).toBe("adaptive"); + }); + + it("does not learn from a 429 that mentions thinking", async () => { + const mock = vi + .fn() + .mockImplementationOnce( + async () => + new Response( + JSON.stringify({ + type: "error", + error: { + type: "rate_limit_error", + message: + "Extended thinking output tokens per minute exceeded; not allowed until reset.", + }, + }), + { status: 429 }, + ), + ) + .mockImplementation(ok("anthropic-after-tool.json")); + vi.stubGlobal("fetch", mock); + const c = client("my-claude-proxy"); + const wanted = params({ reasoning: { effort: "low", summaries: true } }); + await expect(c.generateCompletion(wanted)).rejects.toMatchObject({ + httpStatus: 429, + }); + await c.generateCompletion(wanted); + expect(sent(mock, 1).body.thinking.type).toBe("adaptive"); + }); + + it("tries the full thinking form again an hour after a refusal", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const mock = vi + .fn() + .mockImplementationOnce(fail(400, "anthropic-adaptive-rejected.json")) + .mockImplementation(ok("anthropic-after-tool.json")); + vi.stubGlobal("fetch", mock); + const c = client("my-claude-proxy"); + const wanted = params({ reasoning: { effort: "low", summaries: true } }); + await c.generateCompletion(wanted); + await c.generateCompletion(wanted); + expect(sent(mock, 2).body.thinking.type).toBe("enabled"); + vi.advanceTimersByTime(60 * 60 * 1000); + await c.generateCompletion(wanted); + expect(sent(mock, 3).body.thinking.type).toBe("adaptive"); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("Anthropic adaptive rounds that chose not to think", () => { it("keeps thinking on for the next round of its own turn", async () => { const noThought = { diff --git a/src/lib/ai/__tests__/codex-reasoning.test.ts b/src/lib/ai/__tests__/codex-reasoning.test.ts index 4b640e5f0..ac022be44 100644 --- a/src/lib/ai/__tests__/codex-reasoning.test.ts +++ b/src/lib/ai/__tests__/codex-reasoning.test.ts @@ -401,4 +401,158 @@ describe("Codex reasoning refusals", () => { ).rejects.toMatchObject({ httpStatus: 400 }); expect(mock).toHaveBeenCalledTimes(1); }); + + it("does not learn from a replay error about a reasoning item", async () => { + const replay = JSON.stringify({ + error: { + message: + "Item 'rs_123' of type 'reasoning' was provided without its required following item.", + type: "invalid_request_error", + param: "input", + }, + }); + const mock = vi + .fn() + .mockResolvedValueOnce(errorResponse(400, replay)) + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + await expect( + client().generateCompletion( + params({ reasoning: { effort: "low", summaries: true } }), + ), + ).rejects.toMatchObject({ httpStatus: 400 }); + expect(mock).toHaveBeenCalledTimes(1); + // The slug still reasons on the next call. + await client().generateCompletion( + params({ reasoning: { effort: "low", summaries: true } }), + ); + expect(sentBody(mock, 1).reasoning).toEqual({ + effort: "low", + summary: "auto", + }); + }); + + it("does not learn from a 401 or 429 that mentions reasoning", async () => { + for (const status of [401, 429]) { + resetReasoningDialectCache(); + const mock = vi + .fn() + .mockResolvedValueOnce( + errorResponse( + status, + JSON.stringify({ + error: { message: "Reasoning model quota: unsupported plan." }, + }), + ), + ) + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + await client() + .generateCompletion( + params({ reasoning: { effort: "low", summaries: false } }), + ) + .catch(() => undefined); + await client().generateCompletion( + params({ reasoning: { effort: "low", summaries: false } }), + ); + const last = sentBody(mock, mock.mock.calls.length - 1); + expect(last.reasoning).toEqual({ effort: "low" }); + } + }); + + it("forgets a learned refusal after an hour", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const refusal = JSON.stringify({ + error: { + message: "Reasoning is not supported for this model.", + type: "invalid_request_error", + param: "reasoning", + }, + }); + const mock = vi + .fn() + .mockResolvedValueOnce(errorResponse(400, refusal)) + .mockImplementation(async () => + sseResponse(fixture("codex-after-tool.sse")), + ); + vi.stubGlobal("fetch", mock); + const wanted = params({ reasoning: { effort: "low", summaries: false } }); + await client().generateCompletion(wanted); + await client().generateCompletion(wanted); + expect(sentBody(mock, 2).reasoning).toBeNull(); + vi.advanceTimersByTime(60 * 60 * 1000); + await client().generateCompletion(wanted); + expect(sentBody(mock, 3).reasoning).toEqual({ effort: "low" }); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("Codex reasoning items kept for the next round", () => { + it("keeps none when the request sent no reasoning wire", async () => { + // The slug refused reasoning, so the retry runs with `reasoning: null` + // and `include: []`; the server may still emit reasoning items, but + // without encrypted content they cannot be replayed under store:false. + const refusal = JSON.stringify({ + error: { + message: "Reasoning is not supported for this model.", + type: "invalid_request_error", + param: "reasoning", + }, + }); + const withoutContent = fixture("codex-summary.sse").replace( + /,"encrypted_content":"enc-fixture-\d+"/g, + "", + ); + const mock = vi + .fn() + .mockResolvedValueOnce(errorResponse(400, refusal)) + .mockImplementation(async () => sseResponse(withoutContent)); + vi.stubGlobal("fetch", mock); + const result = await client().generateCompletion( + params({ reasoning: { effort: "low", summaries: true } }), + ); + expect(sentBody(mock, 1).reasoning).toBeNull(); + expect(sentBody(mock, 1).include).toEqual([]); + expect(result.providerState).toBeUndefined(); + }); + + it("keeps none when the request asked for effort none", async () => { + const mock = vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + const result = await client().generateCompletion( + params({ reasoning: { effort: "off", summaries: false } }), + ); + expect(sentBody(mock).reasoning).toEqual({ effort: "none" }); + expect(sentBody(mock).include).toEqual([]); + expect(result.providerState).toBeUndefined(); + }); + + it("drops an item without encrypted content even when it was asked for", async () => { + const withoutContent = fixture("codex-summary.sse").replace( + /,"encrypted_content":"enc-fixture-\d+"/g, + "", + ); + const mock = vi + .fn() + .mockImplementation(async () => sseResponse(withoutContent)); + vi.stubGlobal("fetch", mock); + const result = await client().generateCompletion( + params({ reasoning: { effort: "medium", summaries: true } }), + ); + expect(sentBody(mock).include).toEqual(["reasoning.encrypted_content"]); + expect(result.providerState).toBeUndefined(); + // The summary still reaches the trail. + expect(result.reasoning?.summary.length).toBeGreaterThan(0); + }); }); diff --git a/src/lib/ai/__tests__/local-reasoning.test.ts b/src/lib/ai/__tests__/local-reasoning.test.ts index 5c0a27c65..aff6c797a 100644 --- a/src/lib/ai/__tests__/local-reasoning.test.ts +++ b/src/lib/ai/__tests__/local-reasoning.test.ts @@ -276,3 +276,72 @@ describe("Local reasoning stream", () => { expect(result.content).toBe(" { + const err = (status: number, message: string) => async () => + new Response(JSON.stringify({ error: { message } }), { status }); + + it.each([ + [401, "Unauthorized: thinking models require a key; not allowed."], + [429, "Too many requests to the reasoning queue; not permitted."], + [500, "reasoning_effort: unsupported value (internal error)."], + ])("raises HTTP %i once and keeps the field", async (status, message) => { + const mock = vi + .fn() + .mockImplementationOnce(err(status, message)) + .mockImplementation(buffered({ content: "ok" })); + vi.stubGlobal("fetch", mock); + const wanted = params({ reasoning: { effort: "low", summaries: true } }); + await expect(client().generateCompletion(wanted)).rejects.toMatchObject({ + httpStatus: status, + }); + expect(mock).toHaveBeenCalledTimes(1); + await client().generateCompletion(wanted); + expect(body(mock, 1).reasoning_effort).toBe("low"); + }); + + it("does not learn from a 400 whose only match is the model name", async () => { + const mock = vi + .fn() + .mockImplementationOnce( + err(400, "model 'qwen3-thinking' is not supported by this runner"), + ) + .mockImplementation(buffered({ content: "ok" })); + vi.stubGlobal("fetch", mock); + const c = new LocalOpenAICompatibleClient({ + model: "qwen3-thinking", + baseUrl: BASE, + }); + const wanted = params({ reasoning: { effort: "low", summaries: true } }); + await expect(c.generateCompletion(wanted)).rejects.toMatchObject({ + httpStatus: 400, + }); + await c.generateCompletion(wanted); + expect(body(mock, 1).reasoning_effort).toBe("low"); + }); + + it("sends the field again an hour after a refusal", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const refusal = readFileSync( + join(FIXTURES, "gateway-reasoning-rejected.json"), + "utf8", + ); + const mock = vi + .fn() + .mockImplementationOnce( + async () => new Response(refusal, { status: 400 }), + ) + .mockImplementation(buffered({ content: "ok" })); + vi.stubGlobal("fetch", mock); + const wanted = params({ reasoning: { effort: "low", summaries: true } }); + await client().generateCompletion(wanted); + expect(body(mock, 1)).not.toHaveProperty("reasoning_effort"); + vi.advanceTimersByTime(60 * 60 * 1000); + await client().generateCompletion(wanted); + expect(body(mock, 2).reasoning_effort).toBe("low"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/ai/__tests__/openai-reasoning.test.ts b/src/lib/ai/__tests__/openai-reasoning.test.ts index 6ca9c3c91..0e0d53b10 100644 --- a/src/lib/ai/__tests__/openai-reasoning.test.ts +++ b/src/lib/ai/__tests__/openai-reasoning.test.ts @@ -285,3 +285,73 @@ describe("Gateway reasoning", () => { expect(body(mock, 2)).not.toHaveProperty("reasoning_effort"); }); }); + +describe("OpenAI reasoning refusals the client must not learn from", () => { + const err = (status: number, message: string) => async () => + new Response( + JSON.stringify({ error: { message, type: "invalid_request_error" } }), + { status }, + ); + + it.each([ + [401, "Incorrect API key for the reasoning tier; not allowed."], + [429, "Rate limit reached for reasoning_effort requests; not permitted."], + [404, "The model `deepseek-reasoner` does not exist or is not supported."], + ])("raises HTTP %i once and keeps reasoning", async (status, message) => { + const mock = vi + .fn() + .mockImplementationOnce(err(status, message)) + .mockImplementation(plain("ok")); + vi.stubGlobal("fetch", mock); + const c = gateway("https://litellm.example.com/v1", "deepseek-reasoner"); + const wanted = params({ reasoning: { effort: "medium", summaries: true } }); + await expect(c.generateCompletion(wanted)).rejects.toMatchObject({ + httpStatus: status, + }); + expect(mock).toHaveBeenCalledTimes(1); + await c.generateCompletion(wanted); + expect(body(mock, 1).reasoning_effort).toBe("medium"); + }); + + it("does not learn from a 400 whose only reasoning word is the model name", async () => { + const mock = vi + .fn() + .mockImplementationOnce( + err(400, "deepseek-reasoner is not supported by this deployment."), + ) + .mockImplementation(plain("ok")); + vi.stubGlobal("fetch", mock); + const c = gateway("https://litellm.example.com/v1", "deepseek-reasoner"); + const wanted = params({ reasoning: { effort: "low", summaries: false } }); + await expect(c.generateCompletion(wanted)).rejects.toMatchObject({ + httpStatus: 400, + }); + await c.generateCompletion(wanted); + expect(body(mock, 1).reasoning_effort).toBe("low"); + }); + + it("sends reasoning again an hour after a refusal", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const mock = vi + .fn() + .mockImplementationOnce( + async () => + new Response(fixture("gateway-reasoning-rejected.json"), { + status: 400, + }), + ) + .mockImplementation(plain("ok")); + vi.stubGlobal("fetch", mock); + const c = gateway("https://litellm.example.com/v1", "llama3"); + const wanted = params({ reasoning: { effort: "low", summaries: false } }); + await c.generateCompletion(wanted); + expect(body(mock, 1)).not.toHaveProperty("reasoning_effort"); + vi.advanceTimersByTime(60 * 60 * 1000); + await c.generateCompletion(wanted); + expect(body(mock, 2).reasoning_effort).toBe("low"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 558985f2a..654de706b 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -13,6 +13,7 @@ import type { } from "./types"; import { annotateReasoningDowngrade, + isReasoningParameterRejection, learnedReasoningDialect, rememberReasoningDialect, } from "./reasoning/dialect-cache"; @@ -649,8 +650,11 @@ export class AnthropicClient implements AIProvider { } /** - * Learn from a 400 that names a parameter we sent. Returns false when the - * body is about something else; the caller then raises it as before. + * Learn from a 400 that refuses a parameter we sent. Returns false when the + * body is about something else, including a replay error about the + * thinking blocks handed back ("must start with a thinking block"), which + * says nothing about the thinking form the model accepts; the caller then + * raises it as before. */ private learnFromRejection( plan: ThinkingPlan, @@ -658,14 +662,27 @@ export class AnthropicClient implements AIProvider { body: string, dialect: AnthropicDialect, ): boolean { - const lower = body.toLowerCase(); + const model = this.config.model; let changed: string | null = null; - if (sentTemperature && /temperature|top_p|sampling/.test(lower)) { + if ( + sentTemperature && + isReasoningParameterRejection( + 400, + body, + /temperature|top_p|sampling/i, + model, + ) + ) { dialect.noSampling = true; changed = "sampling"; } else if ( plan.kind !== "none" && - /thinking|budget_tokens|adaptive|output_config|effort/.test(lower) + isReasoningParameterRejection( + 400, + body, + /thinking|budget_tokens|adaptive|output_config|effort/i, + model, + ) ) { dialect.thinking = plan.kind === "adaptive" ? "manual" : "none"; changed = plan.kind; diff --git a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts index 8dd4aeb0d..d98ccc52c 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts @@ -1,6 +1,6 @@ /** - * v1.41 — the budgeted loop: every stop reason forces a final round without - * tools and with the reason line, the ledger is reserved and settled round + * v1.41 — the budgeted loop: every stop reason forces a final round with + * `toolChoice: "none"` (the tool definitions stay) and the reason line, the ledger is reserved and settled round * by round, a repeated call is never run, a question ends the turn, the * reasoning level and the provider state reach the provider, and the trail * opens its thinking entry before the provider is called. @@ -90,6 +90,12 @@ function spend(admit: boolean[] = []): RoundSpend & { return { reserveRound, settleRound }; } +/** Every stop ends on a round that forbids calls but keeps the definitions. */ +function expectFinalRoundKeepsTools() { + expect(lastParams().toolChoice).toBe("none"); + expect(lastParams().tools).toEqual(COACH_TOOL_DEFS); +} + function lastParams() { const calls = runRawCompletionWithFallback.mock.calls; return calls[calls.length - 1][0].params as { @@ -123,7 +129,9 @@ describe("stop reasons", () => { expect(out.stop).toEqual({ reason: "budget", rounds: 2 }); expect(out.forcedFinal).toBe(true); expect(lastParams().toolChoice).toBe("none"); - expect(lastParams().tools).toBeUndefined(); + // The tools stay on the final round: the history holds the calls, and + // Anthropic refuses tool_use/tool_result blocks without `tools` (400). + expect(lastParams().tools).toEqual(COACH_TOOL_DEFS); expect(lastParams().system).toMatch( /FINAL ROUND: this turn's token budget/, ); @@ -155,6 +163,7 @@ describe("stop reasons", () => { }); expect(out.stop?.reason).toBe("time"); expect(out.forcedFinal).toBe(true); + expectFinalRoundKeepsTools(); }); it("answers when the daily ledger refuses the next round", async () => { @@ -170,6 +179,7 @@ describe("stop reasons", () => { }); expect(out.stop).toEqual({ reason: "budget", rounds: 3 }); expect(ledger.reserveRound).toHaveBeenCalledTimes(2); + expectFinalRoundKeepsTools(); // Three rounds settled; only the last against the final reserve. expect(ledger.settleRound.mock.calls.map((c) => c[0].final)).toEqual([ false, @@ -192,6 +202,7 @@ describe("stop reasons", () => { const out = await runCoachToolLoop({ ...baseArgs, budget: roomy() }); expect(out.stop).toEqual({ reason: "no_progress", rounds: 3 }); expect(out.forcedFinal).toBe(false); + expectFinalRoundKeepsTools(); expect(lastParams().system).toMatch(/brought nothing new/); }); }); @@ -234,6 +245,7 @@ describe("repeated calls", () => { const out = await runCoachToolLoop({ ...baseArgs, budget: roomy() }); expect(out.stop).toEqual({ reason: "no_progress", rounds: 3 }); expect(executeCoachTool).toHaveBeenCalledTimes(1); + expectFinalRoundKeepsTools(); }); }); diff --git a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts new file mode 100644 index 000000000..9a7c287b5 --- /dev/null +++ b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts @@ -0,0 +1,358 @@ +/** + * v1.41 — the forced final round, end to end: the loop's parameters go + * through each real provider client onto a fake upstream that enforces the + * provider's documented request rules, so a final round the provider would + * refuse fails here rather than on the first budget, time, cap or + * no-progress stop in production. + * + * - Anthropic refuses a request whose history holds `tool_use` / + * `tool_result` blocks but defines no `tools`; with thinking on it accepts + * only `tool_choice` `auto` or `none`. + * https://platform.claude.com/docs/en/build-with-claude/extended-thinking + * - OpenAI Chat Completions refuses `tool_choice` without `tools`. + * https://platform.openai.com/docs/api-reference/chat/create + * - The Codex Responses wire takes `tools` + `tool_choice: "none"`. + * - Local servers never get `tools` or `tool_choice` (most reject them). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CoachToolResult } from "@/lib/ai/coach/tools/executor"; + +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); +vi.mock("@/lib/ai/local-host-allowlist", () => ({ + aiEgressPolicyFor: () => ({}), +})); +const executeCoachTool = vi.fn<(args?: unknown) => Promise>(); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ + executeCoachTool: (args: unknown) => executeCoachTool(args), +})); +const runRawCompletionWithFallback = vi.fn(); +vi.mock("@/lib/ai/provider-runner", () => ({ + runRawCompletionWithFallback: (args: unknown) => + runRawCompletionWithFallback(args), +})); + +import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop"; +import { createTurnBudget } from "@/lib/ai/coach/tools/turn-budget"; +import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; +import { AnthropicClient } from "@/lib/ai/anthropic-client"; +import { OpenAIClient } from "@/lib/ai/openai-client"; +import { CodexClient } from "@/lib/ai/codex-client"; +import { LocalOpenAICompatibleClient } from "@/lib/ai/local-client"; +import { clearCodexSlugCache } from "@/lib/ai/codex-slug-cache"; +import { resetReasoningDialectCache } from "@/lib/ai/reasoning/dialect-cache"; +import type { CompletionParams, CompletionResult } from "@/lib/ai/types"; + +type Body = Record & { + tools?: unknown[]; + tool_choice?: unknown; + messages?: Array<{ content?: unknown }>; +}; + +const ARGS = JSON.stringify({ metric: "bp", window: "last30days" }); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function sse(events: unknown[]): Response { + const text = events + .map((e) => `data: ${JSON.stringify(e)}\n\n`) + .concat("data: [DONE]\n\n") + .join(""); + return new Response( + new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode(text)); + c.close(); + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); +} + +/** Run a turn that calls one tool and then hits the round cap. */ +async function capTurn( + client: { + generateCompletion(p: CompletionParams): Promise; + }, + providerType: string, + extra: { reasoning?: CompletionParams["reasoning"] } = {}, +) { + runRawCompletionWithFallback.mockImplementation( + async ({ params }: { params: CompletionParams }) => ({ + result: await client.generateCompletion({ ...params, ...extra }), + workingProvider: { providerType }, + fallbackHops: [], + }), + ); + return runCoachToolLoop({ + userId: "u1", + providers: [], + system: "sys", + messages: [{ role: "user", content: "How is my blood pressure?" }], + tools: COACH_TOOL_DEFS, + reach: UNBOUNDED_REACH, + budget: createTurnBudget({ + payer: "operator", + initialInputTokens: 10, + limits: { tokens: 1e9, wallMs: 1e9, maxRounds: 2 }, + }), + }); +} + +function bodies(mock: ReturnType): Body[] { + return mock.mock.calls.map( + (c) => JSON.parse((c[1] as { body: string }).body) as Body, + ); +} + +beforeEach(() => { + vi.restoreAllMocks(); + executeCoachTool.mockReset(); + runRawCompletionWithFallback.mockReset(); + executeCoachTool.mockResolvedValue({ present: true, data: { mean: 128 } }); + clearCodexSlugCache(); + resetReasoningDialectCache(); +}); + +describe("the forced final round on each provider wire", () => { + it("Anthropic: keeps the tools beside tool_use history, tool_choice none, thinking on", async () => { + const upstream = vi.fn(async (_url: string, init: { body: string }) => { + const body = JSON.parse(init.body) as Body; + const blocks = (body.messages ?? []).flatMap((m) => + Array.isArray(m.content) ? (m.content as Array<{ type: string }>) : [], + ); + const toolHistory = blocks.some( + (b) => b.type === "tool_use" || b.type === "tool_result", + ); + if (toolHistory && !body.tools) { + return json( + { + type: "error", + error: { + type: "invalid_request_error", + message: + "Requests which include `tool_use` or `tool_result` blocks must define tools.", + }, + }, + 400, + ); + } + const choice = (body.tool_choice as { type?: string } | undefined)?.type; + if (body.thinking && choice && choice !== "auto" && choice !== "none") { + return json( + { + type: "error", + error: { + type: "invalid_request_error", + message: + "Thinking may not be enabled when tool_choice forces tool use.", + }, + }, + 400, + ); + } + if (!toolHistory) { + return json({ + content: [ + { type: "thinking", thinking: "Look it up.", signature: "sig-1" }, + { + type: "tool_use", + id: "toolu_1", + name: "get_metric_table", + input: JSON.parse(ARGS), + }, + ], + stop_reason: "tool_use", + usage: { input_tokens: 10, output_tokens: 5 }, + }); + } + return json({ + content: [{ type: "text", text: "Your readings average 128." }], + stop_reason: "end_turn", + usage: { input_tokens: 20, output_tokens: 6 }, + }); + }); + vi.stubGlobal("fetch", upstream); + const client = new AnthropicClient({ + apiKey: "sk-ant-test", + model: "claude-opus-4-7", + }); + + const out = await capTurn(client, "anthropic", { + reasoning: { effort: "medium", summaries: true }, + }); + + expect(out.result.content).toBe("Your readings average 128."); + expect(out.stop).toEqual({ reason: "cap", rounds: 2 }); + const final = bodies(upstream)[1]; + expect(final.tools).toHaveLength(COACH_TOOL_DEFS.length); + expect(final.tool_choice).toEqual({ type: "none" }); + expect(final.thinking).toMatchObject({ type: "adaptive" }); + }); + + it("OpenAI: sends tool_choice none only beside the tools", async () => { + const upstream = vi.fn(async (_url: string, init: { body: string }) => { + const body = JSON.parse(init.body) as Body; + if (body.tool_choice !== undefined && !body.tools) { + return json( + { + error: { + message: + "Invalid value for 'tool_choice': 'tool_choice' is only allowed when 'tools' are specified.", + type: "invalid_request_error", + param: "tool_choice", + code: null, + }, + }, + 400, + ); + } + const round = (body.messages ?? []).length; + return json( + round <= 2 + ? { + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_metric_table", arguments: ARGS }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { total_tokens: 30 }, + } + : { + choices: [ + { + message: { + role: "assistant", + content: "Your readings average 128.", + }, + finish_reason: "stop", + }, + ], + usage: { total_tokens: 40 }, + }, + ); + }); + vi.stubGlobal("fetch", upstream); + const client = new OpenAIClient({ + apiKey: "sk-test", + model: "gpt-4.1", + baseUrl: "https://api.openai.com/v1", + }); + + const out = await capTurn(client, "admin-key"); + + expect(out.result.content).toBe("Your readings average 128."); + const final = bodies(upstream)[1]; + expect(final.tools).toHaveLength(COACH_TOOL_DEFS.length); + expect(final.tool_choice).toBe("none"); + }); + + it("Codex: sends the tool definitions with tool_choice none", async () => { + let n = 0; + const upstream = vi.fn(async () => { + n += 1; + return n === 1 + ? sse([ + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_1", + name: "get_metric_table", + arguments: ARGS, + }, + }, + { type: "response.completed", response: { usage: {} } }, + ]) + : sse([ + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Your readings average 128." }, + ], + }, + }, + { type: "response.completed", response: { usage: {} } }, + ]); + }); + vi.stubGlobal("fetch", upstream); + const client = new CodexClient({ + accessToken: "t", + accountId: "a", + onTokenRefresh: vi.fn(), + slugChain: ["gpt-5.5"], + }); + + const out = await capTurn(client, "codex"); + + expect(out.result.content).toBe("Your readings average 128."); + const final = bodies(upstream)[1]; + expect(final.tools).toHaveLength(COACH_TOOL_DEFS.length); + expect(final.tool_choice).toBe("none"); + }); + + it("Local: never sends tools or tool_choice, final round included", async () => { + const upstream = vi.fn(async () => + json({ + choices: [ + { + message: { + role: "assistant", + content: "Your readings average 128.", + }, + finish_reason: "stop", + }, + ], + usage: { total_tokens: 20 }, + }), + ); + vi.stubGlobal("fetch", upstream); + const client = new LocalOpenAICompatibleClient({ + model: "llama3", + baseUrl: "http://localhost:11434/v1", + }); + const params: CompletionParams = { + system: "sys", + messages: [{ role: "user", content: "u" }], + tools: COACH_TOOL_DEFS, + toolChoice: "none", + }; + + const result = await client.generateCompletion(params); + + expect(result.content).toBe("Your readings average 128."); + const sent = bodies(upstream)[0]; + expect(sent).not.toHaveProperty("tools"); + expect(sent).not.toHaveProperty("tool_choice"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 011fe3cd7..b6130c0b3 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -135,7 +135,7 @@ describe("runCoachToolLoop", () => { it("forces a final answer at the round cap (no infinite loop)", async () => { executeCoachTool.mockResolvedValue({ present: true, data: { v: 1 } }); // The model asks for a new read every round. The loop must still - // terminate: the round the cap allows last offers no tools + // terminate: the round the cap allows last forbids tool calls // (toolChoice none), so the model is forced to produce prose. const metrics = ["bp", "weight", "pulse", "hrv", "steps", "sleep"]; let n = 0; @@ -174,13 +174,16 @@ describe("runCoachToolLoop", () => { expect(out.rounds).toBe(6); expect(out.result.content).toBe("Final forced answer."); expect(out.stop).toEqual({ reason: "cap", rounds: 6 }); - // The final round must have been called with toolChoice "none". + // The final round forbids calls with toolChoice "none" but still + // carries the tool definitions: the history holds tool_use/tool_result + // blocks, and Anthropic refuses those without `tools` (400). const lastCall = runRawCompletionWithFallback.mock.calls[ runRawCompletionWithFallback.mock.calls.length - 1 ][0]; expect(lastCall.params.toolChoice).toBe("none"); - expect(lastCall.params.tools).toBeUndefined(); + expect(lastCall.params.tools).toEqual(COACH_TOOL_DEFS); + expect(lastCall.params.tools.length).toBeGreaterThan(0); }); it("appends assistant(toolCalls) + tool turns to the message array", async () => { diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 74ef7e9c6..f784a9096 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -7,9 +7,14 @@ * token, time and round limits of whoever pays for it (`turn-budget.ts`), a * no-progress brake (`progress.ts`) and, round by round, the day's ledger * (`spend`, reserved before each round and settled after it). Whichever of - * them stops the loop, the next round is the final one: sent without tools, - * with one line telling the model why, and the room for it was held back - * from the start, so a turn always ends with an answer. + * them stops the loop, the next round is the final one: sent with + * `toolChoice: "none"`, with one line telling the model why, and the room + * for it was held back from the start, so a turn always ends with an answer. + * The final round still carries the tool definitions: the history holds the + * earlier calls and their results, and Anthropic refuses a request with + * `tool_use` / `tool_result` blocks that defines no tools (400). `none` with + * the tools present is valid on every wire, thinking included, and keeps the + * cached tools prefix warm. * * Each round is a NON-streaming completion over the fallback chain; the * clients read the provider's stream internally where it has one and report @@ -344,9 +349,8 @@ export async function runCoachToolLoop(args: { }, } : {}), - ...(isFinal - ? { toolChoice: "none" as const } - : { tools, toolChoice: "auto" as const }), + tools, + toolChoice: isFinal ? ("none" as const) : ("auto" as const), }, }); const result = fallback.result; diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 863ad36ac..8a4fe0fdf 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -13,6 +13,7 @@ import type { } from "./types"; import { annotateReasoningDowngrade, + isReasoningParameterRejection, learnedReasoningDialect, rememberReasoningDialect, } from "./reasoning/dialect-cache"; @@ -335,6 +336,14 @@ function planCodexReasoning( }; } +/** + * True when the request asks for the encrypted reasoning items, the only + * reasoning state a `store: false` request can hand to the next round. + */ +function asksEncryptedReasoning(wire: CodexReasoningWire): boolean { + return wire !== null && wire.effort !== "none"; +} + /** * Read a 400 that refuses a reasoning parameter and learn what the slug * accepts instead. Returns the lesser wire to retry with, or `undefined` @@ -346,9 +355,10 @@ function learnFromReasoningRejection( status: number, body: string, ): CodexReasoningWire | undefined { - // Only a body that names the reasoning parameter counts; a 400 about the - // prompt or a tool must not teach the slug anything. - if (status !== 400 || !/reasoning/i.test(body)) { + // Only a 400 that refuses the reasoning parameter counts; a 400 about the + // prompt or a tool, and a replay error about a reasoning item handed back + // from an earlier round, must not teach the slug anything. + if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) { return undefined; } const dialect = readCodexDialect(slug); @@ -706,10 +716,9 @@ export class CodexClient implements AIProvider { // Required field; empty array when not asking for reasoning. With // reasoning on, the encrypted items come back so the next tool // round can hand them over statelessly (`store` must stay false). - include: - wire && wire.effort !== "none" - ? ["reasoning.encrypted_content"] - : [], + include: asksEncryptedReasoning(wire) + ? ["reasoning.encrypted_content"] + : [], }), // SSE streaming completion — match the 60 s budget the other AI // clients use so a long generation is not clipped by the 15 s @@ -775,6 +784,7 @@ export class CodexClient implements AIProvider { // the receiver screens them. const emit = params.reasoning ? params.onReasoning : undefined; const reasoningItems: Record[] = []; + const keepsReasoningItems = asksEncryptedReasoning(wire); const summaries: string[] = []; let reasoningTokens: number | null = null; const summaryBuffers = new Map(); @@ -882,7 +892,17 @@ export class CodexClient implements AIProvider { if (text) assembledText += text; } else if (item?.type === "reasoning" && params.reasoning) { // The whole item, untouched: the next round hands it back. - reasoningItems.push(item as Record); + // Only an item that carries its encrypted content, and only + // when this request asked for it: under `store: false` the + // server keeps nothing, so a replayed item without it points + // at nothing and the next round fails with a 400. + if ( + keepsReasoningItems && + typeof (item as { encrypted_content?: unknown }) + .encrypted_content === "string" + ) { + reasoningItems.push(item as Record); + } (item.summary ?? []).forEach((part, index) => { if (typeof part.text !== "string" || !part.text) return; summaries.push(part.text); diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 2f6ef99e6..3c3a5f184 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -26,6 +26,7 @@ import { import type { ReasoningEffort } from "./reasoning-effort"; import { annotateReasoningDowngrade, + isReasoningParameterRejection, learnedReasoningDialect, rememberReasoningDialect, } from "./reasoning/dialect-cache"; @@ -348,9 +349,12 @@ export class LocalOpenAICompatibleClient implements AIProvider { const sentEffort = this.callReasoningEffort(params); if ( sentEffort && - res.status >= 400 && - res.status < 500 && - /reasoning|think/i.test(bodyExcerpt) + isReasoningParameterRejection( + res.status, + bodyExcerpt, + /reasoning|think/i, + this.config.model, + ) ) { rememberReasoningDialect(this.reasoningDialectKey(), "none"); annotateReasoningDowngrade("local", sentEffort, "none"); diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index e8acd00bd..227613d67 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -8,6 +8,7 @@ import { } from "./openai-capabilities"; import { annotateReasoningDowngrade, + isReasoningParameterRejection, learnedReasoningDialect, rememberReasoningDialect, } from "./reasoning/dialect-cache"; @@ -144,10 +145,10 @@ export class OpenAIClient implements AIProvider { // Only force OpenAI's strict JSON mode when the caller actually consumes a // JSON object AND no tools are in play — mirrors the Anthropic client's // `usePrefill` gate. JSON mode coerces `message.content` into a valid JSON - // object, which contradicts the Coach prose contract; the F1 tool loop's - // forced-final round (toolChoice:"none", no tools, no responseFormat) must - // therefore stay out of JSON mode, and every tool round is non-JSON by - // construction. Insight/extraction callers opt in with `responseFormat:"json"`. + // object, which contradicts the Coach prose contract; the tool loop's + // forced-final round (tools with toolChoice:"none", no responseFormat) + // must therefore stay out of JSON mode, and every tool round is non-JSON + // by construction. Insight/extraction callers opt in with `responseFormat:"json"`. const useJsonFormat = params.responseFormat === "json" && !hasTools; // v1.33.1 (#470) — a gateway may reject the standard `response_format` // field. Learn that per endpoint (shared cache with the Local client) and @@ -268,9 +269,12 @@ export class OpenAIClient implements AIProvider { // naming the reasoning field must not switch JSON mode off. if ( reasoningWire.kind !== "none" && - res.status >= 400 && - res.status < 500 && - /reasoning|thinking/i.test(bodyExcerpt) + isReasoningParameterRejection( + res.status, + bodyExcerpt, + /reasoning|thinking/i, + this.config.model, + ) ) { rememberReasoningDialect(this.reasoningDialectKey(), "none"); annotateReasoningDowngrade(this.type, reasoningWire.effort, "none"); @@ -466,8 +470,10 @@ export class OpenAIClient implements AIProvider { * `api.openai.com` (operator key, personal key): `reasoning_effort` only for * a reasoning model, and only on a round without tools. Chat Completions * has no reasoning items to hand back between tool rounds, and newer - * models restrict reasoning alongside function calls; the Coach's final - * round and the background jobs carry no tools, which is where it counts. + * models restrict reasoning alongside function calls. The background jobs + * carry no tools, which is where it counts; the Coach's final round + * carries its tools with `tool_choice: "none"` (the history holds the + * calls), so here it runs without `reasoning_effort` like its tool rounds. * * Gateway: the endpoint's own dialect, tools or not; its reasoning state * travels in `providerState`, and a refusal is learned per endpoint. diff --git a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts index 857a16cbf..37014bf91 100644 --- a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts +++ b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts @@ -10,6 +10,8 @@ vi.mock("@/lib/logging/context", () => ({ import { annotateReasoningDowngrade, + DIALECT_TTL_MS, + isReasoningParameterRejection, learnedReasoningDialect, rememberReasoningDialect, resetReasoningDialectCache, @@ -49,4 +51,101 @@ describe("reasoning dialect cache", () => { }, ]); }); + + it("forgets a learned value after the TTL, and a re-learn restarts it", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const key = { provider: "openai", endpoint: "http://g/v1", model: "m" }; + rememberReasoningDialect(key, "none"); + vi.advanceTimersByTime(DIALECT_TTL_MS - 1); + expect(learnedReasoningDialect(key)).toBe("none"); + vi.advanceTimersByTime(1); + expect(learnedReasoningDialect(key)).toBeUndefined(); + rememberReasoningDialect(key, "none"); + vi.advanceTimersByTime(DIALECT_TTL_MS / 2); + expect(learnedReasoningDialect(key)).toBe("none"); + expect(DIALECT_TTL_MS).toBe(60 * 60 * 1000); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("isReasoningParameterRejection", () => { + const REASONING = /reasoning|thinking/i; + const learns = ( + status: number, + message: string, + model = "m", + param: string | null = null, + ) => + isReasoningParameterRejection( + status, + JSON.stringify({ error: { message, param } }), + REASONING, + model, + ); + + it.each([ + "Unrecognized request argument supplied: reasoning_effort", + "reasoning_effort: Extra inputs are not permitted", + "Reasoning is not supported for this model.", + "thinking.type.enabled is not supported for this model.", + "thinking.type: Input tag 'adaptive' found using 'type' does not match any of the expected tags: 'disabled', 'enabled'", + "Invalid value for 'reasoning.summary'.", + ])("learns from a 400 that refuses the parameter: %s", (message) => { + expect(learns(400, message)).toBe(true); + }); + + it("learns from a refusal that names the parameter only in `param`", () => { + expect( + learns( + 400, + "Unsupported value: 'minimal' is not supported with the 'gpt-5.5' model.", + "gpt-5.5", + "reasoning.effort", + ), + ).toBe(true); + }); + + it.each([401, 403, 404, 422, 429, 500, 503])( + "never learns from HTTP %i", + (status) => { + expect(learns(status, "reasoning_effort is not supported")).toBe(false); + }, + ); + + it.each([ + "Expected `thinking` or `redacted_thinking`, but found `text`. When `thinking` is enabled, a final `assistant` message must start with a thinking block.", + "messages.1.content.0: Invalid `signature` in `thinking` block", + "Item with id 'rs_1' not found. Items are not persisted when `store` is set to false.", + "Item 'rs_1' of type 'reasoning' was provided without its required following item.", + "The encrypted content for item rs_1 could not be verified; reasoning is not supported for replay.", + ])("never learns from a replay error: %s", (message) => { + expect(learns(400, message)).toBe(false); + }); + + it("never learns from a model name that merely contains the word", () => { + expect( + learns( + 400, + "The model `deepseek-reasoner` does not support this request format.", + "deepseek-reasoner", + ), + ).toBe(false); + expect( + learns( + 400, + "qwen3-thinking is not supported on this server.", + "qwen3-thinking", + ), + ).toBe(false); + }); + + it("never learns from a 400 about something else", () => { + expect(learns(400, "messages: too long")).toBe(false); + expect(learns(400, "Invalid schema for function 'get_weather'.")).toBe( + false, + ); + }); }); diff --git a/src/lib/ai/reasoning/dialect-cache.ts b/src/lib/ai/reasoning/dialect-cache.ts index c9024da09..37c85bf4f 100644 --- a/src/lib/ai/reasoning/dialect-cache.ts +++ b/src/lib/ai/reasoning/dialect-cache.ts @@ -1,6 +1,8 @@ /** * v1.41 — what an endpoint answered when it was asked to reason, learned at - * runtime and kept for the life of the process. + * runtime and kept for an hour (`DIALECT_TTL_MS`), so an endpoint that + * gains the parameter, or a downgrade learned from a misread error, heals + * without a restart. * * Every client that sends reasoning parameters to a wire it does not control * (a gateway, a local server, a model name the client has never seen, a Codex @@ -16,6 +18,13 @@ * strings each client defines for itself; nothing outside the client reads * them. * + * Only an HTTP 400 that refuses one of the parameters the client sent may + * teach anything (`isReasoningParameterRejection`): never an auth, rate-limit + * or server error, never a body that merely mentions a model whose name + * contains "reasoner" or "thinking", and never a replay error about the + * reasoning state handed back from an earlier round, which says nothing + * about what the endpoint accepts. + * * Pattern borrowed from `../json-dialect.ts`, which learns the JSON-mode flag * the same way. Server-only: the provider clients are its only importers. */ @@ -29,7 +38,10 @@ export interface ReasoningDialectKey { model: string; } -const learned = new Map(); +/** How long a learned downgrade holds before the full request is tried again. */ +export const DIALECT_TTL_MS = 60 * 60 * 1000; + +const learned = new Map(); function cacheKey(key: ReasoningDialectKey): string { return `${key.provider}\u0000${key.endpoint}\u0000${key.model}`; @@ -39,7 +51,14 @@ function cacheKey(key: ReasoningDialectKey): string { export function learnedReasoningDialect( key: ReasoningDialectKey, ): string | undefined { - return learned.get(cacheKey(key)); + const k = cacheKey(key); + const entry = learned.get(k); + if (!entry) return undefined; + if (Date.now() >= entry.expiresAt) { + learned.delete(k); + return undefined; + } + return entry.value; } /** Record what `key` accepts after it refused a richer request. */ @@ -47,7 +66,41 @@ export function rememberReasoningDialect( key: ReasoningDialectKey, value: string, ): void { - learned.set(cacheKey(key), value); + learned.set(cacheKey(key), { value, expiresAt: Date.now() + DIALECT_TTL_MS }); +} + +/** + * Errors about the reasoning state an earlier round handed back (a thinking + * block missing or altered, an encrypted reasoning item the server cannot + * read or no longer has). They name the reasoning vocabulary but say nothing + * about the parameters the endpoint accepts. + */ +const REPLAY_ERROR = + /thinking block|redacted_thinking|signature|encrypted[_ ]content|item with id|of type 'reasoning'|required following item|previous_response|not persisted/i; + +/** Wording an endpoint uses when it refuses a parameter or one of its values. */ +const PARAMETER_REFUSAL = + /unsupported|not supported|does not support|doesn't support|unrecognized|unrecognised|unknown (?:parameter|field|argument|key)|extra (?:inputs|fields|arguments)|not permitted|not allowed|invalid value|does not match any of the expected|does not accept|not accepted|cannot be used|unexpected (?:keyword|field|parameter|argument)/i; + +/** + * True when `status` and `body` say the endpoint refused a reasoning (or + * sampling) parameter the client sent: an HTTP 400 whose body names one of + * `params` together with refusal wording, and which is not a replay error. + * The model name is cut out of the body first, so a model called + * `deepseek-reasoner` or `qwen3-thinking` cannot supply the parameter match. + */ +export function isReasoningParameterRejection( + status: number, + body: string, + params: RegExp, + model: string, +): boolean { + if (status !== 400 || !body) return false; + // Only a model name that could itself supply the match is cut; a short + // name ("m", "o3") must not take letters out of the wording. + const text = model && params.test(model) ? body.split(model).join(" ") : body; + if (REPLAY_ERROR.test(text)) return false; + return params.test(text) && PARAMETER_REFUSAL.test(text); } /** Test hook: forget everything learned. */