@jxsuite/server 4.2.2 → 4.4.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/package.json +16 -13
- package/src/ai-api.ts +95 -394
- package/src/problem.ts +6 -3
- package/src/studio-api.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./packages": "./src/packages.ts",
|
|
23
23
|
"./project-server": "./src/project-server.ts",
|
|
24
24
|
"./oauth-loopback": "./src/oauth-loopback.ts",
|
|
25
|
+
"./net-guard": "./src/net-guard.ts",
|
|
25
26
|
"./live-preview": "./src/live-preview.ts",
|
|
26
27
|
"./site-preview": "./src/site-preview.ts",
|
|
27
28
|
"./refactor": "./src/refactor/index.ts",
|
|
@@ -40,23 +41,25 @@
|
|
|
40
41
|
"upgrade": "bun update --latest && bun install"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
44
|
+
"@jxsuite/ai": "^0.39.0",
|
|
43
45
|
"@jxsuite/catalog": "^0.1.0",
|
|
44
|
-
"@jxsuite/collab": "^0.
|
|
45
|
-
"@jxsuite/compiler": "^4.0.
|
|
46
|
-
"@jxsuite/create": "^1.3.
|
|
47
|
-
"@jxsuite/import": "^0.40.
|
|
48
|
-
"@jxsuite/protocol": "^2.
|
|
49
|
-
"@jxsuite/runtime": "^4.0.
|
|
50
|
-
"@jxsuite/schema": "^2.
|
|
51
|
-
"@jxsuite/site": "^2.0.
|
|
52
|
-
"@jxsuite/starters": "^1.8.
|
|
46
|
+
"@jxsuite/collab": "^0.10.0",
|
|
47
|
+
"@jxsuite/compiler": "^4.0.2",
|
|
48
|
+
"@jxsuite/create": "^1.3.12",
|
|
49
|
+
"@jxsuite/import": "^0.40.4",
|
|
50
|
+
"@jxsuite/protocol": "^2.5.0",
|
|
51
|
+
"@jxsuite/runtime": "^4.0.2",
|
|
52
|
+
"@jxsuite/schema": "^2.3.0",
|
|
53
|
+
"@jxsuite/site": "^2.0.2",
|
|
54
|
+
"@jxsuite/starters": "^1.8.2",
|
|
53
55
|
"chokidar": "^5.0.0",
|
|
54
56
|
"kysely": "^0.29.6",
|
|
57
|
+
"oxfmt": "^0.70.0",
|
|
55
58
|
"zod": "^4.6.5"
|
|
56
59
|
},
|
|
57
60
|
"devDependencies": {
|
|
58
|
-
"@jxsuite/auth": "^0.6.
|
|
59
|
-
"@jxsuite/connector": "^0.6.
|
|
60
|
-
"@jxsuite/parser": "^1.8.
|
|
61
|
+
"@jxsuite/auth": "^0.6.1",
|
|
62
|
+
"@jxsuite/connector": "^0.6.1",
|
|
63
|
+
"@jxsuite/parser": "^1.8.2"
|
|
61
64
|
}
|
|
62
65
|
}
|
package/src/ai-api.ts
CHANGED
|
@@ -1,54 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ai-api.js — AI proxy endpoints for Jx Studio
|
|
3
3
|
*
|
|
4
|
-
* Handles /__studio/ai/chat (SSE streaming proxy to OpenAI) and /__studio/ai/models.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Handles /__studio/ai/chat (SSE streaming proxy to OpenAI) and /__studio/ai/models. The server
|
|
5
|
+
* acts as a thin proxy: validates the request shape, forwards to OpenAI, normalizes the SSE stream
|
|
6
|
+
* into StreamEvent-compatible format, and pipes back.
|
|
7
|
+
*
|
|
8
|
+
* The wire half of that (reading the body, the upstream request, the stream normalizer, the SSE
|
|
9
|
+
* framing, the problem responses) is `@jxsuite/ai/gateway`, which every backend serving these
|
|
10
|
+
* routes can share. What stays here is this server's POLICY, which no other host shares:
|
|
7
11
|
*
|
|
8
12
|
* API key flow:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
*
|
|
14
|
+
* 1. Request header X-Api-Key or Authorization: Bearer <key>
|
|
15
|
+
* 2. Fallback: OPENAI_API_KEY env var — attached ONLY to the env/default base URL, never to a
|
|
16
|
+
* caller-supplied X-Api-Base-URL (prevents exfiltrating the server key to a chosen endpoint)
|
|
17
|
+
* 3. If neither → 401 with error message
|
|
13
18
|
*
|
|
14
19
|
* Base URL flow:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
*
|
|
21
|
+
* 1. Request header X-Api-Base-URL — allowed only alongside a header API key
|
|
22
|
+
* 2. Fallback: OPENAI_BASE_URL env var
|
|
23
|
+
* 3. Default: https://api.openai.com/v1
|
|
24
|
+
*
|
|
25
|
+
* A base URL resolving to a cloud metadata / link-local host is refused (SSRF defense).
|
|
26
|
+
*
|
|
27
|
+
* Prompt-cache affinity: a request carrying a valid X-Jx-Ai-Session header (the client's
|
|
28
|
+
* conversation id) is forwarded with `sessionAffinity` set to a hash of it, scoped to this server
|
|
29
|
+
* ("local"). The gateway sends that hash only to an upstream that routes on it (OpenAI, Workers
|
|
30
|
+
* AI); the raw id never leaves this process. An absent or malformed header just means no hint: the
|
|
31
|
+
* request is still served.
|
|
32
|
+
*
|
|
33
|
+
* The model catalogue for /models is this server's too: its defaults, and how it reads the
|
|
34
|
+
* upstream's own listing.
|
|
19
35
|
*
|
|
20
36
|
* @license MIT
|
|
21
37
|
*/
|
|
22
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
AI_SESSION_HEADER,
|
|
40
|
+
affinityKey,
|
|
41
|
+
createChatHandler,
|
|
42
|
+
extractUpstreamErrorMessage,
|
|
43
|
+
isAiSessionId,
|
|
44
|
+
modelsResponse,
|
|
45
|
+
problemResponse,
|
|
46
|
+
} from "@jxsuite/ai/gateway";
|
|
47
|
+
import type { GatewayRefusal, Upstream } from "@jxsuite/ai/gateway";
|
|
23
48
|
import { problemDetails } from "@jxsuite/protocol";
|
|
49
|
+
import { problemTypeForStatus } from "./problem.ts";
|
|
24
50
|
|
|
25
51
|
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
26
52
|
|
|
27
53
|
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
28
54
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
index: number;
|
|
32
|
-
id?: string;
|
|
33
|
-
type?: string;
|
|
34
|
-
function?: { name?: string; arguments?: string };
|
|
35
|
-
}
|
|
55
|
+
/** The model a chat request that names none is forwarded with. */
|
|
56
|
+
const DEFAULT_MODEL = "gpt-4o";
|
|
36
57
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
content?: string;
|
|
43
|
-
/** Thinking models' chain-of-thought: DeepSeek/Volcengine spell it this way… */
|
|
44
|
-
reasoning_content?: string;
|
|
45
|
-
/** …and OpenRouter this way. Both carry the same thing. */
|
|
46
|
-
reasoning?: string;
|
|
47
|
-
tool_calls?: ToolCallDelta[];
|
|
48
|
-
};
|
|
49
|
-
finish_reason?: string | null;
|
|
50
|
-
}[];
|
|
51
|
-
}
|
|
58
|
+
/**
|
|
59
|
+
* This server's namespace for affinity keys. Every dev/desktop server shares it: their keys never
|
|
60
|
+
* meet a hosted backend's, because that one hashes under its own scope.
|
|
61
|
+
*/
|
|
62
|
+
const AFFINITY_SCOPE = "local";
|
|
52
63
|
|
|
53
64
|
/** A model entry from the upstream `/models` listing. */
|
|
54
65
|
interface ModelEntry {
|
|
@@ -144,347 +155,57 @@ function getConfig(req: Request): AiConfig {
|
|
|
144
155
|
|
|
145
156
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
146
157
|
|
|
147
|
-
/** Write an SSE event to the response stream. */
|
|
148
|
-
function writeSSE(controller: ReadableStreamDefaultController, event: unknown): void {
|
|
149
|
-
const data = JSON.stringify(event);
|
|
150
|
-
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
158
|
/**
|
|
154
|
-
*
|
|
155
|
-
* `fallback` when the body is empty).
|
|
159
|
+
* A refusal at a status this file chose.
|
|
156
160
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* key at all. Without this branch that shape parses as valid JSON with nothing matched, and the
|
|
161
|
-
* caller was left showing the whole raw body (e.g. the full `{"success":false,"errors":[...]}`
|
|
162
|
-
* blob) instead of the human sentence inside it.
|
|
161
|
+
* The status picks the type rather than the other way round, because this file's refusals were
|
|
162
|
+
* always written as statuses (`reject.status`), and the response carries the TYPE's status, exactly
|
|
163
|
+
* as `problem()` answers.
|
|
163
164
|
*/
|
|
164
|
-
function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
try {
|
|
169
|
-
const { error, errors } = JSON.parse(rawBody) as {
|
|
170
|
-
error?: string | { message?: string };
|
|
171
|
-
errors?: { code?: number; message?: string }[];
|
|
172
|
-
};
|
|
173
|
-
if (typeof error === "string") {
|
|
174
|
-
return error;
|
|
175
|
-
}
|
|
176
|
-
if (error) {
|
|
177
|
-
const { message } = error;
|
|
178
|
-
if (message) {
|
|
179
|
-
return message;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
const [first] = errors ?? [];
|
|
183
|
-
if (first) {
|
|
184
|
-
const { message } = first;
|
|
185
|
-
if (message) {
|
|
186
|
-
return message;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
} catch {
|
|
190
|
-
/* Not JSON — use the raw body. */
|
|
191
|
-
}
|
|
192
|
-
return rawBody;
|
|
165
|
+
function refuse(status: number, message: string): GatewayRefusal {
|
|
166
|
+
const problem = problemDetails(problemTypeForStatus(status), message);
|
|
167
|
+
return { problem, status: problem.status };
|
|
193
168
|
}
|
|
194
169
|
|
|
195
170
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* forward an upstream provider's status — which is information, and collapsing it to the type's own
|
|
200
|
-
* would throw it away.
|
|
171
|
+
* This server's upstream for a request, or its refusal: the key-provenance and SSRF rules above,
|
|
172
|
+
* applied per request (the environment is read each time, so a key set later is honoured). A valid
|
|
173
|
+
* session header adds the hashed affinity key; an invalid one is ignored rather than refused.
|
|
201
174
|
*/
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
|
|
207
|
-
|
|
208
|
-
/** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
|
|
209
|
-
export async function handleChat(req: Request): Promise<Response> {
|
|
210
|
-
const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
|
|
175
|
+
async function resolveUpstream(req: Request): Promise<Upstream | GatewayRefusal> {
|
|
176
|
+
const { apiKey, baseUrl, reject } = getConfig(req);
|
|
211
177
|
if (reject) {
|
|
212
|
-
return
|
|
178
|
+
return refuse(reject.status, reject.message);
|
|
213
179
|
}
|
|
214
|
-
if (
|
|
215
|
-
return
|
|
180
|
+
if (!apiKey) {
|
|
181
|
+
return refuse(
|
|
216
182
|
401,
|
|
217
183
|
"No API key configured. Set OPENAI_API_KEY env var or send X-Api-Key header.",
|
|
218
184
|
);
|
|
219
185
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
model?: string;
|
|
186
|
+
const upstream: Upstream = {
|
|
187
|
+
apiKey,
|
|
188
|
+
baseUrl,
|
|
189
|
+
defaultModel: DEFAULT_MODEL,
|
|
190
|
+
family: "openai-compat",
|
|
191
|
+
managed: false,
|
|
227
192
|
};
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
tools?: unknown[];
|
|
232
|
-
systemPrompt?: string;
|
|
233
|
-
model?: string;
|
|
234
|
-
};
|
|
235
|
-
} catch {
|
|
236
|
-
return jsonError(400, "Invalid JSON body");
|
|
193
|
+
const sessionId = req.headers.get(AI_SESSION_HEADER);
|
|
194
|
+
if (isAiSessionId(sessionId)) {
|
|
195
|
+
return { ...upstream, sessionAffinity: await affinityKey(AFFINITY_SCOPE, sessionId) };
|
|
237
196
|
}
|
|
197
|
+
return upstream;
|
|
198
|
+
}
|
|
238
199
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (!Array.isArray(messages)) {
|
|
242
|
-
return jsonError(400, "messages must be an array");
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Build OpenAI request
|
|
246
|
-
const openaiBody: {
|
|
247
|
-
model: string;
|
|
248
|
-
messages: unknown[];
|
|
249
|
-
stream: boolean;
|
|
250
|
-
stream_options: { include_usage: boolean };
|
|
251
|
-
tools?: unknown[];
|
|
252
|
-
tool_choice?: string;
|
|
253
|
-
parallel_tool_calls?: boolean;
|
|
254
|
-
} = {
|
|
255
|
-
model,
|
|
256
|
-
messages: [{ role: "system", content: systemPrompt }, ...messages],
|
|
257
|
-
stream: true,
|
|
258
|
-
stream_options: { include_usage: true },
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
if (tools && tools.length > 0) {
|
|
262
|
-
openaiBody.tools = tools;
|
|
263
|
-
openaiBody.tool_choice = "auto";
|
|
264
|
-
openaiBody.parallel_tool_calls = true;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const upstreamUrl = `${baseUrl}/chat/completions`;
|
|
268
|
-
|
|
269
|
-
// Create the SSE stream
|
|
270
|
-
const stream = new ReadableStream({
|
|
271
|
-
async start(controller) {
|
|
272
|
-
let response;
|
|
273
|
-
try {
|
|
274
|
-
response = await fetch(upstreamUrl, {
|
|
275
|
-
method: "POST",
|
|
276
|
-
headers: {
|
|
277
|
-
"Content-Type": "application/json",
|
|
278
|
-
Authorization: `Bearer ${apiKey}`,
|
|
279
|
-
},
|
|
280
|
-
body: JSON.stringify(openaiBody),
|
|
281
|
-
signal: req.signal,
|
|
282
|
-
});
|
|
283
|
-
} catch (error) {
|
|
284
|
-
if ((error as Error).name === "AbortError") {
|
|
285
|
-
writeSSE(controller, { type: "done", stopReason: "cancelled" });
|
|
286
|
-
} else {
|
|
287
|
-
const message = `Network error: ${(error as Error).message}`;
|
|
288
|
-
writeSSE(controller, {
|
|
289
|
-
message,
|
|
290
|
-
/*
|
|
291
|
-
* The frame carries a problem rather than being one: the response began with a 200
|
|
292
|
-
* long before this failed, so nothing can change the status now. `message` stays for
|
|
293
|
-
* the readers that already show it (server.md §4.3).
|
|
294
|
-
*/
|
|
295
|
-
problem: problemDetails("upstreamFailure", message),
|
|
296
|
-
type: "error",
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
controller.close();
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (!response.ok) {
|
|
304
|
-
let errorBody = "";
|
|
305
|
-
try {
|
|
306
|
-
errorBody = await response.text();
|
|
307
|
-
} catch {
|
|
308
|
-
/* Ignore */
|
|
309
|
-
}
|
|
310
|
-
const cleanMessage = extractUpstreamErrorMessage(errorBody, response.statusText);
|
|
311
|
-
writeSSE(controller, {
|
|
312
|
-
code: String(response.status),
|
|
313
|
-
message: cleanMessage,
|
|
314
|
-
// The upstream's own status is preserved in `code`; the problem names the KIND.
|
|
315
|
-
problem: problemDetails(problemTypeForStatus(response.status), cleanMessage),
|
|
316
|
-
type: "error",
|
|
317
|
-
});
|
|
318
|
-
controller.close();
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const reader = response.body?.getReader();
|
|
323
|
-
if (!reader) {
|
|
324
|
-
writeSSE(controller, {
|
|
325
|
-
message: "No response body from upstream",
|
|
326
|
-
problem: problemDetails("upstreamFailure", "No response body from upstream"),
|
|
327
|
-
type: "error",
|
|
328
|
-
});
|
|
329
|
-
controller.close();
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const decoder = new TextDecoder();
|
|
334
|
-
let buffer = "";
|
|
200
|
+
// ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
|
|
335
201
|
|
|
336
|
-
|
|
202
|
+
const chatHandler = createChatHandler<{ readonly signal: AbortSignal }>({
|
|
203
|
+
resolveUpstream: (request) => resolveUpstream(request),
|
|
204
|
+
});
|
|
337
205
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (done) {
|
|
342
|
-
break;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
buffer += decoder.decode(value, { stream: true });
|
|
346
|
-
const lines = buffer.split("\n");
|
|
347
|
-
buffer = lines.pop() || "";
|
|
348
|
-
|
|
349
|
-
for (const line of lines) {
|
|
350
|
-
const trimmed = line.trim();
|
|
351
|
-
if (!trimmed || !trimmed.startsWith("data: ")) {
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const dataStr = trimmed.slice(6);
|
|
356
|
-
if (dataStr === "[DONE]") {
|
|
357
|
-
// Emit pending tool call ends
|
|
358
|
-
for (const [, tc] of pendingToolCalls) {
|
|
359
|
-
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
360
|
-
}
|
|
361
|
-
pendingToolCalls.clear();
|
|
362
|
-
writeSSE(controller, { type: "done", stopReason: "stop" });
|
|
363
|
-
controller.close();
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
let parsed: OpenAIStreamChunk;
|
|
368
|
-
try {
|
|
369
|
-
parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
|
|
370
|
-
} catch {
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const choice = parsed.choices?.[0];
|
|
375
|
-
if (!choice) {
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const { delta } = choice;
|
|
380
|
-
if (!delta) {
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Text content
|
|
385
|
-
if (delta.content) {
|
|
386
|
-
writeSSE(controller, { type: "delta", content: delta.content });
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/* Chain-of-thought, under either of the two names providers give it. Forwarded rather
|
|
390
|
-
than dropped: DeepSeek's thinking mode requires every prior turn's reasoning back on
|
|
391
|
-
any request carrying `tools`, so a proxy that swallows these frames makes the NEXT
|
|
392
|
-
round a 400 the client cannot repair. */
|
|
393
|
-
const reasoning = delta.reasoning_content ?? delta.reasoning;
|
|
394
|
-
if (typeof reasoning === "string" && reasoning) {
|
|
395
|
-
writeSSE(controller, { type: "reasoning", content: reasoning });
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Tool calls
|
|
399
|
-
if (delta.tool_calls) {
|
|
400
|
-
for (const tc of delta.tool_calls) {
|
|
401
|
-
if (tc.id) {
|
|
402
|
-
// First appearance
|
|
403
|
-
const entry = {
|
|
404
|
-
id: tc.id,
|
|
405
|
-
name: tc.function?.name || "",
|
|
406
|
-
args: tc.function?.arguments || "",
|
|
407
|
-
};
|
|
408
|
-
pendingToolCalls.set(tc.index, entry);
|
|
409
|
-
|
|
410
|
-
writeSSE(controller, { type: "tool_call_start", id: tc.id, name: entry.name });
|
|
411
|
-
|
|
412
|
-
if (entry.args) {
|
|
413
|
-
writeSSE(controller, { type: "tool_call_delta", id: tc.id, args: entry.args });
|
|
414
|
-
}
|
|
415
|
-
} else if (tc.function?.arguments) {
|
|
416
|
-
// Subsequent fragment
|
|
417
|
-
const existing = pendingToolCalls.get(tc.index);
|
|
418
|
-
if (existing) {
|
|
419
|
-
existing.args += tc.function.arguments;
|
|
420
|
-
writeSSE(controller, {
|
|
421
|
-
type: "tool_call_delta",
|
|
422
|
-
id: existing.id,
|
|
423
|
-
args: tc.function.arguments,
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Finish reason
|
|
431
|
-
if (choice.finish_reason === "tool_calls") {
|
|
432
|
-
for (const [, tc] of pendingToolCalls) {
|
|
433
|
-
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
434
|
-
}
|
|
435
|
-
pendingToolCalls.clear();
|
|
436
|
-
writeSSE(controller, { type: "done", stopReason: "tool_calls" });
|
|
437
|
-
controller.close();
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
|
|
442
|
-
for (const [, tc] of pendingToolCalls) {
|
|
443
|
-
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
444
|
-
}
|
|
445
|
-
pendingToolCalls.clear();
|
|
446
|
-
writeSSE(controller, {
|
|
447
|
-
type: "done",
|
|
448
|
-
stopReason: choice.finish_reason === "length" ? "length" : "stop",
|
|
449
|
-
});
|
|
450
|
-
controller.close();
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Stream ended without explicit finish_reason
|
|
457
|
-
for (const [, tc] of pendingToolCalls) {
|
|
458
|
-
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
459
|
-
}
|
|
460
|
-
pendingToolCalls.clear();
|
|
461
|
-
writeSSE(controller, { type: "done", stopReason: "stop" });
|
|
462
|
-
controller.close();
|
|
463
|
-
} catch (error) {
|
|
464
|
-
void reader.cancel();
|
|
465
|
-
if ((error as Error).name === "AbortError") {
|
|
466
|
-
writeSSE(controller, { type: "done", stopReason: "cancelled" });
|
|
467
|
-
controller.close();
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
const message = `Stream error: ${(error as Error).message}`;
|
|
471
|
-
writeSSE(controller, {
|
|
472
|
-
message,
|
|
473
|
-
problem: problemDetails("upstreamFailure", message),
|
|
474
|
-
type: "error",
|
|
475
|
-
});
|
|
476
|
-
controller.close();
|
|
477
|
-
}
|
|
478
|
-
},
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
return new Response(stream, {
|
|
482
|
-
headers: {
|
|
483
|
-
"Content-Type": "text/event-stream",
|
|
484
|
-
"Cache-Control": "no-cache",
|
|
485
|
-
Connection: "keep-alive",
|
|
486
|
-
},
|
|
487
|
-
});
|
|
206
|
+
/** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
|
|
207
|
+
export async function handleChat(req: Request): Promise<Response> {
|
|
208
|
+
return chatHandler(req, { signal: req.signal });
|
|
488
209
|
}
|
|
489
210
|
|
|
490
211
|
// ─── /__studio/ai/models — model listing ────────────────────────────────────
|
|
@@ -500,7 +221,7 @@ export async function handleChat(req: Request): Promise<Response> {
|
|
|
500
221
|
export async function handleModels(req: Request): Promise<Response> {
|
|
501
222
|
const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
|
|
502
223
|
if (reject) {
|
|
503
|
-
return
|
|
224
|
+
return problemResponse(refuse(reject.status, reject.message));
|
|
504
225
|
}
|
|
505
226
|
|
|
506
227
|
// No key available → return hardcoded defaults so the UI can at least render.
|
|
@@ -511,12 +232,7 @@ export async function handleModels(req: Request): Promise<Response> {
|
|
|
511
232
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextWindow: 1_000_000 },
|
|
512
233
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
|
|
513
234
|
];
|
|
514
|
-
return
|
|
515
|
-
{ models: defaults, configured: false, managed: false },
|
|
516
|
-
{
|
|
517
|
-
headers: { "Content-Type": "application/json" },
|
|
518
|
-
},
|
|
519
|
-
);
|
|
235
|
+
return modelsResponse({ models: defaults, configured: false, managed: false });
|
|
520
236
|
}
|
|
521
237
|
|
|
522
238
|
// Key is available — proxy to the upstream /models endpoint.
|
|
@@ -536,18 +252,13 @@ export async function handleModels(req: Request): Promise<Response> {
|
|
|
536
252
|
}
|
|
537
253
|
const upstreamMessage = extractUpstreamErrorMessage(errorBody, upstreamResp.statusText);
|
|
538
254
|
const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
|
|
539
|
-
return
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
},
|
|
547
|
-
{
|
|
548
|
-
headers: { "Content-Type": "application/json" },
|
|
549
|
-
},
|
|
550
|
-
);
|
|
255
|
+
return modelsResponse({
|
|
256
|
+
models: defaults,
|
|
257
|
+
configured: true,
|
|
258
|
+
managed: false,
|
|
259
|
+
upstreamError: upstreamResp.status,
|
|
260
|
+
upstreamMessage,
|
|
261
|
+
});
|
|
551
262
|
}
|
|
552
263
|
|
|
553
264
|
const data = (await upstreamResp.json()) as { data?: ModelEntry[] } | ModelEntry[];
|
|
@@ -565,27 +276,17 @@ export async function handleModels(req: Request): Promise<Response> {
|
|
|
565
276
|
ownedBy: owned_by,
|
|
566
277
|
}));
|
|
567
278
|
|
|
568
|
-
return
|
|
569
|
-
{ models, configured: true, managed: false },
|
|
570
|
-
{
|
|
571
|
-
headers: { "Content-Type": "application/json" },
|
|
572
|
-
},
|
|
573
|
-
);
|
|
279
|
+
return modelsResponse({ models, configured: true, managed: false });
|
|
574
280
|
} catch (error) {
|
|
575
281
|
// Network error → return defaults.
|
|
576
282
|
const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
|
|
577
|
-
return
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
},
|
|
585
|
-
{
|
|
586
|
-
headers: { "Content-Type": "application/json" },
|
|
587
|
-
},
|
|
588
|
-
);
|
|
283
|
+
return modelsResponse({
|
|
284
|
+
models: defaults,
|
|
285
|
+
configured: true,
|
|
286
|
+
managed: false,
|
|
287
|
+
upstreamError: "network",
|
|
288
|
+
upstreamMessage: (error as Error).message,
|
|
289
|
+
});
|
|
589
290
|
}
|
|
590
291
|
}
|
|
591
292
|
|
package/src/problem.ts
CHANGED
|
@@ -44,9 +44,12 @@ export function problem(
|
|
|
44
44
|
* The declared type for a status a caller already computed.
|
|
45
45
|
*
|
|
46
46
|
* Two files need this and no more should: `data-api.ts`, whose `ApiError` predates the registry and
|
|
47
|
-
* carries only a status, and `ai-api.ts`,
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* carries only a status, and `ai-api.ts`, whose key and base-URL refusals are written as statuses.
|
|
48
|
+
* Everywhere else the type is named at the call site, which is what keeps the registry meaningful —
|
|
49
|
+
* a status is not a failure kind. The AI proxy's other use, forwarding an upstream provider's
|
|
50
|
+
* status because collapsing it would discard information, moved with the stream normalizer into
|
|
51
|
+
* `@jxsuite/ai/gateway`, which carries its own copy of this table; `tests/ai-api-gateway.test.ts`
|
|
52
|
+
* holds the two to agreement for every status.
|
|
50
53
|
*
|
|
51
54
|
* @param {number} status
|
|
52
55
|
* @returns {ProblemTypeName}
|
package/src/studio-api.ts
CHANGED
|
@@ -1206,6 +1206,33 @@ export async function handleStudioApi(
|
|
|
1206
1206
|
}
|
|
1207
1207
|
}
|
|
1208
1208
|
|
|
1209
|
+
/* Read a file's raw BYTES.
|
|
1210
|
+
The read side of `/__studio/file/upload`, and a separate route from `/__studio/file` rather
|
|
1211
|
+
than a flag on it, because the two differ in more than encoding: that route answers a JSON
|
|
1212
|
+
envelope `{ content, path }` with `max-age=5`, and both are wrong here. A caller wanting bytes
|
|
1213
|
+
wants a body, and a caller about to overwrite the file must not be handed a five-second-old
|
|
1214
|
+
copy of it — an edit applied to stale bytes is a silently wrong file.
|
|
1215
|
+
|
|
1216
|
+
`Bun.file` streams from disk and infers the media type from the extension, which is what makes
|
|
1217
|
+
this a handful of lines rather than a decoder. */
|
|
1218
|
+
if (path === "/__studio/file/bytes" && req.method === "GET") {
|
|
1219
|
+
const fp = url.searchParams.get("path");
|
|
1220
|
+
if (!fp) {
|
|
1221
|
+
return problem("invalidRequest", "Missing path");
|
|
1222
|
+
}
|
|
1223
|
+
const abs = expandTilde(fp);
|
|
1224
|
+
try {
|
|
1225
|
+
assertAccessible(abs, root, activeProjectRoot);
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
return new Response(errorMessage(error), { status: 400 });
|
|
1228
|
+
}
|
|
1229
|
+
const file = Bun.file(abs);
|
|
1230
|
+
if (!(await file.exists())) {
|
|
1231
|
+
return problem("notFound", "Not found");
|
|
1232
|
+
}
|
|
1233
|
+
return new Response(file, { headers: { "Cache-Control": "no-store" } });
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1209
1236
|
// Write file
|
|
1210
1237
|
if (path === "/__studio/file" && req.method === "PUT") {
|
|
1211
1238
|
const fp = url.searchParams.get("path");
|