Merge remote-tracking branch 'origin/release/v1.41.0' into v141/b-loop

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:39:06 +02:00
commit 88b2bf13b0
30 files changed
+3935 -101

No files matched your search

@@ -3,11 +3,15 @@
* the setting the resolver bound to the provider instance.
*
* Default must send nothing, so the key may not appear on any wire except as
* the bound value, and only the two clients whose endpoint a person chose may
* carry it. Pinned here:
* the bound value or the call's own resolved level, and only the two clients
* whose endpoint a person chose may carry it. Pinned here:
*
* 1. the key appears in exactly the Local client and the OpenAI client,
* once each, and only as `reasoning_effort: this.reasoningEffort`;
* twice each: once as `reasoning_effort: this.reasoningEffort`, sent
* only when the call carries no level of its own (`!params.reasoning`),
* and once as the call's level (v1.41 `CompletionParams.reasoning`,
* resolved upstream against the person's choice and the operator's
* cap), never as a literal;
* 2. nothing but the binder assigns `.reasoningEffort`;
* 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`.
*
@@ -20,6 +24,7 @@
* the behaviour on the wire.
*
* Mutation check: hard-code `reasoning_effort: "none"` into either client,
* drop the `!params.reasoning` condition in front of the bound value,
* set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort`
* from `resolveProviderForTest`: each one goes red here.
*/
@@ -60,13 +65,17 @@ describe("reasoning_effort on the wire", () => {
});
it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])(
"%s sends it once, as the bound value",
"%s sends the bound value only without a call level, and never a literal",
(rel) => {
const source = code(rel);
expect(source.match(new RegExp(KEY))?.length).toBe(1);
expect(source.match(new RegExp(KEY))?.length).toBe(2);
expect(source).toMatch(
/\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/,
);
expect(source).toMatch(
/!\s*params\s*\.\s*reasoning\s*&&\s*(?:this\s*\.\s*isGateway\s*&&\s*)?this\s*\.\s*reasoningEffort\b/,
);
expect(source).not.toMatch(/\breasoning_effort["']?\s*:\s*["'`]/);
},
);
});
@@ -72,9 +72,10 @@ describe("AnthropicClient", () => {
},
]);
expect(body.messages).toEqual([expect.objectContaining({ role: "user" })]);
// User message carries the original text plus the JSON-coercion instruction.
expect(body.messages[0].content).toContain("Analyze this data.");
expect(body.messages[0].content.toLowerCase()).toContain("json");
// v1.41 — no `responseFormat: "json"`, no JSON instruction: the user
// message goes out as written. (It used to be appended to every request
// without tools, prose included.)
expect(body.messages[0].content).toBe("Analyze this data.");
});
it("sends no assistant turn for JSON surfaces and instructs the last user turn", async () => {
@@ -0,0 +1,492 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AnthropicClient } from "../anthropic-client";
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
import type {
AiMessage,
AiReasoningEvent,
AiToolDef,
CompletionParams,
CompletionResult,
} from "../types";
// safeFetch's requirePublicHost path runs through undici's own `fetch`.
// Delegate it to the global stub these tests install.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
/**
* v1.41 — Anthropic thinking at the wire. The response fixtures follow the
* documented Messages API shapes (thinking blocks with `signature`,
* `redacted_thinking` with `data`, interleaved text and tool_use):
* https://platform.claude.com/docs/en/build-with-claude/extended-thinking.
* No health data; signatures are placeholders.
*/
const FIXTURES = join(__dirname, "fixtures/reasoning");
const fixture = (name: string) => readFileSync(join(FIXTURES, name), "utf8");
const ok = (name: string) => async () =>
new Response(fixture(name), {
status: 200,
headers: { "content-type": "application/json" },
});
const fail = (status: number, name: string) => async () =>
new Response(fixture(name), { status });
const TOOLS: AiToolDef[] = [
{
name: "get_weather",
description: "Weather for a city",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
];
function params(extra: Partial<CompletionParams> = {}): CompletionParams {
return {
system: "You are terse.",
messages: [{ role: "user", content: "Weather in the capital of France?" }],
maxTokens: 600,
...extra,
};
}
function sent(mock: ReturnType<typeof vi.fn>, call = 0) {
const init = mock.mock.calls[call][1];
return { body: JSON.parse(init.body), headers: init.headers };
}
const client = (model: string) =>
new AnthropicClient({ apiKey: "sk-ant-test-key", model });
beforeEach(() => {
vi.restoreAllMocks();
resetReasoningDialectCache();
});
describe("Anthropic thinking dialects", () => {
it("keeps today's wire when no reasoning is asked, on a model that takes sampling", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-6").generateCompletion(params());
const { body, headers } = sent(mock);
expect(body.temperature).toBe(0.3);
expect(body.max_tokens).toBe(600);
expect(body).not.toHaveProperty("thinking");
expect(body).not.toHaveProperty("output_config");
expect(headers).not.toHaveProperty("anthropic-beta");
});
it("never sends temperature from 4.7 on, reasoning or not", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-opus-5").generateCompletion(params());
await client("claude-opus-4-7").generateCompletion(params());
await client("claude-sonnet-5").generateCompletion(params());
for (const call of [0, 1, 2]) {
expect(sent(mock, call).body).not.toHaveProperty("temperature");
}
});
it("asks a 4.5 model for a thinking budget and interleaves on tool rounds", async () => {
const mock = vi
.fn()
.mockImplementation(ok("anthropic-thinking-tool-round.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-5").generateCompletion(
params({
tools: TOOLS,
toolChoice: "auto",
reasoning: { effort: "medium", summaries: true },
}),
);
const { body, headers } = sent(mock);
expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 4096 });
// budget_tokens must stay below max_tokens: answer + budget.
expect(body.max_tokens).toBe(600 + 4096);
expect(body).not.toHaveProperty("temperature");
expect(body).not.toHaveProperty("output_config");
expect(body.tool_choice).toEqual({ type: "auto" });
expect(headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14");
});
it("asks 4.6 and later for adaptive thinking with an effort", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
for (const model of [
"claude-sonnet-4-6",
"claude-opus-4-7",
"claude-opus-5",
]) {
await client(model).generateCompletion(
params({
tools: TOOLS,
reasoning: { effort: "high", summaries: true },
}),
);
}
for (const call of [0, 1, 2]) {
const { body, headers } = sent(mock, call);
expect(body.thinking).toEqual({
type: "adaptive",
display: "summarized",
});
expect(body.output_config).toEqual({ effort: "high" });
expect(body.max_tokens).toBe(600 + 12_000);
expect(body).not.toHaveProperty("temperature");
expect(headers).not.toHaveProperty("anthropic-beta");
}
});
it("switches off where it can and falls to low effort where thinking is always on", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const off = { reasoning: { effort: "off" as const, summaries: true } };
await client("claude-opus-4-7").generateCompletion(params(off));
await client("claude-sonnet-4-5").generateCompletion(params(off));
await client("claude-opus-5").generateCompletion(params(off));
expect(sent(mock, 0).body).not.toHaveProperty("thinking");
expect(sent(mock, 0).body).not.toHaveProperty("temperature");
expect(sent(mock, 1).body).not.toHaveProperty("thinking");
expect(sent(mock, 1).body.temperature).toBe(0.3);
expect(sent(mock, 2).body.thinking).toEqual({
type: "adaptive",
display: "summarized",
});
expect(sent(mock, 2).body.output_config).toEqual({ effort: "low" });
});
it("does not ask a model that cannot think", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-3-5-sonnet-latest").generateCompletion(
params({ reasoning: { effort: "high", summaries: true } }),
);
expect(sent(mock).body).not.toHaveProperty("thinking");
expect(sent(mock).body.temperature).toBe(0.3);
});
});
describe("Anthropic thinking replies", () => {
it("returns the summary after the reply and keeps the whole reply as state", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(ok("anthropic-thinking-tool-round.json")),
);
const events: AiReasoningEvent[] = [];
const result = await client("claude-sonnet-4-5").generateCompletion(
params({
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
onReasoning: (e) => events.push(e),
}),
);
const thought =
"**Choosing the city**\nThe capital of France is Paris, so the weather lookup needs Paris.";
expect(events).toEqual([
{ kind: "title", text: "Choosing the city" },
{ kind: "text", text: thought },
{ kind: "done", text: "" },
]);
expect(result.reasoning).toEqual({ summary: [thought], tokens: null });
expect(result.content).toBe("Let me look that up.");
expect(result.finishReason).toBe("tool_calls");
expect(result.providerState).toEqual({
providerType: "anthropic",
model: "claude-sonnet-4-5",
items: JSON.parse(fixture("anthropic-thinking-tool-round.json")).content,
});
});
it("joins every text block, not just the first", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(ok("anthropic-after-tool.json")),
);
const result = await client("claude-sonnet-4-5").generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(result.content).toBe("Paris is at 18 °C and cloudy.");
// A redacted block has no readable text, but it is state.
expect(result.reasoning?.summary).toEqual([]);
expect(result.providerState?.items[0]).toEqual({
type: "redacted_thinking",
data: "redacted-fixture-b1",
});
});
it("no longer tells a prose request to answer in JSON", async () => {
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-6").generateCompletion(params());
await client("claude-sonnet-4-6").generateCompletion(
params({ responseFormat: "json" }),
);
expect(sent(mock, 0).body.messages[0].content).toBe(
"Weather in the capital of France?",
);
expect(sent(mock, 1).body.messages[0].content).toMatch(
/single JSON object/,
);
});
});
describe("Anthropic thinking round trip", () => {
async function firstRound(
model = "claude-sonnet-4-5",
): Promise<CompletionResult> {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(ok("anthropic-thinking-tool-round.json")),
);
return client(model).generateCompletion(
params({
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
}
function next(
first: CompletionResult,
state = first.providerState,
): AiMessage[] {
return [
{ role: "user", content: "Weather in the capital of France?" },
{
role: "assistant",
content: first.content,
toolCalls: first.toolCalls,
providerState: state,
},
{
role: "tool",
toolCallId: first.toolCalls![0].id,
content: '{"tempC":18}',
},
];
}
it("hands the assistant reply back unchanged, signature included", async () => {
const first = await firstRound();
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
await client("claude-sonnet-4-5").generateCompletion(
params({
messages: next(first),
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
const { body } = sent(mock);
expect(body.messages[1]).toEqual({
role: "assistant",
content: JSON.parse(fixture("anthropic-thinking-tool-round.json"))
.content,
});
expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 4096 });
});
it("leads with the thinking blocks when the turn's calls changed", async () => {
const first = await firstRound();
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const messages = next(first);
messages[1] = {
...messages[1],
toolCalls: [{ id: "toolu_other", name: "get_weather", arguments: "{}" }],
};
messages[2] = { ...messages[2], toolCallId: "toolu_other" };
await client("claude-sonnet-4-5").generateCompletion(
params({
messages,
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
const content = sent(mock).body.messages[1].content;
expect(content.map((b: { type: string }) => b.type)).toEqual([
"thinking",
"text",
"tool_use",
]);
expect(content[0].signature).toBe("sig-fixture-a1");
expect(content[2].id).toBe("toolu_other");
});
it("turns thinking off for a tool round another provider started", async () => {
const first = await firstRound();
const mock = vi.fn().mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const fromCodex = {
providerType: "codex" as const,
model: "gpt-5.5",
items: [{ type: "reasoning" }],
};
const result = await client("claude-sonnet-4-5").generateCompletion(
params({
messages: next(first, fromCodex),
tools: TOOLS,
reasoning: { effort: "medium", summaries: true },
}),
);
const { body, headers } = sent(mock);
expect(body).not.toHaveProperty("thinking");
expect(headers).not.toHaveProperty("anthropic-beta");
// The foreign state never reaches the wire.
expect(JSON.stringify(body)).not.toContain('"reasoning"');
expect(
body.messages[1].content.map((b: { type: string }) => b.type),
).toEqual(["text", "tool_use"]);
// Not a refusal by the provider: no downgrade reported.
expect(result.reasoning?.downgradedTo).toBeUndefined();
});
});
describe("Anthropic thinking refusals", () => {
it("falls from adaptive to a budget on an unknown name, and remembers", async () => {
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 result = await c.generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(sent(mock, 0).body.thinking.type).toBe("adaptive");
expect(sent(mock, 1).body.thinking).toEqual({
type: "enabled",
budget_tokens: 1024,
});
expect(sent(mock, 1).body).not.toHaveProperty("output_config");
expect(result.content).toBe("Paris is at 18 °C and cloudy.");
expect(result.reasoning?.downgradedTo).toBeUndefined();
await c.generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(sent(mock, 2).body.thinking.type).toBe("enabled");
});
it("answers without thinking when every form is refused", async () => {
const mock = vi
.fn()
.mockImplementationOnce(fail(400, "anthropic-adaptive-rejected.json"))
.mockImplementationOnce(fail(400, "anthropic-adaptive-rejected.json"))
.mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const result = await client("my-claude-proxy").generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(sent(mock, 2).body).not.toHaveProperty("thinking");
expect(result.reasoning?.downgradedTo).toBe("off");
});
it("drops temperature an unknown model refuses, and remembers", async () => {
const mock = vi
.fn()
.mockImplementationOnce(fail(400, "anthropic-temperature-rejected.json"))
.mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const c = client("my-claude-proxy");
await c.generateCompletion(params());
expect(sent(mock, 0).body.temperature).toBe(0.3);
expect(sent(mock, 1).body).not.toHaveProperty("temperature");
await c.generateCompletion(params());
expect(mock).toHaveBeenCalledTimes(3);
expect(sent(mock, 2).body).not.toHaveProperty("temperature");
});
it("raises a 400 that is about something else, once", async () => {
const mock = vi.fn().mockImplementation(
async () =>
new Response(
JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message: "messages: too long",
},
}),
{ status: 400 },
),
);
vi.stubGlobal("fetch", mock);
await expect(
client("claude-opus-5").generateCompletion(
params({ reasoning: { effort: "high", summaries: true } }),
),
).rejects.toMatchObject({ httpStatus: 400, upstream: "anthropic" });
expect(mock).toHaveBeenCalledTimes(1);
});
});
describe("Anthropic adaptive rounds that chose not to think", () => {
it("keeps thinking on for the next round of its own turn", async () => {
const noThought = {
id: "msg_fixture_03",
type: "message",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_fixture_02",
name: "get_weather",
input: { city: "Paris" },
},
],
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20 },
};
const mock = vi
.fn()
.mockImplementationOnce(
async () => new Response(JSON.stringify(noThought), { status: 200 }),
)
.mockImplementation(ok("anthropic-after-tool.json"));
vi.stubGlobal("fetch", mock);
const c = client("claude-opus-5");
const reasoning = { effort: "medium" as const, summaries: true };
const first = await c.generateCompletion(
params({ tools: TOOLS, reasoning }),
);
expect(first.providerState?.items).toEqual(noThought.content);
await c.generateCompletion(
params({
tools: TOOLS,
reasoning,
messages: [
{ role: "user", content: "Weather?" },
{
role: "assistant",
content: first.content,
toolCalls: first.toolCalls,
providerState: first.providerState,
},
{ role: "tool", toolCallId: "toolu_fixture_02", content: "{}" },
],
}),
);
expect(sent(mock, 1).body.thinking).toEqual({
type: "adaptive",
display: "summarized",
});
});
});
@@ -0,0 +1,404 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CodexClient } from "../codex-client";
import { clearCodexSlugCache } from "../codex-slug-cache";
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
import type {
AiMessage,
AiReasoningEvent,
CompletionParams,
CompletionResult,
} from "../types";
/**
* v1.41 — Codex reasoning at the wire. The SSE fixtures were recorded against
* the live backend on 2026-10-06 with throwaway prompts (no health data) and
* sanitised: encrypted reasoning content replaced by placeholders, account
* identifiers and echoed instructions removed. The 400 bodies are the
* backend's own answers to an unsupported effort and summary value.
*/
const FIXTURES = join(__dirname, "fixtures/reasoning");
const fixture = (name: string) => readFileSync(join(FIXTURES, name), "utf8");
function sseResponse(body: string): Response {
const encoder = new TextEncoder();
// Split into uneven chunks so event boundaries fall mid-chunk, as on a
// real connection.
const chunks: string[] = [];
for (let i = 0; i < body.length; i += 173)
chunks.push(body.slice(i, i + 173));
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function errorResponse(status: number, body: string): Response {
return new Response(body, { status });
}
function client() {
return new CodexClient({
accessToken: "test-token",
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
slugChain: ["gpt-5.5"],
});
}
function params(extra: Partial<CompletionParams> = {}): CompletionParams {
return {
system: "You are a terse assistant.",
messages: [{ role: "user", content: "How long was the train moving?" }],
...extra,
};
}
function sentBody(mock: ReturnType<typeof vi.fn>, call = 0) {
return JSON.parse(mock.mock.calls[call][1].body);
}
beforeEach(() => {
vi.restoreAllMocks();
clearCodexSlugCache();
resetReasoningDialectCache();
});
describe("Codex reasoning request", () => {
it("sends today's wire when the call asks for no reasoning", async () => {
const mock = vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-summary.sse")),
);
vi.stubGlobal("fetch", mock);
const onReasoning = vi.fn();
const result = await client().generateCompletion(params({ onReasoning }));
const body = sentBody(mock);
expect(body.reasoning).toBeNull();
expect(body.include).toEqual([]);
// No reasoning asked → no events, no reasoning block, no state.
expect(onReasoning).not.toHaveBeenCalled();
expect(result.reasoning).toBeUndefined();
expect(result.providerState).toBeUndefined();
});
it("asks for effort, a summary and the encrypted items", async () => {
const mock = vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-summary.sse")),
);
vi.stubGlobal("fetch", mock);
await client().generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
const body = sentBody(mock);
expect(body.reasoning).toEqual({ effort: "medium", summary: "auto" });
expect(body.include).toEqual(["reasoning.encrypted_content"]);
expect(body.store).toBe(false);
expect(body.stream).toBe(true);
});
it("leaves the summary out when none is wanted", async () => {
const mock = vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-summary.sse")),
);
vi.stubGlobal("fetch", mock);
await client().generateCompletion(
params({ reasoning: { effort: "high", summaries: false } }),
);
expect(sentBody(mock).reasoning).toEqual({ effort: "high" });
});
it("switches reasoning off with effort none, without encrypted items", async () => {
const mock = vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-after-tool.sse")),
);
vi.stubGlobal("fetch", mock);
const result = await client().generateCompletion(
params({ reasoning: { effort: "off", summaries: true } }),
);
const body = sentBody(mock);
expect(body.reasoning).toEqual({ effort: "none" });
expect(body.include).toEqual([]);
expect(result.reasoning?.downgradedTo).toBeUndefined();
});
});
describe("Codex reasoning stream", () => {
it("reports the title live, then the text, then done", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-summary.sse")),
),
);
const events: AiReasoningEvent[] = [];
const result = await client().generateCompletion(
params({
reasoning: { effort: "medium", summaries: true },
onReasoning: (e) => events.push(e),
}),
);
expect(events).toEqual([
{ kind: "title", text: "Calculating elapsed time minus offset" },
{ kind: "text", text: "**Calculating elapsed time minus offset**" },
{ kind: "done", text: "" },
]);
expect(result.content).toBe(
"The train was moving for 3 hours and 10 minutes.",
);
expect(result.reasoning).toEqual({
summary: ["**Calculating elapsed time minus offset**"],
tokens: 33,
});
// Reasoning tokens are inside the billed total.
expect(result.tokensUsed).toBe(101);
});
it("keeps the reasoning items of a tool round as provider state", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-tool-round.sse")),
),
);
const result = await client().generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(result.finishReason).toBe("tool_calls");
expect(result.toolCalls).toEqual([
{
id: "call_edjM3le4GcXAsF7vywK4kQgY",
name: "get_weather",
arguments: '{"city":"Paris"}',
},
]);
expect(result.reasoning).toEqual({ summary: [], tokens: 16 });
expect(result.providerState?.providerType).toBe("codex");
expect(result.providerState?.model).toBe("gpt-5.5");
expect(result.providerState?.items).toHaveLength(1);
expect(result.providerState?.items[0]).toMatchObject({
type: "reasoning",
id: "rs_03743ca81429e023016ac52522f51c87d297ef373ae4b945c9",
encrypted_content: expect.stringMatching(/^enc-fixture-/),
});
});
});
describe("Codex reasoning round trip", () => {
async function firstRound(): Promise<CompletionResult> {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-tool-round.sse")),
),
);
return client().generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
}
function secondRoundMessages(first: CompletionResult): AiMessage[] {
return [
{ role: "user", content: "What is the weather in the capital?" },
{
role: "assistant",
content: first.content,
toolCalls: first.toolCalls,
providerState: first.providerState,
},
{
role: "tool",
toolCallId: first.toolCalls![0].id,
content: '{"tempC":18,"sky":"cloudy"}',
},
];
}
it("hands the reasoning item back unchanged before the call it led to", async () => {
const first = await firstRound();
const mock = vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-after-tool.sse")),
);
vi.stubGlobal("fetch", mock);
const second = await client().generateCompletion(
params({
messages: secondRoundMessages(first),
reasoning: { effort: "low", summaries: true },
}),
);
const input = sentBody(mock).input as Array<Record<string, unknown>>;
expect(input.map((i) => i.type)).toEqual([
"message",
"reasoning",
"message",
"function_call",
"function_call_output",
]);
expect(input[1]).toEqual(first.providerState!.items[0]);
expect(second.content).toBe("Paris weather: 18°C, cloudy.");
});
it("drops state made for another slug or by another provider", async () => {
const first = await firstRound();
const mock = vi
.fn()
.mockImplementation(async () =>
sseResponse(fixture("codex-after-tool.sse")),
);
vi.stubGlobal("fetch", mock);
const messages = secondRoundMessages(first);
messages[1] = {
...messages[1],
providerState: { ...first.providerState!, model: "gpt-5.4" },
};
await client().generateCompletion(params({ messages }));
messages[1] = {
...messages[1],
providerState: { ...first.providerState!, providerType: "anthropic" },
};
await client().generateCompletion(params({ messages }));
for (const call of [0, 1]) {
const types = (sentBody(mock, call).input as Array<{ type: string }>).map(
(i) => i.type,
);
expect(types).not.toContain("reasoning");
}
});
});
describe("Codex reasoning refusals", () => {
it("retries without the summary the backend refused, and remembers", async () => {
const mock = vi
.fn()
.mockResolvedValueOnce(
errorResponse(400, fixture("codex-summary-rejected.json")),
)
.mockImplementation(async () =>
sseResponse(fixture("codex-summary.sse")),
);
vi.stubGlobal("fetch", mock);
const c = client();
const result = await c.generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
expect(result.content).toBe(
"The train was moving for 3 hours and 10 minutes.",
);
expect(sentBody(mock, 0).reasoning).toEqual({
effort: "medium",
summary: "auto",
});
expect(sentBody(mock, 1).reasoning).toEqual({ effort: "medium" });
// Same level, only the summary went: not a downgrade.
expect(result.reasoning?.downgradedTo).toBeUndefined();
await c.generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(sentBody(mock, 2).reasoning).toEqual({ effort: "medium" });
});
it("steps down to the nearest effort the slug names, and remembers", async () => {
// The recorded refusal, with the slug's list narrowed to below "high".
const refusal = fixture("codex-effort-rejected.json")
.replace("'minimal'", "'high'")
.replace(
"'none', 'low', 'medium', 'high', and 'xhigh'",
"'none', 'low', and 'medium'",
);
const mock = vi
.fn()
.mockResolvedValueOnce(errorResponse(400, refusal))
.mockImplementation(async () =>
sseResponse(fixture("codex-summary.sse")),
);
vi.stubGlobal("fetch", mock);
const c = client();
const result = await c.generateCompletion(
params({ reasoning: { effort: "high", summaries: true } }),
);
expect(sentBody(mock, 1).reasoning).toEqual({
effort: "medium",
summary: "auto",
});
expect(result.reasoning?.downgradedTo).toBe("medium");
const again = await c.generateCompletion(
params({ reasoning: { effort: "high", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(sentBody(mock, 2).reasoning.effort).toBe("medium");
expect(again.reasoning?.downgradedTo).toBe("medium");
});
it("drops reasoning entirely when the refusal names no alternative", async () => {
const mock = vi
.fn()
.mockResolvedValueOnce(
errorResponse(
400,
JSON.stringify({
error: {
message: "Reasoning is not supported for this model.",
type: "invalid_request_error",
param: "reasoning",
},
}),
),
)
.mockImplementation(async () =>
sseResponse(fixture("codex-after-tool.sse")),
);
vi.stubGlobal("fetch", mock);
const result = await client().generateCompletion(
params({ reasoning: { effort: "low", summaries: false } }),
);
expect(sentBody(mock, 1).reasoning).toBeNull();
expect(sentBody(mock, 1).include).toEqual([]);
expect(result.reasoning?.downgradedTo).toBe("off");
expect(result.content).toBe("Paris weather: 18°C, cloudy.");
});
it("does not learn from a 400 that is not about reasoning", async () => {
const mock = vi.fn().mockResolvedValue(
errorResponse(
400,
JSON.stringify({
error: { message: "Invalid schema for function 'get_weather'." },
}),
),
);
vi.stubGlobal("fetch", mock);
await expect(
client().generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
),
).rejects.toMatchObject({ httpStatus: 400 });
expect(mock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,7 @@
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "thinking.type: Input tag 'adaptive' found using 'type' does not match any of the expected tags: 'disabled', 'enabled'"
}
}
@@ -0,0 +1,17 @@
{
"id": "msg_fixture_02",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [
{ "type": "redacted_thinking", "data": "redacted-fixture-b1" },
{ "type": "text", "text": "Paris is at 18 °C" },
{ "type": "text", "text": " and cloudy." }
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 760,
"output_tokens": 41,
"cache_read_input_tokens": 512
}
}
@@ -0,0 +1,7 @@
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "temperature: this model does not accept sampling parameters"
}
}
@@ -0,0 +1,29 @@
{
"id": "msg_fixture_01",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [
{
"type": "thinking",
"thinking": "**Choosing the city**\nThe capital of France is Paris, so the weather lookup needs Paris.",
"signature": "sig-fixture-a1"
},
{
"type": "text",
"text": "Let me look that up."
},
{
"type": "tool_use",
"id": "toolu_fixture_01",
"name": "get_weather",
"input": { "city": "Paris" }
}
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 612,
"output_tokens": 118,
"cache_read_input_tokens": 0
}
}
@@ -0,0 +1,51 @@
event: response.created
data: {"type":"response.created","response":{"id":"resp_03743ca81429e023016ac52524c4c487d284b7ab8375c47930","object":"response","created_at":1791304996,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0}
event: response.in_progress
data: {"type":"response.in_progress","response":{"id":"resp_03743ca81429e023016ac52524c4c487d284b7ab8375c47930","object":"response","created_at":1791304996,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1}
event: response.output_item.added
data: {"type":"response.output_item.added","item":{"id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2}
event: response.content_part.added
data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"Paris","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":4}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":" weather","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":5}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":6}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":7}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"18","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":8}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"°C","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":9}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":10}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":" cloudy","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":11}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":12}
event: response.output_text.done
data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":13,"text":"Paris weather: 18°C, cloudy."}
event: response.content_part.done
data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"Paris weather: 18°C, cloudy."},"sequence_number":14}
event: response.output_item.done
data: {"type":"response.output_item.done","item":{"id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Paris weather: 18°C, cloudy."}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":15}
event: response.completed
data: {"type":"response.completed","response":{"id":"resp_03743ca81429e023016ac52524c4c487d284b7ab8375c47930","object":"response","created_at":1791304996,"status":"completed","access_programs":null,"background":false,"completed_at":1791304997,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"default","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":205,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":13,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":218},"user":null,"metadata":{}},"sequence_number":16}
@@ -0,0 +1,8 @@
{
"error": {
"message": "Unsupported value: 'minimal' is not supported with the 'gpt-5.5' model. Supported values are: 'none', 'low', 'medium', 'high', and 'xhigh'.",
"type": "invalid_request_error",
"param": "reasoning.effort",
"code": "unsupported_value"
}
}
@@ -0,0 +1,8 @@
{
"error": {
"message": "Invalid value: 'verbose'. Supported values are: 'concise', 'detailed', and 'auto'.",
"type": "invalid_request_error",
"param": "reasoning.summary",
"code": "invalid_value"
}
}
@@ -0,0 +1,81 @@
event: response.created
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_01241f364fbd794f016ac5253e2ce08191afe0c9a23644957f","object":"response","created_at":1791305022,"completed_at":null,"status":"in_progress","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-5.5","access_programs":null,"previous_response_id":null,"output":[],"error":null,"incomplete_details":null,"tool_choice":"auto","parallel_tool_calls":false,"prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"medium","summary":"detailed","context":"current_turn","mode":"standard"},"user":null,"usage":null,"store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"auto","truncation":"disabled"}}
event: response.in_progress
data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_01241f364fbd794f016ac5253e2ce08191afe0c9a23644957f","object":"response","created_at":1791305022,"completed_at":null,"status":"in_progress","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-5.5","access_programs":null,"previous_response_id":null,"output":[],"error":null,"incomplete_details":null,"tool_choice":"auto","parallel_tool_calls":false,"prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"medium","summary":"detailed","context":"current_turn","mode":"standard"},"user":null,"usage":null,"store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"auto","truncation":"disabled"}}
event: response.output_item.added
data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"type":"reasoning","id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","summary":[],"content":[],"encrypted_content":"enc-fixture-1"}}
event: response.reasoning_summary_part.added
data: {"type":"response.reasoning_summary_part.added","sequence_number":3,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}
event: response.reasoning_summary_text.delta
data: {"type":"response.reasoning_summary_text.delta","sequence_number":4,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"delta":"**Calculating elapsed time minus offset**"}
event: response.reasoning_summary_text.done
data: {"type":"response.reasoning_summary_text.done","sequence_number":5,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"text":"**Calculating elapsed time minus offset**"}
event: response.reasoning_summary_part.done
data: {"type":"response.reasoning_summary_part.done","sequence_number":6,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":"**Calculating elapsed time minus offset**"}}
event: response.output_item.done
data: {"type":"response.output_item.done","sequence_number":7,"output_index":0,"item":{"type":"reasoning","id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","summary":[{"type":"summary_text","text":"**Calculating elapsed time minus offset**"}],"content":[],"encrypted_content":"enc-fixture-2"}}
event: response.output_item.added
data: {"type":"response.output_item.added","sequence_number":8,"output_index":1,"item":{"type":"message","id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","role":"assistant","status":"in_progress","content":[],"phase":"final_answer"}}
event: response.content_part.added
data: {"type":"response.content_part.added","sequence_number":9,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":10,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":"The","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":11,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" train","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":12,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" was","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":13,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" moving","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":14,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" for","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":15,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" ","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":"3","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":17,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" hours","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":18,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" and","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":19,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" ","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":"10","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" minutes","logprobs":[]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":".","logprobs":[]}
event: response.output_text.done
data: {"type":"response.output_text.done","sequence_number":23,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"text":"The train was moving for 3 hours and 10 minutes.","logprobs":[]}
event: response.content_part.done
data: {"type":"response.content_part.done","sequence_number":24,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"part":{"type":"output_text","text":"The train was moving for 3 hours and 10 minutes.","annotations":[],"logprobs":[]}}
event: response.output_item.done
data: {"type":"response.output_item.done","sequence_number":25,"output_index":1,"item":{"type":"message","id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","role":"assistant","status":"completed","content":[{"type":"output_text","text":"The train was moving for 3 hours and 10 minutes.","annotations":[],"logprobs":[]}],"phase":"final_answer"}}
event: response.completed
data: {"type":"response.completed","sequence_number":26,"response":{"id":"resp_01241f364fbd794f016ac5253e2ce08191afe0c9a23644957f","object":"response","created_at":1791305022,"completed_at":1791305023,"status":"completed","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-5.5","access_programs":null,"previous_response_id":null,"output":[{"type":"reasoning","id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","summary":[{"type":"summary_text","text":"**Calculating elapsed time minus offset**"}],"content":[],"encrypted_content":"enc-fixture-3"},{"type":"message","id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","role":"assistant","status":"completed","content":[{"type":"output_text","text":"The train was moving for 3 hours and 10 minutes.","annotations":[],"logprobs":[]}],"phase":"final_answer"}],"error":null,"incomplete_details":null,"tool_choice":"auto","parallel_tool_calls":false,"prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"medium","summary":"detailed","context":"current_turn","mode":"standard"},"user":null,"usage":{"input_tokens":49,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":52,"output_tokens_details":{"reasoning_tokens":33},"total_tokens":101},"store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"default","truncation":"disabled"}}
@@ -0,0 +1,39 @@
event: response.created
data: {"type":"response.created","response":{"id":"resp_03743ca81429e023016ac5252240a887d29b3b3e712af57529","object":"response","created_at":1791304994,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0}
event: response.in_progress
data: {"type":"response.in_progress","response":{"id":"resp_03743ca81429e023016ac5252240a887d29b3b3e712af57529","object":"response","created_at":1791304994,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1}
event: response.output_item.added
data: {"type":"response.output_item.added","item":{"id":"rs_03743ca81429e023016ac52522f51c87d297ef373ae4b945c9","type":"reasoning","content":[],"encrypted_content":"enc-fixture-4","summary":[]},"output_index":0,"sequence_number":2}
event: response.output_item.done
data: {"type":"response.output_item.done","item":{"id":"rs_03743ca81429e023016ac52522f51c87d297ef373ae4b945c9","type":"reasoning","content":[],"encrypted_content":"enc-fixture-5","summary":[]},"output_index":0,"sequence_number":3}
event: response.output_item.added
data: {"type":"response.output_item.added","item":{"id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","type":"function_call","status":"in_progress","arguments":"","call_id":"call_edjM3le4GcXAsF7vywK4kQgY","name":"get_weather"},"output_index":1,"sequence_number":4}
event: response.function_call_arguments.delta
data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":5}
event: response.function_call_arguments.delta
data: {"type":"response.function_call_arguments.delta","delta":"city","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":6}
event: response.function_call_arguments.delta
data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":7}
event: response.function_call_arguments.delta
data: {"type":"response.function_call_arguments.delta","delta":"Paris","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":8}
event: response.function_call_arguments.delta
data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":9}
event: response.function_call_arguments.done
data: {"type":"response.function_call_arguments.done","arguments":"{\"city\":\"Paris\"}","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":10}
event: response.output_item.done
data: {"type":"response.output_item.done","item":{"id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_edjM3le4GcXAsF7vywK4kQgY","name":"get_weather"},"output_index":1,"sequence_number":11}
event: response.completed
data: {"type":"response.completed","response":{"id":"resp_03743ca81429e023016ac5252240a887d29b3b3e712af57529","object":"response","created_at":1791304994,"status":"completed","access_programs":null,"background":false,"completed_at":1791304995,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"default","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":147,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":36,"output_tokens_details":{"reasoning_tokens":16},"total_tokens":183},"user":null,"metadata":{}},"sequence_number":12}
@@ -0,0 +1,8 @@
{
"error": {
"message": "Unrecognized request argument supplied: reasoning_effort",
"type": "invalid_request_error",
"param": null,
"code": null
}
}
@@ -0,0 +1,36 @@
{
"id": "gen-fixture",
"object": "chat.completion",
"model": "anthropic/claude-haiku-4.5",
"service_tier": "default",
"choices": [
{
"index": 0,
"logprobs": null,
"finish_reason": "stop",
"native_finish_reason": "end_turn",
"message": {
"role": "assistant",
"content": "Paris (the capital of France) currently has cloudy skies and a temperature of 18°C.",
"refusal": null,
"reasoning": null
}
}
],
"usage": {
"prompt_tokens": 746,
"completion_tokens": 25,
"total_tokens": 771,
"prompt_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 0,
"audio_tokens": 0,
"video_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"image_tokens": 0,
"audio_tokens": 0
}
}
}
@@ -0,0 +1,56 @@
{
"id": "gen-fixture",
"object": "chat.completion",
"model": "anthropic/claude-haiku-4.5",
"service_tier": "default",
"choices": [
{
"index": 0,
"logprobs": null,
"finish_reason": "tool_calls",
"native_finish_reason": "tool_use",
"message": {
"role": "assistant",
"content": null,
"refusal": null,
"reasoning": "The user is asking me to:\n1. Think about which city is the capital of France\n2. Call get_weather for that city\n\nThe capital of France is Paris. So I should call get_weather with \"Paris\" as the city parameter.",
"tool_calls": [
{
"type": "function",
"index": 0,
"id": "toolu_bdrk_014bbCR7ZtuZso1nhAXhWT8u",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"Paris\"}"
}
}
],
"reasoning_details": [
{
"type": "reasoning.text",
"text": "The user is asking me to:\n1. Think about which city is the capital of France\n2. Call get_weather for that city\n\nThe capital of France is Paris. So I should call get_weather with \"Paris\" as the city parameter.",
"format": "anthropic-claude-v1",
"index": 0,
"signature": "sig-fixture-1"
}
]
}
}
],
"usage": {
"prompt_tokens": 609,
"completion_tokens": 114,
"total_tokens": 723,
"prompt_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 0,
"audio_tokens": 0,
"video_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 58,
"image_tokens": 0,
"audio_tokens": 0
}
}
}
@@ -0,0 +1,278 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Destination policy has its own suites; stub it so the wire can be asserted
// against the global fetch stub.
vi.mock("../local-host-allowlist", () => ({ aiEgressPolicyFor: () => ({}) }));
import {
LocalOpenAICompatibleClient,
resetLocalJsonDialectCache,
} from "../local-client";
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
import type { AiReasoningEvent, CompletionParams } from "../types";
/**
* v1.41 — reasoning on local OpenAI-compatible servers. The reply shapes are
* the documented ones: `reasoning_content` (llama.cpp, LiteLLM), `reasoning`
* (vLLM, Ollama, LM Studio), on the message and on each streamed delta, and
* an inline leading `<think>` block.
*/
const FIXTURES = join(__dirname, "fixtures/reasoning");
const BASE = "http://localhost:11434/v1";
function buffered(message: Record<string, unknown>) {
return async () =>
new Response(
JSON.stringify({
choices: [{ message, finish_reason: "stop" }],
usage: {
total_tokens: 70,
completion_tokens_details: { reasoning_tokens: 30 },
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function stream(deltas: Array<Record<string, string>>) {
return async () => {
const encoder = new TextEncoder();
const frames = [
...deltas.map(
(delta) =>
`data: ${JSON.stringify({ choices: [{ delta, finish_reason: null }] })}\n\n`,
),
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }], usage: { total_tokens: 55 } })}\n\n`,
"data: [DONE]\n\n",
];
return new Response(
new ReadableStream<Uint8Array>({
start(c) {
for (const f of frames) c.enqueue(encoder.encode(f));
c.close();
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
);
};
}
function params(extra: Partial<CompletionParams> = {}): CompletionParams {
return {
system: "s",
messages: [{ role: "user", content: "u" }],
maxTokens: 600,
...extra,
};
}
const body = (mock: ReturnType<typeof vi.fn>, call = 0) =>
JSON.parse(mock.mock.calls[call][1].body);
const client = () =>
new LocalOpenAICompatibleClient({ model: "qwen3:8b", baseUrl: BASE });
beforeEach(() => {
vi.restoreAllMocks();
resetLocalJsonDialectCache();
resetReasoningDialectCache();
});
describe("Local reasoning request", () => {
it("sends the call's level, off as none, and gives it room to think", async () => {
const mock = vi.fn().mockImplementation(buffered({ content: "ok" }));
vi.stubGlobal("fetch", mock);
const c = client();
c.reasoningEffort = "high";
await c.generateCompletion(params());
await c.generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
await c.generateCompletion(
params({ reasoning: { effort: "off", summaries: true } }),
);
// No level on the call: the entry's own setting, as before.
expect(body(mock, 0).reasoning_effort).toBe("high");
expect(body(mock, 0).max_tokens).toBe(600);
expect(body(mock, 1).reasoning_effort).toBe("medium");
expect(body(mock, 1).max_tokens).toBe(600 + 4096);
expect(body(mock, 2).reasoning_effort).toBe("none");
expect(body(mock, 2).max_tokens).toBe(600);
});
it("retries once without the field when the server refuses it, and remembers", async () => {
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 c = client();
const result = await c.generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(result.content).toBe("ok");
expect(body(mock, 1)).not.toHaveProperty("reasoning_effort");
await c.generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(body(mock, 2)).not.toHaveProperty("reasoning_effort");
});
});
describe("Local reasoning replies", () => {
it.each([["reasoning_content"], ["reasoning"]])(
"reads %s off the message",
async (field) => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(
buffered({ content: "Answer.", [field]: "**Plan**\nThink." }),
),
);
const events: AiReasoningEvent[] = [];
const result = await client().generateCompletion(
params({
reasoning: { effort: "low", summaries: true },
onReasoning: (e) => events.push(e),
}),
);
expect(result.content).toBe("Answer.");
expect(result.reasoning).toEqual({
summary: ["**Plan**\nThink."],
tokens: 30,
});
expect(events).toEqual([
{ kind: "title", text: "Plan" },
{ kind: "text", text: "**Plan**\nThink." },
{ kind: "done", text: "" },
]);
},
);
it("splits an inline think block only for a caller that asked for reasoning", async () => {
const reply = "<think>check the window</think>\n\nThe answer.";
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(buffered({ content: reply })),
);
const asked = await client().generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(asked.content).toBe("The answer.");
expect(asked.reasoning?.summary).toEqual(["check the window"]);
const unasked = await client().generateCompletion(params());
expect(unasked.content).toBe(reply);
expect(unasked.reasoning).toBeUndefined();
});
});
describe("Local reasoning stream", () => {
it("reports streamed reasoning live and keeps it out of the answer", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(
stream([
{ reasoning_content: "**Choosing" },
{ reasoning_content: " the window**\n" },
{ reasoning_content: "Ninety days." },
{ content: "Your " },
{ content: "answer." },
]),
),
);
const events: AiReasoningEvent[] = [];
const deltas: string[] = [];
const result = await client().generateCompletionStream(
params({
reasoning: { effort: "medium", summaries: true },
onReasoning: (e) => events.push(e),
}),
(d) => deltas.push(d),
);
expect(deltas).toEqual(["Your ", "answer."]);
expect(result.content).toBe("Your answer.");
expect(events).toEqual([
{ kind: "title", text: "Choosing the window" },
{ kind: "text", text: "**Choosing the window**\nNinety days." },
{ kind: "done", text: "" },
]);
expect(result.reasoning?.summary).toEqual([
"**Choosing the window**\nNinety days.",
]);
});
it("routes an inline think block split across chunks to the reasoning", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(
stream([
{ content: " <thi" },
{ content: "nk>weigh the" },
{ content: " two readings</th" },
{ content: "ink>\n\nThe " },
{ content: "answer." },
]),
),
);
const deltas: string[] = [];
const result = await client().generateCompletionStream(
params({ reasoning: { effort: "low", summaries: true } }),
(d) => deltas.push(d),
);
expect(deltas.join("")).toBe("The answer.");
expect(result.content).toBe("The answer.");
expect(result.reasoning?.summary).toEqual(["weigh the two readings"]);
});
it("streams untouched for a caller that asked for no reasoning", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(
stream([
{ reasoning: "ignored" },
{ content: "<think>x</think>" },
{ content: "y" },
]),
),
);
const deltas: string[] = [];
const onReasoning = vi.fn();
const result = await client().generateCompletionStream(
params({ onReasoning }),
(d) => deltas.push(d),
);
expect(deltas).toEqual(["<think>x</think>", "y"]);
expect(result.content).toBe("<think>x</think>y");
expect(result.reasoning).toBeUndefined();
expect(onReasoning).not.toHaveBeenCalled();
});
it("passes a short reply that only looked like it might open a think block", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(stream([{ content: "<t" }])),
);
const result = await client().generateCompletionStream(
params({ reasoning: { effort: "low", summaries: true } }),
() => {},
);
expect(result.content).toBe("<t");
});
});
@@ -0,0 +1,287 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OpenAIClient } from "../openai-client";
import { resetJsonModeDialectCache, jsonModeDialectFor } from "../json-dialect";
import { resetReasoningDialectCache } from "../reasoning/dialect-cache";
import type { AiReasoningEvent, AiToolDef, CompletionParams } from "../types";
// safeFetch's requirePublicHost path runs through undici's own `fetch`.
// Delegate it to the global stub these tests install.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
/**
* v1.41 — reasoning on the Chat Completions wire. The OpenRouter fixtures
* were recorded on 2026-10-06 with a throwaway prompt (no health data);
* signatures and encrypted payloads are placeholders. The gateway refusal is
* the standard OpenAI error envelope.
*/
const FIXTURES = join(__dirname, "fixtures/reasoning");
const fixture = (name: string) => readFileSync(join(FIXTURES, name), "utf8");
const ok = (name: string) => async () =>
new Response(fixture(name), {
status: 200,
headers: { "content-type": "application/json" },
});
const plain =
(content: string, extra: Record<string, unknown> = {}) =>
async () =>
new Response(
JSON.stringify({
choices: [
{
message: { role: "assistant", content, ...extra },
finish_reason: "stop",
},
],
usage: {
total_tokens: 90,
completion_tokens_details: { reasoning_tokens: 40 },
},
}),
{ status: 200 },
);
const TOOLS: AiToolDef[] = [
{
name: "get_weather",
description: "Weather for a city",
parameters: { type: "object", properties: { city: { type: "string" } } },
},
];
function params(extra: Partial<CompletionParams> = {}): CompletionParams {
return {
system: "You are terse.",
messages: [{ role: "user", content: "Weather in the capital of France?" }],
maxTokens: 600,
...extra,
};
}
const body = (mock: ReturnType<typeof vi.fn>, call = 0) =>
JSON.parse(mock.mock.calls[call][1].body);
const openai = (model: string) =>
new OpenAIClient({
apiKey: "sk-test",
model,
baseUrl: "https://api.openai.com/v1",
});
const gateway = (baseUrl: string, model: string) =>
new OpenAIClient({
apiKey: "sk-test",
model,
baseUrl,
providerType: "openai-compatible",
});
beforeEach(() => {
vi.restoreAllMocks();
resetReasoningDialectCache();
resetJsonModeDialectCache();
});
describe("OpenAI reasoning", () => {
it("sends nothing new when the call asks for no reasoning", async () => {
const mock = vi.fn().mockImplementation(plain("ok"));
vi.stubGlobal("fetch", mock);
const result = await openai("gpt-5.5").generateCompletion(params());
expect(body(mock)).not.toHaveProperty("reasoning_effort");
expect(body(mock).max_completion_tokens).toBe(600);
expect(result.reasoning).toBeUndefined();
});
it("sends the effort on a round without tools, with room to think", async () => {
const mock = vi.fn().mockImplementation(plain("ok"));
vi.stubGlobal("fetch", mock);
const result = await openai("gpt-5.5").generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
expect(body(mock).reasoning_effort).toBe("medium");
expect(body(mock).max_completion_tokens).toBe(600 + 4096);
expect(body(mock)).not.toHaveProperty("temperature");
expect(result.reasoning).toEqual({ summary: [], tokens: 40 });
});
it("sends no effort on a tool round", async () => {
const mock = vi.fn().mockImplementation(plain("ok"));
vi.stubGlobal("fetch", mock);
await openai("gpt-5.5").generateCompletion(
params({ tools: TOOLS, reasoning: { effort: "high", summaries: true } }),
);
expect(body(mock)).not.toHaveProperty("reasoning_effort");
expect(body(mock).max_completion_tokens).toBe(600);
});
it("maps off onto each family's lowest effort, and skips non-reasoning models", async () => {
const mock = vi.fn().mockImplementation(plain("ok"));
vi.stubGlobal("fetch", mock);
const off = { reasoning: { effort: "off" as const, summaries: false } };
await openai("gpt-5.5").generateCompletion(params(off));
await openai("gpt-5-mini").generateCompletion(params(off));
await openai("o3").generateCompletion(params(off));
await openai("gpt-4o").generateCompletion(params(off));
expect(body(mock, 0).reasoning_effort).toBe("none");
expect(body(mock, 1).reasoning_effort).toBe("minimal");
expect(body(mock, 2).reasoning_effort).toBe("low");
expect(body(mock, 3)).not.toHaveProperty("reasoning_effort");
});
});
describe("Gateway reasoning", () => {
it("lets the call's level win over the entry's stored setting", async () => {
const mock = vi.fn().mockImplementation(plain("ok"));
vi.stubGlobal("fetch", mock);
const c = gateway("https://litellm.example.com/v1", "qwen3");
c.reasoningEffort = "high";
await c.generateCompletion(params());
await c.generateCompletion(
params({ reasoning: { effort: "low", summaries: true } }),
);
expect(body(mock, 0).reasoning_effort).toBe("high");
expect(body(mock, 1).reasoning_effort).toBe("low");
});
it("reads reasoning_content as the summary", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockImplementation(
plain("Paris.", { reasoning_content: "**Capital**\nIt is Paris." }),
),
);
const events: AiReasoningEvent[] = [];
const result = await gateway(
"https://litellm.example.com/v1",
"qwen3",
).generateCompletion(
params({
reasoning: { effort: "medium", summaries: true },
onReasoning: (e) => events.push(e),
}),
);
expect(result.content).toBe("Paris.");
expect(result.reasoning?.summary).toEqual(["**Capital**\nIt is Paris."]);
expect(events.map((e) => e.kind)).toEqual(["title", "text", "done"]);
});
it("speaks OpenRouter's dialect and carries reasoning_details to the next round", async () => {
const mock = vi
.fn()
.mockImplementationOnce(ok("openrouter-claude-tool-round.json"))
.mockImplementation(ok("openrouter-claude-after-tool.json"));
vi.stubGlobal("fetch", mock);
const c = gateway(
"https://openrouter.ai/api/v1",
"anthropic/claude-haiku-4.5",
);
const first = await c.generateCompletion(
params({ tools: TOOLS, reasoning: { effort: "low", summaries: true } }),
);
expect(body(mock, 0).reasoning).toEqual({ effort: "low" });
expect(body(mock, 0)).not.toHaveProperty("reasoning_effort");
expect(first.finishReason).toBe("tool_calls");
expect(first.reasoning?.tokens).toBe(58);
expect(first.reasoning?.summary[0]).toMatch(/capital of France is Paris/);
const details = JSON.parse(fixture("openrouter-claude-tool-round.json"))
.choices[0].message.reasoning_details;
expect(first.providerState).toEqual({
providerType: "openai-compatible",
model: "anthropic/claude-haiku-4.5",
items: [{ reasoning_details: details }],
});
await c.generateCompletion(
params({
tools: TOOLS,
reasoning: { effort: "low", summaries: true },
messages: [
{ role: "user", content: "Weather in the capital of France?" },
{
role: "assistant",
content: first.content,
toolCalls: first.toolCalls,
providerState: first.providerState,
},
{ role: "tool", toolCallId: first.toolCalls![0].id, content: "{}" },
],
}),
);
const assistant = body(mock, 1).messages[2];
expect(assistant.role).toBe("assistant");
expect(assistant.reasoning_details).toEqual(details);
});
it("does not hand state to a different model", async () => {
const mock = vi.fn().mockImplementation(plain("ok"));
vi.stubGlobal("fetch", mock);
await gateway(
"https://openrouter.ai/api/v1",
"openai/gpt-5-mini",
).generateCompletion(
params({
messages: [
{ role: "user", content: "q" },
{
role: "assistant",
content: "",
toolCalls: [{ id: "c1", name: "get_weather", arguments: "{}" }],
providerState: {
providerType: "openai-compatible",
model: "anthropic/claude-haiku-4.5",
items: [{ reasoning_details: [{ type: "reasoning.text" }] }],
},
},
{ role: "tool", toolCallId: "c1", content: "{}" },
],
}),
);
expect(body(mock).messages[2]).not.toHaveProperty("reasoning_details");
});
it("retries once without reasoning when the gateway refuses it, and remembers", async () => {
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 result = await c.generateCompletion(
params({
responseFormat: "json",
reasoning: { effort: "medium", summaries: true },
}),
);
expect(body(mock, 0).reasoning_effort).toBe("medium");
expect(body(mock, 1)).not.toHaveProperty("reasoning_effort");
// The JSON flag survives: the refusal named the reasoning field.
expect(body(mock, 1).response_format).toEqual({ type: "json_object" });
expect(jsonModeDialectFor("https://litellm.example.com/v1")).toBe(
"response_format",
);
expect(result.reasoning?.downgradedTo).toBe("off");
await c.generateCompletion(
params({ reasoning: { effort: "medium", summaries: true } }),
);
expect(mock).toHaveBeenCalledTimes(3);
expect(body(mock, 2)).not.toHaveProperty("reasoning_effort");
});
});
@@ -0,0 +1,117 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
/**
* v1.41 — `providerState` (encrypted reasoning items, signed thinking blocks,
* gateway reasoning details) lives in memory for one turn. It is opaque
* provider output tied to the request that produced it: never persisted,
* never logged, never passed to `annotate()`.
*
* The guard freezes where the name may appear at all — the clients that make
* and consume it, the contract, and the Coach tool loop that threads it from
* one round to the next — and, inside those files, forbids any line that
* names it alongside a logging or persistence call. A new reader elsewhere
* (a persistence helper, a route, a job, a log line) fails here and has to
* argue its case in this list.
*/
const ROOT = join(__dirname, "../../../..");
const SRC = join(ROOT, "src");
const ALLOWED_FILES = new Set([
"src/lib/ai/types.ts",
"src/lib/ai/codex-client.ts",
"src/lib/ai/openai-client.ts",
"src/lib/ai/anthropic-client.ts",
"src/lib/ai/local-client.ts",
"src/lib/ai/mock-client.ts",
]);
/** The Coach tool loop carries it between rounds of one turn. */
const ALLOWED_PREFIXES = ["src/lib/ai/coach/tools/"];
/**
* A read (`x.providerState`) or a key / field declaration
* (`providerState: …`, `providerState?: …`). Comment lines are skipped. A
* local variable that merely shares the name (the onboarding screen's
* `const providerState = useAiProviderState()`) is a different thing and
* does not match.
*/
const STATE_REFERENCE = /\.providerState\b|\bproviderState\??\s*:/;
const FORBIDDEN_ON_SAME_LINE =
/annotate\(|prisma\.|auditLog|console\.|logger\.|reportToGlitchtip|captureException|persist|JSON\.stringify|encrypt\(/;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (
name === "generated" ||
name === "node_modules" ||
name === "__tests__"
) {
continue;
}
const full = join(dir, name);
if (statSync(full).isDirectory()) {
walk(full, out);
} else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) {
out.push(full);
}
}
return out;
}
function mentions(): Array<{ file: string; lines: string[] }> {
return walk(SRC)
.map((full) => {
const lines = readFileSync(full, "utf8")
.split("\n")
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
.filter((l) => STATE_REFERENCE.test(l));
return { file: relative(ROOT, full).split("\\").join("/"), lines };
})
.filter((m) => m.lines.length > 0);
}
describe("providerState never leaves the turn", () => {
const found = mentions();
it("finds the clients that make it (a guard that matches nothing proves nothing)", () => {
const files = found.map((m) => m.file);
// The contract, the three clients that run tool rounds (Codex, OpenAI,
// Anthropic) and the mock, today. Local never runs tools.
expect(files.length).toBeGreaterThanOrEqual(5);
for (const client of [
"src/lib/ai/codex-client.ts",
"src/lib/ai/anthropic-client.ts",
"src/lib/ai/openai-client.ts",
"src/lib/ai/types.ts",
]) {
expect(files).toContain(client);
}
});
it("appears only in the clients, the contract and the tool loop", () => {
const outside = found
.map((m) => m.file)
.filter(
(f) =>
!ALLOWED_FILES.has(f) &&
!ALLOWED_PREFIXES.some((p) => f.startsWith(p)),
);
expect(outside).toEqual([]);
});
it("is never named on a line that logs, annotates or persists", () => {
const offending = found.flatMap((m) =>
m.lines
.filter((l) => FORBIDDEN_ON_SAME_LINE.test(l))
.map((l) => `${m.file}: ${l.trim()}`),
);
expect(offending).toEqual([]);
});
it("has no column in the schema", () => {
const schema = readFileSync(join(ROOT, "prisma/schema.prisma"), "utf8");
expect(schema).not.toMatch(/providerState|provider_state/);
});
});
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AiReasoningEvent, CompletionParams } from "../types";
/**
* v1.41 — the chain runner hands `reasoning` and `onReasoning` to every
* candidate unchanged, reports reasoning as counts only, and the mock client
* reasons deterministically for the loop's tests.
*/
const annotations = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("@/lib/logging/context", () => ({
annotate: (fields: { meta?: Record<string, unknown> }) => {
if (fields.meta) annotations.push(fields.meta);
},
}));
vi.mock("../coach/budget", async () => {
const actual =
await vi.importActual<typeof import("../coach/budget")>("../coach/budget");
return {
...actual,
readDailySpend: vi.fn(async () => ({ total: 0, operator: 0 })),
};
});
import { MockAIProvider } from "../mock-client";
import {
clearLastWorkingProviderCache,
runRawCompletionWithFallback,
} from "../provider-runner";
import { createInMemoryProviderHealthLedger } from "../provider-health-ledger";
function params(extra: Partial<CompletionParams> = {}): CompletionParams {
return {
system: "s",
messages: [{ role: "user", content: "u" }],
...extra,
};
}
beforeEach(() => {
annotations.length = 0;
clearLastWorkingProviderCache();
});
describe("MockAIProvider reasoning", () => {
it("reasons deterministically when asked, with state for the next round", async () => {
const mock = new MockAIProvider({ providerType: "codex", responses: "a" });
const events: AiReasoningEvent[] = [];
const first = await mock.generateCompletion(
params({
reasoning: { effort: "medium", summaries: true },
onReasoning: (e) => events.push(e),
}),
);
expect(events).toEqual([
{ kind: "title", text: "Reading the request" },
{
kind: "text",
text: "**Reading the request**\nMock reasoning for call 1.",
},
{ kind: "done", text: "" },
]);
expect(first.reasoning).toEqual({
summary: ["**Reading the request**\nMock reasoning for call 1."],
tokens: 8,
});
expect(first.providerState).toEqual({
providerType: "codex",
model: "mock-model",
items: [{ type: "mock-reasoning", call: 1 }],
});
});
it("stays silent without a request, and reports nothing for off", async () => {
const mock = new MockAIProvider({ responses: "a" });
const onReasoning = vi.fn();
const plain = await mock.generateCompletion(params({ onReasoning }));
expect(onReasoning).not.toHaveBeenCalled();
expect(plain.reasoning).toBeUndefined();
const off = await mock.generateCompletion(
params({ reasoning: { effort: "off", summaries: true }, onReasoning }),
);
expect(off.reasoning).toEqual({ summary: [], tokens: null });
expect(off.providerState).toBeUndefined();
});
});
describe("Chain runner reasoning pass-through", () => {
it("hands the same reasoning request to the fallback hop and counts it", async () => {
const failing = new MockAIProvider({
providerType: "anthropic",
rejectWith: Object.assign(new Error("down"), { httpStatus: 503 }),
});
const working = new MockAIProvider({
providerType: "codex",
responses: "ok",
});
const events: AiReasoningEvent[] = [];
const onReasoning = (e: AiReasoningEvent) => events.push(e);
const reasoning = { effort: "high" as const, summaries: true };
const out = await runRawCompletionWithFallback({
userId: "u1",
providers: [
{ providerType: "anthropic", instance: failing },
{ providerType: "codex", instance: working },
],
params: params({ reasoning, onReasoning }),
surface: "coach",
ledger: createInMemoryProviderHealthLedger(),
});
expect(failing.calls[0].reasoning).toBe(reasoning);
expect(working.calls[0].reasoning).toBe(reasoning);
expect(working.calls[0].onReasoning).toBe(onReasoning);
expect(events.map((e) => e.kind)).toEqual(["title", "text", "done"]);
expect(out.result.providerState?.items).toHaveLength(1);
const success = annotations.find((m) => "ai_reasoning_spend" in m);
expect(success).toMatchObject({
ai_reasoning_spend: 8,
ai_reasoning_summary_count: 1,
});
// Counts only: no model text, no provider state on the wide event.
const flat = JSON.stringify(annotations);
expect(flat).not.toContain("mock-reasoning");
expect(flat).not.toContain("Reading the request");
});
});
+452 -55
View File
@@ -5,11 +5,29 @@ import type {
AIProvider,
AiContentPart,
AiMessage,
AiProviderState,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
import {
annotateReasoningDowngrade,
learnedReasoningDialect,
rememberReasoningDialect,
} from "./reasoning/dialect-cache";
import {
REASONING_LEVELS,
REASONING_THINKING_BUDGET,
type ReasoningLevel,
} from "./reasoning/levels";
import {
anthropicRefusesSampling,
anthropicThinkingDialect,
anthropicThinksAlways,
reasoningTitleOf,
type AnthropicThinkingDialect,
} from "./reasoning/support";
/**
* v1.18.9 — Anthropic Messages API content blocks. The text-only path sends a
@@ -42,13 +60,37 @@ type AnthropicContentBlock =
type: "tool_result";
tool_use_id: string;
content: string;
};
}
// v1.41 — `thinking` / `redacted_thinking` blocks of an earlier round,
// replayed byte-for-byte from `providerState` (signature included).
| Record<string, unknown>;
interface AnthropicWireMessage {
role: "user" | "assistant";
content: string | AnthropicContentBlock[];
}
/**
* The thinking blocks a model returned, in the shape the next round must hand
* back: "you must pass thinking blocks back to the API … the complete,
* unmodified block", with `signature` (`thinking`) or `data`
* (`redacted_thinking`). https://platform.claude.com/docs/en/build-with-claude/extended-thinking
*/
function isThinkingBlock(block: unknown): block is Record<string, unknown> {
const type = (block as { type?: unknown } | null)?.type;
return type === "thinking" || type === "redacted_thinking";
}
/** `state` when this client made it for `model`, else `null`. */
function ownState(
state: AiProviderState | undefined,
model: string,
): AiProviderState | null {
return state && state.providerType === "anthropic" && state.model === model
? state
: null;
}
interface AnthropicClientConfig {
apiKey: string;
model: string;
@@ -58,6 +100,27 @@ interface AnthropicClientConfig {
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
const ANTHROPIC_VERSION = "2023-06-01";
/**
* Manual (budget) thinking keeps its thoughts between tool calls only with
* this beta header; adaptive thinking interleaves on its own.
* https://platform.claude.com/docs/en/build-with-claude/extended-thinking#interleaved-thinking
*/
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
/** What one request asks for. */
type ThinkingPlan =
| { kind: "none" }
| { kind: "manual"; budget: number; level: Exclude<ReasoningLevel, "off"> }
| { kind: "adaptive"; effort: Exclude<ReasoningLevel, "off"> };
/** What a model behind an endpoint was learned to refuse. */
interface AnthropicDialect {
/** The thinking form that worked after the name-derived one was refused. */
thinking?: AnthropicThinkingDialect;
/** The model refused `temperature`. */
noSampling?: true;
}
// The only JSON steering on this wire. Current Anthropic models reject an
// assistant-turn prefill (the old `{` trick) with HTTP 400, so the contract
// rides on the instruction and on `extractJsonObject` on the way out.
@@ -104,7 +167,7 @@ function mapParts(parts: AiContentPart[]): AnthropicContentBlock[] {
* a `role:"user"` turn carrying a `tool_result` block (Anthropic's convention).
* An assistant turn with `toolCalls` emits `tool_use` blocks alongside any text.
*/
function mapMessage(m: AiMessage): AnthropicWireMessage {
function mapMessage(m: AiMessage, model: string): AnthropicWireMessage {
if (m.role === "tool") {
return {
role: "user",
@@ -120,6 +183,41 @@ function mapMessage(m: AiMessage): AnthropicWireMessage {
const hasToolCalls =
m.role === "assistant" && !!m.toolCalls && m.toolCalls.length > 0;
// v1.41 — an assistant round that thought. When the stored reply's tool
// calls are exactly the ones this turn carries, the reply goes back as it
// came (interleaved thinking keeps its order); otherwise its thinking
// blocks lead and the text and tool_use blocks are rebuilt behind them.
const state =
m.role === "assistant" ? ownState(m.providerState, model) : null;
if (state) {
const stored = state.items.filter(
(b): b is Record<string, unknown> => b !== null && typeof b === "object",
);
const storedIds = stored
.filter((b) => b.type === "tool_use")
.map((b) => String(b.id))
.sort();
const turnIds = (m.toolCalls ?? []).map((tc) => tc.id).sort();
if (
storedIds.length > 0 &&
storedIds.length === turnIds.length &&
storedIds.every((id, i) => id === turnIds[i])
) {
return { role: "assistant", content: stored };
}
const thinking = stored.filter(isThinkingBlock);
if (thinking.length > 0) {
const rebuilt = mapMessage({ ...m, providerState: undefined }, model);
const rest =
typeof rebuilt.content === "string"
? rebuilt.content.length > 0
? [{ type: "text" as const, text: rebuilt.content }]
: []
: rebuilt.content;
return { role: "assistant", content: [...thinking, ...rest] };
}
}
// Plain-string content with no tool calls stays a bare string on the wire —
// byte-identical to the pre-refactor text-only path. Only build a block array
// when there are content parts or tool_use blocks to carry.
@@ -180,15 +278,17 @@ export class AnthropicClient implements AIProvider {
// JSON body; the object extraction below stays off that path.
const wantsJson = params.responseFormat === "json" && !hasTools;
const wireMessages: AnthropicWireMessage[] =
params.messages.map(mapMessage);
const wireMessages: AnthropicWireMessage[] = params.messages.map((m) =>
mapMessage(m, this.config.model),
);
// JSON-reliability instruction. The pre-refactor client appended this to
// the user prompt UNCONDITIONALLY (not gated on `responseFormat`), so every
// Anthropic caller — including the Coach prose path — saw it. Preserve that
// exactly by appending to the LAST user turn's text. Tools imply the
// non-JSON tool flow, so skip the instruction when tools are present.
if (!hasTools) {
// JSON-reliability instruction, only for callers that asked for JSON.
// v1.41 — it used to ride on every request without tools, so the Coach's
// prose rounds, the judge's final-line verdict and the array-shaped memory
// extractors were all told to "reply with a single JSON object". Every
// object-shaped caller sets `responseFormat: "json"` (the status provider
// does by default), so gating on it changes nothing for them.
if (wantsJson) {
for (let i = wireMessages.length - 1; i >= 0; i -= 1) {
if (wireMessages[i].role !== "user") continue;
const turn = wireMessages[i];
@@ -199,7 +299,7 @@ export class AnthropicClient implements AIProvider {
// end with the instruction text) so the wire stays a single text
// block — byte-identical to the pre-refactor `wrapForJson` output.
const last = turn.content[turn.content.length - 1];
if (last && last.type === "text") {
if (last && last.type === "text" && typeof last.text === "string") {
last.text = `${last.text}${JSON_INSTRUCTION}`;
} else {
turn.content = [
@@ -228,6 +328,7 @@ export class AnthropicClient implements AIProvider {
const tools = hasTools
? buildAnthropicTools(params.tools as AiToolDef[])
: undefined;
// Thinking allows only `auto` and `none`, which is all this maps to.
const toolChoice =
hasTools && params.toolChoice
? params.toolChoice === "none"
@@ -235,40 +336,48 @@ export class AnthropicClient implements AIProvider {
: { type: "auto" as const }
: undefined;
const res = await safeFetch(
url,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.config.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
},
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
temperature: params.temperature ?? 0.3,
system,
messages: wireMessages,
...(tools ? { tools } : {}),
...(toolChoice ? { tool_choice: toolChoice } : {}),
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
// v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS
// check so a private/metadata address is rejected (SSRF/rebinding).
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
// The record owner's response-timeout setting, else the surface value, else 60 s.
{
timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
requirePublicHost: true,
signal: params.signal,
},
);
const dialect = this.readDialect();
let plan = this.planThinking(params, dialect);
let sendTemperature = this.sendsTemperature(plan, dialect);
let res = await this.send(url, params, {
plan,
sendTemperature,
system,
wireMessages,
tools,
toolChoice,
hasTools,
});
// v1.41 — a model that refuses the thinking form or the sampling field it
// was sent is asked again without it, and the endpoint remembers. Each
// step drops something, so the loop ends; the answer is never lost to a
// reasoning parameter.
let rawBody = "";
for (let step = 0; !res.ok && step < 3; step += 1) {
rawBody = await res.text().catch(() => "");
if (res.status !== 400) break;
const learned = this.learnFromRejection(
plan,
sendTemperature,
rawBody,
dialect,
);
if (!learned) break;
plan = this.planThinking(params, dialect);
sendTemperature = this.sendsTemperature(plan, dialect);
rawBody = "";
res = await this.send(url, params, {
plan,
sendTemperature,
system,
wireMessages,
tools,
toolChoice,
hasTools,
});
}
if (!res.ok) {
// Mirror the openai-client body-capture so 4xx/5xx upstream incidents
@@ -276,11 +385,8 @@ export class AnthropicClient implements AIProvider {
// instead of surfacing as an opaque "Anthropic request failed (5xx)".
// Strip anything that looks like an Anthropic API key from the excerpt
// before logging.
const rawBody = await res.text().catch(() => "");
const bodyExcerpt = rawBody
.slice(0, 500)
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "sk-ant-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
if (!rawBody) rawBody = await res.text().catch(() => "");
const bodyExcerpt = redactAnthropicBody(rawBody);
const err = new Error(`Anthropic request failed (${res.status})`);
Object.assign(err, {
httpStatus: res.status,
@@ -295,6 +401,7 @@ export class AnthropicClient implements AIProvider {
content?: Array<{
type: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
@@ -304,16 +411,34 @@ export class AnthropicClient implements AIProvider {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
output_tokens_details?: { thinking_tokens?: number };
};
};
const textBlock = json.content?.find((c) => c.type === "text");
const rawText = textBlock?.text;
const blocks = json.content ?? [];
// v1.41 — every text block, not just the first: with thinking on, a reply
// can carry text before and after its thinking or tool_use blocks. Text
// blocks that follow each other directly are one passage split by the
// API; a non-text block between them is a paragraph break.
let rawText: string | undefined;
let brokenSinceText = false;
for (const block of blocks) {
if (block.type !== "text") {
brokenSinceText = true;
continue;
}
const text = block.text ?? "";
rawText =
rawText === undefined
? text
: `${rawText}${brokenSinceText && text ? "\n\n" : ""}${text}`;
brokenSinceText = false;
}
if (rawText !== undefined && rawText.trim() === "") rawText = undefined;
// v1.20.0 — surface tool_use blocks for F1's loop.
const toolUseBlocks = (json.content ?? []).filter(
(c) => c.type === "tool_use",
);
const toolUseBlocks = blocks.filter((c) => c.type === "tool_use");
const toolCalls: AiToolCall[] | undefined =
toolUseBlocks.length > 0
? toolUseBlocks.map((b) => ({
@@ -338,6 +463,23 @@ export class AnthropicClient implements AIProvider {
throw err;
}
// v1.41 — thinking. The blocks travel to the next round untouched (the
// whole reply, so interleaved order survives); readable summaries go to
// the receiver after the reply, since this client does not stream.
const thinkingBlocks = blocks.filter(isThinkingBlock);
const summary = thinkingBlocks
.map((b) => (typeof b.thinking === "string" ? b.thinking.trim() : ""))
.filter((t) => t.length > 0);
const emit = params.reasoning ? params.onReasoning : undefined;
if (emit) {
for (const text of summary) {
const title = reasoningTitleOf(text);
if (title) emit({ kind: "title", text: title });
emit({ kind: "text", text });
}
emit({ kind: "done", text: "" });
}
// Without a prefill the model returns the whole object, sometimes inside
// a ```json fence or behind a short lead-in sentence; narrow to the
// object so the caller parses it as-is.
@@ -366,6 +508,22 @@ export class AnthropicClient implements AIProvider {
? "stop"
: undefined;
const sentLevel = levelOfPlan(plan);
const reasoning: CompletionResult["reasoning"] = params.reasoning
? {
summary,
// Thinking is billed as output and already inside `output_tokens`.
tokens: json.usage?.output_tokens_details?.thinking_tokens ?? null,
// Only a refusal the endpoint taught counts as a downgrade; a turn
// that started elsewhere and so runs without thinking does not.
...(dialect.thinking !== undefined &&
REASONING_LEVELS.indexOf(sentLevel) <
REASONING_LEVELS.indexOf(params.reasoning.effort)
? { downgradedTo: sentLevel }
: {}),
}
: undefined;
return {
content,
tokensUsed,
@@ -374,6 +532,245 @@ export class AnthropicClient implements AIProvider {
providerType: "anthropic",
...(toolCalls ? { toolCalls } : {}),
finishReason,
...(reasoning ? { reasoning } : {}),
// State goes back whenever the reply thought, and on every tool round
// of a call that asked for reasoning: an adaptive round may choose not
// to think, and the next round must still recognise it as its own.
...(thinkingBlocks.length > 0 || (params.reasoning && toolCalls)
? {
providerState: {
providerType: "anthropic" as const,
model: this.config.model,
items: blocks,
},
}
: {}),
};
}
// ── v1.41 reasoning ───────────────────────────────────────────────────────
private dialectKey() {
return {
provider: "anthropic",
endpoint: this.config.baseUrl ?? DEFAULT_BASE_URL,
model: this.config.model,
};
}
private readDialect(): AnthropicDialect {
const raw = learnedReasoningDialect(this.dialectKey());
if (!raw) return {};
try {
return JSON.parse(raw) as AnthropicDialect;
} catch {
return {};
}
}
/**
* The thinking this request asks for. Absent `params.reasoning` → none,
* today's wire. The dialect comes from the model name (manual budget up to
* 4.5, adaptive effort from 4.6) unless the endpoint taught otherwise.
*
* A turn that began on another provider (or another model) carries
* assistant tool rounds without our thinking blocks; with thinking on, the
* API expects the current tool round to start with one, so thinking stays
* off for the rest of that turn.
*/
private planThinking(
params: CompletionParams,
dialect: AnthropicDialect,
): ThinkingPlan {
const reasoning = params.reasoning;
if (!reasoning) return { kind: "none" };
const form =
dialect.thinking ?? anthropicThinkingDialect(this.config.model);
if (form === "none") return { kind: "none" };
if (this.toolRoundStartedElsewhere(params.messages, form)) {
return { kind: "none" };
}
const level = reasoning.effort;
if (form === "manual") {
return level === "off"
? { kind: "none" }
: { kind: "manual", budget: REASONING_THINKING_BUDGET[level], level };
}
if (level === "off") {
// From the 5 generation thinking cannot be switched off; the lowest
// effort is the honest "off" (`offIsReal: false` in support.ts).
return anthropicThinksAlways(this.config.model)
? { kind: "adaptive", effort: "low" }
: { kind: "none" };
}
return { kind: "adaptive", effort: level };
}
/**
* True when the current tool round (the turns after the last user message)
* has an assistant turn this client did not make for this model: another
* provider, or another model, started it. In manual mode a turn of ours
* that ran without thinking counts too, because a budget-mode continuation
* must open with a thinking block. In adaptive mode the model may have
* chosen not to think in that round, which the API accepts.
*/
private toolRoundStartedElsewhere(
messages: AiMessage[],
form: AnthropicThinkingDialect,
): boolean {
let lastUser = -1;
messages.forEach((m, i) => {
if (m.role === "user") lastUser = i;
});
return messages.slice(lastUser + 1).some((m) => {
if (m.role !== "assistant" || (m.toolCalls?.length ?? 0) === 0) {
return false;
}
const own = ownState(m.providerState, this.config.model);
if (!own) return true;
return form === "manual" && !own.items.some(isThinkingBlock);
});
}
/**
* `temperature` goes out only where the model takes it: never from 4.7 on
* (the 5 generation refuses even the default), never with thinking on, and
* never once the endpoint refused it.
*/
private sendsTemperature(
plan: ThinkingPlan,
dialect: AnthropicDialect,
): boolean {
return (
plan.kind === "none" &&
!dialect.noSampling &&
!anthropicRefusesSampling(this.config.model)
);
}
/**
* 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.
*/
private learnFromRejection(
plan: ThinkingPlan,
sentTemperature: boolean,
body: string,
dialect: AnthropicDialect,
): boolean {
const lower = body.toLowerCase();
let changed: string | null = null;
if (sentTemperature && /temperature|top_p|sampling/.test(lower)) {
dialect.noSampling = true;
changed = "sampling";
} else if (
plan.kind !== "none" &&
/thinking|budget_tokens|adaptive|output_config|effort/.test(lower)
) {
dialect.thinking = plan.kind === "adaptive" ? "manual" : "none";
changed = plan.kind;
}
if (!changed) return false;
rememberReasoningDialect(this.dialectKey(), JSON.stringify(dialect));
annotateReasoningDowngrade(
"anthropic",
changed,
changed === "sampling" ? "no-sampling" : (dialect.thinking ?? "none"),
);
return true;
}
private send(
url: string,
params: CompletionParams,
req: {
plan: ThinkingPlan;
sendTemperature: boolean;
system: unknown;
wireMessages: AnthropicWireMessage[];
tools: unknown;
toolChoice: unknown;
hasTools: boolean;
},
): Promise<Response> {
const { plan } = req;
const answerBudget = params.maxTokens ?? 1000;
// `max_tokens` bounds thinking and answer together, so the thinking
// budget rides on top of the answer budget; manual mode additionally
// requires `budget_tokens < max_tokens`. It is a ceiling, not a charge.
const maxTokens =
plan.kind === "manual"
? answerBudget + plan.budget
: plan.kind === "adaptive"
? answerBudget + REASONING_THINKING_BUDGET[plan.effort]
: answerBudget;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-api-key": this.config.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
};
if (plan.kind === "manual" && req.hasTools) {
headers["anthropic-beta"] = INTERLEAVED_THINKING_BETA;
}
return safeFetch(
url,
{
method: "POST",
headers,
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
body: JSON.stringify({
model: this.config.model,
max_tokens: maxTokens,
...(req.sendTemperature
? { temperature: params.temperature ?? 0.3 }
: {}),
// Manual: a fixed budget. Adaptive: the model decides how much,
// `effort` steers it, and `display: "summarized"` asks for readable
// thinking where the default would leave it empty (5 generation).
// https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking
...(plan.kind === "manual"
? { thinking: { type: "enabled", budget_tokens: plan.budget } }
: plan.kind === "adaptive"
? {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: plan.effort },
}
: {}),
system: req.system,
messages: req.wireMessages,
...(req.tools ? { tools: req.tools } : {}),
...(req.toolChoice ? { tool_choice: req.toolChoice } : {}),
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
// v1.11.2 — base URL is user/admin-overridable; pin the connect-time DNS
// check so a private/metadata address is rejected (SSRF/rebinding).
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
// The record owner's response-timeout setting, else the surface value, else 60 s.
{
timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
requirePublicHost: true,
signal: params.signal,
},
);
}
}
/** The level a plan delivers, for `downgradedTo`. */
function levelOfPlan(plan: ThinkingPlan): ReasoningLevel {
return plan.kind === "manual"
? plan.level
: plan.kind === "adaptive"
? plan.effort
: "off";
}
function redactAnthropicBody(rawBody: string): string {
return rawBody
.slice(0, 500)
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "sk-ant-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
}
+315 -22
View File
@@ -5,11 +5,19 @@ import type {
AIProvider,
AiContentPart,
AiMessage,
AiProviderState,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
import {
annotateReasoningDowngrade,
learnedReasoningDialect,
rememberReasoningDialect,
} from "./reasoning/dialect-cache";
import type { ReasoningLevel } from "./reasoning/levels";
import { levelOfWireEffort, reasoningTitleOf } from "./reasoning/support";
import {
getCachedCodexSlug,
invalidateCachedCodexSlug,
@@ -140,7 +148,10 @@ type CodexInputItem =
type: "function_call_output";
call_id: string;
output: string;
};
}
// v1.41 — a reasoning item from an earlier round of the same turn, handed
// back exactly as the stream delivered it (encrypted content included).
| Record<string, unknown>;
/**
* Map an `AiContentPart[]` body into Responses input content blocks. Text parts
@@ -175,7 +186,10 @@ function mapCodexParts(
* top-level items the Responses API expects; everything else is a `message`.
* A text-only single user turn is byte-identical to the pre-refactor wire.
*/
function buildCodexInput(messages: AiMessage[]): CodexInputItem[] {
function buildCodexInput(
messages: AiMessage[],
slug: string,
): CodexInputItem[] {
const items: CodexInputItem[] = [];
for (const m of messages) {
if (m.role === "tool") {
@@ -198,6 +212,18 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] {
typeof m.content === "string"
? [{ type: textType, text: m.content }]
: mapCodexParts(m.content, m.role);
// v1.41 — the reasoning items this round produced go back before its
// message and function calls, unchanged: with `store: false` the server
// keeps nothing, and a tool round that loses its reasoning items reasons
// from scratch (https://developers.openai.com/cookbook/examples/responses_api/reasoning_items).
// Only state this client produced for this slug is re-inserted; anything
// else (another provider's state, a slug the chain walked away from) is
// dropped, because an encrypted item is only readable where it was made.
if (m.role === "assistant") {
for (const item of codexStateItems(m.providerState, slug)) {
items.push(item);
}
}
items.push({ type: "message", role: m.role, content });
if (m.role === "assistant" && m.toolCalls) {
for (const tc of m.toolCalls) {
@@ -213,6 +239,149 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] {
return items;
}
/** The reasoning items of `state` when this client made it for `slug`. */
function codexStateItems(
state: AiProviderState | undefined,
slug: string,
): Record<string, unknown>[] {
if (!state || state.providerType !== "codex" || state.model !== slug) {
return [];
}
return state.items.filter(
(item): item is Record<string, unknown> =>
item !== null &&
typeof item === "object" &&
(item as { type?: unknown }).type === "reasoning",
);
}
// ── v1.41 reasoning wire ────────────────────────────────────────────────────
/**
* The Responses-API efforts in ascending order. The Codex backend names the
* values a slug accepts in its 400 body ("Supported values are: 'none',
* 'low', …"), which is how a slug's ladder is learned.
*/
const CODEX_EFFORT_ORDER = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;
/** What one request sends: `null` is today's `reasoning: null`. */
type CodexReasoningWire = { effort: string; summary: boolean } | null;
/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */
interface CodexReasoningDialect {
/** The slug refused `reasoning` altogether. */
disabled?: true;
/** The slug refused `reasoning.summary`. */
noSummary?: true;
/** The efforts the slug named as supported when it refused one. */
efforts?: string[];
}
function dialectKey(slug: string) {
return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug };
}
function readCodexDialect(slug: string): CodexReasoningDialect {
const raw = learnedReasoningDialect(dialectKey(slug));
if (!raw) return {};
try {
return JSON.parse(raw) as CodexReasoningDialect;
} catch {
return {};
}
}
function wantedEffort(level: ReasoningLevel): string {
// "Off" is a real off on the current slugs: a 2026-10-06 probe answered
// `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that
// refuses it learns its lowest supported value instead.
return level === "off" ? "none" : level;
}
/** The supported effort closest to `wanted` from below, else the lowest. */
function nearestEffort(wanted: string, supported: string[]): string | null {
const rank = (e: string) =>
(CODEX_EFFORT_ORDER as readonly string[]).indexOf(e);
const known = supported
.filter((e) => rank(e) >= 0)
.sort((a, b) => rank(a) - rank(b));
if (known.length === 0) return null;
const below = known.filter((e) => rank(e) <= rank(wanted));
return below.length > 0 ? below[below.length - 1] : known[0];
}
function planCodexReasoning(
reasoning: CompletionParams["reasoning"],
slug: string,
): CodexReasoningWire {
if (!reasoning) return null;
const dialect = readCodexDialect(slug);
if (dialect.disabled) return null;
let effort: string | null = wantedEffort(reasoning.effort);
if (dialect.efforts && !dialect.efforts.includes(effort)) {
effort = nearestEffort(effort, dialect.efforts);
}
if (!effort) return null;
return {
effort,
summary: reasoning.summaries && effort !== "none" && !dialect.noSummary,
};
}
/**
* Read a 400 that refuses a reasoning parameter and learn what the slug
* accepts instead. Returns the lesser wire to retry with, or `undefined`
* when the body is not about reasoning (the caller's error path takes it).
*/
function learnFromReasoningRejection(
slug: string,
wire: Exclude<CodexReasoningWire, null>,
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)) {
return undefined;
}
const dialect = readCodexDialect(slug);
let next: CodexReasoningWire;
if (wire.summary && /reasoning\.summary|summary/i.test(body)) {
dialect.noSummary = true;
next = { effort: wire.effort, summary: false };
} else {
const listed = /supported values are:([^"\n]*)/i.exec(body);
const efforts = listed
? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1])
: [];
const nearest =
efforts.length > 0 && !efforts.includes(wire.effort)
? nearestEffort(wire.effort, efforts)
: null;
if (nearest) {
dialect.efforts = efforts;
next = { effort: nearest, summary: wire.summary && nearest !== "none" };
} else {
dialect.disabled = true;
next = null;
}
}
rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect));
annotateReasoningDowngrade(
"codex",
wire.effort,
next ? next.effort : "default",
);
return next;
}
/**
* Map tool defs into the Responses `function` tool shape.
*
@@ -311,7 +480,8 @@ export class CodexClient implements AIProvider {
for (const slug of order) {
attempted.push(slug);
const firstAttempt = await this.doRequest(params, slug);
let wire = planCodexReasoning(params.reasoning, slug);
const firstAttempt = await this.doRequest(params, slug, wire);
if (firstAttempt.status === 401) {
// Token-refresh path — don't walk the chain, this is auth-state
@@ -319,7 +489,7 @@ export class CodexClient implements AIProvider {
const fresh = await this.onTokenRefresh();
this.accessToken = fresh.accessToken;
this.accountId = fresh.accountId;
const retryAfterRefresh = await this.doRequest(params, slug);
const retryAfterRefresh = await this.doRequest(params, slug, wire);
if (retryAfterRefresh.ok) {
this.lastDiagnostics = {
attempted,
@@ -327,7 +497,7 @@ export class CodexClient implements AIProvider {
workingSlug: slug,
};
setCachedCodexSlug(slug);
return this.parseResponse(retryAfterRefresh, slug);
return this.parseResponse(retryAfterRefresh, slug, params, wire);
}
// Even after refresh — surface the auth failure verbatim, do
// NOT walk the chain (auth issues don't get fixed by changing
@@ -339,30 +509,51 @@ export class CodexClient implements AIProvider {
);
}
if (firstAttempt.ok) {
let attempt = firstAttempt;
let rawBody = "";
// v1.41 — a slug that refuses a reasoning parameter is asked again with
// the lesser request it names (no summary, a lower effort, no reasoning
// at all), and the slug remembers it. The answer is never lost to a
// reasoning refusal. Bounded: each step strictly reduces the request.
for (let step = 0; !attempt.ok && step < 3; step += 1) {
rawBody = await attempt.text().catch(() => "");
if (!wire) break;
const next = learnFromReasoningRejection(
slug,
wire,
attempt.status,
redactBody(rawBody),
);
if (next === undefined) break;
wire = next;
rawBody = "";
attempt = await this.doRequest(params, slug, wire);
}
if (attempt.ok) {
this.lastDiagnostics = {
attempted,
cacheState,
workingSlug: slug,
};
setCachedCodexSlug(slug);
return this.parseResponse(firstAttempt, slug);
return this.parseResponse(attempt, slug, params, wire);
}
// Capture the body for the slug-rejection check.
const rawBody = await firstAttempt.text().catch(() => "");
if (!rawBody) rawBody = await attempt.text().catch(() => "");
const bodyExcerpt = redactBody(rawBody);
if (isSlugRejection(firstAttempt.status, bodyExcerpt)) {
if (isSlugRejection(attempt.status, bodyExcerpt)) {
// Slug rejected — drop the cache (in case it pointed here),
// record the failure for diagnostics, and walk to the next
// chain slot.
invalidateCachedCodexSlug();
const slugErr = new Error(
`Codex slug "${slug}" rejected (${firstAttempt.status})`,
`Codex slug "${slug}" rejected (${attempt.status})`,
);
Object.assign(slugErr, {
httpStatus: firstAttempt.status,
httpStatus: attempt.status,
upstream: "codex",
model: slug,
bodyExcerpt,
@@ -382,11 +573,11 @@ export class CodexClient implements AIProvider {
// un-diagnosable. The body is already redacted of bearer/sk- secrets.
const err = new Error(
bodyExcerpt
? `Codex request failed (${firstAttempt.status}): ${bodyExcerpt}`
: `Codex request failed (${firstAttempt.status})`,
? `Codex request failed (${attempt.status}): ${bodyExcerpt}`
: `Codex request failed (${attempt.status})`,
);
Object.assign(err, {
httpStatus: firstAttempt.status,
httpStatus: attempt.status,
upstream: "codex",
model: slug,
bodyExcerpt,
@@ -447,6 +638,7 @@ export class CodexClient implements AIProvider {
private async doRequest(
params: CompletionParams,
slug: string,
wire: CodexReasoningWire,
): Promise<Response> {
const sessionId = randomUUID();
const threadId = randomUUID();
@@ -466,7 +658,7 @@ export class CodexClient implements AIProvider {
// v1.20.0 — `input` is now built from the full message array (multi-turn +
// tool-result turns), with `tools` mapped to the Responses `function` shape
// (the wire already declared `tools`/`tool_choice` — now they carry defs).
const input = buildCodexInput(params.messages);
const input = buildCodexInput(params.messages, slug);
const tools =
params.tools && params.tools.length > 0
? buildCodexTools(params.tools)
@@ -496,15 +688,28 @@ export class CodexClient implements AIProvider {
tools,
tool_choice: toolChoice,
parallel_tool_calls: false,
// Reasoning is required-but-nullable on the wire. We don't ask
// for reasoning summaries — emit `null` so the JSON has the
// field present and the server stops complaining about a
// missing key (it returned 400 in earlier iterations).
reasoning: null,
// Reasoning is required-but-nullable on the wire. Without a
// requested level (every caller before v1.41, and every caller
// that does not opt in) it stays `null`, the field present so the
// server stops complaining about a missing key (it returned 400 in
// earlier iterations). With one, the official client's shape:
// `{ effort, summary }`, `summary` only when summaries are wanted.
// https://developers.openai.com/api/docs/guides/reasoning
reasoning: wire
? {
effort: wire.effort,
...(wire.summary ? { summary: "auto" } : {}),
}
: null,
store: false,
stream: true,
// Required field; empty array when not asking for reasoning.
include: [],
// 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"]
: [],
}),
// SSE streaming completion — match the 60 s budget the other AI
// clients use so a long generation is not clipped by the 15 s
@@ -540,6 +745,8 @@ export class CodexClient implements AIProvider {
private async parseResponse(
res: Response,
requestedSlug: string,
params: CompletionParams,
wire: CodexReasoningWire,
): Promise<CompletionResult> {
if (!res.body) {
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
@@ -563,6 +770,31 @@ export class CodexClient implements AIProvider {
let serverModel: string | null = null;
const toolCalls: AiToolCall[] = [];
// v1.41 — reasoning. Events only reach `onReasoning` when the caller asked
// for reasoning (the contract in `types.ts`); summaries are model text and
// the receiver screens them.
const emit = params.reasoning ? params.onReasoning : undefined;
const reasoningItems: Record<string, unknown>[] = [];
const summaries: string[] = [];
let reasoningTokens: number | null = null;
const summaryBuffers = new Map<string, string>();
const titled = new Set<string>();
const texted = new Set<string>();
const emitTitle = (key: string, text: string) => {
if (!emit || titled.has(key)) return;
const title = reasoningTitleOf(text);
if (title) {
titled.add(key);
emit({ kind: "title", text: title });
}
};
const emitText = (key: string, text: string) => {
if (!emit || texted.has(key) || !text.trim()) return;
emitTitle(key, text);
texted.add(key);
emit({ kind: "text", text });
};
// The server reports the actual routed model in the OpenAI-Model
// response header — useful when safety-routing kicks in.
const headerModel = res.headers.get("OpenAI-Model");
@@ -589,10 +821,14 @@ export class CodexClient implements AIProvider {
let parsed: {
type?: string;
delta?: string;
text?: string;
item_id?: string;
summary_index?: number;
item?: {
type?: string;
role?: string;
content?: Array<{ type?: string; text?: string }>;
summary?: Array<{ type?: string; text?: string }>;
id?: string;
call_id?: string;
name?: string;
@@ -602,6 +838,7 @@ export class CodexClient implements AIProvider {
usage?: {
total_tokens?: number;
input_tokens_details?: { cached_tokens?: number };
output_tokens_details?: { reasoning_tokens?: number };
};
error?: { code?: string; message?: string };
incomplete_details?: { reason?: string };
@@ -619,6 +856,22 @@ export class CodexClient implements AIProvider {
if (parsed.delta) deltaText += parsed.delta;
break;
}
// v1.41 — live reasoning summaries. A summary's first bold line
// is its title; it goes out as soon as it has closed, the full
// text when the part is done.
case "response.reasoning_summary_text.delta": {
const key = `${parsed.item_id ?? ""}:${parsed.summary_index ?? 0}`;
const buffered =
(summaryBuffers.get(key) ?? "") + (parsed.delta ?? "");
summaryBuffers.set(key, buffered);
emitTitle(key, buffered);
break;
}
case "response.reasoning_summary_text.done": {
const key = `${parsed.item_id ?? ""}:${parsed.summary_index ?? 0}`;
emitText(key, parsed.text ?? summaryBuffers.get(key) ?? "");
break;
}
case "response.output_item.done": {
const item = parsed.item;
if (item?.type === "message" && item.role === "assistant") {
@@ -627,6 +880,16 @@ export class CodexClient implements AIProvider {
.map((c) => c.text!)
.join("");
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<string, unknown>);
(item.summary ?? []).forEach((part, index) => {
if (typeof part.text !== "string" || !part.text) return;
summaries.push(part.text);
// A part whose `.done` never arrived still reaches the
// receiver, once.
emitText(`${item.id ?? ""}:${index}`, part.text);
});
} else if (item?.type === "function_call") {
// v1.20.0 — the model asked to call a tool. Surface it for
// F1's loop; the id is the call_id the function_call_output
@@ -644,6 +907,11 @@ export class CodexClient implements AIProvider {
cachedInputTokens =
parsed.response?.usage?.input_tokens_details?.cached_tokens ??
null;
// Reasoning tokens are billed as output and already inside
// `total_tokens`; reported separately for the trail and budget.
reasoningTokens =
parsed.response?.usage?.output_tokens_details
?.reasoning_tokens ?? null;
break;
}
case "response.failed": {
@@ -713,6 +981,19 @@ export class CodexClient implements AIProvider {
throw err;
}
if (emit) emit({ kind: "done", text: "" });
const sentLevel = wire ? levelOfWireEffort(wire.effort) : null;
const reasoning: CompletionResult["reasoning"] = params.reasoning
? {
summary: summaries,
tokens: reasoningTokens,
...(sentLevel !== params.reasoning.effort
? { downgradedTo: sentLevel ?? "off" }
: {}),
}
: undefined;
return {
content,
tokensUsed,
@@ -721,6 +1002,18 @@ export class CodexClient implements AIProvider {
providerType: "codex",
...(toolCalls.length > 0 ? { toolCalls } : {}),
finishReason: toolCalls.length > 0 ? "tool_calls" : "stop",
...(reasoning ? { reasoning } : {}),
// Keyed on the slug that was asked, not the routed model header: the
// next round re-inserts the items only when it asks the same slug.
...(reasoningItems.length > 0
? {
providerState: {
providerType: "codex" as const,
model: requestedSlug,
items: reasoningItems,
},
}
: {}),
};
}
}
+228 -11
View File
@@ -24,6 +24,13 @@ import {
type JsonModeDialect,
} from "./json-dialect";
import type { ReasoningEffort } from "./reasoning-effort";
import {
annotateReasoningDowngrade,
learnedReasoningDialect,
rememberReasoningDialect,
} from "./reasoning/dialect-cache";
import { REASONING_THINKING_BUDGET } from "./reasoning/levels";
import { reasoningTitleOf, splitThinkTags } from "./reasoning/support";
interface LocalClientConfig {
apiKey?: string | null;
@@ -88,6 +95,68 @@ function withStrictJsonPrefix(messages: AiMessage[]): AiMessage[] {
return out;
}
/**
* v1.41 — collects what a call reasoned and reports it to `onReasoning`.
* Local servers return the reasoning as `reasoning_content` (llama.cpp,
* LiteLLM) or `reasoning` (vLLM, Ollama, LM Studio), on the message and on
* each streamed delta, or inline as a leading `<think>…</think>` block.
* https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md,
* https://docs.vllm.ai/en/latest/features/reasoning_outputs.html,
* https://docs.ollama.com/api/openai-compatibility
*/
class ReasoningCollector {
private text = "";
private titled = false;
private finished = false;
constructor(private readonly params: CompletionParams) {}
private get emit() {
return this.params.reasoning ? this.params.onReasoning : undefined;
}
/** A streamed piece of reasoning; the title goes out once it has closed. */
add(delta: string): void {
this.text += delta;
if (!this.titled && this.emit) {
const title = reasoningTitleOf(this.text);
if (title) {
this.titled = true;
this.emit({ kind: "title", text: title });
}
}
}
/** The reasoning is complete (the answer started, or the call ended). */
finish(): void {
if (this.finished) return;
this.finished = true;
const text = this.text.trim();
if (!text || !this.emit) return;
if (!this.titled) {
const title = reasoningTitleOf(text);
if (title) this.emit({ kind: "title", text: title });
}
this.emit({ kind: "text", text });
}
result(tokens: number | null): CompletionResult["reasoning"] {
this.finish();
if (!this.params.reasoning) return undefined;
this.emit?.({ kind: "done", text: "" });
const text = this.text.trim();
return { summary: text ? [text] : [], tokens };
}
}
/** The reasoning text a non-streamed message carries, if any. */
function messageReasoning(message: unknown): string | null {
const m = (message ?? {}) as Record<string, unknown>;
const text = [m.reasoning_content, m.reasoning].find(
(v): v is string => typeof v === "string" && v.trim().length > 0,
);
return text ?? null;
}
/**
* Talks to OpenAI-compatible servers: local runtimes (Ollama, LocalAI,
* LM Studio, vLLM, …) AND hosted gateways (LiteLLM, OpenRouter, any
@@ -158,11 +227,18 @@ export class LocalOpenAICompatibleClient implements AIProvider {
withStrictJsonPrefix(params.messages),
);
const callEffort = this.callReasoningEffort(params);
// Reasoning tokens count against `max_tokens` on these servers; the
// thinking budget rides on top of the answer budget (a ceiling only).
const thinkingTokens =
params.reasoning && callEffort && params.reasoning.effort !== "off"
? REASONING_THINKING_BUDGET[params.reasoning.effort]
: 0;
const body = JSON.stringify({
model: this.config.model,
messages,
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
max_tokens: (params.maxTokens ?? 1000) + thinkingTokens,
// Deterministic seed for reproducible reference output. Ollama +
// most OpenAI-compatible local servers honour it; servers that
// ignore it simply disregard the field. Omitted when unset.
@@ -170,9 +246,11 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// #1126 — the entry's reasoning setting. Default sends nothing, so the
// body of a setup that never touched it stays byte-identical; "Off" is
// `none`, which stops a thinking model spending the answer budget.
...(this.reasoningEffort
// v1.41 — a call that carries its own resolved level wins over it.
...(!params.reasoning && this.reasoningEffort
? { reasoning_effort: this.reasoningEffort }
: {}),
...(callEffort ? { reasoning_effort: callEffort } : {}),
// v1.28.28 (#470) — the JSON surfaces opt in via `responseFormat:
// "json"`. Default dialect sends the STANDARD OpenAI field (the
// top-level Ollama-native `format: "json"` this client used to send
@@ -194,6 +272,27 @@ export class LocalOpenAICompatibleClient implements AIProvider {
return { url, headers, body, jsonDialect };
}
private reasoningDialectKey() {
return {
provider: "local",
endpoint: this.config.baseUrl,
model: this.config.model,
};
}
/**
* The `reasoning_effort` a call with its own resolved level sends (`off`
* is `none`), unless this endpoint refused the field before. `null` for a
* call without one: the entry's #1126 setting applies, exactly as before.
*/
private callReasoningEffort(params: CompletionParams): string | null {
if (!params.reasoning) return null;
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
return null;
}
return params.reasoning.effort === "off" ? "none" : params.reasoning.effort;
}
async generateCompletion(
params: CompletionParams,
): Promise<CompletionResult> {
@@ -243,6 +342,20 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// strip anything that looks like a key before logging.
const rawBody = await res.text().catch(() => "");
const bodyExcerpt = sanitiseBodyExcerpt(rawBody);
// v1.41 — a server that refuses the reasoning field is asked once more
// without it and remembers. Before the JSON dialect, so a complaint
// about reasoning never switches JSON mode off.
const sentEffort = this.callReasoningEffort(params);
if (
sentEffort &&
res.status >= 400 &&
res.status < 500 &&
/reasoning|think/i.test(bodyExcerpt)
) {
rememberReasoningDialect(this.reasoningDialectKey(), "none");
annotateReasoningDowngrade("local", sentEffort, "none");
return this.generateCompletion(params);
}
// v1.28.28 (#470) — dialect self-heal. A 4xx whose body names
// `response_format` (or an unknown parameter) means this endpoint
// rejects the standard JSON flag: learn the no-flag dialect for the
@@ -292,7 +405,10 @@ export class LocalOpenAICompatibleClient implements AIProvider {
message?: { content?: string };
finish_reason?: string;
}>;
usage?: { total_tokens?: number };
usage?: {
total_tokens?: number;
completion_tokens_details?: { reasoning_tokens?: number };
};
};
// Same embedded-failure shape the OpenAI-compatible gateway tag handles:
@@ -307,7 +423,22 @@ export class LocalOpenAICompatibleClient implements AIProvider {
model: this.config.model,
});
const content = json.choices?.[0]?.message?.content;
const collector = new ReasoningCollector(params);
let content = json.choices?.[0]?.message?.content;
if (params.reasoning) {
const fielded = messageReasoning(json.choices?.[0]?.message);
if (fielded) collector.add(fielded);
// Only a caller that asked for reasoning gets the inline block split
// off; every other caller keeps the text exactly as it came.
if (content) {
const split = splitThinkTags(content);
if (split.reasoning) collector.add(split.reasoning);
content = split.content;
}
}
const reasoning = collector.result(
json.usage?.completion_tokens_details?.reasoning_tokens ?? null,
);
if (!content) {
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
// an empty 200-OK reply apart from a transport failure. Cascade unchanged
@@ -329,6 +460,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// v1.28.28 (#470) — surface why the model stopped so the JSON callers
// can tell a token-ceiling truncation ("length") from bad output.
finishReason: mapFinishReason(json.choices?.[0]?.finish_reason),
...(reasoning ? { reasoning } : {}),
};
}
@@ -416,7 +548,18 @@ export class LocalOpenAICompatibleClient implements AIProvider {
label: "local AI endpoint",
model: this.config.model,
});
const buffered = json?.choices?.[0]?.message?.content;
const collector = new ReasoningCollector(params);
let buffered = json?.choices?.[0]?.message?.content;
if (params.reasoning) {
const fielded = messageReasoning(json?.choices?.[0]?.message);
if (fielded) collector.add(fielded);
if (buffered) {
const split = splitThinkTags(buffered);
if (split.reasoning) collector.add(split.reasoning);
buffered = split.content;
}
}
const reasoning = collector.result(null);
if (!buffered) {
const err = new Error("Local AI returned empty content");
Object.assign(err, {
@@ -433,6 +576,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
model: this.config.model,
providerType: "local",
finishReason: mapFinishReason(json?.choices?.[0]?.finish_reason),
...(reasoning ? { reasoning } : {}),
};
}
@@ -441,8 +585,55 @@ export class LocalOpenAICompatibleClient implements AIProvider {
let sseBuffer = "";
let content = "";
let tokensUsed: number | null = null;
let reasoningTokens: number | null = null;
let finishReason: string | undefined;
// v1.41 — reasoning on the stream: the `reasoning_content` / `reasoning`
// delta fields, or a leading `<think>` block inside `content`. Inline
// reasoning never reaches `onDelta`; it would otherwise show up as the
// answer. Only for a caller that asked for reasoning.
const collector = new ReasoningCollector(params);
let thinkState: "detect" | "inside" | "passthrough" = params.reasoning
? "detect"
: "passthrough";
let pending = "";
const passContent = (text: string) => {
if (!text) return;
collector.finish();
content += text;
onDelta(text);
};
const onContent = (delta: string) => {
if (thinkState === "passthrough") {
passContent(delta);
return;
}
pending += delta;
if (thinkState === "detect") {
const lead = pending.replace(/^\s+/, "");
if (lead.length < "<think>".length && "<think>".startsWith(lead)) {
return;
}
if (!lead.startsWith("<think>")) {
thinkState = "passthrough";
const out = pending;
pending = "";
passContent(out);
return;
}
thinkState = "inside";
pending = lead.slice("<think>".length);
}
const close = pending.indexOf("</think>");
if (close === -1) return;
collector.add(pending.slice(0, close));
collector.finish();
const rest = pending.slice(close + "</think>".length).replace(/^\s+/, "");
pending = "";
thinkState = "passthrough";
passContent(rest);
};
// An SSE frame can carry the same embedded failure the buffered body
// carries. Remember the first one; it only matters if the stream ends
// without a single token, in which case it is the honest reason.
@@ -474,11 +665,18 @@ export class LocalOpenAICompatibleClient implements AIProvider {
let chunk: {
error?: unknown;
choices?: Array<{
delta?: { content?: string };
delta?: {
content?: string;
reasoning_content?: string;
reasoning?: string;
};
finish_reason?: string | null;
error?: unknown;
}>;
usage?: { total_tokens?: number };
usage?: {
total_tokens?: number;
completion_tokens_details?: { reasoning_tokens?: number };
};
};
try {
chunk = JSON.parse(payload);
@@ -489,14 +687,19 @@ export class LocalOpenAICompatibleClient implements AIProvider {
if (streamedError === null && readEmbeddedError(chunk) !== null) {
streamedError = chunk;
}
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
content += delta;
onDelta(delta);
const choiceDelta = chunk.choices?.[0]?.delta;
const reasoningDelta =
choiceDelta?.reasoning_content ?? choiceDelta?.reasoning;
if (params.reasoning && typeof reasoningDelta === "string") {
collector.add(reasoningDelta);
}
const delta = choiceDelta?.content;
if (delta) onContent(delta);
if (typeof chunk.usage?.total_tokens === "number") {
tokensUsed = chunk.usage.total_tokens;
}
const rt = chunk.usage?.completion_tokens_details?.reasoning_tokens;
if (typeof rt === "number") reasoningTokens = rt;
const chunkFinish = chunk.choices?.[0]?.finish_reason;
if (typeof chunkFinish === "string") finishReason = chunkFinish;
}
@@ -521,6 +724,19 @@ export class LocalOpenAICompatibleClient implements AIProvider {
reader.cancel().catch(() => {});
}
// A reply short enough to still sit in the `<think>` detector is prose;
// an unclosed block is reasoning that ran out of budget.
// (The closures above move `thinkState`; read it afresh.)
const endState = thinkState as "detect" | "inside" | "passthrough";
if (endState === "detect") {
thinkState = "passthrough";
passContent(pending);
} else if (endState === "inside") {
collector.add(pending);
}
pending = "";
const reasoning = collector.result(reasoningTokens);
if (!content) {
// A stream that ended with a failure frame and no token reports the
// failure, not "empty content".
@@ -544,6 +760,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
model: this.config.model,
providerType: "local",
finishReason: mapFinishReason(finishReason),
...(reasoning ? { reasoning } : {}),
};
}
}
+47
View File
@@ -4,6 +4,7 @@ import type {
CompletionParams,
CompletionResult,
} from "./types";
import { reasoningTitleOf } from "./reasoning/support";
/**
* Deterministic mock AI provider for tests.
@@ -52,6 +53,14 @@ export interface MockAIProviderOptions {
finishReason?: CompletionResult["finishReason"];
/** v1.20.0 — cachedInputTokens echoed on the result. Defaults to null. */
cachedInputTokens?: number | null;
/**
* v1.41 — what a call that asked for reasoning reports. Unset: a fixed
* summary per call (`**Reading the request**` + the call number), 8 tokens,
* and one `providerState` item naming the call. `false`: the mock never
* reasons, like a provider without a reasoning stream. Calls without
* `params.reasoning` (or with `off`) never get either, as on a real client.
*/
reasoning?: { summary: string[]; tokens: number | null } | false;
}
/** Type guard: a per-call array of tool-call sets. */
@@ -82,6 +91,7 @@ export class MockAIProvider implements AIProvider {
private readonly cachedInputTokens: number | null;
private readonly model: string;
private readonly rejectWith: Error | undefined;
private readonly reasoning: MockAIProviderOptions["reasoning"];
private callIdx = 0;
constructor(opts: MockAIProviderOptions = {}) {
@@ -90,6 +100,7 @@ export class MockAIProvider implements AIProvider {
this.rejectWith = opts.rejectWith;
this.finishReason = opts.finishReason;
this.cachedInputTokens = opts.cachedInputTokens ?? null;
this.reasoning = opts.reasoning;
if (isToolCallMatrix(opts.toolCalls)) {
this.toolCalls = opts.toolCalls;
} else if (Array.isArray(opts.toolCalls)) {
@@ -127,6 +138,7 @@ export class MockAIProvider implements AIProvider {
? this.toolCalls[Math.min(this.callIdx, this.toolCalls.length - 1)]
: undefined;
this.callIdx++;
const reasoned = this.reason(params, this.callIdx);
return {
content: this.responses[idx],
tokensUsed: this.tokens[tokIdx] ?? null,
@@ -135,6 +147,41 @@ export class MockAIProvider implements AIProvider {
providerType: this.type,
...(toolCalls ? { toolCalls } : {}),
finishReason: this.finishReason,
...reasoned,
};
}
/**
* v1.41 — deterministic reasoning for a call that asked for it: the same
* events a real client reports (title, text, done) and a `providerState`
* the next round can be checked for.
*/
private reason(
params: CompletionParams,
call: number,
): Pick<CompletionResult, "reasoning" | "providerState"> {
if (!params.reasoning) return {};
if (params.reasoning.effort === "off" || this.reasoning === false) {
params.onReasoning?.({ kind: "done", text: "" });
return { reasoning: { summary: [], tokens: null } };
}
const reasoning = this.reasoning ?? {
summary: [`**Reading the request**\nMock reasoning for call ${call}.`],
tokens: 8,
};
for (const text of reasoning.summary) {
const title = reasoningTitleOf(text);
if (title) params.onReasoning?.({ kind: "title", text: title });
params.onReasoning?.({ kind: "text", text });
}
params.onReasoning?.({ kind: "done", text: "" });
return {
reasoning: { summary: [...reasoning.summary], tokens: reasoning.tokens },
providerState: {
providerType: this.type,
model: this.model,
items: [{ type: "mock-reasoning", call }],
},
};
}
+211 -5
View File
@@ -2,7 +2,23 @@ import { safeFetch } from "@/lib/safe-fetch";
import { callTimeoutMs } from "./effective-timeout";
import { annotate } from "@/lib/logging/context";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import { selectOpenAIChatCompletionsCapabilities } from "./openai-capabilities";
import {
selectOpenAIChatCompletionsCapabilities,
type OpenAIChatCompletionsCapabilities,
} from "./openai-capabilities";
import {
annotateReasoningDowngrade,
learnedReasoningDialect,
rememberReasoningDialect,
} from "./reasoning/dialect-cache";
import { REASONING_THINKING_BUDGET } from "./reasoning/levels";
import {
isOpenRouterEndpoint,
levelOfWireEffort,
openAIOffEffort,
openAIReasoningFamily,
reasoningTitleOf,
} from "./reasoning/support";
import {
hasLearnedJsonModeDialect,
isResponseFormatRejection,
@@ -59,6 +75,26 @@ interface OpenAIClientConfig {
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
/**
* v1.41 — the reasoning one request sends.
* - `reasoning_effort`: the Chat Completions field (OpenAI, LiteLLM, vLLM,
* most gateways). https://platform.openai.com/docs/api-reference/chat/create
* - `openrouter`: OpenRouter's `reasoning: { effort }` object, which also
* returns `reasoning_details` to hand back on tool rounds.
* https://openrouter.ai/docs/use-cases/reasoning-tokens
*/
type OpenAIReasoningWire =
| { kind: "none" }
| { kind: "reasoning_effort"; effort: string }
| { kind: "openrouter"; effort: string };
/**
* Assistant-message fields a gateway returns and needs back unmodified on the
* next tool round: OpenRouter's `reasoning_details` and the `thinking_blocks`
* LiteLLM carries for Claude upstreams (https://docs.litellm.ai/docs/reasoning_content).
*/
const ROUND_TRIP_FIELDS = ["reasoning_details", "thinking_blocks"] as const;
export class OpenAIClient implements AIProvider {
readonly type: OpenAIProviderType;
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
@@ -94,7 +130,10 @@ export class OpenAIClient implements AIProvider {
// conversation turns mapped 1:1. Vision parts (Lab-OCR) become the
// multimodal `image_url` content array `gpt-4o`-class models accept. The
// image is framed as untrusted DATA by the system prompt.
const messages = buildOpenAIMessages(params.system, params.messages);
const messages = this.withRoundTripState(
buildOpenAIMessages(params.system, params.messages),
params,
);
// v1.20.0 — tool plumbing. The defs map onto the OpenAI `function` tool
// wire; F1 supplies real defs and consumes the parsed `toolCalls`. No F4
@@ -122,7 +161,19 @@ export class OpenAIClient implements AIProvider {
this.config.baseUrl,
this.config.model,
);
const tokenBudget = params.maxTokens ?? 1000;
const reasoningWire = this.planReasoning(params, hasTools, capabilities);
// Reasoning tokens count against the completion budget on every one of
// these wires, so the thinking budget rides on top of the answer budget
// (a ceiling, not a charge). Without it a 600-token Coach round spends
// its whole allowance thinking and returns no answer.
const wireLevel =
reasoningWire.kind === "none" || reasoningWire.effort === "none"
? null
: levelOfWireEffort(reasoningWire.effort);
const thinkingTokens = wireLevel
? REASONING_THINKING_BUDGET[wireLevel === "off" ? "low" : wireLevel]
: 0;
const tokenBudget = (params.maxTokens ?? 1000) + thinkingTokens;
const capabilityParams = capabilities.supportsSamplingControls
? {
[capabilities.tokenBudgetField]: tokenBudget,
@@ -157,10 +208,16 @@ export class OpenAIClient implements AIProvider {
...(tools ? { tools } : {}),
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
// #1126 — the gateway entry's reasoning setting; Default sends
// nothing. `api.openai.com` and Codex never get it.
...(this.isGateway && this.reasoningEffort
// nothing. `api.openai.com` and Codex never get it. v1.41 — a call
// that carries its own resolved level (`params.reasoning`) wins.
...(!params.reasoning && this.isGateway && this.reasoningEffort
? { reasoning_effort: this.reasoningEffort }
: {}),
...(reasoningWire.kind === "reasoning_effort"
? { reasoning_effort: reasoningWire.effort }
: reasoningWire.kind === "openrouter"
? { reasoning: { effort: reasoningWire.effort } }
: {}),
}),
},
// 60 s ceiling so a tar-pit upstream cannot pin a worker
@@ -205,6 +262,20 @@ export class OpenAIClient implements AIProvider {
// that looks like an API key from the excerpt before logging.
const rawBody = await res.text().catch(() => "");
const bodyExcerpt = sanitiseBodyExcerpt(rawBody);
// v1.41 — an endpoint or model that refuses the reasoning field is
// asked once more without it and remembers, so the answer never fails
// on a reasoning parameter. Checked before the JSON dialect: a body
// 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)
) {
rememberReasoningDialect(this.reasoningDialectKey(), "none");
annotateReasoningDowngrade(this.type, reasoningWire.effort, "none");
return this.generateCompletion(params);
}
// v1.33.1 (#470) — dialect self-heal, gateway only. A 4xx whose body
// names `response_format` (or an unknown parameter) means this endpoint
// rejects the standard JSON flag: learn the no-flag dialect for the base
@@ -320,6 +391,31 @@ export class OpenAIClient implements AIProvider {
throw err;
}
// v1.41 — what the call reasoned. Gateways return the text as
// `reasoning` (OpenRouter, vLLM, Ollama) or `reasoning_content`
// (LiteLLM, llama.cpp); `api.openai.com` returns none on this wire.
const message = (choice?.message ?? {}) as Record<string, unknown>;
const reasoningText = [message.reasoning, message.reasoning_content].find(
(v): v is string => typeof v === "string" && v.trim().length > 0,
);
const summary = reasoningText ? [reasoningText.trim()] : [];
const emit = params.reasoning ? params.onReasoning : undefined;
if (emit) {
for (const text of summary) {
const title = reasoningTitleOf(text);
if (title) emit({ kind: "title", text: title });
emit({ kind: "text", text });
}
emit({ kind: "done", text: "" });
}
const roundTrip = ROUND_TRIP_FIELDS.filter(
(field) => message[field] !== undefined && message[field] !== null,
).map((field) => ({ [field]: message[field] }));
const usage = json.usage as
{ completion_tokens_details?: { reasoning_tokens?: number } } | undefined;
const learnedNone =
learnedReasoningDialect(this.reasoningDialectKey()) === "none";
return {
content: content ?? "",
tokensUsed: json.usage?.total_tokens ?? null,
@@ -328,6 +424,116 @@ export class OpenAIClient implements AIProvider {
providerType: this.type,
...(toolCalls ? { toolCalls } : {}),
finishReason: mapFinishReason(choice?.finish_reason),
...(params.reasoning
? {
reasoning: {
summary,
// Billed as completion tokens, already inside `total_tokens`.
tokens:
usage?.completion_tokens_details?.reasoning_tokens ?? null,
...(learnedNone && params.reasoning.effort !== "off"
? { downgradedTo: "off" as const }
: {}),
},
}
: {}),
...(roundTrip.length > 0
? {
providerState: {
providerType: this.type,
model: this.config.model,
items: roundTrip,
},
}
: {}),
};
}
// ── v1.41 reasoning ───────────────────────────────────────────────────────
private reasoningDialectKey() {
return {
provider: "openai",
endpoint: this.config.baseUrl,
model: this.config.model,
};
}
/**
* The reasoning this request sends. Absent `params.reasoning` → none here
* (the #1126 gateway setting is applied separately, unchanged).
*
* `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.
*
* Gateway: the endpoint's own dialect, tools or not; its reasoning state
* travels in `providerState`, and a refusal is learned per endpoint.
*/
private planReasoning(
params: CompletionParams,
hasTools: boolean,
capabilities: OpenAIChatCompletionsCapabilities,
): OpenAIReasoningWire {
const reasoning = params.reasoning;
if (!reasoning) return { kind: "none" };
if (learnedReasoningDialect(this.reasoningDialectKey()) === "none") {
return { kind: "none" };
}
const level = reasoning.effort;
if (this.isGateway) {
const effort = level === "off" ? "none" : level;
return isOpenRouterEndpoint(this.config.baseUrl)
? { kind: "openrouter", effort }
: { kind: "reasoning_effort", effort };
}
const family = openAIReasoningFamily(this.config.model);
if (
!family ||
hasTools ||
capabilities.tokenBudgetField !== "max_completion_tokens"
) {
return { kind: "none" };
}
return {
kind: "reasoning_effort",
effort: level === "off" ? openAIOffEffort(family) : level,
};
}
/**
* Put the reasoning state of earlier tool rounds back on their assistant
* messages. `buildOpenAIMessages` maps turn `i` to wire message `i + 1`
* (the system turn leads). Only state this client made for this model is
* used; anything else is dropped.
*/
private withRoundTripState<T extends object>(
wire: T[],
params: CompletionParams,
): T[] {
params.messages.forEach((m, i) => {
const state = m.providerState;
if (
m.role !== "assistant" ||
!state ||
state.providerType !== this.type ||
state.model !== this.config.model
) {
return;
}
const target = wire[i + 1] as Record<string, unknown> | undefined;
if (!target) return;
for (const item of state.items) {
if (!item || typeof item !== "object") continue;
for (const field of ROUND_TRIP_FIELDS) {
if (field in item) {
target[field] = (item as Record<string, unknown>)[field];
}
}
}
});
return wire;
}
}
+14
View File
@@ -413,6 +413,10 @@ async function runRawChain(
surface: BudgetSurface;
ledger?: ProviderHealthLedger;
},
// v1.41 — `params` reaches every candidate unchanged, `reasoning` and
// `onReasoning` included. A fallback hop may see `providerState` another
// provider made on an earlier round; each client re-inserts only its own
// and drops the rest, so nothing is filtered here.
invoke: (candidate: ProviderChainResolved) => Promise<CompletionResult>,
successMeta?: (
candidate: ProviderChainResolved,
@@ -494,6 +498,16 @@ async function runRawChain(
ai_chain_working_provider: candidate.providerType,
ai_chain_fallback_count: hops.length,
...(successMeta ? successMeta(candidate) : {}),
// v1.41 — counts only. The summaries are model text and the
// provider state is opaque; neither ever reaches a wide event. The
// reasoning-token count is `spend`: a key naming tokens is blanked
// by the central redactor.
...(result.reasoning
? {
ai_reasoning_spend: result.reasoning.tokens,
ai_reasoning_summary_count: result.reasoning.summary.length,
}
: {}),
},
});
return {
@@ -0,0 +1,52 @@
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,
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",
},
},
]);
});
});
@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import {
anthropicModelVersion,
anthropicRefusesSampling,
anthropicThinkingDialect,
anthropicThinksAlways,
isOpenRouterEndpoint,
levelOfWireEffort,
openAIOffEffort,
openAIReasoningFamily,
reasoningSupport,
reasoningTitleOf,
splitThinkTags,
} from "../support";
describe("anthropicModelVersion", () => {
it.each([
["claude-sonnet-4-6", { major: 4, minor: 6 }],
["claude-opus-4-7", { major: 4, minor: 7 }],
["claude-opus-5", { major: 5, minor: 0 }],
["claude-opus-5-5", { major: 5, minor: 5 }],
["claude-fable-5-1", { major: 5, minor: 1 }],
["claude-haiku-4-5", { major: 4, minor: 5 }],
// An eight-digit segment is a snapshot date, not a minor.
["claude-opus-4-20250514", { major: 4, minor: 0 }],
["claude-opus-4-1-20250805", { major: 4, minor: 1 }],
["claude-sonnet-4.5", { major: 4, minor: 5 }],
["anthropic/claude-sonnet-4.6", { major: 4, minor: 6 }],
["claude-3-7-sonnet-latest", { major: 3, minor: 7 }],
["claude-3-5-sonnet-latest", { major: 3, minor: 5 }],
])("%s", (model, expected) => {
expect(anthropicModelVersion(model)).toEqual(expected);
});
it("returns null for a name it cannot read", () => {
expect(anthropicModelVersion("my-claude-proxy")).toBeNull();
});
});
describe("anthropicThinkingDialect", () => {
it.each([
["claude-3-5-sonnet-latest", "none"],
["claude-3-7-sonnet-latest", "manual"],
["claude-opus-4-20250514", "manual"],
["claude-haiku-4-5", "manual"],
["claude-sonnet-4-5", "manual"],
// budget_tokens is deprecated on 4.6 and refused from 4.7 on.
["claude-sonnet-4-6", "adaptive"],
["claude-opus-4-7", "adaptive"],
["claude-opus-5", "adaptive"],
["claude-sonnet-5", "adaptive"],
// Unknown names try the current form first and learn from a refusal.
["my-claude-proxy", "adaptive"],
])("%s → %s", (model, dialect) => {
expect(anthropicThinkingDialect(model)).toBe(dialect);
});
it("refuses sampling from 4.7 on, never for an unknown name", () => {
expect(anthropicRefusesSampling("claude-sonnet-4-6")).toBe(false);
expect(anthropicRefusesSampling("claude-opus-4-7")).toBe(true);
expect(anthropicRefusesSampling("claude-sonnet-5")).toBe(true);
expect(anthropicRefusesSampling("claude-opus-5-5")).toBe(true);
expect(anthropicRefusesSampling("my-claude-proxy")).toBe(false);
});
it("thinks always from the 5 generation on", () => {
expect(anthropicThinksAlways("claude-opus-4-8")).toBe(false);
expect(anthropicThinksAlways("claude-opus-5")).toBe(true);
expect(anthropicThinksAlways("claude-sonnet-5")).toBe(true);
});
});
describe("OpenAI families", () => {
it.each([
["gpt-5.5", "gpt-5.1+", "none"],
["gpt-5.1-mini", "gpt-5.1+", "none"],
["gpt-5", "gpt-5", "minimal"],
["gpt-5-mini", "gpt-5", "minimal"],
["o3", "o", "low"],
["o4-mini", "o", "low"],
] as const)("%s", (model, family, off) => {
expect(openAIReasoningFamily(model)).toBe(family);
expect(openAIOffEffort(family)).toBe(off);
});
it("does not treat a non-reasoning model as one", () => {
expect(openAIReasoningFamily("gpt-4o")).toBeNull();
expect(openAIReasoningFamily("gpt-4.1-mini")).toBeNull();
});
it("recognises OpenRouter by host only", () => {
expect(isOpenRouterEndpoint("https://openrouter.ai/api/v1")).toBe(true);
expect(isOpenRouterEndpoint("https://eu.openrouter.ai/api/v1")).toBe(true);
expect(isOpenRouterEndpoint("https://openrouter.ai.evil.example/v1")).toBe(
false,
);
expect(isOpenRouterEndpoint("not a url")).toBe(false);
});
});
describe("reasoningSupport", () => {
it("reports the matrix per provider", () => {
expect(reasoningSupport("codex", "gpt-5.5")).toEqual({
effort: true,
summaries: true,
liveSummaries: true,
stateRoundTrip: true,
offIsReal: true,
});
expect(reasoningSupport("anthropic", "claude-opus-5").offIsReal).toBe(
false,
);
expect(reasoningSupport("anthropic", "claude-sonnet-4-6").offIsReal).toBe(
true,
);
expect(
reasoningSupport("anthropic", "claude-3-5-sonnet-latest").effort,
).toBe(false);
expect(reasoningSupport("admin-key", "gpt-4o").effort).toBe(false);
expect(reasoningSupport("admin-key", "gpt-5").offIsReal).toBe(false);
expect(
reasoningSupport("openai-compatible", "x", {
baseUrl: "https://openrouter.ai/api/v1",
}).stateRoundTrip,
).toBe(true);
expect(
reasoningSupport("openai-compatible", "x", {
baseUrl: "https://litellm.example.com/v1",
}).stateRoundTrip,
).toBe(false);
expect(reasoningSupport("local", "qwen3").liveSummaries).toBe(true);
expect(reasoningSupport("none", "").effort).toBe(false);
});
});
describe("parsing helpers", () => {
it("reads a bold first line as the title, and nothing else", () => {
expect(reasoningTitleOf("**Checking the window**\n\nBody")).toBe(
"Checking the window",
);
expect(reasoningTitleOf("**Checking the window**")).toBe(
"Checking the window",
);
expect(reasoningTitleOf("**Checking the")).toBeNull();
expect(reasoningTitleOf("Plain first line\n**Later**")).toBeNull();
});
it("splits a leading think block and leaves later mentions alone", () => {
expect(splitThinkTags("<think>a plan</think>\n\nThe answer.")).toEqual({
content: "The answer.",
reasoning: "a plan",
});
expect(splitThinkTags("The answer mentions <think>.")).toEqual({
content: "The answer mentions <think>.",
reasoning: null,
});
// Unclosed: the budget ran out inside the reasoning.
expect(splitThinkTags("<think>still going")).toEqual({
content: "",
reasoning: "still going",
});
});
it("maps wire efforts back onto levels", () => {
expect(levelOfWireEffort("none")).toBe("off");
expect(levelOfWireEffort("minimal")).toBe("off");
expect(levelOfWireEffort("medium")).toBe("medium");
expect(levelOfWireEffort("xhigh")).toBe("high");
expect(levelOfWireEffort(null)).toBe("off");
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* v1.41 — what an endpoint answered when it was asked to reason, learned at
* runtime and kept for the life of the process.
*
* 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
* slug whose accepted efforts moved) asks this cache first. When the endpoint
* rejects a parameter the client retries once with the lesser request and
* records the lesser request here, so the next call does not pay the rejected
* round-trip again. A successful call never has to be recorded: the default
* is "send what was asked".
*
* Keyed by provider, endpoint and model, because the same gateway can serve a
* model that reasons and one that does not, and the same model name can sit
* behind two endpoints with different parameter rules. The values are short
* strings each client defines for itself; nothing outside the client reads
* them.
*
* Pattern borrowed from `../json-dialect.ts`, which learns the JSON-mode flag
* the same way. Server-only: the provider clients are its only importers.
*/
import { annotate } from "@/lib/logging/context";
export interface ReasoningDialectKey {
/** The client that learned it (`codex`, `anthropic`, `openai`, `local`). */
provider: string;
/** Base URL or endpoint constant. */
endpoint: string;
model: string;
}
const learned = new Map<string, string>();
function cacheKey(key: ReasoningDialectKey): string {
return `${key.provider}\u0000${key.endpoint}\u0000${key.model}`;
}
/** The learned dialect value for `key`, or `undefined` when nothing was learned. */
export function learnedReasoningDialect(
key: ReasoningDialectKey,
): string | undefined {
return learned.get(cacheKey(key));
}
/** Record what `key` accepts after it refused a richer request. */
export function rememberReasoningDialect(
key: ReasoningDialectKey,
value: string,
): void {
learned.set(cacheKey(key), value);
}
/** Test hook: forget everything learned. */
export function resetReasoningDialectCache(): void {
learned.clear();
}
/**
* Mark the request's wide event with a reasoning downgrade. Meta keys only,
* never an action: the route or job owns the action name dashboards pin on.
* The values are our own vocabulary (provider tag, wire effort), never model
* or upstream text.
*/
export function annotateReasoningDowngrade(
provider: string,
from: string,
to: string,
): void {
annotate({
meta: {
ai_reasoning_downgraded: true,
ai_reasoning_downgraded_provider: provider,
ai_reasoning_downgraded_from: from,
ai_reasoning_downgraded_to: to,
},
});
}
+297
View File
@@ -0,0 +1,297 @@
/**
* v1.41 — what each provider can do with reasoning, read from the provider
* type and the model name alone.
*
* No admin keeps a model list. A name this file does not recognise is treated
* optimistically: the client sends the request, and if the endpoint refuses a
* parameter it retries without it and learns the refusal per endpoint and
* model (`dialect-cache.ts`). What is reported here is therefore the starting
* assumption, not a promise; the settings use `offIsReal` to label the lowest
* option "Off" or "Minimal".
*
* The wire facts behind each branch, with sources:
*
* - Codex backend: Responses API `reasoning: { effort, summary }` plus
* `include: ["reasoning.encrypted_content"]` for the stateless round trip.
* A live probe on 2026-10-06 against the current slug (`gpt-5.5`) answered
* `effort: "none"` with a non-reasoning reply and rejected `minimal` with
* "Supported values are: 'none', 'low', 'medium', 'high', and 'xhigh'",
* so "off" is real there. https://developers.openai.com/api/docs/guides/reasoning,
* https://developers.openai.com/cookbook/examples/responses_api/reasoning_items
* - OpenAI Chat Completions: `reasoning_effort`, no summaries and no
* reasoning items to hand back between tool rounds, so reasoning is only
* sent on rounds without tools. `none` exists from gpt-5.1 on; gpt-5 knows
* `minimal`; the o-series starts at `low`.
* https://platform.openai.com/docs/api-reference/chat/create
* - Anthropic: manual `thinking: { type: "enabled", budget_tokens }` up to
* the 4.5 generation; adaptive `thinking: { type: "adaptive" }` with
* `output_config.effort` from 4.6 on (`budget_tokens` is deprecated on 4.6
* and refused from 4.7). From the 5 generation thinking is on whatever the
* request says, so "off" becomes the lowest effort. Sampling parameters
* are refused from 4.7 on.
* https://platform.claude.com/docs/en/build-with-claude/extended-thinking,
* https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking,
* https://platform.claude.com/docs/en/build-with-claude/effort
* - OpenRouter: `reasoning: { effort }` with `none` and `minimal` accepted,
* reply `reasoning` (text) and `reasoning_details[]`, which must go back
* unmodified on the assistant message of a tool round.
* https://openrouter.ai/docs/use-cases/reasoning-tokens
* - Other OpenAI-compatible gateways and local servers: `reasoning_effort`,
* reply `reasoning_content` (llama.cpp, LiteLLM) or `reasoning` (vLLM,
* Ollama, LM Studio), some models inline `<think>…</think>`.
* https://docs.litellm.ai/docs/reasoning_content,
* https://docs.vllm.ai/en/latest/features/reasoning_outputs.html,
* https://docs.ollama.com/api/openai-compatibility,
* https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
*
* Client-safe: no server import.
*/
import type { ProviderType } from "../types";
import type { ReasoningLevel } from "./levels";
export interface ReasoningSupport {
/** The provider takes an effort (or a budget) at all. */
effort: boolean;
/** It returns readable summaries of the reasoning. */
summaries: boolean;
/** Summaries arrive while the call runs, not only after it. */
liveSummaries: boolean;
/** Reasoning state has to travel between tool rounds (`providerState`). */
stateRoundTrip: boolean;
/** "Off" switches reasoning off; false means it falls to the lowest level. */
offIsReal: boolean;
}
const NONE: ReasoningSupport = {
effort: false,
summaries: false,
liveSummaries: false,
stateRoundTrip: false,
offIsReal: true,
};
// ── Anthropic ───────────────────────────────────────────────────────────────
export interface AnthropicModelVersion {
major: number;
minor: number;
}
/**
* The generation of a Claude model id: `claude-<tier>-<major>[-<minor>][-<date>]`
* from Claude 4 on (`claude-sonnet-4-6`, `claude-opus-5`, `claude-opus-4-20250514`),
* `claude-<major>[-<minor>]-<tier>` before (`claude-3-7-sonnet-latest`). A
* dotted minor (`claude-sonnet-4.5`, the gateway spelling) reads the same. An
* eight-digit segment is a snapshot date, not a minor. `null` for anything
* else (a proxy alias, a typo): the client then tries the current dialect and
* learns from the answer.
*/
export function anthropicModelVersion(
model: string,
): AnthropicModelVersion | null {
const m = model.toLowerCase().replace(/^anthropic\//, "");
const modern =
/^claude-(?:opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:[-.](\d{1,2}))?(?:$|[-@:])/.exec(
m,
);
if (modern) {
return { major: Number(modern[1]), minor: Number(modern[2] ?? 0) };
}
const legacy = /^claude-(\d)(?:[-.](\d))?-(?:opus|sonnet|haiku)/.exec(m);
if (legacy) {
return { major: Number(legacy[1]), minor: Number(legacy[2] ?? 0) };
}
return null;
}
function atLeast(
v: AnthropicModelVersion,
major: number,
minor: number,
): boolean {
return v.major > major || (v.major === major && v.minor >= minor);
}
/**
* How a Claude model is asked to think. `manual` is the budget form (up to
* 4.5, and 3.7), `adaptive` the effort form (4.6 on, and every name this
* function cannot read), `none` a model that cannot think (3.5 and older).
*/
export type AnthropicThinkingDialect = "adaptive" | "manual" | "none";
export function anthropicThinkingDialect(
model: string,
): AnthropicThinkingDialect {
const v = anthropicModelVersion(model);
if (!v) return "adaptive";
if (v.major < 3) return "none";
if (v.major === 3) return v.minor >= 7 ? "manual" : "none";
return atLeast(v, 4, 6) ? "adaptive" : "manual";
}
/**
* True when the model refuses `temperature` / `top_p` / `top_k` (4.7 on; the
* 5 generation refuses any value, including the default). Unknown names
* return false: the client keeps today's wire and learns from a refusal.
*/
export function anthropicRefusesSampling(model: string): boolean {
const v = anthropicModelVersion(model);
return v !== null && atLeast(v, 4, 7);
}
/** True from the 5 generation on: thinking runs whatever the request says. */
export function anthropicThinksAlways(model: string): boolean {
const v = anthropicModelVersion(model);
return v !== null && v.major >= 5;
}
// ── OpenAI ──────────────────────────────────────────────────────────────────
/**
* The reasoning family of an OpenAI model id, or `null` for a model that does
* not reason (`gpt-4o`, `gpt-4.1`). `gpt-5.1+` knows `none`, bare `gpt-5`
* (and its mini / nano) stops at `minimal`, the o-series at `low`.
*/
export type OpenAIReasoningFamily = "gpt-5.1+" | "gpt-5" | "o" | null;
export function openAIReasoningFamily(model: string): OpenAIReasoningFamily {
const m = model.toLowerCase().replace(/^openai\//, "");
const gpt5 = /^gpt-5(?:\.(\d+))?(?:$|-)/.exec(m);
if (gpt5) return gpt5[1] && Number(gpt5[1]) >= 1 ? "gpt-5.1+" : "gpt-5";
if (/^o[134](?:$|-)/.test(m)) return "o";
return null;
}
/** The lowest `reasoning_effort` an OpenAI reasoning family accepts. */
export function openAIOffEffort(
family: Exclude<OpenAIReasoningFamily, null>,
): "none" | "minimal" | "low" {
return family === "gpt-5.1+"
? "none"
: family === "gpt-5"
? "minimal"
: "low";
}
/** True for an OpenRouter base URL, whose reasoning dialect differs. */
export function isOpenRouterEndpoint(baseUrl: string): boolean {
try {
const host = new URL(baseUrl).hostname.toLowerCase();
return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
} catch {
return false;
}
}
// ── Support matrix ──────────────────────────────────────────────────────────
/**
* What `providerType` serving `model` can do with reasoning. `baseUrl` only
* matters for the gateway tag (OpenRouter has its own dialect) and the
* operator's OpenAI provider (a non-OpenAI base URL is a gateway in all but
* name).
*/
export function reasoningSupport(
providerType: ProviderType,
model: string,
opts: { baseUrl?: string } = {},
): ReasoningSupport {
switch (providerType) {
case "codex":
return {
effort: true,
summaries: true,
liveSummaries: true,
stateRoundTrip: true,
offIsReal: true,
};
case "anthropic": {
const dialect = anthropicThinkingDialect(model);
if (dialect === "none") return NONE;
return {
effort: true,
summaries: true,
liveSummaries: false,
stateRoundTrip: true,
offIsReal: !anthropicThinksAlways(model),
};
}
case "admin-key": {
const family = openAIReasoningFamily(model);
if (!family) return NONE;
return {
effort: true,
summaries: false,
liveSummaries: false,
stateRoundTrip: false,
offIsReal: family === "gpt-5.1+",
};
}
case "openai-compatible": {
const openRouter = opts.baseUrl
? isOpenRouterEndpoint(opts.baseUrl)
: false;
return {
effort: true,
summaries: true,
liveSummaries: false,
stateRoundTrip: openRouter,
offIsReal: true,
};
}
case "local":
return {
effort: true,
summaries: true,
liveSummaries: true,
stateRoundTrip: false,
offIsReal: true,
};
case "none":
return NONE;
}
}
// ── Shared parsing helpers ──────────────────────────────────────────────────
/**
* The title of a reasoning summary: its first line when that line is wholly
* bold (`**Checking the window**`), the convention the Responses API
* summaries follow. `null` when the summary has no such line yet or at all.
*/
export function reasoningTitleOf(text: string): string | null {
const m = /^\s*\*\*([^*\n]+?)\*\*\s*(?:\n|$)/.exec(text);
return m ? m[1].trim() : null;
}
/**
* Split a reply that inlines its reasoning as a leading `<think>…</think>`
* block (some local models do). Only a block at the very start counts; a
* mention later in the prose stays prose. An unclosed block means the answer
* budget ran out inside the reasoning: everything is reasoning, no answer.
*/
export function splitThinkTags(content: string): {
content: string;
reasoning: string | null;
} {
const m = /^\s*<think>([\s\S]*?)(<\/think>|$)/.exec(content);
if (!m) return { content, reasoning: null };
const reasoning = m[1].trim();
const rest = m[2] ? content.slice(m[0].length).replace(/^\s+/, "") : "";
return { content: rest, reasoning: reasoning.length > 0 ? reasoning : null };
}
/** The level a provider's wire effort corresponds to, for `downgradedTo`. */
export function levelOfWireEffort(effort: string | null): ReasoningLevel {
switch (effort) {
case "low":
case "medium":
case "high":
return effort;
case "xhigh":
case "max":
return "high";
default:
return "off";
}
}