auto-model-router 0.4.13 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+
3
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
4
+ import type { RouterConfig } from "../src/config/types.ts";
5
+ import { EMPTY_USAGE } from "../src/cost/types.ts";
6
+ import { startServer, type StartedServer } from "../src/server/http.ts";
7
+ import { readFileSync } from "node:fs";
8
+ import { anthropicIdentityHeaders, countAnthropicTokens, sessionFromUserId, createMessagesBufferedSink, createMessagesStreamingSink, mapAnthropicModel, messagesToChatBody, parseMessagesRequest, renderAnthropicError } from "../src/wire/anthropic/messages.ts";
9
+ import type { StreamEvent, TurnSummary, UpstreamChunk, UpstreamMutations } from "../src/wire/types.ts";
10
+
11
+ /**
12
+ * The Anthropic Messages wire Claude Code speaks: a Messages body becomes the
13
+ * chat shape the core understands (system, tool_use/tool_result, images,
14
+ * tools, tool_choice, thinking), the identity is derived from what Claude
15
+ * Code sends, and the upstream stream is rendered back as Messages events or
16
+ * one Message object, with the Anthropic error envelope on failure.
17
+ */
18
+
19
+ const MUT: UpstreamMutations = { slug: "x/y", fallbacks: [], sessionId: "s", cacheBreakpointMessageIndices: [], reasoning: undefined, maxTokens: undefined, stripAssistantReasoning: false };
20
+
21
+ const CLAUDE_CODE_BODY = {
22
+ model: "claude-sonnet-4-5-20250929",
23
+ max_tokens: 32000,
24
+ system: [
25
+ { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.", cache_control: { type: "ephemeral" } },
26
+ { type: "text", text: "# Environment\nWorking directory: E:/projects/x" },
27
+ ],
28
+ messages: [
29
+ { role: "user", content: [{ type: "text", text: "read package.json and tell me the version" }] },
30
+ { role: "assistant", content: [{ type: "thinking", thinking: "I should read the file.", signature: "abc" }, { type: "text", text: "Reading it." }, { type: "tool_use", id: "toolu_01", name: "Read", input: { file_path: "package.json" } }] },
31
+ { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_01", content: [{ type: "text", text: '{"version":"1.2.3"}' }] }, { type: "text", text: "thanks" }] },
32
+ ],
33
+ tools: [
34
+ { name: "Read", description: "Reads a file", input_schema: { type: "object", properties: { file_path: { type: "string" } }, required: ["file_path"] } },
35
+ { type: "web_search_20250305", name: "web_search", max_uses: 5 },
36
+ { type: "text_editor_20250728", name: "str_replace_based_edit_tool" },
37
+ ],
38
+ tool_choice: { type: "auto", disable_parallel_tool_use: true },
39
+ metadata: { user_id: "user_9f8e_account_a1b2c3d4-0000-4000-8000-000000000001_session_0c8d5f1e-1234-4bcd-9abc-def012345678" },
40
+ thinking: { type: "enabled", budget_tokens: 4096 },
41
+ stream: true,
42
+ };
43
+
44
+ describe("messagesToChatBody", () => {
45
+ test("translates a Claude Code turn: system blocks, tool_use/tool_result, custom tools only, tool_choice, thinking budget", () => {
46
+ const b = messagesToChatBody(CLAUDE_CODE_BODY);
47
+ expect(b.model).toBe("auto");
48
+ const messages = b.messages as { role: string; content: unknown; tool_calls?: unknown; tool_call_id?: string }[];
49
+ expect(messages.map((m) => m.role)).toEqual(["system", "user", "assistant", "tool", "user"]);
50
+ expect(messages[0]!.content).toBe("You are Claude Code, Anthropic's official CLI for Claude.\n# Environment\nWorking directory: E:/projects/x");
51
+ expect(messages[1]!.content).toBe("read package.json and tell me the version");
52
+ expect(messages[2]).toEqual({ role: "assistant", content: "Reading it.", tool_calls: [{ id: "toolu_01", type: "function", function: { name: "Read", arguments: '{"file_path":"package.json"}' } }] });
53
+ expect(messages[3]).toEqual({ role: "tool", tool_call_id: "toolu_01", content: '{"version":"1.2.3"}' });
54
+ expect(messages[4]!.content).toBe("thanks");
55
+ const tools = b.tools as { type: string; function: { name: string; parameters: unknown } }[];
56
+ expect(tools.map((t) => t.function.name)).toEqual(["Read"]); // server and Anthropic-schema tools dropped
57
+ expect(tools[0]!.function.parameters).toEqual(CLAUDE_CODE_BODY.tools[0]!.input_schema);
58
+ expect(b.tool_choice).toBe("auto");
59
+ expect(b.parallel_tool_calls).toBe(false);
60
+ expect(b.max_tokens).toBe(32000);
61
+ expect(b.reasoning).toEqual({ effort: "medium" });
62
+ expect(b.stream).toBe(true);
63
+ for (const k of ["system", "metadata", "thinking", "tool_choice_anthropic", "cache_control"]) expect(k in b && k !== "tool_choice").toBe(false);
64
+ });
65
+
66
+ test("string bodies, images, documents, error tool results, tool_choice variants, stop sequences, effort", () => {
67
+ const b = messagesToChatBody({
68
+ model: "auto-max",
69
+ system: "sys",
70
+ max_tokens: 10,
71
+ stop_sequences: ["END", 5],
72
+ temperature: 0.2,
73
+ top_k: 3,
74
+ messages: [
75
+ { role: "user", content: [{ type: "text", text: "look" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } }, { type: "document", title: "spec.pdf", source: { type: "base64", media_type: "application/pdf", data: "x" } }] },
76
+ { role: "assistant", content: "ok" },
77
+ { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "boom", is_error: true }] },
78
+ ],
79
+ tools: [{ type: "custom", name: "f", input_schema: { type: "object" } }],
80
+ tool_choice: { type: "tool", name: "f" },
81
+ thinking: { type: "disabled" },
82
+ output_config: { effort: "xhigh" },
83
+ });
84
+ expect(b.model).toBe("auto-max"); // profile ids pass through
85
+ const messages = b.messages as { role: string; content: unknown; tool_call_id?: string }[];
86
+ expect(messages[0]).toEqual({ role: "system", content: "sys" });
87
+ expect(messages[1]!.content).toEqual([{ type: "text", text: "look" }, { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, { type: "text", text: "[document: spec.pdf]" }]);
88
+ expect(messages[3]).toEqual({ role: "tool", tool_call_id: "t1", content: "[tool error] boom" });
89
+ expect(b.stop).toEqual(["END"]);
90
+ expect(b.temperature).toBe(0.2);
91
+ expect(b.top_k).toBe(3);
92
+ expect(b.tool_choice).toEqual({ type: "function", function: { name: "f" } });
93
+ expect(b.reasoning).toEqual({ effort: "xhigh" }); // output_config wins over thinking
94
+ expect(messagesToChatBody({ model: "m", messages: [{ role: "user", content: "x" }], tools: [{ name: "f", input_schema: {} }], tool_choice: { type: "any" } }).tool_choice).toBe("required");
95
+ expect(messagesToChatBody({ model: "m", messages: [{ role: "user", content: "x" }], tool_choice: { type: "any" } }).tool_choice).toBeUndefined(); // no tools ⇒ no choice
96
+ expect(messagesToChatBody({ model: "m", messages: [{ role: "user", content: "x" }], thinking: { type: "enabled", budget_tokens: 30000 } }).reasoning).toEqual({ effort: "high" });
97
+ expect(messagesToChatBody({ model: "m", messages: [{ role: "user", content: "x" }], thinking: { type: "adaptive" } }).reasoning).toEqual({ effort: "medium" });
98
+ });
99
+
100
+ test("rejects what cannot be a turn", () => {
101
+ expect(() => messagesToChatBody("nope")).toThrow("JSON object");
102
+ expect(() => messagesToChatBody({ model: "", messages: [{ role: "user", content: "x" }] })).toThrow("model");
103
+ expect(() => messagesToChatBody({ model: "m", messages: [] })).toThrow("messages");
104
+ expect(() => messagesToChatBody({ model: "m", messages: [{ role: "tool", content: "x" }] })).toThrow("user, assistant or system");
105
+ expect(() => messagesToChatBody({ model: "m", messages: [{ role: "user", content: 5 }] })).toThrow("content");
106
+ });
107
+
108
+ test("model names map by glob, first match wins, profile ids pass through", () => {
109
+ expect(mapAnthropicModel("claude-haiku-4-5-20251001")).toBe("auto-cheap");
110
+ expect(mapAnthropicModel("claude-opus-4-8")).toBe("auto");
111
+ expect(mapAnthropicModel("auto-sub")).toBe("auto-sub");
112
+ expect(mapAnthropicModel("claude-opus-4-8", { "claude-opus-*": "auto-max", "claude-*": "auto" })).toBe("auto-max");
113
+ expect(mapAnthropicModel("Claude-Sonnet-5", { "claude-*": "auto" })).toBe("auto"); // case-insensitive
114
+ });
115
+ });
116
+
117
+ describe("parseMessagesRequest", () => {
118
+ test("derives the harness from the user agent and the session from metadata; explicit headers win; the rendered body is chat-shaped", () => {
119
+ const headers = new Headers({ "user-agent": "claude-cli/2.1.263 (external, cli)", "x-api-key": "k", "anthropic-version": "2023-06-01" });
120
+ const norm = parseMessagesRequest(CLAUDE_CODE_BODY, headers);
121
+ expect(norm.protocol).toBe("anthropic-messages");
122
+ expect(norm.harnessId).toBe("claude-code");
123
+ expect(norm.ompSessionId).toBe("0c8d5f1e-1234-4bcd-9abc-def012345678");
124
+ expect(norm.requestedModel).toBe("auto");
125
+ expect(norm.tools.map((t) => t.name)).toEqual(["Read"]);
126
+ expect(norm.forcedToolChoice).toBe(false);
127
+ expect(norm.reasoning).toBe("medium");
128
+ expect(norm.stream).toBe(true);
129
+ expect(norm.messages.at(-1)?.text).toBe("thanks");
130
+ const body = norm.renderUpstreamBody({ ...MUT, cacheBreakpointMessageIndices: [0] });
131
+ expect(body.model).toBe("x/y");
132
+ expect((body.messages as { content: unknown }[])[0]!.content).toEqual([{ type: "text", text: expect.stringContaining("Claude Code"), cache_control: { type: "ephemeral" } }]);
133
+ expect("system" in body).toBe(false);
134
+ expect("metadata" in body).toBe(false);
135
+ const explicit = anthropicIdentityHeaders(CLAUDE_CODE_BODY, new Headers({ "x-omp-harness": "mine", "x-omp-session": "s-1" }));
136
+ expect(explicit.get("x-omp-harness")).toBe("mine");
137
+ expect(explicit.get("x-omp-session")).toBe("s-1");
138
+ expect(anthropicIdentityHeaders({}, new Headers({ "user-agent": "python-requests" })).get("x-omp-harness")).toBe("anthropic");
139
+ });
140
+
141
+ test("a request captured from Claude Code 2.1: system inside messages, JSON user_id, adaptive thinking with effort, 23 custom tools", () => {
142
+ const fixture = JSON.parse(readFileSync("test/fixtures/harness/claude-code.json", "utf8")) as { headers: Record<string, string>; body: Record<string, unknown> };
143
+ const norm = parseMessagesRequest(fixture.body, new Headers(fixture.headers));
144
+ expect(norm.harnessId).toBe("claude-code");
145
+ expect(norm.ompSessionId).toBe("a2321f8a-1ce0-44f7-831b-839a35035f9c");
146
+ expect(norm.requestedModel).toBe("auto");
147
+ expect(norm.tools).toHaveLength(23);
148
+ expect(norm.tools.map((t) => t.name)).toContain("Read");
149
+ expect(norm.messages.map((m) => m.role)).toEqual(["system", "user", "system"]);
150
+ expect(norm.reasoning).toBe("high"); // output_config.effort over adaptive thinking
151
+ expect(norm.maxTokens).toBe(64000);
152
+ expect(norm.stream).toBe(true);
153
+ const body = norm.renderUpstreamBody(MUT);
154
+ for (const k of ["system", "metadata", "thinking", "context_management", "output_config"]) expect(k in body).toBe(false);
155
+ expect((body.tools as unknown[]).length).toBe(23);
156
+ expect(sessionFromUserId("user_9f8e_account_a1b2_session_0c8d5f1e-1234-4bcd-9abc-def012345678")).toBe("0c8d5f1e-1234-4bcd-9abc-def012345678");
157
+ expect(sessionFromUserId("nothing here")).toBeNull();
158
+ });
159
+
160
+ test("count_tokens estimates from the prompt bytes", () => {
161
+ expect(countAnthropicTokens(CLAUDE_CODE_BODY, DEFAULT_CONFIG.anthropic.models, null)).toBeGreaterThan(50);
162
+ expect(() => countAnthropicTokens({ model: "m", messages: [] }, {}, null)).toThrow("messages");
163
+ });
164
+ });
165
+
166
+ // ---------------------------------------------------------------- rendering
167
+
168
+ const chunk = (events: StreamEvent[]): UpstreamChunk => ({ raw: {}, events });
169
+ const summary: TurnSummary = { servedSlug: "anthropic/claude-sonnet-5", tier: "moderate", attempts: 1, predictedUsd: 0.01, reportedUsd: 0.012, usage: { ...EMPTY_USAGE, promptTokens: 1200, cachedTokens: 900, cacheWriteTokens: 100, completionTokens: 40, reasoningTokens: 10 }, reasons: ["r"], escalated: false };
170
+
171
+ async function drain(res: Response): Promise<{ type: string; data: Record<string, unknown> }[]> {
172
+ const text = await res.text();
173
+ const out: { type: string; data: Record<string, unknown> }[] = [];
174
+ for (const frame of text.split("\n\n")) {
175
+ const ev = /^event: (.+)$/m.exec(frame)?.[1];
176
+ const data = /^data: (.+)$/m.exec(frame)?.[1];
177
+ if (ev !== undefined && data !== undefined) out.push({ type: ev, data: JSON.parse(data) as Record<string, unknown> });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ describe("Messages rendering", () => {
183
+ test("streams thinking, text and a tool call as Messages events with Anthropic usage and the routing summary", async () => {
184
+ const { sink, response } = createMessagesStreamingSink("claude-sonnet-4-5");
185
+ sink.chunk(chunk([{ type: "start", servedSlug: "anthropic/claude-sonnet-5", generationId: "g" }]));
186
+ sink.chunk(chunk([{ type: "reasoning", delta: "Let me " }, { type: "reasoning", delta: "think." }]));
187
+ sink.chunk(chunk([{ type: "text", delta: "Hel" }, { type: "text", delta: "lo" }]));
188
+ sink.chunk(chunk([{ type: "tool_call", index: 0, id: "call_1", name: "Read", argsDelta: '{"file_' }]));
189
+ sink.chunk(chunk([{ type: "tool_call", index: 0, argsDelta: 'path":"a.ts"}' }]));
190
+ sink.chunk(chunk([{ type: "finish", reason: "tool_calls" }, { type: "usage", usage: summary.usage, reportedCostUsd: 0.012 }]));
191
+ sink.finish(summary);
192
+ const events = await drain(response);
193
+ expect(events.map((e) => e.type)).toEqual(["message_start", "ping", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop"]);
194
+ const start = events[0]!.data.message as { model: string; role: string; content: unknown[] };
195
+ expect(start).toMatchObject({ model: "claude-sonnet-4-5", role: "assistant", content: [] });
196
+ expect(events[2]!.data.content_block).toEqual({ type: "thinking", thinking: "" });
197
+ expect(events[3]!.data.delta).toEqual({ type: "thinking_delta", thinking: "Let me " });
198
+ expect(events[6]!.data).toMatchObject({ index: 1, content_block: { type: "text", text: "" } });
199
+ expect(events[7]!.data.delta).toEqual({ type: "text_delta", text: "Hel" });
200
+ expect(events[10]!.data).toMatchObject({ index: 2, content_block: { type: "tool_use", id: "call_1", name: "Read", input: {} } });
201
+ expect(events[11]!.data.delta).toEqual({ type: "input_json_delta", partial_json: '{"file_' });
202
+ const delta = events[14]!.data as { delta: { stop_reason: string }; usage: Record<string, number>; x_auto_model_router: { model: string } };
203
+ expect(delta.delta.stop_reason).toBe("tool_use");
204
+ expect(delta.usage).toEqual({ input_tokens: 200, cache_read_input_tokens: 900, cache_creation_input_tokens: 100, output_tokens: 40 });
205
+ expect(delta.x_auto_model_router.model).toBe("anthropic/claude-sonnet-5");
206
+ });
207
+
208
+ test("a buffered turn is one Message with parsed tool input, end_turn, and the summary headers", async () => {
209
+ const { sink, response } = createMessagesBufferedSink("claude-opus-4-8");
210
+ sink.chunk(chunk([{ type: "start", servedSlug: "x/y", generationId: null }, { type: "text", delta: "done" }, { type: "finish", reason: "stop" }]));
211
+ sink.finish(summary);
212
+ const res = await response;
213
+ expect(res.status).toBe(200);
214
+ expect(res.headers.get("x-auto-model-router-tier")).toBe("moderate");
215
+ const msg = (await res.json()) as { type: string; role: string; model: string; content: unknown[]; stop_reason: string; usage: Record<string, number>; x_auto_model_router: unknown };
216
+ expect(msg).toMatchObject({ type: "message", role: "assistant", model: "claude-opus-4-8", content: [{ type: "text", text: "done" }], stop_reason: "end_turn", usage: { output_tokens: 40 } });
217
+
218
+ const tool = createMessagesBufferedSink("m");
219
+ tool.sink.chunk(chunk([{ type: "tool_call", index: 0, id: "t", name: "f", argsDelta: '{"a":1}' }, { type: "tool_call", index: 1, id: "u", name: "g", argsDelta: "not json" }, { type: "finish", reason: "length" }]));
220
+ tool.sink.finish(summary);
221
+ const m2 = (await (await tool.response).json()) as { content: { type: string; id?: string; name?: string; input?: unknown }[]; stop_reason: string };
222
+ expect(m2.content).toEqual([{ type: "tool_use", id: "t", name: "f", input: { a: 1 } }, { type: "tool_use", id: "u", name: "g", input: {} }]);
223
+ expect(m2.stop_reason).toBe("max_tokens");
224
+ });
225
+
226
+ test("failures use the Anthropic envelope, streaming as an error event", async () => {
227
+ const { sink, response } = createMessagesStreamingSink("m");
228
+ sink.chunk(chunk([{ type: "start", servedSlug: "x/y", generationId: null }]));
229
+ sink.error({ status: 429, code: "rate_limited", message: "slow down" });
230
+ const events = await drain(response);
231
+ expect(events.at(-1)).toEqual({ type: "error", data: { type: "error", error: { type: "rate_limit_error", message: "slow down", code: "rate_limited" } } });
232
+ const buffered = createMessagesBufferedSink("m");
233
+ buffered.sink.error({ status: 502, code: "upstream", message: "no" });
234
+ const res = await buffered.response;
235
+ expect(res.status).toBe(502);
236
+ expect(((await res.json()) as { error: { type: string } }).error.type).toBe("api_error");
237
+ expect(renderAnthropicError({ status: 401, code: "u", message: "m" })).toMatchObject({ type: "error", error: { type: "authentication_error" } });
238
+ expect(renderAnthropicError({ status: 529, code: "o", message: "m" })).toMatchObject({ error: { type: "overloaded_error" } });
239
+ });
240
+ });
241
+
242
+ // ---------------------------------------------------------------- HTTP
243
+
244
+ describe("POST /v1/messages over HTTP", () => {
245
+ let handle: StartedServer;
246
+ let base: string;
247
+ beforeAll(() => {
248
+ const cfg: RouterConfig = {
249
+ ...DEFAULT_CONFIG,
250
+ server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 4, subagentProfile: "auto-sub", apiKey: "rk" },
251
+ ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" },
252
+ context: { ...DEFAULT_CONFIG.context, enabled: false },
253
+ logLevel: "silent",
254
+ };
255
+ handle = startServer(cfg);
256
+ base = `http://127.0.0.1:${handle.server.port}`;
257
+ });
258
+ afterAll(async () => {
259
+ await handle.stop();
260
+ });
261
+
262
+ test("x-api-key authenticates like a bearer, and refusals come in the Anthropic envelope", async () => {
263
+ const body = JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 5, messages: [{ role: "user", content: "hi" }] });
264
+ const noKey = await fetch(`${base}/v1/messages`, { method: "POST", headers: { "content-type": "application/json" }, body });
265
+ expect(noKey.status).toBe(401);
266
+ expect(((await noKey.json()) as { type: string; error: { type: string } })).toMatchObject({ type: "error", error: { type: "authentication_error" } });
267
+ const bad = await fetch(`${base}/v1/messages`, { method: "POST", headers: { "content-type": "application/json", "x-api-key": "rk" }, body: "{" });
268
+ expect(bad.status).toBe(400);
269
+ expect(((await bad.json()) as { error: { type: string } }).error.type).toBe("invalid_request_error");
270
+ const noMessages = await fetch(`${base}/v1/messages`, { method: "POST", headers: { "content-type": "application/json", "x-api-key": "rk" }, body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 5, messages: [] }) });
271
+ expect(noMessages.status).toBe(400);
272
+ expect(((await noMessages.json()) as { error: { message: string } }).error.message).toContain("messages");
273
+ // The OpenAI routes keep their own envelope.
274
+ const chat = await fetch(`${base}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, body });
275
+ expect(((await chat.json()) as { error: { type: string } }).error.type).toBe("authentication_error");
276
+ expect("type" in ((await (await fetch(`${base}/v1/models`, { headers: { "x-api-key": "rk" } })).json()) as Record<string, unknown>)).toBe(false);
277
+ });
278
+
279
+ test("count_tokens answers with an estimate", async () => {
280
+ const res = await fetch(`${base}/v1/messages/count_tokens`, { method: "POST", headers: { "content-type": "application/json", "x-api-key": "rk", "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: "claude-sonnet-4-5", system: "You are terse.", messages: [{ role: "user", content: "Summarise the repository layout in three lines." }] }) });
281
+ expect(res.status).toBe(200);
282
+ const n = ((await res.json()) as { input_tokens: number }).input_tokens;
283
+ expect(n).toBeGreaterThan(10);
284
+ expect(n).toBeLessThan(100);
285
+ expect((await fetch(`${base}/v1/messages/count_tokens`, { method: "POST", headers: { "content-type": "application/json", "x-api-key": "rk" }, body: "nope" })).status).toBe(400);
286
+ });
287
+ });
@@ -77,6 +77,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
77
77
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [], dailySummary: false },
80
+ anthropic: { models: { "*haiku*": "auto-cheap", "claude-*": "auto" } },
80
81
  harnessSwitch: { enabled: false, models: {}, minConfidence: 0.6 },
81
82
  digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000, toolAliases: {} },
82
83
  profiles: [],