@jxsuite/ai 0.37.2 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The chat route's framing: one Server-Sent Events `data:` line per frame.
3
+ *
4
+ * @module @jxsuite/ai/gateway
5
+ */
6
+
7
+ import type { StreamEvent } from "../streaming-client.ts";
8
+
9
+ /**
10
+ * The headers a chat stream is answered with. `no-cache` because a replayed stream is a different
11
+ * turn, and `keep-alive` because the answer is long-lived by design.
12
+ */
13
+ export const SSE_HEADERS: Readonly<Record<string, string>> = {
14
+ "Content-Type": "text/event-stream",
15
+ "Cache-Control": "no-cache",
16
+ Connection: "keep-alive",
17
+ };
18
+
19
+ /**
20
+ * Frame `events` as a Server-Sent Events body: `data: <JSON>` and a blank line per frame, and
21
+ * nothing else (no `event:`, `id:` or `retry:` field), so the JSON's own `type` is the only
22
+ * discriminator a reader needs.
23
+ *
24
+ * The stream is PUSHED: iteration starts when the stream is constructed, not when it is first read,
25
+ * and every frame is enqueued as soon as it exists. That is how the proxy has always behaved (its
26
+ * upstream request is on the wire before the response is returned), and this extraction keeps it.
27
+ * An iterable that throws errors the stream.
28
+ *
29
+ * @param {AsyncIterable<StreamEvent>} events - The frames, in wire order
30
+ * @returns {ReadableStream<Uint8Array>}
31
+ */
32
+ export function encodeSse(events: AsyncIterable<StreamEvent>): ReadableStream<Uint8Array> {
33
+ const encoder = new TextEncoder();
34
+ return new ReadableStream<Uint8Array>({
35
+ async start(controller) {
36
+ for await (const event of events) {
37
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
38
+ }
39
+ controller.close();
40
+ },
41
+ });
42
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The gateway's contract types: what a host hands `createChatHandler`, and what the handler reports
3
+ * back to it.
4
+ *
5
+ * The split these types draw is the point of the gateway. The WIRE (reading the body, shaping the
6
+ * upstream request, normalizing the upstream stream, framing SSE, rendering a refusal) is the same
7
+ * for every host, so it lives here once. The POLICY (whose key a request may use, which base URLs
8
+ * are safe to reach, how many requests a caller may make) differs per host, so the host supplies it
9
+ * as `admit` and `resolveUpstream` and the gateway never second-guesses the answer.
10
+ *
11
+ * Type-only, so it is on the allowlist in `scripts/check-coverage-manifest.ts`.
12
+ *
13
+ * @module @jxsuite/ai/gateway
14
+ */
15
+
16
+ import type { ProblemDetails } from "@jxsuite/protocol/problem";
17
+
18
+ /** Where one chat request is forwarded. A host decides it per request, in `resolveUpstream`. */
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. */
23
+ readonly baseUrl: string;
24
+ /** Sent as a bearer token in the `Authorization` header. */
25
+ readonly apiKey: string;
26
+ /** The model a request that names none is forwarded with. */
27
+ readonly defaultModel: string;
28
+ /** True when the platform brokers the credential, rather than the user supplying one. */
29
+ readonly managed: boolean;
30
+ /**
31
+ * The prompt-cache affinity key for the request's conversation, when the host derived one: the
32
+ * gateway forwards it in whatever form this upstream understands, and to no other upstream
33
+ * (`upstreamCacheHints`). A host derives it with `affinityKey(scope, id)` from the client's
34
+ * `X-Jx-Ai-Session` header, so the raw id never leaves the host.
35
+ */
36
+ readonly sessionAffinity?: string;
37
+ }
38
+
39
+ /**
40
+ * A request the gateway (or the host's policy) declined, answered as `application/problem+json`
41
+ * before any upstream is contacted.
42
+ */
43
+ export interface GatewayRefusal {
44
+ /** The HTTP status of the refusal. */
45
+ readonly status: number;
46
+ /** The RFC 9457 problem document the body carries. */
47
+ readonly problem: ProblemDetails;
48
+ /** A machine code for clients that key on `code` rather than on the problem type. */
49
+ readonly code?: string;
50
+ }
51
+
52
+ /** What a request the gateway accepted asked for, reported to `onAccepted` for audit. */
53
+ export interface ChatAdmission {
54
+ /** The wire version of the accepted body. Version 1 is the only one. */
55
+ readonly wire: 1;
56
+ /** The model the request is forwarded with: the body's, or the upstream's default. */
57
+ readonly model: string;
58
+ /** How many messages the body carried, not counting the system prompt the gateway prepends. */
59
+ readonly messageCount: number;
60
+ /** The body's size in bytes, as read rather than as declared. */
61
+ readonly bytes: number;
62
+ }
63
+
64
+ /** A host's half of the chat route. Only `resolveUpstream` is required. */
65
+ export interface ChatGatewayOptions<C> {
66
+ /** Runs first, before the body is read (a rate limit, say). A refusal ends the request. */
67
+ readonly admit?: (
68
+ request: Request,
69
+ context: C,
70
+ ) => Promise<GatewayRefusal | null> | GatewayRefusal | null;
71
+ /**
72
+ * Where the request goes, or why it goes nowhere. This is the host's policy: key provenance and
73
+ * any guard on a caller-supplied base URL belong here, not in the gateway.
74
+ */
75
+ readonly resolveUpstream: (
76
+ request: Request,
77
+ context: C,
78
+ ) => Promise<Upstream | GatewayRefusal> | Upstream | GatewayRefusal;
79
+ /** The largest body accepted, in bytes actually read. Reading stops once it is passed. */
80
+ readonly maxBodyBytes?: number;
81
+ /** The most messages one request may carry. */
82
+ readonly maxMessages?: number;
83
+ /** Called once per accepted request, after every check and before the upstream is contacted. */
84
+ readonly onAccepted?: (admission: ChatAdmission, upstream: Upstream, context: C) => void;
85
+ /** The fetch the upstream is called with. The global `fetch`, looked up per call, when absent. */
86
+ readonly fetch?: typeof fetch;
87
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The sentence inside an upstream provider's error body.
3
+ *
4
+ * @module @jxsuite/ai/gateway
5
+ */
6
+
7
+ /**
8
+ * Extract a human-readable message from an upstream error body, falling back to the raw body (or
9
+ * `fallback` when the body is empty).
10
+ *
11
+ * OpenAI-compatible providers return `{ error: "..." }` or `{ error: { message: "..." } }`.
12
+ * Cloudflare's own REST API (what a BYOK base URL pointed at Workers AI actually returns) uses a
13
+ * different envelope, `{ success: false, errors: [{ code, message }] }`, with no top-level `error`
14
+ * key at all. Without this branch that shape parses as valid JSON with nothing matched, and the
15
+ * caller was left showing the whole raw body (e.g. the full `{"success":false,"errors":[...]}`
16
+ * blob) instead of the human sentence inside it.
17
+ *
18
+ * The chat stream's `error` frame reads it, and so does a host's model catalogue, which reports the
19
+ * same sentence as `upstreamMessage`.
20
+ *
21
+ * @param {string} rawBody - The upstream's response body, as text
22
+ * @param {string} fallback - What to say when the body is empty (the status text, usually)
23
+ * @returns {string}
24
+ */
25
+ export function extractUpstreamErrorMessage(rawBody: string, fallback: string): string {
26
+ if (!rawBody) {
27
+ return fallback;
28
+ }
29
+ try {
30
+ const { error, errors } = JSON.parse(rawBody) as {
31
+ error?: string | { message?: string };
32
+ errors?: { code?: number; message?: string }[];
33
+ };
34
+ if (typeof error === "string") {
35
+ return error;
36
+ }
37
+ if (error) {
38
+ const { message } = error;
39
+ if (message) {
40
+ return message;
41
+ }
42
+ }
43
+ const [first] = errors ?? [];
44
+ if (first) {
45
+ const { message } = first;
46
+ if (message) {
47
+ return message;
48
+ }
49
+ }
50
+ } catch {
51
+ /* Not JSON: use the raw body. */
52
+ }
53
+ return rawBody;
54
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Index.js — AI infrastructure for Jx Suite (`@jxsuite/ai`)
3
3
  *
4
- * Re-exports the core modules: streaming client abstraction, tool registry, and
5
- * reactive chat state management. Provider-agnostic; no Studio or Jx dependencies.
4
+ * Re-exports the core modules: streaming client abstraction, tool registry, and reactive chat state
5
+ * management. Provider-agnostic; no Studio or Jx dependencies.
6
6
  *
7
7
  * @license MIT
8
8
  */
@@ -13,6 +13,8 @@ export { createToolDefinition, createToolRegistry } from "./tools.js";
13
13
 
14
14
  export { createChatState } from "./chat-state.js";
15
15
 
16
+ export type { ChatUsage } from "./chat-state.js";
17
+
16
18
  export {
17
19
  STREAM_EVENT_TYPES,
18
20
  createOpenAIStreamingClient,
@@ -20,6 +22,15 @@ export {
20
22
  createProxyStreamingClient,
21
23
  } from "./streaming-client.js";
22
24
 
25
+ export {
26
+ AI_SESSION_HEADER,
27
+ affinityKey,
28
+ isAiSessionId,
29
+ upstreamCacheHints,
30
+ } from "./cache-hints.js";
31
+
32
+ export type { UpstreamCacheHints } from "./cache-hints.js";
33
+
23
34
  export type {
24
35
  AnthropicStreamingClientOptions,
25
36
  OpenAIStreamingClientOptions,
@@ -33,4 +44,5 @@ export type {
33
44
  StreamToolCallDeltaEvent,
34
45
  StreamToolCallEndEvent,
35
46
  StreamToolCallStartEvent,
47
+ StreamUsageEvent,
36
48
  } from "./streaming-client.js";
@@ -1,19 +1,30 @@
1
1
  /**
2
2
  * Streaming-client.js — Provider-agnostic streaming LLM client abstraction
3
3
  *
4
- * Defines the StreamingClient interface, the StreamEvent union every implementation yields,
5
- * and concrete implementations for OpenAI and Anthropic. Designed upfront so switching
6
- * providers is a new implementation, not a refactor.
4
+ * Defines the StreamingClient interface, the StreamEvent union every implementation yields, and
5
+ * concrete implementations for OpenAI and Anthropic. Designed upfront so switching providers is a
6
+ * new implementation, not a refactor.
7
7
  *
8
- * The union and its seven members are exported because they are the contract a third-party Studio
8
+ * The union and its eight members are exported because they are the contract a third-party Studio
9
9
  * backend implements for the `ai/chat` route, not an internal detail — see the docs page below.
10
10
  *
11
- * @license MIT
12
11
  * @module @jxsuite/ai/streaming-client
12
+ * @license MIT
13
13
  * @docs extending/embedding/backend-protocol
14
14
  */
15
15
 
16
16
  import type { ProblemDetails } from "@jxsuite/protocol";
17
+ import { AI_SESSION_HEADER, upstreamCacheHints } from "./cache-hints.ts";
18
+
19
+ /* Re-exported so `./streaming-client`, the subpath a backend already imports, carries the cache
20
+ hints too: a Worker backend hashing the session header needs nothing else from this package. */
21
+ export {
22
+ AI_SESSION_HEADER,
23
+ affinityKey,
24
+ isAiSessionId,
25
+ upstreamCacheHints,
26
+ } from "./cache-hints.ts";
27
+ export type { UpstreamCacheHints } from "./cache-hints.ts";
17
28
 
18
29
  export type StreamEvent =
19
30
  | StreamDeltaEvent
@@ -21,6 +32,7 @@ export type StreamEvent =
21
32
  | StreamToolCallStartEvent
22
33
  | StreamToolCallDeltaEvent
23
34
  | StreamToolCallEndEvent
35
+ | StreamUsageEvent
24
36
  | StreamDoneEvent
25
37
  | StreamErrorEvent;
26
38
 
@@ -61,6 +73,27 @@ export interface StreamToolCallEndEvent {
61
73
  id: string;
62
74
  }
63
75
 
76
+ /**
77
+ * What the provider counted for the request that produced this stream, when it reported a count.
78
+ *
79
+ * Emitted at most once, immediately BEFORE `done`, because a reader stops at `done`. It is optional
80
+ * and additive: a backend with no figure sends none, and a reader that predates it ignores it. It
81
+ * exists because every budget the client keeps was an estimate of four characters per token, and
82
+ * the provider's own count, which the client was asking for all along (`include_usage`), was
83
+ * discarded before it arrived.
84
+ */
85
+ export interface StreamUsageEvent {
86
+ type: "usage";
87
+ /** Tokens the provider read: system prompt, tool schemas and the whole history. */
88
+ inputTokens: number;
89
+ /** Tokens the provider generated, reasoning included. */
90
+ outputTokens: number;
91
+ /** The part of `inputTokens` served from a prompt cache, when the provider says. */
92
+ cachedInputTokens?: number;
93
+ /** The part of `outputTokens` spent on chain-of-thought, when the provider says. */
94
+ reasoningTokens?: number;
95
+ }
96
+
64
97
  export interface StreamDoneEvent {
65
98
  type: "done";
66
99
  stopReason: string;
@@ -107,6 +140,8 @@ interface OpenAIChatRequestBody {
107
140
  tools?: object[];
108
141
  tool_choice?: string;
109
142
  parallel_tool_calls?: boolean;
143
+ /** OpenAI's prompt-cache routing key, sent only to OpenAI itself (see `cache-hints.ts`). */
144
+ prompt_cache_key?: string;
110
145
  }
111
146
 
112
147
  /** A tool-call fragment inside an OpenAI streaming `delta`. */
@@ -131,6 +166,53 @@ interface OpenAIStreamChunk {
131
166
  };
132
167
  finish_reason?: string | null;
133
168
  }[];
169
+ /** The count `stream_options.include_usage` asks for — normally a final chunk with no choices. */
170
+ usage?: OpenAIUsage | null;
171
+ }
172
+
173
+ /** OpenAI's usage object, as chat-completions streams it. */
174
+ export interface OpenAIUsage {
175
+ prompt_tokens?: number;
176
+ completion_tokens?: number;
177
+ prompt_tokens_details?: { cached_tokens?: number } | null;
178
+ completion_tokens_details?: { reasoning_tokens?: number } | null;
179
+ }
180
+
181
+ /**
182
+ * Normalize OpenAI's usage object into a {@link StreamUsageEvent}, or null when it carries no count.
183
+ * The two detail figures are included only when the provider sent them: absent means "not said",
184
+ * which is not the same as zero.
185
+ */
186
+ export function usageEventFromOpenAI(usage?: OpenAIUsage | null): StreamUsageEvent | null {
187
+ if (!usage || typeof usage.prompt_tokens !== "number") {
188
+ return null;
189
+ }
190
+ const event: StreamUsageEvent = {
191
+ type: "usage",
192
+ inputTokens: usage.prompt_tokens,
193
+ outputTokens: usage.completion_tokens ?? 0,
194
+ };
195
+ const cached = usage.prompt_tokens_details?.cached_tokens;
196
+ if (typeof cached === "number") {
197
+ event.cachedInputTokens = cached;
198
+ }
199
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens;
200
+ if (typeof reasoning === "number") {
201
+ event.reasoningTokens = reasoning;
202
+ }
203
+ return event;
204
+ }
205
+
206
+ /**
207
+ * The stop reasons the client reports, from an OpenAI `finish_reason`. Anything else (a content
208
+ * filter, a provider's own spelling) is not a stop the loop acts on, so it maps to null and the
209
+ * stream keeps reading.
210
+ */
211
+ function stopReasonFromOpenAI(reason: string | null | undefined): string | null {
212
+ if (reason === "tool_calls" || reason === "length" || reason === "stop") {
213
+ return reason;
214
+ }
215
+ return null;
134
216
  }
135
217
 
136
218
  /** Error response body — the proxy's flat `{ error: "..." }` or OpenAI's `{ error: { message } }`. */
@@ -199,6 +281,7 @@ export const STREAM_EVENT_TYPES = {
199
281
  TOOL_CALL_START: "tool_call_start",
200
282
  TOOL_CALL_DELTA: "tool_call_delta",
201
283
  TOOL_CALL_END: "tool_call_end",
284
+ USAGE: "usage",
202
285
  DONE: "done",
203
286
  ERROR: "error",
204
287
  } as const;
@@ -235,6 +318,9 @@ export const STREAM_EVENT_TYPES = {
235
318
  * @param {number} [opts.temperature] - Sampling temperature, forwarded only when defined. Omit for
236
319
  * reasoning models (GPT-5.x, o-series) that reject a custom temperature; set 0 for
237
320
  * near-deterministic eval runs.
321
+ * @param {string} [opts.sessionAffinity] - A stable key for the conversation, forwarded as the
322
+ * prompt-cache hint the provider understands (`prompt_cache_key` to OpenAI, `x-session-affinity`
323
+ * to Workers AI) and to no other host. See `upstreamCacheHints`.
238
324
  * @returns {StreamingClient}
239
325
  */
240
326
  export interface OpenAIStreamingClientOptions {
@@ -242,6 +328,7 @@ export interface OpenAIStreamingClientOptions {
242
328
  apiKey: string;
243
329
  model?: string;
244
330
  temperature?: number | undefined;
331
+ sessionAffinity?: string | undefined;
245
332
  }
246
333
 
247
334
  export function createOpenAIStreamingClient({
@@ -249,7 +336,11 @@ export function createOpenAIStreamingClient({
249
336
  apiKey,
250
337
  model = "gpt-4o",
251
338
  temperature,
339
+ sessionAffinity,
252
340
  }: OpenAIStreamingClientOptions): StreamingClient {
341
+ // Fixed for the client's life, so it is classified once rather than per request.
342
+ const hints = upstreamCacheHints(baseUrl, sessionAffinity);
343
+
253
344
  /**
254
345
  * @param {object[]} messages
255
346
  * @param {object[]} tools
@@ -283,6 +374,7 @@ export function createOpenAIStreamingClient({
283
374
  body.tool_choice = "auto";
284
375
  body.parallel_tool_calls = true;
285
376
  }
377
+ Object.assign(body, hints.body);
286
378
 
287
379
  let response;
288
380
  try {
@@ -291,6 +383,7 @@ export function createOpenAIStreamingClient({
291
383
  headers: {
292
384
  "Content-Type": "application/json",
293
385
  Authorization: `Bearer ${apiKey}`,
386
+ ...hints.headers,
294
387
  },
295
388
  body: JSON.stringify(body),
296
389
  signal,
@@ -332,8 +425,37 @@ export function createOpenAIStreamingClient({
332
425
  const decoder = new TextDecoder();
333
426
  let buffer = "";
334
427
 
335
- /** @type {Map<string, { id: string; name: string; args: string }>} */
336
428
  const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
429
+ /* The finish reason is REMEMBERED rather than acted on. With `include_usage`, the count arrives
430
+ in a chunk of its own after the one carrying `finish_reason`, so returning on the finish
431
+ dropped the count on every request. The stream ends at `[DONE]` or when the body closes. */
432
+ let stopReason: string | null = null;
433
+ let usage: StreamUsageEvent | null = null;
434
+
435
+ /**
436
+ * Close the tool calls still open. Idempotent, so a finish chunk and `[DONE]` can both call it.
437
+ *
438
+ * @yields {StreamEvent} A `tool_call_end` per open call.
439
+ */
440
+ function* closePendingToolCalls(): Generator<StreamEvent> {
441
+ for (const tc of pendingToolCalls.values()) {
442
+ yield { type: "tool_call_end", id: tc.id };
443
+ }
444
+ pendingToolCalls.clear();
445
+ }
446
+
447
+ /**
448
+ * The stream's last frames: any open calls, the count when there is one, then `done`.
449
+ *
450
+ * @yields {StreamEvent} The closing frames, in order.
451
+ */
452
+ function* finish(): Generator<StreamEvent> {
453
+ yield* closePendingToolCalls();
454
+ if (usage) {
455
+ yield usage;
456
+ }
457
+ yield { type: "done", stopReason: stopReason ?? "stop" };
458
+ }
337
459
 
338
460
  try {
339
461
  while (true) {
@@ -355,12 +477,7 @@ export function createOpenAIStreamingClient({
355
477
 
356
478
  const dataStr = trimmed.slice(6);
357
479
  if (dataStr === "[DONE]") {
358
- // Emit any pending tool call ends before done
359
- for (const tc of pendingToolCalls.values()) {
360
- yield { type: "tool_call_end", id: tc.id };
361
- }
362
- pendingToolCalls.clear();
363
- yield { type: "done", stopReason: "stop" };
480
+ yield* finish();
364
481
  return;
365
482
  }
366
483
 
@@ -371,29 +488,29 @@ export function createOpenAIStreamingClient({
371
488
  continue; // Skip unparseable chunks
372
489
  }
373
490
 
491
+ // Usually a final chunk with no choices, but a provider may put it beside the finish.
492
+ usage = usageEventFromOpenAI(parsed.usage) ?? usage;
493
+
374
494
  const choice = parsed.choices?.[0];
375
495
  if (!choice) {
376
496
  continue;
377
497
  }
378
498
 
379
499
  const { delta } = choice;
380
- if (!delta) {
381
- continue;
382
- }
383
500
 
384
501
  // Text content
385
- if (delta.content) {
502
+ if (delta?.content) {
386
503
  yield { type: "delta", content: delta.content };
387
504
  }
388
505
 
389
506
  // Chain-of-thought, under either of the two names providers give it.
390
- const reasoning = delta.reasoning_content ?? delta.reasoning;
507
+ const reasoning = delta?.reasoning_content ?? delta?.reasoning;
391
508
  if (typeof reasoning === "string" && reasoning) {
392
509
  yield { type: "reasoning", content: reasoning };
393
510
  }
394
511
 
395
512
  // Tool calls
396
- if (delta.tool_calls) {
513
+ if (delta?.tool_calls) {
397
514
  for (const tc of delta.tool_calls) {
398
515
  const existing = pendingToolCalls.get(tc.index);
399
516
 
@@ -423,38 +540,17 @@ export function createOpenAIStreamingClient({
423
540
  }
424
541
  }
425
542
 
426
- // Finish reason
427
- if (choice.finish_reason === "tool_calls") {
428
- // Emit pending tool call ends
429
- for (const tc of pendingToolCalls.values()) {
430
- yield { type: "tool_call_end", id: tc.id };
431
- }
432
- pendingToolCalls.clear();
433
- yield { type: "done", stopReason: "tool_calls" };
434
- return;
435
- }
436
-
437
- if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
438
- // Emit any pending tool call ends
439
- for (const tc of pendingToolCalls.values()) {
440
- yield { type: "tool_call_end", id: tc.id };
441
- }
442
- pendingToolCalls.clear();
443
- yield {
444
- type: "done",
445
- stopReason: choice.finish_reason === "length" ? "length" : "stop",
446
- };
447
- return;
543
+ // Finish reason: close the calls now, report the reason at the end of the stream.
544
+ const finished = stopReasonFromOpenAI(choice.finish_reason);
545
+ if (finished) {
546
+ yield* closePendingToolCalls();
547
+ stopReason = finished;
448
548
  }
449
549
  }
450
550
  }
451
551
 
452
- // Stream ended without explicit finish_reason
453
- for (const tc of pendingToolCalls.values()) {
454
- yield { type: "tool_call_end", id: tc.id };
455
- }
456
- pendingToolCalls.clear();
457
- yield { type: "done", stopReason: "stop" };
552
+ // Stream ended without `[DONE]`
553
+ yield* finish();
458
554
  } catch (error) {
459
555
  void reader.cancel();
460
556
  if ((error as Error).name === "AbortError") {
@@ -513,17 +609,26 @@ export function createAnthropicStreamingClient(
513
609
  * `@jxsuite/server/ai-api`). No API key handling here — the proxy owns provider credentials.
514
610
  *
515
611
  * @param {object} opts
516
- * @param {string} opts.chatUrl - URL to POST `{ messages, tools, systemPrompt, model }` to
612
+ * @param {string | (() => string | Promise<string>)} opts.chatUrl - URL to POST `{ messages, tools,
613
+ * systemPrompt, model }` to, or a function that answers it. A function is called once, inside the
614
+ * first stream, so a caller can hand over its turn's signal before it waits on anything: a Stop
615
+ * during a lookup over IPC then takes effect once it answers, and the stream ends cancelled with
616
+ * nothing sent. The outcome is kept for the client's later streams. A rejection propagates out of
617
+ * the stream, unless the signal was aborted by then, which ends it cancelled.
517
618
  * @param {string} [opts.model] - Default model if not specified per-request
518
619
  * @param {string} [opts.apiKey] - Optional client-supplied key, sent as the `X-Api-Key` header
519
620
  * @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
621
+ * @param {string} [opts.sessionId] - The conversation's id, sent as the `X-Jx-Ai-Session` header so
622
+ * the backend can derive a prompt-cache affinity key from it. The raw id goes to the backend
623
+ * only; a provider sees a hash of it, scoped to that backend (`affinityKey`).
520
624
  * @returns {StreamingClient}
521
625
  */
522
626
  export interface ProxyStreamingClientOptions {
523
- chatUrl: string;
627
+ chatUrl: string | (() => string | Promise<string>);
524
628
  model?: string;
525
629
  apiKey?: string | undefined;
526
630
  baseUrl?: string | undefined;
631
+ sessionId?: string | undefined;
527
632
  }
528
633
 
529
634
  export function createProxyStreamingClient({
@@ -531,7 +636,11 @@ export function createProxyStreamingClient({
531
636
  model = "gpt-4o",
532
637
  apiKey,
533
638
  baseUrl,
639
+ sessionId,
534
640
  }: ProxyStreamingClientOptions): StreamingClient {
641
+ /** The URL, once the first stream has asked for it. */
642
+ let resolvedUrl: Promise<string> | null = null;
643
+
535
644
  /**
536
645
  * @param {object[]} messages
537
646
  * @param {object[]} tools
@@ -555,10 +664,34 @@ export function createProxyStreamingClient({
555
664
  if (baseUrl) {
556
665
  headers["X-Api-Base-URL"] = baseUrl;
557
666
  }
667
+ /* The conversation, so consecutive rounds can be routed to a warm prompt cache. Same-origin on
668
+ every platform, so the extra header costs no preflight; a backend that ignores it loses only
669
+ the cache hit. */
670
+ if (sessionId) {
671
+ headers[AI_SESSION_HEADER] = sessionId;
672
+ }
673
+
674
+ resolvedUrl ??= Promise.resolve(typeof chatUrl === "function" ? chatUrl() : chatUrl);
675
+ let url: string;
676
+ try {
677
+ url = await resolvedUrl;
678
+ } catch (error) {
679
+ // A lookup that failed after the turn was stopped is part of the stop, not an error.
680
+ if (signal?.aborted) {
681
+ yield { type: "done", stopReason: "cancelled" };
682
+ return;
683
+ }
684
+ throw error;
685
+ }
686
+ // Stopped while the URL was being resolved: nothing has been sent, and nothing will be.
687
+ if (signal?.aborted) {
688
+ yield { type: "done", stopReason: "cancelled" };
689
+ return;
690
+ }
558
691
 
559
692
  let response;
560
693
  try {
561
- response = await fetch(chatUrl, {
694
+ response = await fetch(url, {
562
695
  method: "POST",
563
696
  headers,
564
697
  body: JSON.stringify({ messages, tools, systemPrompt, model }),