auto-model-router 0.12.0 → 0.13.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 +50 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +16 -1
- package/src/catalog/static-catalog.ts +89 -0
- package/src/catalog/types.ts +2 -2
- package/src/config/apply.ts +5 -1
- package/src/config/defaults.ts +3 -0
- package/src/config/load.ts +3 -0
- package/src/config/schema.ts +38 -0
- package/src/config/types.ts +55 -0
- package/src/config/upstreams.ts +31 -0
- package/src/cost/report.ts +23 -2
- package/src/cost/views.ts +2 -1
- package/src/lib.ts +2 -1
- package/src/server/http.ts +12 -2
- package/src/server/providers.ts +34 -1
- package/src/upstream/anthropic.ts +470 -0
- package/src/upstream/compat.ts +267 -0
- package/src/upstream/multi.ts +23 -9
- package/test/config-wizard.test.ts +1 -1
- package/test/failover.test.ts +1 -0
- package/test/turn.test.ts +1 -0
- package/test/upstreams.test.ts +378 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A native Anthropic upstream (`api.anthropic.com/v1/messages`), configured as
|
|
3
|
+
* an `upstreams: []` entry of kind `anthropic` with a static priced model list.
|
|
4
|
+
*
|
|
5
|
+
* The router's internal shape is OpenAI chat-completions, so this client
|
|
6
|
+
* translates in both directions: the rendered body becomes a Messages request
|
|
7
|
+
* (system blocks, alternating user/assistant turns, tool_use / tool_result
|
|
8
|
+
* blocks, tools with input_schema, thinking from the reasoning effort,
|
|
9
|
+
* `cache_control` markers kept because Anthropic honours them natively), and
|
|
10
|
+
* the Messages SSE stream becomes the same `UpstreamChunk`s an OpenAI stream
|
|
11
|
+
* yields — the `raw` of each chunk is a synthesised chat-completions chunk,
|
|
12
|
+
* because the wire forwards `raw` to OpenAI-protocol clients.
|
|
13
|
+
*
|
|
14
|
+
* Usage follows the OpenAI convention the ledger expects: prompt tokens INCLUDE
|
|
15
|
+
* the cached and cache-written ones, reported as sub-counts. Anthropic reports
|
|
16
|
+
* no cost, so the catalog price (with its cache read/write rates) applies.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RouterConfig, UpstreamEntry, UpstreamModelConfig } from "../config/types.ts";
|
|
20
|
+
import type { UsageCounts } from "../cost/types.ts";
|
|
21
|
+
import { createLogger } from "../util/log.ts";
|
|
22
|
+
import type { FinishReason, StreamEvent, UpstreamChunk } from "../wire/types.ts";
|
|
23
|
+
import { createBreaker, type NamedUpstreamClient, upstreamLookup, upstreamModelId } from "./compat.ts";
|
|
24
|
+
import type { FetchLike } from "./ollama.ts";
|
|
25
|
+
import { UpstreamError, type Dispatch, type DispatchOptions, type UpstreamErrorKind } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
export const ANTHROPIC_VERSION = "2023-06-01";
|
|
28
|
+
|
|
29
|
+
function asRec(v: unknown): Record<string, unknown> | null {
|
|
30
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
31
|
+
}
|
|
32
|
+
function num(v: unknown): number {
|
|
33
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Thinking budgets per reasoning effort, in tokens. */
|
|
37
|
+
const THINKING_BUDGET: Record<string, number> = { minimal: 1024, low: 2048, medium: 8192, high: 16384, xhigh: 32000, max: 32000 };
|
|
38
|
+
|
|
39
|
+
type Block = Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
function textBlocks(content: unknown, keepCache: boolean): Block[] {
|
|
42
|
+
if (typeof content === "string") return content === "" ? [] : [{ type: "text", text: content }];
|
|
43
|
+
if (!Array.isArray(content)) return [];
|
|
44
|
+
const out: Block[] = [];
|
|
45
|
+
for (const partRaw of content) {
|
|
46
|
+
const part = asRec(partRaw);
|
|
47
|
+
if (part === null) continue;
|
|
48
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
49
|
+
if (part.text === "") continue;
|
|
50
|
+
const block: Block = { type: "text", text: part.text };
|
|
51
|
+
if (keepCache && part.cache_control !== undefined) block.cache_control = part.cache_control;
|
|
52
|
+
out.push(block);
|
|
53
|
+
} else if (part.type === "image_url") {
|
|
54
|
+
const url = typeof part.image_url === "string" ? part.image_url : (asRec(part.image_url)?.url as string | undefined);
|
|
55
|
+
if (typeof url !== "string") continue;
|
|
56
|
+
const m = /^data:([^;]+);base64,(.+)$/s.exec(url);
|
|
57
|
+
out.push(m !== null ? { type: "image", source: { type: "base64", media_type: m[1], data: m[2] } } : { type: "image", source: { type: "url", url } });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toolResultText(content: unknown): string {
|
|
64
|
+
if (typeof content === "string") return content;
|
|
65
|
+
if (Array.isArray(content)) return content.map((p) => (typeof (asRec(p)?.text) === "string" ? (asRec(p)!.text as string) : "")).join("");
|
|
66
|
+
return content === null || content === undefined ? "" : JSON.stringify(content);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AnthropicBodyOptions {
|
|
70
|
+
/** The bare model id sent upstream. */
|
|
71
|
+
modelId: string;
|
|
72
|
+
/** The model's published completion ceiling, when known; caps max_tokens. */
|
|
73
|
+
maxCompletionTokens?: number;
|
|
74
|
+
supportsReasoning: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Renders an OpenAI-shaped (OpenRouter dialect) body as an Anthropic Messages request. Pure. */
|
|
78
|
+
export function toAnthropicBody(body: Record<string, unknown>, opts: AnthropicBodyOptions): Record<string, unknown> {
|
|
79
|
+
const system: Block[] = [];
|
|
80
|
+
const messages: { role: "user" | "assistant"; content: Block[] }[] = [];
|
|
81
|
+
const push = (role: "user" | "assistant", blocks: Block[]): void => {
|
|
82
|
+
if (blocks.length === 0) return;
|
|
83
|
+
const last = messages[messages.length - 1];
|
|
84
|
+
// Anthropic wants strict alternation: adjacent same-role turns fold into one.
|
|
85
|
+
if (last !== undefined && last.role === role) last.content.push(...blocks);
|
|
86
|
+
else messages.push({ role, content: blocks });
|
|
87
|
+
};
|
|
88
|
+
for (const mRaw of Array.isArray(body.messages) ? body.messages : []) {
|
|
89
|
+
const m = asRec(mRaw);
|
|
90
|
+
if (m === null) continue;
|
|
91
|
+
switch (m.role) {
|
|
92
|
+
case "system":
|
|
93
|
+
case "developer":
|
|
94
|
+
system.push(...textBlocks(m.content, true));
|
|
95
|
+
break;
|
|
96
|
+
case "user":
|
|
97
|
+
push("user", textBlocks(m.content, true));
|
|
98
|
+
break;
|
|
99
|
+
case "assistant": {
|
|
100
|
+
const blocks = textBlocks(m.content, false);
|
|
101
|
+
if (Array.isArray(m.tool_calls)) {
|
|
102
|
+
for (const tcRaw of m.tool_calls) {
|
|
103
|
+
const tc = asRec(tcRaw);
|
|
104
|
+
const fn = tc ? asRec(tc.function) : null;
|
|
105
|
+
if (tc === null || fn === null || typeof fn.name !== "string") continue;
|
|
106
|
+
let input: unknown = {};
|
|
107
|
+
if (typeof fn.arguments === "string" && fn.arguments.trim() !== "") {
|
|
108
|
+
try {
|
|
109
|
+
input = JSON.parse(fn.arguments);
|
|
110
|
+
} catch {
|
|
111
|
+
input = { _raw: fn.arguments };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
blocks.push({ type: "tool_use", id: typeof tc.id === "string" ? tc.id : `call_${blocks.length}`, name: fn.name, input });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
push("assistant", blocks);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "tool":
|
|
121
|
+
push("user", [{ type: "tool_result", tool_use_id: typeof m.tool_call_id === "string" ? m.tool_call_id : "", content: toolResultText(m.content) }]);
|
|
122
|
+
break;
|
|
123
|
+
default:
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// The conversation must open with the user.
|
|
128
|
+
if (messages.length === 0 || messages[0]!.role !== "user") messages.unshift({ role: "user", content: [{ type: "text", text: "(continue)" }] });
|
|
129
|
+
|
|
130
|
+
const out: Record<string, unknown> = { model: opts.modelId, messages, stream: body.stream === true };
|
|
131
|
+
if (system.length > 0) out.system = system;
|
|
132
|
+
const requested = typeof body.max_completion_tokens === "number" ? body.max_completion_tokens : typeof body.max_tokens === "number" ? body.max_tokens : 4096;
|
|
133
|
+
let maxTokens = opts.maxCompletionTokens !== undefined ? Math.min(requested, opts.maxCompletionTokens) : requested;
|
|
134
|
+
if (typeof body.temperature === "number") out.temperature = body.temperature;
|
|
135
|
+
if (typeof body.top_p === "number") out.top_p = body.top_p;
|
|
136
|
+
if (typeof body.stop === "string") out.stop_sequences = [body.stop];
|
|
137
|
+
else if (Array.isArray(body.stop)) out.stop_sequences = body.stop.filter((s): s is string => typeof s === "string");
|
|
138
|
+
|
|
139
|
+
const tools = Array.isArray(body.tools) ? body.tools : [];
|
|
140
|
+
const mapped: Block[] = [];
|
|
141
|
+
for (const tRaw of tools) {
|
|
142
|
+
const fn = asRec(asRec(tRaw)?.function);
|
|
143
|
+
if (fn === null || typeof fn.name !== "string") continue;
|
|
144
|
+
const tool: Block = { name: fn.name, input_schema: asRec(fn.parameters) ?? { type: "object", properties: {} } };
|
|
145
|
+
if (typeof fn.description === "string") tool.description = fn.description;
|
|
146
|
+
mapped.push(tool);
|
|
147
|
+
}
|
|
148
|
+
if (mapped.length > 0) {
|
|
149
|
+
out.tools = mapped;
|
|
150
|
+
const choice = body.tool_choice;
|
|
151
|
+
const disableParallel = body.parallel_tool_calls === false;
|
|
152
|
+
if (choice === "required") out.tool_choice = { type: "any", disable_parallel_tool_use: disableParallel };
|
|
153
|
+
else if (choice === "none") delete out.tools;
|
|
154
|
+
else if (asRec(choice)?.type === "function" && typeof asRec(asRec(choice)?.function)?.name === "string") out.tool_choice = { type: "tool", name: asRec(asRec(choice)?.function)!.name, disable_parallel_tool_use: disableParallel };
|
|
155
|
+
else if (disableParallel) out.tool_choice = { type: "auto", disable_parallel_tool_use: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const reasoning = asRec(body.reasoning);
|
|
159
|
+
if (opts.supportsReasoning && reasoning !== null && reasoning.enabled !== false && typeof reasoning.effort === "string") {
|
|
160
|
+
const budget = THINKING_BUDGET[reasoning.effort];
|
|
161
|
+
if (budget !== undefined) {
|
|
162
|
+
// max_tokens must exceed the budget; thinking also forbids sampling knobs.
|
|
163
|
+
if (maxTokens <= budget) maxTokens = budget + 1024;
|
|
164
|
+
out.thinking = { type: "enabled", budget_tokens: budget };
|
|
165
|
+
delete out.temperature;
|
|
166
|
+
delete out.top_p;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
out.max_tokens = maxTokens;
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One SSE frame with its event name; the shared parser drops names, and Anthropic routes on them. */
|
|
174
|
+
export interface SseFrame {
|
|
175
|
+
event: string;
|
|
176
|
+
data: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseFrame> {
|
|
180
|
+
const reader = stream.getReader();
|
|
181
|
+
const decoder = new TextDecoder();
|
|
182
|
+
let buf = "";
|
|
183
|
+
let event = "";
|
|
184
|
+
let data: string[] = [];
|
|
185
|
+
const flush = (): SseFrame | null => {
|
|
186
|
+
if (data.length === 0 && event === "") return null;
|
|
187
|
+
const frame = { event, data: data.join("\n") };
|
|
188
|
+
event = "";
|
|
189
|
+
data = [];
|
|
190
|
+
return frame;
|
|
191
|
+
};
|
|
192
|
+
const line = (l: string): SseFrame | null => {
|
|
193
|
+
if (l === "") return flush();
|
|
194
|
+
if (l.startsWith(":")) return null;
|
|
195
|
+
if (l.startsWith("event:")) event = l.slice(6).trim();
|
|
196
|
+
else if (l.startsWith("data:")) data.push(l.slice(5).replace(/^ /, ""));
|
|
197
|
+
return null;
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
for (;;) {
|
|
201
|
+
const { done, value } = await reader.read();
|
|
202
|
+
if (done) break;
|
|
203
|
+
buf += decoder.decode(value, { stream: true });
|
|
204
|
+
let nl: number;
|
|
205
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
206
|
+
const l = buf.slice(0, nl);
|
|
207
|
+
buf = buf.slice(nl + 1);
|
|
208
|
+
const f = line(l.endsWith("\r") ? l.slice(0, -1) : l);
|
|
209
|
+
if (f !== null) yield f;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
buf += decoder.decode();
|
|
213
|
+
if (buf.length > 0) {
|
|
214
|
+
const f = line(buf.endsWith("\r") ? buf.slice(0, -1) : buf);
|
|
215
|
+
if (f !== null) yield f;
|
|
216
|
+
}
|
|
217
|
+
const f = flush();
|
|
218
|
+
if (f !== null) yield f;
|
|
219
|
+
} finally {
|
|
220
|
+
reader.releaseLock();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function mapStop(reason: unknown): FinishReason {
|
|
225
|
+
switch (reason) {
|
|
226
|
+
case "end_turn":
|
|
227
|
+
case "stop_sequence":
|
|
228
|
+
return "stop";
|
|
229
|
+
case "max_tokens":
|
|
230
|
+
return "length";
|
|
231
|
+
case "tool_use":
|
|
232
|
+
return "tool_calls";
|
|
233
|
+
case "refusal":
|
|
234
|
+
return "content_filter";
|
|
235
|
+
default:
|
|
236
|
+
return "stop";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Turns Anthropic stream events into the router's chunks, one call per frame.
|
|
242
|
+
* `servedSlug` is the catalog slug (`<id>/<model>`) the chunks report.
|
|
243
|
+
*/
|
|
244
|
+
export function createAnthropicTranslator(servedSlug: string): { push(frame: SseFrame): UpstreamChunk | null; generationId(): string | null } {
|
|
245
|
+
let id: string | null = null;
|
|
246
|
+
let started = false;
|
|
247
|
+
let toolCount = 0;
|
|
248
|
+
const toolIndexByBlock = new Map<number, number>();
|
|
249
|
+
let inputTokens = 0;
|
|
250
|
+
let cacheRead = 0;
|
|
251
|
+
let cacheWrite = 0;
|
|
252
|
+
const created = Math.floor(Date.now() / 1000);
|
|
253
|
+
const chunk = (delta: Record<string, unknown>, finish: FinishReason | null, events: StreamEvent[], usage?: Record<string, unknown>): UpstreamChunk => {
|
|
254
|
+
const raw: Record<string, unknown> = { id: id ?? "", object: "chat.completion.chunk", created, model: servedSlug, choices: [{ index: 0, delta, finish_reason: finish }] };
|
|
255
|
+
if (usage !== undefined) raw.usage = usage;
|
|
256
|
+
return { raw, events };
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
generationId: () => id,
|
|
260
|
+
push(frame) {
|
|
261
|
+
let data: Record<string, unknown> | null = null;
|
|
262
|
+
try {
|
|
263
|
+
data = asRec(JSON.parse(frame.data));
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (data === null) return null;
|
|
268
|
+
const type = typeof data.type === "string" ? data.type : frame.event;
|
|
269
|
+
switch (type) {
|
|
270
|
+
case "message_start": {
|
|
271
|
+
const message = asRec(data.message);
|
|
272
|
+
const usage = asRec(message?.usage);
|
|
273
|
+
id = typeof message?.id === "string" ? message.id : null;
|
|
274
|
+
inputTokens = num(usage?.input_tokens);
|
|
275
|
+
cacheRead = num(usage?.cache_read_input_tokens);
|
|
276
|
+
cacheWrite = num(usage?.cache_creation_input_tokens);
|
|
277
|
+
started = true;
|
|
278
|
+
return chunk({ role: "assistant", content: "" }, null, [{ type: "start", servedSlug, generationId: id }]);
|
|
279
|
+
}
|
|
280
|
+
case "content_block_start": {
|
|
281
|
+
const block = asRec(data.content_block);
|
|
282
|
+
const index = num(data.index);
|
|
283
|
+
if (block?.type === "tool_use") {
|
|
284
|
+
const toolIndex = toolCount++;
|
|
285
|
+
toolIndexByBlock.set(index, toolIndex);
|
|
286
|
+
const callId = typeof block.id === "string" ? block.id : `call_${toolIndex}`;
|
|
287
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
288
|
+
return chunk({ tool_calls: [{ index: toolIndex, id: callId, type: "function", function: { name, arguments: "" } }] }, null, [{ type: "tool_call", index: toolIndex, id: callId, name }]);
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
case "content_block_delta": {
|
|
293
|
+
const delta = asRec(data.delta);
|
|
294
|
+
const index = num(data.index);
|
|
295
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text !== "") return chunk({ content: delta.text }, null, [{ type: "text", delta: delta.text }]);
|
|
296
|
+
if (delta?.type === "thinking_delta" && typeof delta.thinking === "string" && delta.thinking !== "") return chunk({ reasoning: delta.thinking }, null, [{ type: "reasoning", delta: delta.thinking }]);
|
|
297
|
+
if (delta?.type === "input_json_delta" && typeof delta.partial_json === "string" && delta.partial_json !== "") {
|
|
298
|
+
const toolIndex = toolIndexByBlock.get(index) ?? 0;
|
|
299
|
+
return chunk({ tool_calls: [{ index: toolIndex, function: { arguments: delta.partial_json } }] }, null, [{ type: "tool_call", index: toolIndex, argsDelta: delta.partial_json }]);
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
case "message_delta": {
|
|
304
|
+
const delta = asRec(data.delta);
|
|
305
|
+
const usage = asRec(data.usage);
|
|
306
|
+
const finish = mapStop(delta?.stop_reason);
|
|
307
|
+
const output = num(usage?.output_tokens);
|
|
308
|
+
// A final reading may restate the input side; prefer it when present.
|
|
309
|
+
if (usage?.input_tokens !== undefined) inputTokens = num(usage.input_tokens);
|
|
310
|
+
if (usage?.cache_read_input_tokens !== undefined) cacheRead = num(usage.cache_read_input_tokens);
|
|
311
|
+
if (usage?.cache_creation_input_tokens !== undefined) cacheWrite = num(usage.cache_creation_input_tokens);
|
|
312
|
+
const counts: UsageCounts = { promptTokens: inputTokens + cacheRead + cacheWrite, cachedTokens: cacheRead, cacheWriteTokens: cacheWrite, completionTokens: output, reasoningTokens: 0, images: 0 };
|
|
313
|
+
return chunk({}, finish, [{ type: "finish", reason: finish }, { type: "usage", usage: counts, reportedCostUsd: null }], {
|
|
314
|
+
prompt_tokens: counts.promptTokens,
|
|
315
|
+
completion_tokens: output,
|
|
316
|
+
total_tokens: counts.promptTokens + output,
|
|
317
|
+
prompt_tokens_details: { cached_tokens: cacheRead, cache_write_tokens: cacheWrite },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
case "error": {
|
|
321
|
+
const err = asRec(data.error);
|
|
322
|
+
const message = typeof err?.message === "string" ? err.message : "Anthropic stream error";
|
|
323
|
+
throw new UpstreamError(err?.type === "overloaded_error" ? "upstream_error" : "upstream_error", 0, message, true, data);
|
|
324
|
+
}
|
|
325
|
+
default:
|
|
326
|
+
// ping, content_block_stop, message_stop: nothing to forward.
|
|
327
|
+
return started ? null : null;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** HTTP status → error kind for the Messages API. */
|
|
334
|
+
export function classifyAnthropicStatus(id: string, status: number, body: unknown): UpstreamError {
|
|
335
|
+
const rec = asRec(body);
|
|
336
|
+
const errRec = rec ? asRec(rec.error) : null;
|
|
337
|
+
const msg = errRec?.message ?? rec?.message;
|
|
338
|
+
const message = typeof msg === "string" && msg !== "" ? msg : `${id} HTTP ${status}`;
|
|
339
|
+
const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError => new UpstreamError(kind, status, message, retryable, body);
|
|
340
|
+
if (status === 401 || status === 403) return fail("auth", false);
|
|
341
|
+
if (status === 404) return fail("model_unavailable", true);
|
|
342
|
+
if (status === 413) return fail("context_length", false);
|
|
343
|
+
if (status === 429) return fail("rate_limit", true);
|
|
344
|
+
if (status === 400 || status === 422) return /prompt is too long|too many tokens|context/i.test(message) ? fail("context_length", false) : fail("invalid_request", false);
|
|
345
|
+
if (status === 529) return fail("upstream_error", true);
|
|
346
|
+
if (status >= 500) return fail("upstream_error", true);
|
|
347
|
+
return fail("upstream_error", status === 408);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function transportError(id: string, err: unknown): UpstreamError {
|
|
351
|
+
if (err instanceof UpstreamError) return err;
|
|
352
|
+
const name = err instanceof Error ? err.name : "";
|
|
353
|
+
if (name === "TimeoutError") return new UpstreamError("timeout", 0, `${id} request timed out`, true);
|
|
354
|
+
if (name === "AbortError") return new UpstreamError("aborted", 0, "request aborted", false);
|
|
355
|
+
return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function createAnthropicClient(cfg: RouterConfig, id: string, fetchImpl: FetchLike = fetch): NamedUpstreamClient {
|
|
359
|
+
const lookup = upstreamLookup(cfg, id);
|
|
360
|
+
const log = createLogger(cfg.logLevel);
|
|
361
|
+
const entry = (): UpstreamEntry => {
|
|
362
|
+
const e = lookup();
|
|
363
|
+
if (e === undefined) throw new UpstreamError("model_unavailable", 0, `upstream ${id} is no longer configured`, true);
|
|
364
|
+
return e;
|
|
365
|
+
};
|
|
366
|
+
// An overloaded API (529) is a moment, not a fault: a short cooldown like a rate limit.
|
|
367
|
+
const breaker = createBreaker(id, log, (kind) => {
|
|
368
|
+
const e = lookup();
|
|
369
|
+
if (e === undefined) return 0;
|
|
370
|
+
return kind === "quota" ? e.quotaCooldownMs : kind === "rate_limit" || kind === "upstream_error" ? e.rateLimitCooldownMs : 0;
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
function modelInfo(e: UpstreamEntry, slug: string): { modelId: string; model: UpstreamModelConfig | undefined } {
|
|
374
|
+
const modelId = upstreamModelId(id, slug);
|
|
375
|
+
return { modelId, model: e.models.find((m) => m.id === modelId) };
|
|
376
|
+
}
|
|
377
|
+
function render(e: UpstreamEntry, body: Record<string, unknown>): { rendered: Record<string, unknown>; servedSlug: string } {
|
|
378
|
+
const slug = typeof body.model === "string" ? body.model : "";
|
|
379
|
+
const { modelId, model } = modelInfo(e, slug);
|
|
380
|
+
const opts: AnthropicBodyOptions = { modelId, supportsReasoning: model?.supportsReasoning ?? false };
|
|
381
|
+
if (model?.maxCompletionTokens !== undefined) opts.maxCompletionTokens = model.maxCompletionTokens;
|
|
382
|
+
return { rendered: toAnthropicBody(body, opts), servedSlug: `${id}/${modelId}` };
|
|
383
|
+
}
|
|
384
|
+
function composeSignal(e: UpstreamEntry, caller: AbortSignal | undefined): AbortSignal | null {
|
|
385
|
+
const timeout = e.timeoutMs > 0 ? AbortSignal.timeout(e.timeoutMs) : null;
|
|
386
|
+
if (caller && timeout) return AbortSignal.any([caller, timeout]);
|
|
387
|
+
return caller ?? timeout;
|
|
388
|
+
}
|
|
389
|
+
async function post(e: UpstreamEntry, body: Record<string, unknown>, signal: AbortSignal | undefined): Promise<Response> {
|
|
390
|
+
const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": ANTHROPIC_VERSION, ...e.headers };
|
|
391
|
+
if (e.apiKey !== "") headers["x-api-key"] = e.apiKey;
|
|
392
|
+
try {
|
|
393
|
+
return await fetchImpl(`${e.baseUrl.replace(/\/+$/, "")}/v1/messages`, { method: "POST", headers, body: JSON.stringify(body), signal: composeSignal(e, signal) });
|
|
394
|
+
} catch (err) {
|
|
395
|
+
throw transportError(id, err);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function httpError(res: Response): Promise<UpstreamError> {
|
|
399
|
+
let body: unknown = null;
|
|
400
|
+
try {
|
|
401
|
+
body = await res.json();
|
|
402
|
+
} catch {
|
|
403
|
+
/* status alone */
|
|
404
|
+
}
|
|
405
|
+
const err = classifyAnthropicStatus(id, res.status, body);
|
|
406
|
+
if (err.kind === "rate_limit" || res.status === 529) breaker.trip(err);
|
|
407
|
+
return err;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
id,
|
|
412
|
+
available: breaker.available,
|
|
413
|
+
cooldownUntilMs: breaker.cooldownUntilMs,
|
|
414
|
+
lastTrip: breaker.lastTrip,
|
|
415
|
+
|
|
416
|
+
async dispatch(opts: DispatchOptions): Promise<Dispatch> {
|
|
417
|
+
const e = entry();
|
|
418
|
+
const { rendered, servedSlug } = render(e, { ...opts.body, stream: true });
|
|
419
|
+
const res = await post(e, rendered, opts.signal);
|
|
420
|
+
if (!res.ok) throw await httpError(res);
|
|
421
|
+
if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
|
|
422
|
+
const translator = createAnthropicTranslator(servedSlug);
|
|
423
|
+
let resolveId!: (v: string | null) => void;
|
|
424
|
+
const idPromise = new Promise<string | null>((resolve) => {
|
|
425
|
+
resolveId = resolve;
|
|
426
|
+
});
|
|
427
|
+
let idResolved = false;
|
|
428
|
+
const resolveOnce = (v: string | null): void => {
|
|
429
|
+
if (!idResolved) {
|
|
430
|
+
idResolved = true;
|
|
431
|
+
resolveId(v);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const frames = readSseFrames(res.body);
|
|
435
|
+
const chunks = (async function* (): AsyncGenerator<UpstreamChunk> {
|
|
436
|
+
try {
|
|
437
|
+
for await (const frame of frames) {
|
|
438
|
+
const c = translator.push(frame);
|
|
439
|
+
if (c === null) continue;
|
|
440
|
+
if (!idResolved && translator.generationId() !== null) resolveOnce(translator.generationId());
|
|
441
|
+
yield c;
|
|
442
|
+
}
|
|
443
|
+
} catch (err) {
|
|
444
|
+
throw transportError(id, err);
|
|
445
|
+
} finally {
|
|
446
|
+
resolveOnce(null);
|
|
447
|
+
}
|
|
448
|
+
})();
|
|
449
|
+
return { chunks, generationId: () => idPromise };
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }> {
|
|
453
|
+
const e = entry();
|
|
454
|
+
const { rendered } = render(e, { ...body, stream: false });
|
|
455
|
+
const res = await post(e, rendered, signal);
|
|
456
|
+
if (!res.ok) throw await httpError(res);
|
|
457
|
+
const json = asRec(await res.json());
|
|
458
|
+
const content = Array.isArray(json?.content) ? json.content : [];
|
|
459
|
+
const text = content.map((b) => (asRec(b)?.type === "text" && typeof asRec(b)?.text === "string" ? (asRec(b)!.text as string) : "")).join("");
|
|
460
|
+
return { text, costUsd: null };
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
async fetchModels(): Promise<unknown[]> {
|
|
464
|
+
return [];
|
|
465
|
+
},
|
|
466
|
+
async fetchModelsForUser(): Promise<unknown[]> {
|
|
467
|
+
return [];
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|