@hasna/switcher 0.1.0 → 0.1.1

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.
@@ -12,8 +12,8 @@ import { dirname, resolve } from "path";
12
12
 
13
13
  // src/domain.ts
14
14
  import { z } from "zod";
15
- var VERSION = "0.1.0";
16
- var harnessSchema = z.enum(["claude", "codex", "grok", "opencode2"]);
15
+ var VERSION = "0.1.1";
16
+ var harnessSchema = z.enum(["claude", "codex", "grok", "opencode2", "pi"]);
17
17
  var protocolSchema = z.enum(["anthropic-messages", "openai-responses", "openai-chat"]);
18
18
  var idSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
19
19
  var label = z.string().min(1).max(200);
@@ -41,6 +41,7 @@ var modelSchema = z.object({
41
41
  id: z.string().min(1).max(300),
42
42
  name: label,
43
43
  description: z.string().max(8000).optional(),
44
+ available: z.boolean().optional(),
44
45
  contextWindow: z.number().int().positive().optional(),
45
46
  maxOutputTokens: z.number().int().positive().optional(),
46
47
  inputModalities: z.array(z.string().max(50)).max(20).optional(),
@@ -54,9 +55,32 @@ var providerInputSchema = z.object({
54
55
  protocol: protocolSchema,
55
56
  credentialEnv: envRef.optional(),
56
57
  authStyle: z.enum(["bearer", "x-api-key"]).default("bearer"),
58
+ catalogBaseUrl: urlSchema.optional(),
59
+ catalogFormat: z.enum(["openai", "ollama", "mistral", "together", "fireworks", "dashscope", "none"]).optional(),
60
+ catalogAuthStyle: z.enum(["bearer", "x-api-key", "none"]).optional(),
61
+ catalogCredentialEnv: envRef.optional(),
62
+ catalogAccountId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/).optional(),
57
63
  modelsPath: z.string().regex(/^[a-zA-Z0-9_/-]+$/).max(200).default("models"),
58
64
  manualModels: z.array(modelSchema).max(1e4).default([])
59
65
  }).strict().refine((p) => !p.modelsPath.split("/").includes("..") && !p.modelsPath.startsWith("/"), "modelsPath must be relative");
66
+ var providerPresetSchema = z.object({
67
+ id: idSchema,
68
+ name: label,
69
+ credentialEnv: envRef.optional(),
70
+ credentialAliases: z.array(z.string().regex(/^[A-Z][A-Z0-9_]+$/)),
71
+ protocols: z.array(z.object({
72
+ protocol: protocolSchema,
73
+ baseUrl: urlSchema.optional(),
74
+ authStyle: z.enum(["bearer", "x-api-key"]),
75
+ catalogBaseUrl: urlSchema.optional(),
76
+ catalogFormat: z.enum(["openai", "ollama", "mistral", "together", "fireworks", "dashscope", "none"]),
77
+ catalogAuthStyle: z.enum(["bearer", "x-api-key", "none"]).optional(),
78
+ modelsPath: z.string(),
79
+ notes: z.array(z.string())
80
+ }).strict()).min(1),
81
+ sources: z.array(z.string().url()),
82
+ verification: z.literal("documented")
83
+ }).strict();
60
84
  var profileInputSchema = z.object({
61
85
  id: idSchema,
62
86
  name: label,
@@ -94,7 +118,7 @@ function compatible(harness, protocol) {
94
118
  return harness === "claude" ? protocol === "anthropic-messages" : harness === "codex" ? protocol === "openai-responses" : true;
95
119
  }
96
120
  function codingEligible(model) {
97
- return (!model.outputModalities || model.outputModalities.includes("text")) && (!model.supportedParameters || model.supportedParameters.includes("tools"));
121
+ return model.available !== false && (!model.outputModalities || model.outputModalities.includes("text")) && (!model.supportedParameters || model.supportedParameters.includes("tools"));
98
122
  }
99
123
 
100
124
  // src/store.ts
@@ -116,33 +140,37 @@ class Store {
116
140
  static async open(config) {
117
141
  if (!!config.databaseUrl === !!config.sqlitePath)
118
142
  throw new Fault(500, "storage_config", "Choose exactly one PostgreSQL URL or SQLite path.");
119
- let sql;
120
- let engine;
121
- if (config.databaseUrl) {
122
- if (!/^postgres(ql)?:\/\//.test(config.databaseUrl))
123
- throw new Fault(500, "storage_config", "Database URL must use PostgreSQL.");
124
- sql = new SQL(config.databaseUrl);
125
- engine = "postgresql";
126
- } else {
127
- const file = config.sqlitePath;
128
- if (file !== ":memory:")
129
- await mkdir(dirname(resolve(file)), { recursive: true, mode: 448 });
130
- sql = new SQL({ adapter: "sqlite", filename: file });
131
- engine = "sqlite";
132
- await sql.unsafe("PRAGMA foreign_keys = ON");
133
- await sql.unsafe("PRAGMA busy_timeout = 5000");
134
- await sql.unsafe("PRAGMA journal_mode = WAL");
135
- if (file !== ":memory:")
136
- await chmod(file, 384);
137
- }
138
- const store = new Store(sql, engine);
139
- try {
140
- await store.migrate();
141
- } catch {
142
- await sql.close();
143
- throw new Fault(500, "storage_unavailable", "Database migration failed; check server database configuration.");
143
+ const engine = config.databaseUrl ? "postgresql" : "sqlite";
144
+ if (config.databaseUrl && !/^postgres(ql)?:\/\//.test(config.databaseUrl))
145
+ throw new Fault(500, "storage_config", "Database URL must use PostgreSQL.");
146
+ const file = config.sqlitePath;
147
+ if (engine === "sqlite" && file !== ":memory:")
148
+ await mkdir(dirname(resolve(file)), { recursive: true, mode: 448 });
149
+ const deadline = Date.now() + 1e4;
150
+ for (let attempt = 0;; attempt++) {
151
+ let sql;
152
+ try {
153
+ sql = engine === "postgresql" ? new SQL(config.databaseUrl) : new SQL({ adapter: "sqlite", filename: file });
154
+ if (engine === "sqlite") {
155
+ await sql.unsafe("PRAGMA busy_timeout = 5000");
156
+ await sql.unsafe("PRAGMA foreign_keys = ON");
157
+ await sql.unsafe("PRAGMA journal_mode = WAL");
158
+ if (file !== ":memory:")
159
+ await chmod(file, 384);
160
+ }
161
+ const store = new Store(sql, engine);
162
+ await store.migrate();
163
+ return store;
164
+ } catch (error) {
165
+ await sql?.close().catch(() => {});
166
+ const code = error?.code;
167
+ if (engine === "sqlite" && ["SQLITE_BUSY", "SQLITE_BUSY_SNAPSHOT", "SQLITE_LOCKED"].includes(code ?? "") && Date.now() < deadline) {
168
+ await new Promise((resolve2) => setTimeout(resolve2, Math.min(200, 20 * (attempt + 1))));
169
+ continue;
170
+ }
171
+ throw new Fault(500, "storage_unavailable", "Database startup failed; check configuration, permissions and other database users.");
172
+ }
144
173
  }
145
- return store;
146
174
  }
147
175
  async migrate() {
148
176
  await this.sql.begin(async (tx) => {
@@ -292,24 +320,48 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
292
320
  // src/catalog.ts
293
321
  var positive = (v) => typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
294
322
  var strings = (v) => Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
295
- async function discover(provider, env = process.env) {
323
+ var modalities = (v) => {
324
+ if (v === undefined)
325
+ return;
326
+ if (!Array.isArray(v) || !v.every((i) => typeof i === "string"))
327
+ throw new Fault(502, "invalid_catalog", "Provider returned malformed modality metadata.");
328
+ return v.map((i) => i.toLowerCase());
329
+ };
330
+ async function discover(provider, env = process.env, resolveCredential) {
296
331
  const refreshedAt = new Date().toISOString();
297
332
  if (provider.manualModels.length)
298
333
  return { models: provider.manualModels, source: "manual", refreshedAt };
334
+ if (provider.catalogFormat === "none")
335
+ throw new Fault(422, "catalog_unsupported", "This provider has no documented model-list contract; configure manual models or an explicit catalog URL and parser.");
299
336
  const headers = { accept: "application/json" };
300
- if (provider.credentialEnv) {
301
- const credential = env[provider.credentialEnv];
337
+ if (provider.catalogFormat === "fireworks" && !provider.catalogBaseUrl && !provider.catalogAccountId)
338
+ throw new Fault(422, "catalog_account_required", "Fireworks model discovery requires a catalog account ID or an explicit catalog URL.");
339
+ if (provider.catalogFormat === "fireworks" && !provider.catalogBaseUrl && new URL(provider.baseUrl).origin !== "https://api.fireworks.ai")
340
+ throw new Fault(422, "catalog_url_required", "A custom Fireworks inference authority requires an explicit catalog URL; its deployment prefix cannot be inferred.");
341
+ const catalogRoot = provider.catalogBaseUrl ?? (provider.catalogFormat === "fireworks" ? `https://api.fireworks.ai/v1/accounts/${encodeURIComponent(provider.catalogAccountId)}` : provider.baseUrl);
342
+ const url = new URL(`${catalogRoot}/${provider.modelsPath}`);
343
+ if (provider.catalogFormat === "fireworks")
344
+ url.searchParams.set("pageSize", "200");
345
+ const authStyle = provider.catalogAuthStyle ?? provider.authStyle;
346
+ const credentialEnv = provider.catalogCredentialEnv ?? provider.credentialEnv;
347
+ if (authStyle !== "none" && credentialEnv) {
348
+ if (url.origin !== new URL(provider.baseUrl).origin && !provider.catalogCredentialEnv)
349
+ throw new Fault(422, "catalog_credential_authority", "A different catalog origin requires an explicit catalog credential reference or catalogAuthStyle: none.");
350
+ const credential = resolveCredential ? await resolveCredential({ ...provider, baseUrl: provider.catalogBaseUrl ?? provider.baseUrl, credentialEnv }) : env[credentialEnv];
302
351
  if (!credential)
303
352
  throw new Fault(422, "credential_missing", "Provider credential environment variable is not available on the server.");
304
- headers[provider.authStyle === "x-api-key" ? "x-api-key" : "authorization"] = provider.authStyle === "x-api-key" ? credential : `Bearer ${credential}`;
353
+ if (/[\r\n]/.test(credential))
354
+ throw new Fault(422, "credential_invalid", "Catalog credential contains invalid header characters.");
355
+ headers[authStyle === "x-api-key" ? "x-api-key" : "authorization"] = authStyle === "x-api-key" ? credential : `Bearer ${credential}`;
305
356
  }
306
- const url = new URL(`${provider.baseUrl}/${provider.modelsPath}`);
307
357
  if (provider.protocol === "anthropic-messages" && url.hostname !== "openrouter.ai")
308
358
  headers["anthropic-version"] = "2023-06-01";
309
359
  if (url.hostname === "openrouter.ai")
310
360
  url.searchParams.set("output_modalities", "all");
311
361
  const models = new Map;
312
362
  const seenCursors = new Set;
363
+ const seenPages = new Set([url.href]);
364
+ let fireworksTotal;
313
365
  for (let page = 0;page < 100; page++) {
314
366
  let response;
315
367
  try {
@@ -322,21 +374,56 @@ async function discover(provider, env = process.env) {
322
374
  throw new Fault(502, "provider_rejected", `Provider catalog returned HTTP ${response.status}.`);
323
375
  }
324
376
  const data = await boundedJson(response);
325
- if (!Array.isArray(data.data))
326
- throw new Fault(502, "invalid_catalog", "Expected a provider catalog with a data array.");
327
- for (const row of data.data) {
328
- if (!row || typeof row.id !== "string")
377
+ if (provider.catalogFormat === "fireworks" && data?.totalSize !== undefined) {
378
+ if (typeof data.totalSize !== "number" || !Number.isInteger(data.totalSize) || data.totalSize < 0)
379
+ throw new Fault(502, "invalid_catalog", "Fireworks catalog count metadata is malformed.");
380
+ if (fireworksTotal !== undefined && fireworksTotal !== data.totalSize)
381
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count changed during pagination; retry the refresh.");
382
+ fireworksTotal = data.totalSize;
383
+ }
384
+ const rows = provider.catalogFormat === "together" ? data : provider.catalogFormat === "ollama" ? data?.models : provider.catalogFormat === "fireworks" ? data?.models : provider.catalogFormat === "dashscope" ? data?.output?.models : data?.data;
385
+ if (!Array.isArray(rows))
386
+ throw new Fault(502, "invalid_catalog", "Expected a provider catalog with a model array matching its configured format.");
387
+ for (const row of rows) {
388
+ const id = provider.catalogFormat === "ollama" ? row?.model ?? row?.name : provider.catalogFormat === "fireworks" ? row?.name : provider.catalogFormat === "dashscope" ? row?.model : row?.id;
389
+ if (typeof id !== "string")
329
390
  throw new Fault(502, "invalid_catalog", "Catalog entry is missing a model ID.");
330
391
  const candidate = {
331
- id: row.id,
332
- name: row.name ?? row.display_name ?? row.id,
392
+ id,
393
+ name: row.displayName ?? row.name ?? row.display_name ?? id,
394
+ available: provider.catalogFormat === "mistral" && typeof row.archived === "boolean" ? !row.archived : undefined,
333
395
  description: typeof row.description === "string" ? row.description.slice(0, 8000) : undefined,
334
- contextWindow: positive(row.context_length ?? row.context_window),
335
- maxOutputTokens: positive(row.top_provider?.max_completion_tokens ?? row.max_output_tokens),
336
- inputModalities: strings(row.architecture?.input_modalities ?? row.input_modalities),
337
- outputModalities: strings(row.architecture?.output_modalities ?? row.output_modalities),
396
+ contextWindow: positive(row.context_length ?? row.context_window ?? row.contextLength ?? row.model_info?.context_window ?? (provider.catalogFormat === "mistral" ? row.max_context_length : undefined)),
397
+ maxOutputTokens: positive(row.top_provider?.max_completion_tokens ?? row.max_output_tokens ?? row.model_info?.max_output_tokens),
398
+ inputModalities: strings(row.architecture?.input_modalities ?? row.input_modalities) ?? modalities(row.inference_metadata?.request_modality),
399
+ outputModalities: strings(row.architecture?.output_modalities ?? row.output_modalities) ?? modalities(row.inference_metadata?.response_modality),
338
400
  supportedParameters: strings(row.supported_parameters)
339
401
  };
402
+ if (provider.catalogFormat === "mistral") {
403
+ const capabilities = row.capabilities;
404
+ if (typeof capabilities?.function_calling === "boolean")
405
+ candidate.supportedParameters = capabilities.function_calling ? ["tools"] : [];
406
+ if (typeof capabilities?.vision === "boolean")
407
+ candidate.inputModalities = capabilities.vision ? ["text", "image"] : ["text"];
408
+ if (typeof capabilities?.completion_chat === "boolean")
409
+ candidate.outputModalities = capabilities.completion_chat ? ["text"] : [];
410
+ }
411
+ if (provider.catalogFormat === "together") {
412
+ const modalities2 = { chat: ["text"], language: ["text"], code: ["text"], image: ["image"], audio: ["audio"], video: ["video"], embedding: ["embedding"], rerank: ["rerank"], moderation: ["classification"] };
413
+ candidate.outputModalities = modalities2[row.type];
414
+ }
415
+ if (provider.catalogFormat === "fireworks") {
416
+ if (row.supportsImageInput === true)
417
+ candidate.inputModalities = ["text", "image"];
418
+ if (typeof row.supportsTools === "boolean")
419
+ candidate.supportedParameters = row.supportsTools ? ["tools"] : [];
420
+ }
421
+ if (provider.catalogFormat === "dashscope") {
422
+ if (row.features !== undefined && !Array.isArray(row.features))
423
+ throw new Fault(502, "invalid_catalog", "DashScope model features metadata is malformed.");
424
+ if (Array.isArray(row.features))
425
+ candidate.supportedParameters = row.features.includes("function-calling") ? ["tools"] : [];
426
+ }
340
427
  const parsed = modelSchema.safeParse(candidate);
341
428
  if (!parsed.success)
342
429
  throw new Fault(502, "invalid_catalog", "Provider returned malformed model metadata.");
@@ -344,8 +431,68 @@ async function discover(provider, env = process.env) {
344
431
  if (models.size > 1e4)
345
432
  throw new Fault(502, "catalog_too_large", "Catalog exceeds 10,000 models; configure a narrower endpoint.");
346
433
  }
347
- if (!data.has_more)
434
+ const next = data.links?.next;
435
+ if (next !== undefined && next !== null) {
436
+ if (typeof next !== "string" || !next || next.length > 2000)
437
+ throw new Fault(502, "invalid_catalog", "Provider returned an invalid catalog continuation link.");
438
+ let target;
439
+ try {
440
+ target = new URL(next, url);
441
+ } catch {
442
+ throw new Fault(502, "invalid_catalog", "Provider returned an invalid catalog continuation link.");
443
+ }
444
+ if (target.origin !== url.origin || target.pathname !== url.pathname || target.username || target.password || target.hash)
445
+ throw new Fault(502, "catalog_credential_authority", "Catalog pagination must stay on its original origin and path.");
446
+ if (url.searchParams.has("output_modalities"))
447
+ target.searchParams.set("output_modalities", url.searchParams.get("output_modalities"));
448
+ if (seenPages.has(target.href))
449
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
450
+ seenPages.add(target.href);
451
+ url.href = target.href;
452
+ continue;
453
+ }
454
+ if (provider.catalogFormat === "fireworks" && data.nextPageToken !== undefined && data.nextPageToken !== null) {
455
+ if (typeof data.nextPageToken !== "string" || data.nextPageToken.length > 2000)
456
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
457
+ if (data.nextPageToken) {
458
+ if (seenCursors.has(data.nextPageToken))
459
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
460
+ seenCursors.add(data.nextPageToken);
461
+ url.searchParams.set("pageToken", data.nextPageToken);
462
+ url.searchParams.set("pageSize", "200");
463
+ continue;
464
+ }
465
+ }
466
+ if (provider.catalogFormat === "fireworks") {
467
+ if (fireworksTotal !== undefined && fireworksTotal !== models.size)
468
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count does not match the collected models; retry the refresh.");
348
469
  return { models: [...models.values()], source: "remote", refreshedAt };
470
+ }
471
+ if (provider.catalogFormat === "dashscope") {
472
+ const output = data.output;
473
+ const total = output?.total;
474
+ const pageNo = output?.page_no;
475
+ const pageSize = output?.page_size;
476
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 0 || typeof pageNo !== "number" || !Number.isInteger(pageNo) || pageNo < 1 || typeof pageSize !== "number" || !Number.isInteger(pageSize) || pageSize < 1)
477
+ throw new Fault(502, "invalid_catalog", "DashScope catalog pagination metadata is malformed.");
478
+ if (models.size < total && pageNo * pageSize < total) {
479
+ const nextPage = pageNo + 1;
480
+ if (seenCursors.has(String(nextPage)))
481
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
482
+ seenCursors.add(String(nextPage));
483
+ url.searchParams.set("page_no", String(nextPage));
484
+ url.searchParams.set("page_size", String(pageSize));
485
+ continue;
486
+ }
487
+ if (models.size !== total)
488
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count does not match the collected models; retry the refresh.");
489
+ return { models: [...models.values()], source: "remote", refreshedAt };
490
+ }
491
+ if (provider.catalogFormat === "together" || !data.has_more) {
492
+ if (typeof data.total_count === "number" && data.total_count !== models.size)
493
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count does not match the collected models; retry the refresh.");
494
+ return { models: [...models.values()], source: "remote", refreshedAt };
495
+ }
349
496
  const cursor = data.last_id;
350
497
  if (typeof cursor !== "string" || seenCursors.has(cursor))
351
498
  throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
@@ -355,12 +502,92 @@ async function discover(provider, env = process.env) {
355
502
  }
356
503
  throw new Fault(502, "catalog_too_large", "Provider catalog pagination exceeded 100 pages.");
357
504
  }
505
+
506
+ // src/presets.ts
507
+ var route = (protocol, baseUrl, options = {}) => ({
508
+ protocol,
509
+ baseUrl,
510
+ authStyle: "bearer",
511
+ catalogFormat: "openai",
512
+ modelsPath: "models",
513
+ notes: [],
514
+ ...options
515
+ });
516
+ var preset = (id, name, protocols, sources, alias) => parse(providerPresetSchema, {
517
+ id,
518
+ name,
519
+ protocols,
520
+ sources,
521
+ credentialAliases: alias ? [alias] : [],
522
+ credentialEnv: alias ? `SWITCHER_PROVIDER_${id.toUpperCase().replace(/-/g, "_")}` : undefined,
523
+ verification: "documented"
524
+ });
525
+ var providerPresets = [
526
+ preset("deepseek", "DeepSeek", [
527
+ route("openai-chat", "https://api.deepseek.com", { catalogBaseUrl: "https://api.deepseek.com" }),
528
+ route("anthropic-messages", "https://api.deepseek.com/anthropic/v1", { catalogBaseUrl: "https://api.deepseek.com" })
529
+ ], ["https://api-docs.deepseek.com/guides/anthropic_api", "https://api-docs.deepseek.com/api/list-models"], "DEEPSEEK_API_KEY"),
530
+ preset("openrouter", "OpenRouter", ["openai-chat", "openai-responses", "anthropic-messages"].map((protocol) => route(protocol, "https://openrouter.ai/api/v1", { catalogAuthStyle: "none" })), ["https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties", "https://openrouter.ai/docs/guides/overview"], "OPENROUTER_API_KEY"),
531
+ preset("anthropic", "Anthropic", [route("anthropic-messages", "https://api.anthropic.com/v1", { authStyle: "x-api-key" })], ["https://platform.claude.com/docs/en/api/overview", "https://platform.claude.com/docs/en/api/models/list"], "ANTHROPIC_API_KEY"),
532
+ preset("openai", "OpenAI", [route("openai-responses", "https://api.openai.com/v1"), route("openai-chat", "https://api.openai.com/v1")], ["https://platform.openai.com/docs/api-reference/introduction", "https://platform.openai.com/docs/api-reference/models/list"], "OPENAI_API_KEY"),
533
+ preset("xai", "xAI", ["openai-chat", "openai-responses", "anthropic-messages"].map((protocol) => route(protocol, "https://api.x.ai/v1")), ["https://api.x.ai/docs/", "https://docs.x.ai/developers/model-capabilities/text/generate-text"], "XAI_API_KEY"),
534
+ preset("ollama", "Ollama", ["openai-chat", "openai-responses"].map((protocol) => route(protocol, "http://127.0.0.1:11434/v1", {
535
+ catalogBaseUrl: "http://127.0.0.1:11434",
536
+ modelsPath: "api/tags",
537
+ catalogFormat: "ollama",
538
+ catalogAuthStyle: "none",
539
+ notes: protocol === "openai-responses" ? ["Requires Ollama 0.13.3 or newer; only stateless Responses are supported."] : []
540
+ })), ["https://docs.ollama.com/api/openai-compatibility", "https://docs.ollama.com/api/tags"]),
541
+ preset("lmstudio", "LM Studio", ["openai-chat", "openai-responses", "anthropic-messages"].map((protocol) => route(protocol, "http://127.0.0.1:1234/v1", {
542
+ notes: ["Server authentication is optional. Use --credential-env when authentication is enabled."]
543
+ })), ["https://lmstudio.ai/docs/developer/rest"]),
544
+ preset("vllm", "vLLM (operator endpoint)", [
545
+ route("openai-chat", undefined, { notes: ["Pass the operator's OpenAI-compatible URL, normally ending in /v1. vLLM exposes Chat Completions at /chat/completions and GET /models under that prefix; configure --credential-env only when the server was started with --api-key or VLLM_API_KEY."] }),
546
+ route("openai-responses", undefined, { notes: ["Pass the operator's OpenAI-compatible URL, normally ending in /v1. Responses is available for supported text-generation models at /responses; configure --credential-env only when the server was started with --api-key or VLLM_API_KEY."] }),
547
+ route("anthropic-messages", undefined, { notes: ["Pass the operator's URL, normally ending in /v1. vLLM exposes the Anthropic Messages API at /messages for supported deployments. Chat templates and the configured tool parser determine whether streaming and tool calls work for a served model; configure --credential-env only when the server was started with --api-key or VLLM_API_KEY."] })
548
+ ], ["https://docs.vllm.ai/en/latest/serving/online_serving/openai_compatible_server/", "https://github.com/vllm-project/vllm/blob/main/docs/serving/online_serving/README.md"]),
549
+ preset("litellm", "LiteLLM Proxy (operator endpoint)", [
550
+ route("openai-chat", undefined, { notes: ["Pass the proxy's documented OpenAI-compatible base URL exactly; the official quick start uses the root server URL, while a deployment may add a prefix such as /v1. LiteLLM serves Chat Completions and GET /models relative to that URL; use --credential-env for the proxy's configured master key or other bearer token."] }),
551
+ route("openai-responses", undefined, { notes: ["Pass the proxy's documented OpenAI-compatible base URL exactly; LiteLLM documents the Responses API under the same proxy root or deployment prefix. Use --credential-env for the proxy's configured master key or other bearer token."] }),
552
+ route("anthropic-messages", undefined, { notes: ["Pass the complete inference prefix ending in /v1, including any deployment prefix. LiteLLM registers /v1/messages; Switcher appends /messages to the stored prefix and discovers /models there. This is a gateway adapter: streaming and tool behavior depend on the configured upstream model and route, so verify those capabilities independently. Use --credential-env for the proxy's configured master key or other bearer token."] })
553
+ ], ["https://docs.litellm.ai/", "https://docs.litellm.ai/docs/proxy/quick_start", "https://github.com/BerriAI/litellm/blob/main/litellm/proxy/anthropic_endpoints/endpoints.py"]),
554
+ preset("groq", "Groq", [route("openai-chat", "https://api.groq.com/openai/v1"), route("openai-responses", "https://api.groq.com/openai/v1", { notes: ["Responses is an upstream beta API."] })], ["https://console.groq.com/docs/api-reference"], "GROQ_API_KEY"),
555
+ preset("cerebras", "Cerebras", [route("openai-chat", "https://api.cerebras.ai/v1")], ["https://inference-docs.cerebras.ai/api-reference/chat-completions"], "CEREBRAS_API_KEY"),
556
+ preset("mistral", "Mistral", [route("openai-chat", "https://api.mistral.ai/v1", { catalogFormat: "mistral" })], ["https://docs.mistral.ai/api/endpoint/chat", "https://docs.mistral.ai/api/endpoint/models"], "MISTRAL_API_KEY"),
557
+ preset("together", "Together AI", [route("openai-chat", "https://api.together.ai/v1", { catalogFormat: "together" })], ["https://docs.together.ai/docs/inference/openai-compatibility", "https://docs.together.ai/reference/models"], "TOGETHER_API_KEY"),
558
+ preset("fireworks", "Fireworks AI", [
559
+ route("openai-chat", "https://api.fireworks.ai/inference/v1", { catalogFormat: "fireworks", notes: ["Model discovery uses GET /v1/accounts/{account_id}/models; provide --catalog-account-id or --catalog-url."] }),
560
+ route("openai-responses", "https://api.fireworks.ai/inference/v1", { catalogFormat: "fireworks", notes: ["Model discovery uses GET /v1/accounts/{account_id}/models; provide --catalog-account-id or --catalog-url."] }),
561
+ route("anthropic-messages", "https://api.fireworks.ai/inference/v1", { catalogFormat: "fireworks", notes: ["Model discovery uses GET /v1/accounts/{account_id}/models; provide --catalog-account-id or --catalog-url."] })
562
+ ], ["https://docs.fireworks.ai/getting-started/quickstart", "https://docs.fireworks.ai/tools-sdks/python-client/api-reference", "https://docs.fireworks.ai/api-reference/anthropic-messages", "https://docs.fireworks.ai/api-reference/post-chatcompletions", "https://docs.fireworks.ai/api-reference/list-models"], "FIREWORKS_API_KEY"),
563
+ preset("moonshot", "Moonshot AI (Kimi)", [route("openai-chat", "https://api.moonshot.ai/v1", { catalogBaseUrl: "https://api.moonshot.ai/v1" })], ["https://platform.kimi.ai/docs/api/chat", "https://platform.kimi.ai/docs/api/list-models"], "MOONSHOT_API_KEY"),
564
+ preset("dashscope", "Alibaba Cloud Model Studio (Qwen)", [route("openai-chat", "https://dashscope-us.aliyuncs.com/compatible-mode/v1", {
565
+ catalogFormat: "none",
566
+ notes: ["Inference keys and endpoints are region/workspace-specific. Model discovery uses GET /api/v1/models on a documented region or workspace catalog URL; pass --catalog-url and --catalog-format dashscope."]
567
+ })], ["https://help.aliyun.com/en/model-studio/base-url", "https://help.aliyun.com/en/model-studio/compatibility-of-openai-with-dashscope", "https://help.aliyun.com/en/model-studio/list-models"], "DASHSCOPE_API_KEY"),
568
+ preset("zai", "Z.AI", [route("openai-chat", "https://api.z.ai/api/paas/v4", {
569
+ catalogFormat: "none",
570
+ notes: ["The published API reference documents inference endpoints but no model-list endpoint; use manual models or provide an explicit catalog URL and parser."]
571
+ })], ["https://docs.z.ai/api-reference/introduction", "https://docs.z.ai/devpack/quick-start"], "ZAI_API_KEY"),
572
+ preset("minimax", "MiniMax", [
573
+ route("openai-chat", "https://api.minimax.cn/v1", { catalogBaseUrl: "https://api.minimax.cn/v1", notes: ["The Open Platform contract uses api.minimax.cn and Bearer auth. Token Plan documentation uses api.minimaxi.com; select that authority explicitly with --url and matching auth/credential settings."] }),
574
+ route("anthropic-messages", "https://api.minimax.cn/anthropic/v1", { authStyle: "x-api-key", catalogBaseUrl: "https://api.minimax.cn/anthropic/v1", catalogAuthStyle: "x-api-key", notes: ["The Open Platform contract uses api.minimax.cn/anthropic/v1 and X-Api-Key. Token Plan documentation uses api.minimaxi.com/anthropic; select that authority explicitly with --url and matching auth/credential settings."] })
575
+ ], ["https://platform.minimaxi.com/docs/api-reference/text-chat-anthropic", "https://platform.minimaxi.com/docs/api-reference/models/anthropic/list-models", "https://platform.minimaxi.com/docs/api-reference/models/openai/list-models", "https://platform.minimaxi.com/docs/token-plan/other-tools"], "MINIMAX_API_KEY"),
576
+ preset("siliconflow", "SiliconFlow", [route("openai-chat", "https://api.siliconflow.cn/v1", { catalogBaseUrl: "https://api.siliconflow.cn/v1", catalogFormat: "openai", notes: ["The official SiliconCloud OpenAPI contract defines GET /models with Bearer auth and data[] model rows; optional type and sub_type filters are available at the upstream endpoint."] })], ["https://github.com/siliconflow/siliconcloud/blob/main/openapi.yaml", "https://docs.siliconflow.cn/docs/userguide/quickstart", "https://docs.siliconflow.cn/docs/api/chat-completions-post"], "SILICONFLOW_API_KEY"),
577
+ ...["anthropic-messages", "openai-responses", "openai-chat"].map((protocol) => preset(`generic-${protocol}`, `Custom ${protocol}`, [route(protocol)], []))
578
+ ];
579
+ function getProviderPreset(id) {
580
+ const entry = providerPresets.find((p) => p.id === id);
581
+ if (!entry)
582
+ throw new Fault(404, "preset_not_found", "Unknown provider preset. Use switcher providers presets to list available presets.");
583
+ return structuredClone(entry);
584
+ }
358
585
  // openapi.json
359
586
  var openapi_default = {
360
587
  openapi: "3.0.3",
361
588
  info: {
362
589
  title: "Switcher API",
363
- version: "0.1.0",
590
+ version: "0.1.1",
364
591
  description: "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
365
592
  },
366
593
  security: [
@@ -945,6 +1172,82 @@ var openapi_default = {
945
1172
  }
946
1173
  }
947
1174
  },
1175
+ "/v1/provider-presets": {
1176
+ get: {
1177
+ operationId: "listProviderPresets",
1178
+ parameters: [],
1179
+ responses: {
1180
+ "200": {
1181
+ description: "Success",
1182
+ content: {
1183
+ "application/json": {
1184
+ schema: {
1185
+ type: "object",
1186
+ required: [
1187
+ "data"
1188
+ ],
1189
+ properties: {
1190
+ data: {
1191
+ type: "array",
1192
+ items: {
1193
+ $ref: "#/components/schemas/ProviderPreset"
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1199
+ }
1200
+ },
1201
+ default: {
1202
+ description: "Structured error",
1203
+ content: {
1204
+ "application/json": {
1205
+ schema: {
1206
+ $ref: "#/components/schemas/Error"
1207
+ }
1208
+ }
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1213
+ },
1214
+ "/v1/provider-presets/{id}": {
1215
+ get: {
1216
+ operationId: "getProviderPreset",
1217
+ parameters: [
1218
+ {
1219
+ name: "id",
1220
+ in: "path",
1221
+ required: true,
1222
+ schema: {
1223
+ type: "string"
1224
+ }
1225
+ }
1226
+ ],
1227
+ responses: {
1228
+ "200": {
1229
+ description: "Success",
1230
+ content: {
1231
+ "application/json": {
1232
+ schema: {
1233
+ $ref: "#/components/schemas/ProviderPreset"
1234
+ }
1235
+ }
1236
+ }
1237
+ },
1238
+ default: {
1239
+ description: "Structured error",
1240
+ content: {
1241
+ "application/json": {
1242
+ schema: {
1243
+ $ref: "#/components/schemas/Error"
1244
+ }
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ }
1250
+ },
948
1251
  "/v1/providers/{id}/models": {
949
1252
  get: {
950
1253
  operationId: "listModels",
@@ -1465,6 +1768,122 @@ var openapi_default = {
1465
1768
  }
1466
1769
  },
1467
1770
  schemas: {
1771
+ ProviderPreset: {
1772
+ type: "object",
1773
+ properties: {
1774
+ id: {
1775
+ type: "string",
1776
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1777
+ },
1778
+ name: {
1779
+ type: "string",
1780
+ minLength: 1,
1781
+ maxLength: 200
1782
+ },
1783
+ credentialEnv: {
1784
+ type: "string",
1785
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
1786
+ },
1787
+ credentialAliases: {
1788
+ type: "array",
1789
+ items: {
1790
+ type: "string",
1791
+ pattern: "^[A-Z][A-Z0-9_]+$"
1792
+ }
1793
+ },
1794
+ protocols: {
1795
+ type: "array",
1796
+ items: {
1797
+ type: "object",
1798
+ properties: {
1799
+ protocol: {
1800
+ type: "string",
1801
+ enum: [
1802
+ "anthropic-messages",
1803
+ "openai-responses",
1804
+ "openai-chat"
1805
+ ]
1806
+ },
1807
+ baseUrl: {
1808
+ type: "string",
1809
+ maxLength: 2000
1810
+ },
1811
+ authStyle: {
1812
+ type: "string",
1813
+ enum: [
1814
+ "bearer",
1815
+ "x-api-key"
1816
+ ]
1817
+ },
1818
+ catalogBaseUrl: {
1819
+ type: "string",
1820
+ maxLength: 2000
1821
+ },
1822
+ catalogFormat: {
1823
+ type: "string",
1824
+ enum: [
1825
+ "openai",
1826
+ "ollama",
1827
+ "mistral",
1828
+ "together",
1829
+ "fireworks",
1830
+ "dashscope",
1831
+ "none"
1832
+ ]
1833
+ },
1834
+ catalogAuthStyle: {
1835
+ type: "string",
1836
+ enum: [
1837
+ "bearer",
1838
+ "x-api-key",
1839
+ "none"
1840
+ ]
1841
+ },
1842
+ modelsPath: {
1843
+ type: "string"
1844
+ },
1845
+ notes: {
1846
+ type: "array",
1847
+ items: {
1848
+ type: "string"
1849
+ }
1850
+ }
1851
+ },
1852
+ required: [
1853
+ "protocol",
1854
+ "authStyle",
1855
+ "catalogFormat",
1856
+ "modelsPath",
1857
+ "notes"
1858
+ ],
1859
+ additionalProperties: false
1860
+ },
1861
+ minItems: 1
1862
+ },
1863
+ sources: {
1864
+ type: "array",
1865
+ items: {
1866
+ type: "string",
1867
+ format: "uri"
1868
+ }
1869
+ },
1870
+ verification: {
1871
+ type: "string",
1872
+ enum: [
1873
+ "documented"
1874
+ ]
1875
+ }
1876
+ },
1877
+ required: [
1878
+ "id",
1879
+ "name",
1880
+ "credentialAliases",
1881
+ "protocols",
1882
+ "sources",
1883
+ "verification"
1884
+ ],
1885
+ additionalProperties: false
1886
+ },
1468
1887
  ProviderInput: {
1469
1888
  type: "object",
1470
1889
  properties: {
@@ -1501,6 +1920,38 @@ var openapi_default = {
1501
1920
  ],
1502
1921
  default: "bearer"
1503
1922
  },
1923
+ catalogBaseUrl: {
1924
+ type: "string",
1925
+ maxLength: 2000
1926
+ },
1927
+ catalogFormat: {
1928
+ type: "string",
1929
+ enum: [
1930
+ "openai",
1931
+ "ollama",
1932
+ "mistral",
1933
+ "together",
1934
+ "fireworks",
1935
+ "dashscope",
1936
+ "none"
1937
+ ]
1938
+ },
1939
+ catalogAuthStyle: {
1940
+ type: "string",
1941
+ enum: [
1942
+ "bearer",
1943
+ "x-api-key",
1944
+ "none"
1945
+ ]
1946
+ },
1947
+ catalogCredentialEnv: {
1948
+ type: "string",
1949
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
1950
+ },
1951
+ catalogAccountId: {
1952
+ type: "string",
1953
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
1954
+ },
1504
1955
  modelsPath: {
1505
1956
  type: "string",
1506
1957
  pattern: "^[a-zA-Z0-9_/-]+$",
@@ -1526,6 +1977,9 @@ var openapi_default = {
1526
1977
  type: "string",
1527
1978
  maxLength: 8000
1528
1979
  },
1980
+ available: {
1981
+ type: "boolean"
1982
+ },
1529
1983
  contextWindow: {
1530
1984
  type: "integer",
1531
1985
  exclusiveMinimum: true,
@@ -1615,6 +2069,38 @@ var openapi_default = {
1615
2069
  ],
1616
2070
  default: "bearer"
1617
2071
  },
2072
+ catalogBaseUrl: {
2073
+ type: "string",
2074
+ maxLength: 2000
2075
+ },
2076
+ catalogFormat: {
2077
+ type: "string",
2078
+ enum: [
2079
+ "openai",
2080
+ "ollama",
2081
+ "mistral",
2082
+ "together",
2083
+ "fireworks",
2084
+ "dashscope",
2085
+ "none"
2086
+ ]
2087
+ },
2088
+ catalogAuthStyle: {
2089
+ type: "string",
2090
+ enum: [
2091
+ "bearer",
2092
+ "x-api-key",
2093
+ "none"
2094
+ ]
2095
+ },
2096
+ catalogCredentialEnv: {
2097
+ type: "string",
2098
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
2099
+ },
2100
+ catalogAccountId: {
2101
+ type: "string",
2102
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
2103
+ },
1618
2104
  modelsPath: {
1619
2105
  type: "string",
1620
2106
  pattern: "^[a-zA-Z0-9_/-]+$",
@@ -1640,6 +2126,9 @@ var openapi_default = {
1640
2126
  type: "string",
1641
2127
  maxLength: 8000
1642
2128
  },
2129
+ available: {
2130
+ type: "boolean"
2131
+ },
1643
2132
  contextWindow: {
1644
2133
  type: "integer",
1645
2134
  exclusiveMinimum: true,
@@ -1725,7 +2214,8 @@ var openapi_default = {
1725
2214
  "claude",
1726
2215
  "codex",
1727
2216
  "grok",
1728
- "opencode2"
2217
+ "opencode2",
2218
+ "pi"
1729
2219
  ]
1730
2220
  },
1731
2221
  model: {
@@ -1765,7 +2255,8 @@ var openapi_default = {
1765
2255
  "claude",
1766
2256
  "codex",
1767
2257
  "grok",
1768
- "opencode2"
2258
+ "opencode2",
2259
+ "pi"
1769
2260
  ]
1770
2261
  },
1771
2262
  model: {
@@ -1810,6 +2301,9 @@ var openapi_default = {
1810
2301
  type: "string",
1811
2302
  maxLength: 8000
1812
2303
  },
2304
+ available: {
2305
+ type: "boolean"
2306
+ },
1813
2307
  contextWindow: {
1814
2308
  type: "integer",
1815
2309
  exclusiveMinimum: true,
@@ -1873,6 +2367,9 @@ var openapi_default = {
1873
2367
  type: "string",
1874
2368
  maxLength: 8000
1875
2369
  },
2370
+ available: {
2371
+ type: "boolean"
2372
+ },
1876
2373
  contextWindow: {
1877
2374
  type: "integer",
1878
2375
  exclusiveMinimum: true,
@@ -1971,6 +2468,9 @@ var openapi_default = {
1971
2468
  type: "string",
1972
2469
  maxLength: 8000
1973
2470
  },
2471
+ available: {
2472
+ type: "boolean"
2473
+ },
1974
2474
  contextWindow: {
1975
2475
  type: "integer",
1976
2476
  exclusiveMinimum: true,
@@ -2070,6 +2570,38 @@ var openapi_default = {
2070
2570
  ],
2071
2571
  default: "bearer"
2072
2572
  },
2573
+ catalogBaseUrl: {
2574
+ type: "string",
2575
+ maxLength: 2000
2576
+ },
2577
+ catalogFormat: {
2578
+ type: "string",
2579
+ enum: [
2580
+ "openai",
2581
+ "ollama",
2582
+ "mistral",
2583
+ "together",
2584
+ "fireworks",
2585
+ "dashscope",
2586
+ "none"
2587
+ ]
2588
+ },
2589
+ catalogAuthStyle: {
2590
+ type: "string",
2591
+ enum: [
2592
+ "bearer",
2593
+ "x-api-key",
2594
+ "none"
2595
+ ]
2596
+ },
2597
+ catalogCredentialEnv: {
2598
+ type: "string",
2599
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
2600
+ },
2601
+ catalogAccountId: {
2602
+ type: "string",
2603
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
2604
+ },
2073
2605
  modelsPath: {
2074
2606
  type: "string",
2075
2607
  pattern: "^[a-zA-Z0-9_/-]+$",
@@ -2095,6 +2627,9 @@ var openapi_default = {
2095
2627
  type: "string",
2096
2628
  maxLength: 8000
2097
2629
  },
2630
+ available: {
2631
+ type: "boolean"
2632
+ },
2098
2633
  contextWindow: {
2099
2634
  type: "integer",
2100
2635
  exclusiveMinimum: true,
@@ -2180,7 +2715,8 @@ var openapi_default = {
2180
2715
  "claude",
2181
2716
  "codex",
2182
2717
  "grok",
2183
- "opencode2"
2718
+ "opencode2",
2719
+ "pi"
2184
2720
  ]
2185
2721
  },
2186
2722
  model: {
@@ -2230,6 +2766,9 @@ var openapi_default = {
2230
2766
  type: "string",
2231
2767
  maxLength: 8000
2232
2768
  },
2769
+ available: {
2770
+ type: "boolean"
2771
+ },
2233
2772
  contextWindow: {
2234
2773
  type: "integer",
2235
2774
  exclusiveMinimum: true,
@@ -2322,7 +2861,8 @@ var openapi_default = {
2322
2861
  "claude",
2323
2862
  "codex",
2324
2863
  "grok",
2325
- "opencode2"
2864
+ "opencode2",
2865
+ "pi"
2326
2866
  ]
2327
2867
  },
2328
2868
  model: {
@@ -2379,7 +2919,8 @@ var openapi_default = {
2379
2919
  "claude",
2380
2920
  "codex",
2381
2921
  "grok",
2382
- "opencode2"
2922
+ "opencode2",
2923
+ "pi"
2383
2924
  ]
2384
2925
  },
2385
2926
  model: {
@@ -2562,7 +3103,7 @@ var openapi_default = {
2562
3103
  // src/service.ts
2563
3104
  var snapshot = (profile, provider, catalog) => createHash("sha256").update(JSON.stringify([profile, provider, { models: catalog.models, source: catalog.source }])).digest("hex");
2564
3105
  var hash = (s) => createHash("sha256").update(s).digest();
2565
- function createHandler(store, apiKey, providerEnv = process.env) {
3106
+ function createHandler(store, apiKey, providerEnv = process.env, resolveCredential) {
2566
3107
  if (!apiKey || apiKey.length < 24)
2567
3108
  throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
2568
3109
  const expected = hash(`Bearer ${apiKey}`);
@@ -2571,12 +3112,12 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2571
3112
  const json = (body, status = 200) => Response.json(body, { status, headers: { "x-request-id": requestId, "cache-control": "no-store", "x-content-type-options": "nosniff" } });
2572
3113
  try {
2573
3114
  const url = new URL(request.url);
2574
- const route = url.pathname.replace(/\/$/, "");
2575
- if (request.method === "GET" && route === "/health")
3115
+ const route2 = url.pathname.replace(/\/$/, "");
3116
+ if (request.method === "GET" && route2 === "/health")
2576
3117
  return json({ status: "ok", version: VERSION, backend: store.engine });
2577
- if (request.method === "GET" && route === "/version")
3118
+ if (request.method === "GET" && route2 === "/version")
2578
3119
  return json({ version: VERSION });
2579
- if (request.method === "GET" && route === "/ready") {
3120
+ if (request.method === "GET" && route2 === "/ready") {
2580
3121
  try {
2581
3122
  await store.ready();
2582
3123
  return json({ ready: true });
@@ -2586,9 +3127,9 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2586
3127
  }
2587
3128
  if (!timingSafeEqual(expected, hash(request.headers.get("authorization") ?? "")))
2588
3129
  throw new Fault(401, "unauthorized", "A valid API bearer token is required.");
2589
- if (request.method === "GET" && ["/v1/openapi.json", "/openapi.json"].includes(route))
3130
+ if (request.method === "GET" && ["/v1/openapi.json", "/openapi.json"].includes(route2))
2590
3131
  return json(openapi_default);
2591
- const parts = route.split("/").filter(Boolean);
3132
+ const parts = route2.split("/").filter(Boolean);
2592
3133
  if (parts[0] !== "v1")
2593
3134
  throw new Fault(404, "not_found", "Route was not found.");
2594
3135
  const resource = parts[1];
@@ -2601,6 +3142,8 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2601
3142
  search: z2.string().max(200).default("")
2602
3143
  }).strict(), Object.fromEntries(url.searchParams));
2603
3144
  if (request.method === "GET") {
3145
+ if (resource === "provider-presets" && parts.length <= 3)
3146
+ return json(id ? getProviderPreset(id) : { data: providerPresets });
2604
3147
  if (["providers", "profiles", "runs"].includes(resource) && parts.length <= 3) {
2605
3148
  const kind = resource;
2606
3149
  return json(id ? await store.get(kind, id) : await store.list(kind, page()));
@@ -2628,7 +3171,7 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2628
3171
  throw new Fault(400, "invalid_json", "Request must contain valid JSON under 1 MiB.");
2629
3172
  }
2630
3173
  }
2631
- const fingerprint = hash(JSON.stringify([request.method, route, body, request.headers.get("if-match")])).toString("hex");
3174
+ const fingerprint = hash(JSON.stringify([request.method, route2, body, request.headers.get("if-match")])).toString("hex");
2632
3175
  const version = () => {
2633
3176
  const v = request.headers.get("if-match");
2634
3177
  if (!v || !/^[1-9]\d*$/.test(v))
@@ -2642,7 +3185,7 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2642
3185
  if (resource === "providers" && id && parts[3] === "refresh" && parts.length === 4 && request.method === "POST") {
2643
3186
  parse(z2.object({}).strict(), body);
2644
3187
  const provider = await store.get("providers", id);
2645
- refreshed = { provider, catalog: await discover(provider, providerEnv) };
3188
+ refreshed = { provider, catalog: await discover(provider, providerEnv, resolveCredential) };
2646
3189
  }
2647
3190
  const result = await store.mutate(key, fingerprint, async (db) => {
2648
3191
  if ((resource === "providers" || resource === "profiles") && parts.length <= 3) {
@@ -2747,6 +3290,46 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2747
3290
  };
2748
3291
  }
2749
3292
 
3293
+ // src/server.ts
3294
+ async function startServer(options) {
3295
+ const port = options.port ?? 0;
3296
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
3297
+ throw new Fault(400, "invalid_port", "Port must be an integer between 0 and 65535.");
3298
+ if (options.apiKey.length < 24 || /[\r\n]/.test(options.apiKey))
3299
+ throw new Fault(500, "auth_config", "Use a random operator token of at least 24 characters.");
3300
+ const store = await Store.open({ databaseUrl: options.databaseUrl, sqlitePath: options.sqlitePath });
3301
+ let server;
3302
+ try {
3303
+ server = Bun.serve({
3304
+ hostname: options.hostname ?? "127.0.0.1",
3305
+ port,
3306
+ maxRequestBodySize: 1024 * 1024,
3307
+ idleTimeout: 60,
3308
+ fetch: createHandler(store, options.apiKey, options.providerEnv, options.resolveCredential)
3309
+ });
3310
+ await store.ready();
3311
+ } catch (error) {
3312
+ await server?.stop(true);
3313
+ await store.close();
3314
+ throw error;
3315
+ }
3316
+ const listener = server;
3317
+ let closing;
3318
+ return {
3319
+ url: listener.url.href,
3320
+ storage: store.engine,
3321
+ close() {
3322
+ return closing ??= (async () => {
3323
+ try {
3324
+ await listener.stop(true);
3325
+ } finally {
3326
+ await store.close();
3327
+ }
3328
+ })();
3329
+ }
3330
+ };
3331
+ }
3332
+
2750
3333
  // src/serve.ts
2751
3334
  async function main(args = process.argv.slice(2)) {
2752
3335
  const { values } = parseArgs({ args, options: {
@@ -2776,16 +3359,19 @@ Requires HASNA_SWITCHER_API_KEY (24+ characters). Provider credentials: SWITCHER
2776
3359
  throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
2777
3360
  if (values.sqlite && values["data-dir"])
2778
3361
  throw new Fault(400, "storage_config", "Choose --sqlite or --data-dir.");
2779
- const store = await Store.open({ databaseUrl: process.env.HASNA_SWITCHER_DATABASE_URL, sqlitePath: values.sqlite ?? (values["data-dir"] ? join(values["data-dir"], "switcher.db") : process.env.HASNA_SWITCHER_SQLITE_PATH) });
2780
- const server = Bun.serve({ hostname: values.host ?? "127.0.0.1", port, maxRequestBodySize: 1024 * 1024, idleTimeout: 60, fetch: createHandler(store, apiKey) });
2781
- console.log(JSON.stringify({ event: "listening", version: VERSION, url: server.url.href, storage: store.engine }));
2782
- let stopping = false;
2783
- const stop = async () => {
2784
- if (stopping)
2785
- return;
2786
- stopping = true;
2787
- await server.stop();
2788
- await store.close();
3362
+ const server = await startServer({
3363
+ apiKey,
3364
+ hostname: values.host ?? "127.0.0.1",
3365
+ port,
3366
+ databaseUrl: process.env.HASNA_SWITCHER_DATABASE_URL,
3367
+ sqlitePath: values.sqlite ?? (values["data-dir"] ? join(values["data-dir"], "switcher.db") : process.env.HASNA_SWITCHER_SQLITE_PATH)
3368
+ });
3369
+ console.log(JSON.stringify({ event: "listening", version: VERSION, url: server.url, storage: server.storage }));
3370
+ const stop = () => {
3371
+ server.close().catch(() => {
3372
+ console.error("Server shutdown failed.");
3373
+ process.exitCode = 1;
3374
+ });
2789
3375
  };
2790
3376
  process.once("SIGTERM", stop);
2791
3377
  process.once("SIGINT", stop);