@jxsuite/ai 0.37.2 → 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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@jxsuite/ai/gateway`: the server half of the `ai/chat` and `ai/models` routes, written once.
3
+ *
4
+ * Every Jx backend that proxies the assistant to a provider does the same wire work: read the
5
+ * client's body, forward it as an OpenAI-compatible request, normalize the provider's stream into
6
+ * the route's frames, and frame those as SSE. Before this module each backend carried its own copy,
7
+ * and the copies drifted. Now `@jxsuite/server` runs this one, and any other backend can.
8
+ *
9
+ * What stays with the host is policy: whose key a request may use, which base URLs are safe to
10
+ * reach, how requests are admitted, and what the model catalogue says. The host supplies those as
11
+ * `ChatGatewayOptions`, and the gateway never second-guesses them.
12
+ *
13
+ * Worker-safe: it imports `@jxsuite/protocol`'s problem modules and nothing host-bound
14
+ * (`tests/worker-safety.test.ts`, `tsconfig.worker.json`).
15
+ *
16
+ * @module @jxsuite/ai/gateway
17
+ * @docs extending/embedding/backend-protocol
18
+ */
19
+
20
+ export { createChatHandler } from "./chat.ts";
21
+ export { modelsResponse } from "./models.ts";
22
+ export { normalizeOpenAIStream } from "./normalize.ts";
23
+ export { problemResponse } from "./problem.ts";
24
+ export { encodeSse } from "./sse.ts";
25
+ export { extractUpstreamErrorMessage } from "./upstream-error.ts";
26
+ export type { ChatAdmission, ChatGatewayOptions, GatewayRefusal, Upstream } from "./types.ts";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The models route's answer.
3
+ *
4
+ * @module @jxsuite/ai/gateway
5
+ */
6
+
7
+ import type { AiModelsResponse } from "@jxsuite/protocol/types";
8
+
9
+ /**
10
+ * Answer the models route with `body`, always at `200`.
11
+ *
12
+ * The models route is a capability probe (ai.md §2.1), so every credential state is a 200: "no
13
+ * credentials", "credentials lapsed" and "the upstream could not list" are all answers the client
14
+ * reads, and a non-2xx makes the probe throw. Which catalogue to answer with is the host's business
15
+ * (its defaults, the upstream's listing, a brokered one); this only fixes the envelope.
16
+ *
17
+ * @param {AiModelsResponse} body - The catalogue and the credential state
18
+ * @returns {Response}
19
+ */
20
+ export function modelsResponse(body: AiModelsResponse): Response {
21
+ return Response.json(body, { headers: { "Content-Type": "application/json" }, status: 200 });
22
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The OpenAI-compatible stream normalizer: an upstream chat-completions response in, the chat
3
+ * route's frames out.
4
+ *
5
+ * This is `@jxsuite/server`'s normalizer, moved here verbatim (specs/ai.md §2.4), so that every
6
+ * backend serving the chat route can run the same one. Its frames are frozen by the server's
7
+ * upstream fixtures (`packages/server/tests/fixtures/ai-upstream/*.server.json`); a change to what
8
+ * this file yields is a behaviour change and belongs in a change whose spec fragment names it.
9
+ *
10
+ * @module @jxsuite/ai/gateway
11
+ */
12
+
13
+ import { problemDetails } from "@jxsuite/protocol/problems";
14
+ import type { StreamEvent, StreamUsageEvent } from "../streaming-client.ts";
15
+ import { problemTypeForStatus } from "./problem.ts";
16
+ import { extractUpstreamErrorMessage } from "./upstream-error.ts";
17
+
18
+ /** A tool-call fragment inside an OpenAI streaming `delta`. */
19
+ interface ToolCallDelta {
20
+ index: number;
21
+ id?: string;
22
+ type?: string;
23
+ function?: { name?: string; arguments?: string };
24
+ }
25
+
26
+ /** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
27
+ interface OpenAIStreamChunk {
28
+ choices?: {
29
+ index?: number;
30
+ delta?: {
31
+ content?: string;
32
+ /** Thinking models' chain-of-thought: DeepSeek and Volcengine spell it this way... */
33
+ reasoning_content?: string;
34
+ /** ...and OpenRouter this way. Both carry the same thing. */
35
+ reasoning?: string;
36
+ tool_calls?: ToolCallDelta[];
37
+ };
38
+ finish_reason?: string | null;
39
+ }[];
40
+ /** The count `stream_options.include_usage` asks for, normally a final chunk with no choices. */
41
+ usage?: {
42
+ prompt_tokens?: number;
43
+ completion_tokens?: number;
44
+ prompt_tokens_details?: { cached_tokens?: number } | null;
45
+ completion_tokens_details?: { reasoning_tokens?: number } | null;
46
+ } | null;
47
+ }
48
+
49
+ /**
50
+ * Normalize an upstream usage object into a `usage` frame, or null when it carries no count. The
51
+ * detail figures are included only when the provider sent them: absent is "not said", not zero.
52
+ *
53
+ * @param {OpenAIStreamChunk["usage"]} usage - The chunk's `usage` member
54
+ * @returns {StreamUsageEvent | null}
55
+ */
56
+ function usageFrame(usage: OpenAIStreamChunk["usage"]): StreamUsageEvent | null {
57
+ if (!usage || typeof usage.prompt_tokens !== "number") {
58
+ return null;
59
+ }
60
+ const frame: StreamUsageEvent = {
61
+ type: "usage",
62
+ inputTokens: usage.prompt_tokens,
63
+ outputTokens: usage.completion_tokens ?? 0,
64
+ };
65
+ const cached = usage.prompt_tokens_details?.cached_tokens;
66
+ if (typeof cached === "number") {
67
+ frame.cachedInputTokens = cached;
68
+ }
69
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens;
70
+ if (typeof reasoning === "number") {
71
+ frame.reasoningTokens = reasoning;
72
+ }
73
+ return frame;
74
+ }
75
+
76
+ /**
77
+ * The stop reasons the stream reports; any other `finish_reason` keeps it reading.
78
+ *
79
+ * @param {string | null | undefined} reason - The chunk's `finish_reason`
80
+ * @returns {string | null}
81
+ */
82
+ function stopReasonOf(reason: string | null | undefined): string | null {
83
+ if (reason === "tool_calls" || reason === "length" || reason === "stop") {
84
+ return reason;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * The frame for an upstream request that never got a response: `done: cancelled` when it was
91
+ * aborted, otherwise an `error` frame carrying an `upstreamFailure` problem.
92
+ *
93
+ * The frame carries a problem rather than being one: the response began with a 200 long before this
94
+ * failed, so nothing can change the status now. `message` stays for the readers that already show
95
+ * it (server.md §4.3).
96
+ *
97
+ * @param {unknown} error - What `fetch` rejected with
98
+ * @returns {StreamEvent}
99
+ */
100
+ export function fetchFailureFrame(error: unknown): StreamEvent {
101
+ if ((error as Error).name === "AbortError") {
102
+ return { type: "done", stopReason: "cancelled" };
103
+ }
104
+ const message = `Network error: ${(error as Error).message}`;
105
+ return { message, problem: problemDetails("upstreamFailure", message), type: "error" };
106
+ }
107
+
108
+ /**
109
+ * Normalize an OpenAI-compatible chat-completions response into the chat route's frames.
110
+ *
111
+ * - A non-2xx response is one `error` frame: the sentence inside the provider's body as `message`,
112
+ * the upstream's own status as `code`, and a problem naming the kind of failure.
113
+ * - Text is `delta`; chain-of-thought (`reasoning_content`, or `reasoning` at OpenRouter) is
114
+ * `reasoning`, forwarded rather than dropped, because DeepSeek's thinking mode requires every
115
+ * prior turn's reasoning back on any request carrying `tools`.
116
+ * - A tool call is `tool_call_start`, then `tool_call_delta` per fragment, then `tool_call_end`.
117
+ * - The finish reason is REMEMBERED, not acted on: with `include_usage` the count arrives in a chunk
118
+ * of its own after the finish, so the stream ends at `[DONE]` or when the body closes. The last
119
+ * frames are the open calls' ends, the `usage` count when there is one, then `done`.
120
+ * - An aborted read is `done: cancelled`; any other failed read is an `error` frame.
121
+ * - A consumer that stops reading early cancels the upstream body.
122
+ *
123
+ * @param {Response} response - The upstream's response to the chat-completions request
124
+ * @yields {StreamEvent} The route's frames, in wire order
125
+ */
126
+ export async function* normalizeOpenAIStream(response: Response): AsyncGenerator<StreamEvent> {
127
+ if (!response.ok) {
128
+ let errorBody = "";
129
+ try {
130
+ errorBody = await response.text();
131
+ } catch {
132
+ /* Ignore */
133
+ }
134
+ const cleanMessage = extractUpstreamErrorMessage(errorBody, response.statusText);
135
+ yield {
136
+ code: String(response.status),
137
+ message: cleanMessage,
138
+ // The upstream's own status is preserved in `code`; the problem names the KIND.
139
+ problem: problemDetails(problemTypeForStatus(response.status), cleanMessage),
140
+ type: "error",
141
+ };
142
+ return;
143
+ }
144
+
145
+ const reader = response.body?.getReader();
146
+ if (!reader) {
147
+ yield {
148
+ message: "No response body from upstream",
149
+ problem: problemDetails("upstreamFailure", "No response body from upstream"),
150
+ type: "error",
151
+ };
152
+ return;
153
+ }
154
+
155
+ const decoder = new TextDecoder();
156
+ let buffer = "";
157
+
158
+ const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
159
+ let stopReason: string | null = null;
160
+ let usage: StreamUsageEvent | null = null;
161
+
162
+ /**
163
+ * Close every open call.
164
+ *
165
+ * @yields {StreamEvent} A `tool_call_end` per open call
166
+ */
167
+ function* closePendingToolCalls(): Generator<StreamEvent> {
168
+ for (const [, tc] of pendingToolCalls) {
169
+ yield { type: "tool_call_end", id: tc.id };
170
+ }
171
+ pendingToolCalls.clear();
172
+ }
173
+
174
+ /**
175
+ * The stream's last frames: open calls, the count when there is one, then `done`. The count comes
176
+ * BEFORE `done`, because a reader stops at `done`.
177
+ *
178
+ * @yields {StreamEvent} The closing frames, in order
179
+ */
180
+ function* finish(): Generator<StreamEvent> {
181
+ yield* closePendingToolCalls();
182
+ if (usage) {
183
+ yield usage;
184
+ }
185
+ yield { type: "done", stopReason: stopReason ?? "stop" };
186
+ }
187
+
188
+ /* Whether the loop ended on its own terms. A consumer that stops reading ends a generator through
189
+ `return()`, which runs `finally` and nothing else; when the server wrote each frame itself, that
190
+ exit was the next write throwing inside this `try`, so the `catch` cancelled the upstream body.
191
+ `finally` keeps that: the upstream must not outlive the reader it streams to. */
192
+ let settled = false;
193
+ try {
194
+ while (true) {
195
+ const { done, value } = await reader.read();
196
+ if (done) {
197
+ break;
198
+ }
199
+
200
+ buffer += decoder.decode(value, { stream: true });
201
+ const lines = buffer.split("\n");
202
+ buffer = lines.pop() || "";
203
+
204
+ for (const line of lines) {
205
+ const trimmed = line.trim();
206
+ if (!trimmed || !trimmed.startsWith("data: ")) {
207
+ continue;
208
+ }
209
+
210
+ const dataStr = trimmed.slice(6);
211
+ if (dataStr === "[DONE]") {
212
+ yield* finish();
213
+ settled = true;
214
+ return;
215
+ }
216
+
217
+ let parsed: OpenAIStreamChunk;
218
+ try {
219
+ parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
220
+ } catch {
221
+ continue;
222
+ }
223
+
224
+ // Usually a final chunk with no choices, but a provider may put it beside the finish.
225
+ usage = usageFrame(parsed.usage) ?? usage;
226
+
227
+ const choice = parsed.choices?.[0];
228
+ if (!choice) {
229
+ continue;
230
+ }
231
+
232
+ const { delta } = choice;
233
+
234
+ // Text content
235
+ if (delta?.content) {
236
+ yield { type: "delta", content: delta.content };
237
+ }
238
+
239
+ /* Chain-of-thought, under either of the two names providers give it. Forwarded rather than
240
+ dropped: DeepSeek's thinking mode requires every prior turn's reasoning back on any
241
+ request carrying `tools`, so a proxy that swallows these frames makes the NEXT round a
242
+ 400 the client cannot repair. */
243
+ const reasoning = delta?.reasoning_content ?? delta?.reasoning;
244
+ if (typeof reasoning === "string" && reasoning) {
245
+ yield { type: "reasoning", content: reasoning };
246
+ }
247
+
248
+ // Tool calls
249
+ if (delta?.tool_calls) {
250
+ for (const tc of delta.tool_calls) {
251
+ if (tc.id) {
252
+ // First appearance
253
+ const entry = {
254
+ id: tc.id,
255
+ name: tc.function?.name || "",
256
+ args: tc.function?.arguments || "",
257
+ };
258
+ pendingToolCalls.set(tc.index, entry);
259
+
260
+ yield { type: "tool_call_start", id: tc.id, name: entry.name };
261
+
262
+ if (entry.args) {
263
+ yield { type: "tool_call_delta", id: tc.id, args: entry.args };
264
+ }
265
+ } else if (tc.function?.arguments) {
266
+ // Subsequent fragment
267
+ const existing = pendingToolCalls.get(tc.index);
268
+ if (existing) {
269
+ existing.args += tc.function.arguments;
270
+ yield { type: "tool_call_delta", id: existing.id, args: tc.function.arguments };
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // Finish reason: close the calls now, report the reason at the end of the stream.
277
+ const finished = stopReasonOf(choice.finish_reason);
278
+ if (finished) {
279
+ yield* closePendingToolCalls();
280
+ stopReason = finished;
281
+ }
282
+ }
283
+ }
284
+
285
+ // Stream ended without `[DONE]`
286
+ yield* finish();
287
+ settled = true;
288
+ } catch (error) {
289
+ settled = true;
290
+ void reader.cancel();
291
+ if ((error as Error).name === "AbortError") {
292
+ yield { type: "done", stopReason: "cancelled" };
293
+ return;
294
+ }
295
+ const message = `Stream error: ${(error as Error).message}`;
296
+ yield { message, problem: problemDetails("upstreamFailure", message), type: "error" };
297
+ } finally {
298
+ if (!settled) {
299
+ void reader.cancel();
300
+ }
301
+ }
302
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * How the gateway says no: an RFC 9457 problem response, and the problem type an upstream's status
3
+ * maps onto.
4
+ *
5
+ * @module @jxsuite/ai/gateway
6
+ */
7
+
8
+ import { PROBLEM_MEDIA_TYPE } from "@jxsuite/protocol/problem";
9
+ import { problemDetails } from "@jxsuite/protocol/problems";
10
+ import type { ProblemTypeName } from "@jxsuite/protocol/problems";
11
+ import type { GatewayRefusal } from "./types.ts";
12
+
13
+ /**
14
+ * A refusal of one declared problem type, answered with that type's own status.
15
+ *
16
+ * @param {ProblemTypeName} name - A declared type from `PROBLEM_TYPES`
17
+ * @param {string} detail - What happened this time; the line a human reads
18
+ * @returns {GatewayRefusal}
19
+ */
20
+ export function refusal(name: ProblemTypeName, detail: string): GatewayRefusal {
21
+ const problem = problemDetails(name, detail);
22
+ return { problem, status: problem.status };
23
+ }
24
+
25
+ /**
26
+ * The response a refusal is answered with: `application/problem+json` at the refusal's status.
27
+ *
28
+ * One body satisfies both kinds of client. A problem reader keys on `type` and shows `detail`; an
29
+ * older reader shows `error` and acts on `code`. So `error` is present whenever `detail` is (it
30
+ * already is on a problem built by `problemDetails`, and is added here to one built by hand), and
31
+ * the refusal's `code`, when it has one, rides beside them.
32
+ *
33
+ * @param {GatewayRefusal} refused - What was refused, and why
34
+ * @returns {Response}
35
+ */
36
+ export function problemResponse(refused: GatewayRefusal): Response {
37
+ const { code, problem, status } = refused;
38
+ const body = {
39
+ ...problem,
40
+ ...(problem.error === undefined && problem.detail !== undefined
41
+ ? { error: problem.detail }
42
+ : {}),
43
+ ...(code === undefined ? {} : { code }),
44
+ };
45
+ // Response.json would send `application/json`; RFC 9457 §3 is what says this body is a problem.
46
+ return Response.json(body, { headers: { "Content-Type": PROBLEM_MEDIA_TYPE }, status });
47
+ }
48
+
49
+ /**
50
+ * The declared type for an upstream provider's status.
51
+ *
52
+ * The normalizer needs it for the one failure that arrives with a status of its own: an upstream
53
+ * that answered non-2xx. The status itself is kept (as the frame's `code`), and the problem names
54
+ * the KIND of failure, which is the part a client can act on. `@jxsuite/server`'s `problem.ts`
55
+ * carries the same table for its own routes; `packages/server/tests/ai-api-gateway.test.ts` holds
56
+ * the two to agreement.
57
+ *
58
+ * @param {number} status - The upstream's HTTP status
59
+ * @returns {ProblemTypeName}
60
+ */
61
+ export function problemTypeForStatus(status: number): ProblemTypeName {
62
+ /* Any other status still becomes SOME type, and which side of 500 it falls on is what decides
63
+ whether the caller is being told it made a mistake or that the upstream did. */
64
+ return STATUS_TYPES[status] ?? (status >= 500 ? "internalError" : "invalidRequest");
65
+ }
66
+
67
+ /** The statuses the table distinguishes, each onto a type answered with that same status. */
68
+ const STATUS_TYPES: Readonly<Record<number, ProblemTypeName>> = {
69
+ 400: "invalidRequest",
70
+ 401: "unauthorized",
71
+ 403: "forbidden",
72
+ 404: "notFound",
73
+ 405: "methodNotAllowed",
74
+ 409: "conflict",
75
+ 413: "payloadTooLarge",
76
+ 501: "capabilityUnavailable",
77
+ 502: "upstreamFailure",
78
+ 503: "upstreamFailure",
79
+ 504: "upstreamFailure",
80
+ };
@@ -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,80 @@
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
+
32
+ /**
33
+ * A request the gateway (or the host's policy) declined, answered as `application/problem+json`
34
+ * before any upstream is contacted.
35
+ */
36
+ export interface GatewayRefusal {
37
+ /** The HTTP status of the refusal. */
38
+ readonly status: number;
39
+ /** The RFC 9457 problem document the body carries. */
40
+ readonly problem: ProblemDetails;
41
+ /** A machine code for clients that key on `code` rather than on the problem type. */
42
+ readonly code?: string;
43
+ }
44
+
45
+ /** What a request the gateway accepted asked for, reported to `onAccepted` for audit. */
46
+ export interface ChatAdmission {
47
+ /** The wire version of the accepted body. Version 1 is the only one. */
48
+ readonly wire: 1;
49
+ /** The model the request is forwarded with: the body's, or the upstream's default. */
50
+ readonly model: string;
51
+ /** How many messages the body carried, not counting the system prompt the gateway prepends. */
52
+ readonly messageCount: number;
53
+ /** The body's size in bytes, as read rather than as declared. */
54
+ readonly bytes: number;
55
+ }
56
+
57
+ /** A host's half of the chat route. Only `resolveUpstream` is required. */
58
+ export interface ChatGatewayOptions<C> {
59
+ /** Runs first, before the body is read (a rate limit, say). A refusal ends the request. */
60
+ readonly admit?: (
61
+ request: Request,
62
+ context: C,
63
+ ) => Promise<GatewayRefusal | null> | GatewayRefusal | null;
64
+ /**
65
+ * Where the request goes, or why it goes nowhere. This is the host's policy: key provenance and
66
+ * any guard on a caller-supplied base URL belong here, not in the gateway.
67
+ */
68
+ readonly resolveUpstream: (
69
+ request: Request,
70
+ context: C,
71
+ ) => Promise<Upstream | GatewayRefusal> | Upstream | GatewayRefusal;
72
+ /** The largest body accepted, in bytes actually read. Reading stops once it is passed. */
73
+ readonly maxBodyBytes?: number;
74
+ /** The most messages one request may carry. */
75
+ readonly maxMessages?: number;
76
+ /** Called once per accepted request, after every check and before the upstream is contacted. */
77
+ readonly onAccepted?: (admission: ChatAdmission, upstream: Upstream, context: C) => void;
78
+ /** The fetch the upstream is called with. The global `fetch`, looked up per call, when absent. */
79
+ readonly fetch?: typeof fetch;
80
+ }
@@ -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
@@ -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,
@@ -33,4 +35,5 @@ export type {
33
35
  StreamToolCallDeltaEvent,
34
36
  StreamToolCallEndEvent,
35
37
  StreamToolCallStartEvent,
38
+ StreamUsageEvent,
36
39
  } from "./streaming-client.js";