@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.
package/README.md CHANGED
@@ -1,15 +1,16 @@
1
1
  # @jxsuite/ai
2
2
 
3
- The provider-agnostic substrate Jx Studio's assistant is built on: a streaming LLM client abstraction that normalizes provider SSE into seven event shapes, a tool registry that speaks OpenAI function-calling, and a reactive chat store. It has no Jx-domain dependencies (just a type-only import of `ProblemDetails` from `@jxsuite/protocol`, plus `@vue/reactivity`), and its only platform APIs are `fetch`, `TextDecoder` and `AbortSignal`, so the same modules run in the browser and in Bun/Node.
3
+ The provider-agnostic substrate Jx Studio's assistant is built on: a streaming LLM client abstraction that normalizes provider SSE into eight event shapes, a tool registry that speaks OpenAI function-calling, and a reactive chat store. It has no Jx-domain dependencies beyond `@jxsuite/protocol`, for the wire's problem documents (the gateway builds them at runtime; everything else imports only the `ProblemDetails` type), plus `@vue/reactivity` for the chat store. Its platform APIs are the web-standard ones (`fetch`, `Request` and `Response`, streams, `TextEncoder` and `TextDecoder`, `AbortSignal`), so the same modules run in the browser, in Bun/Node and, for the Worker-safe subpaths, in a Cloudflare Worker.
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`, `STREAM_EVENT_TYPES`, and the streaming types |
10
- | `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its seven members, the three factories and their option types, `STREAM_EVENT_TYPES` |
11
- | `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, and their types |
12
- | `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `Message`, `ToolCallRecord`, `ChatState`, `MessageRole` |
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 |
13
14
 
14
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.
15
16
 
