@jxsuite/ai 0.34.0 → 0.36.1
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 +177 -0
- package/package.json +4 -3
- package/src/index.ts +14 -0
- package/src/streaming-client.ts +48 -25
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# @jxsuite/ai
|
|
2
|
+
|
|
3
|
+
The provider-agnostic substrate Jx Studio's assistant is built on: a streaming LLM client
|
|
4
|
+
abstraction that normalizes provider SSE into six event shapes, a tool registry that speaks OpenAI
|
|
5
|
+
function-calling, and a reactive chat store. It has no Jx-domain dependencies — only a type-only
|
|
6
|
+
import of `ProblemDetails` from `@jxsuite/protocol` and `@vue/reactivity` — and its only platform
|
|
7
|
+
APIs are `fetch`, `TextDecoder` and `AbortSignal`, so the same modules run in the browser and in
|
|
8
|
+
Bun/Node.
|
|
9
|
+
|
|
10
|
+
Governing spec: [`specs/ai.md`](../../specs/ai.md) §1-§2 (Status: Partial). User-facing
|
|
11
|
+
documentation for the Studio feature lives at [`docs/studio/ai.md`](../../docs/studio/ai.md).
|
|
12
|
+
|
|
13
|
+
| Entrypoint | Exports |
|
|
14
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `@jxsuite/ai` | Barrel: the three factories, `createToolDefinition`, `createToolRegistry`, `createChatState`, `STREAM_EVENT_TYPES`, and the streaming types |
|
|
16
|
+
| `@jxsuite/ai/streaming-client` | `StreamingClient`, `StreamEvent` and its six members, the three factories and their option types, `STREAM_EVENT_TYPES` |
|
|
17
|
+
| `@jxsuite/ai/tools` | `createToolDefinition`, `createToolRegistry`, `toolSuccess`, `toolError`, and their types |
|
|
18
|
+
| `@jxsuite/ai/chat-state` | `createChatState`, `ChatStore`, `Message`, `ToolCallRecord`, `ChatState`, `MessageRole` |
|
|
19
|
+
|
|
20
|
+
`toolSuccess` / `toolError` are **not** re-exported by the barrel — import them from
|
|
21
|
+
`@jxsuite/ai/tools`. Importing the subpaths rather than the root is also what makes the package
|
|
22
|
+
tree-shakeable.
|
|
23
|
+
|
|
24
|
+
## The streaming client
|
|
25
|
+
|
|
26
|
+
`StreamingClient` is one method:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
streamChat(messages: object[], tools: object[], systemPrompt: string, signal: AbortSignal)
|
|
30
|
+
: AsyncGenerator<StreamEvent>;
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Every implementation yields the same discriminated union — `delta {content}`,
|
|
34
|
+
`tool_call_start {id, name}`, `tool_call_delta {id, args}`, `tool_call_end {id}`,
|
|
35
|
+
`done {stopReason}`, `error {message, code?, problem?}` — whose type strings are also published as
|
|
36
|
+
`STREAM_EVENT_TYPES`. This union is the contract a third-party Studio backend must satisfy for the
|
|
37
|
+
`ai/chat` route (see [`packages/protocol/README.md`](../protocol/README.md)); `@jxsuite/server`
|
|
38
|
+
emits the same format without depending on this package. `StreamEvent` and each member are
|
|
39
|
+
exported, so an implementer can import the type rather than reconstruct it.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createProxyStreamingClient } from "@jxsuite/ai";
|
|
43
|
+
|
|
44
|
+
const client = createProxyStreamingClient({
|
|
45
|
+
chatUrl: "/__studio/ai/chat",
|
|
46
|
+
model: "gpt-4o",
|
|
47
|
+
});
|
|
48
|
+
for await (const event of client.streamChat(messages, tools, systemPrompt, signal)) {
|
|
49
|
+
switch (event.type) {
|
|
50
|
+
/* … */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- **`createOpenAIStreamingClient({ baseUrl, apiKey, model = "gpt-4o", temperature })`** POSTs to
|
|
56
|
+
`${baseUrl}/chat/completions` with `Authorization: Bearer`, `stream: true`,
|
|
57
|
+
`stream_options: { include_usage: true }`, and `{ role: "system", content: systemPrompt }`
|
|
58
|
+
prepended to your messages. A non-empty `tools` array also sets `tool_choice: "auto"` and
|
|
59
|
+
`parallel_tool_calls: true`; an empty one sends no `tools` key. `temperature` is forwarded **only
|
|
60
|
+
when defined**, because reasoning models (GPT-5.x, o-series) reject a custom temperature — a
|
|
61
|
+
provider behaviour recorded in the source comment, not covered by a test here. It does
|
|
62
|
+
its own SSE framing and reassembles fragmented tool-call argument JSON keyed by the provider's
|
|
63
|
+
`delta.tool_calls[].index`.
|
|
64
|
+
- **`createAnthropicStreamingClient({ baseUrl, apiKey })`** is a stub. It makes no network call and
|
|
65
|
+
yields exactly one `error` event with `code: "NOT_IMPLEMENTED"`. Only the OpenAI-compatible path
|
|
66
|
+
ships (`specs/ai.md` §2).
|
|
67
|
+
- **`createProxyStreamingClient({ chatUrl, model = "gpt-4o", apiKey, baseUrl })`** POSTs
|
|
68
|
+
`{ messages, tools, systemPrompt, model }` to an endpoint that already speaks the normalized
|
|
69
|
+
format — the dev/desktop server's `/__studio/ai/chat`. It handles no credentials of its own;
|
|
70
|
+
`apiKey` and `baseUrl` are passed through as the `X-Api-Key` and `X-Api-Base-URL` headers only
|
|
71
|
+
when truthy, and the proxy owns the provider key. It is a pass-through, **not** a translator: it
|
|
72
|
+
re-yields whatever parses out of each `data:` line and returns at the first `done` or `error`, so
|
|
73
|
+
frames after `done` are dropped.
|
|
74
|
+
|
|
75
|
+
Three behaviours are easy to get wrong when consuming the stream:
|
|
76
|
+
|
|
77
|
+
1. **Cancellation is a `done`, not an `error`.** An `AbortError` from `fetch` or mid-read becomes
|
|
78
|
+
`{ type: "done", stopReason: "cancelled" }`. Treating abort as failure paints a user's Stop
|
|
79
|
+
button as a crash. The OpenAI client's other stop reasons are `"stop"`, `"tool_calls"` and
|
|
80
|
+
`"length"`.
|
|
81
|
+
2. **The OpenAI client drains pending tool calls on every termination path.** `[DONE]`, a
|
|
82
|
+
`finish_reason` of `stop`, `length` or `tool_calls` (any other value is ignored and the stream
|
|
83
|
+
continues), and a body that simply closes all emit `tool_call_end` for anything still open, then
|
|
84
|
+
exactly one `done`. The proxy client tracks nothing of its own, so a body that closes without a
|
|
85
|
+
`done` frame ends its generator with no `done` at all.
|
|
86
|
+
3. **`error` frames may carry an RFC 9457 `problem` beside the human `message`,** because the
|
|
87
|
+
response has already begun with a 200 and the status can no longer change (a committed standards
|
|
88
|
+
row in `specs/ai.md` §5). The field is optional and no client in this package sets it —
|
|
89
|
+
`@jxsuite/server`'s proxy is what populates it and `createProxyStreamingClient` passes it
|
|
90
|
+
through. Read `problem.type` for machine dispatch when it is present; keep showing `message`.
|
|
91
|
+
|
|
92
|
+
## The tool registry
|
|
93
|
+
|
|
94
|
+
`createToolRegistry()` returns `{ register, list, listForLLM, validate, execute, getDefinition }`.
|
|
95
|
+
`listForLLM()` emits `{ type: "function", function: { name, description, parameters } }`, ready to
|
|
96
|
+
hand straight to `streamChat`'s `tools` argument.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { createToolDefinition, createToolRegistry, toolSuccess } from "@jxsuite/ai/tools";
|
|
100
|
+
|
|
101
|
+
const registry = createToolRegistry();
|
|
102
|
+
registry.register(
|
|
103
|
+
createToolDefinition({
|
|
104
|
+
name: "set_property",
|
|
105
|
+
description: "Set a property on a node",
|
|
106
|
+
parameters: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: { path: { type: "string" } },
|
|
109
|
+
required: ["path"],
|
|
110
|
+
},
|
|
111
|
+
execute: (args) => toolSuccess(args, "set"),
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **`strict` and `llmStrict` are unrelated despite the names.** `strict` (default `true`) drives the
|
|
117
|
+
registry's own argument validation. `llmStrict` (default `false`) is the only one that reaches the
|
|
118
|
+
wire, adding OpenAI's `strict: true` to the emitted function schema. OpenAI strict mode demands
|
|
119
|
+
`additionalProperties: false` and every property in `required`, which Jx's own tools deliberately
|
|
120
|
+
violate — GPT-5.x rejects such a request outright (again, committed rationale rather than a test).
|
|
121
|
+
- **`validate()` is a lightweight structural check, not a JSON Schema validator.** It reads only
|
|
122
|
+
`type`, `properties` and `required`: nested schemas, `enum`, `pattern`, `minimum` and every other
|
|
123
|
+
keyword are ignored. Unknown properties are ignored too (the model may send extra); a required key
|
|
124
|
+
that is present but `null`/`undefined` counts as missing; numeric strings coerce, so `"42"`
|
|
125
|
+
validates against `number`. `strict: false`, or a schema without `properties`, skips it entirely.
|
|
126
|
+
- **`execute()` never throws.** An unknown tool, a validation failure, and an exception inside the
|
|
127
|
+
tool all return `{ success: false, error }` — which is what lets an agent loop feed the failure
|
|
128
|
+
back to the model and self-correct. It calls `this.validate(...)`, so it is `this`-bound: do not
|
|
129
|
+
destructure it off the registry.
|
|
130
|
+
- Re-registering a name `console.warn`s and overwrites.
|
|
131
|
+
|
|
132
|
+
To vary the tool set per turn, wrap rather than mutate — Studio's `createGatedToolRegistry` returns
|
|
133
|
+
a `ToolRegistry` that filters `list()`/`listForLLM()` through per-tool predicates
|
|
134
|
+
(`packages/studio/src/services/gated-registry.ts`).
|
|
135
|
+
|
|
136
|
+
## The reactive chat store
|
|
137
|
+
|
|
138
|
+
`createChatState(options?: { model?: string })` returns a `@vue/reactivity` `reactive()` store —
|
|
139
|
+
`messages`, `status`, `streamingContent`, `pendingToolCalls`, `error`, `model`, `tokenCount`,
|
|
140
|
+
`contextWarning` — with its mutators `Object.assign`ed onto the same proxy, so the returned value is
|
|
141
|
+
both the state and the API. The model falls back to `"gpt-4o"` via `||`, so an empty string takes
|
|
142
|
+
the default too. `status` is `"idle" | "streaming" | "error"`; `toMessagesArray()`
|
|
143
|
+
serializes back to OpenAI wire shape (assistant `tool_calls`, `tool` messages with `tool_call_id`,
|
|
144
|
+
everything else `{ role, content }`).
|
|
145
|
+
|
|
146
|
+
Two ordering rules inside `beginAssistantTurn` are load-bearing, and any reimplementation must keep
|
|
147
|
+
them: `status` is set to `"streaming"` **before** the placeholder message is pushed (the push itself
|
|
148
|
+
triggers effects, and a reader seeing `"idle"` renders the empty placeholder as a finished message),
|
|
149
|
+
and the pushed object is immediately re-read back through the proxy (`store.messages.at(-1)`) —
|
|
150
|
+
mutating the raw object would notify nothing. A regression test asserts that an `effect()` reading
|
|
151
|
+
`messages.at(-1).content` re-runs on every `appendDelta`.
|
|
152
|
+
|
|
153
|
+
Further surprises:
|
|
154
|
+
|
|
155
|
+
- **`sendMessage(text)` produces two messages**, not one: the user message plus an empty assistant
|
|
156
|
+
placeholder, and status flips to `"streaming"`. There is no way to append a bare user message with
|
|
157
|
+
it. `beginAssistantTurn()` exists separately so an agent loop can open the next round after
|
|
158
|
+
pushing tool results, with no new user message.
|
|
159
|
+
- **`sendMessage` / `beginAssistantTurn` are no-ops while streaming**; `appendDelta` /
|
|
160
|
+
`appendToolCallStart` are no-ops while not streaming.
|
|
161
|
+
- **`setError` and `cancelStream` delete the partial streaming message**, because it may hold
|
|
162
|
+
incomplete `tool_calls` that would poison history on the next send. That deletion is why
|
|
163
|
+
`specs/ai.md` §3.2 forbids reporting an exhausted tool-call budget as an error: the error path
|
|
164
|
+
destroys the only account of the edits that did land.
|
|
165
|
+
- **`appendToolCallEnd(id)` is an intentional no-op.** Arguments are already fully accumulated; the
|
|
166
|
+
caller parses the JSON and executes the tool.
|
|
167
|
+
- Message ids come from a module-level counter (`msg_${Date.now()}_${n}`), not a UUID.
|
|
168
|
+
|
|
169
|
+
## Versioning
|
|
170
|
+
|
|
171
|
+
Published to npm as `@jxsuite/ai` — TypeScript source, like every published `@jxsuite` library,
|
|
172
|
+
following the monorepo's release train; every `exports` subpath resolves to `./src/*.ts`, so a
|
|
173
|
+
consumer must be
|
|
174
|
+
able to compile TypeScript out of `node_modules`. Within the monorepo, `@jxsuite/studio` depends on
|
|
175
|
+
it via `workspace:^` and is its only consumer. `@vue/reactivity` is pinned to an **exact** version,
|
|
176
|
+
matching `@jxsuite/studio`, `@jxsuite/runtime` and `@jxsuite/compiler`: reactive proxies from one
|
|
177
|
+
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.36.1",
|
|
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": {
|
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@
|
|
31
|
+
"@jxsuite/protocol": "^1.1.2",
|
|
32
|
+
"@vue/reactivity": "3.5.41"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
|
-
"@happy-dom/global-registrator": "^20.11.
|
|
35
|
+
"@happy-dom/global-registrator": "^20.11.6"
|
|
35
36
|
}
|
|
36
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,3 +19,17 @@ export {
|
|
|
19
19
|
createAnthropicStreamingClient,
|
|
20
20
|
createProxyStreamingClient,
|
|
21
21
|
} from "./streaming-client.js";
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
AnthropicStreamingClientOptions,
|
|
25
|
+
OpenAIStreamingClientOptions,
|
|
26
|
+
ProxyStreamingClientOptions,
|
|
27
|
+
StreamDeltaEvent,
|
|
28
|
+
StreamDoneEvent,
|
|
29
|
+
StreamErrorEvent,
|
|
30
|
+
StreamEvent,
|
|
31
|
+
StreamingClient,
|
|
32
|
+
StreamToolCallDeltaEvent,
|
|
33
|
+
StreamToolCallEndEvent,
|
|
34
|
+
StreamToolCallStartEvent,
|
|
35
|
+
} from "./streaming-client.js";
|
package/src/streaming-client.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Streaming-client.js — Provider-agnostic streaming LLM client abstraction
|
|
3
3
|
*
|
|
4
|
-
* Defines the StreamingClient interface
|
|
4
|
+
* Defines the StreamingClient interface, the StreamEvent union every implementation yields,
|
|
5
5
|
* and concrete implementations for OpenAI and Anthropic. Designed upfront so switching
|
|
6
6
|
* providers is a new implementation, not a refactor.
|
|
7
7
|
*
|
|
8
|
+
* The union and its six members are exported because they are the contract a third-party Studio
|
|
9
|
+
* backend implements for the `ai/chat` route, not an internal detail — see the docs page below.
|
|
10
|
+
*
|
|
8
11
|
* @license MIT
|
|
9
12
|
* @module @jxsuite/ai/streaming-client
|
|
13
|
+
* @docs extending/embedding/backend-protocol
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
|
-
type
|
|
16
|
+
import type { ProblemDetails } from "@jxsuite/protocol";
|
|
17
|
+
|
|
18
|
+
export type StreamEvent =
|
|
13
19
|
| StreamDeltaEvent
|
|
14
20
|
| StreamToolCallStartEvent
|
|
15
21
|
| StreamToolCallDeltaEvent
|
|
@@ -17,37 +23,49 @@ type StreamEvent =
|
|
|
17
23
|
| StreamDoneEvent
|
|
18
24
|
| StreamErrorEvent;
|
|
19
25
|
|
|
20
|
-
interface StreamDeltaEvent {
|
|
26
|
+
export interface StreamDeltaEvent {
|
|
21
27
|
type: "delta";
|
|
22
28
|
content: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
interface StreamToolCallStartEvent {
|
|
31
|
+
export interface StreamToolCallStartEvent {
|
|
26
32
|
type: "tool_call_start";
|
|
27
33
|
id: string;
|
|
28
34
|
name: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
interface StreamToolCallDeltaEvent {
|
|
37
|
+
export interface StreamToolCallDeltaEvent {
|
|
32
38
|
type: "tool_call_delta";
|
|
33
39
|
id: string;
|
|
34
40
|
args: string;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
interface StreamToolCallEndEvent {
|
|
43
|
+
export interface StreamToolCallEndEvent {
|
|
38
44
|
type: "tool_call_end";
|
|
39
45
|
id: string;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
interface StreamDoneEvent {
|
|
48
|
+
export interface StreamDoneEvent {
|
|
43
49
|
type: "done";
|
|
44
50
|
stopReason: string;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
/**
|
|
54
|
+
* A failure that arrived mid-stream.
|
|
55
|
+
*
|
|
56
|
+
* **This is the one place RFC 9457 could not simply replace what was here**, and the reason is
|
|
57
|
+
* structural: a problem document is a _response body_, and by the time this frame is written the
|
|
58
|
+
* response has already begun with a 200. Nothing can change the status any more. So the adoption is
|
|
59
|
+
* that the frame CARRIES a problem rather than being replaced by one — `problem` is the machine
|
|
60
|
+
* half (a `type` a client can key on), `message` stays the human half every existing reader already
|
|
61
|
+
* shows, and `code` remains for the providers that send one.
|
|
62
|
+
*/
|
|
63
|
+
export interface StreamErrorEvent {
|
|
48
64
|
type: "error";
|
|
49
65
|
message: string;
|
|
50
66
|
code?: string;
|
|
67
|
+
/** RFC 9457 problem document describing the failure, when the producer knows its type. */
|
|
68
|
+
problem?: ProblemDetails;
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
/**
|
|
@@ -148,17 +166,19 @@ export const STREAM_EVENT_TYPES = {
|
|
|
148
166
|
* near-deterministic eval runs.
|
|
149
167
|
* @returns {StreamingClient}
|
|
150
168
|
*/
|
|
169
|
+
export interface OpenAIStreamingClientOptions {
|
|
170
|
+
baseUrl: string;
|
|
171
|
+
apiKey: string;
|
|
172
|
+
model?: string;
|
|
173
|
+
temperature?: number | undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
151
176
|
export function createOpenAIStreamingClient({
|
|
152
177
|
baseUrl,
|
|
153
178
|
apiKey,
|
|
154
179
|
model = "gpt-4o",
|
|
155
180
|
temperature,
|
|
156
|
-
}: {
|
|
157
|
-
baseUrl: string;
|
|
158
|
-
apiKey: string;
|
|
159
|
-
model?: string;
|
|
160
|
-
temperature?: number | undefined;
|
|
161
|
-
}): StreamingClient {
|
|
181
|
+
}: OpenAIStreamingClientOptions): StreamingClient {
|
|
162
182
|
/**
|
|
163
183
|
* @param {object[]} messages
|
|
164
184
|
* @param {object[]} tools
|
|
@@ -384,13 +404,14 @@ export function createOpenAIStreamingClient({
|
|
|
384
404
|
* @param {string} _opts.apiKey
|
|
385
405
|
* @returns {StreamingClient}
|
|
386
406
|
*/
|
|
387
|
-
export
|
|
388
|
-
baseUrl: _,
|
|
389
|
-
apiKey: __,
|
|
390
|
-
}: {
|
|
407
|
+
export interface AnthropicStreamingClientOptions {
|
|
391
408
|
baseUrl: string;
|
|
392
409
|
apiKey: string;
|
|
393
|
-
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function createAnthropicStreamingClient(
|
|
413
|
+
_opts: AnthropicStreamingClientOptions,
|
|
414
|
+
): StreamingClient {
|
|
394
415
|
/**
|
|
395
416
|
* @yields {StreamEvent} Normalized stream events.
|
|
396
417
|
* @returns {AsyncGenerator<StreamEvent>}
|
|
@@ -420,17 +441,19 @@ export function createAnthropicStreamingClient({
|
|
|
420
441
|
* @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
|
|
421
442
|
* @returns {StreamingClient}
|
|
422
443
|
*/
|
|
444
|
+
export interface ProxyStreamingClientOptions {
|
|
445
|
+
chatUrl: string;
|
|
446
|
+
model?: string;
|
|
447
|
+
apiKey?: string | undefined;
|
|
448
|
+
baseUrl?: string | undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
423
451
|
export function createProxyStreamingClient({
|
|
424
452
|
chatUrl,
|
|
425
453
|
model = "gpt-4o",
|
|
426
454
|
apiKey,
|
|
427
455
|
baseUrl,
|
|
428
|
-
}: {
|
|
429
|
-
chatUrl: string;
|
|
430
|
-
model?: string;
|
|
431
|
-
apiKey?: string | undefined;
|
|
432
|
-
baseUrl?: string | undefined;
|
|
433
|
-
}): StreamingClient {
|
|
456
|
+
}: ProxyStreamingClientOptions): StreamingClient {
|
|
434
457
|
/**
|
|
435
458
|
* @param {object[]} messages
|
|
436
459
|
* @param {object[]} tools
|