@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.
- package/Dockerfile +11 -0
- package/LICENSE +201 -0
- package/README.md +112 -0
- package/dist/catalog.d.ts +2 -0
- package/dist/cli/index.js +759 -0
- package/dist/cli.d.ts +2 -0
- package/dist/domain.d.ts +231 -0
- package/dist/generated/api.d.ts +1174 -0
- package/dist/harness-types.d.ts +24 -0
- package/dist/harnesses.d.ts +13 -0
- package/dist/http.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +227 -0
- package/dist/launcher.d.ts +9 -0
- package/dist/mcp/index.js +268 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/sdk.d.ts +311 -0
- package/dist/sdk.js +227 -0
- package/dist/serve/index.js +2800 -0
- package/dist/serve.d.ts +2 -0
- package/dist/service.d.ts +2 -0
- package/dist/store.d.ts +48 -0
- package/docker-compose.yml +41 -0
- package/hasna.contract.json +94 -0
- package/openapi.json +2202 -0
- package/package.json +70 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { parseArgs } from "util";
|
|
6
|
+
|
|
7
|
+
// src/domain.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
var VERSION = "0.1.0";
|
|
10
|
+
var harnessSchema = z.enum(["claude", "codex", "grok", "opencode2"]);
|
|
11
|
+
var protocolSchema = z.enum(["anthropic-messages", "openai-responses", "openai-chat"]);
|
|
12
|
+
var idSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
|
|
13
|
+
var label = z.string().min(1).max(200);
|
|
14
|
+
var envRef = z.string().regex(/^SWITCHER_PROVIDER_[A-Z0-9_]+$/);
|
|
15
|
+
function endpoint(value) {
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(value);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Fault(400, "invalid_url", "Use an absolute HTTPS URL (HTTP is allowed on loopback).");
|
|
21
|
+
}
|
|
22
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
23
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local) || url.username || url.password || url.search || url.hash)
|
|
24
|
+
throw new Fault(400, "invalid_url", "URL must use HTTPS, contain no credentials/query/fragment, or use HTTP on loopback.");
|
|
25
|
+
return url.href.replace(/\/+$/, "");
|
|
26
|
+
}
|
|
27
|
+
var urlSchema = z.string().max(2000).superRefine((v, ctx) => {
|
|
28
|
+
try {
|
|
29
|
+
endpoint(v);
|
|
30
|
+
} catch {
|
|
31
|
+
ctx.addIssue({ code: "custom", message: "Invalid endpoint URL" });
|
|
32
|
+
}
|
|
33
|
+
}).transform(endpoint);
|
|
34
|
+
var modelSchema = z.object({
|
|
35
|
+
id: z.string().min(1).max(300),
|
|
36
|
+
name: label,
|
|
37
|
+
description: z.string().max(8000).optional(),
|
|
38
|
+
contextWindow: z.number().int().positive().optional(),
|
|
39
|
+
maxOutputTokens: z.number().int().positive().optional(),
|
|
40
|
+
inputModalities: z.array(z.string().max(50)).max(20).optional(),
|
|
41
|
+
outputModalities: z.array(z.string().max(50)).max(20).optional(),
|
|
42
|
+
supportedParameters: z.array(z.string().max(100)).max(100).optional()
|
|
43
|
+
}).strict();
|
|
44
|
+
var providerInputSchema = z.object({
|
|
45
|
+
id: idSchema,
|
|
46
|
+
name: label,
|
|
47
|
+
baseUrl: urlSchema,
|
|
48
|
+
protocol: protocolSchema,
|
|
49
|
+
credentialEnv: envRef.optional(),
|
|
50
|
+
authStyle: z.enum(["bearer", "x-api-key"]).default("bearer"),
|
|
51
|
+
modelsPath: z.string().regex(/^[a-zA-Z0-9_/-]+$/).max(200).default("models"),
|
|
52
|
+
manualModels: z.array(modelSchema).max(1e4).default([])
|
|
53
|
+
}).strict().refine((p) => !p.modelsPath.split("/").includes("..") && !p.modelsPath.startsWith("/"), "modelsPath must be relative");
|
|
54
|
+
var profileInputSchema = z.object({
|
|
55
|
+
id: idSchema,
|
|
56
|
+
name: label,
|
|
57
|
+
providerId: idSchema,
|
|
58
|
+
harness: harnessSchema,
|
|
59
|
+
model: z.string().min(1).max(300)
|
|
60
|
+
}).strict();
|
|
61
|
+
var runInputSchema = z.object({
|
|
62
|
+
profileId: idSchema,
|
|
63
|
+
harness: harnessSchema,
|
|
64
|
+
model: z.string().min(1).max(300),
|
|
65
|
+
planToken: z.string().regex(/^[a-f0-9]{64}$/)
|
|
66
|
+
}).strict();
|
|
67
|
+
var runUpdateSchema = z.object({
|
|
68
|
+
status: z.enum(["exited", "failed", "interrupted"]),
|
|
69
|
+
exitCode: z.number().int().min(0).max(255)
|
|
70
|
+
}).strict();
|
|
71
|
+
|
|
72
|
+
class Fault extends Error {
|
|
73
|
+
status;
|
|
74
|
+
code;
|
|
75
|
+
constructor(status, code, message) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.status = status;
|
|
78
|
+
this.code = code;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function parse(schema, value) {
|
|
82
|
+
const result = schema.safeParse(value);
|
|
83
|
+
if (!result.success)
|
|
84
|
+
throw new Fault(400, "invalid_request", result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
|
|
85
|
+
return result.data;
|
|
86
|
+
}
|
|
87
|
+
function compatible(harness, protocol) {
|
|
88
|
+
return harness === "claude" ? protocol === "anthropic-messages" : harness === "codex" ? protocol === "openai-responses" : true;
|
|
89
|
+
}
|
|
90
|
+
function codingEligible(model) {
|
|
91
|
+
return (!model.outputModalities || model.outputModalities.includes("text")) && (!model.supportedParameters || model.supportedParameters.includes("tools"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/http.ts
|
|
95
|
+
var MAX_BYTES = 16 * 1024 * 1024;
|
|
96
|
+
async function boundedJson(response, maxBytes = MAX_BYTES) {
|
|
97
|
+
if (!response.body)
|
|
98
|
+
throw new Fault(502, "invalid_upstream", "Upstream returned no body.");
|
|
99
|
+
const reader = response.body.getReader();
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let size = 0;
|
|
102
|
+
try {
|
|
103
|
+
while (true) {
|
|
104
|
+
const item = await reader.read();
|
|
105
|
+
if (item.done)
|
|
106
|
+
break;
|
|
107
|
+
size += item.value.byteLength;
|
|
108
|
+
if (size > maxBytes)
|
|
109
|
+
throw new Fault(502, "response_too_large", "Response exceeds the size limit.");
|
|
110
|
+
chunks.push(item.value);
|
|
111
|
+
}
|
|
112
|
+
const bytes = new Uint8Array(size);
|
|
113
|
+
let offset = 0;
|
|
114
|
+
for (const chunk of chunks) {
|
|
115
|
+
bytes.set(chunk, offset);
|
|
116
|
+
offset += chunk.length;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Fault(502, "invalid_upstream", "Upstream returned invalid JSON.");
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
await reader.cancel().catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/sdk.ts
|
|
129
|
+
import { resolveCredential } from "@hasna/contracts/client";
|
|
130
|
+
|
|
131
|
+
class SwitcherError extends Error {
|
|
132
|
+
status;
|
|
133
|
+
code;
|
|
134
|
+
requestId;
|
|
135
|
+
constructor(status, code, message, requestId) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.status = status;
|
|
138
|
+
this.code = code;
|
|
139
|
+
this.requestId = requestId;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
class SwitcherClient {
|
|
144
|
+
options;
|
|
145
|
+
baseUrl;
|
|
146
|
+
constructor(options) {
|
|
147
|
+
this.baseUrl = endpoint(options.baseUrl).replace(/\/v1$/, "");
|
|
148
|
+
if (typeof options.apiKey === "string" && (!options.apiKey || /[\r\n]/.test(options.apiKey)))
|
|
149
|
+
throw new Error("Switcher API key is required.");
|
|
150
|
+
this.options = { ...options };
|
|
151
|
+
}
|
|
152
|
+
async request(method, path, body, options = {}) {
|
|
153
|
+
if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) || path.includes(".."))
|
|
154
|
+
throw new Error("Invalid API path.");
|
|
155
|
+
const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
|
|
156
|
+
if (!apiKey || /[\r\n]/.test(apiKey))
|
|
157
|
+
throw new Error("Switcher API key is required.");
|
|
158
|
+
const headers = { authorization: `Bearer ${apiKey}`, accept: "application/json" };
|
|
159
|
+
if (body !== undefined)
|
|
160
|
+
headers["content-type"] = "application/json";
|
|
161
|
+
if (method !== "GET")
|
|
162
|
+
headers["idempotency-key"] = options.idempotencyKey ?? crypto.randomUUID();
|
|
163
|
+
if (options.version !== undefined)
|
|
164
|
+
headers["if-match"] = String(options.version);
|
|
165
|
+
let response;
|
|
166
|
+
try {
|
|
167
|
+
response = await (this.options.fetch ?? fetch)(`${this.baseUrl}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: "manual", signal: AbortSignal.timeout(this.options.timeoutMs ?? 120000) });
|
|
168
|
+
} catch {
|
|
169
|
+
throw new SwitcherError(0, "connection_failed", "Switcher API request failed; check endpoint and service availability.");
|
|
170
|
+
}
|
|
171
|
+
let data;
|
|
172
|
+
try {
|
|
173
|
+
data = await boundedJson(response);
|
|
174
|
+
} catch {
|
|
175
|
+
throw new SwitcherError(response.status, "invalid_response", "Switcher API returned invalid JSON.");
|
|
176
|
+
}
|
|
177
|
+
if (!response.ok)
|
|
178
|
+
throw new SwitcherError(response.status, data?.error?.code ?? "api_error", data?.error?.message ?? `Switcher API returned HTTP ${response.status}.`, data?.error?.requestId);
|
|
179
|
+
return data;
|
|
180
|
+
}
|
|
181
|
+
query(options = {}) {
|
|
182
|
+
return new URLSearchParams(Object.entries(options).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])).toString();
|
|
183
|
+
}
|
|
184
|
+
listProviders(options = {}) {
|
|
185
|
+
return this.request("GET", `/v1/providers?${this.query(options)}`);
|
|
186
|
+
}
|
|
187
|
+
getProvider(id) {
|
|
188
|
+
return this.request("GET", `/v1/providers/${encodeURIComponent(id)}`);
|
|
189
|
+
}
|
|
190
|
+
createProvider(input, idempotencyKey) {
|
|
191
|
+
return this.request("POST", "/v1/providers", input, { idempotencyKey });
|
|
192
|
+
}
|
|
193
|
+
updateProvider(input, version, idempotencyKey) {
|
|
194
|
+
return this.request("PUT", `/v1/providers/${encodeURIComponent(input.id)}`, input, { version, idempotencyKey });
|
|
195
|
+
}
|
|
196
|
+
deleteProvider(id, version, idempotencyKey) {
|
|
197
|
+
return this.request("DELETE", `/v1/providers/${encodeURIComponent(id)}`, undefined, { version, idempotencyKey });
|
|
198
|
+
}
|
|
199
|
+
refreshModels(id, idempotencyKey) {
|
|
200
|
+
return this.request("POST", `/v1/providers/${encodeURIComponent(id)}/refresh`, {}, { idempotencyKey });
|
|
201
|
+
}
|
|
202
|
+
listModels(id, options = {}) {
|
|
203
|
+
return this.request("GET", `/v1/providers/${encodeURIComponent(id)}/models?${this.query(options)}`);
|
|
204
|
+
}
|
|
205
|
+
listProfiles(options = {}) {
|
|
206
|
+
return this.request("GET", `/v1/profiles?${this.query(options)}`);
|
|
207
|
+
}
|
|
208
|
+
getProfile(id) {
|
|
209
|
+
return this.request("GET", `/v1/profiles/${encodeURIComponent(id)}`);
|
|
210
|
+
}
|
|
211
|
+
createProfile(input, idempotencyKey) {
|
|
212
|
+
return this.request("POST", "/v1/profiles", input, { idempotencyKey });
|
|
213
|
+
}
|
|
214
|
+
updateProfile(input, version, idempotencyKey) {
|
|
215
|
+
return this.request("PUT", `/v1/profiles/${encodeURIComponent(input.id)}`, input, { version, idempotencyKey });
|
|
216
|
+
}
|
|
217
|
+
deleteProfile(id, version, idempotencyKey) {
|
|
218
|
+
return this.request("DELETE", `/v1/profiles/${encodeURIComponent(id)}`, undefined, { version, idempotencyKey });
|
|
219
|
+
}
|
|
220
|
+
launchPlan(profileId, idempotencyKey) {
|
|
221
|
+
return this.request("POST", "/v1/launch-plans", { profileId }, { idempotencyKey });
|
|
222
|
+
}
|
|
223
|
+
listRuns(options = {}) {
|
|
224
|
+
return this.request("GET", `/v1/runs?${this.query(options)}`);
|
|
225
|
+
}
|
|
226
|
+
getRun(id) {
|
|
227
|
+
return this.request("GET", `/v1/runs/${encodeURIComponent(id)}`);
|
|
228
|
+
}
|
|
229
|
+
createRun(input, idempotencyKey) {
|
|
230
|
+
return this.request("POST", "/v1/runs", input, { idempotencyKey });
|
|
231
|
+
}
|
|
232
|
+
finishRun(id, version, input, idempotencyKey) {
|
|
233
|
+
return this.request("PATCH", `/v1/runs/${encodeURIComponent(id)}`, input, { version, idempotencyKey });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function clientFromEnv(env = process.env) {
|
|
237
|
+
const credential = () => resolveCredential("switcher", Object.fromEntries(Object.entries(env).filter(([name]) => name === "HASNA_SWITCHER_API_KEY")), { keychain: { enabled: false } })?.apiKey ?? "";
|
|
238
|
+
if (!env.HASNA_SWITCHER_API_URL || !credential())
|
|
239
|
+
throw new Error("Set HASNA_SWITCHER_API_URL and HASNA_SWITCHER_API_KEY; no local database fallback is available.");
|
|
240
|
+
return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/harnesses.ts
|
|
244
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
245
|
+
import { join, isAbsolute } from "path";
|
|
246
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
247
|
+
import { execFile } from "child_process";
|
|
248
|
+
import { promisify } from "util";
|
|
249
|
+
var execute = promisify(execFile);
|
|
250
|
+
var KEY = "SWITCHER_HARNESS_API_KEY";
|
|
251
|
+
var quote = (value) => JSON.stringify(value);
|
|
252
|
+
async function detectHarness(harness, override) {
|
|
253
|
+
const executable = override ?? Bun.which(harness) ?? harness;
|
|
254
|
+
try {
|
|
255
|
+
const { stdout } = await execute(executable, ["--version"], { timeout: 8000, maxBuffer: 65536 });
|
|
256
|
+
return { harness, executable, available: true, version: stdout.trim().slice(0, 200) };
|
|
257
|
+
} catch {
|
|
258
|
+
return { harness, executable, available: false, version: undefined };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
var versionAtLeast = (raw, minimum) => {
|
|
262
|
+
const match = raw?.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
263
|
+
if (!match)
|
|
264
|
+
return false;
|
|
265
|
+
const actual = match.slice(1).map(Number);
|
|
266
|
+
for (let i = 0;i < 3; i++) {
|
|
267
|
+
if (actual[i] > minimum[i])
|
|
268
|
+
return true;
|
|
269
|
+
if (actual[i] < minimum[i])
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
};
|
|
274
|
+
async function jsonFile(dir, name, value) {
|
|
275
|
+
const path = join(dir, name);
|
|
276
|
+
await writeFile(path, JSON.stringify(value, null, 2) + `
|
|
277
|
+
`, { mode: 384, flag: "wx" });
|
|
278
|
+
return path;
|
|
279
|
+
}
|
|
280
|
+
function codexModel(model, priority) {
|
|
281
|
+
return {
|
|
282
|
+
slug: model.id,
|
|
283
|
+
display_name: model.name,
|
|
284
|
+
description: model.description ?? model.id,
|
|
285
|
+
shell_type: "shell_command",
|
|
286
|
+
visibility: "list",
|
|
287
|
+
supported_in_api: true,
|
|
288
|
+
priority,
|
|
289
|
+
supported_reasoning_levels: [],
|
|
290
|
+
default_reasoning_level: null,
|
|
291
|
+
support_verbosity: false,
|
|
292
|
+
supports_reasoning_summary_parameter: false,
|
|
293
|
+
default_verbosity: null,
|
|
294
|
+
supports_parallel_tool_calls: false,
|
|
295
|
+
apply_patch_tool_type: null,
|
|
296
|
+
truncation_policy: { mode: "tokens", limit: 1e4 },
|
|
297
|
+
experimental_supported_tools: [],
|
|
298
|
+
context_window: model.contextWindow ?? null,
|
|
299
|
+
max_context_window: model.contextWindow ?? null,
|
|
300
|
+
input_modalities: (model.inputModalities ?? ["text"]).filter((m) => m === "text" || m === "image"),
|
|
301
|
+
prefer_websockets: false,
|
|
302
|
+
use_responses_lite: false,
|
|
303
|
+
model_messages: { instructions_template: "You are Codex, a coding assistant. Help the user with their requested work. Follow the user and developer instructions, use the available tools when appropriate, preserve unrelated changes, and report actual results and remaining limitations.", instructions_variables: null }
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function grokBridge(input) {
|
|
307
|
+
const token = crypto.randomUUID() + crypto.randomUUID();
|
|
308
|
+
const expected = createHash("sha256").update(`Bearer ${token}`).digest();
|
|
309
|
+
const models = new Map(input.models.flatMap((m) => [[m.id, m.id], [grokAlias(input, m.id), m.id]]));
|
|
310
|
+
let server;
|
|
311
|
+
const apiBackend = { "anthropic-messages": "messages", "openai-responses": "responses", "openai-chat": "chat_completions" }[input.protocol];
|
|
312
|
+
const apiPath = { "anthropic-messages": "/messages", "openai-responses": "/responses", "openai-chat": "/chat/completions" }[input.protocol];
|
|
313
|
+
server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 4 * 1024 * 1024, idleTimeout: 255, async fetch(request) {
|
|
314
|
+
const auth = request.headers.get("authorization") ?? (request.headers.has("x-api-key") ? `Bearer ${request.headers.get("x-api-key")}` : "");
|
|
315
|
+
if (!timingSafeEqual(expected, createHash("sha256").update(auth).digest()))
|
|
316
|
+
return Response.json({ error: { message: "Unauthorized" } }, { status: 401 });
|
|
317
|
+
const path = new URL(request.url).pathname;
|
|
318
|
+
if (request.method === "GET" && path === "/v1/api-key")
|
|
319
|
+
return Response.json({ api_key_blocked: false, api_key_disabled: false, team_blocked: false });
|
|
320
|
+
if (request.method === "GET" && path === "/v1/models")
|
|
321
|
+
return Response.json({ data: input.models.map((m) => ({
|
|
322
|
+
id: grokAlias(input, m.id),
|
|
323
|
+
model: m.id,
|
|
324
|
+
name: m.name,
|
|
325
|
+
description: m.description,
|
|
326
|
+
base_url: new URL("v1", server.url).href,
|
|
327
|
+
context_window: m.contextWindow,
|
|
328
|
+
max_completion_tokens: m.maxOutputTokens,
|
|
329
|
+
api_backend: apiBackend,
|
|
330
|
+
env_key: KEY
|
|
331
|
+
})) });
|
|
332
|
+
if (request.method !== "POST" || path !== `/v1${apiPath}`)
|
|
333
|
+
return Response.json({ error: { message: "Unsupported route" } }, { status: 404 });
|
|
334
|
+
let body;
|
|
335
|
+
try {
|
|
336
|
+
body = await request.json();
|
|
337
|
+
} catch {
|
|
338
|
+
return Response.json({ error: { message: "Invalid JSON" } }, { status: 400 });
|
|
339
|
+
}
|
|
340
|
+
if (!models.has(body.model))
|
|
341
|
+
return Response.json({ error: { message: "Model is outside this launch catalog" } }, { status: 403 });
|
|
342
|
+
body.model = models.get(body.model);
|
|
343
|
+
const headers = { "content-type": "application/json" };
|
|
344
|
+
if (input.credential)
|
|
345
|
+
headers[input.authStyle === "x-api-key" ? "x-api-key" : "authorization"] = input.authStyle === "x-api-key" ? input.credential : `Bearer ${input.credential}`;
|
|
346
|
+
if (input.protocol === "anthropic-messages") {
|
|
347
|
+
headers["anthropic-version"] = request.headers.get("anthropic-version") ?? "2023-06-01";
|
|
348
|
+
if (request.headers.has("anthropic-beta"))
|
|
349
|
+
headers["anthropic-beta"] = request.headers.get("anthropic-beta");
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const response = await fetch(`${input.baseUrl}${apiPath}`, { method: "POST", headers, body: JSON.stringify(body), redirect: "manual", signal: AbortSignal.any([request.signal, AbortSignal.timeout(240000)]) });
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
await response.body?.cancel();
|
|
355
|
+
return Response.json({ error: { message: `Provider returned HTTP ${response.status}` } }, { status: response.status >= 300 && response.status < 400 ? 502 : response.status });
|
|
356
|
+
}
|
|
357
|
+
return new Response(response.body, { status: response.status, headers: { "content-type": response.headers.get("content-type") ?? "application/json", "cache-control": "no-store" } });
|
|
358
|
+
} catch {
|
|
359
|
+
return Response.json({ error: { message: "Provider request failed" } }, { status: 502 });
|
|
360
|
+
}
|
|
361
|
+
} });
|
|
362
|
+
return { baseUrl: new URL("v1", server.url).href, token, cleanup: async () => {
|
|
363
|
+
await server.stop(true);
|
|
364
|
+
} };
|
|
365
|
+
}
|
|
366
|
+
function grokAlias(input, model) {
|
|
367
|
+
return "switcher-" + createHash("sha256").update(JSON.stringify([input.baseUrl, input.protocol])).digest("hex").slice(0, 12) + "/" + model;
|
|
368
|
+
}
|
|
369
|
+
async function prepareNativeLaunch(input) {
|
|
370
|
+
input = { ...input, baseUrl: endpoint(input.baseUrl) };
|
|
371
|
+
if (!compatible(input.harness, input.protocol))
|
|
372
|
+
throw new Error("Harness and provider protocol are incompatible.");
|
|
373
|
+
if (!isAbsolute(input.stateDir) || !isAbsolute(input.cwd))
|
|
374
|
+
throw new Error("Launch state and working directories must be absolute.");
|
|
375
|
+
if (!input.models.length || !input.models.some((m) => m.id === input.model))
|
|
376
|
+
throw new Error("Selected model is missing from the launch catalog.");
|
|
377
|
+
if (input.models.some((m) => !codingEligible(m)))
|
|
378
|
+
throw new Error("Launch catalog contains a model explicitly ineligible for coding.");
|
|
379
|
+
if (input.credential && /[\r\n]/.test(input.credential))
|
|
380
|
+
throw new Error("Provider credential contains invalid header characters.");
|
|
381
|
+
await mkdir(input.stateDir, { recursive: true, mode: 448 });
|
|
382
|
+
const args = [...input.args ?? []];
|
|
383
|
+
const env = {};
|
|
384
|
+
const warnings = [];
|
|
385
|
+
const configPaths = [];
|
|
386
|
+
const executable = input.executable ?? input.harness;
|
|
387
|
+
const missing = input.models.filter((m) => !m.contextWindow).length;
|
|
388
|
+
if (missing)
|
|
389
|
+
warnings.push(`${missing} catalog models have no declared context limit; native fallback limits may be inaccurate.`);
|
|
390
|
+
if (input.harness === "claude") {
|
|
391
|
+
if (!versionAtLeast(input.version, [2, 1, 242]))
|
|
392
|
+
throw new Error("Claude Code >=2.1.242 is required for a full native modelPicker.");
|
|
393
|
+
env.ANTHROPIC_BASE_URL = input.baseUrl.replace(/\/v1$/, "");
|
|
394
|
+
env.ANTHROPIC_MODEL = input.model;
|
|
395
|
+
env[input.authStyle === "x-api-key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN"] = input.credential ?? "switcher-local-no-auth";
|
|
396
|
+
const file2 = await jsonFile(input.stateDir, "claude-settings.json", { modelPicker: { replaceBuiltInOptions: true, options: input.models.map((m) => ({ model: m.id, label: m.name, description: m.description?.slice(0, 300) })) } });
|
|
397
|
+
configPaths.push(file2);
|
|
398
|
+
warnings.push("Claude managed settings and model allowlists can restrict the generated picker.");
|
|
399
|
+
return { executable, args: ["--settings", file2, "--model", input.model, ...args], env, configPaths, warnings };
|
|
400
|
+
}
|
|
401
|
+
if (input.harness === "codex") {
|
|
402
|
+
if (!versionAtLeast(input.version, [0, 153, 0]))
|
|
403
|
+
throw new Error("Codex >=0.153.0 is required by this catalog adapter.");
|
|
404
|
+
const file2 = await jsonFile(input.stateDir, "codex-models.json", { models: input.models.map(codexModel) });
|
|
405
|
+
configPaths.push(file2);
|
|
406
|
+
env[KEY] = input.credential ?? "switcher-local-no-auth";
|
|
407
|
+
const provider = { name: "Switcher", base_url: input.baseUrl, wire_api: "responses", requires_openai_auth: false };
|
|
408
|
+
{
|
|
409
|
+
if (input.authStyle === "x-api-key")
|
|
410
|
+
provider.env_http_headers = { "x-api-key": KEY };
|
|
411
|
+
else
|
|
412
|
+
provider.env_key = KEY;
|
|
413
|
+
}
|
|
414
|
+
const toml = Object.entries(provider).map(([k, v]) => `${k} = ${typeof v === "object" ? "{ " + Object.entries(v).map(([key, value]) => `${quote(key)} = ${quote(value)}`).join(", ") + " }" : quote(v)}`).join(", ");
|
|
415
|
+
const overrides = ["-c", `model_provider="switcher"`, "-c", `model_providers.switcher={ ${toml} }`, "-c", `model_catalog_json=${quote(file2)}`, "-c", `model=${quote(input.model)}`];
|
|
416
|
+
warnings.push("Codex catalog uses conservative generic tool metadata and a model-neutral coding prompt; provider-specific reasoning is not advertised.");
|
|
417
|
+
return { executable, args: [...overrides, ...args], env, configPaths, warnings };
|
|
418
|
+
}
|
|
419
|
+
if (input.harness === "grok") {
|
|
420
|
+
if (!versionAtLeast(input.version, [1, 0, 13]))
|
|
421
|
+
throw new Error("Grok Build >=1.0.13 is required by this remote catalog adapter.");
|
|
422
|
+
if (args.some((a) => ["--resume", "-r", "--continue", "-c"].includes(a)))
|
|
423
|
+
throw new Error("Grok resume is not supported by the per-launch bridge yet; start a new session.");
|
|
424
|
+
const file2 = await jsonFile(input.stateDir, "grok-overlay.json", { models: { default: grokAlias(input, input.model), allowed_models: input.models.map((m) => grokAlias(input, m.id)) } });
|
|
425
|
+
configPaths.push(file2);
|
|
426
|
+
const bridge = grokBridge(input);
|
|
427
|
+
env.GROK_MODELS_BASE_URL = bridge.baseUrl;
|
|
428
|
+
env.GROK_MODELS_LIST_URL = bridge.baseUrl + "/models";
|
|
429
|
+
env.GROK_XAI_API_BASE_URL = bridge.baseUrl;
|
|
430
|
+
env.XAI_API_KEY = bridge.token;
|
|
431
|
+
env[KEY] = bridge.token;
|
|
432
|
+
env.GROK_CONFIG_PATH = file2;
|
|
433
|
+
warnings.push("Grok uses a per-launch loopback catalog/auth bridge; native managed model policies still apply.");
|
|
434
|
+
return { executable, args: ["--model", grokAlias(input, input.model), ...args], env, configPaths, warnings, cleanup: bridge.cleanup };
|
|
435
|
+
}
|
|
436
|
+
if (!input.version?.includes("opencode2") && !versionAtLeast(input.version, [2, 0, 0]))
|
|
437
|
+
throw new Error("Use the OpenCode 2 executable, not legacy OpenCode.");
|
|
438
|
+
const providerID = "switcher-" + createHash("sha256").update(input.baseUrl + input.protocol).digest("hex").slice(0, 12);
|
|
439
|
+
const packageName = { "anthropic-messages": "anthropic", "openai-responses": "openai/responses", "openai-chat": "openai-compatible" }[input.protocol];
|
|
440
|
+
const models = Object.fromEntries(input.models.map((m) => [m.id, {
|
|
441
|
+
modelID: m.id,
|
|
442
|
+
name: m.name,
|
|
443
|
+
capabilities: { tools: m.supportedParameters?.includes("tools") ?? true, input: m.inputModalities ?? ["text"], output: m.outputModalities ?? ["text"] },
|
|
444
|
+
limit: { ...m.contextWindow ? { context: m.contextWindow } : {}, ...m.maxOutputTokens ? { output: m.maxOutputTokens } : {} }
|
|
445
|
+
}]));
|
|
446
|
+
const settings = { baseURL: input.baseUrl };
|
|
447
|
+
env[KEY] = input.credential ?? "switcher-local-no-auth";
|
|
448
|
+
settings.apiKey = `{env:${KEY}}`;
|
|
449
|
+
const config = { model: `${providerID}/${input.model}`, providers: { [providerID]: { name: "Switcher", env: [KEY], package: `@opencode-ai/ai/providers/${packageName}`, settings, models } } };
|
|
450
|
+
const file = await jsonFile(input.stateDir, "opencode.json", config);
|
|
451
|
+
configPaths.push(file);
|
|
452
|
+
env.OPENCODE_CONFIG = file;
|
|
453
|
+
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ model: config.model });
|
|
454
|
+
const native = args[0] === "run" ? ["run", "--standalone", "--model", `${providerID}/${input.model}`, ...args.slice(1)] : args[0] === "models" ? ["models", "--standalone", ...args.slice(1)] : ["--standalone", ...args];
|
|
455
|
+
warnings.push("OpenCode 2 uses a standalone server so concurrent launch profiles cannot share provider configuration.");
|
|
456
|
+
if (input.models.some((m) => !m.supportedParameters || !m.inputModalities || !m.outputModalities))
|
|
457
|
+
warnings.push("OpenCode requires complete capabilities; unknown fields use text-only/tool-enabled native defaults, not verified provider capabilities.");
|
|
458
|
+
return { executable, args: native, env, configPaths, warnings };
|
|
459
|
+
}
|
|
460
|
+
async function prepareHarnessLaunch(input) {
|
|
461
|
+
const reserved = {
|
|
462
|
+
claude: ["--model", "--settings", "--setting-sources"],
|
|
463
|
+
codex: ["--model", "-m", "--profile", "-p"],
|
|
464
|
+
grok: ["--model", "-m", "--oauth"],
|
|
465
|
+
opencode2: ["--model", "-m", "--server"]
|
|
466
|
+
};
|
|
467
|
+
for (let i = 0;i < (input.args ?? []).length; i++) {
|
|
468
|
+
const arg = input.args[i], flag = arg.split("=")[0];
|
|
469
|
+
if (reserved[input.harness].includes(flag))
|
|
470
|
+
throw new Error("Provider/model configuration arguments are reserved by the launch profile; update the profile instead.");
|
|
471
|
+
if (input.harness === "codex" && (flag === "-c" || flag === "--config" || arg.startsWith("-c"))) {
|
|
472
|
+
const value = arg === "-c" || arg === "--config" ? input.args[i + 1] ?? "" : arg.replace(/^(-c|--config=)/, "");
|
|
473
|
+
if (/^(model|model_provider|model_providers|model_catalog_json)([.=]|$)/.test(value.trim()))
|
|
474
|
+
throw new Error("Codex provider/model configuration must come from the launch profile.");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const nativeAuth = input.protocol === "anthropic-messages" ? "x-api-key" : "bearer";
|
|
478
|
+
const adaptAuth = input.harness === "opencode2" && (input.authStyle ?? "bearer") !== nativeAuth;
|
|
479
|
+
if (input.harness === "opencode2" && (!input.credential || adaptAuth) && (input.args ?? []).some((a) => ["--continue", "-c", "--session", "-s", "--fork"].includes(a.split("=")[0])))
|
|
480
|
+
throw new Error("OpenCode resume with a temporary auth bridge is not supported yet; start a new session.");
|
|
481
|
+
if (input.credential && !adaptAuth || input.harness === "grok")
|
|
482
|
+
return prepareNativeLaunch(input);
|
|
483
|
+
const bridge = grokBridge({ ...input, baseUrl: endpoint(input.baseUrl) });
|
|
484
|
+
try {
|
|
485
|
+
const prepared = await prepareNativeLaunch({ ...input, baseUrl: bridge.baseUrl, credential: bridge.token, authStyle: input.harness === "opencode2" ? nativeAuth : "bearer" });
|
|
486
|
+
return { ...prepared, cleanup: async () => {
|
|
487
|
+
await prepared.cleanup?.();
|
|
488
|
+
await bridge.cleanup();
|
|
489
|
+
} };
|
|
490
|
+
} catch (error) {
|
|
491
|
+
await bridge.cleanup();
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/launcher.ts
|
|
497
|
+
import { spawn } from "child_process";
|
|
498
|
+
import { mkdir as mkdir2, mkdtemp, rm } from "fs/promises";
|
|
499
|
+
import { join as join2, resolve } from "path";
|
|
500
|
+
import { homedir } from "os";
|
|
501
|
+
function childEnvironment(env = process.env) {
|
|
502
|
+
const allowed = /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TEMP|TMP|TERM|COLORTERM|LANG|LC_[A-Z_]+|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_STATE_HOME|XDG_CACHE_HOME|SSH_AUTH_SOCK|GIT_SSH_COMMAND|EDITOR|VISUAL|NO_COLOR|FORCE_COLOR|CODEX_HOME|GROK_HOME|GROK_SANDBOX|GROK_DISABLE_API_KEY_AUTH|CLAUDE_CONFIG_DIR)$/;
|
|
503
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => allowed.test(entry[0]) && entry[1] !== undefined));
|
|
504
|
+
}
|
|
505
|
+
async function launch(client, profileId, options = {}) {
|
|
506
|
+
const profile = await client.getProfile(profileId);
|
|
507
|
+
await client.refreshModels(profile.providerId);
|
|
508
|
+
const plan = await client.launchPlan(profileId);
|
|
509
|
+
const detection = await detectHarness(plan.profile.harness, options.executable);
|
|
510
|
+
if (!detection.available)
|
|
511
|
+
throw new Error(`Harness ${plan.profile.harness} is not installed; use --executable PATH after installing it.`);
|
|
512
|
+
const credential = plan.provider.credentialEnv ? process.env[plan.provider.credentialEnv] : undefined;
|
|
513
|
+
if (plan.provider.credentialEnv && !credential)
|
|
514
|
+
throw new Error("Provider credential environment reference is not available in this local launcher process.");
|
|
515
|
+
const root = resolve(options.stateDir ?? join2(process.env.HASNA_SWITCHER_HOME ?? join2(homedir(), ".hasna", "switcher"), "state"));
|
|
516
|
+
await mkdir2(root, { recursive: true, mode: 448 });
|
|
517
|
+
const stateDir = await mkdtemp(join2(root, "launch-"));
|
|
518
|
+
let run;
|
|
519
|
+
let cleanup;
|
|
520
|
+
try {
|
|
521
|
+
const prepared = await prepareHarnessLaunch({
|
|
522
|
+
harness: plan.profile.harness,
|
|
523
|
+
baseUrl: plan.provider.baseUrl,
|
|
524
|
+
protocol: plan.provider.protocol,
|
|
525
|
+
model: plan.profile.model,
|
|
526
|
+
models: plan.catalog.models.filter(codingEligible),
|
|
527
|
+
credential,
|
|
528
|
+
authStyle: plan.provider.authStyle,
|
|
529
|
+
executable: options.executable,
|
|
530
|
+
args: options.args ?? [],
|
|
531
|
+
stateDir,
|
|
532
|
+
cwd: resolve(options.cwd ?? process.cwd()),
|
|
533
|
+
version: detection.version
|
|
534
|
+
});
|
|
535
|
+
cleanup = prepared.cleanup;
|
|
536
|
+
for (const warning of [...plan.warnings, ...prepared.warnings])
|
|
537
|
+
console.error(`switcher: ${warning}`);
|
|
538
|
+
run = await client.createRun({ profileId, model: plan.profile.model, harness: plan.profile.harness, planToken: plan.planToken });
|
|
539
|
+
let interrupted = false;
|
|
540
|
+
const code = await new Promise((resolveCode, reject) => {
|
|
541
|
+
const child = spawn(prepared.executable, prepared.args, { cwd: resolve(options.cwd ?? process.cwd()), env: { ...childEnvironment(), ...prepared.env }, stdio: "inherit", shell: false });
|
|
542
|
+
let killTimer;
|
|
543
|
+
const forward = (signal) => {
|
|
544
|
+
interrupted = true;
|
|
545
|
+
child.kill(signal);
|
|
546
|
+
killTimer ??= setTimeout(() => child.kill("SIGKILL"), 5000).unref();
|
|
547
|
+
};
|
|
548
|
+
const onInt = () => forward("SIGINT");
|
|
549
|
+
const onTerm = () => forward("SIGTERM");
|
|
550
|
+
process.on("SIGINT", onInt);
|
|
551
|
+
process.on("SIGTERM", onTerm);
|
|
552
|
+
const timeout = options.timeoutMs ? setTimeout(() => forward("SIGTERM"), options.timeoutMs) : undefined;
|
|
553
|
+
const cleanup2 = () => {
|
|
554
|
+
process.off("SIGINT", onInt);
|
|
555
|
+
process.off("SIGTERM", onTerm);
|
|
556
|
+
if (timeout)
|
|
557
|
+
clearTimeout(timeout);
|
|
558
|
+
if (killTimer)
|
|
559
|
+
clearTimeout(killTimer);
|
|
560
|
+
};
|
|
561
|
+
child.once("error", () => {
|
|
562
|
+
cleanup2();
|
|
563
|
+
reject(new Error("Harness process could not start; check executable and permissions."));
|
|
564
|
+
});
|
|
565
|
+
child.once("exit", (code2, signal) => {
|
|
566
|
+
cleanup2();
|
|
567
|
+
resolveCode(code2 ?? (signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 137));
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
await client.finishRun(run.id, run.version, { status: interrupted ? "interrupted" : code === 0 ? "exited" : "failed", exitCode: code }, crypto.randomUUID()).catch(() => console.error(`switcher: Harness exited ${code}; final metadata could not be saved for run ${run.id}.`));
|
|
571
|
+
return code;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
if (run)
|
|
574
|
+
await client.finishRun(run.id, run.version, { status: "failed", exitCode: 1 }).catch(() => console.error("switcher: Could not persist final run status; inspect the run through the API."));
|
|
575
|
+
throw error;
|
|
576
|
+
} finally {
|
|
577
|
+
await cleanup?.();
|
|
578
|
+
await rm(stateDir, { recursive: true, force: true });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/cli.ts
|
|
583
|
+
var HELP = `switcher \u2014 launch a coding harness with a provider and its model catalog
|
|
584
|
+
|
|
585
|
+
switcher providers list [--search TEXT] [--limit N] [--offset N]
|
|
586
|
+
switcher providers add ID --url URL --protocol PROTOCOL [--credential-env NAME]
|
|
587
|
+
switcher providers add ID --preset openrouter --protocol PROTOCOL
|
|
588
|
+
switcher providers get|refresh ID
|
|
589
|
+
switcher providers update ID --file provider.json --version N
|
|
590
|
+
switcher providers delete ID --version N
|
|
591
|
+
switcher models PROVIDER [--refresh] [--search TEXT] [--limit N]
|
|
592
|
+
switcher profiles list|get [ID]
|
|
593
|
+
switcher profiles add ID --provider ID --harness HARNESS --model MODEL
|
|
594
|
+
switcher profiles update ID --file profile.json --version N
|
|
595
|
+
switcher profiles delete ID --version N
|
|
596
|
+
switcher launch PROFILE [--cwd DIR] [--executable PATH] [--state-dir DIR]
|
|
597
|
+
[--timeout SECONDS] -- [native harness arguments]
|
|
598
|
+
switcher runs list|get [ID]
|
|
599
|
+
switcher doctor
|
|
600
|
+
|
|
601
|
+
HARNESS: claude, codex, grok, opencode2
|
|
602
|
+
PROTOCOL: anthropic-messages, openai-responses, openai-chat
|
|
603
|
+
All data commands require HASNA_SWITCHER_API_URL + HASNA_SWITCHER_API_KEY.
|
|
604
|
+
Provider credential references must start SWITCHER_PROVIDER_.
|
|
605
|
+
--file accepts a JSON object including id; raw credentials are never accepted.
|
|
606
|
+
--json outputs machine-readable records (also the default for data commands).
|
|
607
|
+
switcher --version | --help
|
|
608
|
+
`;
|
|
609
|
+
async function readInput(path) {
|
|
610
|
+
try {
|
|
611
|
+
return await Bun.file(path).json();
|
|
612
|
+
} catch {
|
|
613
|
+
throw new Error("Input file must be readable, valid JSON.");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function main(args = process.argv.slice(2)) {
|
|
617
|
+
const split = args.indexOf("--");
|
|
618
|
+
const nativeArgs = split >= 0 ? args.slice(split + 1) : [];
|
|
619
|
+
const { values, positionals } = parseArgs({ args: split >= 0 ? args.slice(0, split) : args, allowPositionals: true, options: {
|
|
620
|
+
help: { type: "boolean" },
|
|
621
|
+
version: { type: "string" },
|
|
622
|
+
json: { type: "boolean" },
|
|
623
|
+
url: { type: "string" },
|
|
624
|
+
protocol: { type: "string" },
|
|
625
|
+
preset: { type: "string" },
|
|
626
|
+
name: { type: "string" },
|
|
627
|
+
file: { type: "string" },
|
|
628
|
+
"credential-env": { type: "string" },
|
|
629
|
+
"auth-style": { type: "string" },
|
|
630
|
+
provider: { type: "string" },
|
|
631
|
+
harness: { type: "string" },
|
|
632
|
+
model: { type: "string" },
|
|
633
|
+
search: { type: "string" },
|
|
634
|
+
limit: { type: "string" },
|
|
635
|
+
offset: { type: "string" },
|
|
636
|
+
refresh: { type: "boolean" },
|
|
637
|
+
cwd: { type: "string" },
|
|
638
|
+
executable: { type: "string" },
|
|
639
|
+
"state-dir": { type: "string" },
|
|
640
|
+
timeout: { type: "string" }
|
|
641
|
+
} });
|
|
642
|
+
if (values.help || !positionals.length) {
|
|
643
|
+
console.log(HELP);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const [command, action, id] = positionals;
|
|
647
|
+
const output = (value) => console.log(JSON.stringify(value, null, 2));
|
|
648
|
+
if (command === "doctor") {
|
|
649
|
+
const harnesses = await Promise.all(["claude", "codex", "grok", "opencode2"].map((h) => detectHarness(h)));
|
|
650
|
+
let api = { configured: false };
|
|
651
|
+
if (process.env.HASNA_SWITCHER_API_URL) {
|
|
652
|
+
try {
|
|
653
|
+
await clientFromEnv().listProviders({ limit: 1 });
|
|
654
|
+
api = { configured: true, reachable: true };
|
|
655
|
+
} catch {
|
|
656
|
+
api = { configured: true, reachable: false };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
output({ version: VERSION, harnesses, api, liveVerified: false });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const client = clientFromEnv();
|
|
663
|
+
const page = { limit: values.limit ? Number(values.limit) : undefined, offset: values.offset ? Number(values.offset) : undefined, search: values.search };
|
|
664
|
+
const currentVersion = () => {
|
|
665
|
+
const n = Number(values.version);
|
|
666
|
+
if (!Number.isSafeInteger(n) || n < 1)
|
|
667
|
+
throw new Error("Use --version N with the record's current version.");
|
|
668
|
+
return n;
|
|
669
|
+
};
|
|
670
|
+
if (command === "launch") {
|
|
671
|
+
if (!action)
|
|
672
|
+
throw new Error("A profile ID is required.");
|
|
673
|
+
const timeoutMs = values.timeout ? Number(values.timeout) * 1000 : undefined;
|
|
674
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0))
|
|
675
|
+
throw new Error("--timeout must be positive seconds.");
|
|
676
|
+
process.exitCode = await launch(client, action, { cwd: values.cwd, executable: values.executable, stateDir: values["state-dir"], args: nativeArgs, timeoutMs });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (command === "models" && action) {
|
|
680
|
+
if (values.refresh)
|
|
681
|
+
await client.refreshModels(action);
|
|
682
|
+
output(await client.listModels(action, page));
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (command === "runs") {
|
|
686
|
+
output(action === "get" && id ? await client.getRun(id) : action === "list" ? await client.listRuns(page) : (() => {
|
|
687
|
+
throw new Error("Use runs list|get ID.");
|
|
688
|
+
})());
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (command === "providers") {
|
|
692
|
+
if (action === "list") {
|
|
693
|
+
output(await client.listProviders(page));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (action === "get" && id) {
|
|
697
|
+
output(await client.getProvider(id));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (action === "refresh" && id) {
|
|
701
|
+
output(await client.refreshModels(id));
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (action === "delete" && id) {
|
|
705
|
+
output(await client.deleteProvider(id, currentVersion()));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (["add", "update"].includes(action) && id) {
|
|
709
|
+
if (values.preset && values.preset !== "openrouter")
|
|
710
|
+
throw new Error("Supported preset: openrouter.");
|
|
711
|
+
const input = parse(providerInputSchema, values.file ? await readInput(values.file) : {
|
|
712
|
+
id,
|
|
713
|
+
name: values.name ?? id,
|
|
714
|
+
baseUrl: values.url ?? (values.preset === "openrouter" ? "https://openrouter.ai/api/v1" : undefined),
|
|
715
|
+
protocol: values.protocol,
|
|
716
|
+
credentialEnv: values["credential-env"],
|
|
717
|
+
authStyle: values["auth-style"]
|
|
718
|
+
});
|
|
719
|
+
if (input.id !== id)
|
|
720
|
+
throw new Error("File id must match the command id.");
|
|
721
|
+
output(action === "add" ? await client.createProvider(input) : await client.updateProvider(input, currentVersion()));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (command === "profiles") {
|
|
726
|
+
if (action === "list") {
|
|
727
|
+
output(await client.listProfiles(page));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (action === "get" && id) {
|
|
731
|
+
output(await client.getProfile(id));
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (action === "delete" && id) {
|
|
735
|
+
output(await client.deleteProfile(id, currentVersion()));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (["add", "update"].includes(action) && id) {
|
|
739
|
+
const input = parse(profileInputSchema, values.file ? await readInput(values.file) : { id, name: values.name ?? id, providerId: values.provider, harness: values.harness, model: values.model });
|
|
740
|
+
if (input.id !== id)
|
|
741
|
+
throw new Error("File id must match the command id.");
|
|
742
|
+
output(action === "add" ? await client.createProfile(input) : await client.updateProfile(input, currentVersion()));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
throw new Error("Unknown command or missing arguments. Run switcher --help.");
|
|
747
|
+
}
|
|
748
|
+
if (import.meta.main) {
|
|
749
|
+
if (process.argv.slice(2).length === 1 && process.argv[2] === "--version")
|
|
750
|
+
console.log(VERSION);
|
|
751
|
+
else
|
|
752
|
+
main().catch((error) => {
|
|
753
|
+
console.error(JSON.stringify({ error: error instanceof SwitcherError ? { code: error.code, message: error.message, requestId: error.requestId } : { message: error instanceof Error ? error.message : "Command failed." } }));
|
|
754
|
+
process.exitCode = 1;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
export {
|
|
758
|
+
main
|
|
759
|
+
};
|