@@ -22,7 +23,7 @@ streamChat(messages: object[], tools: object[], systemPrompt: string, signal: Ab
22
23
  : AsyncGenerator<StreamEvent>;
23
24
  ```
24
25
 
25
- Every implementation yields the same discriminated union, whose type strings are also published as `STREAM_EVENT_TYPES`: `delta {content}`, `tool_call_start {id, name}`, `tool_call_delta {id, args}`, `tool_call_end {id}`, `done {stopReason}`, `error {message, code?, problem?}`. This union is the contract a third-party Studio backend must satisfy for the `ai/chat` route (see [`packages/protocol/README.md`](../protocol/README.md)); `@jxsuite/server` emits the same format without depending on this package. `StreamEvent` and each member are exported, so an implementer can import the type instead of reconstructing it.
26
+ Every implementation yields the same discriminated union, whose type strings are also published as `STREAM_EVENT_TYPES`: `delta {content}`, `reasoning {content}`, `tool_call_start {id, name}`, `tool_call_delta {id, args}`, `tool_call_end {id}`, `usage {inputTokens, outputTokens, cachedInputTokens?, reasoningTokens?}`, `done {stopReason}`, `error {message, code?, problem?}`. This union is the contract a third-party Studio backend must satisfy for the `ai/chat` route (see [`packages/protocol/README.md`](../protocol/README.md)); `@jxsuite/server` emits the same format through [the gateway](#the-gateway). `StreamEvent` and each member are exported, so an implementer can import the type instead of reconstructing it.
26
27
 
27
28
  ```ts
28
29
  import { createProxyStreamingClient } from "@jxsuite/ai";
@@ -38,15 +39,47 @@ for await (const event of client.streamChat(messages, tools, systemPrompt, signa
38
39
  }
39
40
  ```
40
41
 
41
- - **`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`.
42
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).
43
- - **`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`). 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.
44
45
 
45
46
  Three behaviours are easy to get wrong when consuming the stream:
46
47
 
47
48
  1. **Cancellation is a `done`, not an `error`.** An `AbortError` from `fetch` or mid-read becomes `{ type: "done", stopReason: "cancelled" }`. Treating abort as failure paints a user's Stop button as a crash. The OpenAI client's other stop reasons are `"stop"`, `"tool_calls"` and `"length"`.
48
- 2. **The OpenAI client drains pending tool calls on every termination path.** `[DONE]`, a `finish_reason` of `stop`, `length` or `tool_calls` (any other value is ignored and the stream continues), and a body that simply closes all emit `tool_call_end` for anything still open, then exactly one `done`. The proxy client tracks nothing of its own, so a body that closes without a `done` frame ends its generator with no `done` at all.
49
- 3. **`error` frames may carry an RFC 9457 `problem` beside the human `message`,** because the response has already begun with a 200 and the status can no longer change (a committed standards row in `specs/ai.md` §5). The field is optional and no client in this package sets it. `@jxsuite/server`'s proxy is what populates it, and `createProxyStreamingClient` passes it through. Read `problem.type` for machine dispatch when it is present; keep showing `message`.
49
+ 2. **A finish is remembered, not acted on.** A `finish_reason` of `stop`, `length` or `tool_calls` emits `tool_call_end` for every call still open and records the reason (any other value is ignored), but the stream keeps reading, because with `include_usage` the count arrives in a chunk of its own after the finish. The stream ends at `[DONE]` or when the body closes, and ends the same way every time: `tool_call_end` for anything still open, one `usage` frame when the provider reported a count, then exactly one `done` carrying the recorded reason (`stop` when there was none). `usage` precedes `done` because a reader stops at `done`. The proxy client tracks nothing of its own, so a body that closes without a `done` frame ends its generator with no `done` at all.
50
+ 3. **`error` frames may carry an RFC 9457 `problem` beside the human `message`,** because the response has already begun with a 200 and the status can no longer change (a committed standards row in `specs/ai.md` §5). The field is optional and no client in this package sets it. The gateway (which `@jxsuite/server`'s proxy runs) is what populates it, and `createProxyStreamingClient` passes it through. Read `problem.type` for machine dispatch when it is present; keep showing `message`.
51
+
52
+ ## The gateway
53
+
54
+ `@jxsuite/ai/gateway` is the other side of the wire: the server half of the `ai/chat` and `ai/models` routes, which `@jxsuite/server` runs and any other backend can. The host supplies its policy and the gateway does the rest:
55
+
56
+ ```ts
57
+ import { createChatHandler, modelsResponse } from "@jxsuite/ai/gateway";
58
+
59
+ const chat = createChatHandler<{ signal: AbortSignal }>({
60
+ // Whose key, which base URL: the host's rules. Return a refusal to answer with a problem instead.
61
+ resolveUpstream: () => ({
62
+ apiKey: env.OPENAI_API_KEY,
63
+ baseUrl: "https://api.openai.com/v1",
64
+ defaultModel: "gpt-4o",
65
+ family: "openai-compat",
66
+ managed: false,
67
+ }),
68
+ maxBodyBytes: 1_000_000,
69
+ maxMessages: 200,
70
+ });
71
+
72
+ const response = await chat(request, { signal: request.signal });
73
+ const models = modelsResponse({ configured: true, models: [{ id: "gpt-4o" }] });
74
+ ```
75
+
76
+ - **The order is fixed.** `admit` (before the body is read), `resolveUpstream`, the body (`413` once it passes `maxBodyBytes`, counted on the bytes actually read, and `400` when it is not JSON), `messages` (`400` when it is present and not an array), `maxMessages` (`413`), `onAccepted`, then the stream. The first step that refuses answers the request as `application/problem+json`, and the provider is never contacted. `problemResponse` writes `error` beside `detail` and a refusal's `code` beside both, so a problem reader and an older `{error, code}` reader both understand it.
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
+ - **`fetch` is looked up per request** unless you pass one, so a host (or a test) can swap it.
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.
81
+
82
+ `./gateway` is Worker-safe. Studio never imports it (`packages/studio/tests/ai-import-rules.test.ts`).
50
83
 
51
84
  ## The tool registry
52
85
 
@@ -74,12 +107,13 @@ registry.register(
74
107
  - **`validate()` is a lightweight structural check, not a JSON Schema validator.** It reads only `type`, `properties` and `required`: nested schemas, `enum`, `pattern`, `minimum` and every other keyword are ignored. Unknown properties are ignored too (the model may send extra); a required key that is present but `null`/`undefined` counts as missing; numeric strings coerce, so `"42"` validates against `number`. `strict: false`, or a schema without `properties`, skips it entirely.
75
108
  - **`execute()` never throws.** An unknown tool, a validation failure, and an exception inside the tool all return `{ success: false, error }`, which is what lets an agent loop feed the failure back to the model and self-correct. It calls `this.validate(...)`, so it is `this`-bound: do not destructure it off the registry.
76
109
  - Re-registering a name `console.warn`s and overwrites.
110
+ - **Every call carries a `ToolContext`** as `execute`'s second argument: the call's own signal, its id, the actor, the turn's write ledger, the conversation's session facts and a progress sink (specs/ai.md §3.7). `registry.execute(name, args, ctx)` passes it on; without a `ctx` the call gets `createToolContext()`, a detached one whose signal never aborts and whose ledger nothing files. `linkCallSignal(turnSignal)` gives a call a signal that aborts with the turn and is unlinked by `release()` once the call settles. A definition marks `interactive: true` when its call suspends the turn on a person (`ask_user`). A wrapping registry must forward `ctx`. See `docs/extending/embedding/assistant-harness.md`.
77
111
 
78
112
  To vary the tool set per turn, wrap rather than mutate. Studio's `createGatedToolRegistry` returns a `ToolRegistry` that filters `list()`/`listForLLM()` through per-tool predicates (`packages/studio/src/services/gated-registry.ts`).
79
113
 
80
114
  ## The reactive chat store
81
115
 
82
- `createChatState(options?: { model?: string })` returns a `@vue/reactivity` `reactive()` store with its mutators `Object.assign`ed onto the same proxy, so the returned value is both the state and the API. The store holds `messages`, `status`, `streamingContent`, `pendingToolCalls`, `error`, `model`, `tokenCount` and `contextWarning`. The model falls back to `"gpt-4o"` via `||`, so an empty string takes the default too. `status` is `"idle" | "streaming" | "error"`; `toMessagesArray()` serializes back to OpenAI wire shape (assistant `tool_calls`, `tool` messages with `tool_call_id`, everything else `{ role, content }`).
116
+ `createChatState(options?: { model?: string })` returns a `@vue/reactivity` `reactive()` store with its mutators `Object.assign`ed onto the same proxy, so the returned value is both the state and the API. The store holds `messages`, `status`, `streamingContent`, `pendingToolCalls`, `error`, `model`, `tokenCount`, `contextWarning` and `usage`. `recordUsage(event, { systemTokens? })` stores a `usage` frame as a `ChatUsage`: the counts, the context it leaves for the next request (input plus output, less reasoning the provider billed but never streamed), and the id and position of the last message it covers, so a budget can reuse it only while that message is still in place. It also sets `tokenCount` to that context figure. `clearUsage()` forgets it, and `clearChat()` does too. The model falls back to `"gpt-4o"` via `||`, so an empty string takes the default too. `status` is `"idle" | "streaming" | "error"`; `toMessagesArray()` serializes back to OpenAI wire shape (assistant `tool_calls`, `tool` messages with `tool_call_id`, everything else `{ role, content }`).
83
117
 
84
118
  Two ordering rules inside `beginAssistantTurn` are load-bearing, and any reimplementation must keep them: `status` is set to `"streaming"` **before** the placeholder message is pushed (the push itself triggers effects, and a reader seeing `"idle"` renders the empty placeholder as a finished message), and the pushed object is immediately re-read back through the proxy (`store.messages.at(-1)`), because mutating the raw object would notify nothing. A regression test asserts that an `effect()` reading `messages.at(-1).content` re-runs on every `appendDelta`.
85
119
 
@@ -93,4 +127,4 @@ Further surprises:
93
127
 
94
128
  ## Versioning
95
129
 
96
- Published to npm as `@jxsuite/ai`, following the monorepo's release train. Like every published `@jxsuite` library, it ships TypeScript source: every `exports` subpath resolves to `./src/*.ts`, so a consumer must be able to compile TypeScript out of `node_modules`. Within the monorepo, `@jxsuite/studio` depends on it via `workspace:^` and is its only consumer. `@vue/reactivity` is pinned to an **exact** version, matching `@jxsuite/studio`, `@jxsuite/runtime` and `@jxsuite/compiler`: reactive proxies from one copy do not track in effects from another, and the failure is silent.
130
+ Published to npm as `@jxsuite/ai`, following the monorepo's release train. Like every published `@jxsuite` library, it ships TypeScript source: every `exports` subpath resolves to `./src/*.ts`, so a consumer must be able to compile TypeScript out of `node_modules`. Within the monorepo, `@jxsuite/studio` (the client subpaths) and `@jxsuite/server` (`./gateway`) depend on it via `workspace:^`. `@vue/reactivity` is pinned to an **exact** version, matching `@jxsuite/studio`, `@jxsuite/runtime` and `@jxsuite/compiler`: reactive proxies from one copy do not track in effects from another, and the failure is silent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/ai",
3
- "version": "0.37.2",
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": {
@@ -17,7 +17,8 @@
17
17
  ".": "./src/index.ts",
18
18
  "./streaming-client": "./src/streaming-client.ts",
19
19
  "./tools": "./src/tools.ts",
20
- "./chat-state": "./src/chat-state.ts"
20
+ "./chat-state": "./src/chat-state.ts",
21
+ "./gateway": "./src/gateway/index.ts"
21
22
  },
22
23
  "publishConfig": {
23
24
  "provenance": true
@@ -28,10 +29,11 @@
28
29
  "upgrade": "bun update --latest && bun install"
29
30
  },
30
31
  "dependencies": {
31
- "@jxsuite/protocol": "^2.3.1",
32
+ "@jxsuite/protocol": "^2.5.0",
32
33
  "@vue/reactivity": "3.5.43"
33
34
  },
34
35
  "devDependencies": {
36
+ "@cloudflare/workers-types": "^5.20260928.1",
35
37
  "@happy-dom/global-registrator": "^20.14.5"
36
38
  }
37
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/chat-state.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { reactive } from "@vue/reactivity";
13
13
 
14
+ import type { StreamUsageEvent } from "./streaming-client.ts";
14
15
  import type { ToolResult } from "./tools.ts";
15
16
 
16
17
  export type ChatState = "idle" | "streaming" | "error";
@@ -40,6 +41,34 @@ export interface ToolCallRecord {
40
41
  result?: ToolResult | null;
41
42
  }
42
43
 
44
+ /**
45
+ * The provider's own count for the last request, and where in the transcript it was taken.
46
+ *
47
+ * `messageCount` and `lastMessageId` are what make the figure reusable: every message up to and
48
+ * including `lastMessageId` was part of a request the provider counted, so a later budget needs to
49
+ * estimate only what was added since, rather than re-estimating the whole history at four
50
+ * characters per token. A transcript whose message at that position is no longer `lastMessageId`
51
+ * has been trimmed, rewound, repaired or replaced, and the figure no longer describes it — length
52
+ * alone cannot say so, because a retry re-grows the transcript to the same length.
53
+ */
54
+ export interface ChatUsage {
55
+ inputTokens: number;
56
+ outputTokens: number;
57
+ cachedInputTokens?: number;
58
+ reasoningTokens?: number;
59
+ messageCount: number;
60
+ /** The id of the last message the count covers (`messages[messageCount - 1]`). */
61
+ lastMessageId: string;
62
+ /**
63
+ * What the counted request leaves in the context for the NEXT request: input plus output, minus
64
+ * reasoning the provider generated but never streamed. That reasoning (OpenAI's o-series on chat
65
+ * completions) is billed as output yet is not replayed, so it occupies nothing on the next send.
66
+ */
67
+ contextTokens: number;
68
+ /** The estimate of the system prompt the counted request carried, when the caller supplied it. */
69
+ systemTokens?: number;
70
+ }
71
+
43
72
  export interface ChatStore {
44
73
  messages: Message[];
45
74
  status: ChatState;
@@ -49,6 +78,8 @@ export interface ChatStore {
49
78
  model: string;
50
79
  tokenCount: number;
51
80
  contextWarning: boolean;
81
+ /** The last reported count, or null when none has been reported since the transcript changed. */
82
+ usage: ChatUsage | null;
52
83
  }
53
84
 
54
85
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -63,6 +94,11 @@ function uid() {
63
94
  return `msg_${Date.now()}_${_idCounter}`;
64
95
  }
65
96
 
97
+ /** A token count a budget can use: a finite, non-negative number. */
98
+ function isCount(value: unknown): value is number {
99
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
100
+ }
101
+
66
102
  // ─── Factory ─────────────────────────────────────────────────────────────────
67
103
 
68
104
  /**
@@ -87,6 +123,8 @@ function uid() {
87
123
  * retryLast: () => void;
88
124
  * setModel: (model: string) => void;
89
125
  * setTokenCount: (count: number) => void;
126
+ * recordUsage: (usage: StreamUsageEvent, opts?: { systemTokens?: number }) => void;
127
+ * clearUsage: () => void;
90
128
  * setContextWarning: (warning: boolean) => void;
91
129
  * toMessagesArray: () => object[];
92
130
  * }}
@@ -103,6 +141,7 @@ export function createChatState(opts: { model?: string } = {}) {
103
141
  model,
104
142
  tokenCount: 0,
105
143
  contextWarning: false,
144
+ usage: null,
106
145
  });
107
146
 
108
147
  let _streamingMessage: Message | null = null;
@@ -257,22 +296,28 @@ export function createChatState(opts: { model?: string } = {}) {
257
296
  }
258
297
 
259
298
  /**
260
- * Attach a result to a pending tool call.
299
+ * Attach a result to the tool call it answers, on the assistant message that made the call.
300
+ *
301
+ * The record is looked for on that message rather than on the stream: a loop runs its tools after
302
+ * the round's stream has finished, when there is no streaming message and no pending call left,
303
+ * and looking only there attached every result to nothing, so a live chip never showed how its
304
+ * call ended. The request is the assistant message directly before the tool replies at the end of
305
+ * the transcript, the one position the wire allows a reply in; a provider may reuse call ids
306
+ * across rounds, so an earlier request carrying the same id is never the one being answered.
261
307
  *
262
308
  * @param {string} id
263
309
  * @param {ToolResult} result
264
310
  */
265
311
  function appendToolResult(id: string, result: ToolResult) {
266
- const tc = store.pendingToolCalls.find((t) => t.id === id);
267
- if (tc) {
268
- tc.result = result;
312
+ let index = store.messages.length - 1;
313
+ while (index >= 0 && store.messages[index]!.role === "tool") {
314
+ index -= 1;
269
315
  }
270
- // Also update in the message record
271
- if (_streamingMessage?.toolCalls) {
272
- const mtc = _streamingMessage.toolCalls.find((t) => t.id === id);
273
- if (mtc) {
274
- mtc.result = result;
275
- }
316
+ const request = store.messages[index];
317
+ const record =
318
+ request?.role === "assistant" ? request.toolCalls?.find((t) => t.id === id) : null;
319
+ if (record) {
320
+ record.result = result;
276
321
  }
277
322
  }
278
323
 
@@ -297,6 +342,7 @@ export function createChatState(opts: { model?: string } = {}) {
297
342
  store.status = "error";
298
343
  store.error = message;
299
344
  store.streamingContent = "";
345
+ store.pendingToolCalls = [];
300
346
  // Remove the partial streaming message — it may contain incomplete tool_calls
301
347
  // That would poison the conversation history on the next send.
302
348
  if (_streamingMessage) {
@@ -333,6 +379,7 @@ export function createChatState(opts: { model?: string } = {}) {
333
379
  store.pendingToolCalls = [];
334
380
  store.error = null;
335
381
  store.contextWarning = false;
382
+ store.usage = null;
336
383
  _streamingMessage = null;
337
384
  }
338
385
 
@@ -368,6 +415,47 @@ export function createChatState(opts: { model?: string } = {}) {
368
415
  store.tokenCount = count;
369
416
  }
370
417
 
418
+ /**
419
+ * Record the provider's count for the request that just finished. The count covers everything the
420
+ * request carried plus what it generated, which is the context's size at this point — less any
421
+ * reasoning the provider billed but never streamed, since the next request cannot replay what it
422
+ * never received. It replaces the estimate in `tokenCount` rather than adding to it.
423
+ *
424
+ * @param {StreamUsageEvent} usage
425
+ * @param {{ systemTokens?: number }} [extra] - The estimate of the system prompt this request
426
+ * carried, so a later budget can add what the prompt has grown by since.
427
+ */
428
+ function recordUsage(usage: StreamUsageEvent, extra: { systemTokens?: number } = {}) {
429
+ /* A backend's frame, trusted no further than its shape: a count that is not a finite,
430
+ non-negative number would turn every later budget into NaN, and a NaN budget trims history
431
+ it has no reason to. Such a frame is ignored, and the estimate stands. */
432
+ if (!isCount(usage.inputTokens) || !isCount(usage.outputTokens)) {
433
+ return;
434
+ }
435
+ const { type: _type, ...counts } = usage;
436
+ const counted = _streamingMessage ?? store.messages.at(-1);
437
+ /* Replayed means SENT: `toMessagesArray` drops an assistant turn carrying neither text nor
438
+ tool calls, reasoning and all, so a turn that only thought is not replayed either. */
439
+ const replayed = Boolean(
440
+ counted?.reasoningContent && (counted.content || (counted.toolCalls?.length ?? 0) > 0),
441
+ );
442
+ const unreplayed = replayed || !isCount(usage.reasoningTokens) ? 0 : usage.reasoningTokens;
443
+ const contextTokens = Math.max(0, usage.inputTokens + usage.outputTokens - unreplayed);
444
+ store.usage = {
445
+ ...counts,
446
+ contextTokens,
447
+ lastMessageId: store.messages.at(-1)?.id ?? "",
448
+ messageCount: store.messages.length,
449
+ ...(extra.systemTokens === undefined ? {} : { systemTokens: extra.systemTokens }),
450
+ };
451
+ store.tokenCount = contextTokens;
452
+ }
453
+
454
+ /** Forget the last count — the transcript it described has changed underneath it. */
455
+ function clearUsage() {
456
+ store.usage = null;
457
+ }
458
+
371
459
  /**
372
460
  * Set the context overflow warning flag.
373
461
  *
@@ -465,6 +553,8 @@ export function createChatState(opts: { model?: string } = {}) {
465
553
  retryLast,
466
554
  setModel,
467
555
  setTokenCount,
556
+ recordUsage,
557
+ clearUsage,
468
558
  setContextWarning,
469
559
  toMessagesArray,
470
560
  });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Core-types.ts — the shapes a tool call carries, shared by every Jx harness host.
3
+ *
4
+ * Type-only. `./tools` re-exports all of it; the constructors live there (`createLedger`,
5
+ * `createSessionFacts`, `createToolContext`, `linkCallSignal`).
6
+ *
7
+ * A tool used to reach what it needed from its host through module-level slots: the turn's signal
8
+ * and the call's id from Studio's `ai-turn-signal.ts`, the write ledger from `ai-writes.ts`, the
9
+ * one-import guard from a flag in `ai-import-tools.ts`. That tied every tool to one Studio window
10
+ * running one turn. A {@link ToolContext} carries those facts with the call instead, so the same
11
+ * tool can run in a page, a Worker or an MCP server (specs/ai.md §3.7).
12
+ *
13
+ * @license MIT
14
+ * @module @jxsuite/ai/tools
15
+ */
16
+
17
+ /** A value that survives a JSON round trip unchanged. */
18
+ export type JsonValue =
19
+ | null
20
+ | boolean
21
+ | number
22
+ | string
23
+ | JsonValue[]
24
+ | { [key: string]: JsonValue };
25
+
26
+ /** What a tool call answers. */
27
+ export interface ToolResult {
28
+ success: boolean;
29
+ data?: unknown;
30
+ error?: string;
31
+ summary?: string;
32
+ }
33
+
34
+ /** One change the assistant made, as recorded by the tool that made it. */
35
+ export interface AiWrite {
36
+ /** Project-relative path, or the document path for an in-editor mutation. */
37
+ path: string;
38
+ /** The tool that did it: the command title the chip renders. */
39
+ tool: string;
40
+ /**
41
+ * True when the change went to disk with no transaction behind it.
42
+ *
43
+ * This is the undo caveat, as a fact rather than a sentence: a `disk` write is NOT reachable by
44
+ * undo, by the tab's history, or by "Restore to here".
45
+ */
46
+ disk: boolean;
47
+ /** False when the tool reported a failure: a listed attempt that changed nothing. */
48
+ ok: boolean;
49
+ /** Why it failed, when it did. */
50
+ error?: string;
51
+ }
52
+
53
+ /** Where a turn's tools record their changes. One per turn; the host files it when the turn ends. */
54
+ export interface WriteLedger {
55
+ record: (write: AiWrite) => void;
56
+ /** Every write recorded so far, oldest first. */
57
+ readonly writes: readonly AiWrite[];
58
+ }
59
+
60
+ /** Who a tool call acts for. */
61
+ export interface Actor {
62
+ readonly kind: "assistant";
63
+ /** `assistant:<sessionId|local>:<turnId>`: the key history entries and lanes use. */
64
+ readonly id: string;
65
+ readonly turnId: string;
66
+ readonly sessionId: string | null;
67
+ readonly model?: string;
68
+ }
69
+
70
+ /**
71
+ * Per-session JSON facts: what a tool must remember across turns (an import already ran). The host
72
+ * decides how long they last; Studio keeps them until New Chat.
73
+ */
74
+ export interface SessionFacts {
75
+ readonly sessionId: string | null;
76
+ get: (key: string) => JsonValue | undefined;
77
+ set: (key: string, value: JsonValue) => void;
78
+ }
79
+
80
+ /** Per-call facts. Host services a tool needs are bound when the tool is built, not passed here. */
81
+ export interface ToolContext {
82
+ /**
83
+ * A per-CALL child of the turn's signal: aborts with the turn, and is unlinked when the call
84
+ * settles.
85
+ */
86
+ readonly signal: AbortSignal;
87
+ /**
88
+ * The provider's id for this call. Equals the chip's id, the question's id and the import run's
89
+ * key.
90
+ */
91
+ readonly callId: string;
92
+ readonly actor: Actor;
93
+ readonly ledger: WriteLedger;
94
+ readonly session: SessionFacts;
95
+ /** Report progress on a long call (an import's log lines). A no-op when nothing observes it. */
96
+ progress: (event: JsonValue) => void;
97
+ }