@nvae/llmswitch 0.4.0 → 0.6.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.
@@ -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)) {
@@ -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
+ }
@@ -104,11 +104,17 @@ export async function fetchModelList(options) {
104
104
  if (!baseUrl?.trim()) {
105
105
  throw new Error("Base URL 为空,无法拉取模型列表");
106
106
  }
107
- if (!apiKey?.trim()) {
108
- throw new Error("API Key 为空,无法拉取模型列表");
109
- }
110
107
  const effectiveBaseUrl = normalizeBaseUrlForFormat(apiFormat, baseUrl);
111
108
  const endpoints = modelListEndpoints(effectiveBaseUrl);
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
+ }
112
118
  const headers = buildModelsRequestHeaders(apiFormat, apiKey.trim(), customHeaders);
113
119
  const errors = [];
114
120
  for (const endpoint of endpoints) {
@@ -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,10 +1,11 @@
1
1
  {
2
2
  "name": "@nvae/llmswitch",
3
- "version": "0.4.0",
3
+ "version": "0.6.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
9
  "llmswitch": "dist/index.js"
9
10
  },
10
11
  "files": [