@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.
- package/README.md +46 -13
- package/package.json +5 -3
- package/src/chat-state.ts +100 -10
- package/src/core-types.ts +97 -0
- package/src/gateway/chat.ts +217 -0
- package/src/gateway/index.ts +26 -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 +80 -0
- package/src/gateway/upstream-error.ts +54 -0
- package/src/index.ts +3 -0
- package/src/streaming-client.ts +144 -45
- package/src/tools.ts +153 -14
|
@@ -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";
|