@jxsuite/server 8.0.1 → 8.1.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.
Files changed (2) hide show
  1. package/package.json +9 -9
  2. package/src/ai-api.ts +123 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "8.0.1",
3
+ "version": "8.1.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,20 +54,20 @@
54
54
  "upgrade": "bun update --latest && bun install"
55
55
  },
56
56
  "dependencies": {
57
- "@jxsuite/ai": "^0.41.2",
58
- "@jxsuite/catalog": "^0.1.5",
57
+ "@jxsuite/ai": "^0.42.0",
58
+ "@jxsuite/catalog": "^0.1.6",
59
59
  "@jxsuite/collab": "^0.11.4",
60
- "@jxsuite/compiler": "^8.0.1",
61
- "@jxsuite/create": "^1.5.1",
62
- "@jxsuite/import": "^0.42.1",
63
- "@jxsuite/protocol": "^6.1.0",
60
+ "@jxsuite/compiler": "^8.0.2",
61
+ "@jxsuite/create": "^1.5.2",
62
+ "@jxsuite/import": "^0.42.2",
63
+ "@jxsuite/protocol": "^6.2.0",
64
64
  "@jxsuite/runtime": "^6.0.1",
65
65
  "@jxsuite/schema": "^5.0.0",
66
66
  "@jxsuite/site": "^2.2.2",
67
- "@jxsuite/starters": "^3.1.3",
67
+ "@jxsuite/starters": "^3.1.4",
68
68
  "@npmcli/arborist": "^10.0.4",
69
69
  "chokidar": "^6.0.0",
70
- "kysely": "^0.29.7",
70
+ "kysely": "^0.29.8",
71
71
  "npm-package-arg": "^14.0.0",
72
72
  "oxfmt": "^0.75.0",
73
73
  "ws": "^8.22.1",
package/src/ai-api.ts CHANGED
@@ -22,6 +22,13 @@
22
22
  * 2. Fallback: OPENAI_BASE_URL env var
23
23
  * 3. Default: https://api.openai.com/v1
24
24
  *
25
+ * Provider family (specs/ai.md §2.6): a request carrying `X-Api-Provider: anthropic` is served by
26
+ * the Anthropic Messages API instead, with the same two flows read from ANTHROPIC_API_KEY,
27
+ * ANTHROPIC_BASE_URL and `https://api.anthropic.com`. An env key is still attached only to its own
28
+ * family's env/default base URL, so neither server key reaches the other family or a caller's
29
+ * endpoint. Any other value is refused: a key must never be sent to a provider it was not meant
30
+ * for. The models route lists both families in `providers`.
31
+ *
25
32
  * A base URL resolving to a cloud metadata / link-local host is refused (SSRF defense).
26
33
  *
27
34
  * Prompt-cache affinity: a request carrying a valid X-Jx-Ai-Session header (the client's
@@ -38,15 +45,19 @@
38
45
  import {
39
46
  AI_SESSION_HEADER,
40
47
  affinityKey,
48
+ ANTHROPIC_BASE_URL,
41
49
  createChatHandler,
42
50
  DEFAULT_UPSTREAM_BASE_URL,
43
51
  extractUpstreamErrorMessage,
44
52
  isAiSessionId,
53
+ listAnthropicModels,
45
54
  modelsResponse,
46
55
  problemResponse,
47
56
  } from "@jxsuite/ai/gateway";
48
57
  import type { GatewayRefusal, Upstream } from "@jxsuite/ai/gateway";
58
+ import { AI_PROVIDER_HEADER } from "@jxsuite/ai/streaming-client";
49
59
  import { problemDetails } from "@jxsuite/protocol";
60
+ import type { AiModelInfo, AiProviderFamily } from "@jxsuite/protocol";
50
61
  import { errorMessage } from "@jxsuite/schema/parse";
51
62
  import { problemTypeForStatus } from "./problem.ts";
52
63
 
@@ -55,6 +66,19 @@ import { problemTypeForStatus } from "./problem.ts";
55
66
  /** The model a chat request that names none is forwarded with. */
56
67
  const DEFAULT_MODEL = "gpt-4o";
57
68
 
69
+ /** The model an Anthropic chat request that names none is forwarded with, and the probe prefers. */
70
+ const DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5";
71
+
72
+ /** Every family this server's chat route can reach, as the models route declares them. */
73
+ const PROVIDERS: AiProviderFamily[] = ["openai-compat", "anthropic"];
74
+
75
+ /** The Claude catalogue the models route answers when it cannot list the caller's own. */
76
+ const ANTHROPIC_DEFAULTS: AiModelInfo[] = [
77
+ { contextWindow: 1_000_000, id: "claude-opus-5-5", name: "Claude Opus 5.5" },
78
+ { contextWindow: 1_000_000, id: "claude-sonnet-5-5", name: "Claude Sonnet 5.5" },
79
+ { contextWindow: 1_000_000, id: "claude-haiku-5-5", name: "Claude Haiku 5.5" },
80
+ ];
81
+
58
82
  /**
59
83
  * This server's namespace for affinity keys. Every dev/desktop server shares it: their keys never
60
84
  * meet a hosted backend's, because that one hashes under its own scope.
@@ -95,16 +119,64 @@ function isBlockedHost(rawUrl: string): boolean {
95
119
  }
96
120
 
97
121
  interface AiConfig {
122
+ family: AiProviderFamily;
98
123
  apiKey: string | null;
99
124
  baseUrl: string;
100
125
  missingKey: boolean;
101
126
  reject?: { status: number; message: string };
102
127
  }
103
128
 
129
+ /** Where each family's server-side key and base URL come from, and its default endpoint. */
130
+ interface FamilyEnvironment {
131
+ keyVariable: "OPENAI_API_KEY" | "ANTHROPIC_API_KEY";
132
+ baseUrlVariable: "OPENAI_BASE_URL" | "ANTHROPIC_BASE_URL";
133
+ defaultBaseUrl: string;
134
+ }
135
+
136
+ const ENVIRONMENTS: Readonly<Record<AiProviderFamily, FamilyEnvironment>> = {
137
+ anthropic: {
138
+ baseUrlVariable: "ANTHROPIC_BASE_URL",
139
+ defaultBaseUrl: ANTHROPIC_BASE_URL,
140
+ keyVariable: "ANTHROPIC_API_KEY",
141
+ },
142
+ "openai-compat": {
143
+ baseUrlVariable: "OPENAI_BASE_URL",
144
+ defaultBaseUrl: DEFAULT_UPSTREAM_BASE_URL,
145
+ keyVariable: "OPENAI_API_KEY",
146
+ },
147
+ };
148
+
149
+ /**
150
+ * The family a request names in `X-Api-Provider`: absent or empty is the OpenAI-compatible default,
151
+ * and a value naming no family this server reaches is null, which the caller refuses.
152
+ */
153
+ function familyOf(req: Request): AiProviderFamily | null {
154
+ const named = (req.headers.get(AI_PROVIDER_HEADER) || "").trim().toLowerCase();
155
+ if (named === "" || named === "openai-compat") {
156
+ return "openai-compat";
157
+ }
158
+ return named === "anthropic" ? "anthropic" : null;
159
+ }
160
+
104
161
  function getConfig(req: Request): AiConfig {
105
162
  const authHeader = req.headers.get("Authorization") || "";
106
163
  const apiKeyHeader = req.headers.get("X-Api-Key") || "";
107
164
  const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
165
+ const named = familyOf(req);
166
+ if (named === null) {
167
+ return {
168
+ apiKey: null,
169
+ baseUrl: "",
170
+ family: "openai-compat",
171
+ missingKey: true,
172
+ reject: {
173
+ status: 400,
174
+ message: `Unknown ${AI_PROVIDER_HEADER}: this server reaches openai-compat and anthropic.`,
175
+ },
176
+ };
177
+ }
178
+ const family = named;
179
+ const environment = ENVIRONMENTS[family];
108
180
 
109
181
  // Track the PROVENANCE of the key: a header-supplied key may ride a caller's custom base URL; the
110
182
  // Server's env key never leaves for a caller-chosen endpoint (key-exfiltration defense).
@@ -120,12 +192,14 @@ function getConfig(req: Request): AiConfig {
120
192
  }
121
193
 
122
194
  const baseFromHeader = Boolean(baseUrlHeader);
123
- const baseUrl = baseUrlHeader || process.env.OPENAI_BASE_URL || DEFAULT_UPSTREAM_BASE_URL;
195
+ const baseUrl =
196
+ baseUrlHeader || process.env[environment.baseUrlVariable] || environment.defaultBaseUrl;
124
197
 
125
198
  if (isBlockedHost(baseUrl)) {
126
199
  return {
127
200
  apiKey: null,
128
201
  baseUrl,
202
+ family,
129
203
  missingKey: true,
130
204
  reject: { status: 403, message: "Base URL host is not permitted." },
131
205
  };
@@ -137,6 +211,7 @@ function getConfig(req: Request): AiConfig {
137
211
  return {
138
212
  apiKey: null,
139
213
  baseUrl,
214
+ family,
140
215
  missingKey: true,
141
216
  reject: {
142
217
  status: 401,
@@ -145,12 +220,13 @@ function getConfig(req: Request): AiConfig {
145
220
  };
146
221
  }
147
222
 
148
- // Env key is only ever attached to the env/default base URL.
149
- if (!apiKey && process.env.OPENAI_API_KEY) {
150
- apiKey = process.env.OPENAI_API_KEY;
223
+ // Env key is only ever attached to its own family's env/default base URL.
224
+ const envKey = process.env[environment.keyVariable];
225
+ if (!apiKey && envKey) {
226
+ apiKey = envKey;
151
227
  }
152
228
 
153
- return { apiKey, baseUrl, missingKey: !apiKey };
229
+ return { apiKey, baseUrl, family, missingKey: !apiKey };
154
230
  }
155
231
 
156
232
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -173,21 +249,21 @@ function refuse(status: number, message: string): GatewayRefusal {
173
249
  * session header adds the hashed affinity key; an invalid one is ignored rather than refused.
174
250
  */
175
251
  async function resolveUpstream(req: Request): Promise<Upstream | GatewayRefusal> {
176
- const { apiKey, baseUrl, reject } = getConfig(req);
252
+ const { apiKey, baseUrl, family, reject } = getConfig(req);
177
253
  if (reject) {
178
254
  return refuse(reject.status, reject.message);
179
255
  }
180
256
  if (!apiKey) {
181
257
  return refuse(
182
258
  401,
183
- "No API key configured. Set OPENAI_API_KEY env var or send X-Api-Key header.",
259
+ `No API key configured. Set ${ENVIRONMENTS[family].keyVariable} env var or send X-Api-Key header.`,
184
260
  );
185
261
  }
186
262
  const upstream: Upstream = {
187
263
  apiKey,
188
264
  baseUrl,
189
- defaultModel: DEFAULT_MODEL,
190
- family: "openai-compat",
265
+ defaultModel: family === "anthropic" ? DEFAULT_ANTHROPIC_MODEL : DEFAULT_MODEL,
266
+ family,
191
267
  managed: false,
192
268
  };
193
269
  const sessionId = req.headers.get(AI_SESSION_HEADER);
@@ -203,7 +279,7 @@ const chatHandler = createChatHandler<{ readonly signal: AbortSignal }>({
203
279
  resolveUpstream: (request) => resolveUpstream(request),
204
280
  });
205
281
 
206
- /** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
282
+ /** Handle POST /__studio/ai/chat — proxy the chat to the request's provider family via SSE. */
207
283
  export async function handleChat(req: Request): Promise<Response> {
208
284
  return chatHandler(req, { signal: req.signal });
209
285
  }
@@ -219,10 +295,13 @@ export async function handleChat(req: Request): Promise<Response> {
219
295
  * key is available.
220
296
  */
221
297
  export async function handleModels(req: Request): Promise<Response> {
222
- const { apiKey, baseUrl, missingKey, reject } = getConfig(req);
298
+ const { apiKey, baseUrl, family, missingKey, reject } = getConfig(req);
223
299
  if (reject) {
224
300
  return problemResponse(refuse(reject.status, reject.message));
225
301
  }
302
+ if (family === "anthropic") {
303
+ return anthropicModels(apiKey, baseUrl);
304
+ }
226
305
 
227
306
  // No key available → return hardcoded defaults so the UI can at least render.
228
307
  if (missingKey) {
@@ -232,7 +311,12 @@ export async function handleModels(req: Request): Promise<Response> {
232
311
  { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextWindow: 1_000_000 },
233
312
  { id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
234
313
  ];
235
- return modelsResponse({ models: defaults, configured: false, managed: false });
314
+ return modelsResponse({
315
+ models: defaults,
316
+ configured: false,
317
+ managed: false,
318
+ providers: PROVIDERS,
319
+ });
236
320
  }
237
321
 
238
322
  // Key is available — proxy to the upstream /models endpoint.
@@ -256,6 +340,7 @@ export async function handleModels(req: Request): Promise<Response> {
256
340
  models: defaults,
257
341
  configured: true,
258
342
  managed: false,
343
+ providers: PROVIDERS,
259
344
  upstreamError: upstreamResp.status,
260
345
  upstreamMessage,
261
346
  });
@@ -280,7 +365,7 @@ export async function handleModels(req: Request): Promise<Response> {
280
365
  ownedBy: owned_by,
281
366
  }));
282
367
 
283
- return modelsResponse({ models, configured: true, managed: false });
368
+ return modelsResponse({ models, configured: true, managed: false, providers: PROVIDERS });
284
369
  } catch (error) {
285
370
  // Network error → return defaults.
286
371
  const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
@@ -288,12 +373,37 @@ export async function handleModels(req: Request): Promise<Response> {
288
373
  models: defaults,
289
374
  configured: true,
290
375
  managed: false,
376
+ providers: PROVIDERS,
291
377
  upstreamError: "network",
292
378
  upstreamMessage: errorMessage(error),
293
379
  });
294
380
  }
295
381
  }
296
382
 
383
+ /**
384
+ * The models route for the Anthropic family: the caller's own listing from `/v1/models`, or the
385
+ * Claude defaults when there is no key or the listing failed, with the reason beside them exactly
386
+ * as the OpenAI-compatible branch reports one. `defaultModel` is always set, because a client that
387
+ * has chosen nothing must not fall back to an OpenAI model id here.
388
+ */
389
+ async function anthropicModels(apiKey: string | null, baseUrl: string): Promise<Response> {
390
+ const declared = { defaultModel: DEFAULT_ANTHROPIC_MODEL, managed: false, providers: PROVIDERS };
391
+ if (!apiKey) {
392
+ return modelsResponse({ ...declared, configured: false, models: ANTHROPIC_DEFAULTS });
393
+ }
394
+ const listing = await listAnthropicModels({ apiKey, baseUrl });
395
+ if (listing.ok) {
396
+ return modelsResponse({ ...declared, configured: true, models: listing.models });
397
+ }
398
+ return modelsResponse({
399
+ ...declared,
400
+ configured: true,
401
+ models: ANTHROPIC_DEFAULTS,
402
+ upstreamError: listing.upstreamError,
403
+ upstreamMessage: listing.message,
404
+ });
405
+ }
406
+
297
407
  // ─── Route handler ───────────────────────────────────────────────────────────
298
408
 
299
409
  /**