@hasna/switcher 0.1.0 → 0.1.2

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,9 +12,9 @@ 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"]);
17
- var protocolSchema = z.enum(["anthropic-messages", "openai-responses", "openai-chat"]);
15
+ var VERSION = "0.1.2";
16
+ var harnessSchema = z.enum(["claude", "codex", "grok", "opencode", "opencode2", "pi", "omp", "dsh", "cline", "hermes", "prime-agent", "gemini", "aider", "kilo"]);
17
+ var protocolSchema = z.enum(["anthropic-messages", "openai-responses", "openai-chat", "gemini-generate-content"]);
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);
20
20
  var envRef = z.string().regex(/^SWITCHER_PROVIDER_[A-Z0-9_]+$/);
@@ -41,11 +41,13 @@ 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(),
47
48
  outputModalities: z.array(z.string().max(50)).max(20).optional(),
48
- supportedParameters: z.array(z.string().max(100)).max(100).optional()
49
+ supportedParameters: z.array(z.string().max(100)).max(100).optional(),
50
+ supportedGenerationMethods: z.array(z.string().min(1).max(100)).max(100).optional()
49
51
  }).strict();
50
52
  var providerInputSchema = z.object({
51
53
  id: idSchema,
@@ -53,10 +55,33 @@ var providerInputSchema = z.object({
53
55
  baseUrl: urlSchema,
54
56
  protocol: protocolSchema,
55
57
  credentialEnv: envRef.optional(),
56
- authStyle: z.enum(["bearer", "x-api-key"]).default("bearer"),
58
+ authStyle: z.enum(["bearer", "x-api-key", "api-key"]).default("bearer"),
59
+ catalogBaseUrl: urlSchema.optional(),
60
+ catalogFormat: z.enum(["openai", "ollama", "mistral", "together", "fireworks", "dashscope", "gemini", "none"]).optional(),
61
+ catalogAuthStyle: z.enum(["bearer", "x-api-key", "api-key", "none"]).optional(),
62
+ catalogCredentialEnv: envRef.optional(),
63
+ catalogAccountId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/).optional(),
57
64
  modelsPath: z.string().regex(/^[a-zA-Z0-9_/-]+$/).max(200).default("models"),
58
65
  manualModels: z.array(modelSchema).max(1e4).default([])
59
66
  }).strict().refine((p) => !p.modelsPath.split("/").includes("..") && !p.modelsPath.startsWith("/"), "modelsPath must be relative");
67
+ var providerPresetSchema = z.object({
68
+ id: idSchema,
69
+ name: label,
70
+ credentialEnv: envRef.optional(),
71
+ credentialAliases: z.array(z.string().regex(/^[A-Z][A-Z0-9_]+$/)),
72
+ protocols: z.array(z.object({
73
+ protocol: protocolSchema,
74
+ baseUrl: urlSchema.optional(),
75
+ authStyle: z.enum(["bearer", "x-api-key", "api-key"]),
76
+ catalogBaseUrl: urlSchema.optional(),
77
+ catalogFormat: z.enum(["openai", "ollama", "mistral", "together", "fireworks", "dashscope", "gemini", "none"]),
78
+ catalogAuthStyle: z.enum(["bearer", "x-api-key", "api-key", "none"]).optional(),
79
+ modelsPath: z.string(),
80
+ notes: z.array(z.string())
81
+ }).strict()).min(1),
82
+ sources: z.array(z.string().url()),
83
+ verification: z.literal("documented")
84
+ }).strict();
60
85
  var profileInputSchema = z.object({
61
86
  id: idSchema,
62
87
  name: label,
@@ -91,10 +116,27 @@ function parse(schema, value) {
91
116
  return result.data;
92
117
  }
93
118
  function compatible(harness, protocol) {
94
- return harness === "claude" ? protocol === "anthropic-messages" : harness === "codex" ? protocol === "openai-responses" : true;
119
+ if (harness === "claude")
120
+ return protocol === "anthropic-messages";
121
+ if (harness === "codex")
122
+ return protocol === "openai-responses";
123
+ if (harness === "gemini")
124
+ return protocol === "gemini-generate-content";
125
+ if (protocol === "gemini-generate-content")
126
+ return false;
127
+ return true;
128
+ }
129
+ function validateHarnessProvider(harness, provider) {
130
+ if (!compatible(harness, provider.protocol))
131
+ throw new Fault(422, "protocol_mismatch", "Harness does not support this provider protocol.");
132
+ if (harness === "gemini" && provider.authStyle !== "x-api-key")
133
+ throw new Fault(422, "auth_mismatch", "Gemini CLI requires x-api-key authentication for its native generateContent protocol.");
95
134
  }
96
135
  function codingEligible(model) {
97
- return (!model.outputModalities || model.outputModalities.includes("text")) && (!model.supportedParameters || model.supportedParameters.includes("tools"));
136
+ return model.available !== false && (!model.supportedGenerationMethods || model.supportedGenerationMethods.includes("generateContent")) && (!model.outputModalities || model.outputModalities.includes("text")) && (!model.supportedParameters || model.supportedParameters.includes("tools"));
137
+ }
138
+ function harnessEligible(model, harness) {
139
+ return harness === "aider" ? model.available !== false && (!model.supportedGenerationMethods || model.supportedGenerationMethods.includes("generateContent")) && (!model.inputModalities || model.inputModalities.includes("text")) && (!model.outputModalities || model.outputModalities.includes("text")) : codingEligible(model);
98
140
  }
99
141
 
100
142
  // src/store.ts
@@ -116,33 +158,37 @@ class Store {
116
158
  static async open(config) {
117
159
  if (!!config.databaseUrl === !!config.sqlitePath)
118
160
  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.");
161
+ const engine = config.databaseUrl ? "postgresql" : "sqlite";
162
+ if (config.databaseUrl && !/^postgres(ql)?:\/\//.test(config.databaseUrl))
163
+ throw new Fault(500, "storage_config", "Database URL must use PostgreSQL.");
164
+ const file = config.sqlitePath;
165
+ if (engine === "sqlite" && file !== ":memory:")
166
+ await mkdir(dirname(resolve(file)), { recursive: true, mode: 448 });
167
+ const deadline = Date.now() + 1e4;
168
+ for (let attempt = 0;; attempt++) {
169
+ let sql;
170
+ try {
171
+ sql = engine === "postgresql" ? new SQL(config.databaseUrl) : new SQL({ adapter: "sqlite", filename: file });
172
+ if (engine === "sqlite") {
173
+ await sql.unsafe("PRAGMA busy_timeout = 5000");
174
+ await sql.unsafe("PRAGMA foreign_keys = ON");
175
+ await sql.unsafe("PRAGMA journal_mode = WAL");
176
+ if (file !== ":memory:")
177
+ await chmod(file, 384);
178
+ }
179
+ const store = new Store(sql, engine);
180
+ await store.migrate();
181
+ return store;
182
+ } catch (error) {
183
+ await sql?.close().catch(() => {});
184
+ const code = error?.code;
185
+ if (engine === "sqlite" && ["SQLITE_BUSY", "SQLITE_BUSY_SNAPSHOT", "SQLITE_LOCKED"].includes(code ?? "") && Date.now() < deadline) {
186
+ await new Promise((resolve2) => setTimeout(resolve2, Math.min(200, 20 * (attempt + 1))));
187
+ continue;
188
+ }
189
+ throw new Fault(500, "storage_unavailable", "Database startup failed; check configuration, permissions and other database users.");
190
+ }
144
191
  }
145
- return store;
146
192
  }
147
193
  async migrate() {
148
194
  await this.sql.begin(async (tx) => {
@@ -289,54 +335,172 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
289
335
  }
290
336
  }
291
337
 
338
+ // src/auth.ts
339
+ function authHeader(style, credential) {
340
+ if (/[^\x20-\x7e]/.test(credential))
341
+ throw new Error("Provider credential contains invalid header characters.");
342
+ return style === "bearer" ? ["authorization", `Bearer ${credential}`] : [style, credential];
343
+ }
344
+
292
345
  // src/catalog.ts
293
346
  var positive = (v) => typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
294
347
  var strings = (v) => Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
295
- async function discover(provider, env = process.env) {
348
+ var modalities = (v) => {
349
+ if (v === undefined)
350
+ return;
351
+ if (!Array.isArray(v) || !v.every((i) => typeof i === "string"))
352
+ throw new Fault(502, "invalid_catalog", "Provider returned malformed modality metadata.");
353
+ return v.map((i) => i.toLowerCase());
354
+ };
355
+ var CATALOG_REQUEST_TIMEOUT_MS = 20000;
356
+ var CATALOG_REFRESH_DEADLINE_MS = 60000;
357
+ var CATALOG_MAX_RETRIES = 2;
358
+ var TRANSIENT_CATALOG_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
359
+ function retryAfterMs(value) {
360
+ if (!value)
361
+ return;
362
+ const trimmed = value.trim();
363
+ if (/^\d+$/.test(trimmed)) {
364
+ try {
365
+ const milliseconds = BigInt(trimmed) * 1000n;
366
+ if (milliseconds > BigInt(Number.MAX_SAFE_INTEGER))
367
+ return Number.POSITIVE_INFINITY;
368
+ return Number(milliseconds);
369
+ } catch {
370
+ return Number.POSITIVE_INFINITY;
371
+ }
372
+ }
373
+ const date = Date.parse(trimmed);
374
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
375
+ }
376
+ function catalogDeadlineFault() {
377
+ return new Fault(502, "provider_unavailable", "Provider catalog refresh exceeded its bounded deadline.");
378
+ }
379
+ async function waitForCatalogRetry(delayMs, deadline) {
380
+ if (Date.now() + delayMs > deadline)
381
+ throw catalogDeadlineFault();
382
+ if (delayMs > 0)
383
+ await new Promise((resolve2) => setTimeout(resolve2, delayMs));
384
+ }
385
+ async function fetchCatalogPage(url, headers, deadline) {
386
+ for (let retry = 0;; retry++) {
387
+ const remaining = deadline - Date.now();
388
+ if (remaining <= 0)
389
+ throw catalogDeadlineFault();
390
+ let response;
391
+ try {
392
+ response = await fetch(url, {
393
+ headers,
394
+ redirect: "manual",
395
+ signal: AbortSignal.timeout(Math.max(1, Math.min(CATALOG_REQUEST_TIMEOUT_MS, remaining)))
396
+ });
397
+ } catch {
398
+ if (retry >= CATALOG_MAX_RETRIES)
399
+ throw new Fault(502, "provider_unavailable", "Provider catalog request failed.");
400
+ await waitForCatalogRetry(100 * 2 ** retry, deadline);
401
+ continue;
402
+ }
403
+ if (response.ok)
404
+ return response;
405
+ const retryable = TRANSIENT_CATALOG_STATUSES.has(response.status);
406
+ await response.body?.cancel().catch(() => {});
407
+ if (!retryable || retry >= CATALOG_MAX_RETRIES)
408
+ throw new Fault(502, "provider_rejected", `Provider catalog returned HTTP ${response.status}.`);
409
+ const delay = retryAfterMs(response.headers.get("retry-after"));
410
+ await waitForCatalogRetry(delay ?? 100 * 2 ** retry, deadline);
411
+ }
412
+ }
413
+ async function discover(provider, env = process.env, resolveCredential) {
296
414
  const refreshedAt = new Date().toISOString();
297
415
  if (provider.manualModels.length)
298
416
  return { models: provider.manualModels, source: "manual", refreshedAt };
417
+ if (provider.catalogFormat === "none")
418
+ 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
419
  const headers = { accept: "application/json" };
300
- if (provider.credentialEnv) {
301
- const credential = env[provider.credentialEnv];
420
+ if (provider.catalogFormat === "fireworks" && !provider.catalogBaseUrl && !provider.catalogAccountId)
421
+ throw new Fault(422, "catalog_account_required", "Fireworks model discovery requires a catalog account ID or an explicit catalog URL.");
422
+ if (provider.catalogFormat === "fireworks" && !provider.catalogBaseUrl && new URL(provider.baseUrl).origin !== "https://api.fireworks.ai")
423
+ throw new Fault(422, "catalog_url_required", "A custom Fireworks inference authority requires an explicit catalog URL; its deployment prefix cannot be inferred.");
424
+ const catalogRoot = provider.catalogBaseUrl ?? (provider.catalogFormat === "fireworks" ? `https://api.fireworks.ai/v1/accounts/${encodeURIComponent(provider.catalogAccountId)}` : provider.baseUrl);
425
+ const url = new URL(`${catalogRoot}/${provider.modelsPath}`);
426
+ if (provider.catalogFormat === "fireworks")
427
+ url.searchParams.set("pageSize", "200");
428
+ const authStyle = provider.catalogAuthStyle ?? provider.authStyle;
429
+ const credentialEnv = provider.catalogCredentialEnv ?? provider.credentialEnv;
430
+ if (authStyle !== "none" && credentialEnv) {
431
+ if (url.origin !== new URL(provider.baseUrl).origin && !provider.catalogCredentialEnv)
432
+ throw new Fault(422, "catalog_credential_authority", "A different catalog origin requires an explicit catalog credential reference or catalogAuthStyle: none.");
433
+ const credential = resolveCredential ? await resolveCredential({ ...provider, baseUrl: provider.catalogBaseUrl ?? provider.baseUrl, credentialEnv }) : env[credentialEnv];
302
434
  if (!credential)
303
435
  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}`;
436
+ if (/[\r\n]/.test(credential))
437
+ throw new Fault(422, "credential_invalid", "Catalog credential contains invalid header characters.");
438
+ const [header, value] = authHeader(authStyle, credential);
439
+ headers[provider.catalogFormat === "gemini" && header === "x-api-key" ? "x-goog-api-key" : header] = value;
305
440
  }
306
- const url = new URL(`${provider.baseUrl}/${provider.modelsPath}`);
307
441
  if (provider.protocol === "anthropic-messages" && url.hostname !== "openrouter.ai")
308
442
  headers["anthropic-version"] = "2023-06-01";
309
443
  if (url.hostname === "openrouter.ai")
310
444
  url.searchParams.set("output_modalities", "all");
311
445
  const models = new Map;
312
446
  const seenCursors = new Set;
447
+ const seenPages = new Set([url.href]);
448
+ const deadline = Date.now() + CATALOG_REFRESH_DEADLINE_MS;
449
+ let fireworksTotal;
313
450
  for (let page = 0;page < 100; page++) {
314
- let response;
315
- try {
316
- response = await fetch(url, { headers, redirect: "manual", signal: AbortSignal.timeout(20000) });
317
- } catch {
318
- throw new Fault(502, "provider_unavailable", "Provider catalog request failed.");
319
- }
320
- if (!response.ok) {
321
- await response.body?.cancel();
322
- throw new Fault(502, "provider_rejected", `Provider catalog returned HTTP ${response.status}.`);
323
- }
451
+ const response = await fetchCatalogPage(url, headers, deadline);
324
452
  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")
453
+ if (provider.catalogFormat === "fireworks" && data?.totalSize !== undefined) {
454
+ if (typeof data.totalSize !== "number" || !Number.isInteger(data.totalSize) || data.totalSize < 0)
455
+ throw new Fault(502, "invalid_catalog", "Fireworks catalog count metadata is malformed.");
456
+ if (fireworksTotal !== undefined && fireworksTotal !== data.totalSize)
457
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count changed during pagination; retry the refresh.");
458
+ fireworksTotal = data.totalSize;
459
+ }
460
+ const rows = provider.catalogFormat === "together" ? data : provider.catalogFormat === "ollama" ? data?.models : provider.catalogFormat === "fireworks" || provider.catalogFormat === "gemini" ? data?.models : provider.catalogFormat === "dashscope" ? data?.output?.models : data?.data;
461
+ if (!Array.isArray(rows))
462
+ throw new Fault(502, "invalid_catalog", "Expected a provider catalog with a model array matching its configured format.");
463
+ for (const row of rows) {
464
+ const id = provider.catalogFormat === "ollama" ? row?.model ?? row?.name : provider.catalogFormat === "fireworks" ? row?.name : provider.catalogFormat === "gemini" ? typeof row?.name === "string" ? row.name.replace(/^models\//, "") : undefined : provider.catalogFormat === "dashscope" ? row?.model : row?.id;
465
+ if (typeof id !== "string")
329
466
  throw new Fault(502, "invalid_catalog", "Catalog entry is missing a model ID.");
330
467
  const candidate = {
331
- id: row.id,
332
- name: row.name ?? row.display_name ?? row.id,
468
+ id,
469
+ name: row.displayName ?? row.name ?? row.display_name ?? id,
470
+ available: provider.catalogFormat === "mistral" && typeof row.archived === "boolean" ? !row.archived : undefined,
333
471
  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),
338
- supportedParameters: strings(row.supported_parameters)
472
+ contextWindow: positive(row.context_length ?? row.context_window ?? row.contextLength ?? row.inputTokenLimit ?? row.model_info?.context_window ?? (provider.catalogFormat === "mistral" ? row.max_context_length : undefined)),
473
+ maxOutputTokens: positive(row.top_provider?.max_completion_tokens ?? row.max_output_tokens ?? row.outputTokenLimit ?? row.model_info?.max_output_tokens),
474
+ inputModalities: strings(row.architecture?.input_modalities ?? row.input_modalities) ?? modalities(row.inference_metadata?.request_modality),
475
+ outputModalities: strings(row.architecture?.output_modalities ?? row.output_modalities) ?? modalities(row.inference_metadata?.response_modality),
476
+ supportedParameters: strings(row.supported_parameters),
477
+ ...provider.catalogFormat === "gemini" && row.supportedGenerationMethods !== undefined ? { supportedGenerationMethods: row.supportedGenerationMethods } : {}
339
478
  };
479
+ if (provider.catalogFormat === "mistral") {
480
+ const capabilities = row.capabilities;
481
+ if (typeof capabilities?.function_calling === "boolean")
482
+ candidate.supportedParameters = capabilities.function_calling ? ["tools"] : [];
483
+ if (typeof capabilities?.vision === "boolean")
484
+ candidate.inputModalities = capabilities.vision ? ["text", "image"] : ["text"];
485
+ if (typeof capabilities?.completion_chat === "boolean")
486
+ candidate.outputModalities = capabilities.completion_chat ? ["text"] : [];
487
+ }
488
+ if (provider.catalogFormat === "together") {
489
+ const modalities2 = { chat: ["text"], language: ["text"], code: ["text"], image: ["image"], audio: ["audio"], video: ["video"], embedding: ["embedding"], rerank: ["rerank"], moderation: ["classification"] };
490
+ candidate.outputModalities = modalities2[row.type];
491
+ }
492
+ if (provider.catalogFormat === "fireworks") {
493
+ if (row.supportsImageInput === true)
494
+ candidate.inputModalities = ["text", "image"];
495
+ if (typeof row.supportsTools === "boolean")
496
+ candidate.supportedParameters = row.supportsTools ? ["tools"] : [];
497
+ }
498
+ if (provider.catalogFormat === "dashscope") {
499
+ if (row.features !== undefined && !Array.isArray(row.features))
500
+ throw new Fault(502, "invalid_catalog", "DashScope model features metadata is malformed.");
501
+ if (Array.isArray(row.features))
502
+ candidate.supportedParameters = row.features.includes("function-calling") ? ["tools"] : [];
503
+ }
340
504
  const parsed = modelSchema.safeParse(candidate);
341
505
  if (!parsed.success)
342
506
  throw new Fault(502, "invalid_catalog", "Provider returned malformed model metadata.");
@@ -344,8 +508,83 @@ async function discover(provider, env = process.env) {
344
508
  if (models.size > 1e4)
345
509
  throw new Fault(502, "catalog_too_large", "Catalog exceeds 10,000 models; configure a narrower endpoint.");
346
510
  }
347
- if (!data.has_more)
511
+ const next = data.links?.next;
512
+ if (next !== undefined && next !== null) {
513
+ if (typeof next !== "string" || !next || next.length > 2000)
514
+ throw new Fault(502, "invalid_catalog", "Provider returned an invalid catalog continuation link.");
515
+ let target;
516
+ try {
517
+ target = new URL(next, url);
518
+ } catch {
519
+ throw new Fault(502, "invalid_catalog", "Provider returned an invalid catalog continuation link.");
520
+ }
521
+ if (target.origin !== url.origin || target.pathname !== url.pathname || target.username || target.password || target.hash)
522
+ throw new Fault(502, "catalog_credential_authority", "Catalog pagination must stay on its original origin and path.");
523
+ if (url.searchParams.has("output_modalities"))
524
+ target.searchParams.set("output_modalities", url.searchParams.get("output_modalities"));
525
+ if (seenPages.has(target.href))
526
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
527
+ seenPages.add(target.href);
528
+ url.href = target.href;
529
+ continue;
530
+ }
531
+ if (provider.catalogFormat === "fireworks" && data.nextPageToken !== undefined && data.nextPageToken !== null) {
532
+ if (typeof data.nextPageToken !== "string" || data.nextPageToken.length > 2000)
533
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
534
+ if (data.nextPageToken) {
535
+ if (seenCursors.has(data.nextPageToken))
536
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
537
+ seenCursors.add(data.nextPageToken);
538
+ url.searchParams.set("pageToken", data.nextPageToken);
539
+ url.searchParams.set("pageSize", "200");
540
+ continue;
541
+ }
542
+ }
543
+ if (provider.catalogFormat === "fireworks") {
544
+ if (fireworksTotal !== undefined && fireworksTotal !== models.size)
545
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count does not match the collected models; retry the refresh.");
546
+ return { models: [...models.values()], source: "remote", refreshedAt };
547
+ }
548
+ if (provider.catalogFormat === "gemini") {
549
+ const nextPageToken = data.nextPageToken;
550
+ if (nextPageToken !== undefined && nextPageToken !== null) {
551
+ if (typeof nextPageToken !== "string" || nextPageToken.length > 2000)
552
+ throw new Fault(502, "invalid_catalog", "Gemini catalog pagination token is malformed.");
553
+ if (nextPageToken) {
554
+ if (seenCursors.has(nextPageToken))
555
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
556
+ seenCursors.add(nextPageToken);
557
+ url.searchParams.set("pageToken", nextPageToken);
558
+ continue;
559
+ }
560
+ }
561
+ return { models: [...models.values()], source: "remote", refreshedAt };
562
+ }
563
+ if (provider.catalogFormat === "dashscope") {
564
+ const output = data.output;
565
+ const total = output?.total;
566
+ const pageNo = output?.page_no;
567
+ const pageSize = output?.page_size;
568
+ 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)
569
+ throw new Fault(502, "invalid_catalog", "DashScope catalog pagination metadata is malformed.");
570
+ if (models.size < total && pageNo * pageSize < total) {
571
+ const nextPage = pageNo + 1;
572
+ if (seenCursors.has(String(nextPage)))
573
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
574
+ seenCursors.add(String(nextPage));
575
+ url.searchParams.set("page_no", String(nextPage));
576
+ url.searchParams.set("page_size", String(pageSize));
577
+ continue;
578
+ }
579
+ if (models.size !== total)
580
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count does not match the collected models; retry the refresh.");
581
+ return { models: [...models.values()], source: "remote", refreshedAt };
582
+ }
583
+ if (provider.catalogFormat === "together" || !data.has_more) {
584
+ if (typeof data.total_count === "number" && data.total_count !== models.size)
585
+ throw new Fault(502, "incomplete_catalog", "Provider catalog count does not match the collected models; retry the refresh.");
348
586
  return { models: [...models.values()], source: "remote", refreshedAt };
587
+ }
349
588
  const cursor = data.last_id;
350
589
  if (typeof cursor !== "string" || seenCursors.has(cursor))
351
590
  throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
@@ -355,12 +594,103 @@ async function discover(provider, env = process.env) {
355
594
  }
356
595
  throw new Fault(502, "catalog_too_large", "Provider catalog pagination exceeded 100 pages.");
357
596
  }
597
+
598
+ // src/presets.ts
599
+ var route = (protocol, baseUrl, options = {}) => ({
600
+ protocol,
601
+ baseUrl,
602
+ authStyle: "bearer",
603
+ catalogFormat: "openai",
604
+ modelsPath: "models",
605
+ notes: [],
606
+ ...options
607
+ });
608
+ var preset = (id, name, protocols, sources, alias) => parse(providerPresetSchema, {
609
+ id,
610
+ name,
611
+ protocols,
612
+ sources,
613
+ credentialAliases: alias ? [alias] : [],
614
+ credentialEnv: alias ? `SWITCHER_PROVIDER_${id.toUpperCase().replace(/-/g, "_")}` : undefined,
615
+ verification: "documented"
616
+ });
617
+ var providerPresets = [
618
+ preset("deepseek", "DeepSeek", [
619
+ route("openai-chat", "https://api.deepseek.com", { catalogBaseUrl: "https://api.deepseek.com" }),
620
+ route("anthropic-messages", "https://api.deepseek.com/anthropic/v1", { catalogBaseUrl: "https://api.deepseek.com" })
621
+ ], ["https://api-docs.deepseek.com/guides/anthropic_api", "https://api-docs.deepseek.com/api/list-models"], "DEEPSEEK_API_KEY"),
622
+ 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"),
623
+ 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"),
624
+ preset("gemini", "Google Gemini", [route("gemini-generate-content", "https://generativelanguage.googleapis.com/v1beta", {
625
+ authStyle: "x-api-key",
626
+ catalogBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
627
+ catalogFormat: "gemini",
628
+ catalogAuthStyle: "x-api-key",
629
+ notes: ["Gemini CLI uses the native generateContent wire with x-goog-api-key authentication; model IDs are returned as models/{id}."]
630
+ }), route("openai-chat", "https://generativelanguage.googleapis.com/v1beta/openai")], ["https://ai.google.dev/api", "https://ai.google.dev/api/models", "https://github.com/google-gemini/gemini-cli", "https://ai.google.dev/gemini-api/docs/openai"], "GEMINI_API_KEY"),
631
+ 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"),
632
+ preset("azure-openai", "Azure OpenAI (v1)", [
633
+ route("openai-responses", undefined, { authStyle: "api-key", catalogFormat: "none", notes: ["Pass the Azure OpenAI v1 resource endpoint ending in /openai/v1. The request model is your deployment name. Azure's model-definition list is not a deployment catalog, so configure manual deployment models or an explicit deployment catalog parser; Switcher does not synthesize deployment paths or api-version query parameters."] }),
634
+ route("openai-chat", undefined, { authStyle: "api-key", catalogFormat: "none", notes: ["Pass the Azure OpenAI v1 resource endpoint ending in /openai/v1. Chat Completions is POST /chat/completions and accepts the literal api-key header. The request model is your deployment name; configure manual deployment models or an explicit deployment catalog parser because GET /models does not establish deployment names."] })
635
+ ], ["https://learn.microsoft.com/en-us/rest/api/aifoundry/azureopenai/models", "https://learn.microsoft.com/en-us/rest/api/microsoft-foundry/azureopenai/chat", "https://learn.microsoft.com/en-us/rest/api/aifoundry/azureopenai/responses"], "AZURE_OPENAI_API_KEY"),
636
+ 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"),
637
+ preset("ollama", "Ollama", ["openai-chat", "openai-responses"].map((protocol) => route(protocol, "http://127.0.0.1:11434/v1", {
638
+ catalogBaseUrl: "http://127.0.0.1:11434",
639
+ modelsPath: "api/tags",
640
+ catalogFormat: "ollama",
641
+ catalogAuthStyle: "none",
642
+ notes: protocol === "openai-responses" ? ["Requires Ollama 0.13.3 or newer; only stateless Responses are supported."] : []
643
+ })), ["https://docs.ollama.com/api/openai-compatibility", "https://docs.ollama.com/api/tags"]),
644
+ preset("lmstudio", "LM Studio", ["openai-chat", "openai-responses", "anthropic-messages"].map((protocol) => route(protocol, "http://127.0.0.1:1234/v1", {
645
+ notes: ["Server authentication is optional. Use --credential-env when authentication is enabled."]
646
+ })), ["https://lmstudio.ai/docs/developer/rest"]),
647
+ preset("vllm", "vLLM (operator endpoint)", [
648
+ 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."] }),
649
+ 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."] }),
650
+ 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."] })
651
+ ], ["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"]),
652
+ preset("litellm", "LiteLLM Proxy (operator endpoint)", [
653
+ 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."] }),
654
+ 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."] }),
655
+ 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."] })
656
+ ], ["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"]),
657
+ 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"),
658
+ preset("cerebras", "Cerebras", [route("openai-chat", "https://api.cerebras.ai/v1")], ["https://inference-docs.cerebras.ai/api-reference/chat-completions"], "CEREBRAS_API_KEY"),
659
+ 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"),
660
+ 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"),
661
+ preset("fireworks", "Fireworks AI", [
662
+ 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."] }),
663
+ 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."] }),
664
+ 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."] })
665
+ ], ["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"),
666
+ 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"),
667
+ preset("dashscope", "Alibaba Cloud Model Studio (Qwen)", [route("openai-chat", "https://dashscope-us.aliyuncs.com/compatible-mode/v1", {
668
+ catalogFormat: "none",
669
+ 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."]
670
+ })], ["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"),
671
+ preset("zai", "Z.AI", [route("openai-chat", "https://api.z.ai/api/paas/v4", {
672
+ catalogFormat: "none",
673
+ notes: ["The published API reference documents inference endpoints but no model-list endpoint; use manual models or provide an explicit catalog URL and parser."]
674
+ })], ["https://docs.z.ai/api-reference/introduction", "https://docs.z.ai/devpack/quick-start"], "ZAI_API_KEY"),
675
+ preset("minimax", "MiniMax", [
676
+ 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."] }),
677
+ 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."] })
678
+ ], ["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"),
679
+ 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"),
680
+ ...["anthropic-messages", "openai-responses", "openai-chat"].map((protocol) => preset(`generic-${protocol}`, `Custom ${protocol}`, [route(protocol)], []))
681
+ ];
682
+ function getProviderPreset(id) {
683
+ const entry = providerPresets.find((p) => p.id === id);
684
+ if (!entry)
685
+ throw new Fault(404, "preset_not_found", "Unknown provider preset. Use switcher providers presets to list available presets.");
686
+ return structuredClone(entry);
687
+ }
358
688
  // openapi.json
359
689
  var openapi_default = {
360
690
  openapi: "3.0.3",
361
691
  info: {
362
692
  title: "Switcher API",
363
- version: "0.1.0",
693
+ version: "0.1.2",
364
694
  description: "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
365
695
  },
366
696
  security: [
@@ -945,6 +1275,82 @@ var openapi_default = {
945
1275
  }
946
1276
  }
947
1277
  },
1278
+ "/v1/provider-presets": {
1279
+ get: {
1280
+ operationId: "listProviderPresets",
1281
+ parameters: [],
1282
+ responses: {
1283
+ "200": {
1284
+ description: "Success",
1285
+ content: {
1286
+ "application/json": {
1287
+ schema: {
1288
+ type: "object",
1289
+ required: [
1290
+ "data"
1291
+ ],
1292
+ properties: {
1293
+ data: {
1294
+ type: "array",
1295
+ items: {
1296
+ $ref: "#/components/schemas/ProviderPreset"
1297
+ }
1298
+ }
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+ },
1304
+ default: {
1305
+ description: "Structured error",
1306
+ content: {
1307
+ "application/json": {
1308
+ schema: {
1309
+ $ref: "#/components/schemas/Error"
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+ },
1317
+ "/v1/provider-presets/{id}": {
1318
+ get: {
1319
+ operationId: "getProviderPreset",
1320
+ parameters: [
1321
+ {
1322
+ name: "id",
1323
+ in: "path",
1324
+ required: true,
1325
+ schema: {
1326
+ type: "string"
1327
+ }
1328
+ }
1329
+ ],
1330
+ responses: {
1331
+ "200": {
1332
+ description: "Success",
1333
+ content: {
1334
+ "application/json": {
1335
+ schema: {
1336
+ $ref: "#/components/schemas/ProviderPreset"
1337
+ }
1338
+ }
1339
+ }
1340
+ },
1341
+ default: {
1342
+ description: "Structured error",
1343
+ content: {
1344
+ "application/json": {
1345
+ schema: {
1346
+ $ref: "#/components/schemas/Error"
1347
+ }
1348
+ }
1349
+ }
1350
+ }
1351
+ }
1352
+ }
1353
+ },
948
1354
  "/v1/providers/{id}/models": {
949
1355
  get: {
950
1356
  operationId: "listModels",
@@ -1465,6 +1871,126 @@ var openapi_default = {
1465
1871
  }
1466
1872
  },
1467
1873
  schemas: {
1874
+ ProviderPreset: {
1875
+ type: "object",
1876
+ properties: {
1877
+ id: {
1878
+ type: "string",
1879
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1880
+ },
1881
+ name: {
1882
+ type: "string",
1883
+ minLength: 1,
1884
+ maxLength: 200
1885
+ },
1886
+ credentialEnv: {
1887
+ type: "string",
1888
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
1889
+ },
1890
+ credentialAliases: {
1891
+ type: "array",
1892
+ items: {
1893
+ type: "string",
1894
+ pattern: "^[A-Z][A-Z0-9_]+$"
1895
+ }
1896
+ },
1897
+ protocols: {
1898
+ type: "array",
1899
+ items: {
1900
+ type: "object",
1901
+ properties: {
1902
+ protocol: {
1903
+ type: "string",
1904
+ enum: [
1905
+ "anthropic-messages",
1906
+ "openai-responses",
1907
+ "openai-chat",
1908
+ "gemini-generate-content"
1909
+ ]
1910
+ },
1911
+ baseUrl: {
1912
+ type: "string",
1913
+ maxLength: 2000
1914
+ },
1915
+ authStyle: {
1916
+ type: "string",
1917
+ enum: [
1918
+ "bearer",
1919
+ "x-api-key",
1920
+ "api-key"
1921
+ ]
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
+ "gemini",
1937
+ "none"
1938
+ ]
1939
+ },
1940
+ catalogAuthStyle: {
1941
+ type: "string",
1942
+ enum: [
1943
+ "bearer",
1944
+ "x-api-key",
1945
+ "api-key",
1946
+ "none"
1947
+ ]
1948
+ },
1949
+ modelsPath: {
1950
+ type: "string"
1951
+ },
1952
+ notes: {
1953
+ type: "array",
1954
+ items: {
1955
+ type: "string"
1956
+ }
1957
+ }
1958
+ },
1959
+ required: [
1960
+ "protocol",
1961
+ "authStyle",
1962
+ "catalogFormat",
1963
+ "modelsPath",
1964
+ "notes"
1965
+ ],
1966
+ additionalProperties: false
1967
+ },
1968
+ minItems: 1
1969
+ },
1970
+ sources: {
1971
+ type: "array",
1972
+ items: {
1973
+ type: "string",
1974
+ format: "uri"
1975
+ }
1976
+ },
1977
+ verification: {
1978
+ type: "string",
1979
+ enum: [
1980
+ "documented"
1981
+ ]
1982
+ }
1983
+ },
1984
+ required: [
1985
+ "id",
1986
+ "name",
1987
+ "credentialAliases",
1988
+ "protocols",
1989
+ "sources",
1990
+ "verification"
1991
+ ],
1992
+ additionalProperties: false
1993
+ },
1468
1994
  ProviderInput: {
1469
1995
  type: "object",
1470
1996
  properties: {
@@ -1486,7 +2012,8 @@ var openapi_default = {
1486
2012
  enum: [
1487
2013
  "anthropic-messages",
1488
2014
  "openai-responses",
1489
- "openai-chat"
2015
+ "openai-chat",
2016
+ "gemini-generate-content"
1490
2017
  ]
1491
2018
  },
1492
2019
  credentialEnv: {
@@ -1497,10 +2024,45 @@ var openapi_default = {
1497
2024
  type: "string",
1498
2025
  enum: [
1499
2026
  "bearer",
1500
- "x-api-key"
2027
+ "x-api-key",
2028
+ "api-key"
1501
2029
  ],
1502
2030
  default: "bearer"
1503
2031
  },
2032
+ catalogBaseUrl: {
2033
+ type: "string",
2034
+ maxLength: 2000
2035
+ },
2036
+ catalogFormat: {
2037
+ type: "string",
2038
+ enum: [
2039
+ "openai",
2040
+ "ollama",
2041
+ "mistral",
2042
+ "together",
2043
+ "fireworks",
2044
+ "dashscope",
2045
+ "gemini",
2046
+ "none"
2047
+ ]
2048
+ },
2049
+ catalogAuthStyle: {
2050
+ type: "string",
2051
+ enum: [
2052
+ "bearer",
2053
+ "x-api-key",
2054
+ "api-key",
2055
+ "none"
2056
+ ]
2057
+ },
2058
+ catalogCredentialEnv: {
2059
+ type: "string",
2060
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
2061
+ },
2062
+ catalogAccountId: {
2063
+ type: "string",
2064
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
2065
+ },
1504
2066
  modelsPath: {
1505
2067
  type: "string",
1506
2068
  pattern: "^[a-zA-Z0-9_/-]+$",
@@ -1526,6 +2088,9 @@ var openapi_default = {
1526
2088
  type: "string",
1527
2089
  maxLength: 8000
1528
2090
  },
2091
+ available: {
2092
+ type: "boolean"
2093
+ },
1529
2094
  contextWindow: {
1530
2095
  type: "integer",
1531
2096
  exclusiveMinimum: true,
@@ -1559,6 +2124,15 @@ var openapi_default = {
1559
2124
  maxLength: 100
1560
2125
  },
1561
2126
  maxItems: 100
2127
+ },
2128
+ supportedGenerationMethods: {
2129
+ type: "array",
2130
+ items: {
2131
+ type: "string",
2132
+ minLength: 1,
2133
+ maxLength: 100
2134
+ },
2135
+ maxItems: 100
1562
2136
  }
1563
2137
  },
1564
2138
  required: [
@@ -1600,7 +2174,8 @@ var openapi_default = {
1600
2174
  enum: [
1601
2175
  "anthropic-messages",
1602
2176
  "openai-responses",
1603
- "openai-chat"
2177
+ "openai-chat",
2178
+ "gemini-generate-content"
1604
2179
  ]
1605
2180
  },
1606
2181
  credentialEnv: {
@@ -1611,10 +2186,45 @@ var openapi_default = {
1611
2186
  type: "string",
1612
2187
  enum: [
1613
2188
  "bearer",
1614
- "x-api-key"
2189
+ "x-api-key",
2190
+ "api-key"
1615
2191
  ],
1616
2192
  default: "bearer"
1617
2193
  },
2194
+ catalogBaseUrl: {
2195
+ type: "string",
2196
+ maxLength: 2000
2197
+ },
2198
+ catalogFormat: {
2199
+ type: "string",
2200
+ enum: [
2201
+ "openai",
2202
+ "ollama",
2203
+ "mistral",
2204
+ "together",
2205
+ "fireworks",
2206
+ "dashscope",
2207
+ "gemini",
2208
+ "none"
2209
+ ]
2210
+ },
2211
+ catalogAuthStyle: {
2212
+ type: "string",
2213
+ enum: [
2214
+ "bearer",
2215
+ "x-api-key",
2216
+ "api-key",
2217
+ "none"
2218
+ ]
2219
+ },
2220
+ catalogCredentialEnv: {
2221
+ type: "string",
2222
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
2223
+ },
2224
+ catalogAccountId: {
2225
+ type: "string",
2226
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
2227
+ },
1618
2228
  modelsPath: {
1619
2229
  type: "string",
1620
2230
  pattern: "^[a-zA-Z0-9_/-]+$",
@@ -1640,6 +2250,9 @@ var openapi_default = {
1640
2250
  type: "string",
1641
2251
  maxLength: 8000
1642
2252
  },
2253
+ available: {
2254
+ type: "boolean"
2255
+ },
1643
2256
  contextWindow: {
1644
2257
  type: "integer",
1645
2258
  exclusiveMinimum: true,
@@ -1673,6 +2286,15 @@ var openapi_default = {
1673
2286
  maxLength: 100
1674
2287
  },
1675
2288
  maxItems: 100
2289
+ },
2290
+ supportedGenerationMethods: {
2291
+ type: "array",
2292
+ items: {
2293
+ type: "string",
2294
+ minLength: 1,
2295
+ maxLength: 100
2296
+ },
2297
+ maxItems: 100
1676
2298
  }
1677
2299
  },
1678
2300
  required: [
@@ -1725,7 +2347,17 @@ var openapi_default = {
1725
2347
  "claude",
1726
2348
  "codex",
1727
2349
  "grok",
1728
- "opencode2"
2350
+ "opencode",
2351
+ "opencode2",
2352
+ "pi",
2353
+ "omp",
2354
+ "dsh",
2355
+ "cline",
2356
+ "hermes",
2357
+ "prime-agent",
2358
+ "gemini",
2359
+ "aider",
2360
+ "kilo"
1729
2361
  ]
1730
2362
  },
1731
2363
  model: {
@@ -1765,7 +2397,17 @@ var openapi_default = {
1765
2397
  "claude",
1766
2398
  "codex",
1767
2399
  "grok",
1768
- "opencode2"
2400
+ "opencode",
2401
+ "opencode2",
2402
+ "pi",
2403
+ "omp",
2404
+ "dsh",
2405
+ "cline",
2406
+ "hermes",
2407
+ "prime-agent",
2408
+ "gemini",
2409
+ "aider",
2410
+ "kilo"
1769
2411
  ]
1770
2412
  },
1771
2413
  model: {
@@ -1810,6 +2452,9 @@ var openapi_default = {
1810
2452
  type: "string",
1811
2453
  maxLength: 8000
1812
2454
  },
2455
+ available: {
2456
+ type: "boolean"
2457
+ },
1813
2458
  contextWindow: {
1814
2459
  type: "integer",
1815
2460
  exclusiveMinimum: true,
@@ -1843,6 +2488,15 @@ var openapi_default = {
1843
2488
  maxLength: 100
1844
2489
  },
1845
2490
  maxItems: 100
2491
+ },
2492
+ supportedGenerationMethods: {
2493
+ type: "array",
2494
+ items: {
2495
+ type: "string",
2496
+ minLength: 1,
2497
+ maxLength: 100
2498
+ },
2499
+ maxItems: 100
1846
2500
  }
1847
2501
  },
1848
2502
  required: [
@@ -1873,6 +2527,9 @@ var openapi_default = {
1873
2527
  type: "string",
1874
2528
  maxLength: 8000
1875
2529
  },
2530
+ available: {
2531
+ type: "boolean"
2532
+ },
1876
2533
  contextWindow: {
1877
2534
  type: "integer",
1878
2535
  exclusiveMinimum: true,
@@ -1907,6 +2564,15 @@ var openapi_default = {
1907
2564
  },
1908
2565
  maxItems: 100
1909
2566
  },
2567
+ supportedGenerationMethods: {
2568
+ type: "array",
2569
+ items: {
2570
+ type: "string",
2571
+ minLength: 1,
2572
+ maxLength: 100
2573
+ },
2574
+ maxItems: 100
2575
+ },
1910
2576
  codingEligible: {
1911
2577
  type: "boolean"
1912
2578
  }
@@ -1971,6 +2637,9 @@ var openapi_default = {
1971
2637
  type: "string",
1972
2638
  maxLength: 8000
1973
2639
  },
2640
+ available: {
2641
+ type: "boolean"
2642
+ },
1974
2643
  contextWindow: {
1975
2644
  type: "integer",
1976
2645
  exclusiveMinimum: true,
@@ -2004,6 +2673,15 @@ var openapi_default = {
2004
2673
  maxLength: 100
2005
2674
  },
2006
2675
  maxItems: 100
2676
+ },
2677
+ supportedGenerationMethods: {
2678
+ type: "array",
2679
+ items: {
2680
+ type: "string",
2681
+ minLength: 1,
2682
+ maxLength: 100
2683
+ },
2684
+ maxItems: 100
2007
2685
  }
2008
2686
  },
2009
2687
  required: [
@@ -2055,7 +2733,8 @@ var openapi_default = {
2055
2733
  enum: [
2056
2734
  "anthropic-messages",
2057
2735
  "openai-responses",
2058
- "openai-chat"
2736
+ "openai-chat",
2737
+ "gemini-generate-content"
2059
2738
  ]
2060
2739
  },
2061
2740
  credentialEnv: {
@@ -2066,10 +2745,45 @@ var openapi_default = {
2066
2745
  type: "string",
2067
2746
  enum: [
2068
2747
  "bearer",
2069
- "x-api-key"
2748
+ "x-api-key",
2749
+ "api-key"
2070
2750
  ],
2071
2751
  default: "bearer"
2072
2752
  },
2753
+ catalogBaseUrl: {
2754
+ type: "string",
2755
+ maxLength: 2000
2756
+ },
2757
+ catalogFormat: {
2758
+ type: "string",
2759
+ enum: [
2760
+ "openai",
2761
+ "ollama",
2762
+ "mistral",
2763
+ "together",
2764
+ "fireworks",
2765
+ "dashscope",
2766
+ "gemini",
2767
+ "none"
2768
+ ]
2769
+ },
2770
+ catalogAuthStyle: {
2771
+ type: "string",
2772
+ enum: [
2773
+ "bearer",
2774
+ "x-api-key",
2775
+ "api-key",
2776
+ "none"
2777
+ ]
2778
+ },
2779
+ catalogCredentialEnv: {
2780
+ type: "string",
2781
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
2782
+ },
2783
+ catalogAccountId: {
2784
+ type: "string",
2785
+ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
2786
+ },
2073
2787
  modelsPath: {
2074
2788
  type: "string",
2075
2789
  pattern: "^[a-zA-Z0-9_/-]+$",
@@ -2095,6 +2809,9 @@ var openapi_default = {
2095
2809
  type: "string",
2096
2810
  maxLength: 8000
2097
2811
  },
2812
+ available: {
2813
+ type: "boolean"
2814
+ },
2098
2815
  contextWindow: {
2099
2816
  type: "integer",
2100
2817
  exclusiveMinimum: true,
@@ -2128,6 +2845,15 @@ var openapi_default = {
2128
2845
  maxLength: 100
2129
2846
  },
2130
2847
  maxItems: 100
2848
+ },
2849
+ supportedGenerationMethods: {
2850
+ type: "array",
2851
+ items: {
2852
+ type: "string",
2853
+ minLength: 1,
2854
+ maxLength: 100
2855
+ },
2856
+ maxItems: 100
2131
2857
  }
2132
2858
  },
2133
2859
  required: [
@@ -2180,7 +2906,17 @@ var openapi_default = {
2180
2906
  "claude",
2181
2907
  "codex",
2182
2908
  "grok",
2183
- "opencode2"
2909
+ "opencode",
2910
+ "opencode2",
2911
+ "pi",
2912
+ "omp",
2913
+ "dsh",
2914
+ "cline",
2915
+ "hermes",
2916
+ "prime-agent",
2917
+ "gemini",
2918
+ "aider",
2919
+ "kilo"
2184
2920
  ]
2185
2921
  },
2186
2922
  model: {
@@ -2230,6 +2966,9 @@ var openapi_default = {
2230
2966
  type: "string",
2231
2967
  maxLength: 8000
2232
2968
  },
2969
+ available: {
2970
+ type: "boolean"
2971
+ },
2233
2972
  contextWindow: {
2234
2973
  type: "integer",
2235
2974
  exclusiveMinimum: true,
@@ -2263,6 +3002,15 @@ var openapi_default = {
2263
3002
  maxLength: 100
2264
3003
  },
2265
3004
  maxItems: 100
3005
+ },
3006
+ supportedGenerationMethods: {
3007
+ type: "array",
3008
+ items: {
3009
+ type: "string",
3010
+ minLength: 1,
3011
+ maxLength: 100
3012
+ },
3013
+ maxItems: 100
2266
3014
  }
2267
3015
  },
2268
3016
  required: [
@@ -2322,7 +3070,17 @@ var openapi_default = {
2322
3070
  "claude",
2323
3071
  "codex",
2324
3072
  "grok",
2325
- "opencode2"
3073
+ "opencode",
3074
+ "opencode2",
3075
+ "pi",
3076
+ "omp",
3077
+ "dsh",
3078
+ "cline",
3079
+ "hermes",
3080
+ "prime-agent",
3081
+ "gemini",
3082
+ "aider",
3083
+ "kilo"
2326
3084
  ]
2327
3085
  },
2328
3086
  model: {
@@ -2379,7 +3137,17 @@ var openapi_default = {
2379
3137
  "claude",
2380
3138
  "codex",
2381
3139
  "grok",
2382
- "opencode2"
3140
+ "opencode",
3141
+ "opencode2",
3142
+ "pi",
3143
+ "omp",
3144
+ "dsh",
3145
+ "cline",
3146
+ "hermes",
3147
+ "prime-agent",
3148
+ "gemini",
3149
+ "aider",
3150
+ "kilo"
2383
3151
  ]
2384
3152
  },
2385
3153
  model: {
@@ -2562,7 +3330,7 @@ var openapi_default = {
2562
3330
  // src/service.ts
2563
3331
  var snapshot = (profile, provider, catalog) => createHash("sha256").update(JSON.stringify([profile, provider, { models: catalog.models, source: catalog.source }])).digest("hex");
2564
3332
  var hash = (s) => createHash("sha256").update(s).digest();
2565
- function createHandler(store, apiKey, providerEnv = process.env) {
3333
+ function createHandler(store, apiKey, providerEnv = process.env, resolveCredential) {
2566
3334
  if (!apiKey || apiKey.length < 24)
2567
3335
  throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
2568
3336
  const expected = hash(`Bearer ${apiKey}`);
@@ -2571,12 +3339,12 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2571
3339
  const json = (body, status = 200) => Response.json(body, { status, headers: { "x-request-id": requestId, "cache-control": "no-store", "x-content-type-options": "nosniff" } });
2572
3340
  try {
2573
3341
  const url = new URL(request.url);
2574
- const route = url.pathname.replace(/\/$/, "");
2575
- if (request.method === "GET" && route === "/health")
3342
+ const route2 = url.pathname.replace(/\/$/, "");
3343
+ if (request.method === "GET" && route2 === "/health")
2576
3344
  return json({ status: "ok", version: VERSION, backend: store.engine });
2577
- if (request.method === "GET" && route === "/version")
3345
+ if (request.method === "GET" && route2 === "/version")
2578
3346
  return json({ version: VERSION });
2579
- if (request.method === "GET" && route === "/ready") {
3347
+ if (request.method === "GET" && route2 === "/ready") {
2580
3348
  try {
2581
3349
  await store.ready();
2582
3350
  return json({ ready: true });
@@ -2586,9 +3354,9 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2586
3354
  }
2587
3355
  if (!timingSafeEqual(expected, hash(request.headers.get("authorization") ?? "")))
2588
3356
  throw new Fault(401, "unauthorized", "A valid API bearer token is required.");
2589
- if (request.method === "GET" && ["/v1/openapi.json", "/openapi.json"].includes(route))
3357
+ if (request.method === "GET" && ["/v1/openapi.json", "/openapi.json"].includes(route2))
2590
3358
  return json(openapi_default);
2591
- const parts = route.split("/").filter(Boolean);
3359
+ const parts = route2.split("/").filter(Boolean);
2592
3360
  if (parts[0] !== "v1")
2593
3361
  throw new Fault(404, "not_found", "Route was not found.");
2594
3362
  const resource = parts[1];
@@ -2601,6 +3369,8 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2601
3369
  search: z2.string().max(200).default("")
2602
3370
  }).strict(), Object.fromEntries(url.searchParams));
2603
3371
  if (request.method === "GET") {
3372
+ if (resource === "provider-presets" && parts.length <= 3)
3373
+ return json(id ? getProviderPreset(id) : { data: providerPresets });
2604
3374
  if (["providers", "profiles", "runs"].includes(resource) && parts.length <= 3) {
2605
3375
  const kind = resource;
2606
3376
  return json(id ? await store.get(kind, id) : await store.list(kind, page()));
@@ -2628,7 +3398,7 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2628
3398
  throw new Fault(400, "invalid_json", "Request must contain valid JSON under 1 MiB.");
2629
3399
  }
2630
3400
  }
2631
- const fingerprint = hash(JSON.stringify([request.method, route, body, request.headers.get("if-match")])).toString("hex");
3401
+ const fingerprint = hash(JSON.stringify([request.method, route2, body, request.headers.get("if-match")])).toString("hex");
2632
3402
  const version = () => {
2633
3403
  const v = request.headers.get("if-match");
2634
3404
  if (!v || !/^[1-9]\d*$/.test(v))
@@ -2642,7 +3412,7 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2642
3412
  if (resource === "providers" && id && parts[3] === "refresh" && parts.length === 4 && request.method === "POST") {
2643
3413
  parse(z2.object({}).strict(), body);
2644
3414
  const provider = await store.get("providers", id);
2645
- refreshed = { provider, catalog: await discover(provider, providerEnv) };
3415
+ refreshed = { provider, catalog: await discover(provider, providerEnv, resolveCredential) };
2646
3416
  }
2647
3417
  const result = await store.mutate(key, fingerprint, async (db) => {
2648
3418
  if ((resource === "providers" || resource === "profiles") && parts.length <= 3) {
@@ -2655,8 +3425,7 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2655
3425
  if (resource === "profiles") {
2656
3426
  const profile = value;
2657
3427
  const provider = await store.get("providers", profile.providerId, db);
2658
- if (!compatible(profile.harness, provider.protocol))
2659
- throw new Fault(422, "protocol_mismatch", "Harness does not support this provider protocol.");
3428
+ validateHarnessProvider(profile.harness, provider);
2660
3429
  }
2661
3430
  const saved = await store.put(resource, value, id ? version() : undefined, db);
2662
3431
  if (resource === "providers" && id)
@@ -2684,8 +3453,7 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2684
3453
  const { profileId } = parse(z2.object({ profileId: idSchema }).strict(), body);
2685
3454
  const profile = await store.get("profiles", profileId, db);
2686
3455
  const provider = await store.get("providers", profile.providerId, db);
2687
- if (!compatible(profile.harness, provider.protocol))
2688
- throw new Fault(422, "protocol_mismatch", "Harness does not support this provider protocol.");
3456
+ validateHarnessProvider(profile.harness, provider);
2689
3457
  let catalog;
2690
3458
  try {
2691
3459
  catalog = await store.get("catalogs", provider.id, db);
@@ -2697,10 +3465,10 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2697
3465
  const selected = catalog.models.find((m) => m.id === profile.model);
2698
3466
  if (!selected)
2699
3467
  throw new Fault(422, "model_missing", "Selected model is not in the provider catalog.");
2700
- if (!codingEligible(selected))
2701
- throw new Fault(422, "model_ineligible", "Selected model explicitly lacks text output or tool support.");
3468
+ if (!harnessEligible(selected, profile.harness))
3469
+ throw new Fault(422, "model_ineligible", "Selected model is unavailable or explicitly lacks a required generation method, text output or tool support.");
2702
3470
  const warnings = [];
2703
- if (!selected.supportedParameters)
3471
+ if (profile.harness !== "aider" && !selected.supportedParameters)
2704
3472
  warnings.push("Provider does not declare tool capabilities; execution compatibility is unverified.");
2705
3473
  if (profile.harness === "claude" && !/claude/i.test(profile.model))
2706
3474
  warnings.push("Anthropic does not support non-Claude models in Claude Code; this combination is experimental.");
@@ -2747,6 +3515,46 @@ function createHandler(store, apiKey, providerEnv = process.env) {
2747
3515
  };
2748
3516
  }
2749
3517
 
3518
+ // src/server.ts
3519
+ async function startServer(options) {
3520
+ const port = options.port ?? 0;
3521
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
3522
+ throw new Fault(400, "invalid_port", "Port must be an integer between 0 and 65535.");
3523
+ if (options.apiKey.length < 24 || /[\r\n]/.test(options.apiKey))
3524
+ throw new Fault(500, "auth_config", "Use a random operator token of at least 24 characters.");
3525
+ const store = await Store.open({ databaseUrl: options.databaseUrl, sqlitePath: options.sqlitePath });
3526
+ let server;
3527
+ try {
3528
+ server = Bun.serve({
3529
+ hostname: options.hostname ?? "127.0.0.1",
3530
+ port,
3531
+ maxRequestBodySize: 1024 * 1024,
3532
+ idleTimeout: 60,
3533
+ fetch: createHandler(store, options.apiKey, options.providerEnv, options.resolveCredential)
3534
+ });
3535
+ await store.ready();
3536
+ } catch (error) {
3537
+ await server?.stop(true);
3538
+ await store.close();
3539
+ throw error;
3540
+ }
3541
+ const listener = server;
3542
+ let closing;
3543
+ return {
3544
+ url: listener.url.href,
3545
+ storage: store.engine,
3546
+ close() {
3547
+ return closing ??= (async () => {
3548
+ try {
3549
+ await listener.stop(true);
3550
+ } finally {
3551
+ await store.close();
3552
+ }
3553
+ })();
3554
+ }
3555
+ };
3556
+ }
3557
+
2750
3558
  // src/serve.ts
2751
3559
  async function main(args = process.argv.slice(2)) {
2752
3560
  const { values } = parseArgs({ args, options: {
@@ -2776,16 +3584,19 @@ Requires HASNA_SWITCHER_API_KEY (24+ characters). Provider credentials: SWITCHER
2776
3584
  throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
2777
3585
  if (values.sqlite && values["data-dir"])
2778
3586
  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();
3587
+ const server = await startServer({
3588
+ apiKey,
3589
+ hostname: values.host ?? "127.0.0.1",
3590
+ port,
3591
+ databaseUrl: process.env.HASNA_SWITCHER_DATABASE_URL,
3592
+ sqlitePath: values.sqlite ?? (values["data-dir"] ? join(values["data-dir"], "switcher.db") : process.env.HASNA_SWITCHER_SQLITE_PATH)
3593
+ });
3594
+ console.log(JSON.stringify({ event: "listening", version: VERSION, url: server.url, storage: server.storage }));
3595
+ const stop = () => {
3596
+ server.close().catch(() => {
3597
+ console.error("Server shutdown failed.");
3598
+ process.exitCode = 1;
3599
+ });
2789
3600
  };
2790
3601
  process.once("SIGTERM", stop);
2791
3602
  process.once("SIGINT", stop);