@jxsuite/ai 0.38.0 → 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.
package/README.md CHANGED
@@ -4,13 +4,13 @@ The provider-agnostic substrate Jx Studio's assistant is built on: a streaming L
4
4
 
5
5
  Governing spec: [`specs/ai.md`](../../specs/ai.md) §1-§2 (Status: Partial). User-facing documentation for the Studio feature lives at [`docs/studio/ai.md`](../../docs/studio/ai.md).
6
6
 
7
- | Entrypoint | Exports |
8
- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
- | `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, the `ChatUsage` type, `STREAM_EVENT_TYPES`, and the streaming types |
10
- | `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its eight members, the three factories and their option types, `STREAM_EVENT_TYPES`, `usageEventFromOpenAI` and its `OpenAIUsage` input type |
11
- | `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, the call context (`createToolContext`, `createLedger`, `createSessionFacts`, `linkCallSignal`), and their types |
12
- | `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `ChatUsage`, `Message`, `ToolCallRecord`, `ChatState`, `MessageRole` |
13
- | `@jxsuite/ai/gateway` | `createChatHandler`, `modelsResponse`, `normalizeOpenAIStream`, `encodeSse`, `problemResponse`, `extractUpstreamErrorMessage`, and the `Upstream`, `GatewayRefusal`, `ChatAdmission` and `ChatGatewayOptions` types |
7
+ | Entrypoint | Exports |
8
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, the `ChatUsage` type, `STREAM_EVENT_TYPES`, the streaming types, and the prompt-cache hints (below) |
10
+ | `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its eight members, the three factories and their option types, `STREAM_EVENT_TYPES`, `usageEventFromOpenAI` and its `OpenAIUsage` input type, and the prompt-cache hints |
11
+ | `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, the call context (`createToolContext`, `createLedger`, `createSessionFacts`, `linkCallSignal`), and their types |
12
+ | `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `ChatUsage`, `Message`, `ToolCallRecord`, `ChatState`, `MessageRole` |
13
+ | `@jxsuite/ai/gateway` | `createChatHandler`, `modelsResponse`, `normalizeOpenAIStream`, `encodeSse`, `problemResponse`, `extractUpstreamErrorMessage`, the prompt-cache hints, and the `Upstream`, `GatewayRefusal`, `ChatAdmission` and `ChatGatewayOptions` types |
14
14
 
15
15
  `toolSuccess` / `toolError` are **not** re-exported by the barrel. Import them from `@jxsuite/ai/tools`. Importing the subpaths instead of the root is also what makes the package tree-shakeable.
16
16
 
@@ -39,9 +39,9 @@ for await (const event of client.streamChat(messages, tools, systemPrompt, signa
39
39
  }
40
40
  ```
41
41
 
42
- - **`createOpenAIStreamingClient({ baseUrl, apiKey, model = "gpt-4o", temperature })`** POSTs to `${baseUrl}/chat/completions` with `Authorization: Bearer`, `stream: true`, `stream_options: { include_usage: true }`, and `{ role: "system", content: systemPrompt }` prepended to your messages. A non-empty `tools` array also sets `tool_choice: "auto"` and `parallel_tool_calls: true`; an empty one sends no `tools` key. `temperature` is forwarded **only when defined**, because reasoning models (GPT-5.x, o-series) reject a custom temperature. That is a provider behaviour recorded in the source comment, not covered by a test here. It does its own SSE framing and reassembles fragmented tool-call argument JSON keyed by the provider's `delta.tool_calls[].index`.
42
+ - **`createOpenAIStreamingClient({ baseUrl, apiKey, model = "gpt-4o", temperature, sessionAffinity })`** POSTs to `${baseUrl}/chat/completions` with `Authorization: Bearer`, `stream: true`, `stream_options: { include_usage: true }`, and `{ role: "system", content: systemPrompt }` prepended to your messages. A non-empty `tools` array also sets `tool_choice: "auto"` and `parallel_tool_calls: true`; an empty one sends no `tools` key. `temperature` is forwarded **only when defined**, because reasoning models (GPT-5.x, o-series) reject a custom temperature. That is a provider behaviour recorded in the source comment, not covered by a test here. It does its own SSE framing and reassembles fragmented tool-call argument JSON keyed by the provider's `delta.tool_calls[].index`.
43
43
  - **`createAnthropicStreamingClient({ baseUrl, apiKey })`** is a stub. It makes no network call and yields exactly one `error` event with `code: "NOT_IMPLEMENTED"`. Only the OpenAI-compatible path ships (`specs/ai.md` §2).
