@nvae/llmswitch 0.2.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/LICENSE +21 -0
- package/README.md +303 -0
- package/dist/adapters/claude.js +117 -0
- package/dist/adapters/codex.js +229 -0
- package/dist/adapters/index.js +33 -0
- package/dist/adapters/merge.js +21 -0
- package/dist/adapters/opencode.js +162 -0
- package/dist/bridge/anthropic-translate-request.js +226 -0
- package/dist/bridge/anthropic-translate-response.js +265 -0
- package/dist/bridge/manager.js +240 -0
- package/dist/bridge/server.js +487 -0
- package/dist/bridge/state.js +125 -0
- package/dist/bridge/translate-request.js +385 -0
- package/dist/bridge/translate-response.js +509 -0
- package/dist/bridge/types.js +8 -0
- package/dist/cli.js +48 -0
- package/dist/commands/bridge-cmd.js +113 -0
- package/dist/commands/launch-cmd.js +83 -0
- package/dist/commands/launch.js +175 -0
- package/dist/commands/prompts.js +595 -0
- package/dist/commands/tool.js +380 -0
- package/dist/formats/compatibility.js +33 -0
- package/dist/index.js +3 -0
- package/dist/presets/index.js +40 -0
- package/dist/store/profiles.js +202 -0
- package/dist/types.js +17 -0
- package/dist/utils/base-url.js +40 -0
- package/dist/utils/fetch-models.js +177 -0
- package/dist/utils/fs.js +40 -0
- package/dist/utils/paths.js +67 -0
- package/dist/utils/proxy.js +68 -0
- package/package.json +49 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { isApiFormat } from "../types.js";
|
|
3
|
+
import { formatLabel, supportedFormats } from "../formats/compatibility.js";
|
|
4
|
+
import { getPreset, presetsForTool } from "../presets/index.js";
|
|
5
|
+
import { assertValidProfileName, profileExists, saveProfile, } from "../store/profiles.js";
|
|
6
|
+
import { fetchModelList, preferResolvedBaseUrl, } from "../utils/fetch-models.js";
|
|
7
|
+
import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
|
|
8
|
+
import { maskSecret } from "../utils/fs.js";
|
|
9
|
+
export function isCancel(value) {
|
|
10
|
+
return p.isCancel(value);
|
|
11
|
+
}
|
|
12
|
+
export function exitOnCancel(value) {
|
|
13
|
+
if (p.isCancel(value)) {
|
|
14
|
+
p.cancel("已取消");
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Safely read clack text result (guards against undefined / cancel). */
|
|
19
|
+
export function readPromptText(value) {
|
|
20
|
+
if (p.isCancel(value)) {
|
|
21
|
+
p.cancel("已取消");
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
if (value == null)
|
|
25
|
+
return "";
|
|
26
|
+
return String(value).trim();
|
|
27
|
+
}
|
|
28
|
+
export async function promptText(options) {
|
|
29
|
+
const v = await p.text({
|
|
30
|
+
message: options.message,
|
|
31
|
+
placeholder: options.placeholder,
|
|
32
|
+
initialValue: options.initialValue ?? "",
|
|
33
|
+
validate: options.validate,
|
|
34
|
+
});
|
|
35
|
+
return readPromptText(v);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Codex-aligned upstream picker for OpenAI-compatible endpoints.
|
|
39
|
+
* Default: Chat Completions (+ local Responses bridge on Codex).
|
|
40
|
+
*/
|
|
41
|
+
export async function promptOpenAiCompatibleUpstream(tool, current) {
|
|
42
|
+
const allowed = supportedFormats(tool);
|
|
43
|
+
const options = [];
|
|
44
|
+
if (allowed.includes("openai-chat")) {
|
|
45
|
+
options.push({
|
|
46
|
+
value: "chat",
|
|
47
|
+
label: "Chat Completions(/v1/chat/completions)",
|
|
48
|
+
hint: tool === "codex"
|
|
49
|
+
? "默认 · 经本地桥兼容 Responses"
|
|
50
|
+
: tool === "claude"
|
|
51
|
+
? "默认 · 经本地桥转为 Anthropic Messages"
|
|
52
|
+
: "默认",
|
|
53
|
+
});
|
|
54
|
+
if (tool === "codex") {
|
|
55
|
+
options.push({
|
|
56
|
+
value: "completions",
|
|
57
|
+
label: "Completions(/v1/completions)",
|
|
58
|
+
hint: "经本地桥 · 旧式补全",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (allowed.includes("openai-responses")) {
|
|
63
|
+
options.push({
|
|
64
|
+
value: "responses",
|
|
65
|
+
label: "OpenAI Responses(/v1/responses)",
|
|
66
|
+
hint: "原生,不经桥",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (options.length === 0) {
|
|
70
|
+
throw new Error(`${tool} 不支持 OpenAI 兼容上游`);
|
|
71
|
+
}
|
|
72
|
+
let initial = "chat";
|
|
73
|
+
if (current?.apiFormat === "openai-responses") {
|
|
74
|
+
initial = "responses";
|
|
75
|
+
}
|
|
76
|
+
else if (current?.bridgeMode === "completions") {
|
|
77
|
+
initial = "completions";
|
|
78
|
+
}
|
|
79
|
+
else if (current?.apiFormat === "openai-chat" || !current) {
|
|
80
|
+
initial = options.some((o) => o.value === "chat") ? "chat" : options[0].value;
|
|
81
|
+
}
|
|
82
|
+
if (!options.some((o) => o.value === initial)) {
|
|
83
|
+
initial = options[0].value;
|
|
84
|
+
}
|
|
85
|
+
const selected = await p.select({
|
|
86
|
+
message: "上游接口类型",
|
|
87
|
+
options,
|
|
88
|
+
initialValue: initial,
|
|
89
|
+
});
|
|
90
|
+
exitOnCancel(selected);
|
|
91
|
+
if (selected === "responses") {
|
|
92
|
+
return { apiFormat: "openai-responses" };
|
|
93
|
+
}
|
|
94
|
+
if (selected === "completions") {
|
|
95
|
+
return { apiFormat: "openai-chat", bridgeMode: "completions" };
|
|
96
|
+
}
|
|
97
|
+
return { apiFormat: "openai-chat", bridgeMode: "chat" };
|
|
98
|
+
}
|
|
99
|
+
/** Edit-time format picker aligned with Codex custom flow. */
|
|
100
|
+
async function promptEditUpstream(tool, current) {
|
|
101
|
+
const allowed = supportedFormats(tool);
|
|
102
|
+
if (allowed.length === 1) {
|
|
103
|
+
return { apiFormat: allowed[0] };
|
|
104
|
+
}
|
|
105
|
+
// Claude-only anthropic already handled above.
|
|
106
|
+
// When OpenAI-compatible formats exist, use the same menu as custom add.
|
|
107
|
+
const openAiOnly = allowed.every((f) => f === "openai-chat" || f === "openai-responses");
|
|
108
|
+
if (openAiOnly) {
|
|
109
|
+
return promptOpenAiCompatibleUpstream(tool, {
|
|
110
|
+
apiFormat: current.apiFormat,
|
|
111
|
+
bridgeMode: current.bridgeMode,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const options = [];
|
|
115
|
+
if (allowed.includes("anthropic")) {
|
|
116
|
+
options.push({
|
|
117
|
+
value: "anthropic",
|
|
118
|
+
label: "Anthropic Messages",
|
|
119
|
+
hint: "Claude 兼容",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (allowed.includes("openai-chat")) {
|
|
123
|
+
options.push({
|
|
124
|
+
value: "openai-chat",
|
|
125
|
+
label: "Chat Completions(/v1/chat/completions)",
|
|
126
|
+
hint: tool === "claude"
|
|
127
|
+
? "经本地桥转为 Anthropic Messages"
|
|
128
|
+
: "OpenAI 兼容",
|
|
129
|
+
});
|
|
130
|
+
if (tool === "codex") {
|
|
131
|
+
options.push({
|
|
132
|
+
value: "completions",
|
|
133
|
+
label: "Completions(/v1/completions)",
|
|
134
|
+
hint: "经本地桥 · 旧式补全",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (allowed.includes("openai-responses")) {
|
|
139
|
+
options.push({
|
|
140
|
+
value: "openai-responses",
|
|
141
|
+
label: "OpenAI Responses(/v1/responses)",
|
|
142
|
+
hint: "原生,不经桥",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
let initial = current.apiFormat;
|
|
146
|
+
if (current.apiFormat === "openai-chat" &&
|
|
147
|
+
current.bridgeMode === "completions" &&
|
|
148
|
+
options.some((o) => o.value === "completions")) {
|
|
149
|
+
initial = "completions";
|
|
150
|
+
}
|
|
151
|
+
const picked = await p.select({
|
|
152
|
+
message: "上游接口类型",
|
|
153
|
+
options,
|
|
154
|
+
initialValue: options.some((o) => o.value === initial)
|
|
155
|
+
? initial
|
|
156
|
+
: options[0].value,
|
|
157
|
+
});
|
|
158
|
+
exitOnCancel(picked);
|
|
159
|
+
if (picked === "completions") {
|
|
160
|
+
return { apiFormat: "openai-chat", bridgeMode: "completions" };
|
|
161
|
+
}
|
|
162
|
+
if (picked === "openai-chat") {
|
|
163
|
+
return {
|
|
164
|
+
apiFormat: "openai-chat",
|
|
165
|
+
bridgeMode: tool === "codex" ? "chat" : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return { apiFormat: picked };
|
|
169
|
+
}
|
|
170
|
+
export async function promptProfileDraft(tool, partial = {}) {
|
|
171
|
+
p.intro(`为 ${tool} 添加供应商配置`);
|
|
172
|
+
const presets = presetsForTool(tool);
|
|
173
|
+
if (presets.length === 0) {
|
|
174
|
+
throw new Error(`${tool} 没有可用的预设模版`);
|
|
175
|
+
}
|
|
176
|
+
let presetId = partial.preset;
|
|
177
|
+
if (!presetId) {
|
|
178
|
+
const selected = await p.select({
|
|
179
|
+
message: "选择预设模版",
|
|
180
|
+
options: presets.map((preset) => ({
|
|
181
|
+
value: preset.id,
|
|
182
|
+
label: preset.displayName,
|
|
183
|
+
hint: preset.id === "custom"
|
|
184
|
+
? tool === "claude"
|
|
185
|
+
? "OpenAI 兼容 · 经本地桥转为 Anthropic"
|
|
186
|
+
: tool === "codex"
|
|
187
|
+
? "OpenAI 兼容 · 默认可经本地桥"
|
|
188
|
+
: "OpenAI 兼容"
|
|
189
|
+
: `${formatLabel(preset.apiFormat)} · ${preset.baseUrl}`,
|
|
190
|
+
})),
|
|
191
|
+
});
|
|
192
|
+
exitOnCancel(selected);
|
|
193
|
+
presetId = selected;
|
|
194
|
+
}
|
|
195
|
+
const preset = getPreset(presetId);
|
|
196
|
+
if (!preset || !presets.some((item) => item.id === preset.id)) {
|
|
197
|
+
throw new Error(`预设「${presetId}」对 ${tool} 不可用。可选:${presets.map((item) => item.id).join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
let name = partial.name;
|
|
200
|
+
if (!name) {
|
|
201
|
+
const v = await p.text({
|
|
202
|
+
message: "Profile 名称(用于命令行引用)",
|
|
203
|
+
placeholder: preset.id === "custom" ? "my-provider" : preset.id,
|
|
204
|
+
validate: (val) => {
|
|
205
|
+
if (!val?.trim())
|
|
206
|
+
return "名称不能为空";
|
|
207
|
+
try {
|
|
208
|
+
assertValidProfileName(val.trim());
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
return e instanceof Error ? e.message : "名称无效";
|
|
212
|
+
}
|
|
213
|
+
if (profileExists(tool, val.trim())) {
|
|
214
|
+
return `「${val.trim()}」已存在`;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
exitOnCancel(v);
|
|
219
|
+
name = v.trim();
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
assertValidProfileName(name);
|
|
223
|
+
if (profileExists(tool, name)) {
|
|
224
|
+
throw new Error(`「${name}」已存在`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let displayName = partial.displayName;
|
|
228
|
+
if (!displayName) {
|
|
229
|
+
const v = await p.text({
|
|
230
|
+
message: "显示名称",
|
|
231
|
+
initialValue: preset.id === "custom" ? name : preset.displayName,
|
|
232
|
+
});
|
|
233
|
+
exitOnCancel(v);
|
|
234
|
+
displayName = v.trim() || name;
|
|
235
|
+
}
|
|
236
|
+
let apiFormat;
|
|
237
|
+
let bridgeMode;
|
|
238
|
+
if (partial.apiFormat) {
|
|
239
|
+
apiFormat = partial.apiFormat;
|
|
240
|
+
bridgeMode = partial.bridgeMode;
|
|
241
|
+
}
|
|
242
|
+
else if (preset.id === "custom") {
|
|
243
|
+
if (tool === "claude") {
|
|
244
|
+
// Claude custom: Chat Completions only, translated via local bridge.
|
|
245
|
+
apiFormat = "openai-chat";
|
|
246
|
+
bridgeMode = undefined;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const upstream = await promptOpenAiCompatibleUpstream(tool, {
|
|
250
|
+
apiFormat: "openai-chat",
|
|
251
|
+
bridgeMode: "chat",
|
|
252
|
+
});
|
|
253
|
+
apiFormat = upstream.apiFormat;
|
|
254
|
+
bridgeMode = upstream.bridgeMode;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
apiFormat = preset.apiFormat;
|
|
259
|
+
bridgeMode = undefined;
|
|
260
|
+
}
|
|
261
|
+
if (!isApiFormat(apiFormat) || !supportedFormats(tool).includes(apiFormat)) {
|
|
262
|
+
throw new Error(`${tool} 不支持格式 ${apiFormat}。可用:${supportedFormats(tool).join(", ")}`);
|
|
263
|
+
}
|
|
264
|
+
let baseUrl = partial.baseUrl;
|
|
265
|
+
if (!baseUrl) {
|
|
266
|
+
const v = await p.text({
|
|
267
|
+
message: "API Base URL",
|
|
268
|
+
initialValue: preset.baseUrl || "",
|
|
269
|
+
placeholder: preset.id === "custom"
|
|
270
|
+
? "https://api.example.com/v1(缺省会自动补全 /v1)"
|
|
271
|
+
: preset.baseUrl || "https://api.example.com",
|
|
272
|
+
validate: (val) => (!val?.trim() ? "Base URL 不能为空" : undefined),
|
|
273
|
+
});
|
|
274
|
+
exitOnCancel(v);
|
|
275
|
+
baseUrl = v.trim();
|
|
276
|
+
}
|
|
277
|
+
{
|
|
278
|
+
const before = baseUrl.trim().replace(/\/+$/, "");
|
|
279
|
+
const normalized = normalizeBaseUrlForFormat(apiFormat, baseUrl);
|
|
280
|
+
if (normalized !== before) {
|
|
281
|
+
p.log.info(`已自动将 Base URL 规范为 ${normalized}`);
|
|
282
|
+
}
|
|
283
|
+
baseUrl = normalized;
|
|
284
|
+
}
|
|
285
|
+
let apiKey = partial.apiKey;
|
|
286
|
+
if (apiKey === undefined) {
|
|
287
|
+
const v = await p.password({
|
|
288
|
+
message: "API Key(拉取模型列表需要;可留空稍后填写)",
|
|
289
|
+
});
|
|
290
|
+
if (p.isCancel(v)) {
|
|
291
|
+
p.cancel("已取消");
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
apiKey = v || "";
|
|
295
|
+
}
|
|
296
|
+
// Proxy before model fetch so listing can go through the same upstream proxy.
|
|
297
|
+
let proxyHttp = partial.proxyHttp;
|
|
298
|
+
let proxyHttps = partial.proxyHttps;
|
|
299
|
+
let proxyAll = partial.proxyAll;
|
|
300
|
+
if (proxyHttp === undefined &&
|
|
301
|
+
proxyHttps === undefined &&
|
|
302
|
+
proxyAll === undefined) {
|
|
303
|
+
const wantProxy = await p.confirm({
|
|
304
|
+
message: "是否配置上游代理?(拉取模型与后续工具请求均可走 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY)",
|
|
305
|
+
initialValue: false,
|
|
306
|
+
});
|
|
307
|
+
if (p.isCancel(wantProxy)) {
|
|
308
|
+
p.cancel("已取消");
|
|
309
|
+
process.exit(0);
|
|
310
|
+
}
|
|
311
|
+
if (wantProxy) {
|
|
312
|
+
proxyAll =
|
|
313
|
+
(await promptText({
|
|
314
|
+
message: "ALL_PROXY(如 socks5h://127.0.0.1:1080,可留空)",
|
|
315
|
+
placeholder: "socks5h://127.0.0.1:1080",
|
|
316
|
+
})) || undefined;
|
|
317
|
+
proxyHttp =
|
|
318
|
+
(await promptText({
|
|
319
|
+
message: "HTTP_PROXY(可留空)",
|
|
320
|
+
placeholder: "http://127.0.0.1:5112",
|
|
321
|
+
})) || undefined;
|
|
322
|
+
proxyHttps =
|
|
323
|
+
(await promptText({
|
|
324
|
+
message: "HTTPS_PROXY(可留空)",
|
|
325
|
+
placeholder: "http://127.0.0.1:5112",
|
|
326
|
+
})) || undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const proxy = buildProxyConfig({
|
|
330
|
+
http: proxyHttp,
|
|
331
|
+
https: proxyHttps,
|
|
332
|
+
all: proxyAll,
|
|
333
|
+
});
|
|
334
|
+
if (tool === "codex" && apiFormat === "openai-chat" && !bridgeMode) {
|
|
335
|
+
bridgeMode = "chat";
|
|
336
|
+
}
|
|
337
|
+
const resolved = await resolveModelsInteractive({
|
|
338
|
+
apiFormat,
|
|
339
|
+
baseUrl,
|
|
340
|
+
apiKey: apiKey || "",
|
|
341
|
+
proxy,
|
|
342
|
+
presetDefault: preset.defaultModel,
|
|
343
|
+
presetModels: preset.models,
|
|
344
|
+
fixedDefault: partial.model,
|
|
345
|
+
fixedList: partial.models,
|
|
346
|
+
});
|
|
347
|
+
const { defaultModel, modelList } = resolved;
|
|
348
|
+
if (resolved.resolvedBaseUrl && resolved.resolvedBaseUrl !== baseUrl) {
|
|
349
|
+
p.log.info(`已根据可用接口将 Base URL 规范为 ${resolved.resolvedBaseUrl}(原输入:${baseUrl})`);
|
|
350
|
+
baseUrl = resolved.resolvedBaseUrl;
|
|
351
|
+
}
|
|
352
|
+
const profile = {
|
|
353
|
+
name,
|
|
354
|
+
displayName,
|
|
355
|
+
apiFormat,
|
|
356
|
+
baseUrl,
|
|
357
|
+
apiKey: apiKey || "",
|
|
358
|
+
models: {
|
|
359
|
+
default: defaultModel,
|
|
360
|
+
list: Array.from(new Set(modelList)),
|
|
361
|
+
},
|
|
362
|
+
proxy,
|
|
363
|
+
bridgeMode: tool === "codex" && apiFormat === "openai-chat"
|
|
364
|
+
? bridgeMode || "chat"
|
|
365
|
+
: undefined,
|
|
366
|
+
headers: {},
|
|
367
|
+
updatedAt: new Date().toISOString(),
|
|
368
|
+
};
|
|
369
|
+
saveProfile(tool, profile);
|
|
370
|
+
p.log.success(`已保存供应商「${name}」`);
|
|
371
|
+
return profile;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Interactively edit connection settings for an existing profile.
|
|
375
|
+
* Does not change model list (use model / provider → 配置模型).
|
|
376
|
+
*/
|
|
377
|
+
export async function promptEditProfile(tool, current) {
|
|
378
|
+
p.log.step(`编辑 ${tool}/${current.name}`);
|
|
379
|
+
const displayName = (await promptText({
|
|
380
|
+
message: "显示名称",
|
|
381
|
+
initialValue: current.displayName,
|
|
382
|
+
})) || current.name;
|
|
383
|
+
const upstream = await promptEditUpstream(tool, current);
|
|
384
|
+
const apiFormat = upstream.apiFormat;
|
|
385
|
+
if (!isApiFormat(apiFormat) || !supportedFormats(tool).includes(apiFormat)) {
|
|
386
|
+
throw new Error(`${tool} 不支持格式 ${apiFormat}。可用:${supportedFormats(tool).join(", ")}`);
|
|
387
|
+
}
|
|
388
|
+
let baseUrl = await promptText({
|
|
389
|
+
message: "API Base URL",
|
|
390
|
+
initialValue: current.baseUrl,
|
|
391
|
+
validate: (v) => (!v?.trim() ? "不能为空" : undefined),
|
|
392
|
+
});
|
|
393
|
+
if (!baseUrl)
|
|
394
|
+
throw new Error("Base URL 不能为空");
|
|
395
|
+
{
|
|
396
|
+
const before = baseUrl.trim().replace(/\/+$/, "");
|
|
397
|
+
const normalized = normalizeBaseUrlForFormat(apiFormat, baseUrl);
|
|
398
|
+
if (normalized !== before) {
|
|
399
|
+
p.log.info(`已自动将 Base URL 规范为 ${normalized}`);
|
|
400
|
+
}
|
|
401
|
+
baseUrl = normalized;
|
|
402
|
+
}
|
|
403
|
+
const changeKey = await p.confirm({
|
|
404
|
+
message: `是否更新 API Key?(当前 ${maskSecret(current.apiKey)})`,
|
|
405
|
+
initialValue: false,
|
|
406
|
+
});
|
|
407
|
+
if (p.isCancel(changeKey)) {
|
|
408
|
+
p.cancel("已取消");
|
|
409
|
+
process.exit(0);
|
|
410
|
+
}
|
|
411
|
+
let apiKey = current.apiKey;
|
|
412
|
+
if (changeKey) {
|
|
413
|
+
const v = await p.password({ message: "新的 API Key(可留空)" });
|
|
414
|
+
if (p.isCancel(v)) {
|
|
415
|
+
p.cancel("已取消");
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
apiKey = v || "";
|
|
419
|
+
}
|
|
420
|
+
const http = await promptText({
|
|
421
|
+
message: "HTTP_PROXY(留空清除)",
|
|
422
|
+
initialValue: current.proxy?.http || "",
|
|
423
|
+
});
|
|
424
|
+
const https = await promptText({
|
|
425
|
+
message: "HTTPS_PROXY(留空清除)",
|
|
426
|
+
initialValue: current.proxy?.https || "",
|
|
427
|
+
});
|
|
428
|
+
const all = await promptText({
|
|
429
|
+
message: "ALL_PROXY(留空清除)",
|
|
430
|
+
initialValue: current.proxy?.all || "",
|
|
431
|
+
});
|
|
432
|
+
const proxy = buildProxyConfig({
|
|
433
|
+
http: http || undefined,
|
|
434
|
+
https: https || undefined,
|
|
435
|
+
all: all || undefined,
|
|
436
|
+
});
|
|
437
|
+
const next = {
|
|
438
|
+
...current,
|
|
439
|
+
displayName,
|
|
440
|
+
apiFormat,
|
|
441
|
+
baseUrl,
|
|
442
|
+
apiKey,
|
|
443
|
+
proxy,
|
|
444
|
+
bridgeMode: tool === "codex" && apiFormat === "openai-chat"
|
|
445
|
+
? upstream.bridgeMode || "chat"
|
|
446
|
+
: undefined,
|
|
447
|
+
};
|
|
448
|
+
saveProfile(tool, next);
|
|
449
|
+
p.log.success(`已更新「${current.name}」的连接信息`);
|
|
450
|
+
return next;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Fetch models from the provider API (when possible), then let the user
|
|
454
|
+
* pick a default + a saved list. Falls back to manual text entry.
|
|
455
|
+
*/
|
|
456
|
+
export async function resolveModelsInteractive(input) {
|
|
457
|
+
if (input.fixedDefault && input.fixedList?.length) {
|
|
458
|
+
const list = [...input.fixedList];
|
|
459
|
+
if (!list.includes(input.fixedDefault))
|
|
460
|
+
list.unshift(input.fixedDefault);
|
|
461
|
+
return { defaultModel: input.fixedDefault, modelList: list };
|
|
462
|
+
}
|
|
463
|
+
if (input.fixedDefault && !input.fixedList) {
|
|
464
|
+
const fetched = await tryFetchModels(input);
|
|
465
|
+
if (fetched?.models.length) {
|
|
466
|
+
const list = Array.from(new Set([input.fixedDefault, ...fetched.models]));
|
|
467
|
+
return {
|
|
468
|
+
defaultModel: input.fixedDefault,
|
|
469
|
+
modelList: list,
|
|
470
|
+
resolvedBaseUrl: fetched.resolvedBaseUrl,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
defaultModel: input.fixedDefault,
|
|
475
|
+
modelList: [input.fixedDefault],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const fetched = await tryFetchModels(input);
|
|
479
|
+
if (fetched && fetched.models.length > 0) {
|
|
480
|
+
const selected = await selectModelsFromFetched(fetched.models, input);
|
|
481
|
+
return { ...selected, resolvedBaseUrl: fetched.resolvedBaseUrl };
|
|
482
|
+
}
|
|
483
|
+
return manualModelsEntry(input);
|
|
484
|
+
}
|
|
485
|
+
async function selectModelsFromFetched(fetched, input) {
|
|
486
|
+
const preferredDefault = (input.preferredDefault && fetched.includes(input.preferredDefault)
|
|
487
|
+
? input.preferredDefault
|
|
488
|
+
: undefined) ||
|
|
489
|
+
(input.presetDefault && fetched.includes(input.presetDefault)
|
|
490
|
+
? input.presetDefault
|
|
491
|
+
: undefined) ||
|
|
492
|
+
fetched[0];
|
|
493
|
+
const defaultModel = await p.select({
|
|
494
|
+
message: `选择默认模型(已从接口获取 ${fetched.length} 个)`,
|
|
495
|
+
options: fetched.map((id) => ({
|
|
496
|
+
value: id,
|
|
497
|
+
label: id,
|
|
498
|
+
hint: id === input.preferredDefault ? "当前" : undefined,
|
|
499
|
+
})),
|
|
500
|
+
initialValue: preferredDefault,
|
|
501
|
+
});
|
|
502
|
+
exitOnCancel(defaultModel);
|
|
503
|
+
const preferredList = (input.preferredList || []).filter((m) => fetched.includes(m));
|
|
504
|
+
const presetList = (input.presetModels || []).filter((m) => fetched.includes(m));
|
|
505
|
+
const picked = await p.multiselect({
|
|
506
|
+
message: "选择要保存到 profile 的模型(空格选择,回车确认)",
|
|
507
|
+
options: fetched.map((id) => ({
|
|
508
|
+
value: id,
|
|
509
|
+
label: id,
|
|
510
|
+
hint: (input.preferredList || []).includes(id) ? "当前" : undefined,
|
|
511
|
+
})),
|
|
512
|
+
initialValues: Array.from(new Set([defaultModel, ...preferredList, ...presetList])),
|
|
513
|
+
required: true,
|
|
514
|
+
});
|
|
515
|
+
if (p.isCancel(picked)) {
|
|
516
|
+
p.cancel("已取消");
|
|
517
|
+
process.exit(0);
|
|
518
|
+
}
|
|
519
|
+
const modelList = Array.from(new Set([defaultModel, ...picked]));
|
|
520
|
+
return { defaultModel, modelList };
|
|
521
|
+
}
|
|
522
|
+
async function manualModelsEntry(input) {
|
|
523
|
+
p.log.warn("未能自动获取模型列表,改为手动输入。");
|
|
524
|
+
let defaultModel = input.fixedDefault || input.preferredDefault;
|
|
525
|
+
if (!defaultModel) {
|
|
526
|
+
defaultModel = await promptText({
|
|
527
|
+
message: "默认模型 ID",
|
|
528
|
+
initialValue: input.presetDefault || "",
|
|
529
|
+
validate: (val) => (!val?.trim() ? "模型不能为空" : undefined),
|
|
530
|
+
});
|
|
531
|
+
if (!defaultModel)
|
|
532
|
+
throw new Error("模型不能为空");
|
|
533
|
+
}
|
|
534
|
+
let modelList = input.fixedList || input.preferredList;
|
|
535
|
+
if (!modelList?.length) {
|
|
536
|
+
const initial = input.presetModels && input.presetModels.length > 0
|
|
537
|
+
? input.presetModels.join(", ")
|
|
538
|
+
: defaultModel;
|
|
539
|
+
const raw = await promptText({
|
|
540
|
+
message: "可用模型列表(逗号分隔)",
|
|
541
|
+
initialValue: initial,
|
|
542
|
+
});
|
|
543
|
+
modelList = raw
|
|
544
|
+
.split(",")
|
|
545
|
+
.map((s) => s.trim())
|
|
546
|
+
.filter(Boolean);
|
|
547
|
+
}
|
|
548
|
+
if (!modelList.includes(defaultModel)) {
|
|
549
|
+
modelList = [defaultModel, ...modelList];
|
|
550
|
+
}
|
|
551
|
+
return { defaultModel, modelList };
|
|
552
|
+
}
|
|
553
|
+
export async function tryFetchModels(input) {
|
|
554
|
+
if (!input.apiKey.trim()) {
|
|
555
|
+
p.log.info("未填写 API Key,跳过自动拉取模型列表。");
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
const spin = p.spinner();
|
|
559
|
+
spin.start("正在从接口拉取模型列表…");
|
|
560
|
+
try {
|
|
561
|
+
const result = await fetchModelList({
|
|
562
|
+
baseUrl: input.baseUrl,
|
|
563
|
+
apiKey: input.apiKey,
|
|
564
|
+
apiFormat: input.apiFormat,
|
|
565
|
+
proxy: input.proxy,
|
|
566
|
+
});
|
|
567
|
+
const resolvedBaseUrl = preferResolvedBaseUrl(input.baseUrl, result.resolvedBaseUrl);
|
|
568
|
+
const normalized = resolvedBaseUrl !== input.baseUrl.trim().replace(/\/+$/, "")
|
|
569
|
+
? resolvedBaseUrl
|
|
570
|
+
: undefined;
|
|
571
|
+
spin.stop(`已获取 ${result.models.length} 个模型(${result.endpoint})`);
|
|
572
|
+
return {
|
|
573
|
+
models: result.models,
|
|
574
|
+
resolvedBaseUrl: normalized,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
579
|
+
spin.stop("拉取模型列表失败");
|
|
580
|
+
p.log.error(msg);
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
export function buildProxyConfig(input) {
|
|
585
|
+
const proxy = {};
|
|
586
|
+
if (input.http?.trim())
|
|
587
|
+
proxy.http = input.http.trim();
|
|
588
|
+
if (input.https?.trim())
|
|
589
|
+
proxy.https = input.https.trim();
|
|
590
|
+
if (input.all?.trim())
|
|
591
|
+
proxy.all = input.all.trim();
|
|
592
|
+
if (!proxy.http && !proxy.https && !proxy.all)
|
|
593
|
+
return undefined;
|
|
594
|
+
return proxy;
|
|
595
|
+
}
|