@jxsuite/server 0.32.0 → 0.34.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 +8 -10
- package/src/ai-api.ts +465 -0
- package/src/project-server.ts +408 -0
- package/src/server.ts +29 -6
- package/src/studio-api.ts +0 -74
- package/src/watch.ts +12 -0
- package/src/claude-session.ts +0 -338
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/server.ts",
|
|
17
|
-
"./
|
|
17
|
+
"./ai-api": "./src/ai-api.ts",
|
|
18
18
|
"./packages": "./src/packages.ts",
|
|
19
|
+
"./project-server": "./src/project-server.ts",
|
|
19
20
|
"./refactor": "./src/refactor/index.ts",
|
|
20
21
|
"./resolve": "./src/resolve.ts",
|
|
21
22
|
"./types": "./src/types.ts"
|
|
@@ -29,14 +30,11 @@
|
|
|
29
30
|
"test:coverage": "bun test --isolate --coverage"
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
|
-
"@
|
|
33
|
-
"@
|
|
34
|
-
"@jxsuite/
|
|
35
|
-
"@jxsuite/
|
|
36
|
-
"@jxsuite/
|
|
37
|
-
"@jxsuite/runtime": "^0.32.0",
|
|
38
|
-
"@jxsuite/schema": "^0.32.0",
|
|
39
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
|
+
"@jxsuite/compiler": "^0.34.0",
|
|
34
|
+
"@jxsuite/create": "^0.34.0",
|
|
35
|
+
"@jxsuite/parser": "^0.34.0",
|
|
36
|
+
"@jxsuite/runtime": "^0.34.0",
|
|
37
|
+
"@jxsuite/schema": "^0.34.0",
|
|
40
38
|
"chokidar": "^5.0.0",
|
|
41
39
|
"zod": "^4.4.3"
|
|
42
40
|
}
|
package/src/ai-api.ts
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ai-api.js — AI proxy endpoints for Jx Studio
|
|
3
|
+
*
|
|
4
|
+
* Handles /__studio/ai/chat (SSE streaming proxy to OpenAI) and /__studio/ai/models.
|
|
5
|
+
* The server acts as a thin proxy: validates the request shape, forwards to OpenAI,
|
|
6
|
+
* normalizes the SSE stream into StreamEvent-compatible format, and pipes back.
|
|
7
|
+
*
|
|
8
|
+
* API key flow:
|
|
9
|
+
* 1. Request header X-Api-Key or Authorization: Bearer <key>
|
|
10
|
+
* 2. Fallback: OPENAI_API_KEY env var
|
|
11
|
+
* 3. If neither → 401 with error message
|
|
12
|
+
*
|
|
13
|
+
* Base URL flow:
|
|
14
|
+
* 1. Request header X-Api-Base-URL
|
|
15
|
+
* 2. Fallback: OPENAI_BASE_URL env var
|
|
16
|
+
* 3. Default: https://api.openai.com/v1
|
|
17
|
+
*
|
|
18
|
+
* @license MIT
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
24
|
+
|
|
25
|
+
/** A tool-call fragment inside an OpenAI streaming `delta`. */
|
|
26
|
+
interface ToolCallDelta {
|
|
27
|
+
index: number;
|
|
28
|
+
id?: string;
|
|
29
|
+
type?: string;
|
|
30
|
+
function?: { name?: string; arguments?: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
|
|
34
|
+
interface OpenAIStreamChunk {
|
|
35
|
+
choices?: {
|
|
36
|
+
index?: number;
|
|
37
|
+
delta?: { content?: string; tool_calls?: ToolCallDelta[] };
|
|
38
|
+
finish_reason?: string | null;
|
|
39
|
+
}[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A model entry from the upstream `/models` listing. */
|
|
43
|
+
interface ModelEntry {
|
|
44
|
+
id: string;
|
|
45
|
+
context_window?: number;
|
|
46
|
+
owned_by?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getConfig(req: Request): { apiKey: string | null; baseUrl: string; missingKey: boolean } {
|
|
50
|
+
const authHeader = req.headers.get("Authorization") || "";
|
|
51
|
+
const apiKeyHeader = req.headers.get("X-Api-Key") || "";
|
|
52
|
+
const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
|
|
53
|
+
|
|
54
|
+
let apiKey = null;
|
|
55
|
+
|
|
56
|
+
// Check Authorization: Bearer <key>
|
|
57
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
58
|
+
apiKey = authHeader.slice(7).trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check X-Api-Key header (overrides Bearer)
|
|
62
|
+
if (apiKeyHeader) {
|
|
63
|
+
apiKey = apiKeyHeader.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check env var (fallback)
|
|
67
|
+
if (!apiKey && process.env.OPENAI_API_KEY) {
|
|
68
|
+
apiKey = process.env.OPENAI_API_KEY;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check base URL
|
|
72
|
+
const baseUrl = baseUrlHeader || process.env.OPENAI_BASE_URL || DEFAULT_BASE_URL;
|
|
73
|
+
|
|
74
|
+
return { apiKey, baseUrl, missingKey: !apiKey };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** Write an SSE event to the response stream. */
|
|
80
|
+
function writeSSE(controller: ReadableStreamDefaultController, event: unknown): void {
|
|
81
|
+
const data = JSON.stringify(event);
|
|
82
|
+
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Write a JSON error response (for non-streaming endpoints and error cases). */
|
|
86
|
+
function jsonError(status: number, message: string): Response {
|
|
87
|
+
return Response.json(
|
|
88
|
+
{ error: message },
|
|
89
|
+
{
|
|
90
|
+
status,
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
|
|
99
|
+
export async function handleChat(req: Request): Promise<Response> {
|
|
100
|
+
const { apiKey, baseUrl, missingKey } = getConfig(req);
|
|
101
|
+
if (missingKey) {
|
|
102
|
+
return jsonError(
|
|
103
|
+
401,
|
|
104
|
+
"No API key configured. Set OPENAI_API_KEY env var or send X-Api-Key header.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Parse request body
|
|
109
|
+
let body: {
|
|
110
|
+
messages?: unknown[];
|
|
111
|
+
tools?: unknown[];
|
|
112
|
+
systemPrompt?: string;
|
|
113
|
+
model?: string;
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
body = (await req.json()) as {
|
|
117
|
+
messages?: unknown[];
|
|
118
|
+
tools?: unknown[];
|
|
119
|
+
systemPrompt?: string;
|
|
120
|
+
model?: string;
|
|
121
|
+
};
|
|
122
|
+
} catch {
|
|
123
|
+
return jsonError(400, "Invalid JSON body");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { messages = [], tools = [], systemPrompt = "", model = "gpt-4o" } = body;
|
|
127
|
+
|
|
128
|
+
if (!Array.isArray(messages)) {
|
|
129
|
+
return jsonError(400, "messages must be an array");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build OpenAI request
|
|
133
|
+
const openaiBody: {
|
|
134
|
+
model: string;
|
|
135
|
+
messages: unknown[];
|
|
136
|
+
stream: boolean;
|
|
137
|
+
stream_options: { include_usage: boolean };
|
|
138
|
+
tools?: unknown[];
|
|
139
|
+
tool_choice?: string;
|
|
140
|
+
parallel_tool_calls?: boolean;
|
|
141
|
+
} = {
|
|
142
|
+
model,
|
|
143
|
+
messages: [{ role: "system", content: systemPrompt }, ...messages],
|
|
144
|
+
stream: true,
|
|
145
|
+
stream_options: { include_usage: true },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (tools && tools.length > 0) {
|
|
149
|
+
openaiBody.tools = tools;
|
|
150
|
+
openaiBody.tool_choice = "auto";
|
|
151
|
+
openaiBody.parallel_tool_calls = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const upstreamUrl = `${baseUrl}/chat/completions`;
|
|
155
|
+
|
|
156
|
+
// Create the SSE stream
|
|
157
|
+
const stream = new ReadableStream({
|
|
158
|
+
async start(controller) {
|
|
159
|
+
let response;
|
|
160
|
+
try {
|
|
161
|
+
response = await fetch(upstreamUrl, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
Authorization: `Bearer ${apiKey}`,
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(openaiBody),
|
|
168
|
+
signal: req.signal,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if ((error as Error).name === "AbortError") {
|
|
172
|
+
writeSSE(controller, { type: "done", stopReason: "cancelled" });
|
|
173
|
+
} else {
|
|
174
|
+
writeSSE(controller, {
|
|
175
|
+
type: "error",
|
|
176
|
+
message: `Network error: ${(error as Error).message}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
controller.close();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
let errorBody = "";
|
|
185
|
+
try {
|
|
186
|
+
errorBody = await response.text();
|
|
187
|
+
} catch {
|
|
188
|
+
/* Ignore */
|
|
189
|
+
}
|
|
190
|
+
// Parse the upstream JSON error body (OpenAI returns { error: { message: "..." } },
|
|
191
|
+
// While some compatible providers return { error: "..." }). Extract a clean message
|
|
192
|
+
// Instead of embedding the raw JSON in the error text.
|
|
193
|
+
let cleanMessage = errorBody || response.statusText;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(errorBody) as { error?: string | { message?: string } };
|
|
196
|
+
if (typeof parsed.error === "string") {
|
|
197
|
+
cleanMessage = parsed.error;
|
|
198
|
+
} else if (parsed.error?.message) {
|
|
199
|
+
cleanMessage = parsed.error.message;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
/* Not JSON — use the raw body. */
|
|
203
|
+
}
|
|
204
|
+
writeSSE(controller, {
|
|
205
|
+
type: "error",
|
|
206
|
+
message: cleanMessage,
|
|
207
|
+
code: String(response.status),
|
|
208
|
+
});
|
|
209
|
+
controller.close();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const reader = response.body?.getReader();
|
|
214
|
+
if (!reader) {
|
|
215
|
+
writeSSE(controller, { type: "error", message: "No response body from upstream" });
|
|
216
|
+
controller.close();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const decoder = new TextDecoder();
|
|
221
|
+
let buffer = "";
|
|
222
|
+
|
|
223
|
+
const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
while (true) {
|
|
227
|
+
const { done, value } = await reader.read();
|
|
228
|
+
if (done) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
buffer += decoder.decode(value, { stream: true });
|
|
233
|
+
const lines = buffer.split("\n");
|
|
234
|
+
buffer = lines.pop() || "";
|
|
235
|
+
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const trimmed = line.trim();
|
|
238
|
+
if (!trimmed || !trimmed.startsWith("data: ")) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const dataStr = trimmed.slice(6);
|
|
243
|
+
if (dataStr === "[DONE]") {
|
|
244
|
+
// Emit pending tool call ends
|
|
245
|
+
for (const [, tc] of pendingToolCalls) {
|
|
246
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
247
|
+
}
|
|
248
|
+
pendingToolCalls.clear();
|
|
249
|
+
writeSSE(controller, { type: "done", stopReason: "stop" });
|
|
250
|
+
controller.close();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let parsed: OpenAIStreamChunk;
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const choice = parsed.choices?.[0];
|
|
262
|
+
if (!choice) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const { delta } = choice;
|
|
267
|
+
if (!delta) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Text content
|
|
272
|
+
if (delta.content) {
|
|
273
|
+
writeSSE(controller, { type: "delta", content: delta.content });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Tool calls
|
|
277
|
+
if (delta.tool_calls) {
|
|
278
|
+
for (const tc of delta.tool_calls) {
|
|
279
|
+
if (tc.id) {
|
|
280
|
+
// First appearance
|
|
281
|
+
const entry = {
|
|
282
|
+
id: tc.id,
|
|
283
|
+
name: tc.function?.name || "",
|
|
284
|
+
args: tc.function?.arguments || "",
|
|
285
|
+
};
|
|
286
|
+
pendingToolCalls.set(tc.index, entry);
|
|
287
|
+
|
|
288
|
+
writeSSE(controller, { type: "tool_call_start", id: tc.id, name: entry.name });
|
|
289
|
+
|
|
290
|
+
if (entry.args) {
|
|
291
|
+
writeSSE(controller, { type: "tool_call_delta", id: tc.id, args: entry.args });
|
|
292
|
+
}
|
|
293
|
+
} else if (tc.function?.arguments) {
|
|
294
|
+
// Subsequent fragment
|
|
295
|
+
const existing = pendingToolCalls.get(tc.index);
|
|
296
|
+
if (existing) {
|
|
297
|
+
existing.args += tc.function.arguments;
|
|
298
|
+
writeSSE(controller, {
|
|
299
|
+
type: "tool_call_delta",
|
|
300
|
+
id: existing.id,
|
|
301
|
+
args: tc.function.arguments,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Finish reason
|
|
309
|
+
if (choice.finish_reason === "tool_calls") {
|
|
310
|
+
for (const [, tc] of pendingToolCalls) {
|
|
311
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
312
|
+
}
|
|
313
|
+
pendingToolCalls.clear();
|
|
314
|
+
writeSSE(controller, { type: "done", stopReason: "tool_calls" });
|
|
315
|
+
controller.close();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
|
|
320
|
+
for (const [, tc] of pendingToolCalls) {
|
|
321
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
322
|
+
}
|
|
323
|
+
pendingToolCalls.clear();
|
|
324
|
+
writeSSE(controller, {
|
|
325
|
+
type: "done",
|
|
326
|
+
stopReason: choice.finish_reason === "length" ? "length" : "stop",
|
|
327
|
+
});
|
|
328
|
+
controller.close();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Stream ended without explicit finish_reason
|
|
335
|
+
for (const [, tc] of pendingToolCalls) {
|
|
336
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
337
|
+
}
|
|
338
|
+
pendingToolCalls.clear();
|
|
339
|
+
writeSSE(controller, { type: "done", stopReason: "stop" });
|
|
340
|
+
controller.close();
|
|
341
|
+
} catch (error) {
|
|
342
|
+
void reader.cancel();
|
|
343
|
+
if ((error as Error).name === "AbortError") {
|
|
344
|
+
writeSSE(controller, { type: "done", stopReason: "cancelled" });
|
|
345
|
+
controller.close();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
writeSSE(controller, {
|
|
349
|
+
type: "error",
|
|
350
|
+
message: `Stream error: ${(error as Error).message}`,
|
|
351
|
+
});
|
|
352
|
+
controller.close();
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return new Response(stream, {
|
|
358
|
+
headers: {
|
|
359
|
+
"Content-Type": "text/event-stream",
|
|
360
|
+
"Cache-Control": "no-cache",
|
|
361
|
+
Connection: "keep-alive",
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── /__studio/ai/models — model listing ────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Handle GET /__studio/ai/models — return available models.
|
|
370
|
+
*
|
|
371
|
+
* When the caller has configured an API key (header or env), the request is proxied to the upstream
|
|
372
|
+
* provider's /models endpoint so any OpenAI-compatible endpoint (OpenRouter, local LLM, OpenCode
|
|
373
|
+
* Zen, Azure, etc.) returns its actual model list. Falls back to a hardcoded default list when no
|
|
374
|
+
* key is available.
|
|
375
|
+
*/
|
|
376
|
+
export async function handleModels(req: Request): Promise<Response> {
|
|
377
|
+
const { apiKey, baseUrl, missingKey } = getConfig(req);
|
|
378
|
+
|
|
379
|
+
// No key available → return hardcoded defaults so the UI can at least render.
|
|
380
|
+
if (missingKey) {
|
|
381
|
+
const defaults = [
|
|
382
|
+
{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 },
|
|
383
|
+
{ id: "gpt-4.1", name: "GPT-4.1", contextWindow: 1_000_000 },
|
|
384
|
+
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextWindow: 1_000_000 },
|
|
385
|
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
|
|
386
|
+
];
|
|
387
|
+
return Response.json(
|
|
388
|
+
{ models: defaults, configured: false },
|
|
389
|
+
{
|
|
390
|
+
headers: { "Content-Type": "application/json" },
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Key is available — proxy to the upstream /models endpoint.
|
|
396
|
+
try {
|
|
397
|
+
const upstreamUrl = `${baseUrl}/models`;
|
|
398
|
+
const upstreamResp = await fetch(upstreamUrl, {
|
|
399
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (!upstreamResp.ok) {
|
|
403
|
+
// Upstream failed — return defaults with configured flag so user can still try.
|
|
404
|
+
const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
|
|
405
|
+
return Response.json(
|
|
406
|
+
{ models: defaults, configured: true, upstreamError: upstreamResp.status },
|
|
407
|
+
{
|
|
408
|
+
headers: { "Content-Type": "application/json" },
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const data = (await upstreamResp.json()) as { data?: ModelEntry[] } | ModelEntry[];
|
|
414
|
+
// OpenAI /models returns { object: "list", data: [{ id, ... }] }
|
|
415
|
+
// Map to our simpler format.
|
|
416
|
+
const rawModels: ModelEntry[] = Array.isArray(data)
|
|
417
|
+
? data
|
|
418
|
+
: Array.isArray(data.data)
|
|
419
|
+
? data.data
|
|
420
|
+
: [];
|
|
421
|
+
const models = rawModels.map(({ id, context_window, owned_by }) => ({
|
|
422
|
+
id,
|
|
423
|
+
name: id,
|
|
424
|
+
contextWindow: context_window || 0,
|
|
425
|
+
ownedBy: owned_by,
|
|
426
|
+
}));
|
|
427
|
+
|
|
428
|
+
return Response.json(
|
|
429
|
+
{ models, configured: true },
|
|
430
|
+
{
|
|
431
|
+
headers: { "Content-Type": "application/json" },
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
} catch {
|
|
435
|
+
// Network error → return defaults.
|
|
436
|
+
const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
|
|
437
|
+
return Response.json(
|
|
438
|
+
{ models: defaults, configured: true, upstreamError: "network" },
|
|
439
|
+
{
|
|
440
|
+
headers: { "Content-Type": "application/json" },
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ─── Route handler ───────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Main handler for /__studio/ai/* requests.
|
|
450
|
+
*
|
|
451
|
+
* @returns Response if handled, null if route doesn't match
|
|
452
|
+
*/
|
|
453
|
+
export async function handleAiApi(req: Request, url: URL): Promise<Response | null> {
|
|
454
|
+
const { pathname } = url;
|
|
455
|
+
|
|
456
|
+
if (pathname === "/__studio/ai/chat" && req.method === "POST") {
|
|
457
|
+
return handleChat(req);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (pathname === "/__studio/ai/models" && req.method === "GET") {
|
|
461
|
+
return handleModels(req);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return null;
|
|
465
|
+
}
|