@jxsuite/ai 0.37.1 → 0.38.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`, 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 |
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";
@@ -40,13 +41,44 @@ for await (const event of client.streamChat(messages, tools, systemPrompt, signa
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
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 })`** 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
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
+
81
+ `./gateway` is Worker-safe. Studio never imports it (`packages/studio/tests/ai-import-rules.test.ts`).
50
82
 
51
83
  ## The tool registry
52
84
 
@@ -74,12 +106,13 @@ registry.register(
74
106
  - **`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
107
  - **`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
108
  - Re-registering a name `console.warn`s and overwrites.
109
+ - **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
110
 
78
111
  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
112
 
80
113
  ## The reactive chat store
81
114
 
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 }`).
115
+ `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
116
 
84
117
  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
118
 
@@ -93,4 +126,4 @@ Further surprises:
93
126
 
94
127
  ## Versioning
95
128
 
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.
129
+ 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.1",
3
+ "version": "0.38.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.0",
32
- "@vue/reactivity": "3.5.42"
32
+ "@jxsuite/protocol": "^2.4.0",
33
+ "@vue/reactivity": "3.5.43"
33
34
  },
34
35
  "devDependencies": {
36
+ "@cloudflare/workers-types": "^5.20260926.1",
35
37
  "@happy-dom/global-registrator": "^20.14.5"
36
38
  }
