@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.
- package/README.md +73 -12
- package/dist/catalog.d.ts +3 -2
- package/dist/cli/index.js +5448 -198
- package/dist/credentials.d.ts +178 -0
- package/dist/direct-launch.d.ts +6 -0
- package/dist/domain.d.ts +118 -8
- package/dist/generated/api.d.ts +150 -5
- package/dist/grok-args.d.ts +1 -0
- package/dist/harness-arguments.d.ts +5 -0
- package/dist/harness-environment.d.ts +1 -0
- package/dist/harness-process.d.ts +11 -0
- package/dist/harness-types.d.ts +1 -0
- package/dist/harnesses.d.ts +33 -2
- package/dist/index.js +163 -2
- package/dist/launcher.d.ts +38 -3
- package/dist/mcp/index.js +123 -3
- package/dist/opencode2-config.d.ts +60 -0
- package/dist/ori-backend.d.ts +89 -0
- package/dist/presets.d.ts +63 -0
- package/dist/runtime.d.ts +20 -0
- package/dist/sdk.d.ts +77 -9
- package/dist/sdk.js +163 -2
- package/dist/serve/index.js +656 -70
- package/dist/server.d.ts +16 -0
- package/dist/service.d.ts +2 -1
- package/dist/terminal-descriptors.d.ts +5 -0
- package/hasna.contract.json +1 -1
- package/openapi.json +320 -6
- package/package.json +9 -4
package/dist/serve/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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.
|
|
301
|
-
|
|
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
|
-
|
|
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 (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (
|
|
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
|
|
332
|
-
name: row.name ?? row.display_name ??
|
|
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
|
-
|
|
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.
|
|
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
|
|
2575
|
-
if (request.method === "GET" &&
|
|
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" &&
|
|
3118
|
+
if (request.method === "GET" && route2 === "/version")
|
|
2578
3119
|
return json({ version: VERSION });
|
|
2579
|
-
if (request.method === "GET" &&
|
|
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(
|
|
3130
|
+
if (request.method === "GET" && ["/v1/openapi.json", "/openapi.json"].includes(route2))
|
|
2590
3131
|
return json(openapi_default);
|
|
2591
|
-
const parts =
|
|
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,
|
|
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
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
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);
|