@jxsuite/ai 0.41.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.41.1",
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,12 +33,12 @@
33
33
  "upgrade": "bun update --latest && bun install"
34
34
  },
35
35
  "dependencies": {
36
- "@jxsuite/protocol": "^6.0.0",
36
+ "@jxsuite/protocol": "^6.2.0",
37
37
  "@jxsuite/schema": "^5.0.0",
38
38
  "@vue/reactivity": "3.5.44"
39
39
  },
40
40
  "devDependencies": {
41
- "@cloudflare/workers-types": "^5.20261026.1",
41
+ "@cloudflare/workers-types": "^5.20261031.1",
42
42
  "@happy-dom/global-registrator": "^21.0.4"
43
43
  }
44
44
  }
@@ -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
+ }
@@ -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 response, or the one frame
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-completions URL
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* normalizeOpenAIStream(response);
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`, or the one frame saying why the upstream could not be reached.
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
- // Looked up per request, not captured at construction: a host or a test may swap it.
227
- const upstreamFetch = options.fetch ?? fetch;
228
- const frames = exchange(upstreamFetch, `${upstream.baseUrl}/chat/completions`, {
229
- method: "POST",
230
- headers: {
231
- "Content-Type": "application/json",
232
- Authorization: `Bearer ${upstream.apiKey}`,
233
- ...hints.headers,
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
- body: JSON.stringify(upstreamBody),
236
- signal: context.signal,
237
- });
274
+ normalizeOpenAIStream,
275
+ );
238
276
  return new Response(encodeSse(frames), { headers: SSE_HEADERS });
239
277
  };
240
278
  }
@@ -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, normalize the provider's stream into
6
- * the route's frames, and frame those as SSE. Before this module each backend carried its own copy,
7
- * and the copies drifted. Now `@jxsuite/server` runs this one, and any other backend can.
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";
@@ -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
- let errorBody = "";
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 failureFrame("Stream error", error);
330
+ yield streamFailureFrame(error);
298
331
  } finally {
299
332
  if (!settled) {
300
333
  void reader.cancel();
@@ -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
- /** The wire the upstream speaks. The OpenAI-compatible chat-completions API is the only one. */
21
- readonly family: "openai-compat";
22
- /** The provider's base URL, without the `/chat/completions` the gateway appends. */
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,
@@ -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 OpenAI-compatible base URL, sent as `X-Api-Base-URL`
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 given its value.
709
- * The optional names go through the index signature, as a `fetch` init's record takes them.
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 OpenAI-compatible endpoint override (local LLM, OpenRouter, Azure, …).
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. */