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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +52 -0
- package/package.json +1 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +7 -0
- package/src/cli/export.ts +29 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +11 -0
- package/src/cost/views.ts +153 -0
- package/src/index.ts +5 -0
- package/src/lib.ts +1 -0
- package/src/server/http.ts +52 -9
- package/src/wire/anthropic/messages.ts +481 -0
- package/src/wire/types.ts +1 -1
- package/test/anthropic-wire.test.ts +287 -0
- package/test/failover.test.ts +1 -0
- package/test/fixtures/harness/claude-code.json +858 -0
- package/test/turn.test.ts +1 -0
- package/test/views.test.ts +161 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages API front end (`POST /v1/messages`), the wire Claude Code
|
|
3
|
+
* speaks. Two halves, like the Responses wire:
|
|
4
|
+
*
|
|
5
|
+
* - Request: a Messages body is translated into the chat-completions shape
|
|
6
|
+
* the rest of the router understands, then parsed by the existing parser.
|
|
7
|
+
* `system` (string or text blocks) becomes the system message; content
|
|
8
|
+
* blocks become chat parts (`tool_use` → an assistant tool call,
|
|
9
|
+
* `tool_result` → a tool message, `image` → an image part); custom tools
|
|
10
|
+
* become chat tools; `tool_choice`, `stop_sequences`, `thinking` and
|
|
11
|
+
* `output_config.effort` map to their chat equivalents. Server-side tools
|
|
12
|
+
* (web search, code execution) and Anthropic-schema client tools have no
|
|
13
|
+
* upstream meaning and are dropped; replayed `thinking` blocks are dropped
|
|
14
|
+
* (the router's thinking blocks carry no signature, so nothing is lost).
|
|
15
|
+
* Client `cache_control` markers are dropped too: the router plans cache
|
|
16
|
+
* breakpoints itself and Anthropic allows four.
|
|
17
|
+
* - Response: the upstream chat stream is re-rendered as Messages SSE events
|
|
18
|
+
* (message_start … message_stop) with text, tool_use and thinking blocks,
|
|
19
|
+
* or, for non-streaming callers, one Message object. The routing summary
|
|
20
|
+
* rides on `message_delta` as `x_auto_model_router`, as the other wires do.
|
|
21
|
+
*
|
|
22
|
+
* Model names: Claude Code asks for `claude-*` models. A glob table maps them
|
|
23
|
+
* to router profiles (haiku → the cheap profile, everything else → `auto`);
|
|
24
|
+
* profile names pass through, so `auto-max` still means what it means.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { encoder, sseDataFrame } from "../../util/sse.ts";
|
|
28
|
+
import type { Ledger } from "../../cost/types.ts";
|
|
29
|
+
import { estimateTokens } from "../../tokens/estimate.ts";
|
|
30
|
+
import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk, WireError } from "../types.ts";
|
|
31
|
+
import { invalidRequest, WireErrorException } from "../openai/errors.ts";
|
|
32
|
+
import { parseChatRequest } from "../openai/request.ts";
|
|
33
|
+
|
|
34
|
+
type Rec = Record<string, unknown>;
|
|
35
|
+
const isRec = (v: unknown): v is Rec => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Model names
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Order matters: the first glob that matches wins. */
|
|
42
|
+
export const DEFAULT_ANTHROPIC_MODELS: Record<string, string> = { "*haiku*": "auto-cheap", "claude-*": "auto" };
|
|
43
|
+
|
|
44
|
+
function globMatch(glob: string, s: string): boolean {
|
|
45
|
+
const re = new RegExp(`^${glob.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
|
|
46
|
+
return re.test(s);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The router profile a Messages `model` means; unmatched names pass through (they may be profile ids). */
|
|
50
|
+
export function mapAnthropicModel(model: string, models: Record<string, string> = DEFAULT_ANTHROPIC_MODELS): string {
|
|
51
|
+
for (const [glob, target] of Object.entries(models)) if (globMatch(glob, model)) return target;
|
|
52
|
+
return model;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Request translation
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function textBlocks(v: unknown): string {
|
|
60
|
+
if (typeof v === "string") return v;
|
|
61
|
+
if (!Array.isArray(v)) return "";
|
|
62
|
+
return v
|
|
63
|
+
.filter(isRec)
|
|
64
|
+
.map((b) => (b.type === "text" && typeof b.text === "string" ? b.text : b.type === "image" ? "[image]" : b.type === "document" ? "[document]" : ""))
|
|
65
|
+
.filter((t) => t !== "")
|
|
66
|
+
.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function imagePart(block: Rec): Rec | null {
|
|
70
|
+
const src = block.source;
|
|
71
|
+
if (!isRec(src)) return null;
|
|
72
|
+
if (src.type === "base64" && typeof src.data === "string") return { type: "image_url", image_url: { url: `data:${typeof src.media_type === "string" ? src.media_type : "image/png"};base64,${src.data}` } };
|
|
73
|
+
if (src.type === "url" && typeof src.url === "string") return { type: "image_url", image_url: { url: src.url } };
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One Messages message → one or more chat messages (tool results become tool messages, first). */
|
|
78
|
+
function translateMessage(raw: unknown, index: number): Rec[] {
|
|
79
|
+
if (!isRec(raw)) throw invalidRequest(`messages[${index}] must be an object`);
|
|
80
|
+
const role = raw.role;
|
|
81
|
+
// Claude Code 2.1 also places system-role messages inside `messages`.
|
|
82
|
+
if (role !== "user" && role !== "assistant" && role !== "system" && role !== "developer") throw invalidRequest(`messages[${index}].role must be user, assistant or system`);
|
|
83
|
+
const content = raw.content;
|
|
84
|
+
if (role === "system" || role === "developer") return [{ role: "system", content: typeof content === "string" ? content : textBlocks(content) }];
|
|
85
|
+
if (typeof content === "string") return [{ role, content }];
|
|
86
|
+
if (!Array.isArray(content)) throw invalidRequest(`messages[${index}].content must be a string or an array of content blocks`);
|
|
87
|
+
|
|
88
|
+
if (role === "user") {
|
|
89
|
+
const toolMessages: Rec[] = [];
|
|
90
|
+
const parts: Rec[] = [];
|
|
91
|
+
for (const block of content) {
|
|
92
|
+
if (!isRec(block)) continue;
|
|
93
|
+
switch (block.type) {
|
|
94
|
+
case "tool_result": {
|
|
95
|
+
const body = textBlocks(block.content);
|
|
96
|
+
toolMessages.push({ role: "tool", tool_call_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "", content: block.is_error === true ? `[tool error] ${body}` : body });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "text":
|
|
100
|
+
if (typeof block.text === "string") parts.push({ type: "text", text: block.text });
|
|
101
|
+
break;
|
|
102
|
+
case "image": {
|
|
103
|
+
const p = imagePart(block);
|
|
104
|
+
if (p !== null) parts.push(p);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "document":
|
|
108
|
+
parts.push({ type: "text", text: typeof block.title === "string" ? `[document: ${block.title}]` : "[document]" });
|
|
109
|
+
break;
|
|
110
|
+
default:
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const out = [...toolMessages];
|
|
115
|
+
if (parts.length > 0) out.push({ role: "user", content: parts.every((p) => p.type === "text") ? parts.map((p) => p.text as string).join("\n") : parts });
|
|
116
|
+
if (out.length === 0) out.push({ role: "user", content: "" });
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const texts: string[] = [];
|
|
121
|
+
const toolCalls: Rec[] = [];
|
|
122
|
+
for (const block of content) {
|
|
123
|
+
if (!isRec(block)) continue;
|
|
124
|
+
if (block.type === "text" && typeof block.text === "string") texts.push(block.text);
|
|
125
|
+
else if (block.type === "tool_use") {
|
|
126
|
+
toolCalls.push({ id: typeof block.id === "string" ? block.id : `toolu_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`, type: "function", function: { name: typeof block.name === "string" ? block.name : "", arguments: JSON.stringify(isRec(block.input) ? block.input : {}) } });
|
|
127
|
+
}
|
|
128
|
+
// thinking / redacted_thinking: dropped on replay.
|
|
129
|
+
}
|
|
130
|
+
const text = texts.join("\n");
|
|
131
|
+
return [{ role: "assistant", content: text === "" ? null : text, ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}) }];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
135
|
+
|
|
136
|
+
/** The chat-completions body a Messages request means. */
|
|
137
|
+
export function messagesToChatBody(body: unknown, models: Record<string, string> = DEFAULT_ANTHROPIC_MODELS): Rec {
|
|
138
|
+
if (!isRec(body)) throw invalidRequest("Request body must be a JSON object");
|
|
139
|
+
if (typeof body.model !== "string" || body.model === "") throw invalidRequest("model must be a non-empty string");
|
|
140
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) throw invalidRequest("messages must be a non-empty array");
|
|
141
|
+
const out: Rec = { model: mapAnthropicModel(body.model, models) };
|
|
142
|
+
|
|
143
|
+
const messages: Rec[] = [];
|
|
144
|
+
const system = typeof body.system === "string" ? body.system : textBlocks(body.system);
|
|
145
|
+
if (system !== "") messages.push({ role: "system", content: system });
|
|
146
|
+
body.messages.forEach((m, i) => messages.push(...translateMessage(m, i)));
|
|
147
|
+
out.messages = messages;
|
|
148
|
+
|
|
149
|
+
if (Array.isArray(body.tools)) {
|
|
150
|
+
const tools: Rec[] = [];
|
|
151
|
+
for (const t of body.tools) {
|
|
152
|
+
if (!isRec(t) || typeof t.name !== "string") continue;
|
|
153
|
+
// Anything with a versioned type is a server tool or an Anthropic-schema client tool: nothing upstream can serve it.
|
|
154
|
+
if (typeof t.type === "string" && t.type !== "custom") continue;
|
|
155
|
+
tools.push({ type: "function", function: { name: t.name, ...(typeof t.description === "string" ? { description: t.description } : {}), parameters: isRec(t.input_schema) ? t.input_schema : { type: "object", properties: {} } } });
|
|
156
|
+
}
|
|
157
|
+
if (tools.length > 0) out.tools = tools;
|
|
158
|
+
}
|
|
159
|
+
const tc = body.tool_choice;
|
|
160
|
+
if (isRec(tc) && out.tools !== undefined) {
|
|
161
|
+
if (tc.type === "auto") out.tool_choice = "auto";
|
|
162
|
+
else if (tc.type === "any") out.tool_choice = "required";
|
|
163
|
+
else if (tc.type === "none") out.tool_choice = "none";
|
|
164
|
+
else if (tc.type === "tool" && typeof tc.name === "string") out.tool_choice = { type: "function", function: { name: tc.name } };
|
|
165
|
+
if (tc.disable_parallel_tool_use === true) out.parallel_tool_calls = false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (typeof body.max_tokens === "number") out.max_tokens = body.max_tokens;
|
|
169
|
+
if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) out.stop = body.stop_sequences.filter((s) => typeof s === "string");
|
|
170
|
+
for (const k of ["temperature", "top_p", "top_k"]) if (typeof body[k] === "number") out[k] = body[k];
|
|
171
|
+
|
|
172
|
+
const th = body.thinking;
|
|
173
|
+
if (isRec(th)) {
|
|
174
|
+
if (th.type === "enabled") {
|
|
175
|
+
const budget = typeof th.budget_tokens === "number" ? th.budget_tokens : 0;
|
|
176
|
+
out.reasoning = { effort: budget <= 2048 ? "low" : budget <= 8192 ? "medium" : "high" };
|
|
177
|
+
} else if (th.type === "adaptive") out.reasoning = { effort: "medium" };
|
|
178
|
+
else if (th.type === "disabled") out.reasoning = { enabled: false };
|
|
179
|
+
}
|
|
180
|
+
const oc = body.output_config;
|
|
181
|
+
if (isRec(oc) && typeof oc.effort === "string" && EFFORTS.has(oc.effort)) out.reasoning = { effort: oc.effort };
|
|
182
|
+
|
|
183
|
+
out.stream = body.stream === true;
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The session id inside `metadata.user_id`: Claude Code 2.1 sends JSON with `session_id`; older builds `…_session_<uuid>`. */
|
|
188
|
+
export function sessionFromUserId(userId: string): string | null {
|
|
189
|
+
try {
|
|
190
|
+
const parsed: unknown = JSON.parse(userId);
|
|
191
|
+
if (isRec(parsed) && typeof parsed.session_id === "string" && parsed.session_id !== "") return parsed.session_id;
|
|
192
|
+
} catch {
|
|
193
|
+
/* not JSON */
|
|
194
|
+
}
|
|
195
|
+
const m = /session(?:_id)?["']?\s*[:=_]\s*["']?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(userId);
|
|
196
|
+
return m === null ? null : m[1]!;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Claude Code carries no router headers, so identity is derived: the harness
|
|
201
|
+
* from its user agent (`claude-cli/…` ⇒ `claude-code`), the session from the
|
|
202
|
+
* `metadata.user_id` it sends. Explicit headers win.
|
|
203
|
+
*/
|
|
204
|
+
export function anthropicIdentityHeaders(body: unknown, headers: Headers): Headers {
|
|
205
|
+
const h = new Headers(headers);
|
|
206
|
+
if ((h.get("x-omp-harness") ?? "").trim() === "") h.set("x-omp-harness", /claude-cli/i.test(h.get("user-agent") ?? "") ? "claude-code" : "anthropic");
|
|
207
|
+
if ((h.get("x-omp-session") ?? "").trim() === "" && isRec(body) && isRec(body.metadata) && typeof body.metadata.user_id === "string") {
|
|
208
|
+
const session = sessionFromUserId(body.metadata.user_id);
|
|
209
|
+
if (session !== null) h.set("x-omp-session", session);
|
|
210
|
+
}
|
|
211
|
+
return h;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function parseMessagesRequest(body: unknown, headers: Headers, models: Record<string, string> = DEFAULT_ANTHROPIC_MODELS): NormRequest {
|
|
215
|
+
const norm = parseChatRequest(messagesToChatBody(body, models), anthropicIdentityHeaders(body, headers));
|
|
216
|
+
return { ...norm, protocol: "anthropic-messages" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `POST /v1/messages/count_tokens`: the router's own estimate over the prompt bytes. */
|
|
220
|
+
export function countAnthropicTokens(body: unknown, models: Record<string, string>, ledger: Ledger | null): number {
|
|
221
|
+
const norm = parseChatRequest(messagesToChatBody({ ...(isRec(body) ? body : {}), stream: false }, models), new Headers());
|
|
222
|
+
return estimateTokens(norm.promptBytes, "anthropic", ledger);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Errors
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function anthropicErrorType(status: number): string {
|
|
230
|
+
if (status === 400) return "invalid_request_error";
|
|
231
|
+
if (status === 401) return "authentication_error";
|
|
232
|
+
if (status === 403) return "permission_error";
|
|
233
|
+
if (status === 404) return "not_found_error";
|
|
234
|
+
if (status === 429) return "rate_limit_error";
|
|
235
|
+
if (status === 529) return "overloaded_error";
|
|
236
|
+
return status >= 500 ? "api_error" : "invalid_request_error";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The Anthropic error envelope. */
|
|
240
|
+
export function renderAnthropicError(err: WireError): Rec {
|
|
241
|
+
return { type: "error", error: { type: anthropicErrorType(err.status), message: err.message, code: err.code } };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function anthropicErrorResponse(err: WireError): Response {
|
|
245
|
+
return new Response(JSON.stringify(renderAnthropicError(err)), { status: err.status, headers: { "content-type": "application/json" } });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export { WireErrorException };
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Response rendering
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
type Block = { type: "text"; text: string } | { type: "thinking"; thinking: string } | { type: "tool_use"; id: string; name: string; args: string };
|
|
255
|
+
|
|
256
|
+
function summaryFields(summary: TurnSummary): Rec {
|
|
257
|
+
return { model: summary.servedSlug, tier: summary.tier, cost_usd: summary.reportedUsd ?? summary.predictedUsd, attempts: summary.attempts };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Anthropic counts cache reads and writes outside `input_tokens`; OpenAI-style prompt tokens include them. */
|
|
261
|
+
function usageJson(summary: TurnSummary): Rec {
|
|
262
|
+
const u = summary.usage;
|
|
263
|
+
return {
|
|
264
|
+
input_tokens: Math.max(0, u.promptTokens - u.cachedTokens - u.cacheWriteTokens),
|
|
265
|
+
cache_read_input_tokens: u.cachedTokens,
|
|
266
|
+
cache_creation_input_tokens: u.cacheWriteTokens,
|
|
267
|
+
output_tokens: u.completionTokens,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function blockJson(b: Block): Rec {
|
|
272
|
+
if (b.type === "text") return { type: "text", text: b.text };
|
|
273
|
+
if (b.type === "thinking") return { type: "thinking", thinking: b.thinking };
|
|
274
|
+
let input: unknown = {};
|
|
275
|
+
try {
|
|
276
|
+
const parsed: unknown = b.args === "" ? {} : JSON.parse(b.args);
|
|
277
|
+
input = isRec(parsed) ? parsed : {};
|
|
278
|
+
} catch {
|
|
279
|
+
input = {};
|
|
280
|
+
}
|
|
281
|
+
return { type: "tool_use", id: b.id, name: b.name, input };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const STOP: Record<string, string> = { stop: "end_turn", length: "max_tokens", tool_calls: "tool_use", content_filter: "end_turn", error: "end_turn" };
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Turns the upstream chat stream into Messages content blocks, emitting the
|
|
288
|
+
* standard event sequence through `emit`. Shared by the streaming and the
|
|
289
|
+
* buffered sink; the buffered one simply ignores the events.
|
|
290
|
+
*/
|
|
291
|
+
class MessageBuilder {
|
|
292
|
+
readonly id = `msg_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`;
|
|
293
|
+
readonly blocks: Block[] = [];
|
|
294
|
+
private started = false;
|
|
295
|
+
private open: number | null = null;
|
|
296
|
+
private stopReason: string | null = null;
|
|
297
|
+
private readonly toolBlockByIndex = new Map<number, number>();
|
|
298
|
+
|
|
299
|
+
constructor(
|
|
300
|
+
private readonly model: string,
|
|
301
|
+
private readonly emit: (type: string, payload: Rec) => void,
|
|
302
|
+
) {}
|
|
303
|
+
|
|
304
|
+
start(): void {
|
|
305
|
+
if (this.started) return;
|
|
306
|
+
this.started = true;
|
|
307
|
+
this.emit("message_start", { type: "message_start", message: { id: this.id, type: "message", role: "assistant", content: [], model: this.model, stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
|
|
308
|
+
this.emit("ping", { type: "ping" });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private closeOpen(): void {
|
|
312
|
+
if (this.open === null) return;
|
|
313
|
+
this.emit("content_block_stop", { type: "content_block_stop", index: this.open });
|
|
314
|
+
this.open = null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private openBlock(block: Block): number {
|
|
318
|
+
this.closeOpen();
|
|
319
|
+
this.blocks.push(block);
|
|
320
|
+
const index = this.blocks.length - 1;
|
|
321
|
+
this.open = index;
|
|
322
|
+
const start = block.type === "tool_use" ? { type: "tool_use", id: block.id, name: block.name, input: {} } : block.type === "text" ? { type: "text", text: "" } : { type: "thinking", thinking: "" };
|
|
323
|
+
this.emit("content_block_start", { type: "content_block_start", index, content_block: start });
|
|
324
|
+
return index;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private current(type: "text" | "thinking"): { index: number; block: Block } {
|
|
328
|
+
if (this.open !== null) {
|
|
329
|
+
const open = this.blocks[this.open];
|
|
330
|
+
if (open !== undefined && open.type === type) return { index: this.open, block: open };
|
|
331
|
+
}
|
|
332
|
+
const block: Block = type === "text" ? { type: "text", text: "" } : { type: "thinking", thinking: "" };
|
|
333
|
+
return { index: this.openBlock(block), block };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
chunk(chunk: UpstreamChunk): void {
|
|
337
|
+
for (const ev of chunk.events) {
|
|
338
|
+
if (ev.type === "start") this.start();
|
|
339
|
+
else if (ev.type === "text") {
|
|
340
|
+
if (ev.delta === "") continue;
|
|
341
|
+
this.start();
|
|
342
|
+
const { index, block } = this.current("text");
|
|
343
|
+
if (block.type === "text") block.text += ev.delta;
|
|
344
|
+
this.emit("content_block_delta", { type: "content_block_delta", index, delta: { type: "text_delta", text: ev.delta } });
|
|
345
|
+
} else if (ev.type === "reasoning") {
|
|
346
|
+
if (ev.delta === "") continue;
|
|
347
|
+
this.start();
|
|
348
|
+
const { index, block } = this.current("thinking");
|
|
349
|
+
if (block.type === "thinking") block.thinking += ev.delta;
|
|
350
|
+
this.emit("content_block_delta", { type: "content_block_delta", index, delta: { type: "thinking_delta", thinking: ev.delta } });
|
|
351
|
+
} else if (ev.type === "tool_call") {
|
|
352
|
+
this.start();
|
|
353
|
+
let index = this.toolBlockByIndex.get(ev.index);
|
|
354
|
+
if (index === undefined) {
|
|
355
|
+
index = this.openBlock({ type: "tool_use", id: ev.id ?? `toolu_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`, name: ev.name ?? "", args: "" });
|
|
356
|
+
this.toolBlockByIndex.set(ev.index, index);
|
|
357
|
+
}
|
|
358
|
+
const block = this.blocks[index];
|
|
359
|
+
if (block === undefined || block.type !== "tool_use") continue;
|
|
360
|
+
if (ev.name !== undefined && block.name === "") block.name = ev.name;
|
|
361
|
+
if (ev.argsDelta !== undefined && ev.argsDelta !== "") {
|
|
362
|
+
block.args += ev.argsDelta;
|
|
363
|
+
this.emit("content_block_delta", { type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: ev.argsDelta } });
|
|
364
|
+
}
|
|
365
|
+
} else if (ev.type === "finish") {
|
|
366
|
+
this.stopReason = STOP[ev.reason] ?? "end_turn";
|
|
367
|
+
this.closeOpen();
|
|
368
|
+
}
|
|
369
|
+
// usage: carried by the summary.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private stop(): string {
|
|
374
|
+
return this.stopReason ?? (this.blocks.some((b) => b.type === "tool_use") ? "tool_use" : "end_turn");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
message(summary: TurnSummary): Rec {
|
|
378
|
+
return { id: this.id, type: "message", role: "assistant", model: this.model, content: this.blocks.map(blockJson), stop_reason: this.stop(), stop_sequence: null, usage: usageJson(summary), x_auto_model_router: summaryFields(summary) };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
finish(summary: TurnSummary): Rec {
|
|
382
|
+
this.start();
|
|
383
|
+
this.closeOpen();
|
|
384
|
+
this.emit("message_delta", { type: "message_delta", delta: { stop_reason: this.stop(), stop_sequence: null }, usage: usageJson(summary), x_auto_model_router: summaryFields(summary) });
|
|
385
|
+
this.emit("message_stop", { type: "message_stop" });
|
|
386
|
+
return this.message(summary);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fail(error: WireError): void {
|
|
390
|
+
this.emit("error", renderAnthropicError(error));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function createMessagesStreamingSink(virtualModel: string): { sink: ResponseSink; response: Response } {
|
|
395
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
396
|
+
let closed = false;
|
|
397
|
+
const body = new ReadableStream<Uint8Array>({
|
|
398
|
+
start(c) {
|
|
399
|
+
controller = c;
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
const send = (text: string): void => {
|
|
403
|
+
if (closed) return;
|
|
404
|
+
try {
|
|
405
|
+
controller?.enqueue(encoder.encode(text));
|
|
406
|
+
} catch {
|
|
407
|
+
closed = true;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const close = (): void => {
|
|
411
|
+
if (closed) return;
|
|
412
|
+
closed = true;
|
|
413
|
+
try {
|
|
414
|
+
controller?.close();
|
|
415
|
+
} catch {
|
|
416
|
+
// Already closed by the runtime.
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const builder = new MessageBuilder(virtualModel, (type, payload) => send(`event: ${type}\n${sseDataFrame(payload)}`));
|
|
420
|
+
const sink: ResponseSink = {
|
|
421
|
+
chunk(chunk) {
|
|
422
|
+
builder.chunk(chunk);
|
|
423
|
+
},
|
|
424
|
+
error(error) {
|
|
425
|
+
builder.fail(error);
|
|
426
|
+
close();
|
|
427
|
+
},
|
|
428
|
+
finish(summary) {
|
|
429
|
+
builder.finish(summary);
|
|
430
|
+
close();
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
return { sink, response: new Response(body, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" } }) };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function createMessagesBufferedSink(virtualModel: string): { sink: ResponseSink; response: Promise<Response> } {
|
|
437
|
+
let resolve!: (r: Response) => void;
|
|
438
|
+
const response = new Promise<Response>((r) => {
|
|
439
|
+
resolve = r;
|
|
440
|
+
});
|
|
441
|
+
let settled = false;
|
|
442
|
+
const builder = new MessageBuilder(virtualModel, () => {});
|
|
443
|
+
const sink: ResponseSink = {
|
|
444
|
+
chunk(chunk) {
|
|
445
|
+
builder.chunk(chunk);
|
|
446
|
+
},
|
|
447
|
+
error(error) {
|
|
448
|
+
if (settled) return;
|
|
449
|
+
settled = true;
|
|
450
|
+
resolve(anthropicErrorResponse(error));
|
|
451
|
+
},
|
|
452
|
+
finish(summary) {
|
|
453
|
+
if (settled) return;
|
|
454
|
+
settled = true;
|
|
455
|
+
const f = summaryFields(summary);
|
|
456
|
+
resolve(
|
|
457
|
+
new Response(JSON.stringify(builder.finish(summary)), {
|
|
458
|
+
status: 200,
|
|
459
|
+
headers: {
|
|
460
|
+
"content-type": "application/json",
|
|
461
|
+
"x-auto-model-router-model": String(f.model),
|
|
462
|
+
"x-auto-model-router-tier": String(f.tier),
|
|
463
|
+
"x-auto-model-router-cost-usd": String(f.cost_usd),
|
|
464
|
+
"x-auto-model-router-attempts": String(f.attempts),
|
|
465
|
+
},
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
return { sink, response };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The wire's `model` for the client: the name it asked for, so its own bookkeeping matches. */
|
|
474
|
+
export function createMessagesWire(models: Record<string, string>): {
|
|
475
|
+
parse(body: unknown, headers: Headers): NormRequest;
|
|
476
|
+
streaming(model: string): { sink: ResponseSink; response: Response };
|
|
477
|
+
buffered(model: string): { sink: ResponseSink; response: Promise<Response> };
|
|
478
|
+
error(err: WireError): Response;
|
|
479
|
+
} {
|
|
480
|
+
return { parse: (body, headers) => parseMessagesRequest(body, headers, models), streaming: createMessagesStreamingSink, buffered: createMessagesBufferedSink, error: anthropicErrorResponse };
|
|
481
|
+
}
|
package/src/wire/types.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { UsageCounts } from "../cost/types.ts";
|
|
12
12
|
|
|
13
|
-
export type WireProtocol = "openai-chat" | "openai-responses" | "pi-native";
|
|
13
|
+
export type WireProtocol = "openai-chat" | "openai-responses" | "anthropic-messages" | "pi-native";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* A routing policy attached to one request. `allow`/`deny` are slug globs
|