@hasna/switcher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2800 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/serve.ts
5
+ import { parseArgs } from "util";
6
+ import { join } from "path";
7
+
8
+ // src/store.ts
9
+ var {SQL } = globalThis.Bun;
10
+ import { chmod, mkdir } from "fs/promises";
11
+ import { dirname, resolve } from "path";
12
+
13
+ // src/domain.ts
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"]);
18
+ var idSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
19
+ var label = z.string().min(1).max(200);
20
+ var envRef = z.string().regex(/^SWITCHER_PROVIDER_[A-Z0-9_]+$/);
21
+ function endpoint(value) {
22
+ let url;
23
+ try {
24
+ url = new URL(value);
25
+ } catch {
26
+ throw new Fault(400, "invalid_url", "Use an absolute HTTPS URL (HTTP is allowed on loopback).");
27
+ }
28
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
29
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local) || url.username || url.password || url.search || url.hash)
30
+ throw new Fault(400, "invalid_url", "URL must use HTTPS, contain no credentials/query/fragment, or use HTTP on loopback.");
31
+ return url.href.replace(/\/+$/, "");
32
+ }
33
+ var urlSchema = z.string().max(2000).superRefine((v, ctx) => {
34
+ try {
35
+ endpoint(v);
36
+ } catch {
37
+ ctx.addIssue({ code: "custom", message: "Invalid endpoint URL" });
38
+ }
39
+ }).transform(endpoint);
40
+ var modelSchema = z.object({
41
+ id: z.string().min(1).max(300),
42
+ name: label,
43
+ description: z.string().max(8000).optional(),
44
+ contextWindow: z.number().int().positive().optional(),
45
+ maxOutputTokens: z.number().int().positive().optional(),
46
+ inputModalities: z.array(z.string().max(50)).max(20).optional(),
47
+ outputModalities: z.array(z.string().max(50)).max(20).optional(),
48
+ supportedParameters: z.array(z.string().max(100)).max(100).optional()
49
+ }).strict();
50
+ var providerInputSchema = z.object({
51
+ id: idSchema,
52
+ name: label,
53
+ baseUrl: urlSchema,
54
+ protocol: protocolSchema,
55
+ credentialEnv: envRef.optional(),
56
+ authStyle: z.enum(["bearer", "x-api-key"]).default("bearer"),
57
+ modelsPath: z.string().regex(/^[a-zA-Z0-9_/-]+$/).max(200).default("models"),
58
+ manualModels: z.array(modelSchema).max(1e4).default([])
59
+ }).strict().refine((p) => !p.modelsPath.split("/").includes("..") && !p.modelsPath.startsWith("/"), "modelsPath must be relative");
60
+ var profileInputSchema = z.object({
61
+ id: idSchema,
62
+ name: label,
63
+ providerId: idSchema,
64
+ harness: harnessSchema,
65
+ model: z.string().min(1).max(300)
66
+ }).strict();
67
+ var runInputSchema = z.object({
68
+ profileId: idSchema,
69
+ harness: harnessSchema,
70
+ model: z.string().min(1).max(300),
71
+ planToken: z.string().regex(/^[a-f0-9]{64}$/)
72
+ }).strict();
73
+ var runUpdateSchema = z.object({
74
+ status: z.enum(["exited", "failed", "interrupted"]),
75
+ exitCode: z.number().int().min(0).max(255)
76
+ }).strict();
77
+
78
+ class Fault extends Error {
79
+ status;
80
+ code;
81
+ constructor(status, code, message) {
82
+ super(message);
83
+ this.status = status;
84
+ this.code = code;
85
+ }
86
+ }
87
+ function parse(schema, value) {
88
+ const result = schema.safeParse(value);
89
+ if (!result.success)
90
+ throw new Fault(400, "invalid_request", result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
91
+ return result.data;
92
+ }
93
+ function compatible(harness, protocol) {
94
+ return harness === "claude" ? protocol === "anthropic-messages" : harness === "codex" ? protocol === "openai-responses" : true;
95
+ }
96
+ function codingEligible(model) {
97
+ return (!model.outputModalities || model.outputModalities.includes("text")) && (!model.supportedParameters || model.supportedParameters.includes("tools"));
98
+ }
99
+
100
+ // src/store.ts
101
+ class Store {
102
+ tail = Promise.resolve();
103
+ async exclusive(action) {
104
+ if (this.engine !== "sqlite")
105
+ return action();
106
+ const next = this.tail.then(action, action);
107
+ this.tail = next.catch(() => {});
108
+ return next;
109
+ }
110
+ sql;
111
+ engine;
112
+ constructor(sql, engine) {
113
+ this.sql = sql;
114
+ this.engine = engine;
115
+ }
116
+ static async open(config) {
117
+ if (!!config.databaseUrl === !!config.sqlitePath)
118
+ 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.");
144
+ }
145
+ return store;
146
+ }
147
+ async migrate() {
148
+ await this.sql.begin(async (tx) => {
149
+ if (this.engine === "postgresql")
150
+ await tx.unsafe("SELECT pg_advisory_xact_lock(782034215)");
151
+ await tx.unsafe("CREATE TABLE IF NOT EXISTS switcher_migrations (version INTEGER PRIMARY KEY)");
152
+ const versions = await tx.unsafe("SELECT version FROM switcher_migrations ORDER BY version");
153
+ if (versions.some((v) => v.version > 1))
154
+ throw new Error("Newer database schema");
155
+ if (versions.length)
156
+ return;
157
+ await tx.unsafe("CREATE TABLE switcher_providers (id TEXT PRIMARY KEY, version INTEGER NOT NULL, updated_at TEXT NOT NULL, payload TEXT NOT NULL)");
158
+ await tx.unsafe("CREATE TABLE switcher_profiles (id TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES switcher_providers(id), version INTEGER NOT NULL, updated_at TEXT NOT NULL, payload TEXT NOT NULL)");
159
+ await tx.unsafe("CREATE TABLE switcher_catalogs (id TEXT PRIMARY KEY REFERENCES switcher_providers(id) ON DELETE CASCADE, version INTEGER NOT NULL, updated_at TEXT NOT NULL, payload TEXT NOT NULL)");
160
+ await tx.unsafe("CREATE TABLE switcher_runs (id TEXT PRIMARY KEY, profile_id TEXT NOT NULL REFERENCES switcher_profiles(id), version INTEGER NOT NULL, updated_at TEXT NOT NULL, payload TEXT NOT NULL)");
161
+ await tx.unsafe("CREATE TABLE switcher_idempotency (key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)");
162
+ await tx.unsafe("INSERT INTO switcher_migrations(version) VALUES (1)");
163
+ });
164
+ }
165
+ async ready() {
166
+ await this.exclusive(async () => {
167
+ await this.sql.unsafe("SELECT 1");
168
+ });
169
+ }
170
+ async close() {
171
+ await this.tail;
172
+ await this.sql.close();
173
+ }
174
+ async get(kind, id, db = this.sql) {
175
+ if (db === this.sql && this.engine === "sqlite")
176
+ return this.exclusive(() => this.read(kind, id, db));
177
+ return this.read(kind, id, db);
178
+ }
179
+ async read(kind, id, db) {
180
+ const rows = await db.unsafe(`SELECT payload FROM switcher_${kind} WHERE id = $1`, [id]);
181
+ if (!rows.length)
182
+ throw new Fault(404, "not_found", `${kind} entry was not found.`);
183
+ return JSON.parse(rows[0].payload);
184
+ }
185
+ async list(kind, { limit = 100, offset = 0, search = "" } = {}) {
186
+ return this.exclusive(async () => {
187
+ const name = this.engine === "sqlite" ? "json_extract(payload, '$.name')" : "payload::jsonb->>'name'";
188
+ const pattern = `%${search.toLowerCase().replace(/[!%_]/g, (c) => "!" + c)}%`;
189
+ const where = `LOWER(id) LIKE $1 ESCAPE '!' OR LOWER(COALESCE(${name}, '')) LIKE $1 ESCAPE '!'`;
190
+ const total = await this.sql.unsafe(`SELECT COUNT(*) AS total FROM switcher_${kind} WHERE ${where}`, [pattern]);
191
+ const rows = await this.sql.unsafe(`SELECT payload FROM switcher_${kind} WHERE ${where} ORDER BY id LIMIT $2 OFFSET $3`, [pattern, limit, offset]);
192
+ return { data: rows.map((r) => JSON.parse(r.payload)), total: Number(total[0].total), limit, offset };
193
+ });
194
+ }
195
+ async put(kind, input, expectedVersion, db) {
196
+ const now = new Date().toISOString();
197
+ const value = { ...input, version: expectedVersion === undefined ? 1 : expectedVersion + 1, updatedAt: now };
198
+ if (expectedVersion === undefined) {
199
+ const extraColumn = kind === "profiles" ? ", provider_id" : kind === "runs" ? ", profile_id" : "";
200
+ const extraValue = kind === "profiles" ? input.providerId : kind === "runs" ? input.profileId : undefined;
201
+ await db.unsafe(`INSERT INTO switcher_${kind} (id, version, updated_at, payload${extraColumn}) VALUES ($1, $2, $3, $4${extraColumn ? ", $5" : ""})`, [input.id, value.version, now, JSON.stringify(value), ...extraColumn ? [extraValue] : []]);
202
+ } else {
203
+ const extra = kind === "profiles" ? ", provider_id = $4" : "";
204
+ const rows = await db.unsafe(`UPDATE switcher_${kind} SET version = $1, updated_at = $2, payload = $3${extra} WHERE id = $${extra ? 5 : 4} AND version = $${extra ? 6 : 5} RETURNING id`, [value.version, now, JSON.stringify(value), ...extra ? [input.providerId] : [], input.id, expectedVersion]);
205
+ if (!rows.length)
206
+ throw new Fault(409, "version_conflict", "Entry changed or is missing; reload before retrying.");
207
+ }
208
+ return value;
209
+ }
210
+ async remove(kind, id, version, db) {
211
+ const rows = await db.unsafe(`DELETE FROM switcher_${kind} WHERE id = $1 AND version = $2 RETURNING id`, [id, version]);
212
+ if (!rows.length)
213
+ throw new Fault(409, "version_conflict", "Entry changed or is missing; reload before retrying.");
214
+ return { deleted: id };
215
+ }
216
+ async mutate(key, fingerprint, action) {
217
+ return this.exclusive(() => this.transaction(key, fingerprint, action));
218
+ }
219
+ async replay(key, fingerprint) {
220
+ return this.exclusive(async () => {
221
+ const rows = await this.sql.unsafe("SELECT fingerprint, payload FROM switcher_idempotency WHERE key = $1", [key]);
222
+ if (!rows.length)
223
+ return { found: false };
224
+ if (rows[0].fingerprint !== fingerprint)
225
+ throw new Fault(409, "idempotency_conflict", "Idempotency key was already used for a different request.");
226
+ return { found: true, value: JSON.parse(rows[0].payload) };
227
+ });
228
+ }
229
+ async transaction(key, fingerprint, action) {
230
+ try {
231
+ return await this.sql.begin(async (tx) => {
232
+ const inserted = await tx.unsafe("INSERT INTO switcher_idempotency (key, fingerprint, payload, created_at) VALUES ($1, $2, '', $3) ON CONFLICT (key) DO NOTHING RETURNING key", [key, fingerprint, new Date().toISOString()]);
233
+ if (!inserted.length) {
234
+ const rows = await tx.unsafe("SELECT fingerprint, payload FROM switcher_idempotency WHERE key = $1", [key]);
235
+ if (rows[0].fingerprint !== fingerprint)
236
+ throw new Fault(409, "idempotency_conflict", "Idempotency key was already used for a different request.");
237
+ return JSON.parse(rows[0].payload);
238
+ }
239
+ const value = await action(tx);
240
+ await tx.unsafe("UPDATE switcher_idempotency SET payload = $1 WHERE key = $2", [JSON.stringify(value), key]);
241
+ return value;
242
+ });
243
+ } catch (error) {
244
+ if (error instanceof Fault)
245
+ throw error;
246
+ const code = String(this.engine === "postgresql" ? error?.errno : error?.code);
247
+ if (["23505", "23503", "SQLITE_CONSTRAINT", "SQLITE_CONSTRAINT_PRIMARYKEY", "SQLITE_CONSTRAINT_FOREIGNKEY"].includes(code))
248
+ throw new Fault(409, "conflict", "Duplicate entry or an entry still referenced by another resource.");
249
+ throw new Fault(503, "storage_unavailable", "Storage operation failed.");
250
+ }
251
+ }
252
+ }
253
+
254
+ // src/service.ts
255
+ import { createHash, timingSafeEqual } from "crypto";
256
+ import { z as z2 } from "zod";
257
+
258
+ // src/http.ts
259
+ var MAX_BYTES = 16 * 1024 * 1024;
260
+ async function boundedJson(response, maxBytes = MAX_BYTES) {
261
+ if (!response.body)
262
+ throw new Fault(502, "invalid_upstream", "Upstream returned no body.");
263
+ const reader = response.body.getReader();
264
+ const chunks = [];
265
+ let size = 0;
266
+ try {
267
+ while (true) {
268
+ const item = await reader.read();
269
+ if (item.done)
270
+ break;
271
+ size += item.value.byteLength;
272
+ if (size > maxBytes)
273
+ throw new Fault(502, "response_too_large", "Response exceeds the size limit.");
274
+ chunks.push(item.value);
275
+ }
276
+ const bytes = new Uint8Array(size);
277
+ let offset = 0;
278
+ for (const chunk of chunks) {
279
+ bytes.set(chunk, offset);
280
+ offset += chunk.length;
281
+ }
282
+ try {
283
+ return JSON.parse(new TextDecoder().decode(bytes));
284
+ } catch {
285
+ throw new Fault(502, "invalid_upstream", "Upstream returned invalid JSON.");
286
+ }
287
+ } finally {
288
+ await reader.cancel().catch(() => {});
289
+ }
290
+ }
291
+
292
+ // src/catalog.ts
293
+ var positive = (v) => typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
294
+ var strings = (v) => Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
295
+ async function discover(provider, env = process.env) {
296
+ const refreshedAt = new Date().toISOString();
297
+ if (provider.manualModels.length)
298
+ return { models: provider.manualModels, source: "manual", refreshedAt };
299
+ const headers = { accept: "application/json" };
300
+ if (provider.credentialEnv) {
301
+ const credential = env[provider.credentialEnv];
302
+ if (!credential)
303
+ 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}`;
305
+ }
306
+ const url = new URL(`${provider.baseUrl}/${provider.modelsPath}`);
307
+ if (provider.protocol === "anthropic-messages" && url.hostname !== "openrouter.ai")
308
+ headers["anthropic-version"] = "2023-06-01";
309
+ if (url.hostname === "openrouter.ai")
310
+ url.searchParams.set("output_modalities", "all");
311
+ const models = new Map;
312
+ const seenCursors = new Set;
313
+ 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
+ }
324
+ 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")
329
+ throw new Fault(502, "invalid_catalog", "Catalog entry is missing a model ID.");
330
+ const candidate = {
331
+ id: row.id,
332
+ name: row.name ?? row.display_name ?? row.id,
333
+ 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)
339
+ };
340
+ const parsed = modelSchema.safeParse(candidate);
341
+ if (!parsed.success)
342
+ throw new Fault(502, "invalid_catalog", "Provider returned malformed model metadata.");
343
+ models.set(candidate.id, parsed.data);
344
+ if (models.size > 1e4)
345
+ throw new Fault(502, "catalog_too_large", "Catalog exceeds 10,000 models; configure a narrower endpoint.");
346
+ }
347
+ if (!data.has_more)
348
+ return { models: [...models.values()], source: "remote", refreshedAt };
349
+ const cursor = data.last_id;
350
+ if (typeof cursor !== "string" || seenCursors.has(cursor))
351
+ throw new Fault(502, "invalid_catalog", "Provider catalog pagination did not advance.");
352
+ seenCursors.add(cursor);
353
+ url.searchParams.set("after_id", cursor);
354
+ url.searchParams.set("limit", "1000");
355
+ }
356
+ throw new Fault(502, "catalog_too_large", "Provider catalog pagination exceeded 100 pages.");
357
+ }
358
+ // openapi.json
359
+ var openapi_default = {
360
+ openapi: "3.0.3",
361
+ info: {
362
+ title: "Switcher API",
363
+ version: "0.1.0",
364
+ description: "Authenticated provider/profile/catalog control plane. Launches run locally; the API never returns provider credentials."
365
+ },
366
+ security: [
367
+ {
368
+ bearerAuth: []
369
+ }
370
+ ],
371
+ paths: {
372
+ "/v1/providers": {
373
+ get: {
374
+ operationId: "listProviders",
375
+ parameters: [
376
+ {
377
+ name: "limit",
378
+ in: "query",
379
+ schema: {
380
+ type: "integer",
381
+ minimum: 1,
382
+ maximum: 1000,
383
+ default: 100
384
+ }
385
+ },
386
+ {
387
+ name: "offset",
388
+ in: "query",
389
+ schema: {
390
+ type: "integer",
391
+ minimum: 0,
392
+ default: 0
393
+ }
394
+ },
395
+ {
396
+ name: "search",
397
+ in: "query",
398
+ schema: {
399
+ type: "string"
400
+ }
401
+ }
402
+ ],
403
+ responses: {
404
+ "200": {
405
+ description: "Success",
406
+ content: {
407
+ "application/json": {
408
+ schema: {
409
+ type: "object",
410
+ required: [
411
+ "data",
412
+ "total",
413
+ "limit",
414
+ "offset"
415
+ ],
416
+ properties: {
417
+ data: {
418
+ type: "array",
419
+ items: {
420
+ $ref: "#/components/schemas/Provider"
421
+ }
422
+ },
423
+ total: {
424
+ type: "integer"
425
+ },
426
+ limit: {
427
+ type: "integer"
428
+ },
429
+ offset: {
430
+ type: "integer"
431
+ }
432
+ }
433
+ }
434
+ }
435
+ }
436
+ },
437
+ default: {
438
+ description: "Structured error",
439
+ content: {
440
+ "application/json": {
441
+ schema: {
442
+ $ref: "#/components/schemas/Error"
443
+ }
444
+ }
445
+ }
446
+ }
447
+ }
448
+ },
449
+ post: {
450
+ operationId: "createProvider",
451
+ parameters: [
452
+ {
453
+ name: "Idempotency-Key",
454
+ in: "header",
455
+ required: true,
456
+ schema: {
457
+ type: "string",
458
+ minLength: 8,
459
+ maxLength: 128
460
+ }
461
+ }
462
+ ],
463
+ requestBody: {
464
+ required: true,
465
+ content: {
466
+ "application/json": {
467
+ schema: {
468
+ $ref: "#/components/schemas/ProviderInput"
469
+ }
470
+ }
471
+ }
472
+ },
473
+ responses: {
474
+ "201": {
475
+ description: "Success",
476
+ content: {
477
+ "application/json": {
478
+ schema: {
479
+ $ref: "#/components/schemas/Provider"
480
+ }
481
+ }
482
+ }
483
+ },
484
+ default: {
485
+ description: "Structured error",
486
+ content: {
487
+ "application/json": {
488
+ schema: {
489
+ $ref: "#/components/schemas/Error"
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+ },
497
+ "/v1/providers/{id}": {
498
+ get: {
499
+ operationId: "getProvider",
500
+ parameters: [
501
+ {
502
+ name: "id",
503
+ in: "path",
504
+ required: true,
505
+ schema: {
506
+ type: "string"
507
+ }
508
+ }
509
+ ],
510
+ responses: {
511
+ "200": {
512
+ description: "Success",
513
+ content: {
514
+ "application/json": {
515
+ schema: {
516
+ $ref: "#/components/schemas/Provider"
517
+ }
518
+ }
519
+ }
520
+ },
521
+ default: {
522
+ description: "Structured error",
523
+ content: {
524
+ "application/json": {
525
+ schema: {
526
+ $ref: "#/components/schemas/Error"
527
+ }
528
+ }
529
+ }
530
+ }
531
+ }
532
+ },
533
+ put: {
534
+ operationId: "updateProvider",
535
+ parameters: [
536
+ {
537
+ name: "id",
538
+ in: "path",
539
+ required: true,
540
+ schema: {
541
+ type: "string"
542
+ }
543
+ },
544
+ {
545
+ name: "Idempotency-Key",
546
+ in: "header",
547
+ required: true,
548
+ schema: {
549
+ type: "string",
550
+ minLength: 8,
551
+ maxLength: 128
552
+ }
553
+ },
554
+ {
555
+ name: "If-Match",
556
+ in: "header",
557
+ required: true,
558
+ schema: {
559
+ type: "integer",
560
+ minimum: 1
561
+ }
562
+ }
563
+ ],
564
+ requestBody: {
565
+ required: true,
566
+ content: {
567
+ "application/json": {
568
+ schema: {
569
+ $ref: "#/components/schemas/ProviderInput"
570
+ }
571
+ }
572
+ }
573
+ },
574
+ responses: {
575
+ "200": {
576
+ description: "Success",
577
+ content: {
578
+ "application/json": {
579
+ schema: {
580
+ $ref: "#/components/schemas/Provider"
581
+ }
582
+ }
583
+ }
584
+ },
585
+ default: {
586
+ description: "Structured error",
587
+ content: {
588
+ "application/json": {
589
+ schema: {
590
+ $ref: "#/components/schemas/Error"
591
+ }
592
+ }
593
+ }
594
+ }
595
+ }
596
+ },
597
+ delete: {
598
+ operationId: "deleteProvider",
599
+ parameters: [
600
+ {
601
+ name: "id",
602
+ in: "path",
603
+ required: true,
604
+ schema: {
605
+ type: "string"
606
+ }
607
+ },
608
+ {
609
+ name: "Idempotency-Key",
610
+ in: "header",
611
+ required: true,
612
+ schema: {
613
+ type: "string",
614
+ minLength: 8,
615
+ maxLength: 128
616
+ }
617
+ },
618
+ {
619
+ name: "If-Match",
620
+ in: "header",
621
+ required: true,
622
+ schema: {
623
+ type: "integer",
624
+ minimum: 1
625
+ }
626
+ }
627
+ ],
628
+ responses: {
629
+ "200": {
630
+ description: "Success",
631
+ content: {
632
+ "application/json": {
633
+ schema: {
634
+ type: "object",
635
+ properties: {
636
+ deleted: {
637
+ type: "string"
638
+ }
639
+ },
640
+ required: [
641
+ "deleted"
642
+ ]
643
+ }
644
+ }
645
+ }
646
+ },
647
+ default: {
648
+ description: "Structured error",
649
+ content: {
650
+ "application/json": {
651
+ schema: {
652
+ $ref: "#/components/schemas/Error"
653
+ }
654
+ }
655
+ }
656
+ }
657
+ }
658
+ }
659
+ },
660
+ "/v1/profiles": {
661
+ get: {
662
+ operationId: "listProfiles",
663
+ parameters: [
664
+ {
665
+ name: "limit",
666
+ in: "query",
667
+ schema: {
668
+ type: "integer",
669
+ minimum: 1,
670
+ maximum: 1000,
671
+ default: 100
672
+ }
673
+ },
674
+ {
675
+ name: "offset",
676
+ in: "query",
677
+ schema: {
678
+ type: "integer",
679
+ minimum: 0,
680
+ default: 0
681
+ }
682
+ },
683
+ {
684
+ name: "search",
685
+ in: "query",
686
+ schema: {
687
+ type: "string"
688
+ }
689
+ }
690
+ ],
691
+ responses: {
692
+ "200": {
693
+ description: "Success",
694
+ content: {
695
+ "application/json": {
696
+ schema: {
697
+ type: "object",
698
+ required: [
699
+ "data",
700
+ "total",
701
+ "limit",
702
+ "offset"
703
+ ],
704
+ properties: {
705
+ data: {
706
+ type: "array",
707
+ items: {
708
+ $ref: "#/components/schemas/Profile"
709
+ }
710
+ },
711
+ total: {
712
+ type: "integer"
713
+ },
714
+ limit: {
715
+ type: "integer"
716
+ },
717
+ offset: {
718
+ type: "integer"
719
+ }
720
+ }
721
+ }
722
+ }
723
+ }
724
+ },
725
+ default: {
726
+ description: "Structured error",
727
+ content: {
728
+ "application/json": {
729
+ schema: {
730
+ $ref: "#/components/schemas/Error"
731
+ }
732
+ }
733
+ }
734
+ }
735
+ }
736
+ },
737
+ post: {
738
+ operationId: "createProfile",
739
+ parameters: [
740
+ {
741
+ name: "Idempotency-Key",
742
+ in: "header",
743
+ required: true,
744
+ schema: {
745
+ type: "string",
746
+ minLength: 8,
747
+ maxLength: 128
748
+ }
749
+ }
750
+ ],
751
+ requestBody: {
752
+ required: true,
753
+ content: {
754
+ "application/json": {
755
+ schema: {
756
+ $ref: "#/components/schemas/ProfileInput"
757
+ }
758
+ }
759
+ }
760
+ },
761
+ responses: {
762
+ "201": {
763
+ description: "Success",
764
+ content: {
765
+ "application/json": {
766
+ schema: {
767
+ $ref: "#/components/schemas/Profile"
768
+ }
769
+ }
770
+ }
771
+ },
772
+ default: {
773
+ description: "Structured error",
774
+ content: {
775
+ "application/json": {
776
+ schema: {
777
+ $ref: "#/components/schemas/Error"
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+ }
784
+ },
785
+ "/v1/profiles/{id}": {
786
+ get: {
787
+ operationId: "getProfile",
788
+ parameters: [
789
+ {
790
+ name: "id",
791
+ in: "path",
792
+ required: true,
793
+ schema: {
794
+ type: "string"
795
+ }
796
+ }
797
+ ],
798
+ responses: {
799
+ "200": {
800
+ description: "Success",
801
+ content: {
802
+ "application/json": {
803
+ schema: {
804
+ $ref: "#/components/schemas/Profile"
805
+ }
806
+ }
807
+ }
808
+ },
809
+ default: {
810
+ description: "Structured error",
811
+ content: {
812
+ "application/json": {
813
+ schema: {
814
+ $ref: "#/components/schemas/Error"
815
+ }
816
+ }
817
+ }
818
+ }
819
+ }
820
+ },
821
+ put: {
822
+ operationId: "updateProfile",
823
+ parameters: [
824
+ {
825
+ name: "id",
826
+ in: "path",
827
+ required: true,
828
+ schema: {
829
+ type: "string"
830
+ }
831
+ },
832
+ {
833
+ name: "Idempotency-Key",
834
+ in: "header",
835
+ required: true,
836
+ schema: {
837
+ type: "string",
838
+ minLength: 8,
839
+ maxLength: 128
840
+ }
841
+ },
842
+ {
843
+ name: "If-Match",
844
+ in: "header",
845
+ required: true,
846
+ schema: {
847
+ type: "integer",
848
+ minimum: 1
849
+ }
850
+ }
851
+ ],
852
+ requestBody: {
853
+ required: true,
854
+ content: {
855
+ "application/json": {
856
+ schema: {
857
+ $ref: "#/components/schemas/ProfileInput"
858
+ }
859
+ }
860
+ }
861
+ },
862
+ responses: {
863
+ "200": {
864
+ description: "Success",
865
+ content: {
866
+ "application/json": {
867
+ schema: {
868
+ $ref: "#/components/schemas/Profile"
869
+ }
870
+ }
871
+ }
872
+ },
873
+ default: {
874
+ description: "Structured error",
875
+ content: {
876
+ "application/json": {
877
+ schema: {
878
+ $ref: "#/components/schemas/Error"
879
+ }
880
+ }
881
+ }
882
+ }
883
+ }
884
+ },
885
+ delete: {
886
+ operationId: "deleteProfile",
887
+ parameters: [
888
+ {
889
+ name: "id",
890
+ in: "path",
891
+ required: true,
892
+ schema: {
893
+ type: "string"
894
+ }
895
+ },
896
+ {
897
+ name: "Idempotency-Key",
898
+ in: "header",
899
+ required: true,
900
+ schema: {
901
+ type: "string",
902
+ minLength: 8,
903
+ maxLength: 128
904
+ }
905
+ },
906
+ {
907
+ name: "If-Match",
908
+ in: "header",
909
+ required: true,
910
+ schema: {
911
+ type: "integer",
912
+ minimum: 1
913
+ }
914
+ }
915
+ ],
916
+ responses: {
917
+ "200": {
918
+ description: "Success",
919
+ content: {
920
+ "application/json": {
921
+ schema: {
922
+ type: "object",
923
+ properties: {
924
+ deleted: {
925
+ type: "string"
926
+ }
927
+ },
928
+ required: [
929
+ "deleted"
930
+ ]
931
+ }
932
+ }
933
+ }
934
+ },
935
+ default: {
936
+ description: "Structured error",
937
+ content: {
938
+ "application/json": {
939
+ schema: {
940
+ $ref: "#/components/schemas/Error"
941
+ }
942
+ }
943
+ }
944
+ }
945
+ }
946
+ }
947
+ },
948
+ "/v1/providers/{id}/models": {
949
+ get: {
950
+ operationId: "listModels",
951
+ parameters: [
952
+ {
953
+ name: "id",
954
+ in: "path",
955
+ required: true,
956
+ schema: {
957
+ type: "string"
958
+ }
959
+ },
960
+ {
961
+ name: "limit",
962
+ in: "query",
963
+ schema: {
964
+ type: "integer",
965
+ minimum: 1,
966
+ maximum: 1000,
967
+ default: 100
968
+ }
969
+ },
970
+ {
971
+ name: "offset",
972
+ in: "query",
973
+ schema: {
974
+ type: "integer",
975
+ minimum: 0,
976
+ default: 0
977
+ }
978
+ },
979
+ {
980
+ name: "search",
981
+ in: "query",
982
+ schema: {
983
+ type: "string"
984
+ }
985
+ }
986
+ ],
987
+ responses: {
988
+ "200": {
989
+ description: "Success",
990
+ content: {
991
+ "application/json": {
992
+ schema: {
993
+ $ref: "#/components/schemas/ModelPage"
994
+ }
995
+ }
996
+ }
997
+ },
998
+ default: {
999
+ description: "Structured error",
1000
+ content: {
1001
+ "application/json": {
1002
+ schema: {
1003
+ $ref: "#/components/schemas/Error"
1004
+ }
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ },
1011
+ "/v1/providers/{id}/refresh": {
1012
+ post: {
1013
+ operationId: "refreshModels",
1014
+ parameters: [
1015
+ {
1016
+ name: "id",
1017
+ in: "path",
1018
+ required: true,
1019
+ schema: {
1020
+ type: "string"
1021
+ }
1022
+ },
1023
+ {
1024
+ name: "Idempotency-Key",
1025
+ in: "header",
1026
+ required: true,
1027
+ schema: {
1028
+ type: "string",
1029
+ minLength: 8,
1030
+ maxLength: 128
1031
+ }
1032
+ }
1033
+ ],
1034
+ requestBody: {
1035
+ required: true,
1036
+ content: {
1037
+ "application/json": {
1038
+ schema: {
1039
+ $ref: "#/components/schemas/Empty"
1040
+ }
1041
+ }
1042
+ }
1043
+ },
1044
+ responses: {
1045
+ "200": {
1046
+ description: "Success",
1047
+ content: {
1048
+ "application/json": {
1049
+ schema: {
1050
+ $ref: "#/components/schemas/Catalog"
1051
+ }
1052
+ }
1053
+ }
1054
+ },
1055
+ default: {
1056
+ description: "Structured error",
1057
+ content: {
1058
+ "application/json": {
1059
+ schema: {
1060
+ $ref: "#/components/schemas/Error"
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+ },
1068
+ "/v1/launch-plans": {
1069
+ post: {
1070
+ operationId: "launchPlan",
1071
+ parameters: [
1072
+ {
1073
+ name: "Idempotency-Key",
1074
+ in: "header",
1075
+ required: true,
1076
+ schema: {
1077
+ type: "string",
1078
+ minLength: 8,
1079
+ maxLength: 128
1080
+ }
1081
+ }
1082
+ ],
1083
+ requestBody: {
1084
+ required: true,
1085
+ content: {
1086
+ "application/json": {
1087
+ schema: {
1088
+ $ref: "#/components/schemas/LaunchInput"
1089
+ }
1090
+ }
1091
+ }
1092
+ },
1093
+ responses: {
1094
+ "200": {
1095
+ description: "Success",
1096
+ content: {
1097
+ "application/json": {
1098
+ schema: {
1099
+ $ref: "#/components/schemas/LaunchPlan"
1100
+ }
1101
+ }
1102
+ }
1103
+ },
1104
+ default: {
1105
+ description: "Structured error",
1106
+ content: {
1107
+ "application/json": {
1108
+ schema: {
1109
+ $ref: "#/components/schemas/Error"
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+ }
1115
+ }
1116
+ },
1117
+ "/v1/runs": {
1118
+ get: {
1119
+ operationId: "listRuns",
1120
+ parameters: [
1121
+ {
1122
+ name: "limit",
1123
+ in: "query",
1124
+ schema: {
1125
+ type: "integer",
1126
+ minimum: 1,
1127
+ maximum: 1000,
1128
+ default: 100
1129
+ }
1130
+ },
1131
+ {
1132
+ name: "offset",
1133
+ in: "query",
1134
+ schema: {
1135
+ type: "integer",
1136
+ minimum: 0,
1137
+ default: 0
1138
+ }
1139
+ },
1140
+ {
1141
+ name: "search",
1142
+ in: "query",
1143
+ schema: {
1144
+ type: "string"
1145
+ }
1146
+ }
1147
+ ],
1148
+ responses: {
1149
+ "200": {
1150
+ description: "Success",
1151
+ content: {
1152
+ "application/json": {
1153
+ schema: {
1154
+ type: "object",
1155
+ required: [
1156
+ "data",
1157
+ "total",
1158
+ "limit",
1159
+ "offset"
1160
+ ],
1161
+ properties: {
1162
+ data: {
1163
+ type: "array",
1164
+ items: {
1165
+ $ref: "#/components/schemas/Run"
1166
+ }
1167
+ },
1168
+ total: {
1169
+ type: "integer"
1170
+ },
1171
+ limit: {
1172
+ type: "integer"
1173
+ },
1174
+ offset: {
1175
+ type: "integer"
1176
+ }
1177
+ }
1178
+ }
1179
+ }
1180
+ }
1181
+ },
1182
+ default: {
1183
+ description: "Structured error",
1184
+ content: {
1185
+ "application/json": {
1186
+ schema: {
1187
+ $ref: "#/components/schemas/Error"
1188
+ }
1189
+ }
1190
+ }
1191
+ }
1192
+ }
1193
+ },
1194
+ post: {
1195
+ operationId: "createRun",
1196
+ parameters: [
1197
+ {
1198
+ name: "Idempotency-Key",
1199
+ in: "header",
1200
+ required: true,
1201
+ schema: {
1202
+ type: "string",
1203
+ minLength: 8,
1204
+ maxLength: 128
1205
+ }
1206
+ }
1207
+ ],
1208
+ requestBody: {
1209
+ required: true,
1210
+ content: {
1211
+ "application/json": {
1212
+ schema: {
1213
+ $ref: "#/components/schemas/RunInput"
1214
+ }
1215
+ }
1216
+ }
1217
+ },
1218
+ responses: {
1219
+ "201": {
1220
+ description: "Success",
1221
+ content: {
1222
+ "application/json": {
1223
+ schema: {
1224
+ $ref: "#/components/schemas/Run"
1225
+ }
1226
+ }
1227
+ }
1228
+ },
1229
+ default: {
1230
+ description: "Structured error",
1231
+ content: {
1232
+ "application/json": {
1233
+ schema: {
1234
+ $ref: "#/components/schemas/Error"
1235
+ }
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+ }
1241
+ },
1242
+ "/v1/runs/{id}": {
1243
+ get: {
1244
+ operationId: "getRun",
1245
+ parameters: [
1246
+ {
1247
+ name: "id",
1248
+ in: "path",
1249
+ required: true,
1250
+ schema: {
1251
+ type: "string"
1252
+ }
1253
+ }
1254
+ ],
1255
+ responses: {
1256
+ "200": {
1257
+ description: "Success",
1258
+ content: {
1259
+ "application/json": {
1260
+ schema: {
1261
+ $ref: "#/components/schemas/Run"
1262
+ }
1263
+ }
1264
+ }
1265
+ },
1266
+ default: {
1267
+ description: "Structured error",
1268
+ content: {
1269
+ "application/json": {
1270
+ schema: {
1271
+ $ref: "#/components/schemas/Error"
1272
+ }
1273
+ }
1274
+ }
1275
+ }
1276
+ }
1277
+ },
1278
+ patch: {
1279
+ operationId: "finishRun",
1280
+ parameters: [
1281
+ {
1282
+ name: "id",
1283
+ in: "path",
1284
+ required: true,
1285
+ schema: {
1286
+ type: "string"
1287
+ }
1288
+ },
1289
+ {
1290
+ name: "Idempotency-Key",
1291
+ in: "header",
1292
+ required: true,
1293
+ schema: {
1294
+ type: "string",
1295
+ minLength: 8,
1296
+ maxLength: 128
1297
+ }
1298
+ },
1299
+ {
1300
+ name: "If-Match",
1301
+ in: "header",
1302
+ required: true,
1303
+ schema: {
1304
+ type: "integer",
1305
+ minimum: 1
1306
+ }
1307
+ }
1308
+ ],
1309
+ requestBody: {
1310
+ required: true,
1311
+ content: {
1312
+ "application/json": {
1313
+ schema: {
1314
+ $ref: "#/components/schemas/RunUpdate"
1315
+ }
1316
+ }
1317
+ }
1318
+ },
1319
+ responses: {
1320
+ "200": {
1321
+ description: "Success",
1322
+ content: {
1323
+ "application/json": {
1324
+ schema: {
1325
+ $ref: "#/components/schemas/Run"
1326
+ }
1327
+ }
1328
+ }
1329
+ },
1330
+ default: {
1331
+ description: "Structured error",
1332
+ content: {
1333
+ "application/json": {
1334
+ schema: {
1335
+ $ref: "#/components/schemas/Error"
1336
+ }
1337
+ }
1338
+ }
1339
+ }
1340
+ }
1341
+ }
1342
+ },
1343
+ "/health": {
1344
+ get: {
1345
+ operationId: "health",
1346
+ parameters: [],
1347
+ responses: {
1348
+ "200": {
1349
+ description: "Success",
1350
+ content: {
1351
+ "application/json": {
1352
+ schema: {
1353
+ $ref: "#/components/schemas/Health"
1354
+ }
1355
+ }
1356
+ }
1357
+ },
1358
+ default: {
1359
+ description: "Structured error",
1360
+ content: {
1361
+ "application/json": {
1362
+ schema: {
1363
+ $ref: "#/components/schemas/Error"
1364
+ }
1365
+ }
1366
+ }
1367
+ }
1368
+ },
1369
+ security: []
1370
+ }
1371
+ },
1372
+ "/ready": {
1373
+ get: {
1374
+ operationId: "ready",
1375
+ parameters: [],
1376
+ responses: {
1377
+ "200": {
1378
+ description: "Success",
1379
+ content: {
1380
+ "application/json": {
1381
+ schema: {
1382
+ $ref: "#/components/schemas/Ready"
1383
+ }
1384
+ }
1385
+ }
1386
+ },
1387
+ default: {
1388
+ description: "Structured error",
1389
+ content: {
1390
+ "application/json": {
1391
+ schema: {
1392
+ $ref: "#/components/schemas/Error"
1393
+ }
1394
+ }
1395
+ }
1396
+ }
1397
+ },
1398
+ security: []
1399
+ }
1400
+ },
1401
+ "/version": {
1402
+ get: {
1403
+ operationId: "version",
1404
+ parameters: [],
1405
+ responses: {
1406
+ "200": {
1407
+ description: "Success",
1408
+ content: {
1409
+ "application/json": {
1410
+ schema: {
1411
+ $ref: "#/components/schemas/Version"
1412
+ }
1413
+ }
1414
+ }
1415
+ },
1416
+ default: {
1417
+ description: "Structured error",
1418
+ content: {
1419
+ "application/json": {
1420
+ schema: {
1421
+ $ref: "#/components/schemas/Error"
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ },
1427
+ security: []
1428
+ }
1429
+ },
1430
+ "/v1/openapi.json": {
1431
+ get: {
1432
+ operationId: "openApi",
1433
+ parameters: [],
1434
+ responses: {
1435
+ "200": {
1436
+ description: "Success",
1437
+ content: {
1438
+ "application/json": {
1439
+ schema: {
1440
+ type: "object",
1441
+ additionalProperties: true
1442
+ }
1443
+ }
1444
+ }
1445
+ },
1446
+ default: {
1447
+ description: "Structured error",
1448
+ content: {
1449
+ "application/json": {
1450
+ schema: {
1451
+ $ref: "#/components/schemas/Error"
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+ }
1457
+ }
1458
+ }
1459
+ },
1460
+ components: {
1461
+ securitySchemes: {
1462
+ bearerAuth: {
1463
+ type: "http",
1464
+ scheme: "bearer"
1465
+ }
1466
+ },
1467
+ schemas: {
1468
+ ProviderInput: {
1469
+ type: "object",
1470
+ properties: {
1471
+ id: {
1472
+ type: "string",
1473
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1474
+ },
1475
+ name: {
1476
+ type: "string",
1477
+ minLength: 1,
1478
+ maxLength: 200
1479
+ },
1480
+ baseUrl: {
1481
+ type: "string",
1482
+ maxLength: 2000
1483
+ },
1484
+ protocol: {
1485
+ type: "string",
1486
+ enum: [
1487
+ "anthropic-messages",
1488
+ "openai-responses",
1489
+ "openai-chat"
1490
+ ]
1491
+ },
1492
+ credentialEnv: {
1493
+ type: "string",
1494
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
1495
+ },
1496
+ authStyle: {
1497
+ type: "string",
1498
+ enum: [
1499
+ "bearer",
1500
+ "x-api-key"
1501
+ ],
1502
+ default: "bearer"
1503
+ },
1504
+ modelsPath: {
1505
+ type: "string",
1506
+ pattern: "^[a-zA-Z0-9_/-]+$",
1507
+ maxLength: 200,
1508
+ default: "models"
1509
+ },
1510
+ manualModels: {
1511
+ type: "array",
1512
+ items: {
1513
+ type: "object",
1514
+ properties: {
1515
+ id: {
1516
+ type: "string",
1517
+ minLength: 1,
1518
+ maxLength: 300
1519
+ },
1520
+ name: {
1521
+ type: "string",
1522
+ minLength: 1,
1523
+ maxLength: 200
1524
+ },
1525
+ description: {
1526
+ type: "string",
1527
+ maxLength: 8000
1528
+ },
1529
+ contextWindow: {
1530
+ type: "integer",
1531
+ exclusiveMinimum: true,
1532
+ minimum: 0
1533
+ },
1534
+ maxOutputTokens: {
1535
+ type: "integer",
1536
+ exclusiveMinimum: true,
1537
+ minimum: 0
1538
+ },
1539
+ inputModalities: {
1540
+ type: "array",
1541
+ items: {
1542
+ type: "string",
1543
+ maxLength: 50
1544
+ },
1545
+ maxItems: 20
1546
+ },
1547
+ outputModalities: {
1548
+ type: "array",
1549
+ items: {
1550
+ type: "string",
1551
+ maxLength: 50
1552
+ },
1553
+ maxItems: 20
1554
+ },
1555
+ supportedParameters: {
1556
+ type: "array",
1557
+ items: {
1558
+ type: "string",
1559
+ maxLength: 100
1560
+ },
1561
+ maxItems: 100
1562
+ }
1563
+ },
1564
+ required: [
1565
+ "id",
1566
+ "name"
1567
+ ],
1568
+ additionalProperties: false
1569
+ },
1570
+ maxItems: 1e4,
1571
+ default: []
1572
+ }
1573
+ },
1574
+ required: [
1575
+ "id",
1576
+ "name",
1577
+ "baseUrl",
1578
+ "protocol"
1579
+ ],
1580
+ additionalProperties: false
1581
+ },
1582
+ Provider: {
1583
+ type: "object",
1584
+ properties: {
1585
+ id: {
1586
+ type: "string",
1587
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1588
+ },
1589
+ name: {
1590
+ type: "string",
1591
+ minLength: 1,
1592
+ maxLength: 200
1593
+ },
1594
+ baseUrl: {
1595
+ type: "string",
1596
+ maxLength: 2000
1597
+ },
1598
+ protocol: {
1599
+ type: "string",
1600
+ enum: [
1601
+ "anthropic-messages",
1602
+ "openai-responses",
1603
+ "openai-chat"
1604
+ ]
1605
+ },
1606
+ credentialEnv: {
1607
+ type: "string",
1608
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
1609
+ },
1610
+ authStyle: {
1611
+ type: "string",
1612
+ enum: [
1613
+ "bearer",
1614
+ "x-api-key"
1615
+ ],
1616
+ default: "bearer"
1617
+ },
1618
+ modelsPath: {
1619
+ type: "string",
1620
+ pattern: "^[a-zA-Z0-9_/-]+$",
1621
+ maxLength: 200,
1622
+ default: "models"
1623
+ },
1624
+ manualModels: {
1625
+ type: "array",
1626
+ items: {
1627
+ type: "object",
1628
+ properties: {
1629
+ id: {
1630
+ type: "string",
1631
+ minLength: 1,
1632
+ maxLength: 300
1633
+ },
1634
+ name: {
1635
+ type: "string",
1636
+ minLength: 1,
1637
+ maxLength: 200
1638
+ },
1639
+ description: {
1640
+ type: "string",
1641
+ maxLength: 8000
1642
+ },
1643
+ contextWindow: {
1644
+ type: "integer",
1645
+ exclusiveMinimum: true,
1646
+ minimum: 0
1647
+ },
1648
+ maxOutputTokens: {
1649
+ type: "integer",
1650
+ exclusiveMinimum: true,
1651
+ minimum: 0
1652
+ },
1653
+ inputModalities: {
1654
+ type: "array",
1655
+ items: {
1656
+ type: "string",
1657
+ maxLength: 50
1658
+ },
1659
+ maxItems: 20
1660
+ },
1661
+ outputModalities: {
1662
+ type: "array",
1663
+ items: {
1664
+ type: "string",
1665
+ maxLength: 50
1666
+ },
1667
+ maxItems: 20
1668
+ },
1669
+ supportedParameters: {
1670
+ type: "array",
1671
+ items: {
1672
+ type: "string",
1673
+ maxLength: 100
1674
+ },
1675
+ maxItems: 100
1676
+ }
1677
+ },
1678
+ required: [
1679
+ "id",
1680
+ "name"
1681
+ ],
1682
+ additionalProperties: false
1683
+ },
1684
+ maxItems: 1e4,
1685
+ default: []
1686
+ },
1687
+ version: {
1688
+ type: "integer",
1689
+ exclusiveMinimum: true,
1690
+ minimum: 0
1691
+ },
1692
+ updatedAt: {
1693
+ type: "string"
1694
+ }
1695
+ },
1696
+ required: [
1697
+ "id",
1698
+ "name",
1699
+ "baseUrl",
1700
+ "protocol",
1701
+ "version",
1702
+ "updatedAt"
1703
+ ],
1704
+ additionalProperties: false
1705
+ },
1706
+ ProfileInput: {
1707
+ type: "object",
1708
+ properties: {
1709
+ id: {
1710
+ type: "string",
1711
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1712
+ },
1713
+ name: {
1714
+ type: "string",
1715
+ minLength: 1,
1716
+ maxLength: 200
1717
+ },
1718
+ providerId: {
1719
+ type: "string",
1720
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1721
+ },
1722
+ harness: {
1723
+ type: "string",
1724
+ enum: [
1725
+ "claude",
1726
+ "codex",
1727
+ "grok",
1728
+ "opencode2"
1729
+ ]
1730
+ },
1731
+ model: {
1732
+ type: "string",
1733
+ minLength: 1,
1734
+ maxLength: 300
1735
+ }
1736
+ },
1737
+ required: [
1738
+ "id",
1739
+ "name",
1740
+ "providerId",
1741
+ "harness",
1742
+ "model"
1743
+ ],
1744
+ additionalProperties: false
1745
+ },
1746
+ Profile: {
1747
+ type: "object",
1748
+ properties: {
1749
+ id: {
1750
+ type: "string",
1751
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1752
+ },
1753
+ name: {
1754
+ type: "string",
1755
+ minLength: 1,
1756
+ maxLength: 200
1757
+ },
1758
+ providerId: {
1759
+ type: "string",
1760
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
1761
+ },
1762
+ harness: {
1763
+ type: "string",
1764
+ enum: [
1765
+ "claude",
1766
+ "codex",
1767
+ "grok",
1768
+ "opencode2"
1769
+ ]
1770
+ },
1771
+ model: {
1772
+ type: "string",
1773
+ minLength: 1,
1774
+ maxLength: 300
1775
+ },
1776
+ version: {
1777
+ type: "integer",
1778
+ exclusiveMinimum: true,
1779
+ minimum: 0
1780
+ },
1781
+ updatedAt: {
1782
+ type: "string"
1783
+ }
1784
+ },
1785
+ required: [
1786
+ "id",
1787
+ "name",
1788
+ "providerId",
1789
+ "harness",
1790
+ "model",
1791
+ "version",
1792
+ "updatedAt"
1793
+ ],
1794
+ additionalProperties: false
1795
+ },
1796
+ Model: {
1797
+ type: "object",
1798
+ properties: {
1799
+ id: {
1800
+ type: "string",
1801
+ minLength: 1,
1802
+ maxLength: 300
1803
+ },
1804
+ name: {
1805
+ type: "string",
1806
+ minLength: 1,
1807
+ maxLength: 200
1808
+ },
1809
+ description: {
1810
+ type: "string",
1811
+ maxLength: 8000
1812
+ },
1813
+ contextWindow: {
1814
+ type: "integer",
1815
+ exclusiveMinimum: true,
1816
+ minimum: 0
1817
+ },
1818
+ maxOutputTokens: {
1819
+ type: "integer",
1820
+ exclusiveMinimum: true,
1821
+ minimum: 0
1822
+ },
1823
+ inputModalities: {
1824
+ type: "array",
1825
+ items: {
1826
+ type: "string",
1827
+ maxLength: 50
1828
+ },
1829
+ maxItems: 20
1830
+ },
1831
+ outputModalities: {
1832
+ type: "array",
1833
+ items: {
1834
+ type: "string",
1835
+ maxLength: 50
1836
+ },
1837
+ maxItems: 20
1838
+ },
1839
+ supportedParameters: {
1840
+ type: "array",
1841
+ items: {
1842
+ type: "string",
1843
+ maxLength: 100
1844
+ },
1845
+ maxItems: 100
1846
+ }
1847
+ },
1848
+ required: [
1849
+ "id",
1850
+ "name"
1851
+ ],
1852
+ additionalProperties: false
1853
+ },
1854
+ ModelPage: {
1855
+ type: "object",
1856
+ properties: {
1857
+ data: {
1858
+ type: "array",
1859
+ items: {
1860
+ type: "object",
1861
+ properties: {
1862
+ id: {
1863
+ type: "string",
1864
+ minLength: 1,
1865
+ maxLength: 300
1866
+ },
1867
+ name: {
1868
+ type: "string",
1869
+ minLength: 1,
1870
+ maxLength: 200
1871
+ },
1872
+ description: {
1873
+ type: "string",
1874
+ maxLength: 8000
1875
+ },
1876
+ contextWindow: {
1877
+ type: "integer",
1878
+ exclusiveMinimum: true,
1879
+ minimum: 0
1880
+ },
1881
+ maxOutputTokens: {
1882
+ type: "integer",
1883
+ exclusiveMinimum: true,
1884
+ minimum: 0
1885
+ },
1886
+ inputModalities: {
1887
+ type: "array",
1888
+ items: {
1889
+ type: "string",
1890
+ maxLength: 50
1891
+ },
1892
+ maxItems: 20
1893
+ },
1894
+ outputModalities: {
1895
+ type: "array",
1896
+ items: {
1897
+ type: "string",
1898
+ maxLength: 50
1899
+ },
1900
+ maxItems: 20
1901
+ },
1902
+ supportedParameters: {
1903
+ type: "array",
1904
+ items: {
1905
+ type: "string",
1906
+ maxLength: 100
1907
+ },
1908
+ maxItems: 100
1909
+ },
1910
+ codingEligible: {
1911
+ type: "boolean"
1912
+ }
1913
+ },
1914
+ required: [
1915
+ "id",
1916
+ "name",
1917
+ "codingEligible"
1918
+ ],
1919
+ additionalProperties: false
1920
+ }
1921
+ },
1922
+ total: {
1923
+ type: "integer"
1924
+ },
1925
+ limit: {
1926
+ type: "integer"
1927
+ },
1928
+ offset: {
1929
+ type: "integer"
1930
+ },
1931
+ refreshedAt: {
1932
+ type: "string"
1933
+ },
1934
+ source: {
1935
+ type: "string",
1936
+ enum: [
1937
+ "remote",
1938
+ "manual"
1939
+ ]
1940
+ }
1941
+ },
1942
+ required: [
1943
+ "data",
1944
+ "total",
1945
+ "limit",
1946
+ "offset",
1947
+ "refreshedAt",
1948
+ "source"
1949
+ ],
1950
+ additionalProperties: false
1951
+ },
1952
+ Catalog: {
1953
+ type: "object",
1954
+ properties: {
1955
+ models: {
1956
+ type: "array",
1957
+ items: {
1958
+ type: "object",
1959
+ properties: {
1960
+ id: {
1961
+ type: "string",
1962
+ minLength: 1,
1963
+ maxLength: 300
1964
+ },
1965
+ name: {
1966
+ type: "string",
1967
+ minLength: 1,
1968
+ maxLength: 200
1969
+ },
1970
+ description: {
1971
+ type: "string",
1972
+ maxLength: 8000
1973
+ },
1974
+ contextWindow: {
1975
+ type: "integer",
1976
+ exclusiveMinimum: true,
1977
+ minimum: 0
1978
+ },
1979
+ maxOutputTokens: {
1980
+ type: "integer",
1981
+ exclusiveMinimum: true,
1982
+ minimum: 0
1983
+ },
1984
+ inputModalities: {
1985
+ type: "array",
1986
+ items: {
1987
+ type: "string",
1988
+ maxLength: 50
1989
+ },
1990
+ maxItems: 20
1991
+ },
1992
+ outputModalities: {
1993
+ type: "array",
1994
+ items: {
1995
+ type: "string",
1996
+ maxLength: 50
1997
+ },
1998
+ maxItems: 20
1999
+ },
2000
+ supportedParameters: {
2001
+ type: "array",
2002
+ items: {
2003
+ type: "string",
2004
+ maxLength: 100
2005
+ },
2006
+ maxItems: 100
2007
+ }
2008
+ },
2009
+ required: [
2010
+ "id",
2011
+ "name"
2012
+ ],
2013
+ additionalProperties: false
2014
+ }
2015
+ },
2016
+ refreshedAt: {
2017
+ type: "string"
2018
+ },
2019
+ source: {
2020
+ type: "string",
2021
+ enum: [
2022
+ "remote",
2023
+ "manual"
2024
+ ]
2025
+ }
2026
+ },
2027
+ required: [
2028
+ "models",
2029
+ "refreshedAt",
2030
+ "source"
2031
+ ],
2032
+ additionalProperties: false
2033
+ },
2034
+ LaunchPlan: {
2035
+ type: "object",
2036
+ properties: {
2037
+ provider: {
2038
+ type: "object",
2039
+ properties: {
2040
+ id: {
2041
+ type: "string",
2042
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2043
+ },
2044
+ name: {
2045
+ type: "string",
2046
+ minLength: 1,
2047
+ maxLength: 200
2048
+ },
2049
+ baseUrl: {
2050
+ type: "string",
2051
+ maxLength: 2000
2052
+ },
2053
+ protocol: {
2054
+ type: "string",
2055
+ enum: [
2056
+ "anthropic-messages",
2057
+ "openai-responses",
2058
+ "openai-chat"
2059
+ ]
2060
+ },
2061
+ credentialEnv: {
2062
+ type: "string",
2063
+ pattern: "^SWITCHER_PROVIDER_[A-Z0-9_]+$"
2064
+ },
2065
+ authStyle: {
2066
+ type: "string",
2067
+ enum: [
2068
+ "bearer",
2069
+ "x-api-key"
2070
+ ],
2071
+ default: "bearer"
2072
+ },
2073
+ modelsPath: {
2074
+ type: "string",
2075
+ pattern: "^[a-zA-Z0-9_/-]+$",
2076
+ maxLength: 200,
2077
+ default: "models"
2078
+ },
2079
+ manualModels: {
2080
+ type: "array",
2081
+ items: {
2082
+ type: "object",
2083
+ properties: {
2084
+ id: {
2085
+ type: "string",
2086
+ minLength: 1,
2087
+ maxLength: 300
2088
+ },
2089
+ name: {
2090
+ type: "string",
2091
+ minLength: 1,
2092
+ maxLength: 200
2093
+ },
2094
+ description: {
2095
+ type: "string",
2096
+ maxLength: 8000
2097
+ },
2098
+ contextWindow: {
2099
+ type: "integer",
2100
+ exclusiveMinimum: true,
2101
+ minimum: 0
2102
+ },
2103
+ maxOutputTokens: {
2104
+ type: "integer",
2105
+ exclusiveMinimum: true,
2106
+ minimum: 0
2107
+ },
2108
+ inputModalities: {
2109
+ type: "array",
2110
+ items: {
2111
+ type: "string",
2112
+ maxLength: 50
2113
+ },
2114
+ maxItems: 20
2115
+ },
2116
+ outputModalities: {
2117
+ type: "array",
2118
+ items: {
2119
+ type: "string",
2120
+ maxLength: 50
2121
+ },
2122
+ maxItems: 20
2123
+ },
2124
+ supportedParameters: {
2125
+ type: "array",
2126
+ items: {
2127
+ type: "string",
2128
+ maxLength: 100
2129
+ },
2130
+ maxItems: 100
2131
+ }
2132
+ },
2133
+ required: [
2134
+ "id",
2135
+ "name"
2136
+ ],
2137
+ additionalProperties: false
2138
+ },
2139
+ maxItems: 1e4,
2140
+ default: []
2141
+ },
2142
+ version: {
2143
+ type: "integer",
2144
+ exclusiveMinimum: true,
2145
+ minimum: 0
2146
+ },
2147
+ updatedAt: {
2148
+ type: "string"
2149
+ }
2150
+ },
2151
+ required: [
2152
+ "id",
2153
+ "name",
2154
+ "baseUrl",
2155
+ "protocol",
2156
+ "version",
2157
+ "updatedAt"
2158
+ ],
2159
+ additionalProperties: false
2160
+ },
2161
+ profile: {
2162
+ type: "object",
2163
+ properties: {
2164
+ id: {
2165
+ type: "string",
2166
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2167
+ },
2168
+ name: {
2169
+ type: "string",
2170
+ minLength: 1,
2171
+ maxLength: 200
2172
+ },
2173
+ providerId: {
2174
+ type: "string",
2175
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2176
+ },
2177
+ harness: {
2178
+ type: "string",
2179
+ enum: [
2180
+ "claude",
2181
+ "codex",
2182
+ "grok",
2183
+ "opencode2"
2184
+ ]
2185
+ },
2186
+ model: {
2187
+ type: "string",
2188
+ minLength: 1,
2189
+ maxLength: 300
2190
+ },
2191
+ version: {
2192
+ type: "integer",
2193
+ exclusiveMinimum: true,
2194
+ minimum: 0
2195
+ },
2196
+ updatedAt: {
2197
+ type: "string"
2198
+ }
2199
+ },
2200
+ required: [
2201
+ "id",
2202
+ "name",
2203
+ "providerId",
2204
+ "harness",
2205
+ "model",
2206
+ "version",
2207
+ "updatedAt"
2208
+ ],
2209
+ additionalProperties: false
2210
+ },
2211
+ catalog: {
2212
+ type: "object",
2213
+ properties: {
2214
+ models: {
2215
+ type: "array",
2216
+ items: {
2217
+ type: "object",
2218
+ properties: {
2219
+ id: {
2220
+ type: "string",
2221
+ minLength: 1,
2222
+ maxLength: 300
2223
+ },
2224
+ name: {
2225
+ type: "string",
2226
+ minLength: 1,
2227
+ maxLength: 200
2228
+ },
2229
+ description: {
2230
+ type: "string",
2231
+ maxLength: 8000
2232
+ },
2233
+ contextWindow: {
2234
+ type: "integer",
2235
+ exclusiveMinimum: true,
2236
+ minimum: 0
2237
+ },
2238
+ maxOutputTokens: {
2239
+ type: "integer",
2240
+ exclusiveMinimum: true,
2241
+ minimum: 0
2242
+ },
2243
+ inputModalities: {
2244
+ type: "array",
2245
+ items: {
2246
+ type: "string",
2247
+ maxLength: 50
2248
+ },
2249
+ maxItems: 20
2250
+ },
2251
+ outputModalities: {
2252
+ type: "array",
2253
+ items: {
2254
+ type: "string",
2255
+ maxLength: 50
2256
+ },
2257
+ maxItems: 20
2258
+ },
2259
+ supportedParameters: {
2260
+ type: "array",
2261
+ items: {
2262
+ type: "string",
2263
+ maxLength: 100
2264
+ },
2265
+ maxItems: 100
2266
+ }
2267
+ },
2268
+ required: [
2269
+ "id",
2270
+ "name"
2271
+ ],
2272
+ additionalProperties: false
2273
+ }
2274
+ },
2275
+ refreshedAt: {
2276
+ type: "string"
2277
+ },
2278
+ source: {
2279
+ type: "string",
2280
+ enum: [
2281
+ "remote",
2282
+ "manual"
2283
+ ]
2284
+ }
2285
+ },
2286
+ required: [
2287
+ "models",
2288
+ "refreshedAt",
2289
+ "source"
2290
+ ],
2291
+ additionalProperties: false
2292
+ },
2293
+ planToken: {
2294
+ type: "string"
2295
+ },
2296
+ warnings: {
2297
+ type: "array",
2298
+ items: {
2299
+ type: "string"
2300
+ }
2301
+ }
2302
+ },
2303
+ required: [
2304
+ "provider",
2305
+ "profile",
2306
+ "catalog",
2307
+ "planToken",
2308
+ "warnings"
2309
+ ],
2310
+ additionalProperties: false
2311
+ },
2312
+ RunInput: {
2313
+ type: "object",
2314
+ properties: {
2315
+ profileId: {
2316
+ type: "string",
2317
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2318
+ },
2319
+ harness: {
2320
+ type: "string",
2321
+ enum: [
2322
+ "claude",
2323
+ "codex",
2324
+ "grok",
2325
+ "opencode2"
2326
+ ]
2327
+ },
2328
+ model: {
2329
+ type: "string",
2330
+ minLength: 1,
2331
+ maxLength: 300
2332
+ },
2333
+ planToken: {
2334
+ type: "string",
2335
+ pattern: "^[a-f0-9]{64}$"
2336
+ }
2337
+ },
2338
+ required: [
2339
+ "profileId",
2340
+ "harness",
2341
+ "model",
2342
+ "planToken"
2343
+ ],
2344
+ additionalProperties: false
2345
+ },
2346
+ RunUpdate: {
2347
+ type: "object",
2348
+ properties: {
2349
+ status: {
2350
+ type: "string",
2351
+ enum: [
2352
+ "exited",
2353
+ "failed",
2354
+ "interrupted"
2355
+ ]
2356
+ },
2357
+ exitCode: {
2358
+ type: "integer",
2359
+ minimum: 0,
2360
+ maximum: 255
2361
+ }
2362
+ },
2363
+ required: [
2364
+ "status",
2365
+ "exitCode"
2366
+ ],
2367
+ additionalProperties: false
2368
+ },
2369
+ Run: {
2370
+ type: "object",
2371
+ properties: {
2372
+ profileId: {
2373
+ type: "string",
2374
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2375
+ },
2376
+ harness: {
2377
+ type: "string",
2378
+ enum: [
2379
+ "claude",
2380
+ "codex",
2381
+ "grok",
2382
+ "opencode2"
2383
+ ]
2384
+ },
2385
+ model: {
2386
+ type: "string",
2387
+ minLength: 1,
2388
+ maxLength: 300
2389
+ },
2390
+ planToken: {
2391
+ type: "string",
2392
+ pattern: "^[a-f0-9]{64}$"
2393
+ },
2394
+ version: {
2395
+ type: "integer",
2396
+ exclusiveMinimum: true,
2397
+ minimum: 0
2398
+ },
2399
+ updatedAt: {
2400
+ type: "string"
2401
+ },
2402
+ providerId: {
2403
+ type: "string",
2404
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2405
+ },
2406
+ providerVersion: {
2407
+ type: "integer",
2408
+ exclusiveMinimum: true,
2409
+ minimum: 0
2410
+ },
2411
+ profileVersion: {
2412
+ type: "integer",
2413
+ exclusiveMinimum: true,
2414
+ minimum: 0
2415
+ },
2416
+ id: {
2417
+ type: "string",
2418
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2419
+ },
2420
+ status: {
2421
+ type: "string",
2422
+ enum: [
2423
+ "running",
2424
+ "exited",
2425
+ "failed",
2426
+ "interrupted"
2427
+ ]
2428
+ },
2429
+ startedAt: {
2430
+ type: "string"
2431
+ },
2432
+ endedAt: {
2433
+ type: "string"
2434
+ },
2435
+ exitCode: {
2436
+ type: "integer"
2437
+ }
2438
+ },
2439
+ required: [
2440
+ "profileId",
2441
+ "harness",
2442
+ "model",
2443
+ "planToken",
2444
+ "version",
2445
+ "updatedAt",
2446
+ "providerId",
2447
+ "providerVersion",
2448
+ "profileVersion",
2449
+ "id",
2450
+ "status",
2451
+ "startedAt"
2452
+ ],
2453
+ additionalProperties: false
2454
+ },
2455
+ Health: {
2456
+ type: "object",
2457
+ properties: {
2458
+ status: {
2459
+ type: "string",
2460
+ enum: [
2461
+ "ok",
2462
+ "degraded",
2463
+ "unavailable"
2464
+ ]
2465
+ },
2466
+ version: {
2467
+ type: "string"
2468
+ },
2469
+ backend: {
2470
+ type: "string",
2471
+ enum: [
2472
+ "sqlite",
2473
+ "postgresql"
2474
+ ]
2475
+ }
2476
+ },
2477
+ required: [
2478
+ "status",
2479
+ "version",
2480
+ "backend"
2481
+ ],
2482
+ additionalProperties: false
2483
+ },
2484
+ Ready: {
2485
+ type: "object",
2486
+ properties: {
2487
+ ready: {
2488
+ type: "boolean"
2489
+ },
2490
+ reason: {
2491
+ type: "string"
2492
+ }
2493
+ },
2494
+ required: [
2495
+ "ready"
2496
+ ],
2497
+ additionalProperties: false
2498
+ },
2499
+ Version: {
2500
+ type: "object",
2501
+ properties: {
2502
+ version: {
2503
+ type: "string"
2504
+ }
2505
+ },
2506
+ required: [
2507
+ "version"
2508
+ ],
2509
+ additionalProperties: false
2510
+ },
2511
+ LaunchInput: {
2512
+ type: "object",
2513
+ properties: {
2514
+ profileId: {
2515
+ type: "string",
2516
+ pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$"
2517
+ }
2518
+ },
2519
+ required: [
2520
+ "profileId"
2521
+ ],
2522
+ additionalProperties: false
2523
+ },
2524
+ Empty: {
2525
+ type: "object",
2526
+ properties: {},
2527
+ additionalProperties: false
2528
+ },
2529
+ Error: {
2530
+ type: "object",
2531
+ properties: {
2532
+ error: {
2533
+ type: "object",
2534
+ properties: {
2535
+ code: {
2536
+ type: "string"
2537
+ },
2538
+ message: {
2539
+ type: "string"
2540
+ },
2541
+ requestId: {
2542
+ type: "string"
2543
+ }
2544
+ },
2545
+ required: [
2546
+ "code",
2547
+ "message",
2548
+ "requestId"
2549
+ ],
2550
+ additionalProperties: false
2551
+ }
2552
+ },
2553
+ required: [
2554
+ "error"
2555
+ ],
2556
+ additionalProperties: false
2557
+ }
2558
+ }
2559
+ }
2560
+ };
2561
+
2562
+ // src/service.ts
2563
+ var snapshot = (profile, provider, catalog) => createHash("sha256").update(JSON.stringify([profile, provider, { models: catalog.models, source: catalog.source }])).digest("hex");
2564
+ var hash = (s) => createHash("sha256").update(s).digest();
2565
+ function createHandler(store, apiKey, providerEnv = process.env) {
2566
+ if (!apiKey || apiKey.length < 24)
2567
+ throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
2568
+ const expected = hash(`Bearer ${apiKey}`);
2569
+ return async (request) => {
2570
+ const requestId = crypto.randomUUID();
2571
+ const json = (body, status = 200) => Response.json(body, { status, headers: { "x-request-id": requestId, "cache-control": "no-store", "x-content-type-options": "nosniff" } });
2572
+ try {
2573
+ const url = new URL(request.url);
2574
+ const route = url.pathname.replace(/\/$/, "");
2575
+ if (request.method === "GET" && route === "/health")
2576
+ return json({ status: "ok", version: VERSION, backend: store.engine });
2577
+ if (request.method === "GET" && route === "/version")
2578
+ return json({ version: VERSION });
2579
+ if (request.method === "GET" && route === "/ready") {
2580
+ try {
2581
+ await store.ready();
2582
+ return json({ ready: true });
2583
+ } catch {
2584
+ return json({ ready: false, reason: "Storage is unavailable." }, 503);
2585
+ }
2586
+ }
2587
+ if (!timingSafeEqual(expected, hash(request.headers.get("authorization") ?? "")))
2588
+ throw new Fault(401, "unauthorized", "A valid API bearer token is required.");
2589
+ if (request.method === "GET" && ["/v1/openapi.json", "/openapi.json"].includes(route))
2590
+ return json(openapi_default);
2591
+ const parts = route.split("/").filter(Boolean);
2592
+ if (parts[0] !== "v1")
2593
+ throw new Fault(404, "not_found", "Route was not found.");
2594
+ const resource = parts[1];
2595
+ const id = parts[2];
2596
+ if (id)
2597
+ parse(idSchema, id);
2598
+ const page = () => parse(z2.object({
2599
+ limit: z2.coerce.number().int().min(1).max(1000).default(100),
2600
+ offset: z2.coerce.number().int().min(0).max(1e6).default(0),
2601
+ search: z2.string().max(200).default("")
2602
+ }).strict(), Object.fromEntries(url.searchParams));
2603
+ if (request.method === "GET") {
2604
+ if (["providers", "profiles", "runs"].includes(resource) && parts.length <= 3) {
2605
+ const kind = resource;
2606
+ return json(id ? await store.get(kind, id) : await store.list(kind, page()));
2607
+ }
2608
+ if (resource === "providers" && id && parts[3] === "models" && parts.length === 4) {
2609
+ const catalog = await store.get("catalogs", id);
2610
+ const p = page();
2611
+ const filtered = catalog.models.filter((m) => [m.id, m.name].some((s) => s.toLowerCase().includes(p.search.toLowerCase())));
2612
+ return json({ ...catalog, models: undefined, data: filtered.slice(p.offset, p.offset + p.limit).map((m) => ({ ...m, codingEligible: codingEligible(m) })), total: filtered.length, ...p });
2613
+ }
2614
+ throw new Fault(404, "not_found", "Route was not found.");
2615
+ }
2616
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method))
2617
+ throw new Fault(405, "method_not_allowed", "Method is not supported.");
2618
+ const key = request.headers.get("idempotency-key");
2619
+ if (!key || !/^[a-zA-Z0-9._:-]{8,128}$/.test(key))
2620
+ throw new Fault(400, "idempotency_required", "Supply an Idempotency-Key of 8\u2013128 ASCII letters, digits, dots, colons, underscores or dashes.");
2621
+ let body = {};
2622
+ if (request.method !== "DELETE") {
2623
+ if (!request.headers.get("content-type")?.startsWith("application/json"))
2624
+ throw new Fault(415, "content_type", "Send application/json.");
2625
+ try {
2626
+ body = await boundedJson(request, 1024 * 1024);
2627
+ } catch {
2628
+ throw new Fault(400, "invalid_json", "Request must contain valid JSON under 1 MiB.");
2629
+ }
2630
+ }
2631
+ const fingerprint = hash(JSON.stringify([request.method, route, body, request.headers.get("if-match")])).toString("hex");
2632
+ const version = () => {
2633
+ const v = request.headers.get("if-match");
2634
+ if (!v || !/^[1-9]\d*$/.test(v))
2635
+ throw new Fault(428, "version_required", "Supply the current numeric version in If-Match.");
2636
+ return Number(v);
2637
+ };
2638
+ const replay = await store.replay(key, fingerprint);
2639
+ if (replay.found)
2640
+ return json(replay.value, request.method === "POST" && ["providers", "profiles", "runs"].includes(resource) && !id ? 201 : 200);
2641
+ let refreshed;
2642
+ if (resource === "providers" && id && parts[3] === "refresh" && parts.length === 4 && request.method === "POST") {
2643
+ parse(z2.object({}).strict(), body);
2644
+ const provider = await store.get("providers", id);
2645
+ refreshed = { provider, catalog: await discover(provider, providerEnv) };
2646
+ }
2647
+ const result = await store.mutate(key, fingerprint, async (db) => {
2648
+ if ((resource === "providers" || resource === "profiles") && parts.length <= 3) {
2649
+ if (request.method === "DELETE" && id)
2650
+ return store.remove(resource, id, version(), db);
2651
+ if (request.method === "POST" && !id || request.method === "PUT" && id) {
2652
+ const value = resource === "providers" ? parse(providerInputSchema, body) : parse(profileInputSchema, body);
2653
+ if (id && value.id !== id)
2654
+ throw new Fault(400, "id_mismatch", "Path and body IDs must match.");
2655
+ if (resource === "profiles") {
2656
+ const profile = value;
2657
+ 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.");
2660
+ }
2661
+ const saved = await store.put(resource, value, id ? version() : undefined, db);
2662
+ if (resource === "providers" && id)
2663
+ await db.unsafe("DELETE FROM switcher_catalogs WHERE id = $1", [id]);
2664
+ return saved;
2665
+ }
2666
+ }
2667
+ if (resource === "providers" && id && parts[3] === "refresh" && parts.length === 4 && request.method === "POST") {
2668
+ if (store.engine === "postgresql")
2669
+ await db.unsafe("SELECT id FROM switcher_providers WHERE id = $1 FOR SHARE", [id]);
2670
+ const provider = await store.get("providers", id, db);
2671
+ if (!refreshed || provider.version !== refreshed.provider.version)
2672
+ throw new Fault(409, "provider_changed", "Provider changed during discovery; refresh again.");
2673
+ const catalog = refreshed.catalog;
2674
+ let old;
2675
+ try {
2676
+ old = await store.get("catalogs", id, db);
2677
+ } catch (e) {
2678
+ if (!(e instanceof Fault && e.status === 404))
2679
+ throw e;
2680
+ }
2681
+ return store.put("catalogs", { id, ...catalog }, old?.version, db);
2682
+ }
2683
+ if (resource === "launch-plans" && !id && request.method === "POST") {
2684
+ const { profileId } = parse(z2.object({ profileId: idSchema }).strict(), body);
2685
+ const profile = await store.get("profiles", profileId, db);
2686
+ 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.");
2689
+ let catalog;
2690
+ try {
2691
+ catalog = await store.get("catalogs", provider.id, db);
2692
+ } catch (e) {
2693
+ if (e instanceof Fault && e.status === 404)
2694
+ throw new Fault(422, "catalog_missing", "Refresh the provider catalog before launching.");
2695
+ throw e;
2696
+ }
2697
+ const selected = catalog.models.find((m) => m.id === profile.model);
2698
+ if (!selected)
2699
+ 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.");
2702
+ const warnings = [];
2703
+ if (!selected.supportedParameters)
2704
+ warnings.push("Provider does not declare tool capabilities; execution compatibility is unverified.");
2705
+ if (profile.harness === "claude" && !/claude/i.test(profile.model))
2706
+ warnings.push("Anthropic does not support non-Claude models in Claude Code; this combination is experimental.");
2707
+ if (Date.now() - Date.parse(catalog.refreshedAt) > 300000)
2708
+ warnings.push("Catalog snapshot is older than five minutes; refresh before launching.");
2709
+ return { profile, provider, catalog, warnings, planToken: snapshot(profile, provider, catalog) };
2710
+ }
2711
+ if (resource === "runs" && !id && request.method === "POST") {
2712
+ const input = parse(runInputSchema, body);
2713
+ if (store.engine === "postgresql")
2714
+ await db.unsafe("SELECT id FROM switcher_profiles WHERE id = $1 FOR SHARE", [input.profileId]);
2715
+ const profile = await store.get("profiles", input.profileId, db);
2716
+ if (store.engine === "postgresql") {
2717
+ await db.unsafe("SELECT id FROM switcher_providers WHERE id = $1 FOR SHARE", [profile.providerId]);
2718
+ await db.unsafe("SELECT id FROM switcher_catalogs WHERE id = $1 FOR SHARE", [profile.providerId]);
2719
+ }
2720
+ const provider = await store.get("providers", profile.providerId, db);
2721
+ let catalog;
2722
+ try {
2723
+ catalog = await store.get("catalogs", profile.providerId, db);
2724
+ } catch (error) {
2725
+ if (error instanceof Fault && error.status === 404)
2726
+ throw new Fault(409, "plan_changed", "Catalog changed; request a fresh launch plan.");
2727
+ throw error;
2728
+ }
2729
+ if (profile.harness !== input.harness || profile.model !== input.model || snapshot(profile, provider, catalog) !== input.planToken)
2730
+ throw new Fault(409, "plan_changed", "Provider, profile or catalog changed; request a fresh launch plan.");
2731
+ return store.put("runs", { ...input, providerId: provider.id, providerVersion: provider.version, profileVersion: profile.version, id: crypto.randomUUID(), status: "running", startedAt: new Date().toISOString() }, undefined, db);
2732
+ }
2733
+ if (resource === "runs" && id && request.method === "PATCH" && parts.length === 3) {
2734
+ const input = parse(runUpdateSchema, body);
2735
+ const run = await store.get("runs", id, db);
2736
+ if (run.status !== "running")
2737
+ throw new Fault(409, "run_finished", "Run has already finished.");
2738
+ return store.put("runs", { ...run, ...input, endedAt: new Date().toISOString() }, version(), db);
2739
+ }
2740
+ throw new Fault(404, "not_found", "Route was not found.");
2741
+ });
2742
+ return json(result, request.method === "POST" && ["providers", "profiles", "runs"].includes(resource) && !id ? 201 : 200);
2743
+ } catch (error) {
2744
+ const safe = error instanceof Fault ? error : new Fault(500, "internal_error", "Request failed.");
2745
+ return json({ error: { code: safe.code, message: safe.message, requestId } }, safe.status);
2746
+ }
2747
+ };
2748
+ }
2749
+
2750
+ // src/serve.ts
2751
+ async function main(args = process.argv.slice(2)) {
2752
+ const { values } = parseArgs({ args, options: {
2753
+ host: { type: "string" },
2754
+ port: { type: "string" },
2755
+ "data-dir": { type: "string" },
2756
+ sqlite: { type: "string" },
2757
+ json: { type: "boolean" },
2758
+ version: { type: "boolean" },
2759
+ help: { type: "boolean" }
2760
+ } });
2761
+ if (values.version) {
2762
+ console.log(VERSION);
2763
+ return;
2764
+ }
2765
+ if (values.help) {
2766
+ console.log(`switcher-serve --sqlite PATH | --data-dir DIR | inject HASNA_SWITCHER_DATABASE_URL
2767
+ --host HOST (127.0.0.1) --port PORT (8080; 0 allocates a port) --json --version
2768
+ Requires HASNA_SWITCHER_API_KEY (24+ characters). Provider credentials: SWITCHER_PROVIDER_* environment references.`);
2769
+ return;
2770
+ }
2771
+ const port = Number(values.port ?? process.env.PORT ?? 8080);
2772
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
2773
+ throw new Fault(400, "invalid_port", "Port must be an integer between 0 and 65535.");
2774
+ const apiKey = process.env.HASNA_SWITCHER_API_KEY ?? "";
2775
+ if (apiKey.length < 24)
2776
+ throw new Fault(500, "auth_config", "Set HASNA_SWITCHER_API_KEY to a random token of at least 24 characters.");
2777
+ if (values.sqlite && values["data-dir"])
2778
+ 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();
2789
+ };
2790
+ process.once("SIGTERM", stop);
2791
+ process.once("SIGINT", stop);
2792
+ }
2793
+ if (import.meta.main)
2794
+ main().catch((error) => {
2795
+ console.error(JSON.stringify({ error: error instanceof Fault ? error.message : "Server startup failed; check configuration." }));
2796
+ process.exitCode = 1;
2797
+ });
2798
+ export {
2799
+ main
2800
+ };