@nvae/llmswitch 0.2.0 → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { readFileSync } from "node:fs";
3
- import { isApiFormat } from "../types.js";
3
+ import { isApiFormat, normalizeProxyValue } from "../types.js";
4
4
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
5
5
  import { atomicWriteFile, ensureDir, maskSecret } from "../utils/fs.js";
6
6
  import { getProfilePath, getProfilesDir, getStatePath, getToolStoreDir, } from "../utils/paths.js";
@@ -63,6 +63,44 @@ export function requireProfile(tool, name) {
63
63
  }
64
64
  return profile;
65
65
  }
66
+ /** 归一化引用串:小写并去掉分隔符(kimi-openai ↔ kimiOpenai)。 */
67
+ export function normalizeReference(value) {
68
+ return value.toLowerCase().replace(/[_\s./-]+/g, "");
69
+ }
70
+ /**
71
+ * 按用户输入解析 profile:优先名称精确匹配,其次显示名称精确匹配,
72
+ * 再尝试归一化(大小写/分隔符)与包含匹配;多义时返回 null。
73
+ */
74
+ export function resolveProfile(tool, query) {
75
+ const trimmed = query.trim();
76
+ if (!trimmed)
77
+ return null;
78
+ const byName = readProfile(tool, trimmed);
79
+ if (byName)
80
+ return byName;
81
+ const profiles = listProfiles(tool);
82
+ const normalized = normalizeReference(trimmed);
83
+ const exactDisplay = profiles.find((p) => normalizeReference(p.displayName) === normalized);
84
+ if (exactDisplay)
85
+ return exactDisplay;
86
+ const fuzzy = profiles.filter((p) => normalizeReference(p.displayName).includes(normalized) ||
87
+ normalizeReference(p.name).includes(normalized));
88
+ return fuzzy.length === 1 ? fuzzy[0] : null;
89
+ }
90
+ export function resolveProfileOrThrow(tool, query) {
91
+ const profile = resolveProfile(tool, query);
92
+ if (!profile) {
93
+ const profiles = listProfiles(tool);
94
+ if (profiles.length === 0) {
95
+ throw new Error(`暂无 ${tool} 供应商。请先:llms ${tool} provider`);
96
+ }
97
+ const names = profiles
98
+ .map((p) => `${p.name}(${p.displayName})`)
99
+ .join(", ");
100
+ throw new Error(`未找到匹配「${query}」的供应商。现有:${names}。可通过名称或显示名称引用。`);
101
+ }
102
+ return profile;
103
+ }
66
104
  export function saveProfile(tool, profile) {
67
105
  assertValidProfileName(profile.name);
68
106
  if (!isApiFormat(profile.apiFormat)) {
@@ -191,7 +229,7 @@ function normalizeProfile(raw, fallbackName) {
191
229
  fast: raw.models?.fast || undefined,
192
230
  list: list.length ? list : raw.models?.default ? [raw.models.default] : [],
193
231
  },
194
- proxy: raw.proxy,
232
+ proxy: normalizeProxyValue(raw.proxy),
195
233
  bridgeMode: raw.bridgeMode,
196
234
  headers: raw.headers || {},
197
235
  updatedAt: raw.updatedAt || new Date(0).toISOString(),
package/dist/types.js CHANGED
@@ -11,7 +11,24 @@ export function isApiFormat(value) {
11
11
  return API_FORMATS.includes(value);
12
12
  }
13
13
  export function emptyProxy(proxy) {
14
- if (!proxy)
15
- return true;
16
- return !proxy.http && !proxy.https && !proxy.all;
14
+ return !proxy || !proxy.trim();
15
+ }
16
+ /**
17
+ * Coerce a stored proxy value into a single URL string. Accepts the current
18
+ * string form, or the legacy `{ http, https, all }` object (preferring `all`,
19
+ * then `https`, then `http`). Returns undefined when no proxy is set.
20
+ */
21
+ export function normalizeProxyValue(raw) {
22
+ if (typeof raw === "string") {
23
+ const trimmed = raw.trim();
24
+ return trimmed ? trimmed : undefined;
25
+ }
26
+ if (raw && typeof raw === "object") {
27
+ const row = raw;
28
+ for (const value of [row.all, row.https, row.http]) {
29
+ if (typeof value === "string" && value.trim())
30
+ return value.trim();
31
+ }
32
+ }
33
+ return undefined;
17
34
  }
@@ -0,0 +1,178 @@
1
+ import { supportedFormats } from "../formats/compatibility.js";
2
+ import { requestWithNodeTransport } from "../bridge/transport.js";
3
+ import { baseUrlFromModelsEndpoint, buildModelsRequestHeaders, modelListEndpoints, parseModelIds, } from "./fetch-models.js";
4
+ const PROBE_TIMEOUT_MS = 4000;
5
+ async function probe(url, method, headers, proxy, body) {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
8
+ try {
9
+ const res = await requestWithNodeTransport({
10
+ url,
11
+ method,
12
+ headers,
13
+ proxy,
14
+ body: body === undefined ? undefined : JSON.stringify(body),
15
+ signal: controller.signal,
16
+ totalTimeoutMs: PROBE_TIMEOUT_MS,
17
+ });
18
+ let json = null;
19
+ try {
20
+ json = await res.json();
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ return { status: res.status, json };
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ finally {
31
+ clearTimeout(timer);
32
+ }
33
+ }
34
+ /** GET /models(Anthropic 头)成功且返回模型 → anthropic 兼容。 */
35
+ async function probeAnthropicModels(baseUrl, apiKey, proxy) {
36
+ const headers = buildModelsRequestHeaders("anthropic", apiKey);
37
+ for (const endpoint of modelListEndpoints(baseUrl)) {
38
+ const res = await probe(endpoint, "GET", headers, proxy);
39
+ if (res &&
40
+ res.status >= 200 &&
41
+ res.status < 300 &&
42
+ parseModelIds(res.json).length > 0) {
43
+ return true;
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+ /** GET /models(Bearer 头)成功 → OpenAI 兼容。 */
49
+ async function probeOpenAiModels(baseUrl, apiKey, proxy) {
50
+ const headers = buildModelsRequestHeaders("openai-chat", apiKey);
51
+ for (const endpoint of modelListEndpoints(baseUrl)) {
52
+ const res = await probe(endpoint, "GET", headers, proxy);
53
+ if (res &&
54
+ res.status >= 200 &&
55
+ res.status < 300 &&
56
+ parseModelIds(res.json).length > 0) {
57
+ return endpoint;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * POST 探测某一路由是否存在:404/501 或非 JSON 响应视为不存在;
64
+ * 400/401/422/200 等带 JSON body 的响应视为路由存在。
65
+ */
66
+ async function routeExists(baseUrl, suffix, apiKey, proxy, body) {
67
+ const base = baseUrl.trim().replace(/\/+$/, "");
68
+ const candidates = [];
69
+ if (/\/v1$/i.test(base)) {
70
+ candidates.push(`${base}/${suffix}`);
71
+ }
72
+ else {
73
+ candidates.push(`${base}/${suffix}`);
74
+ candidates.push(`${base}/v1/${suffix}`);
75
+ }
76
+ const headers = {
77
+ Accept: "application/json",
78
+ "Content-Type": "application/json",
79
+ };
80
+ if (apiKey.trim())
81
+ headers.Authorization = `Bearer ${apiKey.trim()}`;
82
+ for (const endpoint of candidates) {
83
+ const res = await probe(endpoint, "POST", headers, proxy, body);
84
+ if (!res)
85
+ continue;
86
+ if (res.status === 404 || res.status === 501 || res.status === 405) {
87
+ continue;
88
+ }
89
+ if (res.json === null)
90
+ continue;
91
+ return true;
92
+ }
93
+ return false;
94
+ }
95
+ /** GET /api/tags(Ollama 原生)成功且返回模型 → 本地 Ollama。 */
96
+ async function probeOllamaTags(baseUrl, proxy) {
97
+ const base = baseUrl.trim().replace(/\/+$/, "");
98
+ const endpoint = `${base}/api/tags`;
99
+ const res = await probe(endpoint, "GET", { Accept: "application/json" }, proxy);
100
+ return (res !== null &&
101
+ res.status >= 200 &&
102
+ res.status < 300 &&
103
+ parseModelIds(res.json).length > 0);
104
+ }
105
+ /**
106
+ * 自动识别上游接口类型。探测策略(每个请求 4s 超时,串行):
107
+ * 1. Anthropic /models(有 Key 且工具支持时,claude/opencode 优先原生)
108
+ * 2. OpenAI /models(无 Key 也探测,覆盖 Ollama /vLLM 等本地服务)
109
+ * 3. Ollama 原生 /api/tags(兼容 OpenAI 兼容层缺失的旧版本)
110
+ * 4. /models 全失败且有 Key 时兜底探测 /v1/chat/completions 路由
111
+ * 全部失败返回 detected: false,由调用方回退到人工选择。
112
+ */
113
+ export async function detectApiFormat(tool, opts) {
114
+ const base = opts.baseUrl.trim();
115
+ const key = opts.apiKey.trim();
116
+ const supported = supportedFormats(tool);
117
+ const fallback = {
118
+ apiFormat: "openai-chat",
119
+ detected: false,
120
+ };
121
+ // 没有 URL 无法探测
122
+ if (!base)
123
+ return fallback;
124
+ const chatResult = {
125
+ apiFormat: "openai-chat",
126
+ bridgeMode: tool === "codex" ? "chat" : undefined,
127
+ detected: true,
128
+ source: "openai-models",
129
+ };
130
+ if (key && supported.includes("anthropic")) {
131
+ const antOk = await probeAnthropicModels(base, key, opts.proxy);
132
+ if (antOk) {
133
+ return {
134
+ apiFormat: "anthropic",
135
+ detected: true,
136
+ source: "anthropic",
137
+ resolvedBaseUrl: base.replace(/\/+$/, ""),
138
+ };
139
+ }
140
+ }
141
+ const modelsEndpoint = await probeOpenAiModels(base, key, opts.proxy);
142
+ if (modelsEndpoint) {
143
+ const resolvedBaseUrl = baseUrlFromModelsEndpoint(modelsEndpoint) || base.replace(/\/+$/, "");
144
+ // 无 Key 时不做 POST 探测(本地服务默认 chat 最兼容)
145
+ if (key && supported.includes("openai-responses")) {
146
+ const responsesOk = await routeExists(resolvedBaseUrl, "responses", key, opts.proxy, { model: "", input: "hi" });
147
+ if (responsesOk) {
148
+ return {
149
+ apiFormat: "openai-responses",
150
+ detected: true,
151
+ source: "openai-models",
152
+ resolvedBaseUrl,
153
+ };
154
+ }
155
+ }
156
+ return { ...chatResult, resolvedBaseUrl };
157
+ }
158
+ // Ollama 原生 API(/v1 兼容层可能缺失):映射到 OpenAI Chat + /v1
159
+ if (supported.includes("openai-chat")) {
160
+ const ollamaOk = await probeOllamaTags(base, opts.proxy);
161
+ if (ollamaOk) {
162
+ return {
163
+ ...chatResult,
164
+ source: "ollama-tags",
165
+ resolvedBaseUrl: `${base.replace(/\/+$/, "")}/v1`,
166
+ };
167
+ }
168
+ }
169
+ if (key && supported.includes("openai-chat")) {
170
+ const chatOk = await routeExists(base, "chat/completions", key, opts.proxy, {
171
+ model: "",
172
+ messages: [],
173
+ });
174
+ if (chatOk)
175
+ return { ...chatResult, source: "openai-route" };
176
+ }
177
+ return fallback;
178
+ }
@@ -1,6 +1,5 @@
1
- import { emptyProxy } from "../types.js";
1
+ import { requestWithNodeTransport } from "../bridge/transport.js";
2
2
  import { normalizeBaseUrlForFormat } from "./base-url.js";
3
- import { buildProxyEnv } from "./proxy.js";
4
3
  /**
5
4
  * Derive the API base URL from a successful .../models endpoint.
6
5
  */
@@ -45,20 +44,28 @@ export function modelListEndpoints(baseUrl) {
45
44
  }
46
45
  return endpoints;
47
46
  }
48
- export function buildModelsRequestHeaders(apiFormat, apiKey) {
47
+ export function buildModelsRequestHeaders(apiFormat, apiKey, customHeaders = {}) {
49
48
  const headers = {
50
49
  Accept: "application/json",
51
50
  };
52
- if (!apiKey)
53
- return headers;
54
- if (apiFormat === "anthropic") {
55
- headers["x-api-key"] = apiKey;
56
- headers["anthropic-version"] = "2023-06-01";
57
- // Some gateways also accept Bearer
58
- headers.Authorization = `Bearer ${apiKey}`;
51
+ if (apiKey) {
52
+ if (apiFormat === "anthropic") {
53
+ headers["x-api-key"] = apiKey;
54
+ headers["anthropic-version"] = "2023-06-01";
55
+ headers.Authorization = `Bearer ${apiKey}`;
56
+ }
57
+ else {
58
+ headers.Authorization = `Bearer ${apiKey}`;
59
+ }
59
60
  }
60
- else {
61
- headers.Authorization = `Bearer ${apiKey}`;
61
+ for (const [name, value] of Object.entries(customHeaders)) {
62
+ if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
63
+ throw new Error(`无效的模型请求 header: ${name}`);
64
+ }
65
+ const existing = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase());
66
+ if (existing)
67
+ delete headers[existing];
68
+ headers[name] = value;
62
69
  }
63
70
  return headers;
64
71
  }
@@ -93,51 +100,58 @@ export function parseModelIds(payload) {
93
100
  * Tries several common /models paths; uses proxy env when configured.
94
101
  */
95
102
  export async function fetchModelList(options) {
96
- const { baseUrl, apiKey, apiFormat, proxy, timeoutMs = 20_000 } = options;
103
+ const { baseUrl, apiKey, apiFormat, proxy, headers: customHeaders, timeoutMs = 20_000, } = options;
97
104
  if (!baseUrl?.trim()) {
98
105
  throw new Error("Base URL 为空,无法拉取模型列表");
99
106
  }
100
- if (!apiKey?.trim()) {
101
- throw new Error("API Key 为空,无法拉取模型列表");
102
- }
103
107
  const effectiveBaseUrl = normalizeBaseUrlForFormat(apiFormat, baseUrl);
104
108
  const endpoints = modelListEndpoints(effectiveBaseUrl);
105
- const headers = buildModelsRequestHeaders(apiFormat, apiKey.trim());
106
- const restore = applyProxyEnv(proxy);
109
+ // Key 的本地服务(如 Ollama 原生 API)兜底探测 /api/tags
110
+ if (!apiKey?.trim()) {
111
+ const root = effectiveBaseUrl
112
+ .replace(/\/v1$/i, "")
113
+ .replace(/\/+$/, "");
114
+ const tags = `${root}/api/tags`;
115
+ if (!endpoints.includes(tags))
116
+ endpoints.push(tags);
117
+ }
118
+ const headers = buildModelsRequestHeaders(apiFormat, apiKey.trim(), customHeaders);
107
119
  const errors = [];
108
- try {
109
- for (const endpoint of endpoints) {
110
- try {
111
- const models = await requestModels(endpoint, headers, timeoutMs);
112
- if (models.length > 0) {
113
- const resolvedBaseUrl = baseUrlFromModelsEndpoint(endpoint) || effectiveBaseUrl;
114
- return {
115
- models,
116
- endpoint,
117
- resolvedBaseUrl: normalizeBaseUrlForFormat(apiFormat, resolvedBaseUrl),
118
- };
119
- }
120
- errors.push(`${endpoint} → 返回空列表`);
121
- }
122
- catch (err) {
123
- const msg = err instanceof Error ? err.message : String(err);
124
- errors.push(`${endpoint} → ${msg}`);
120
+ for (const endpoint of endpoints) {
121
+ try {
122
+ const models = await requestModels(endpoint, headers, timeoutMs, proxy);
123
+ if (models.length > 0) {
124
+ // Anthropic Messages base URLs are not implied by a sibling /v1/models
125
+ // endpoint; keep the operator-provided base for those.
126
+ const resolvedBaseUrl = apiFormat === "anthropic"
127
+ ? effectiveBaseUrl
128
+ : baseUrlFromModelsEndpoint(endpoint) || effectiveBaseUrl;
129
+ return {
130
+ models,
131
+ endpoint,
132
+ resolvedBaseUrl: normalizeBaseUrlForFormat(apiFormat, resolvedBaseUrl),
133
+ };
125
134
  }
135
+ errors.push(`${endpoint} → 返回空列表`);
136
+ }
137
+ catch (err) {
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ errors.push(`${endpoint} → ${msg}`);
126
140
  }
127
- }
128
- finally {
129
- restore();
130
141
  }
131
142
  throw new Error(`无法从接口拉取模型列表。\n${errors.map((e) => ` - ${e}`).join("\n")}`);
132
143
  }
133
- async function requestModels(endpoint, headers, timeoutMs) {
144
+ async function requestModels(endpoint, headers, timeoutMs, proxy) {
134
145
  const controller = new AbortController();
135
146
  const timer = setTimeout(() => controller.abort(), timeoutMs);
136
147
  try {
137
- const res = await fetch(endpoint, {
148
+ const res = await requestWithNodeTransport({
149
+ url: endpoint,
138
150
  method: "GET",
139
151
  headers,
152
+ proxy,
140
153
  signal: controller.signal,
154
+ totalTimeoutMs: timeoutMs,
141
155
  });
142
156
  if (!res.ok) {
143
157
  const body = (await res.text().catch(() => "")).slice(0, 200);
@@ -156,22 +170,3 @@ async function requestModels(endpoint, headers, timeoutMs) {
156
170
  clearTimeout(timer);
157
171
  }
158
172
  }
159
- function applyProxyEnv(proxy) {
160
- if (emptyProxy(proxy))
161
- return () => undefined;
162
- const next = buildProxyEnv(proxy);
163
- const keys = Object.keys(next);
164
- const backup = new Map();
165
- for (const key of keys) {
166
- backup.set(key, process.env[key]);
167
- process.env[key] = next[key];
168
- }
169
- return () => {
170
- for (const [key, value] of backup) {
171
- if (value === undefined)
172
- delete process.env[key];
173
- else
174
- process.env[key] = value;
175
- }
176
- };
177
- }
@@ -8,39 +8,23 @@ export const PROXY_ENV_KEYS = [
8
8
  "all_proxy",
9
9
  ];
10
10
  /**
11
- * Build proxy env vars for injection into tool configs.
12
- * Prefer explicit http/https; when only `all` is set (e.g. socks5h),
13
- * set ALL_PROXY (and lowercase) and also mirror to HTTP(S)_PROXY
14
- * so runtimes that only read those still attempt the proxy URL.
11
+ * Build proxy env vars for injection into tool configs. A single proxy URL is
12
+ * applied to all traffic, so HTTP_PROXY, HTTPS_PROXY and ALL_PROXY (plus their
13
+ * lowercase forms) are all set to that URL. SOCKS URLs are honored by runtimes
14
+ * that read these variables.
15
15
  */
16
16
  export function buildProxyEnv(proxy) {
17
17
  if (emptyProxy(proxy))
18
18
  return {};
19
- const env = {};
20
- const http = proxy.http?.trim();
21
- const https = proxy.https?.trim();
22
- const all = proxy.all?.trim();
23
- if (all) {
24
- env.ALL_PROXY = all;
25
- env.all_proxy = all;
26
- }
27
- if (http) {
28
- env.HTTP_PROXY = http;
29
- env.http_proxy = http;
30
- }
31
- else if (all) {
32
- env.HTTP_PROXY = all;
33
- env.http_proxy = all;
34
- }
35
- if (https) {
36
- env.HTTPS_PROXY = https;
37
- env.https_proxy = https;
38
- }
39
- else if (all) {
40
- env.HTTPS_PROXY = all;
41
- env.https_proxy = all;
42
- }
43
- return env;
19
+ const url = proxy.trim();
20
+ return {
21
+ HTTP_PROXY: url,
22
+ HTTPS_PROXY: url,
23
+ ALL_PROXY: url,
24
+ http_proxy: url,
25
+ https_proxy: url,
26
+ all_proxy: url,
27
+ };
44
28
  }
45
29
  export function clearProxyEnvKeys(env) {
46
30
  for (const key of PROXY_ENV_KEYS) {
@@ -57,12 +41,5 @@ export function applyProxyToEnvRecord(env, proxy) {
57
41
  export function formatProxySummary(proxy) {
58
42
  if (emptyProxy(proxy))
59
43
  return "(none)";
60
- const parts = [];
61
- if (proxy?.http)
62
- parts.push(`http=${proxy.http}`);
63
- if (proxy?.https)
64
- parts.push(`https=${proxy.https}`);
65
- if (proxy?.all)
66
- parts.push(`all=${proxy.all}`);
67
- return parts.join(", ");
44
+ return proxy.trim();
68
45
  }
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const packageJsonPath = resolve(__dirname, "../../package.json");
6
+ export function getVersion() {
7
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
8
+ return pkg.version ?? "unknown";
9
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@nvae/llmswitch",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI to switch LLM providers and models for Claude Code, Codex, and OpenCode",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "llms": "dist/index.js",
8
- "llm-switch": "dist/index.js"
8
+ "llm-switch": "dist/index.js",
9
+ "llmswitch": "dist/index.js"
9
10
  },
10
11
  "files": [
11
12
  "dist",
@@ -39,7 +40,9 @@
39
40
  "dependencies": {
40
41
  "@clack/prompts": "^0.11.0",
41
42
  "commander": "^14.0.0",
42
- "smol-toml": "^1.4.2"
43
+ "https-proxy-agent": "7.0.6",
44
+ "smol-toml": "^1.4.2",
45
+ "socks-proxy-agent": "8.0.5"
43
46
  },
44
47
  "devDependencies": {
45
48
  "@types/bun": "^1.2.19",