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. */