@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 +48 -14
- package/package.json +5 -3
- package/src/cache-hints.ts +111 -0
- package/src/chat-state.ts +100 -10
- package/src/core-types.ts +97 -0
- package/src/gateway/chat.ts +224 -0
- package/src/gateway/index.ts +33 -0
- package/src/gateway/models.ts +22 -0
- package/src/gateway/normalize.ts +302 -0
- package/src/gateway/problem.ts +80 -0
- package/src/gateway/sse.ts +42 -0
- package/src/gateway/types.ts +87 -0
- package/src/gateway/upstream-error.ts +54 -0
- package/src/index.ts +14 -2
- package/src/streaming-client.ts +182 -49
- package/src/tools.ts +153 -14
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
|
|
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`,
|
|
10
|
-
| `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its
|
|
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
|
|
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. **
|
|
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 `
|
|
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`
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
let index = store.messages.length - 1;
|
|
313
|
+
while (index >= 0 && store.messages[index]!.role === "tool") {
|
|
314
|
+
index -= 1;
|
|
269
315
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
}
|