Files
HealthLog/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts
T
Marc-André Bombeck 1acd41eb58 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.
2026-10-06 21:50:19 +02:00

152 lines
4.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const annotations = vi.hoisted(
() => [] as Array<{ action?: unknown; meta?: Record<string, unknown> }>,
);
vi.mock("@/lib/logging/context", () => ({
annotate: (fields: { action?: unknown; meta?: Record<string, unknown> }) =>
annotations.push(fields),
}));
import {
annotateReasoningDowngrade,
DIALECT_TTL_MS,
isReasoningParameterRejection,
learnedReasoningDialect,
rememberReasoningDialect,
resetReasoningDialectCache,
} from "../dialect-cache";
beforeEach(() => {
resetReasoningDialectCache();
annotations.length = 0;
});
describe("reasoning dialect cache", () => {
it("keys on provider, endpoint and model together", () => {
const key = { provider: "local", endpoint: "http://a/v1", model: "m1" };
rememberReasoningDialect(key, "none");
expect(learnedReasoningDialect(key)).toBe("none");
expect(learnedReasoningDialect({ ...key, model: "m2" })).toBeUndefined();
expect(
learnedReasoningDialect({ ...key, endpoint: "http://b/v1" }),
).toBeUndefined();
expect(
learnedReasoningDialect({ ...key, provider: "openai" }),
).toBeUndefined();
resetReasoningDialectCache();
expect(learnedReasoningDialect(key)).toBeUndefined();
});
it("marks a downgrade with meta keys only, never the action", () => {
annotateReasoningDowngrade("codex", "high", "medium");
expect(annotations).toEqual([
{
meta: {
ai_reasoning_downgraded: true,
ai_reasoning_downgraded_provider: "codex",
ai_reasoning_downgraded_from: "high",
ai_reasoning_downgraded_to: "medium",
},
},
]);
});
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,
);
});
});