@nvae/llmswitch 0.4.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.
- package/README.md +111 -197
- package/dist/cli.js +7 -1
- package/dist/commands/bridge-cmd.js +2 -2
- package/dist/commands/home-cmd.js +8 -0
- package/dist/commands/launch-cmd.js +18 -0
- package/dist/commands/launch.js +2 -2
- package/dist/commands/prompts.js +110 -65
- package/dist/commands/setup-cmd.js +175 -0
- package/dist/commands/tool.js +18 -33
- package/dist/index.js +0 -0
- package/dist/presets/index.js +10 -1
- package/dist/store/profiles.js +38 -0
- package/dist/utils/detect-format.js +178 -0
- package/dist/utils/fetch-models.js +9 -3
- package/dist/utils/version.js +9 -0
- package/package.json +2 -1
|
@@ -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.
|
|
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
9
|
"llmswitch": "dist/index.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|