@hasna/switcher 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sdk.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/domain.ts
2
2
  import { z } from "zod";
3
- var harnessSchema = z.enum(["claude", "codex", "grok", "opencode2"]);
3
+ var harnessSchema = z.enum(["claude", "codex", "grok", "opencode2", "pi"]);
4
4
  var protocolSchema = z.enum(["anthropic-messages", "openai-responses", "openai-chat"]);
5
5
  var idSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/);
6
6
  var label = z.string().min(1).max(200);
@@ -28,6 +28,7 @@ var modelSchema = z.object({
28
28
  id: z.string().min(1).max(300),
29
29
  name: label,
30
30
  description: z.string().max(8000).optional(),
31
+ available: z.boolean().optional(),
31
32
  contextWindow: z.number().int().positive().optional(),
32
33
  maxOutputTokens: z.number().int().positive().optional(),
33
34
  inputModalities: z.array(z.string().max(50)).max(20).optional(),
@@ -41,9 +42,32 @@ var providerInputSchema = z.object({
41
42
  protocol: protocolSchema,
42
43
  credentialEnv: envRef.optional(),
43
44
  authStyle: z.enum(["bearer", "x-api-key"]).default("bearer"),
45
+ catalogBaseUrl: urlSchema.optional(),
46
+ catalogFormat: z.enum(["openai", "ollama", "mistral", "together", "fireworks", "dashscope", "none"]).optional(),
47
+ catalogAuthStyle: z.enum(["bearer", "x-api-key", "none"]).optional(),
48
+ catalogCredentialEnv: envRef.optional(),
49
+ catalogAccountId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/).optional(),
44
50
  modelsPath: z.string().regex(/^[a-zA-Z0-9_/-]+$/).max(200).default("models"),
45
51
  manualModels: z.array(modelSchema).max(1e4).default([])
46
52
  }).strict().refine((p) => !p.modelsPath.split("/").includes("..") && !p.modelsPath.startsWith("/"), "modelsPath must be relative");
53
+ var providerPresetSchema = z.object({
54
+ id: idSchema,
55
+ name: label,
56
+ credentialEnv: envRef.optional(),
57
+ credentialAliases: z.array(z.string().regex(/^[A-Z][A-Z0-9_]+$/)),
58
+ protocols: z.array(z.object({
59
+ protocol: protocolSchema,
60
+ baseUrl: urlSchema.optional(),
61
+ authStyle: z.enum(["bearer", "x-api-key"]),
62
+ catalogBaseUrl: urlSchema.optional(),
63
+ catalogFormat: z.enum(["openai", "ollama", "mistral", "together", "fireworks", "dashscope", "none"]),
64
+ catalogAuthStyle: z.enum(["bearer", "x-api-key", "none"]).optional(),
65
+ modelsPath: z.string(),
66
+ notes: z.array(z.string())
67
+ }).strict()).min(1),
68
+ sources: z.array(z.string().url()),
69
+ verification: z.literal("documented")
70
+ }).strict();
47
71
  var profileInputSchema = z.object({
48
72
  id: idSchema,
49
73
  name: label,
@@ -71,6 +95,15 @@ class Fault extends Error {
71
95
  this.code = code;
72
96
  }
73
97
  }
98
+ function parse(schema, value) {
99
+ const result = schema.safeParse(value);
100
+ if (!result.success)
101
+ throw new Fault(400, "invalid_request", result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "));
102
+ return result.data;
103
+ }
104
+ function compatible(harness, protocol) {
105
+ return harness === "claude" ? protocol === "anthropic-messages" : harness === "codex" ? protocol === "openai-responses" : true;
106
+ }
74
107
 
75
108
  // src/http.ts
76
109
  var MAX_BYTES = 16 * 1024 * 1024;
@@ -109,6 +142,118 @@ async function boundedJson(response, maxBytes = MAX_BYTES) {
109
142
  // src/sdk.ts
110
143
  import { resolveCredential } from "@hasna/contracts/client";
111
144
 
145
+ // src/presets.ts
146
+ var route = (protocol, baseUrl, options = {}) => ({
147
+ protocol,
148
+ baseUrl,
149
+ authStyle: "bearer",
150
+ catalogFormat: "openai",
151
+ modelsPath: "models",
152
+ notes: [],
153
+ ...options
154
+ });
155
+ var preset = (id, name, protocols, sources, alias) => parse(providerPresetSchema, {
156
+ id,
157
+ name,
158
+ protocols,
159
+ sources,
160
+ credentialAliases: alias ? [alias] : [],
161
+ credentialEnv: alias ? `SWITCHER_PROVIDER_${id.toUpperCase().replace(/-/g, "_")}` : undefined,
162
+ verification: "documented"
163
+ });
164
+ var providerPresets = [
165
+ preset("deepseek", "DeepSeek", [
166
+ route("openai-chat", "https://api.deepseek.com", { catalogBaseUrl: "https://api.deepseek.com" }),
167
+ route("anthropic-messages", "https://api.deepseek.com/anthropic/v1", { catalogBaseUrl: "https://api.deepseek.com" })
168
+ ], ["https://api-docs.deepseek.com/guides/anthropic_api", "https://api-docs.deepseek.com/api/list-models"], "DEEPSEEK_API_KEY"),
169
+ preset("openrouter", "OpenRouter", ["openai-chat", "openai-responses", "anthropic-messages"].map((protocol) => route(protocol, "https://openrouter.ai/api/v1", { catalogAuthStyle: "none" })), ["https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties", "https://openrouter.ai/docs/guides/overview"], "OPENROUTER_API_KEY"),
170
+ preset("anthropic", "Anthropic", [route("anthropic-messages", "https://api.anthropic.com/v1", { authStyle: "x-api-key" })], ["https://platform.claude.com/docs/en/api/overview", "https://platform.claude.com/docs/en/api/models/list"], "ANTHROPIC_API_KEY"),
171
+ preset("openai", "OpenAI", [route("openai-responses", "https://api.openai.com/v1"), route("openai-chat", "https://api.openai.com/v1")], ["https://platform.openai.com/docs/api-reference/introduction", "https://platform.openai.com/docs/api-reference/models/list"], "OPENAI_API_KEY"),
172
+ preset("xai", "xAI", ["openai-chat", "openai-responses", "anthropic-messages"].map((protocol) => route(protocol, "https://api.x.ai/v1")), ["https://api.x.ai/docs/", "https://docs.x.ai/developers/model-capabilities/text/generate-text"], "XAI_API_KEY"),
173
+ preset("ollama", "Ollama", ["openai-chat", "openai-responses"].map((protocol) => route(protocol, "http://127.0.0.1:11434/v1", {
174
+ catalogBaseUrl: "http://127.0.0.1:11434",
175
+ modelsPath: "api/tags",
176
+ catalogFormat: "ollama",
177
+ catalogAuthStyle: "none",
178
+ notes: protocol === "openai-responses" ? ["Requires Ollama 0.13.3 or newer; only stateless Responses are supported."] : []
179
+ })), ["https://docs.ollama.com/api/openai-compatibility", "https://docs.ollama.com/api/tags"]),
180
+ preset("lmstudio", "LM Studio", ["openai-chat", "openai-responses", "anthropic-messages"].map((protocol) => route(protocol, "http://127.0.0.1:1234/v1", {
181
+ notes: ["Server authentication is optional. Use --credential-env when authentication is enabled."]
182
+ })), ["https://lmstudio.ai/docs/developer/rest"]),
183
+ preset("vllm", "vLLM (operator endpoint)", [
184
+ route("openai-chat", undefined, { notes: ["Pass the operator's OpenAI-compatible URL, normally ending in /v1. vLLM exposes Chat Completions at /chat/completions and GET /models under that prefix; configure --credential-env only when the server was started with --api-key or VLLM_API_KEY."] }),
185
+ route("openai-responses", undefined, { notes: ["Pass the operator's OpenAI-compatible URL, normally ending in /v1. Responses is available for supported text-generation models at /responses; configure --credential-env only when the server was started with --api-key or VLLM_API_KEY."] }),
186
+ route("anthropic-messages", undefined, { notes: ["Pass the operator's URL, normally ending in /v1. vLLM exposes the Anthropic Messages API at /messages for supported deployments. Chat templates and the configured tool parser determine whether streaming and tool calls work for a served model; configure --credential-env only when the server was started with --api-key or VLLM_API_KEY."] })
187
+ ], ["https://docs.vllm.ai/en/latest/serving/online_serving/openai_compatible_server/", "https://github.com/vllm-project/vllm/blob/main/docs/serving/online_serving/README.md"]),
188
+ preset("litellm", "LiteLLM Proxy (operator endpoint)", [
189
+ route("openai-chat", undefined, { notes: ["Pass the proxy's documented OpenAI-compatible base URL exactly; the official quick start uses the root server URL, while a deployment may add a prefix such as /v1. LiteLLM serves Chat Completions and GET /models relative to that URL; use --credential-env for the proxy's configured master key or other bearer token."] }),
190
+ route("openai-responses", undefined, { notes: ["Pass the proxy's documented OpenAI-compatible base URL exactly; LiteLLM documents the Responses API under the same proxy root or deployment prefix. Use --credential-env for the proxy's configured master key or other bearer token."] }),
191
+ route("anthropic-messages", undefined, { notes: ["Pass the complete inference prefix ending in /v1, including any deployment prefix. LiteLLM registers /v1/messages; Switcher appends /messages to the stored prefix and discovers /models there. This is a gateway adapter: streaming and tool behavior depend on the configured upstream model and route, so verify those capabilities independently. Use --credential-env for the proxy's configured master key or other bearer token."] })
192
+ ], ["https://docs.litellm.ai/", "https://docs.litellm.ai/docs/proxy/quick_start", "https://github.com/BerriAI/litellm/blob/main/litellm/proxy/anthropic_endpoints/endpoints.py"]),
193
+ preset("groq", "Groq", [route("openai-chat", "https://api.groq.com/openai/v1"), route("openai-responses", "https://api.groq.com/openai/v1", { notes: ["Responses is an upstream beta API."] })], ["https://console.groq.com/docs/api-reference"], "GROQ_API_KEY"),
194
+ preset("cerebras", "Cerebras", [route("openai-chat", "https://api.cerebras.ai/v1")], ["https://inference-docs.cerebras.ai/api-reference/chat-completions"], "CEREBRAS_API_KEY"),
195
+ preset("mistral", "Mistral", [route("openai-chat", "https://api.mistral.ai/v1", { catalogFormat: "mistral" })], ["https://docs.mistral.ai/api/endpoint/chat", "https://docs.mistral.ai/api/endpoint/models"], "MISTRAL_API_KEY"),
196
+ preset("together", "Together AI", [route("openai-chat", "https://api.together.ai/v1", { catalogFormat: "together" })], ["https://docs.together.ai/docs/inference/openai-compatibility", "https://docs.together.ai/reference/models"], "TOGETHER_API_KEY"),
197
+ preset("fireworks", "Fireworks AI", [
198
+ route("openai-chat", "https://api.fireworks.ai/inference/v1", { catalogFormat: "fireworks", notes: ["Model discovery uses GET /v1/accounts/{account_id}/models; provide --catalog-account-id or --catalog-url."] }),
199
+ route("openai-responses", "https://api.fireworks.ai/inference/v1", { catalogFormat: "fireworks", notes: ["Model discovery uses GET /v1/accounts/{account_id}/models; provide --catalog-account-id or --catalog-url."] }),
200
+ route("anthropic-messages", "https://api.fireworks.ai/inference/v1", { catalogFormat: "fireworks", notes: ["Model discovery uses GET /v1/accounts/{account_id}/models; provide --catalog-account-id or --catalog-url."] })
201
+ ], ["https://docs.fireworks.ai/getting-started/quickstart", "https://docs.fireworks.ai/tools-sdks/python-client/api-reference", "https://docs.fireworks.ai/api-reference/anthropic-messages", "https://docs.fireworks.ai/api-reference/post-chatcompletions", "https://docs.fireworks.ai/api-reference/list-models"], "FIREWORKS_API_KEY"),
202
+ preset("moonshot", "Moonshot AI (Kimi)", [route("openai-chat", "https://api.moonshot.ai/v1", { catalogBaseUrl: "https://api.moonshot.ai/v1" })], ["https://platform.kimi.ai/docs/api/chat", "https://platform.kimi.ai/docs/api/list-models"], "MOONSHOT_API_KEY"),
203
+ preset("dashscope", "Alibaba Cloud Model Studio (Qwen)", [route("openai-chat", "https://dashscope-us.aliyuncs.com/compatible-mode/v1", {
204
+ catalogFormat: "none",
205
+ notes: ["Inference keys and endpoints are region/workspace-specific. Model discovery uses GET /api/v1/models on a documented region or workspace catalog URL; pass --catalog-url and --catalog-format dashscope."]
206
+ })], ["https://help.aliyun.com/en/model-studio/base-url", "https://help.aliyun.com/en/model-studio/compatibility-of-openai-with-dashscope", "https://help.aliyun.com/en/model-studio/list-models"], "DASHSCOPE_API_KEY"),
207
+ preset("zai", "Z.AI", [route("openai-chat", "https://api.z.ai/api/paas/v4", {
208
+ catalogFormat: "none",
209
+ notes: ["The published API reference documents inference endpoints but no model-list endpoint; use manual models or provide an explicit catalog URL and parser."]
210
+ })], ["https://docs.z.ai/api-reference/introduction", "https://docs.z.ai/devpack/quick-start"], "ZAI_API_KEY"),
211
+ preset("minimax", "MiniMax", [
212
+ route("openai-chat", "https://api.minimax.cn/v1", { catalogBaseUrl: "https://api.minimax.cn/v1", notes: ["The Open Platform contract uses api.minimax.cn and Bearer auth. Token Plan documentation uses api.minimaxi.com; select that authority explicitly with --url and matching auth/credential settings."] }),
213
+ route("anthropic-messages", "https://api.minimax.cn/anthropic/v1", { authStyle: "x-api-key", catalogBaseUrl: "https://api.minimax.cn/anthropic/v1", catalogAuthStyle: "x-api-key", notes: ["The Open Platform contract uses api.minimax.cn/anthropic/v1 and X-Api-Key. Token Plan documentation uses api.minimaxi.com/anthropic; select that authority explicitly with --url and matching auth/credential settings."] })
214
+ ], ["https://platform.minimaxi.com/docs/api-reference/text-chat-anthropic", "https://platform.minimaxi.com/docs/api-reference/models/anthropic/list-models", "https://platform.minimaxi.com/docs/api-reference/models/openai/list-models", "https://platform.minimaxi.com/docs/token-plan/other-tools"], "MINIMAX_API_KEY"),
215
+ preset("siliconflow", "SiliconFlow", [route("openai-chat", "https://api.siliconflow.cn/v1", { catalogBaseUrl: "https://api.siliconflow.cn/v1", catalogFormat: "openai", notes: ["The official SiliconCloud OpenAPI contract defines GET /models with Bearer auth and data[] model rows; optional type and sub_type filters are available at the upstream endpoint."] })], ["https://github.com/siliconflow/siliconcloud/blob/main/openapi.yaml", "https://docs.siliconflow.cn/docs/userguide/quickstart", "https://docs.siliconflow.cn/docs/api/chat-completions-post"], "SILICONFLOW_API_KEY"),
216
+ ...["anthropic-messages", "openai-responses", "openai-chat"].map((protocol) => preset(`generic-${protocol}`, `Custom ${protocol}`, [route(protocol)], []))
217
+ ];
218
+ function getProviderPreset(id) {
219
+ const entry = providerPresets.find((p) => p.id === id);
220
+ if (!entry)
221
+ throw new Fault(404, "preset_not_found", "Unknown provider preset. Use switcher providers presets to list available presets.");
222
+ return structuredClone(entry);
223
+ }
224
+ function providerFromPreset(presetId, options = {}) {
225
+ const preset2 = getProviderPreset(presetId);
226
+ const selected = preset2.protocols.find((p) => (!options.protocol || p.protocol === options.protocol) && (!options.harness || compatible(options.harness, p.protocol)));
227
+ if (!selected)
228
+ throw new Fault(422, "protocol_mismatch", "This provider preset has no native protocol compatible with the requested harness. Choose an explicitly compatible gateway.");
229
+ const baseUrl = options.baseUrl ?? selected.baseUrl;
230
+ if (!baseUrl)
231
+ throw new Fault(400, "endpoint_required", "This preset requires an explicit --url for its inference endpoint.");
232
+ if (options.baseUrl && selected.baseUrl && new URL(endpoint(options.baseUrl)).origin !== new URL(selected.baseUrl).origin && preset2.credentialEnv && !options.credentialEnv)
233
+ throw new Fault(422, "credential_authority", "An endpoint on another origin requires an explicit --credential-env reference.");
234
+ const suffix = selected.protocol === "anthropic-messages" ? "messages" : selected.protocol === "openai-responses" ? "responses" : "chat";
235
+ if (presetId === "fireworks" && !options.catalogBaseUrl && !options.catalogAccountId)
236
+ throw new Fault(400, "catalog_account_required", "Fireworks model discovery requires --catalog-account-id or an explicit --catalog-url.");
237
+ if (selected.catalogFormat === "none" && options.catalogFormat && !options.catalogBaseUrl)
238
+ throw new Fault(400, "catalog_url_required", "This preset requires an explicit --catalog-url when enabling a catalog parser.");
239
+ const catalogBaseUrl = options.catalogBaseUrl ?? (presetId === "fireworks" && options.catalogAccountId ? `https://api.fireworks.ai/v1/accounts/${encodeURIComponent(options.catalogAccountId)}` : options.baseUrl ? undefined : selected.catalogBaseUrl);
240
+ return parse(providerInputSchema, {
241
+ id: options.id ?? `${preset2.id}-${suffix}`,
242
+ name: preset2.name,
243
+ baseUrl,
244
+ protocol: selected.protocol,
245
+ credentialEnv: options.credentialEnv ?? preset2.credentialEnv,
246
+ authStyle: options.authStyle ?? selected.authStyle,
247
+ catalogBaseUrl,
248
+ catalogCredentialEnv: options.catalogCredentialEnv,
249
+ catalogAuthStyle: options.catalogAuthStyle ?? selected.catalogAuthStyle,
250
+ catalogFormat: options.catalogFormat ?? selected.catalogFormat,
251
+ catalogAccountId: options.catalogAccountId,
252
+ modelsPath: options.modelsPath ?? selected.modelsPath
253
+ });
254
+ }
255
+
256
+ // src/sdk.ts
112
257
  class SwitcherError extends Error {
113
258
  status;
114
259
  code;
@@ -131,7 +276,7 @@ class SwitcherClient {
131
276
  this.options = { ...options };
132
277
  }
133
278
  async request(method, path, body, options = {}) {
134
- if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) || path.includes(".."))
279
+ if (!/^\/v1\/[a-zA-Z0-9/?&=._%+-]+$/.test(path) && !["/health", "/ready", "/version"].includes(path) || path.includes(".."))
135
280
  throw new Error("Invalid API path.");
136
281
  const apiKey = typeof this.options.apiKey === "function" ? this.options.apiKey() : this.options.apiKey;
137
282
  if (!apiKey || /[\r\n]/.test(apiKey))
@@ -162,6 +307,21 @@ class SwitcherClient {
162
307
  query(options = {}) {
163
308
  return new URLSearchParams(Object.entries(options).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])).toString();
164
309
  }
310
+ health() {
311
+ return this.request("GET", "/health");
312
+ }
313
+ ready() {
314
+ return this.request("GET", "/ready");
315
+ }
316
+ version() {
317
+ return this.request("GET", "/version");
318
+ }
319
+ listProviderPresets() {
320
+ return this.request("GET", "/v1/provider-presets");
321
+ }
322
+ getProviderPreset(id) {
323
+ return this.request("GET", `/v1/provider-presets/${encodeURIComponent(id)}`);
324
+ }
165
325
  listProviders(options = {}) {
166
326
  return this.request("GET", `/v1/providers?${this.query(options)}`);
167
327
  }
@@ -221,6 +381,7 @@ function clientFromEnv(env = process.env) {
221
381
  return new SwitcherClient({ baseUrl: env.HASNA_SWITCHER_API_URL, apiKey: credential });
222
382
  }
223
383
  export {
384
+ providerFromPreset,
224
385
  clientFromEnv,
225
386
  SwitcherError,
226
387
  SwitcherClient