44
- - **`createProxyStreamingClient({ chatUrl, model = "gpt-4o", apiKey, baseUrl })`** POSTs `{ messages, tools, systemPrompt, model }` to an endpoint that already speaks the normalized format (the dev/desktop server's `/__studio/ai/chat`). `chatUrl` may also be a function returning the URL (or a promise of it): it is called once, inside the first stream, so a caller can arm its turn's signal before waiting on a lookup, and a stream stopped by the time it answers ends `cancelled` without sending anything; a lookup that rejects rejects the stream. It handles no credentials of its own; `apiKey` and `baseUrl` are passed through as the `X-Api-Key` and `X-Api-Base-URL` headers only when truthy, and the proxy owns the provider key. It is a pass-through, **not** a translator: it re-yields whatever parses out of each `data:` line and returns at the first `done` or `error`, so frames after `done` are dropped.
44
+ - **`createProxyStreamingClient({ chatUrl, model = "gpt-4o", apiKey, baseUrl, sessionId })`** POSTs `{ messages, tools, systemPrompt, model }` to an endpoint that already speaks the normalized format (the dev/desktop server's `/__studio/ai/chat`). `chatUrl` may also be a function returning the URL (or a promise of it): it is called once, inside the first stream, so a caller can arm its turn's signal before waiting on a lookup, and a stream stopped by the time it answers ends `cancelled` without sending anything; a lookup that rejects rejects the stream. It handles no credentials of its own; `apiKey` and `baseUrl` are passed through as the `X-Api-Key` and `X-Api-Base-URL` headers only when truthy, and the proxy owns the provider key. `sessionId`, likewise only when truthy, is sent as `X-Jx-Ai-Session` (`AI_SESSION_HEADER`) for the backend to hash into a prompt-cache key; the raw id is meant for the backend alone. It is a pass-through, **not** a translator: it re-yields whatever parses out of each `data:` line and returns at the first `done` or `error`, so frames after `done` are dropped.
45
45
 
46
46
  Three behaviours are easy to get wrong when consuming the stream:
47
47
 
@@ -77,6 +77,7 @@ const models = modelsResponse({ configured: true, models: [{ id: "gpt-4o" }] });
77
77
  - **The stream is always a `200`.** The upstream request is on the wire before the response is returned, and anything that goes wrong after that (a network failure, a provider's non-2xx, a reset mid-stream) is an `error` frame, while an abort is `done: cancelled`. `normalizeOpenAIStream` is the server's normalizer, moved here unchanged, so its frames are the ones `packages/server/tests/fixtures/ai-upstream/*.server.json` records.
78
78
  - **`fetch` is looked up per request** unless you pass one, so a host (or a test) can swap it.
79
79
  - **`modelsResponse(body)` is always a `200`,** because the models route is a capability probe. What the catalogue says is the host's business.
80
+ - **A conversation can keep a warm prompt cache.** Studio's proxy client sends its conversation id as `X-Jx-Ai-Session` (`AI_SESSION_HEADER`). The host checks it with `isAiSessionId`, hashes it with `affinityKey(scope, id)` (the first 32 hex characters of SHA-256 over `jx-ai-affinity/v1:<scope>:<id>`) and returns the hash as the upstream's `sessionAffinity`. `upstreamCacheHints(baseUrl, affinity)` then decides what the provider sees: `prompt_cache_key` in the body for `api.openai.com`, the `x-session-affinity` header for Workers AI (`api.cloudflare.com` with `/ai/` in the path) and AI Gateway (`gateway.ai.cloudflare.com`), and nothing for any other host, so a strict server never meets an unknown field and an arbitrary endpoint never sees a stable id. `createOpenAIStreamingClient` takes the same key as `sessionAffinity` and applies the same rule.
80
81
 
81
82
  `./gateway` is Worker-safe. Studio never imports it (`packages/studio/tests/ai-import-rules.test.ts`).
82
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.38.0",
3
+ "version": "0.39.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": {
@@ -29,11 +29,11 @@
29
29
  "upgrade": "bun update --latest && bun install"
30
30
  },
31
31
  "dependencies": {
32
- "@jxsuite/protocol": "^2.4.0",
32
+ "@jxsuite/protocol": "^2.5.0",
33
33
  "@vue/reactivity": "3.5.43"
34
34
  },
35
35
  "devDependencies": {
36
- "@cloudflare/workers-types": "^5.20260926.1",
36
+ "@cloudflare/workers-types": "^5.20260928.1",
37
37
  "@happy-dom/global-registrator": "^20.14.5"
38
38
  }
39
39
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Cache-hints.ts — Prompt-cache routing hints for OpenAI-compatible upstreams.
3
+ *
4
+ * A provider's prompt cache only pays off when consecutive requests sharing a prefix reach the same
5
+ * machine. The assistant's requests are built for that (a stable system-prompt prefix, then a
6
+ * history that only grows), but the providers that route by prefix still need to be told which
7
+ * requests belong together, and each one is told differently:
8
+ *
9
+ * - OpenAI reads a `prompt_cache_key` body field.
10
+ * - Cloudflare Workers AI (directly, or through AI Gateway) reads an `x-session-affinity` header.
11
+ *
12
+ * Neither hint is sent anywhere else. An OpenAI-compatible server is free to reject an unknown body
13
+ * field, and a browser-side BYOK client adding an unknown header to a cross-origin request turns a
14
+ * simple request into a CORS preflight the endpoint may not answer. Above all, the value is a
15
+ * stable per-conversation identifier, and an arbitrary endpoint the user pointed a key at has no
16
+ * use for one.
17
+ *
18
+ * What travels is never the client's own session id. A client sends that only to its own backend
19
+ * (as {@link AI_SESSION_HEADER}); the backend hashes it with {@link affinityKey}, scoped to itself,
20
+ * and the hash is the only form a provider sees.
21
+ *
22
+ * Worker-safe: `crypto.subtle` and `TextEncoder` are globals in every runtime the gateway targets.
23
+ *
24
+ * @module @jxsuite/ai/streaming-client
25
+ * @license MIT
26
+ * @docs extending/embedding/backend-protocol
27
+ */
28
+
29
+ /**
30
+ * The request header a client puts its conversation id in, for its OWN backend to hash. Never
31
+ * forwarded to a provider as sent.
32
+ */
33
+ export const AI_SESSION_HEADER = "X-Jx-Ai-Session";
34
+
35
+ /** 1–128 characters of `[A-Za-z0-9_.:-]`: wide enough for any id a client mints, nothing else. */
36
+ const SESSION_ID = /^[\w.:-]{1,128}$/;
37
+
38
+ /**
39
+ * Whether a value is a session id a backend may hash. A header that fails this is ignored rather
40
+ * than refused: the hint is an optimisation, and a request without one is still a valid request.
41
+ *
42
+ * @param {unknown} value - Typically the raw {@link AI_SESSION_HEADER} value, or null
43
+ * @returns {boolean}
44
+ */
45
+ export function isAiSessionId(value: unknown): value is string {
46
+ return typeof value === "string" && SESSION_ID.test(value);
47
+ }
48
+
49
+ /**
50
+ * The affinity key a backend forwards for a session: the first 32 hex characters of
51
+ * SHA-256(`jx-ai-affinity/v1:<scope>:<sessionId>`).
52
+ *
53
+ * `scope` names the backend (`"local"` for the dev/desktop server), so two backends that happen to
54
+ * see the same id never share a key, and the version prefix lets the derivation change without
55
+ * colliding with the old one. 128 bits is plenty to keep unrelated conversations apart, and short
56
+ * enough for every provider's limit on the field.
57
+ *
58
+ * @param {string} scope - The backend's own namespace
59
+ * @param {string} sessionId - The client's session id, already checked with {@link isAiSessionId}
60
+ * @returns {Promise<string>} 32 lowercase hex characters
61
+ */
62
+ export async function affinityKey(scope: string, sessionId: string): Promise<string> {
63
+ const input = new TextEncoder().encode(`jx-ai-affinity/v1:${scope}:${sessionId}`);
64
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
65
+ let hex = "";
66
+ for (const byte of digest.subarray(0, 16)) {
67
+ hex += byte.toString(16).padStart(2, "0");
68
+ }
69
+ return hex;
70
+ }
71
+
72
+ /** The hints to merge into one upstream request: extra headers, and extra body members. */
73
+ export interface UpstreamCacheHints {
74
+ headers: Record<string, string>;
75
+ body: { prompt_cache_key?: string };
76
+ }
77
+
78
+ /**
79
+ * The cache hints for a request to `baseUrl`, in the form that upstream understands, or none.
80
+ *
81
+ * Classified by host, because the hint's NAME is per provider: `api.openai.com` gets
82
+ * `prompt_cache_key` in the body; Workers AI (an `api.cloudflare.com` URL with `/ai/` in its path)
83
+ * and AI Gateway (`gateway.ai.cloudflare.com`) get the `x-session-affinity` header. Every other
84
+ * host, an unparseable base URL, and a request with no affinity get nothing.
85
+ *
86
+ * @param {string} baseUrl - The upstream's base URL, as the request is about to use it
87
+ * @param {string} [affinity] - The affinity key, from {@link affinityKey} (or a caller's own id)
88
+ * @returns {UpstreamCacheHints}
89
+ */
90
+ export function upstreamCacheHints(baseUrl: string, affinity?: string): UpstreamCacheHints {
91
+ const hints: UpstreamCacheHints = { body: {}, headers: {} };
92
+ if (!affinity) {
93
+ return hints;
94
+ }
95
+ let url: URL;
96
+ try {
97
+ url = new URL(baseUrl);
98
+ } catch {
99
+ return hints;
100
+ }
101
+ const host = url.hostname.toLowerCase();
102
+ if (host === "api.openai.com") {
103
+ hints.body.prompt_cache_key = affinity;
104
+ } else if (
105
+ (host === "api.cloudflare.com" && url.pathname.includes("/ai/")) ||
106
+ host === "gateway.ai.cloudflare.com"
107
+ ) {
108
+ hints.headers["x-session-affinity"] = affinity;
109
+ }
110
+ return hints;
111
+ }
@@ -4,6 +4,7 @@
4
4
  * @module @jxsuite/ai/gateway
5
5
  */
6
6
 
7
+ import { upstreamCacheHints } from "../cache-hints.ts";
7
8
  import type { StreamEvent } from "../streaming-client.ts";
8
9
  import { fetchFailureFrame, normalizeOpenAIStream } from "./normalize.ts";
9
10
  import { problemResponse, refusal } from "./problem.ts";
@@ -30,6 +31,7 @@ interface UpstreamBody {
30
31
  tools?: unknown;
31
32
  tool_choice?: string;
32
33
  parallel_tool_calls?: boolean;
34
+ prompt_cache_key?: string;
33
35
  }
34
36
 
35
37
  /**
@@ -115,8 +117,10 @@ async function* exchange(
115
117
  *
116
118
  * The version 1 body is forwarded as the OpenAI-compatible request it describes: the system prompt
117
119
  * as a leading `system` message, `stream_options.include_usage` so the provider's own token count
118
- * arrives, and `tool_choice: "auto"` with parallel calls whenever tools are offered. The upstream
119
- * request carries `context.signal`, so aborting it ends the stream with `done: cancelled`.
120
+ * arrives, and `tool_choice: "auto"` with parallel calls whenever tools are offered. When the host
121
+ * resolved a `sessionAffinity`, it rides along as the prompt-cache hint the upstream's host
122
+ * understands, and not at all to any other host (`upstreamCacheHints`). The upstream request
123
+ * carries `context.signal`, so aborting it ends the stream with `done: cancelled`.
120
124
  *
121
125
  * @param {ChatGatewayOptions<C>} options - The host's policy
122
126
  * @returns {(request: Request, context: C) => Promise<Response>}
@@ -200,6 +204,8 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
200
204
  upstreamBody.tool_choice = "auto";
201
205
  upstreamBody.parallel_tool_calls = true;
202
206
  }
207
+ const hints = upstreamCacheHints(upstream.baseUrl, upstream.sessionAffinity);
208
+ Object.assign(upstreamBody, hints.body);
203
209
 
204
210
  // Looked up per request, not captured at construction: a host or a test may swap it.
205
211
  const upstreamFetch = options.fetch ?? fetch;
@@ -208,6 +214,7 @@ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
208
214
  headers: {
209
215
  "Content-Type": "application/json",
210
216
  Authorization: `Bearer ${upstream.apiKey}`,
217
+ ...hints.headers,
211
218
  },
212
219
  body: JSON.stringify(upstreamBody),
213
220
  signal: context.signal,
@@ -17,10 +17,17 @@
17
17
  * @docs extending/embedding/backend-protocol
18
18
  */
19
19
 
20
+ export {
21
+ AI_SESSION_HEADER,
22
+ affinityKey,
23
+ isAiSessionId,
24
+ upstreamCacheHints,
25
+ } from "../cache-hints.ts";
20
26
  export { createChatHandler } from "./chat.ts";
21
27
  export { modelsResponse } from "./models.ts";
22
28
  export { normalizeOpenAIStream } from "./normalize.ts";
23
29
  export { problemResponse } from "./problem.ts";
24
30
  export { encodeSse } from "./sse.ts";
25
31
  export { extractUpstreamErrorMessage } from "./upstream-error.ts";
32
+ export type { UpstreamCacheHints } from "../cache-hints.ts";
26
33
  export type { ChatAdmission, ChatGatewayOptions, GatewayRefusal, Upstream } from "./types.ts";
@@ -27,6 +27,13 @@ export interface Upstream {
27
27
  readonly defaultModel: string;
28
28
  /** True when the platform brokers the credential, rather than the user supplying one. */
29
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;
30
37
  }
31
38
 
32
39
  /**
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
  */
@@ -22,6 +22,15 @@ export {
22
22
  createProxyStreamingClient,
23
23
  } from "./streaming-client.js";
24
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
+
25
34
  export type {
26
35
  AnthropicStreamingClientOptions,
27
36
  OpenAIStreamingClientOptions,
@@ -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
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
@@ -129,6 +140,8 @@ interface OpenAIChatRequestBody {
129
140
  tools?: object[];
130
141
  tool_choice?: string;
131
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;
132
145
  }
133
146
 
134
147
  /** A tool-call fragment inside an OpenAI streaming `delta`. */
@@ -305,6 +318,9 @@ export const STREAM_EVENT_TYPES = {
305
318
  * @param {number} [opts.temperature] - Sampling temperature, forwarded only when defined. Omit for
306
319
  * reasoning models (GPT-5.x, o-series) that reject a custom temperature; set 0 for
307
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`.
308
324
  * @returns {StreamingClient}
309
325
  */
310
326
  export interface OpenAIStreamingClientOptions {
@@ -312,6 +328,7 @@ export interface OpenAIStreamingClientOptions {
312
328
  apiKey: string;
313
329
  model?: string;
314
330
  temperature?: number | undefined;
331
+ sessionAffinity?: string | undefined;
315
332
  }
316
333
 
317
334
  export function createOpenAIStreamingClient({
@@ -319,7 +336,11 @@ export function createOpenAIStreamingClient({
319
336
  apiKey,
320
337
  model = "gpt-4o",
321
338
  temperature,
339
+ sessionAffinity,
322
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
+
323
344
  /**
324
345
  * @param {object[]} messages
325
346
  * @param {object[]} tools
@@ -353,6 +374,7 @@ export function createOpenAIStreamingClient({
353
374
  body.tool_choice = "auto";
354
375
  body.parallel_tool_calls = true;
355
376
  }
377
+ Object.assign(body, hints.body);
356
378
 
357
379
  let response;
358
380
  try {
@@ -361,6 +383,7 @@ export function createOpenAIStreamingClient({
361
383
  headers: {
362
384
  "Content-Type": "application/json",
363
385
  Authorization: `Bearer ${apiKey}`,
386
+ ...hints.headers,
364
387
  },
365
388
  body: JSON.stringify(body),
366
389
  signal,
@@ -595,6 +618,9 @@ export function createAnthropicStreamingClient(
595
618
  * @param {string} [opts.model] - Default model if not specified per-request
596
619
  * @param {string} [opts.apiKey] - Optional client-supplied key, sent as the `X-Api-Key` header
597
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`).
598
624
  * @returns {StreamingClient}
599
625
  */
600
626
  export interface ProxyStreamingClientOptions {
@@ -602,6 +628,7 @@ export interface ProxyStreamingClientOptions {
602
628
  model?: string;
603
629
  apiKey?: string | undefined;
604
630
  baseUrl?: string | undefined;
631
+ sessionId?: string | undefined;
605
632
  }
606
633
 
607
634
  export function createProxyStreamingClient({
@@ -609,6 +636,7 @@ export function createProxyStreamingClient({
609
636
  model = "gpt-4o",
610
637
  apiKey,
611
638
  baseUrl,
639
+ sessionId,
612
640
  }: ProxyStreamingClientOptions): StreamingClient {
613
641
  /** The URL, once the first stream has asked for it. */
614
642
  let resolvedUrl: Promise<string> | null = null;
@@ -636,6 +664,12 @@ export function createProxyStreamingClient({
636
664
  if (baseUrl) {
637
665
  headers["X-Api-Base-URL"] = baseUrl;
638
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
+ }
639
673
 
640
674
  resolvedUrl ??= Promise.resolve(typeof chatUrl === "function" ? chatUrl() : chatUrl);
641
675
  let url: string;