mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge remote-tracking branch 'origin/release/v1.41.0' into v141/b-loop
This commit is contained in:
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
@@ -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
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user