@jxsuite/ai 0.41.2 → 0.42.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/package.json +2 -2
- package/src/gateway/anthropic.ts +898 -0
- package/src/gateway/chat.ts +54 -16
- package/src/gateway/index.ts +19 -3
- package/src/gateway/normalize.ts +53 -20
- package/src/gateway/types.ts +12 -4
- package/src/index.ts +2 -0
- package/src/streaming-client.ts +25 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "AI infrastructure for Jx Suite — streaming LLM client abstraction, tool registry, and reactive chat state",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"upgrade": "bun update --latest && bun install"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@jxsuite/protocol": "^6.
|
|
36
|
+
"@jxsuite/protocol": "^6.2.0",
|
|
37
37
|
"@jxsuite/schema": "^5.0.0",
|
|
38
38
|
"@vue/reactivity": "3.5.44"
|
|
39
39
|
},
|
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Anthropic Messages API behind the chat route: a version 1 body projected onto `POST
|
|
3
|
+
* /v1/messages`, and its event stream normalized into the route's frames.
|
|
4
|
+
*
|
|
5
|
+
* A client of the chat route speaks the OpenAI-compatible version 1 wire whichever provider answers
|
|
6
|
+
* it (specs/ai.md §2.6). When a host resolves an upstream of the `anthropic` family, the gateway
|
|
7
|
+
* does the translation here, so a Studio connected to Claude sends exactly the body it sends to any
|
|
8
|
+
* other provider, and reads exactly the frames it reads from any other.
|
|
9
|
+
*
|
|
10
|
+
* **Reasoning is not replayed on this path.** A Claude thinking block carries a signature bound to
|
|
11
|
+
* the system prompt, the tools and every message before it, and the version 1 wire carries the
|
|
12
|
+
* reasoning's text without the signature. Studio also rebuilds its system prompt every turn, so a
|
|
13
|
+
* replayed block would fail that binding even if the signature travelled. The API accepts a history
|
|
14
|
+
* with every thinking block removed (it is the documented recovery from a binding mismatch), so
|
|
15
|
+
* this projection sends none: each round is answered without the reasoning of the rounds before it,
|
|
16
|
+
* and never with a 400. Replaying signed blocks needs the version 2 wire.
|
|
17
|
+
*
|
|
18
|
+
* @module @jxsuite/ai/gateway
|
|
19
|
+
* @docs extending/embedding/backend-protocol
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { problemDetails } from "@jxsuite/protocol/problems";
|
|
23
|
+
import type { AiModelInfo } from "@jxsuite/protocol/types";
|
|
24
|
+
import type { JsonObject } from "../core-types.ts";
|
|
25
|
+
import type { StreamEvent, StreamUsageEvent } from "../streaming-client.ts";
|
|
26
|
+
import type { JSONSchema } from "../tools.ts";
|
|
27
|
+
import { noBodyFrame, streamFailureFrame, upstreamErrorFrame } from "./normalize.ts";
|
|
28
|
+
import { extractUpstreamErrorMessage } from "./upstream-error.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The Anthropic API's own origin, which a host falls back to when it configures none.
|
|
32
|
+
*
|
|
33
|
+
* Written without `/v1`, as Anthropic's tooling writes `ANTHROPIC_BASE_URL`: the gateway appends
|
|
34
|
+
* `/v1/messages` and `/v1/models`, so an Anthropic-compatible endpoint is configured the way its
|
|
35
|
+
* own documentation says.
|
|
36
|
+
*/
|
|
37
|
+
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
38
|
+
|
|
39
|
+
/** The `anthropic-version` every request is sent with. */
|
|
40
|
+
export const ANTHROPIC_API_VERSION = "2026-10-21";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `max_tokens` every request carries. The Messages API requires one, and the version 1 body has
|
|
44
|
+
* no member for it. 32,000 fits every current model's output cap, with room for adaptive thinking
|
|
45
|
+
* ahead of the reply.
|
|
46
|
+
*/
|
|
47
|
+
export const ANTHROPIC_MAX_TOKENS = 32_000;
|
|
48
|
+
|
|
49
|
+
/** The most pages of `/v1/models` one listing reads. */
|
|
50
|
+
const MAX_MODEL_PAGES = 10;
|
|
51
|
+
|
|
52
|
+
/** A tool-use id the Messages API accepts. */
|
|
53
|
+
const TOOL_ID = /^[a-zA-Z0-9_-]+$/;
|
|
54
|
+
|
|
55
|
+
// ─── The version 1 body, as a client sends it ───────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** One part of an OpenAI content array. Only text parts have a counterpart on this path. */
|
|
58
|
+
interface V1ContentPart {
|
|
59
|
+
type?: string;
|
|
60
|
+
text?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A message's `content`: a string, a content array, or `null` on an assistant turn of calls. */
|
|
64
|
+
type V1Content = string | V1ContentPart[] | null | undefined;
|
|
65
|
+
|
|
66
|
+
/** A call on an assistant message. */
|
|
67
|
+
interface V1ToolCall {
|
|
68
|
+
id?: string;
|
|
69
|
+
function?: { name?: string; arguments?: string };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A version 1 message: the OpenAI-compatible message `toOpenAIMessages` builds (specs/ai.md §2.3).
|
|
74
|
+
* Every member is optional on the wire and each is checked before use.
|
|
75
|
+
*/
|
|
76
|
+
interface V1Message {
|
|
77
|
+
role?: string;
|
|
78
|
+
content?: V1Content;
|
|
79
|
+
tool_call_id?: string;
|
|
80
|
+
tool_calls?: V1ToolCall[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An OpenAI function tool, as `listForLLM()` declares one. */
|
|
84
|
+
interface V1Tool {
|
|
85
|
+
function?: { name?: string; description?: string; parameters?: JSONSchema };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── The Messages API request ───────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** A text content block. */
|
|
91
|
+
interface TextBlock {
|
|
92
|
+
type: "text";
|
|
93
|
+
text: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A call the model made, replayed. */
|
|
97
|
+
interface ToolUseBlock {
|
|
98
|
+
type: "tool_use";
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
input: JsonObject;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A call's outcome, which rides on a user message. */
|
|
105
|
+
interface ToolResultBlock {
|
|
106
|
+
type: "tool_result";
|
|
107
|
+
tool_use_id: string;
|
|
108
|
+
content: string;
|
|
109
|
+
is_error?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One message of a Messages API request. */
|
|
113
|
+
interface AnthropicMessage {
|
|
114
|
+
role: "user" | "assistant";
|
|
115
|
+
content: (TextBlock | ToolUseBlock | ToolResultBlock)[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A tool, as the Messages API declares one. */
|
|
119
|
+
interface AnthropicTool {
|
|
120
|
+
name: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
input_schema: JSONSchema;
|
|
123
|
+
eager_input_streaming?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The Messages API request the gateway sends. */
|
|
127
|
+
export interface AnthropicRequestBody {
|
|
128
|
+
model: string;
|
|
129
|
+
max_tokens: number;
|
|
130
|
+
stream: true;
|
|
131
|
+
messages: AnthropicMessage[];
|
|
132
|
+
system?: TextBlock[];
|
|
133
|
+
tools?: AnthropicTool[];
|
|
134
|
+
cache_control?: { type: "ephemeral" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The version 1 body members the projection reads. Each is checked before use. */
|
|
138
|
+
export interface AnthropicProjectionInput {
|
|
139
|
+
readonly model: string;
|
|
140
|
+
readonly systemPrompt: unknown;
|
|
141
|
+
readonly messages: readonly unknown[];
|
|
142
|
+
readonly tools: unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** How the projection is shaped for the upstream it goes to. */
|
|
146
|
+
export interface AnthropicProjectionOptions {
|
|
147
|
+
/**
|
|
148
|
+
* True when the upstream is Anthropic's own API. Only there does the request carry automatic
|
|
149
|
+
* prompt caching and eager tool-input streaming: an Anthropic-compatible endpoint elsewhere may
|
|
150
|
+
* refuse a member it does not know, as `upstreamCacheHints` reasons for the OpenAI wire.
|
|
151
|
+
*/
|
|
152
|
+
readonly firstParty: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The headers every Anthropic request carries. */
|
|
156
|
+
export interface AnthropicHeaders {
|
|
157
|
+
"Content-Type": "application/json";
|
|
158
|
+
"anthropic-version": string;
|
|
159
|
+
"x-api-key": string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── The Messages API stream ────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** A `usage` object. Every figure is cumulative for the message. */
|
|
165
|
+
interface AnthropicUsage {
|
|
166
|
+
input_tokens?: number;
|
|
167
|
+
output_tokens?: number;
|
|
168
|
+
cache_read_input_tokens?: number | null;
|
|
169
|
+
cache_creation_input_tokens?: number | null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One event of the stream: the JSON after `data:`. Members are read only after a check. */
|
|
173
|
+
interface AnthropicStreamEvent {
|
|
174
|
+
type?: string;
|
|
175
|
+
index?: number;
|
|
176
|
+
message?: { usage?: AnthropicUsage };
|
|
177
|
+
content_block?: { type?: string; id?: string; name?: string; text?: string };
|
|
178
|
+
delta?: {
|
|
179
|
+
type?: string;
|
|
180
|
+
text?: string;
|
|
181
|
+
thinking?: string;
|
|
182
|
+
partial_json?: string;
|
|
183
|
+
stop_reason?: string | null;
|
|
184
|
+
};
|
|
185
|
+
usage?: AnthropicUsage;
|
|
186
|
+
error?: { type?: string; message?: string };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One page of `GET /v1/models`. */
|
|
190
|
+
interface AnthropicModelsPage {
|
|
191
|
+
data?: { id?: string; display_name?: string; max_input_tokens?: number | null }[];
|
|
192
|
+
has_more?: boolean;
|
|
193
|
+
last_id?: string | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Guards ──────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True when a member is a string, empty or not. The body is the client's JSON and the stream the
|
|
200
|
+
* API's, so a member an interface calls a string is checked before it is read.
|
|
201
|
+
*
|
|
202
|
+
* @param {T} value - A member, as sent
|
|
203
|
+
* @returns {boolean}
|
|
204
|
+
*/
|
|
205
|
+
function isText<T>(value: T): value is T & string {
|
|
206
|
+
return typeof value === "string";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* True when a member is a number.
|
|
211
|
+
*
|
|
212
|
+
* @param {T} value - A member, as sent
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
function isCount<T>(value: T): value is T & number {
|
|
216
|
+
return typeof value === "number";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* True when a parsed value is a JSON object, and not `null`, an array or a primitive.
|
|
221
|
+
*
|
|
222
|
+
* @param {unknown} value - A parsed value
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
function isObject(value: unknown): value is JsonObject {
|
|
226
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* True when a member is an object whose members may be read. The body is the client's JSON, so an
|
|
231
|
+
* entry its interface names may still be `null` or a primitive.
|
|
232
|
+
*
|
|
233
|
+
* @param {T} value - An entry, as sent
|
|
234
|
+
* @returns {boolean}
|
|
235
|
+
*/
|
|
236
|
+
function isEntry<T>(value: T): value is T & object {
|
|
237
|
+
return typeof value === "object" && value !== null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Projection ──────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A message's text: a string as sent, or the text parts of an OpenAI content array joined. Parts
|
|
244
|
+
* that are not text have no Messages API counterpart on this path and are not sent.
|
|
245
|
+
*
|
|
246
|
+
* @param {V1Content} content - A version 1 message's `content`
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
function textOf(content: V1Content): string {
|
|
250
|
+
if (isText(content)) {
|
|
251
|
+
return content;
|
|
252
|
+
}
|
|
253
|
+
if (!Array.isArray(content)) {
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
let text = "";
|
|
257
|
+
for (const part of content) {
|
|
258
|
+
if (isEntry(part) && part.type === "text" && isText(part.text)) {
|
|
259
|
+
text += part.text;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return text;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A call's arguments as the `input` object the Messages API requires. Arguments that do not parse
|
|
267
|
+
* to an object are sent as `{}`: the call already has an error result saying they did not parse,
|
|
268
|
+
* and the text itself is not the API's to repair.
|
|
269
|
+
*
|
|
270
|
+
* @param {string | undefined} argumentsText - The call's arguments, as streamed
|
|
271
|
+
* @returns {JsonObject}
|
|
272
|
+
*/
|
|
273
|
+
function inputOf(argumentsText: string | undefined): JsonObject {
|
|
274
|
+
if (!isText(argumentsText)) {
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const parsed: unknown = JSON.parse(argumentsText);
|
|
279
|
+
return isObject(parsed) ? parsed : {};
|
|
280
|
+
} catch {
|
|
281
|
+
return {};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Whether a tool reply reports a failure: a Jx result whose `success` is false. The neutral model
|
|
287
|
+
* reads `isError` the same way (specs/ai.md §2.3).
|
|
288
|
+
*
|
|
289
|
+
* @param {string} content - The reply's content
|
|
290
|
+
* @returns {boolean}
|
|
291
|
+
*/
|
|
292
|
+
function reportsFailure(content: string): boolean {
|
|
293
|
+
try {
|
|
294
|
+
const parsed: unknown = JSON.parse(content);
|
|
295
|
+
return isObject(parsed) && parsed.success === false;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Tool-use ids the Messages API accepts, kept stable across a request. An id that already matches
|
|
303
|
+
* `^[a-zA-Z0-9_-]+$` is kept; any other (a provider's id from before the conversation moved to
|
|
304
|
+
* Claude, say) has every other character replaced, and is suffixed when that collides, so a call
|
|
305
|
+
* and its reply are always rewritten to the same id.
|
|
306
|
+
*
|
|
307
|
+
* @returns {(id: string) => string}
|
|
308
|
+
*/
|
|
309
|
+
function createToolIds(): (id: string) => string {
|
|
310
|
+
const assigned = new Map<string, string>();
|
|
311
|
+
const used = new Set<string>();
|
|
312
|
+
return (id) => {
|
|
313
|
+
const known = assigned.get(id);
|
|
314
|
+
if (known !== undefined) {
|
|
315
|
+
return known;
|
|
316
|
+
}
|
|
317
|
+
const base = TOOL_ID.test(id) ? id : `toolu_${id.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
318
|
+
let next = base;
|
|
319
|
+
for (let suffix = 2; used.has(next); suffix += 1) {
|
|
320
|
+
next = `${base}_${String(suffix)}`;
|
|
321
|
+
}
|
|
322
|
+
assigned.set(id, next);
|
|
323
|
+
used.add(next);
|
|
324
|
+
return next;
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The tools as the Messages API declares them: each OpenAI function's name, description and
|
|
330
|
+
* parameters. A tool without a name is not sent; one without parameters takes an empty object
|
|
331
|
+
* schema, which is the only `input_schema` the API accepts for it.
|
|
332
|
+
*
|
|
333
|
+
* @param {readonly V1Tool[]} tools - The body's `tools`, already known to be an array
|
|
334
|
+
* @param {boolean} firstParty - Whether eager tool-input streaming may be asked for
|
|
335
|
+
* @returns {AnthropicTool[]}
|
|
336
|
+
*/
|
|
337
|
+
function toolsOf(tools: readonly V1Tool[], firstParty: boolean): AnthropicTool[] {
|
|
338
|
+
const out: AnthropicTool[] = [];
|
|
339
|
+
for (const tool of tools) {
|
|
340
|
+
const fn = isEntry(tool) && isEntry(tool.function) ? tool.function : null;
|
|
341
|
+
if (!fn || !isText(fn.name) || !fn.name) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const entry: AnthropicTool = {
|
|
345
|
+
input_schema: isObject(fn.parameters) ? fn.parameters : { properties: {}, type: "object" },
|
|
346
|
+
name: fn.name,
|
|
347
|
+
};
|
|
348
|
+
if (isText(fn.description) && fn.description) {
|
|
349
|
+
entry.description = fn.description;
|
|
350
|
+
}
|
|
351
|
+
/* The arguments stream as they are generated rather than arriving in one burst once a whole
|
|
352
|
+
parameter is written, which is what a tool taking a page body needs. The invoker already
|
|
353
|
+
answers arguments that do not parse with an error result, which is the check eager
|
|
354
|
+
streaming hands to the client. */
|
|
355
|
+
if (firstParty) {
|
|
356
|
+
entry.eager_input_streaming = true;
|
|
357
|
+
}
|
|
358
|
+
out.push(entry);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* An assistant message's blocks: its text, then a `tool_use` block per call, in order. Its
|
|
365
|
+
* reasoning is not sent (see the module comment).
|
|
366
|
+
*
|
|
367
|
+
* @param {V1Message} message - The assistant message
|
|
368
|
+
* @param {(id: string) => string} toolId - The request's id map
|
|
369
|
+
* @returns {(TextBlock | ToolUseBlock)[]}
|
|
370
|
+
*/
|
|
371
|
+
function assistantBlocks(
|
|
372
|
+
message: V1Message,
|
|
373
|
+
toolId: (id: string) => string,
|
|
374
|
+
): (TextBlock | ToolUseBlock)[] {
|
|
375
|
+
const content: (TextBlock | ToolUseBlock)[] = [];
|
|
376
|
+
const text = textOf(message.content);
|
|
377
|
+
if (text) {
|
|
378
|
+
content.push({ text, type: "text" });
|
|
379
|
+
}
|
|
380
|
+
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
381
|
+
for (const call of calls) {
|
|
382
|
+
const fn = isEntry(call) && isEntry(call.function) ? call.function : null;
|
|
383
|
+
if (!fn || !isText(call.id) || !isText(fn.name)) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
content.push({
|
|
387
|
+
id: toolId(call.id),
|
|
388
|
+
input: inputOf(fn.arguments),
|
|
389
|
+
name: fn.name,
|
|
390
|
+
type: "tool_use",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return content;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Whether a message may open the history: a user message that is not a tool reply.
|
|
398
|
+
*
|
|
399
|
+
* @param {AnthropicMessage} message
|
|
400
|
+
* @returns {boolean}
|
|
401
|
+
*/
|
|
402
|
+
function opensConversation(message: AnthropicMessage): boolean {
|
|
403
|
+
return message.role === "user" && message.content.every((block) => block.type === "text");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Project a version 1 chat body onto a Messages API request.
|
|
408
|
+
*
|
|
409
|
+
* - The system prompt is the top-level `system`, and so is any `system` message in the history,
|
|
410
|
+
* appended in order: a mid-conversation system message is not accepted by every model.
|
|
411
|
+
* - An assistant message is its text, then a `tool_use` block per call, in order. Its reasoning is
|
|
412
|
+
* not sent (see the module comment). A message left with neither is not sent.
|
|
413
|
+
* - A run of `tool` messages is one user message of `tool_result` blocks, `is_error` set when the
|
|
414
|
+
* result reports `success: false`.
|
|
415
|
+
* - A user message is its text; an empty one is not sent, because the API refuses an empty text
|
|
416
|
+
* block.
|
|
417
|
+
* - The history starts at a user message that is not a tool reply, so a leading assistant turn and
|
|
418
|
+
* the replies to its calls are dropped together, and a trailing assistant turn is never sent: the
|
|
419
|
+
* current models refuse an assistant prefill.
|
|
420
|
+
* - Tool-use ids outside `^[a-zA-Z0-9_-]+$` are rewritten, the same way on the call and its reply.
|
|
421
|
+
*
|
|
422
|
+
* @param {AnthropicProjectionInput} body - The version 1 body
|
|
423
|
+
* @param {AnthropicProjectionOptions} options - Where the request goes
|
|
424
|
+
* @returns {AnthropicRequestBody}
|
|
425
|
+
*/
|
|
426
|
+
export function toAnthropicRequest(
|
|
427
|
+
body: AnthropicProjectionInput,
|
|
428
|
+
options: AnthropicProjectionOptions,
|
|
429
|
+
): AnthropicRequestBody {
|
|
430
|
+
const toolId = createToolIds();
|
|
431
|
+
const system: string[] = [];
|
|
432
|
+
if (isText(body.systemPrompt) && body.systemPrompt) {
|
|
433
|
+
system.push(body.systemPrompt);
|
|
434
|
+
}
|
|
435
|
+
const messages: AnthropicMessage[] = [];
|
|
436
|
+
let results: ToolResultBlock[] | null = null;
|
|
437
|
+
|
|
438
|
+
for (const entry of body.messages) {
|
|
439
|
+
if (!isEntry(entry)) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
/* SAFETY: the entry is the client's JSON object, and the interface only names the members a
|
|
443
|
+
version 1 message may carry; each is checked (`isText`, `Array.isArray`, `isEntry`) before
|
|
444
|
+
it shapes a block, so a member of the wrong type is read as absent. */
|
|
445
|
+
const message = entry as V1Message;
|
|
446
|
+
if (message.role === "tool") {
|
|
447
|
+
if (!isText(message.tool_call_id)) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const content = textOf(message.content);
|
|
451
|
+
const block: ToolResultBlock = {
|
|
452
|
+
content,
|
|
453
|
+
tool_use_id: toolId(message.tool_call_id),
|
|
454
|
+
type: "tool_result",
|
|
455
|
+
};
|
|
456
|
+
if (reportsFailure(content)) {
|
|
457
|
+
block.is_error = true;
|
|
458
|
+
}
|
|
459
|
+
if (results === null) {
|
|
460
|
+
results = [];
|
|
461
|
+
messages.push({ content: results, role: "user" });
|
|
462
|
+
}
|
|
463
|
+
results.push(block);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
results = null;
|
|
467
|
+
const text = textOf(message.content);
|
|
468
|
+
if (message.role === "system") {
|
|
469
|
+
if (text) {
|
|
470
|
+
system.push(text);
|
|
471
|
+
}
|
|
472
|
+
} else if (message.role === "assistant") {
|
|
473
|
+
const content = assistantBlocks(message, toolId);
|
|
474
|
+
if (content.length > 0) {
|
|
475
|
+
messages.push({ content, role: "assistant" });
|
|
476
|
+
}
|
|
477
|
+
} else if (message.role === "user" && text) {
|
|
478
|
+
messages.push({ content: [{ text, type: "text" }], role: "user" });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const start = messages.findIndex((message) => opensConversation(message));
|
|
483
|
+
messages.splice(0, start === -1 ? messages.length : start);
|
|
484
|
+
if (messages.at(-1)?.role === "assistant") {
|
|
485
|
+
messages.pop();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const request: AnthropicRequestBody = {
|
|
489
|
+
max_tokens: ANTHROPIC_MAX_TOKENS,
|
|
490
|
+
messages,
|
|
491
|
+
model: body.model,
|
|
492
|
+
stream: true,
|
|
493
|
+
};
|
|
494
|
+
if (system.length > 0) {
|
|
495
|
+
request.system = [{ text: system.join("\n\n"), type: "text" }];
|
|
496
|
+
}
|
|
497
|
+
if (Array.isArray(body.tools)) {
|
|
498
|
+
/* SAFETY: an array of the client's tool entries; `toolsOf` checks each entry and member it
|
|
499
|
+
reads, so an entry that is not a function tool is skipped. */
|
|
500
|
+
const tools = toolsOf(body.tools as readonly V1Tool[], options.firstParty);
|
|
501
|
+
if (tools.length > 0) {
|
|
502
|
+
request.tools = tools;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/* One breakpoint on the last cacheable block: every round of a turn resends the same tools,
|
|
506
|
+
system prompt and history, so the next round reads all of it from the cache. */
|
|
507
|
+
if (options.firstParty) {
|
|
508
|
+
request.cache_control = { type: "ephemeral" };
|
|
509
|
+
}
|
|
510
|
+
return request;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Whether a base URL is Anthropic's own API, which alone receives the members an
|
|
515
|
+
* Anthropic-compatible endpoint might refuse.
|
|
516
|
+
*
|
|
517
|
+
* @param {string} baseUrl - The upstream's base URL
|
|
518
|
+
* @returns {boolean}
|
|
519
|
+
*/
|
|
520
|
+
export function isFirstPartyAnthropic(baseUrl: string): boolean {
|
|
521
|
+
try {
|
|
522
|
+
return new URL(baseUrl).hostname.toLowerCase() === "api.anthropic.com";
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The headers every Anthropic request carries.
|
|
530
|
+
*
|
|
531
|
+
* @param {string} apiKey - The caller's key, sent as `x-api-key`
|
|
532
|
+
* @returns {AnthropicHeaders}
|
|
533
|
+
*/
|
|
534
|
+
export function anthropicHeaders(apiKey: string): AnthropicHeaders {
|
|
535
|
+
return {
|
|
536
|
+
"Content-Type": "application/json",
|
|
537
|
+
"anthropic-version": ANTHROPIC_API_VERSION,
|
|
538
|
+
"x-api-key": apiKey,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ─── Normalization ───────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The stop reasons the stream reports, from a Messages API `stop_reason`: `end_turn` and
|
|
546
|
+
* `stop_sequence` are `stop`, `max_tokens` and `model_context_window_exceeded` are `length`,
|
|
547
|
+
* `tool_use` is `tool_calls`, and any other (`pause_turn`, `refusal`) is passed through as sent.
|
|
548
|
+
*
|
|
549
|
+
* @param {string} reason - The message's `stop_reason`
|
|
550
|
+
* @returns {string}
|
|
551
|
+
*/
|
|
552
|
+
function stopReasonOf(reason: string): string {
|
|
553
|
+
switch (reason) {
|
|
554
|
+
case "end_turn":
|
|
555
|
+
case "stop_sequence": {
|
|
556
|
+
return "stop";
|
|
557
|
+
}
|
|
558
|
+
case "max_tokens":
|
|
559
|
+
case "model_context_window_exceeded": {
|
|
560
|
+
return "length";
|
|
561
|
+
}
|
|
562
|
+
case "tool_use": {
|
|
563
|
+
return "tool_calls";
|
|
564
|
+
}
|
|
565
|
+
default: {
|
|
566
|
+
return reason;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** The counts a stream reports, gathered from `message_start` and every `message_delta`. */
|
|
572
|
+
interface UsageCounts {
|
|
573
|
+
input?: number;
|
|
574
|
+
cacheRead?: number;
|
|
575
|
+
cacheWrite?: number;
|
|
576
|
+
output?: number;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Fold a usage object into the counts. Every figure the API reports is cumulative, so a later one
|
|
581
|
+
* replaces an earlier one, and a figure that is absent leaves the earlier one standing.
|
|
582
|
+
*
|
|
583
|
+
* @param {UsageCounts} counts - The counts so far
|
|
584
|
+
* @param {AnthropicUsage | undefined} usage - A `usage` member, as sent
|
|
585
|
+
*/
|
|
586
|
+
function foldUsage(counts: UsageCounts, usage: AnthropicUsage | undefined): void {
|
|
587
|
+
if (!isEntry(usage)) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (isCount(usage.input_tokens)) {
|
|
591
|
+
counts.input = usage.input_tokens;
|
|
592
|
+
}
|
|
593
|
+
if (isCount(usage.cache_read_input_tokens)) {
|
|
594
|
+
counts.cacheRead = usage.cache_read_input_tokens;
|
|
595
|
+
}
|
|
596
|
+
if (isCount(usage.cache_creation_input_tokens)) {
|
|
597
|
+
counts.cacheWrite = usage.cache_creation_input_tokens;
|
|
598
|
+
}
|
|
599
|
+
if (isCount(usage.output_tokens)) {
|
|
600
|
+
counts.output = usage.output_tokens;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The `usage` frame for the counts, or null when the stream reported no input count. `inputTokens`
|
|
606
|
+
* is everything the model read, cached or not, because that is the context's size.
|
|
607
|
+
*
|
|
608
|
+
* @param {UsageCounts} counts
|
|
609
|
+
* @returns {StreamUsageEvent | null}
|
|
610
|
+
*/
|
|
611
|
+
function usageFrame(counts: UsageCounts): StreamUsageEvent | null {
|
|
612
|
+
if (counts.input === undefined) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
const frame: StreamUsageEvent = {
|
|
616
|
+
inputTokens: counts.input + (counts.cacheRead ?? 0) + (counts.cacheWrite ?? 0),
|
|
617
|
+
outputTokens: counts.output ?? 0,
|
|
618
|
+
type: "usage",
|
|
619
|
+
};
|
|
620
|
+
if (counts.cacheRead !== undefined) {
|
|
621
|
+
frame.cachedInputTokens = counts.cacheRead;
|
|
622
|
+
}
|
|
623
|
+
return frame;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The `error` frame for an `error` event in the stream: the API's sentence, its error type as
|
|
628
|
+
* `code`, and an `upstreamFailure` problem, since the response began with a 200.
|
|
629
|
+
*
|
|
630
|
+
* @param {AnthropicStreamEvent["error"]} error - The event's `error` member
|
|
631
|
+
* @returns {StreamEvent}
|
|
632
|
+
*/
|
|
633
|
+
function errorEventFrame(error: AnthropicStreamEvent["error"]): StreamEvent {
|
|
634
|
+
const message =
|
|
635
|
+
isEntry(error) && isText(error.message) && error.message ? error.message : "Upstream error";
|
|
636
|
+
const frame: StreamEvent = {
|
|
637
|
+
message,
|
|
638
|
+
problem: problemDetails("upstreamFailure", message),
|
|
639
|
+
type: "error",
|
|
640
|
+
};
|
|
641
|
+
if (isEntry(error) && isText(error.type) && error.type) {
|
|
642
|
+
frame.code = error.type;
|
|
643
|
+
}
|
|
644
|
+
return frame;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Normalize a Messages API streaming response into the chat route's frames.
|
|
649
|
+
*
|
|
650
|
+
* - A non-2xx response is one `error` frame, read as the OpenAI-compatible normalizer reads one.
|
|
651
|
+
* - `text_delta` is `delta`, and a non-empty `thinking_delta` is `reasoning`. Signatures and redacted
|
|
652
|
+
* thinking are not forwarded (the version 1 wire cannot replay them).
|
|
653
|
+
* - A `tool_use` block is `tool_call_start`, a `tool_call_delta` per non-empty `input_json_delta`,
|
|
654
|
+
* and `tool_call_end` at its `content_block_stop`.
|
|
655
|
+
* - An `error` event is an `error` frame carrying an `upstreamFailure` problem, with the API's error
|
|
656
|
+
* type as `code`, and it ends the stream.
|
|
657
|
+
* - The last frames are the open calls' ends, the `usage` count when there is one, and `done` with
|
|
658
|
+
* the mapped stop reason, at `message_stop` or wherever the body ends.
|
|
659
|
+
* - An aborted read is `done: cancelled`; a consumer that stops reading cancels the upstream body.
|
|
660
|
+
*
|
|
661
|
+
* @param {Response} response - The upstream's response to `POST /v1/messages`
|
|
662
|
+
* @yields {StreamEvent} The route's frames, in wire order
|
|
663
|
+
*/
|
|
664
|
+
export async function* normalizeAnthropicStream(response: Response): AsyncGenerator<StreamEvent> {
|
|
665
|
+
if (!response.ok) {
|
|
666
|
+
yield await upstreamErrorFrame(response);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const reader = response.body?.getReader();
|
|
670
|
+
if (!reader) {
|
|
671
|
+
yield noBodyFrame();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const decoder = new TextDecoder();
|
|
676
|
+
let buffer = "";
|
|
677
|
+
/** The open tool-use blocks' ids, by content-block index. */
|
|
678
|
+
const calls = new Map<number, string>();
|
|
679
|
+
const counts: UsageCounts = {};
|
|
680
|
+
let stopReason: string | null = null;
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The stream's last frames: the open calls, then the count BEFORE `done`, because a reader stops
|
|
684
|
+
* at `done`.
|
|
685
|
+
*
|
|
686
|
+
* @yields {StreamEvent} The closing frames, in order
|
|
687
|
+
*/
|
|
688
|
+
function* finish(): Generator<StreamEvent> {
|
|
689
|
+
for (const id of calls.values()) {
|
|
690
|
+
yield { id, type: "tool_call_end" };
|
|
691
|
+
}
|
|
692
|
+
calls.clear();
|
|
693
|
+
const usage = usageFrame(counts);
|
|
694
|
+
if (usage) {
|
|
695
|
+
yield usage;
|
|
696
|
+
}
|
|
697
|
+
yield { stopReason: stopReason ?? "stop", type: "done" };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The frames for one event of the stream. `message_stop` and `error` are the loop's to act on.
|
|
702
|
+
*
|
|
703
|
+
* @param {AnthropicStreamEvent} event - The parsed `data:` payload
|
|
704
|
+
* @yields {StreamEvent} Its frames
|
|
705
|
+
*/
|
|
706
|
+
function* framesOf(event: AnthropicStreamEvent): Generator<StreamEvent> {
|
|
707
|
+
const index = isCount(event.index) ? event.index : -1;
|
|
708
|
+
const block = isEntry(event.content_block) ? event.content_block : null;
|
|
709
|
+
const delta = isEntry(event.delta) ? event.delta : null;
|
|
710
|
+
if (event.type === "message_start") {
|
|
711
|
+
foldUsage(counts, isEntry(event.message) ? event.message.usage : undefined);
|
|
712
|
+
} else if (event.type === "content_block_start" && block) {
|
|
713
|
+
if (block.type === "tool_use" && isText(block.id)) {
|
|
714
|
+
calls.set(index, block.id);
|
|
715
|
+
yield { id: block.id, name: isText(block.name) ? block.name : "", type: "tool_call_start" };
|
|
716
|
+
} else if (block.type === "text" && isText(block.text) && block.text) {
|
|
717
|
+
yield { content: block.text, type: "delta" };
|
|
718
|
+
}
|
|
719
|
+
} else if (event.type === "content_block_delta" && delta) {
|
|
720
|
+
const id = calls.get(index);
|
|
721
|
+
if (delta.type === "text_delta" && isText(delta.text) && delta.text) {
|
|
722
|
+
yield { content: delta.text, type: "delta" };
|
|
723
|
+
} else if (delta.type === "thinking_delta" && isText(delta.thinking) && delta.thinking) {
|
|
724
|
+
yield { content: delta.thinking, type: "reasoning" };
|
|
725
|
+
} else if (
|
|
726
|
+
delta.type === "input_json_delta" &&
|
|
727
|
+
id !== undefined &&
|
|
728
|
+
isText(delta.partial_json) &&
|
|
729
|
+
delta.partial_json
|
|
730
|
+
) {
|
|
731
|
+
yield { args: delta.partial_json, id, type: "tool_call_delta" };
|
|
732
|
+
}
|
|
733
|
+
} else if (event.type === "content_block_stop") {
|
|
734
|
+
const id = calls.get(index);
|
|
735
|
+
if (id !== undefined) {
|
|
736
|
+
calls.delete(index);
|
|
737
|
+
yield { id, type: "tool_call_end" };
|
|
738
|
+
}
|
|
739
|
+
} else if (event.type === "message_delta") {
|
|
740
|
+
if (delta && isText(delta.stop_reason)) {
|
|
741
|
+
stopReason = stopReasonOf(delta.stop_reason);
|
|
742
|
+
}
|
|
743
|
+
foldUsage(counts, event.usage);
|
|
744
|
+
}
|
|
745
|
+
// `ping`, and any event a later API version adds, carry nothing for the route.
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/* See `normalizeOpenAIStream`: `finally` cancels the upstream when the consumer stops reading. */
|
|
749
|
+
let settled = false;
|
|
750
|
+
try {
|
|
751
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
752
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
753
|
+
const lines = buffer.split("\n");
|
|
754
|
+
buffer = lines.pop() ?? "";
|
|
755
|
+
for (const line of lines) {
|
|
756
|
+
const trimmed = line.trim();
|
|
757
|
+
if (!trimmed.startsWith("data:")) {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
let event: AnthropicStreamEvent | null;
|
|
761
|
+
try {
|
|
762
|
+
/* SAFETY: the interface is the API's event shape, and it is only read: every member
|
|
763
|
+
passes `isEntry`, `isText` or `isCount` before it shapes a frame, and a payload that is
|
|
764
|
+
not an object is skipped below. */
|
|
765
|
+
event = JSON.parse(trimmed.slice(5)) as AnthropicStreamEvent | null;
|
|
766
|
+
} catch {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (!isEntry(event)) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (event.type === "message_stop") {
|
|
773
|
+
yield* finish();
|
|
774
|
+
settled = true;
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (event.type === "error") {
|
|
778
|
+
yield errorEventFrame(event.error);
|
|
779
|
+
settled = true;
|
|
780
|
+
void reader.cancel();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
yield* framesOf(event);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// The body ended without `message_stop`.
|
|
787
|
+
yield* finish();
|
|
788
|
+
settled = true;
|
|
789
|
+
} catch (error) {
|
|
790
|
+
settled = true;
|
|
791
|
+
void reader.cancel();
|
|
792
|
+
yield streamFailureFrame(error);
|
|
793
|
+
} finally {
|
|
794
|
+
if (!settled) {
|
|
795
|
+
void reader.cancel();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ─── Model listing ───────────────────────────────────────────────────────────
|
|
801
|
+
|
|
802
|
+
/** What a listing of an Anthropic endpoint's models found. */
|
|
803
|
+
export type AnthropicModelListing =
|
|
804
|
+
| { readonly ok: true; readonly models: AiModelInfo[] }
|
|
805
|
+
| { readonly ok: false; readonly upstreamError: number | "network"; readonly message: string };
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* A failure's message, for an `Error` or anything else a fetch rejected with.
|
|
809
|
+
*
|
|
810
|
+
* @param {unknown} cause - What was thrown
|
|
811
|
+
* @returns {string}
|
|
812
|
+
*/
|
|
813
|
+
function reasonOf(cause: unknown): string {
|
|
814
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The models on one page, each as its `id`, its `display_name` as the name, and `max_input_tokens`
|
|
819
|
+
* as its context window when the API reports a positive one.
|
|
820
|
+
*
|
|
821
|
+
* @param {AnthropicModelsPage} page - The parsed page
|
|
822
|
+
* @returns {AiModelInfo[]}
|
|
823
|
+
*/
|
|
824
|
+
function modelsOn(page: AnthropicModelsPage): AiModelInfo[] {
|
|
825
|
+
const models: AiModelInfo[] = [];
|
|
826
|
+
for (const entry of Array.isArray(page.data) ? page.data : []) {
|
|
827
|
+
if (!isEntry(entry) || !isText(entry.id) || !entry.id) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const name = isText(entry.display_name) && entry.display_name ? entry.display_name : entry.id;
|
|
831
|
+
const model: AiModelInfo = { id: entry.id, name };
|
|
832
|
+
if (isCount(entry.max_input_tokens) && entry.max_input_tokens > 0) {
|
|
833
|
+
model.contextWindow = entry.max_input_tokens;
|
|
834
|
+
}
|
|
835
|
+
models.push(model);
|
|
836
|
+
}
|
|
837
|
+
return models;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* List an Anthropic endpoint's models through `GET /v1/models`, following its pages.
|
|
842
|
+
*
|
|
843
|
+
* A failed page is the listing's failure: the upstream's status and the sentence in its body, or
|
|
844
|
+
* `"network"` and the reason the request never got a response.
|
|
845
|
+
*
|
|
846
|
+
* @param {{ baseUrl: string; apiKey: string }} upstream - Where to list, and with whose key
|
|
847
|
+
* @param {typeof fetch} [upstreamFetch] - The fetch to call it with; the global one when absent
|
|
848
|
+
* @returns {Promise<AnthropicModelListing>}
|
|
849
|
+
*/
|
|
850
|
+
export async function listAnthropicModels(
|
|
851
|
+
upstream: { readonly baseUrl: string; readonly apiKey: string },
|
|
852
|
+
upstreamFetch: typeof fetch = fetch,
|
|
853
|
+
): Promise<AnthropicModelListing> {
|
|
854
|
+
const models: AiModelInfo[] = [];
|
|
855
|
+
let after = "";
|
|
856
|
+
for (let page = 0; page < MAX_MODEL_PAGES; page += 1) {
|
|
857
|
+
const url = new URL(`${upstream.baseUrl}/v1/models`);
|
|
858
|
+
url.searchParams.set("limit", "1000");
|
|
859
|
+
if (after) {
|
|
860
|
+
url.searchParams.set("after_id", after);
|
|
861
|
+
}
|
|
862
|
+
let response: Response;
|
|
863
|
+
try {
|
|
864
|
+
response = await upstreamFetch(url.href, {
|
|
865
|
+
headers: { ...anthropicHeaders(upstream.apiKey) },
|
|
866
|
+
});
|
|
867
|
+
} catch (error) {
|
|
868
|
+
return { message: reasonOf(error), ok: false, upstreamError: "network" };
|
|
869
|
+
}
|
|
870
|
+
if (!response.ok) {
|
|
871
|
+
let body = "";
|
|
872
|
+
try {
|
|
873
|
+
body = await response.text();
|
|
874
|
+
} catch {
|
|
875
|
+
/* The status alone is the answer. */
|
|
876
|
+
}
|
|
877
|
+
const message = extractUpstreamErrorMessage(body, response.statusText);
|
|
878
|
+
return { message, ok: false, upstreamError: response.status };
|
|
879
|
+
}
|
|
880
|
+
let parsed: AnthropicModelsPage | null;
|
|
881
|
+
try {
|
|
882
|
+
/* SAFETY: the interface is the API's page shape; `modelsOn` checks every entry and member
|
|
883
|
+
it reads, and a body that is not an object ends the listing below. */
|
|
884
|
+
parsed = (await response.json()) as AnthropicModelsPage | null;
|
|
885
|
+
} catch (error) {
|
|
886
|
+
return { message: reasonOf(error), ok: false, upstreamError: response.status };
|
|
887
|
+
}
|
|
888
|
+
if (!isEntry(parsed)) {
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
models.push(...modelsOn(parsed));
|
|
892
|
+
if (parsed.has_more !== true || !isText(parsed.last_id) || !parsed.last_id) {
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
after = parsed.last_id;
|
|
896
|
+
}
|
|
897
|
+
return { models, ok: true };
|
|
898
|
+
}
|
package/src/gateway/chat.ts
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import { upstreamCacheHints } from "../cache-hints.ts";
|
|
8
8
|
import type { StreamEvent } from "../streaming-client.ts";
|
|
9
|
+
import {
|
|
10
|
+
anthropicHeaders,
|
|
11
|
+
isFirstPartyAnthropic,
|
|
12
|
+
normalizeAnthropicStream,
|
|
13
|
+
toAnthropicRequest,
|
|
14
|
+
} from "./anthropic.ts";
|
|
9
15
|
import { fetchFailureFrame, normalizeOpenAIStream } from "./normalize.ts";
|
|
10
16
|
import { problemResponse, refusal } from "./problem.ts";
|
|
11
17
|
import { encodeSse, SSE_HEADERS } from "./sse.ts";
|
|
@@ -86,18 +92,20 @@ async function readBody(request: Request, limit: number | undefined): Promise<Ui
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/**
|
|
89
|
-
* The frames of one upstream exchange: the request, then its normalized
|
|
90
|
-
* that says why there is no response.
|
|
95
|
+
* The frames of one upstream exchange: the request, then its response normalized by the family's
|
|
96
|
+
* normalizer, or the one frame that says why there is no response.
|
|
91
97
|
*
|
|
92
98
|
* @param {typeof fetch} upstreamFetch - The fetch to call the upstream with
|
|
93
|
-
* @param {string} url - The upstream's chat
|
|
99
|
+
* @param {string} url - The upstream's chat URL
|
|
94
100
|
* @param {RequestInit} init - The upstream request
|
|
101
|
+
* @param {(response: Response) => AsyncGenerator<StreamEvent>} normalize - The family's normalizer
|
|
95
102
|
* @yields {StreamEvent} The route's frames, in wire order
|
|
96
103
|
*/
|
|
97
104
|
async function* exchange(
|
|
98
105
|
upstreamFetch: typeof fetch,
|
|
99
106
|
url: string,
|
|
100
107
|
init: RequestInit,
|
|
108
|
+
normalize: (response: Response) => AsyncGenerator<StreamEvent>,
|
|
101
109
|
): AsyncGenerator<StreamEvent> {
|
|
102
110
|
let response: Response;
|
|
103
111
|
try {
|
|
@@ -106,7 +114,7 @@ async function* exchange(
|
|
|
106
114
|
yield fetchFailureFrame(error);
|
|
107
115
|
return;
|
|
108
116
|
}
|
|
109
|
-
yield*
|
|
117
|
+
yield* normalize(response);
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
/**
|
|
@@ -122,7 +130,8 @@ async function* exchange(
|
|
|
122
130
|
* 5. `maxMessages` (413).
|
|
123
131
|
* 6. `onAccepted`.
|
|
124
132
|
* 7. The stream: a `200 text/event-stream` whose frames are the upstream's response normalized by
|
|
125
|
-
* `normalizeOpenAIStream
|
|
133
|
+
* `normalizeOpenAIStream` (or `normalizeAnthropicStream` for the `anthropic` family), or the one
|
|
134
|
+
* frame saying why the upstream could not be reached.
|
|
126
135
|
*
|
|
127
136
|
* The version 1 body is forwarded as the OpenAI-compatible request it describes: the system prompt
|
|
128
137
|
* as a leading `system` message, `stream_options.include_usage` so the provider's own token count
|
|
@@ -131,6 +140,10 @@ async function* exchange(
|
|
|
131
140
|
* understands, and not at all to any other host (`upstreamCacheHints`). The upstream request
|
|
132
141
|
* carries `context.signal`, so aborting it ends the stream with `done: cancelled`.
|
|
133
142
|
*
|
|
143
|
+
* An upstream of the `anthropic` family receives the same body projected onto the Messages API
|
|
144
|
+
* instead (`toAnthropicRequest`, specs/ai.md §2.6), at `<baseUrl>/v1/messages`, with the key as
|
|
145
|
+
* `x-api-key`. It gets no session-affinity hint: Anthropic's prompt cache needs none.
|
|
146
|
+
*
|
|
134
147
|
* @param {ChatGatewayOptions<C>} options - The host's policy
|
|
135
148
|
* @returns {(request: Request, context: C) => Promise<Response>}
|
|
136
149
|
*/
|
|
@@ -205,6 +218,28 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
|
|
|
205
218
|
context,
|
|
206
219
|
);
|
|
207
220
|
|
|
221
|
+
// Looked up per request, not captured at construction: a host or a test may swap it.
|
|
222
|
+
const upstreamFetch = options.fetch ?? fetch;
|
|
223
|
+
|
|
224
|
+
if (upstream.family === "anthropic") {
|
|
225
|
+
const messagesRequest = toAnthropicRequest(
|
|
226
|
+
{ messages, model: String(model), systemPrompt, tools },
|
|
227
|
+
{ firstParty: isFirstPartyAnthropic(upstream.baseUrl) },
|
|
228
|
+
);
|
|
229
|
+
const frames = exchange(
|
|
230
|
+
upstreamFetch,
|
|
231
|
+
`${upstream.baseUrl}/v1/messages`,
|
|
232
|
+
{
|
|
233
|
+
body: JSON.stringify(messagesRequest),
|
|
234
|
+
headers: { ...anthropicHeaders(upstream.apiKey) },
|
|
235
|
+
method: "POST",
|
|
236
|
+
signal: context.signal,
|
|
237
|
+
},
|
|
238
|
+
normalizeAnthropicStream,
|
|
239
|
+
);
|
|
240
|
+
return new Response(encodeSse(frames), { headers: SSE_HEADERS });
|
|
241
|
+
}
|
|
242
|
+
|
|
208
243
|
// SAFETY: Array.isArray above proved `messages` is an array; its entries are forwarded unread.
|
|
209
244
|
const upstreamBody: UpstreamBody = {
|
|
210
245
|
model,
|
|
@@ -223,18 +258,21 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
|
|
|
223
258
|
const hints = upstreamCacheHints(upstream.baseUrl, upstream.sessionAffinity);
|
|
224
259
|
Object.assign(upstreamBody, hints.body);
|
|
225
260
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
261
|
+
const frames = exchange(
|
|
262
|
+
upstreamFetch,
|
|
263
|
+
`${upstream.baseUrl}/chat/completions`,
|
|
264
|
+
{
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: {
|
|
267
|
+
"Content-Type": "application/json",
|
|
268
|
+
Authorization: `Bearer ${upstream.apiKey}`,
|
|
269
|
+
...hints.headers,
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify(upstreamBody),
|
|
272
|
+
signal: context.signal,
|
|
234
273
|
},
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
274
|
+
normalizeOpenAIStream,
|
|
275
|
+
);
|
|
238
276
|
return new Response(encodeSse(frames), { headers: SSE_HEADERS });
|
|
239
277
|
};
|
|
240
278
|
}
|
package/src/gateway/index.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* `@jxsuite/ai/gateway`: the server half of the `ai/chat` and `ai/models` routes, written once.
|
|
3
3
|
*
|
|
4
4
|
* Every Jx backend that proxies the assistant to a provider does the same wire work: read the
|
|
5
|
-
* client's body, forward it as an OpenAI-compatible request
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* client's body, forward it as an OpenAI-compatible request (or project it onto the Anthropic
|
|
6
|
+
* Messages API, for that family), normalize the provider's stream into the route's frames, and
|
|
7
|
+
* frame those as SSE. Before this module each backend carried its own copy, and the copies drifted.
|
|
8
|
+
* Now `@jxsuite/server` runs this one, and any other backend can.
|
|
8
9
|
*
|
|
9
10
|
* What stays with the host is policy: whose key a request may use, which base URLs are safe to
|
|
10
11
|
* reach, how requests are admitted, and what the model catalogue says. The host supplies those as
|
|
@@ -23,6 +24,15 @@ export {
|
|
|
23
24
|
isAiSessionId,
|
|
24
25
|
upstreamCacheHints,
|
|
25
26
|
} from "../cache-hints.ts";
|
|
27
|
+
export {
|
|
28
|
+
ANTHROPIC_API_VERSION,
|
|
29
|
+
ANTHROPIC_BASE_URL,
|
|
30
|
+
ANTHROPIC_MAX_TOKENS,
|
|
31
|
+
isFirstPartyAnthropic,
|
|
32
|
+
listAnthropicModels,
|
|
33
|
+
normalizeAnthropicStream,
|
|
34
|
+
toAnthropicRequest,
|
|
35
|
+
} from "./anthropic.ts";
|
|
26
36
|
export { createChatHandler, DEFAULT_UPSTREAM_BASE_URL } from "./chat.ts";
|
|
27
37
|
export { modelsResponse } from "./models.ts";
|
|
28
38
|
export { normalizeOpenAIStream } from "./normalize.ts";
|
|
@@ -30,4 +40,10 @@ export { problemResponse } from "./problem.ts";
|
|
|
30
40
|
export { encodeSse } from "./sse.ts";
|
|
31
41
|
export { extractUpstreamErrorMessage } from "./upstream-error.ts";
|
|
32
42
|
export type { UpstreamCacheHints } from "../cache-hints.ts";
|
|
43
|
+
export type {
|
|
44
|
+
AnthropicModelListing,
|
|
45
|
+
AnthropicProjectionInput,
|
|
46
|
+
AnthropicProjectionOptions,
|
|
47
|
+
AnthropicRequestBody,
|
|
48
|
+
} from "./anthropic.ts";
|
|
33
49
|
export type { ChatAdmission, ChatGatewayOptions, GatewayRefusal, Upstream } from "./types.ts";
|
package/src/gateway/normalize.ts
CHANGED
|
@@ -91,6 +91,56 @@ export function fetchFailureFrame(cause: unknown): StreamEvent {
|
|
|
91
91
|
return failureFrame("Network error", cause);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The frame for a stream whose read failed part-way: `done: cancelled` when it was aborted,
|
|
96
|
+
* otherwise an `error` frame carrying an `upstreamFailure` problem. Every normalizer ends a failed
|
|
97
|
+
* read with it.
|
|
98
|
+
*
|
|
99
|
+
* @param {unknown} cause - What the read rejected with
|
|
100
|
+
* @returns {StreamEvent}
|
|
101
|
+
*/
|
|
102
|
+
export function streamFailureFrame(cause: unknown): StreamEvent {
|
|
103
|
+
return failureFrame("Stream error", cause);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The one frame for an upstream that answered non-2xx: the sentence inside its body as `message`,
|
|
108
|
+
* its own status as `code`, and a problem naming the kind of failure. Every normalizer answers a
|
|
109
|
+
* refused request with it, so a client reads one shape whichever provider refused.
|
|
110
|
+
*
|
|
111
|
+
* @param {Response} response - The upstream's non-2xx response
|
|
112
|
+
* @returns {Promise<StreamEvent>}
|
|
113
|
+
*/
|
|
114
|
+
export async function upstreamErrorFrame(response: Response): Promise<StreamEvent> {
|
|
115
|
+
let errorBody = "";
|
|
116
|
+
try {
|
|
117
|
+
errorBody = await response.text();
|
|
118
|
+
} catch {
|
|
119
|
+
/* Ignore */
|
|
120
|
+
}
|
|
121
|
+
const cleanMessage = extractUpstreamErrorMessage(errorBody, response.statusText);
|
|
122
|
+
return {
|
|
123
|
+
code: String(response.status),
|
|
124
|
+
message: cleanMessage,
|
|
125
|
+
// The upstream's own status is preserved in `code`; the problem names the KIND.
|
|
126
|
+
problem: problemDetails(problemTypeForStatus(response.status), cleanMessage),
|
|
127
|
+
type: "error",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The frame for a 2xx upstream response with no body to read.
|
|
133
|
+
*
|
|
134
|
+
* @returns {StreamEvent}
|
|
135
|
+
*/
|
|
136
|
+
export function noBodyFrame(): StreamEvent {
|
|
137
|
+
return {
|
|
138
|
+
message: "No response body from upstream",
|
|
139
|
+
problem: problemDetails("upstreamFailure", "No response body from upstream"),
|
|
140
|
+
type: "error",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
/**
|
|
95
145
|
* The frame for an exchange that failed: `done: cancelled` for an abort, otherwise an `error` frame
|
|
96
146
|
* whose `message` is `prefix` and the failure's own message.
|
|
@@ -132,30 +182,13 @@ function failureFrame(prefix: "Network error" | "Stream error", cause: unknown):
|
|
|
132
182
|
*/
|
|
133
183
|
export async function* normalizeOpenAIStream(response: Response): AsyncGenerator<StreamEvent> {
|
|
134
184
|
if (!response.ok) {
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
errorBody = await response.text();
|
|
138
|
-
} catch {
|
|
139
|
-
/* Ignore */
|
|
140
|
-
}
|
|
141
|
-
const cleanMessage = extractUpstreamErrorMessage(errorBody, response.statusText);
|
|
142
|
-
yield {
|
|
143
|
-
code: String(response.status),
|
|
144
|
-
message: cleanMessage,
|
|
145
|
-
// The upstream's own status is preserved in `code`; the problem names the KIND.
|
|
146
|
-
problem: problemDetails(problemTypeForStatus(response.status), cleanMessage),
|
|
147
|
-
type: "error",
|
|
148
|
-
};
|
|
185
|
+
yield await upstreamErrorFrame(response);
|
|
149
186
|
return;
|
|
150
187
|
}
|
|
151
188
|
|
|
152
189
|
const reader = response.body?.getReader();
|
|
153
190
|
if (!reader) {
|
|
154
|
-
yield
|
|
155
|
-
message: "No response body from upstream",
|
|
156
|
-
problem: problemDetails("upstreamFailure", "No response body from upstream"),
|
|
157
|
-
type: "error",
|
|
158
|
-
};
|
|
191
|
+
yield noBodyFrame();
|
|
159
192
|
return;
|
|
160
193
|
}
|
|
161
194
|
|
|
@@ -294,7 +327,7 @@ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator
|
|
|
294
327
|
} catch (error) {
|
|
295
328
|
settled = true;
|
|
296
329
|
void reader.cancel();
|
|
297
|
-
yield
|
|
330
|
+
yield streamFailureFrame(error);
|
|
298
331
|
} finally {
|
|
299
332
|
if (!settled) {
|
|
300
333
|
void reader.cancel();
|
package/src/gateway/types.ts
CHANGED
|
@@ -17,11 +17,19 @@ import type { ProblemDetails } from "@jxsuite/protocol/problem";
|
|
|
17
17
|
|
|
18
18
|
/** Where one chat request is forwarded. A host decides it per request, in `resolveUpstream`. */
|
|
19
19
|
export interface Upstream {
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The wire the upstream speaks: the OpenAI-compatible chat-completions API, or the Anthropic
|
|
22
|
+
* Messages API (specs/ai.md §2.6). A client's body is the same version 1 body either way; the
|
|
23
|
+
* gateway projects it onto the family's wire and normalizes the family's stream.
|
|
24
|
+
*/
|
|
25
|
+
readonly family: "openai-compat" | "anthropic";
|
|
26
|
+
/**
|
|
27
|
+
* The provider's base URL, without the path the gateway appends: `/chat/completions` for the
|
|
28
|
+
* OpenAI-compatible family (so it usually ends in `/v1`), `/v1/messages` for Anthropic (so it is
|
|
29
|
+
* the origin, `ANTHROPIC_BASE_URL`).
|
|
30
|
+
*/
|
|
23
31
|
readonly baseUrl: string;
|
|
24
|
-
/** Sent as a bearer token in the `Authorization` header. */
|
|
32
|
+
/** Sent as a bearer token in the `Authorization` header, or as `x-api-key` to Anthropic. */
|
|
25
33
|
readonly apiKey: string;
|
|
26
34
|
/** The model a request that names none is forwarded with. */
|
|
27
35
|
readonly defaultModel: string;
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { createChatState } from "./chat-state.js";
|
|
|
16
16
|
export type { ChatUsage } from "./chat-state.js";
|
|
17
17
|
|
|
18
18
|
export {
|
|
19
|
+
AI_PROVIDER_HEADER,
|
|
19
20
|
STREAM_EVENT_TYPES,
|
|
20
21
|
createOpenAIStreamingClient,
|
|
21
22
|
createAnthropicStreamingClient,
|
|
@@ -33,6 +34,7 @@ export type { UpstreamCacheHints } from "./cache-hints.js";
|
|
|
33
34
|
|
|
34
35
|
export type {
|
|
35
36
|
AiCredentials,
|
|
37
|
+
AiProviderFamily,
|
|
36
38
|
AnthropicStreamingClientOptions,
|
|
37
39
|
OpenAIStreamingClientOptions,
|
|
38
40
|
ProxyStreamingClientOptions,
|
package/src/streaming-client.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* @docs extending/embedding/backend-protocol
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { ProblemDetails } from "@jxsuite/protocol";
|
|
16
|
+
import type { AiProviderFamily, ProblemDetails } from "@jxsuite/protocol";
|
|
17
17
|
import { AI_SESSION_HEADER, upstreamCacheHints } from "./cache-hints.ts";
|
|
18
18
|
|
|
19
19
|
/* Re-exported so `./streaming-client`, the subpath a backend already imports, carries the cache
|
|
@@ -25,6 +25,15 @@ export {
|
|
|
25
25
|
upstreamCacheHints,
|
|
26
26
|
} from "./cache-hints.ts";
|
|
27
27
|
export type { UpstreamCacheHints } from "./cache-hints.ts";
|
|
28
|
+
export type { AiProviderFamily } from "@jxsuite/protocol";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The request header naming the provider family a caller's key belongs to (specs/ai.md §2.6). Sent
|
|
32
|
+
* only for a family other than the OpenAI-compatible default, and only to a backend whose models
|
|
33
|
+
* route lists that family in `providers`: one that predates the header forwards every key to an
|
|
34
|
+
* OpenAI-compatible endpoint.
|
|
35
|
+
*/
|
|
36
|
+
export const AI_PROVIDER_HEADER = "X-Api-Provider";
|
|
28
37
|
|
|
29
38
|
export type StreamEvent =
|
|
30
39
|
| StreamDeltaEvent
|
|
@@ -689,7 +698,9 @@ export function createAnthropicStreamingClient(_opts: AiCredentials): StreamingC
|
|
|
689
698
|
* the stream, unless the signal was aborted by then, which ends it cancelled.
|
|
690
699
|
* @param {string} [opts.model] - Default model if not specified per-request
|
|
691
700
|
* @param {string} [opts.apiKey] - Optional client-supplied key, sent as the `X-Api-Key` header
|
|
692
|
-
* @param {string} [opts.baseUrl] - Optional
|
|
701
|
+
* @param {string} [opts.baseUrl] - Optional provider base URL, sent as `X-Api-Base-URL`
|
|
702
|
+
* @param {AiProviderFamily} [opts.provider] - The family the key belongs to, sent as
|
|
703
|
+
* `X-Api-Provider` when it is not the OpenAI-compatible default (see {@link AI_PROVIDER_HEADER})
|
|
693
704
|
* @param {string} [opts.sessionId] - The conversation's id, sent as the `X-Jx-Ai-Session` header so
|
|
694
705
|
* the backend can derive a prompt-cache affinity key from it. The raw id goes to the backend
|
|
695
706
|
* only; a provider sees a hash of it, scoped to that backend (`affinityKey`).
|
|
@@ -700,13 +711,15 @@ export interface ProxyStreamingClientOptions {
|
|
|
700
711
|
model?: string;
|
|
701
712
|
apiKey?: string | undefined;
|
|
702
713
|
baseUrl?: string | undefined;
|
|
714
|
+
provider?: AiProviderFamily | undefined;
|
|
703
715
|
sessionId?: string | undefined;
|
|
704
716
|
}
|
|
705
717
|
|
|
706
718
|
/**
|
|
707
|
-
* The headers a proxy request carries: always the content type, plus `X-Api-Key`, `X-Api-Base-URL
|
|
708
|
-
* and the session header `AI_SESSION_HEADER` names, each only when the client was
|
|
709
|
-
* The optional names go through the index signature, as a `fetch` init's record
|
|
719
|
+
* The headers a proxy request carries: always the content type, plus `X-Api-Key`, `X-Api-Base-URL`,
|
|
720
|
+
* `X-Api-Provider` and the session header `AI_SESSION_HEADER` names, each only when the client was
|
|
721
|
+
* given its value. The optional names go through the index signature, as a `fetch` init's record
|
|
722
|
+
* takes them.
|
|
710
723
|
*/
|
|
711
724
|
interface ProxyRequestHeaders extends Record<string, string> {
|
|
712
725
|
"Content-Type": string;
|
|
@@ -729,6 +742,7 @@ export function createProxyStreamingClient({
|
|
|
729
742
|
model = "gpt-4o",
|
|
730
743
|
apiKey,
|
|
731
744
|
baseUrl,
|
|
745
|
+
provider,
|
|
732
746
|
sessionId,
|
|
733
747
|
}: ProxyStreamingClientOptions): StreamingClient {
|
|
734
748
|
/** The URL, once the first stream has asked for it. */
|
|
@@ -753,10 +767,15 @@ export function createProxyStreamingClient({
|
|
|
753
767
|
if (apiKey) {
|
|
754
768
|
headers["X-Api-Key"] = apiKey;
|
|
755
769
|
}
|
|
756
|
-
// Optional
|
|
770
|
+
// Optional endpoint override (local LLM, OpenRouter, Azure, an Anthropic-compatible proxy, …).
|
|
757
771
|
if (baseUrl) {
|
|
758
772
|
headers["X-Api-Base-URL"] = baseUrl;
|
|
759
773
|
}
|
|
774
|
+
/* The default family says nothing, so a request to an OpenAI-compatible provider is the one a
|
|
775
|
+
backend that predates the header has always read. */
|
|
776
|
+
if (provider && provider !== "openai-compat") {
|
|
777
|
+
headers[AI_PROVIDER_HEADER] = provider;
|
|
778
|
+
}
|
|
760
779
|
/* The conversation, so consecutive rounds can be routed to a warm prompt cache. Same-origin on
|
|
761
780
|
every platform, so the extra header costs no preflight; a backend that ignores it loses only
|
|
762
781
|
the cache hit. */
|