37
39
  }
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
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The chat route: a request from a Jx client in, an SSE stream of normalized frames out.
3
+ *
4
+ * @module @jxsuite/ai/gateway
5
+ */
6
+
7
+ import type { StreamEvent } from "../streaming-client.ts";
8
+ import { fetchFailureFrame, normalizeOpenAIStream } from "./normalize.ts";
9
+ import { problemResponse, refusal } from "./problem.ts";
10
+ import { encodeSse, SSE_HEADERS } from "./sse.ts";
11
+ import type { ChatGatewayOptions, GatewayRefusal, Upstream } from "./types.ts";
12
+
13
+ /** The refusal's detail for a body that is not JSON. */
14
+ const INVALID_JSON = "Invalid JSON body";
15
+
16
+ /** The version 1 chat body, as a client sends it. Every member is optional on the wire. */
17
+ interface ChatRequestBody {
18
+ messages?: unknown;
19
+ tools?: unknown;
20
+ systemPrompt?: unknown;
21
+ model?: unknown;
22
+ }
23
+
24
+ /** The chat-completions body forwarded upstream. */
25
+ interface UpstreamBody {
26
+ model: unknown;
27
+ messages: unknown[];
28
+ stream: boolean;
29
+ stream_options: { include_usage: boolean };
30
+ tools?: unknown;
31
+ tool_choice?: string;
32
+ parallel_tool_calls?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Whether `resolveUpstream` answered with a refusal rather than an upstream.
37
+ *
38
+ * @param {Upstream | GatewayRefusal} value - What `resolveUpstream` returned
39
+ * @returns {boolean}
40
+ */
41
+ function isRefusal(value: Upstream | GatewayRefusal): value is GatewayRefusal {
42
+ return "problem" in value;
43
+ }
44
+
45
+ /**
46
+ * Read the request body to the end, or until it passes `limit` bytes, in which case the rest is
47
+ * never read and the result is null. Measured on the bytes that actually arrive, because a
48
+ * `Content-Length` is only a claim.
49
+ *
50
+ * @param {Request} request - The client's request
51
+ * @param {number | undefined} limit - The most bytes accepted, or undefined for no limit
52
+ * @returns {Promise<Uint8Array | null>}
53
+ */
54
+ async function readBody(request: Request, limit: number | undefined): Promise<Uint8Array | null> {
55
+ const chunks: Uint8Array[] = [];
56
+ let length = 0;
57
+ const reader = request.body?.getReader();
58
+ if (reader) {
59
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
60
+ length += chunk.value.byteLength;
61
+ if (limit !== undefined && length > limit) {
62
+ void reader.cancel();
63
+ return null;
64
+ }
65
+ chunks.push(chunk.value);
66
+ }
67
+ }
68
+ const bytes = new Uint8Array(length);
69
+ let offset = 0;
70
+ for (const chunk of chunks) {
71
+ bytes.set(chunk, offset);
72
+ offset += chunk.byteLength;
73
+ }
74
+ return bytes;
75
+ }
76
+
77
+ /**
78
+ * The frames of one upstream exchange: the request, then its normalized response, or the one frame
79
+ * that says why there is no response.
80
+ *
81
+ * @param {typeof fetch} upstreamFetch - The fetch to call the upstream with
82
+ * @param {string} url - The upstream's chat-completions URL
83
+ * @param {RequestInit} init - The upstream request
84
+ * @yields {StreamEvent} The route's frames, in wire order
85
+ */
86
+ async function* exchange(
87
+ upstreamFetch: typeof fetch,
88
+ url: string,
89
+ init: RequestInit,
90
+ ): AsyncGenerator<StreamEvent> {
91
+ let response: Response;
92
+ try {
93
+ response = await upstreamFetch(url, init);
94
+ } catch (error) {
95
+ yield fetchFailureFrame(error);
96
+ return;
97
+ }
98
+ yield* normalizeOpenAIStream(response);
99
+ }
100
+
101
+ /**
102
+ * Build the chat route's handler around a host's policy.
103
+ *
104
+ * Each request goes through the same steps in the same order, and the first one that refuses
105
+ * answers it as `application/problem+json` without contacting the upstream:
106
+ *
107
+ * 1. `admit`, before the body is read.
108
+ * 2. `resolveUpstream`: where the request goes, or why it goes nowhere.
109
+ * 3. The body is read, stopping at `maxBodyBytes` (413), and parsed as JSON (400 when it is not).
110
+ * 4. `messages` must be an array (400); its absence reads as an empty one.
111
+ * 5. `maxMessages` (413).
112
+ * 6. `onAccepted`.
113
+ * 7. The stream: a `200 text/event-stream` whose frames are the upstream's response normalized by
114
+ * `normalizeOpenAIStream`, or the one frame saying why the upstream could not be reached.
115
+ *
116
+ * The version 1 body is forwarded as the OpenAI-compatible request it describes: the system prompt
117
+ * 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
+ *
121
+ * @param {ChatGatewayOptions<C>} options - The host's policy
122
+ * @returns {(request: Request, context: C) => Promise<Response>}
123
+ */
124
+ export function createChatHandler<C extends { readonly signal: AbortSignal }>(
125
+ options: ChatGatewayOptions<C>,
126
+ ): (request: Request, context: C) => Promise<Response> {
127
+ return async (request, context) => {
128
+ const admitted = await options.admit?.(request, context);
129
+ if (admitted) {
130
+ return problemResponse(admitted);
131
+ }
132
+
133
+ const upstream = await options.resolveUpstream(request, context);
134
+ if (isRefusal(upstream)) {
135
+ return problemResponse(upstream);
136
+ }
137
+
138
+ // A body that cannot be read is answered as one that cannot be parsed, as it always was.
139
+ let bytes: Uint8Array | null;
140
+ try {
141
+ bytes = await readBody(request, options.maxBodyBytes);
142
+ } catch {
143
+ return problemResponse(refusal("invalidRequest", INVALID_JSON));
144
+ }
145
+ if (bytes === null) {
146
+ return problemResponse(
147
+ refusal(
148
+ "payloadTooLarge",
149
+ `The request body is larger than this gateway accepts (${String(options.maxBodyBytes)} bytes).`,
150
+ ),
151
+ );
152
+ }
153
+ let body: ChatRequestBody;
154
+ try {
155
+ /* `ignoreBOM` keeps a leading U+FEFF in the text, so a body that starts with one is invalid
156
+ JSON, as it was when the server read it with Bun's `req.json()`. The default decoder would
157
+ strip it and accept the body; this extraction changes no byte a client receives. */
158
+ const text = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }).decode(bytes);
159
+ body = JSON.parse(text) as ChatRequestBody;
160
+ } catch {
161
+ return problemResponse(refusal("invalidRequest", INVALID_JSON));
162
+ }
163
+
164
+ /* Defaults apply to an ABSENT member only, so a `null` one is forwarded as sent (and a `null`
165
+ `messages` is refused below). A body of JSON `null` throws here, as it always has. */
166
+ const { messages = [], tools = [], systemPrompt = "", model = upstream.defaultModel } = body;
167
+
168
+ if (!Array.isArray(messages)) {
169
+ return problemResponse(refusal("invalidRequest", "messages must be an array"));
170
+ }
171
+ if (options.maxMessages !== undefined && messages.length > options.maxMessages) {
172
+ return problemResponse(
173
+ refusal(
174
+ "payloadTooLarge",
175
+ `The request carries ${String(messages.length)} messages; this gateway accepts at most ${String(options.maxMessages)}.`,
176
+ ),
177
+ );
178
+ }
179
+
180
+ options.onAccepted?.(
181
+ {
182
+ bytes: bytes.byteLength,
183
+ messageCount: messages.length,
184
+ model: String(model),
185
+ wire: 1,
186
+ },
187
+ upstream,
188
+ context,
189
+ );
190
+
191
+ const upstreamBody: UpstreamBody = {
192
+ model,
193
+ messages: [{ role: "system", content: systemPrompt }, ...(messages as unknown[])],
194
+ stream: true,
195
+ stream_options: { include_usage: true },
196
+ };
197
+ // The wire has always forwarded `tools` as sent, so a non-array with a length passes through.
198
+ if (tools && (tools as { length: number }).length > 0) {
199
+ upstreamBody.tools = tools;
200
+ upstreamBody.tool_choice = "auto";
201
+ upstreamBody.parallel_tool_calls = true;
202
+ }
203
+
204
+ // Looked up per request, not captured at construction: a host or a test may swap it.
205
+ const upstreamFetch = options.fetch ?? fetch;
206
+ const frames = exchange(upstreamFetch, `${upstream.baseUrl}/chat/completions`, {
207
+ method: "POST",
208
+ headers: {
209
+ "Content-Type": "application/json",
210
+ Authorization: `Bearer ${upstream.apiKey}`,
211
+ },
212
+ body: JSON.stringify(upstreamBody),
213
+ signal: context.signal,
214
+ });
215
+ return new Response(encodeSse(frames), { headers: SSE_HEADERS });
216
+ };
217
+ }