@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.32.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
- "./claude-session": "./src/claude-session.ts",
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
- "@anthropic-ai/claude-agent-sdk": "^0.3.190",
33
- "@anthropic-ai/sdk": "^0.105.0",
34
- "@jxsuite/compiler": "^0.32.0",
35
- "@jxsuite/create": "^0.32.0",
36
- "@jxsuite/parser": "^0.32.0",
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
+ }