@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 +10 -9
- package/package.json +3 -3
- package/src/cache-hints.ts +111 -0
- package/src/gateway/chat.ts +9 -2
- package/src/gateway/index.ts +7 -0
- package/src/gateway/types.ts +7 -0
- package/src/index.ts +11 -2
- package/src/streaming-client.ts +38 -4
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
|
|
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.
|
|
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.
|
|
32
|
+
"@jxsuite/protocol": "^2.5.0",
|
|
33
33
|
"@vue/reactivity": "3.5.43"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@cloudflare/workers-types": "^5.
|
|
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
|
+
}
|
package/src/gateway/chat.ts
CHANGED
|
@@ -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.
|
|
119
|
-
*
|
|
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,
|
package/src/gateway/index.ts
CHANGED
|
@@ -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";
|
package/src/gateway/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
package/src/streaming-client.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
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;
|