@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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat route's framing: one Server-Sent Events `data:` line per frame.
|
|
3
|
+
*
|
|
4
|
+
* @module @jxsuite/ai/gateway
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { StreamEvent } from "../streaming-client.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The headers a chat stream is answered with. `no-cache` because a replayed stream is a different
|
|
11
|
+
* turn, and `keep-alive` because the answer is long-lived by design.
|
|
12
|
+
*/
|
|
13
|
+
export const SSE_HEADERS: Readonly<Record<string, string>> = {
|
|
14
|
+
"Content-Type": "text/event-stream",
|
|
15
|
+
"Cache-Control": "no-cache",
|
|
16
|
+
Connection: "keep-alive",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Frame `events` as a Server-Sent Events body: `data: <JSON>` and a blank line per frame, and
|
|
21
|
+
* nothing else (no `event:`, `id:` or `retry:` field), so the JSON's own `type` is the only
|
|
22
|
+
* discriminator a reader needs.
|
|
23
|
+
*
|
|
24
|
+
* The stream is PUSHED: iteration starts when the stream is constructed, not when it is first read,
|
|
25
|
+
* and every frame is enqueued as soon as it exists. That is how the proxy has always behaved (its
|
|
26
|
+
* upstream request is on the wire before the response is returned), and this extraction keeps it.
|
|
27
|
+
* An iterable that throws errors the stream.
|
|
28
|
+
*
|
|
29
|
+
* @param {AsyncIterable<StreamEvent>} events - The frames, in wire order
|
|
30
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
31
|
+
*/
|
|
32
|
+
export function encodeSse(events: AsyncIterable<StreamEvent>): ReadableStream<Uint8Array> {
|
|
33
|
+
const encoder = new TextEncoder();
|
|
34
|
+
return new ReadableStream<Uint8Array>({
|
|
35
|
+
async start(controller) {
|
|
36
|
+
for await (const event of events) {
|
|
37
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
38
|
+
}
|
|
39
|
+
controller.close();
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway's contract types: what a host hands `createChatHandler`, and what the handler reports
|
|
3
|
+
* back to it.
|
|
4
|
+
*
|
|
5
|
+
* The split these types draw is the point of the gateway. The WIRE (reading the body, shaping the
|
|
6
|
+
* upstream request, normalizing the upstream stream, framing SSE, rendering a refusal) is the same
|
|
7
|
+
* for every host, so it lives here once. The POLICY (whose key a request may use, which base URLs
|
|
8
|
+
* are safe to reach, how many requests a caller may make) differs per host, so the host supplies it
|
|
9
|
+
* as `admit` and `resolveUpstream` and the gateway never second-guesses the answer.
|
|
10
|
+
*
|
|
11
|
+
* Type-only, so it is on the allowlist in `scripts/check-coverage-manifest.ts`.
|
|
12
|
+
*
|
|
13
|
+
* @module @jxsuite/ai/gateway
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ProblemDetails } from "@jxsuite/protocol/problem";
|
|
17
|
+
|
|
18
|
+
/** Where one chat request is forwarded. A host decides it per request, in `resolveUpstream`. */
|
|
19
|
+
export interface Upstream {
|
|
20
|
+
/** The wire the upstream speaks. The OpenAI-compatible chat-completions API is the only one. */
|
|
21
|
+
readonly family: "openai-compat";
|
|
22
|
+
/** The provider's base URL, without the `/chat/completions` the gateway appends. */
|
|
23
|
+
readonly baseUrl: string;
|
|
24
|
+
/** Sent as a bearer token in the `Authorization` header. */
|
|
25
|
+
readonly apiKey: string;
|
|
26
|
+
/** The model a request that names none is forwarded with. */
|
|
27
|
+
readonly defaultModel: string;
|
|
28
|
+
/** True when the platform brokers the credential, rather than the user supplying one. */
|
|
29
|
+
readonly managed: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The prompt-cache affinity key for the request's conversation, when the host derived one: the
|
|
32
|
+
* gateway forwards it in whatever form this upstream understands, and to no other upstream
|
|
33
|
+
* (`upstreamCacheHints`). A host derives it with `affinityKey(scope, id)` from the client's
|
|
34
|
+
* `X-Jx-Ai-Session` header, so the raw id never leaves the host.
|
|
35
|
+
*/
|
|
36
|
+
readonly sessionAffinity?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A request the gateway (or the host's policy) declined, answered as `application/problem+json`
|
|
41
|
+
* before any upstream is contacted.
|
|
42
|
+
*/
|
|
43
|
+
export interface GatewayRefusal {
|
|
44
|
+
/** The HTTP status of the refusal. */
|
|
45
|
+
readonly status: number;
|
|
46
|
+
/** The RFC 9457 problem document the body carries. */
|
|
47
|
+
readonly problem: ProblemDetails;
|
|
48
|
+
/** A machine code for clients that key on `code` rather than on the problem type. */
|
|
49
|
+
readonly code?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a request the gateway accepted asked for, reported to `onAccepted` for audit. */
|
|
53
|
+
export interface ChatAdmission {
|
|
54
|
+
/** The wire version of the accepted body. Version 1 is the only one. */
|
|
55
|
+
readonly wire: 1;
|
|
56
|
+
/** The model the request is forwarded with: the body's, or the upstream's default. */
|
|
57
|
+
readonly model: string;
|
|
58
|
+
/** How many messages the body carried, not counting the system prompt the gateway prepends. */
|
|
59
|
+
readonly messageCount: number;
|
|
60
|
+
/** The body's size in bytes, as read rather than as declared. */
|
|
61
|
+
readonly bytes: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A host's half of the chat route. Only `resolveUpstream` is required. */
|
|
65
|
+
export interface ChatGatewayOptions<C> {
|
|
66
|
+
/** Runs first, before the body is read (a rate limit, say). A refusal ends the request. */
|
|
67
|
+
readonly admit?: (
|
|
68
|
+
request: Request,
|
|
69
|
+
context: C,
|
|
70
|
+
) => Promise<GatewayRefusal | null> | GatewayRefusal | null;
|
|
71
|
+
/**
|
|
72
|
+
* Where the request goes, or why it goes nowhere. This is the host's policy: key provenance and
|
|
73
|
+
* any guard on a caller-supplied base URL belong here, not in the gateway.
|
|
74
|
+
*/
|
|
75
|
+
readonly resolveUpstream: (
|
|
76
|
+
request: Request,
|
|
77
|
+
context: C,
|
|
78
|
+
) => Promise<Upstream | GatewayRefusal> | Upstream | GatewayRefusal;
|
|
79
|
+
/** The largest body accepted, in bytes actually read. Reading stops once it is passed. */
|
|
80
|
+
readonly maxBodyBytes?: number;
|
|
81
|
+
/** The most messages one request may carry. */
|
|
82
|
+
readonly maxMessages?: number;
|
|
83
|
+
/** Called once per accepted request, after every check and before the upstream is contacted. */
|
|
84
|
+
readonly onAccepted?: (admission: ChatAdmission, upstream: Upstream, context: C) => void;
|
|
85
|
+
/** The fetch the upstream is called with. The global `fetch`, looked up per call, when absent. */
|
|
86
|
+
readonly fetch?: typeof fetch;
|
|
87
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sentence inside an upstream provider's error body.
|
|
3
|
+
*
|
|
4
|
+
* @module @jxsuite/ai/gateway
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract a human-readable message from an upstream error body, falling back to the raw body (or
|
|
9
|
+
* `fallback` when the body is empty).
|
|
10
|
+
*
|
|
11
|
+
* OpenAI-compatible providers return `{ error: "..." }` or `{ error: { message: "..." } }`.
|
|
12
|
+
* Cloudflare's own REST API (what a BYOK base URL pointed at Workers AI actually returns) uses a
|
|
13
|
+
* different envelope, `{ success: false, errors: [{ code, message }] }`, with no top-level `error`
|
|
14
|
+
* key at all. Without this branch that shape parses as valid JSON with nothing matched, and the
|
|
15
|
+
* caller was left showing the whole raw body (e.g. the full `{"success":false,"errors":[...]}`
|
|
16
|
+
* blob) instead of the human sentence inside it.
|
|
17
|
+
*
|
|
18
|
+
* The chat stream's `error` frame reads it, and so does a host's model catalogue, which reports the
|
|
19
|
+
* same sentence as `upstreamMessage`.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} rawBody - The upstream's response body, as text
|
|
22
|
+
* @param {string} fallback - What to say when the body is empty (the status text, usually)
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function extractUpstreamErrorMessage(rawBody: string, fallback: string): string {
|
|
26
|
+
if (!rawBody) {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const { error, errors } = JSON.parse(rawBody) as {
|
|
31
|
+
error?: string | { message?: string };
|
|
32
|
+
errors?: { code?: number; message?: string }[];
|
|
33
|
+
};
|
|
34
|
+
if (typeof error === "string") {
|
|
35
|
+
return error;
|
|
36
|
+
}
|
|
37
|
+
if (error) {
|
|
38
|
+
const { message } = error;
|
|
39
|
+
if (message) {
|
|
40
|
+
return message;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const [first] = errors ?? [];
|
|
44
|
+
if (first) {
|
|
45
|
+
const { message } = first;
|
|
46
|
+
if (message) {
|
|
47
|
+
return message;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
/* Not JSON: use the raw body. */
|
|
52
|
+
}
|
|
53
|
+
return rawBody;
|
|
54
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Index.js — AI infrastructure for Jx Suite (`@jxsuite/ai`)
|
|
3
3
|
*
|
|
4
|
-
* Re-exports the core modules: streaming client abstraction, tool registry, and
|
|
5
|
-
*
|
|
4
|
+
* Re-exports the core modules: streaming client abstraction, tool registry, and reactive chat state
|
|
5
|
+
* management. Provider-agnostic; no Studio or Jx dependencies.
|
|
6
6
|
*
|
|
7
7
|
* @license MIT
|
|
8
8
|
*/
|
|
@@ -13,6 +13,8 @@ export { createToolDefinition, createToolRegistry } from "./tools.js";
|
|
|
13
13
|
|
|
14
14
|
export { createChatState } from "./chat-state.js";
|
|
15
15
|
|
|
16
|
+
export type { ChatUsage } from "./chat-state.js";
|
|
17
|
+
|
|
16
18
|
export {
|
|
17
19
|
STREAM_EVENT_TYPES,
|
|
18
20
|
createOpenAIStreamingClient,
|
|
@@ -20,6 +22,15 @@ export {
|
|
|
20
22
|
createProxyStreamingClient,
|
|
21
23
|
} from "./streaming-client.js";
|
|
22
24
|
|
|
25
|
+
export {
|
|
26
|
+
AI_SESSION_HEADER,
|
|
27
|
+
affinityKey,
|
|
28
|
+
isAiSessionId,
|
|
29
|
+
upstreamCacheHints,
|
|
30
|
+
} from "./cache-hints.js";
|
|
31
|
+
|
|
32
|
+
export type { UpstreamCacheHints } from "./cache-hints.js";
|
|
33
|
+
|
|
23
34
|
export type {
|
|
24
35
|
AnthropicStreamingClientOptions,
|
|
25
36
|
OpenAIStreamingClientOptions,
|
|
@@ -33,4 +44,5 @@ export type {
|
|
|
33
44
|
StreamToolCallDeltaEvent,
|
|
34
45
|
StreamToolCallEndEvent,
|
|
35
46
|
StreamToolCallStartEvent,
|
|
47
|
+
StreamUsageEvent,
|
|
36
48
|
} from "./streaming-client.js";
|
package/src/streaming-client.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Streaming-client.js — Provider-agnostic streaming LLM client abstraction
|
|
3
3
|
*
|
|
4
|
-
* Defines the StreamingClient interface, the StreamEvent union every implementation yields,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Defines the StreamingClient interface, the StreamEvent union every implementation yields, and
|
|
5
|
+
* concrete implementations for OpenAI and Anthropic. Designed upfront so switching providers is a
|
|
6
|
+
* new implementation, not a refactor.
|
|
7
7
|
*
|
|
8
|
-
* The union and its
|
|
8
|
+
* The union and its eight members are exported because they are the contract a third-party Studio
|
|
9
9
|
* backend implements for the `ai/chat` route, not an internal detail — see the docs page below.
|
|
10
10
|
*
|
|
11
|
-
* @license MIT
|
|
12
11
|
* @module @jxsuite/ai/streaming-client
|
|
12
|
+
* @license MIT
|
|
13
13
|
* @docs extending/embedding/backend-protocol
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ProblemDetails } from "@jxsuite/protocol";
|
|
17
|
+
import { AI_SESSION_HEADER, upstreamCacheHints } from "./cache-hints.ts";
|
|
18
|
+
|
|
19
|
+
/* Re-exported so `./streaming-client`, the subpath a backend already imports, carries the cache
|
|
20
|
+
hints too: a Worker backend hashing the session header needs nothing else from this package. */
|
|
21
|
+
export {
|
|
22
|
+
AI_SESSION_HEADER,
|
|
23
|
+
affinityKey,
|
|
24
|
+
isAiSessionId,
|
|
25
|
+
upstreamCacheHints,
|
|
26
|
+
} from "./cache-hints.ts";
|
|
27
|
+
export type { UpstreamCacheHints } from "./cache-hints.ts";
|
|
17
28
|
|
|
18
29
|
export type StreamEvent =
|
|
19
30
|
| StreamDeltaEvent
|
|
@@ -21,6 +32,7 @@ export type StreamEvent =
|
|
|
21
32
|
| StreamToolCallStartEvent
|
|
22
33
|
| StreamToolCallDeltaEvent
|
|
23
34
|
| StreamToolCallEndEvent
|
|
35
|
+
| StreamUsageEvent
|
|
24
36
|
| StreamDoneEvent
|
|
25
37
|
| StreamErrorEvent;
|
|
26
38
|
|
|
@@ -61,6 +73,27 @@ export interface StreamToolCallEndEvent {
|
|
|
61
73
|
id: string;
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
/**
|
|
77
|
+
* What the provider counted for the request that produced this stream, when it reported a count.
|
|
78
|
+
*
|
|
79
|
+
* Emitted at most once, immediately BEFORE `done`, because a reader stops at `done`. It is optional
|
|
80
|
+
* and additive: a backend with no figure sends none, and a reader that predates it ignores it. It
|
|
81
|
+
* exists because every budget the client keeps was an estimate of four characters per token, and
|
|
82
|
+
* the provider's own count, which the client was asking for all along (`include_usage`), was
|
|
83
|
+
* discarded before it arrived.
|
|
84
|
+
*/
|
|
85
|
+
export interface StreamUsageEvent {
|
|
86
|
+
type: "usage";
|
|
87
|
+
/** Tokens the provider read: system prompt, tool schemas and the whole history. */
|
|
88
|
+
inputTokens: number;
|
|
89
|
+
/** Tokens the provider generated, reasoning included. */
|
|
90
|
+
outputTokens: number;
|
|
91
|
+
/** The part of `inputTokens` served from a prompt cache, when the provider says. */
|
|
92
|
+
cachedInputTokens?: number;
|
|
93
|
+
/** The part of `outputTokens` spent on chain-of-thought, when the provider says. */
|
|
94
|
+
reasoningTokens?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
64
97
|
export interface StreamDoneEvent {
|
|
65
98
|
type: "done";
|
|
66
99
|
stopReason: string;
|
|
@@ -107,6 +140,8 @@ interface OpenAIChatRequestBody {
|
|
|
107
140
|
tools?: object[];
|
|
108
141
|
tool_choice?: string;
|
|
109
142
|
parallel_tool_calls?: boolean;
|
|
143
|
+
/** OpenAI's prompt-cache routing key, sent only to OpenAI itself (see `cache-hints.ts`). */
|
|
144
|
+
prompt_cache_key?: string;
|
|
110
145
|
}
|
|
111
146
|
|
|
112
147
|
/** A tool-call fragment inside an OpenAI streaming `delta`. */
|
|
@@ -131,6 +166,53 @@ interface OpenAIStreamChunk {
|
|
|
131
166
|
};
|
|
132
167
|
finish_reason?: string | null;
|
|
133
168
|
}[];
|
|
169
|
+
/** The count `stream_options.include_usage` asks for — normally a final chunk with no choices. */
|
|
170
|
+
usage?: OpenAIUsage | null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** OpenAI's usage object, as chat-completions streams it. */
|
|
174
|
+
export interface OpenAIUsage {
|
|
175
|
+
prompt_tokens?: number;
|
|
176
|
+
completion_tokens?: number;
|
|
177
|
+
prompt_tokens_details?: { cached_tokens?: number } | null;
|
|
178
|
+
completion_tokens_details?: { reasoning_tokens?: number } | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Normalize OpenAI's usage object into a {@link StreamUsageEvent}, or null when it carries no count.
|
|
183
|
+
* The two detail figures are included only when the provider sent them: absent means "not said",
|
|
184
|
+
* which is not the same as zero.
|
|
185
|
+
*/
|
|
186
|
+
export function usageEventFromOpenAI(usage?: OpenAIUsage | null): StreamUsageEvent | null {
|
|
187
|
+
if (!usage || typeof usage.prompt_tokens !== "number") {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const event: StreamUsageEvent = {
|
|
191
|
+
type: "usage",
|
|
192
|
+
inputTokens: usage.prompt_tokens,
|
|
193
|
+
outputTokens: usage.completion_tokens ?? 0,
|
|
194
|
+
};
|
|
195
|
+
const cached = usage.prompt_tokens_details?.cached_tokens;
|
|
196
|
+
if (typeof cached === "number") {
|
|
197
|
+
event.cachedInputTokens = cached;
|
|
198
|
+
}
|
|
199
|
+
const reasoning = usage.completion_tokens_details?.reasoning_tokens;
|
|
200
|
+
if (typeof reasoning === "number") {
|
|
201
|
+
event.reasoningTokens = reasoning;
|
|
202
|
+
}
|
|
203
|
+
return event;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The stop reasons the client reports, from an OpenAI `finish_reason`. Anything else (a content
|
|
208
|
+
* filter, a provider's own spelling) is not a stop the loop acts on, so it maps to null and the
|
|
209
|
+
* stream keeps reading.
|
|
210
|
+
*/
|
|
211
|
+
function stopReasonFromOpenAI(reason: string | null | undefined): string | null {
|
|
212
|
+
if (reason === "tool_calls" || reason === "length" || reason === "stop") {
|
|
213
|
+
return reason;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
134
216
|
}
|
|
135
217
|
|
|
136
218
|
/** Error response body — the proxy's flat `{ error: "..." }` or OpenAI's `{ error: { message } }`. */
|
|
@@ -199,6 +281,7 @@ export const STREAM_EVENT_TYPES = {
|
|
|
199
281
|
TOOL_CALL_START: "tool_call_start",
|
|
200
282
|
TOOL_CALL_DELTA: "tool_call_delta",
|
|
201
283
|
TOOL_CALL_END: "tool_call_end",
|
|
284
|
+
USAGE: "usage",
|
|
202
285
|
DONE: "done",
|
|
203
286
|
ERROR: "error",
|
|
204
287
|
} as const;
|
|
@@ -235,6 +318,9 @@ export const STREAM_EVENT_TYPES = {
|
|
|
235
318
|
* @param {number} [opts.temperature] - Sampling temperature, forwarded only when defined. Omit for
|
|
236
319
|
* reasoning models (GPT-5.x, o-series) that reject a custom temperature; set 0 for
|
|
237
320
|
* near-deterministic eval runs.
|
|
321
|
+
* @param {string} [opts.sessionAffinity] - A stable key for the conversation, forwarded as the
|
|
322
|
+
* prompt-cache hint the provider understands (`prompt_cache_key` to OpenAI, `x-session-affinity`
|
|
323
|
+
* to Workers AI) and to no other host. See `upstreamCacheHints`.
|
|
238
324
|
* @returns {StreamingClient}
|
|
239
325
|
*/
|
|
240
326
|
export interface OpenAIStreamingClientOptions {
|
|
@@ -242,6 +328,7 @@ export interface OpenAIStreamingClientOptions {
|
|
|
242
328
|
apiKey: string;
|
|
243
329
|
model?: string;
|
|
244
330
|
temperature?: number | undefined;
|
|
331
|
+
sessionAffinity?: string | undefined;
|
|
245
332
|
}
|
|
246
333
|
|
|
247
334
|
export function createOpenAIStreamingClient({
|
|
@@ -249,7 +336,11 @@ export function createOpenAIStreamingClient({
|
|
|
249
336
|
apiKey,
|
|
250
337
|
model = "gpt-4o",
|
|
251
338
|
temperature,
|
|
339
|
+
sessionAffinity,
|
|
252
340
|
}: OpenAIStreamingClientOptions): StreamingClient {
|
|
341
|
+
// Fixed for the client's life, so it is classified once rather than per request.
|
|
342
|
+
const hints = upstreamCacheHints(baseUrl, sessionAffinity);
|
|
343
|
+
|
|
253
344
|
/**
|
|
254
345
|
* @param {object[]} messages
|
|
255
346
|
* @param {object[]} tools
|
|
@@ -283,6 +374,7 @@ export function createOpenAIStreamingClient({
|
|
|
283
374
|
body.tool_choice = "auto";
|
|
284
375
|
body.parallel_tool_calls = true;
|
|
285
376
|
}
|
|
377
|
+
Object.assign(body, hints.body);
|
|
286
378
|
|
|
287
379
|
let response;
|
|
288
380
|
try {
|
|
@@ -291,6 +383,7 @@ export function createOpenAIStreamingClient({
|
|
|
291
383
|
headers: {
|
|
292
384
|
"Content-Type": "application/json",
|
|
293
385
|
Authorization: `Bearer ${apiKey}`,
|
|
386
|
+
...hints.headers,
|
|
294
387
|
},
|
|
295
388
|
body: JSON.stringify(body),
|
|
296
389
|
signal,
|
|
@@ -332,8 +425,37 @@ export function createOpenAIStreamingClient({
|
|
|
332
425
|
const decoder = new TextDecoder();
|
|
333
426
|
let buffer = "";
|
|
334
427
|
|
|
335
|
-
/** @type {Map<string, { id: string; name: string; args: string }>} */
|
|
336
428
|
const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
|
|
429
|
+
/* The finish reason is REMEMBERED rather than acted on. With `include_usage`, the count arrives
|
|
430
|
+
in a chunk of its own after the one carrying `finish_reason`, so returning on the finish
|
|
431
|
+
dropped the count on every request. The stream ends at `[DONE]` or when the body closes. */
|
|
432
|
+
let stopReason: string | null = null;
|
|
433
|
+
let usage: StreamUsageEvent | null = null;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Close the tool calls still open. Idempotent, so a finish chunk and `[DONE]` can both call it.
|
|
437
|
+
*
|
|
438
|
+
* @yields {StreamEvent} A `tool_call_end` per open call.
|
|
439
|
+
*/
|
|
440
|
+
function* closePendingToolCalls(): Generator<StreamEvent> {
|
|
441
|
+
for (const tc of pendingToolCalls.values()) {
|
|
442
|
+
yield { type: "tool_call_end", id: tc.id };
|
|
443
|
+
}
|
|
444
|
+
pendingToolCalls.clear();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The stream's last frames: any open calls, the count when there is one, then `done`.
|
|
449
|
+
*
|
|
450
|
+
* @yields {StreamEvent} The closing frames, in order.
|
|
451
|
+
*/
|
|
452
|
+
function* finish(): Generator<StreamEvent> {
|
|
453
|
+
yield* closePendingToolCalls();
|
|
454
|
+
if (usage) {
|
|
455
|
+
yield usage;
|
|
456
|
+
}
|
|
457
|
+
yield { type: "done", stopReason: stopReason ?? "stop" };
|
|
458
|
+
}
|
|
337
459
|
|
|
338
460
|
try {
|
|
339
461
|
while (true) {
|
|
@@ -355,12 +477,7 @@ export function createOpenAIStreamingClient({
|
|
|
355
477
|
|
|
356
478
|
const dataStr = trimmed.slice(6);
|
|
357
479
|
if (dataStr === "[DONE]") {
|
|
358
|
-
|
|
359
|
-
for (const tc of pendingToolCalls.values()) {
|
|
360
|
-
yield { type: "tool_call_end", id: tc.id };
|
|
361
|
-
}
|
|
362
|
-
pendingToolCalls.clear();
|
|
363
|
-
yield { type: "done", stopReason: "stop" };
|
|
480
|
+
yield* finish();
|
|
364
481
|
return;
|
|
365
482
|
}
|
|
366
483
|
|
|
@@ -371,29 +488,29 @@ export function createOpenAIStreamingClient({
|
|
|
371
488
|
continue; // Skip unparseable chunks
|
|
372
489
|
}
|
|
373
490
|
|
|
491
|
+
// Usually a final chunk with no choices, but a provider may put it beside the finish.
|
|
492
|
+
usage = usageEventFromOpenAI(parsed.usage) ?? usage;
|
|
493
|
+
|
|
374
494
|
const choice = parsed.choices?.[0];
|
|
375
495
|
if (!choice) {
|
|
376
496
|
continue;
|
|
377
497
|
}
|
|
378
498
|
|
|
379
499
|
const { delta } = choice;
|
|
380
|
-
if (!delta) {
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
500
|
|
|
384
501
|
// Text content
|
|
385
|
-
if (delta
|
|
502
|
+
if (delta?.content) {
|
|
386
503
|
yield { type: "delta", content: delta.content };
|
|
387
504
|
}
|
|
388
505
|
|
|
389
506
|
// Chain-of-thought, under either of the two names providers give it.
|
|
390
|
-
const reasoning = delta
|
|
507
|
+
const reasoning = delta?.reasoning_content ?? delta?.reasoning;
|
|
391
508
|
if (typeof reasoning === "string" && reasoning) {
|
|
392
509
|
yield { type: "reasoning", content: reasoning };
|
|
393
510
|
}
|
|
394
511
|
|
|
395
512
|
// Tool calls
|
|
396
|
-
if (delta
|
|
513
|
+
if (delta?.tool_calls) {
|
|
397
514
|
for (const tc of delta.tool_calls) {
|
|
398
515
|
const existing = pendingToolCalls.get(tc.index);
|
|
399
516
|
|
|
@@ -423,38 +540,17 @@ export function createOpenAIStreamingClient({
|
|
|
423
540
|
}
|
|
424
541
|
}
|
|
425
542
|
|
|
426
|
-
// Finish reason
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
pendingToolCalls.clear();
|
|
433
|
-
yield { type: "done", stopReason: "tool_calls" };
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
|
|
438
|
-
// Emit any pending tool call ends
|
|
439
|
-
for (const tc of pendingToolCalls.values()) {
|
|
440
|
-
yield { type: "tool_call_end", id: tc.id };
|
|
441
|
-
}
|
|
442
|
-
pendingToolCalls.clear();
|
|
443
|
-
yield {
|
|
444
|
-
type: "done",
|
|
445
|
-
stopReason: choice.finish_reason === "length" ? "length" : "stop",
|
|
446
|
-
};
|
|
447
|
-
return;
|
|
543
|
+
// Finish reason: close the calls now, report the reason at the end of the stream.
|
|
544
|
+
const finished = stopReasonFromOpenAI(choice.finish_reason);
|
|
545
|
+
if (finished) {
|
|
546
|
+
yield* closePendingToolCalls();
|
|
547
|
+
stopReason = finished;
|
|
448
548
|
}
|
|
449
549
|
}
|
|
450
550
|
}
|
|
451
551
|
|
|
452
|
-
// Stream ended without
|
|
453
|
-
|
|
454
|
-
yield { type: "tool_call_end", id: tc.id };
|
|
455
|
-
}
|
|
456
|
-
pendingToolCalls.clear();
|
|
457
|
-
yield { type: "done", stopReason: "stop" };
|
|
552
|
+
// Stream ended without `[DONE]`
|
|
553
|
+
yield* finish();
|
|
458
554
|
} catch (error) {
|
|
459
555
|
void reader.cancel();
|
|
460
556
|
if ((error as Error).name === "AbortError") {
|
|
@@ -513,17 +609,26 @@ export function createAnthropicStreamingClient(
|
|
|
513
609
|
* `@jxsuite/server/ai-api`). No API key handling here — the proxy owns provider credentials.
|
|
514
610
|
*
|
|
515
611
|
* @param {object} opts
|
|
516
|
-
* @param {string} opts.chatUrl - URL to POST `{ messages, tools,
|
|
612
|
+
* @param {string | (() => string | Promise<string>)} opts.chatUrl - URL to POST `{ messages, tools,
|
|
613
|
+
* systemPrompt, model }` to, or a function that answers it. A function is called once, inside the
|
|
614
|
+
* first stream, so a caller can hand over its turn's signal before it waits on anything: a Stop
|
|
615
|
+
* during a lookup over IPC then takes effect once it answers, and the stream ends cancelled with
|
|
616
|
+
* nothing sent. The outcome is kept for the client's later streams. A rejection propagates out of
|
|
617
|
+
* the stream, unless the signal was aborted by then, which ends it cancelled.
|
|
517
618
|
* @param {string} [opts.model] - Default model if not specified per-request
|
|
518
619
|
* @param {string} [opts.apiKey] - Optional client-supplied key, sent as the `X-Api-Key` header
|
|
519
620
|
* @param {string} [opts.baseUrl] - Optional OpenAI-compatible base URL, sent as `X-Api-Base-URL`
|
|
621
|
+
* @param {string} [opts.sessionId] - The conversation's id, sent as the `X-Jx-Ai-Session` header so
|
|
622
|
+
* the backend can derive a prompt-cache affinity key from it. The raw id goes to the backend
|
|
623
|
+
* only; a provider sees a hash of it, scoped to that backend (`affinityKey`).
|
|
520
624
|
* @returns {StreamingClient}
|
|
521
625
|
*/
|
|
522
626
|
export interface ProxyStreamingClientOptions {
|
|
523
|
-
chatUrl: string;
|
|
627
|
+
chatUrl: string | (() => string | Promise<string>);
|
|
524
628
|
model?: string;
|
|
525
629
|
apiKey?: string | undefined;
|
|
526
630
|
baseUrl?: string | undefined;
|
|
631
|
+
sessionId?: string | undefined;
|
|
527
632
|
}
|
|
528
633
|
|
|
529
634
|
export function createProxyStreamingClient({
|
|
@@ -531,7 +636,11 @@ export function createProxyStreamingClient({
|
|
|
531
636
|
model = "gpt-4o",
|
|
532
637
|
apiKey,
|
|
533
638
|
baseUrl,
|
|
639
|
+
sessionId,
|
|
534
640
|
}: ProxyStreamingClientOptions): StreamingClient {
|
|
641
|
+
/** The URL, once the first stream has asked for it. */
|
|
642
|
+
let resolvedUrl: Promise<string> | null = null;
|
|
643
|
+
|
|
535
644
|
/**
|
|
536
645
|
* @param {object[]} messages
|
|
537
646
|
* @param {object[]} tools
|
|
@@ -555,10 +664,34 @@ export function createProxyStreamingClient({
|
|
|
555
664
|
if (baseUrl) {
|
|
556
665
|
headers["X-Api-Base-URL"] = baseUrl;
|
|
557
666
|
}
|
|
667
|
+
/* The conversation, so consecutive rounds can be routed to a warm prompt cache. Same-origin on
|
|
668
|
+
every platform, so the extra header costs no preflight; a backend that ignores it loses only
|
|
669
|
+
the cache hit. */
|
|
670
|
+
if (sessionId) {
|
|
671
|
+
headers[AI_SESSION_HEADER] = sessionId;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
resolvedUrl ??= Promise.resolve(typeof chatUrl === "function" ? chatUrl() : chatUrl);
|
|
675
|
+
let url: string;
|
|
676
|
+
try {
|
|
677
|
+
url = await resolvedUrl;
|
|
678
|
+
} catch (error) {
|
|
679
|
+
// A lookup that failed after the turn was stopped is part of the stop, not an error.
|
|
680
|
+
if (signal?.aborted) {
|
|
681
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
// Stopped while the URL was being resolved: nothing has been sent, and nothing will be.
|
|
687
|
+
if (signal?.aborted) {
|
|
688
|
+
yield { type: "done", stopReason: "cancelled" };
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
558
691
|
|
|
559
692
|
let response;
|
|
560
693
|
try {
|
|
561
|
-
response = await fetch(
|
|
694
|
+
response = await fetch(url, {
|
|
562
695
|
method: "POST",
|
|
563
696
|
headers,
|
|
564
697
|
body: JSON.stringify({ messages, tools, systemPrompt, model }),
|