@nvae/llmswitch 1.0.0 → 1.3.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 +52 -9
- package/dist/adapters/claude.js +10 -10
- package/dist/adapters/codex.js +68 -16
- package/dist/adapters/opencode.js +45 -25
- package/dist/bridge/manager.js +8 -1
- package/dist/bridge/responses-to-chat-response.js +1 -1
- package/dist/bridge/runtime.js +40 -2
- package/dist/bridge/server.js +71 -26
- package/dist/bridge/state.js +59 -27
- package/dist/bridge/translate-response.js +98 -0
- package/dist/bridge/transport.js +39 -8
- package/dist/cli.js +18 -6
- package/dist/commands/bridge-cmd.js +15 -8
- package/dist/commands/gateway-cmd.js +112 -31
- package/dist/commands/home-cmd.js +20 -2
- package/dist/commands/launch-cmd.js +4 -0
- package/dist/commands/launch.js +36 -13
- package/dist/commands/prompts.js +96 -13
- package/dist/commands/tool.js +298 -134
- package/dist/gateway/keys.js +11 -2
- package/dist/gateway/rate-limit.js +8 -104
- package/dist/gateway/router.js +7 -1
- package/dist/gateway/server.js +1 -22
- package/dist/gateway/store.js +37 -4
- package/dist/gateway/usage.js +24 -4
- package/dist/index.js +0 -0
- package/dist/store/profiles.js +54 -14
- package/dist/types.js +12 -0
- package/dist/utils/display.js +71 -0
- package/dist/utils/fetch-models.js +26 -12
- package/dist/utils/file-lock.js +149 -0
- package/dist/utils/fs.js +187 -9
- package/dist/utils/model-metadata.js +54 -1
- package/package.json +7 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Option } from "commander";
|
|
2
|
-
import {
|
|
2
|
+
import { confirm, isCancel, password, select, text } from "@clack/prompts";
|
|
3
3
|
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, watchFile, } from "node:fs";
|
|
4
4
|
import { isApiFormat } from "../types.js";
|
|
5
5
|
import { formatLabel } from "../formats/compatibility.js";
|
|
@@ -14,13 +14,32 @@ import { gatewayBaseUrl, gatewayRootUrl, readGatewayState } from "../gateway/sta
|
|
|
14
14
|
import { parseGatewayPort } from "../gateway/runtime.js";
|
|
15
15
|
import { createGatewayKey, deleteGatewayKey, listGatewayKeys, peekDailyQuota, peekRateLimit, publicKeyView, resetRateLimits, revokeGatewayKey, rotateGatewayKey, updateGatewayKey, } from "../gateway/keys.js";
|
|
16
16
|
import { parseBridgeRuntimeLimits } from "../bridge/runtime.js";
|
|
17
|
+
import { renderTable } from "../utils/display.js";
|
|
17
18
|
import { rotateGatewayLogIfNeeded } from "../gateway/manager.js";
|
|
18
19
|
import { deleteGatewayProvider, deleteGatewayRoute, importProvidersFromProfiles, listGatewayProviders, listGatewayRoutes, publicProviderView, readGatewayConfig, requireGatewayProvider, saveGatewayProvider, saveGatewayRoute, writeGatewayConfig, } from "../gateway/store.js";
|
|
19
|
-
import { listRoutableModelIds, listRoutableModels, resolveModelRoute } from "../gateway/router.js";
|
|
20
|
+
import { listRoutableModelIds, listRoutableModels, resolveModelRoute, splitQualified, } from "../gateway/router.js";
|
|
20
21
|
import { resetUsage, summarizeUsage } from "../gateway/usage.js";
|
|
21
22
|
import { DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, } from "../gateway/types.js";
|
|
23
|
+
/**
|
|
24
|
+
* `path=` marker for the provider list. `v1` is the default and stays implicit;
|
|
25
|
+
* an empty prefix means "hit baseUrl directly" and must be shown — the previous
|
|
26
|
+
* condition excluded the empty string, so that case silently rendered nothing.
|
|
27
|
+
*/
|
|
28
|
+
function formatPathPrefix(pathPrefix) {
|
|
29
|
+
if (pathPrefix === undefined || pathPrefix === "v1")
|
|
30
|
+
return "";
|
|
31
|
+
return pathPrefix === "" ? " path=(直连)" : ` path=${pathPrefix}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Abort a gateway command. Uses the same `错误:` prefix on stderr as the rest of
|
|
35
|
+
* the CLI (see cli.ts) instead of clack's boxed output, so piped consumers get a
|
|
36
|
+
* consistent, parseable line. Cancellations are printed as-is.
|
|
37
|
+
*/
|
|
22
38
|
function bail(message) {
|
|
23
|
-
|
|
39
|
+
if (message === "已取消")
|
|
40
|
+
console.error(message);
|
|
41
|
+
else
|
|
42
|
+
console.error(`错误:${message}`);
|
|
24
43
|
process.exit(1);
|
|
25
44
|
}
|
|
26
45
|
function formatDuration(totalSeconds) {
|
|
@@ -223,9 +242,17 @@ function registerUsageCommands(gateway) {
|
|
|
223
242
|
return;
|
|
224
243
|
}
|
|
225
244
|
console.log(`统计范围:最近 ${days} 天\n`);
|
|
226
|
-
|
|
227
|
-
for (const
|
|
228
|
-
|
|
245
|
+
// 中文表头占两列宽,用 padEnd 会错位,改按显示宽度排版。
|
|
246
|
+
for (const line of renderTable(rows, [
|
|
247
|
+
{ header: "日期", value: (r) => r.day },
|
|
248
|
+
{ header: "请求数", value: (r) => String(r.requests), align: "right" },
|
|
249
|
+
{ header: "输入 tokens", value: (r) => String(r.inputTokens), align: "right" },
|
|
250
|
+
{ header: "输出 tokens", value: (r) => String(r.outputTokens), align: "right" },
|
|
251
|
+
{ header: "Key", value: (r) => r.key },
|
|
252
|
+
{ header: "供应商", value: (r) => r.provider },
|
|
253
|
+
{ header: "模型", value: (r) => r.model },
|
|
254
|
+
])) {
|
|
255
|
+
console.log(line);
|
|
229
256
|
}
|
|
230
257
|
});
|
|
231
258
|
usage
|
|
@@ -293,6 +320,12 @@ function registerServerCommands(gateway) {
|
|
|
293
320
|
const data = {
|
|
294
321
|
alive: probe.healthy,
|
|
295
322
|
reachable: probe.reachable,
|
|
323
|
+
// 文本区区分三态,JSON 之前只有两个布尔,脚本无法还原「端口被占用」。
|
|
324
|
+
state: probe.healthy
|
|
325
|
+
? "running"
|
|
326
|
+
: probe.reachable
|
|
327
|
+
? "port_occupied"
|
|
328
|
+
: "stopped",
|
|
296
329
|
listener: state.listener,
|
|
297
330
|
rootUrl: gatewayRootUrl(state),
|
|
298
331
|
openaiBaseUrl: gatewayBaseUrl(state),
|
|
@@ -310,13 +343,18 @@ function registerServerCommands(gateway) {
|
|
|
310
343
|
console.log(JSON.stringify(data, null, 2));
|
|
311
344
|
return;
|
|
312
345
|
}
|
|
313
|
-
console.log(`状态:${data.
|
|
346
|
+
console.log(`状态:${data.state === "running"
|
|
347
|
+
? "运行中"
|
|
348
|
+
: data.state === "port_occupied"
|
|
349
|
+
? "端口被占用(非本网关)"
|
|
350
|
+
: "未运行"}`);
|
|
314
351
|
if (data.alive && probe.uptimeSeconds !== undefined) {
|
|
315
352
|
console.log(`已运行:${formatDuration(probe.uptimeSeconds)}`);
|
|
316
353
|
}
|
|
317
354
|
if (data.alive && probe.stats) {
|
|
318
355
|
const stats = probe.stats;
|
|
319
|
-
console.log(`请求:共 ${stats.requests} 次(4xx ${stats.errors4xx},5xx ${stats.errors5xx}
|
|
356
|
+
console.log(`请求:共 ${stats.requests} 次(4xx ${stats.errors4xx},5xx ${stats.errors5xx}),` +
|
|
357
|
+
`并发 ${stats.activeConnections}${stats.maxConcurrency > 0 ? `/${stats.maxConcurrency}` : "(不限)"}`);
|
|
320
358
|
}
|
|
321
359
|
console.log(`监听:${data.listener.bindHost}:${data.listener.port}${data.listener.allowRemote ? "(已对外暴露)" : "(仅本机)"}`);
|
|
322
360
|
console.log(`OpenAI base:${data.openaiBaseUrl}`);
|
|
@@ -397,7 +435,7 @@ function registerProviderCommands(gateway) {
|
|
|
397
435
|
return;
|
|
398
436
|
}
|
|
399
437
|
for (const item of providers) {
|
|
400
|
-
console.log(`${item.enabled ? "●" : "○"} ${item.name}(${item.displayName}) ${item.apiFormat} ${item.baseUrl} key=${item.apiKey} priority=${item.priority} models=${item.models.length}${item.pathPrefix
|
|
438
|
+
console.log(`${item.enabled ? "●" : "○"} ${item.name}(${item.displayName}) ${item.apiFormat} ${item.baseUrl} key=${item.apiKey} priority=${item.priority} models=${item.models.length}${formatPathPrefix(item.pathPrefix)}${item.headerNames?.length ? ` headers=${item.headerNames.join("/")}` : ""}`);
|
|
401
439
|
}
|
|
402
440
|
});
|
|
403
441
|
provider
|
|
@@ -416,10 +454,12 @@ function registerProviderCommands(gateway) {
|
|
|
416
454
|
.argParser((value, previous) => parseHeaderEntry(value, previous ?? {}))
|
|
417
455
|
.default({}))
|
|
418
456
|
.action(async (opts) => {
|
|
419
|
-
const baseUrl = opts.baseUrl ??
|
|
457
|
+
const baseUrl = opts.baseUrl ??
|
|
458
|
+
(await promptText("API 地址(base URL)", "请改用 --base-url <url>。"));
|
|
420
459
|
if (!baseUrl)
|
|
421
460
|
bail("已取消");
|
|
422
|
-
const apiKey = opts.apiKey ??
|
|
461
|
+
const apiKey = opts.apiKey ??
|
|
462
|
+
(await promptSecret("API Key(本地上游可留空)", "请改用 --api-key <key>。"));
|
|
423
463
|
let apiFormat;
|
|
424
464
|
if (opts.format) {
|
|
425
465
|
if (!isApiFormat(opts.format)) {
|
|
@@ -571,18 +611,22 @@ function registerProviderCommands(gateway) {
|
|
|
571
611
|
}
|
|
572
612
|
}
|
|
573
613
|
}
|
|
614
|
+
const models = result.modelsEndpoint;
|
|
615
|
+
const completion = result.completion;
|
|
616
|
+
// 退出码要在 JSON 分支之前决定,否则脚本用 --json 时拿不到失败信号。
|
|
617
|
+
const allOk = models?.ok && (!completion || completion.ok);
|
|
618
|
+
if (!allOk)
|
|
619
|
+
process.exitCode = 1;
|
|
574
620
|
if (opts.json) {
|
|
575
|
-
console.log(JSON.stringify(result, null, 2));
|
|
621
|
+
console.log(JSON.stringify({ ...result, ok: Boolean(allOk) }, null, 2));
|
|
576
622
|
return;
|
|
577
623
|
}
|
|
578
|
-
const models = result.modelsEndpoint;
|
|
579
624
|
if (models?.ok) {
|
|
580
625
|
console.log(`模型列表:OK(${models.count} 个,${result.modelsLatencyMs}ms)`);
|
|
581
626
|
}
|
|
582
627
|
else {
|
|
583
628
|
console.log(`模型列表:失败 — ${models?.error ?? "未知错误"}`);
|
|
584
629
|
}
|
|
585
|
-
const completion = result.completion;
|
|
586
630
|
if (completion) {
|
|
587
631
|
if (completion.ok) {
|
|
588
632
|
console.log(`补全请求:OK(HTTP ${completion.status},${completion.latencyMs}ms)`);
|
|
@@ -591,9 +635,6 @@ function registerProviderCommands(gateway) {
|
|
|
591
635
|
console.log(`补全请求:失败 — ${completion.error ?? `HTTP ${completion.status}`}`);
|
|
592
636
|
}
|
|
593
637
|
}
|
|
594
|
-
const allOk = models?.ok && (!completion || completion.ok);
|
|
595
|
-
if (!allOk)
|
|
596
|
-
process.exitCode = 1;
|
|
597
638
|
});
|
|
598
639
|
provider
|
|
599
640
|
.command("refresh-models")
|
|
@@ -809,6 +850,7 @@ function registerKeyCommands(gateway) {
|
|
|
809
850
|
.option("--models <list>", "限定可用模型,逗号分隔(覆盖)")
|
|
810
851
|
.option("--formats <list>", "限定可用接口格式:openai-chat,anthropic,openai-responses(覆盖)")
|
|
811
852
|
.option("--rate-limit <rpm>", "每分钟请求上限;-1 表示完全不限流,0 表示继承全局默认")
|
|
853
|
+
.option("--daily-requests <n>", "每日请求配额(UTC 日重置,0 表示不限)")
|
|
812
854
|
.option("--expires-in-days <n>", "新的有效期天数(从现在起算);0 表示永不过期")
|
|
813
855
|
.action((idOrName, opts) => {
|
|
814
856
|
const patch = {};
|
|
@@ -841,11 +883,11 @@ function registerKeyCommands(gateway) {
|
|
|
841
883
|
patch.expiresInDays = value;
|
|
842
884
|
}
|
|
843
885
|
if (!Object.keys(patch).length) {
|
|
844
|
-
bail("没有指定任何修改项;可用 --name/--providers/--models/--formats/--rate-limit/--expires-in-days");
|
|
886
|
+
bail("没有指定任何修改项;可用 --name/--providers/--models/--formats/--rate-limit/--daily-requests/--expires-in-days");
|
|
845
887
|
}
|
|
846
888
|
const updated = updateGatewayKey(idOrName, patch);
|
|
847
889
|
console.log(`已更新 ${updated.id}(${updated.name})`);
|
|
848
|
-
console.log(
|
|
890
|
+
console.log("当前作用域与限额:llms gateway key list");
|
|
849
891
|
});
|
|
850
892
|
key
|
|
851
893
|
.command("rotate")
|
|
@@ -918,13 +960,10 @@ function registerRouteCommands(gateway) {
|
|
|
918
960
|
.option("--fallback <list>", "fallback 列表,逗号分隔,支持 provider 或 provider/model")
|
|
919
961
|
.action((alias, opts) => {
|
|
920
962
|
const fallbacks = splitList(opts.fallback).map((entry) => {
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
return {
|
|
925
|
-
provider: entry.slice(0, index),
|
|
926
|
-
model: entry.slice(index + 1),
|
|
927
|
-
};
|
|
963
|
+
// 与 router 解析请求模型 id 的规则保持一致:provider/model 与
|
|
964
|
+
// provider:model 都认,否则用户按文档写 `provider:model` 会被静默错配。
|
|
965
|
+
const qualified = splitQualified(entry);
|
|
966
|
+
return qualified ? qualified : { provider: entry };
|
|
928
967
|
});
|
|
929
968
|
const saved = saveGatewayRoute({
|
|
930
969
|
alias,
|
|
@@ -952,10 +991,40 @@ function registerConfigCommands(gateway) {
|
|
|
952
991
|
config
|
|
953
992
|
.command("show", { isDefault: true })
|
|
954
993
|
.description("显示当前配置与生效的运行时限额")
|
|
955
|
-
.
|
|
994
|
+
.option("--json", "JSON 输出")
|
|
995
|
+
.action((opts) => {
|
|
956
996
|
const config = readGatewayConfig();
|
|
957
997
|
const limits = parseBridgeRuntimeLimits();
|
|
958
|
-
|
|
998
|
+
if (opts.json) {
|
|
999
|
+
console.log(JSON.stringify({ config, runtimeLimits: limits }, null, 2));
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
// 其他命令都是「文本为主,--json 可选」,这里以前只吐 JSON,风格不一致。
|
|
1003
|
+
console.log(`兜底供应商:${config.defaultProvider ?? "(未设置)"}`);
|
|
1004
|
+
console.log(`默认限流:${config.rateLimitPerMinute > 0 ? `${config.rateLimitPerMinute} 次/分钟` : "不限"}`);
|
|
1005
|
+
console.log(`Provider fallback:${config.fallback.enabled ? "启用" : "关闭"}` +
|
|
1006
|
+
`(最多尝试 ${config.fallback.maxAttempts} 个上游,触发状态码 ${config.fallback.retryStatuses.join("/") || "无"})`);
|
|
1007
|
+
console.log(`CORS 来源:${config.corsOrigins?.length ? config.corsOrigins.join(", ") : "(未开启)"}`);
|
|
1008
|
+
console.log("");
|
|
1009
|
+
console.log("运行时限额(环境变量可调,与 bridge 共用):");
|
|
1010
|
+
for (const line of renderTable([
|
|
1011
|
+
{
|
|
1012
|
+
k: "最大并发",
|
|
1013
|
+
v: limits.maxConcurrency > 0 ? String(limits.maxConcurrency) : "不限",
|
|
1014
|
+
env: "LLM_SWITCH_MAX_CONCURRENCY",
|
|
1015
|
+
},
|
|
1016
|
+
{ k: "请求体上限", v: `${limits.maxBodyBytes} B`, env: "LLM_SWITCH_MAX_BODY_BYTES" },
|
|
1017
|
+
{ k: "上游响应上限", v: `${limits.maxResponseBytes} B`, env: "LLM_SWITCH_MAX_RESPONSE_BYTES" },
|
|
1018
|
+
{ k: "连接超时", v: `${limits.connectTimeoutMs} ms`, env: "LLM_SWITCH_CONNECT_TIMEOUT_MS" },
|
|
1019
|
+
{ k: "流式空闲超时", v: `${limits.idleTimeoutMs} ms`, env: "LLM_SWITCH_IDLE_TIMEOUT_MS" },
|
|
1020
|
+
{ k: "单请求总超时", v: `${limits.totalTimeoutMs} ms`, env: "LLM_SWITCH_TOTAL_TIMEOUT_MS" },
|
|
1021
|
+
], [
|
|
1022
|
+
{ header: "项目", value: (r) => r.k },
|
|
1023
|
+
{ header: "当前值", value: (r) => r.v, align: "right" },
|
|
1024
|
+
{ header: "环境变量", value: (r) => r.env },
|
|
1025
|
+
])) {
|
|
1026
|
+
console.log(` ${line}`);
|
|
1027
|
+
}
|
|
959
1028
|
});
|
|
960
1029
|
config
|
|
961
1030
|
.command("set")
|
|
@@ -1009,23 +1078,35 @@ function registerConfigCommands(gateway) {
|
|
|
1009
1078
|
next.rateLimitPerMinute = value;
|
|
1010
1079
|
}
|
|
1011
1080
|
writeGatewayConfig(next);
|
|
1012
|
-
console.log(
|
|
1081
|
+
console.log("已更新网关配置。当前生效值:llms gateway config show");
|
|
1013
1082
|
});
|
|
1014
1083
|
}
|
|
1015
1084
|
// --- prompts ----------------------------------------------------------------
|
|
1016
|
-
|
|
1085
|
+
/**
|
|
1086
|
+
* clack 在非 TTY 下会永久等待输入,表现为「命令卡住」而不是报错。
|
|
1087
|
+
* 每个交互入口先在这里挡一次,并说明该用哪个参数改成非交互。
|
|
1088
|
+
*/
|
|
1089
|
+
function requireTty(what, hint) {
|
|
1090
|
+
if (process.stdin.isTTY)
|
|
1091
|
+
return;
|
|
1092
|
+
bail(`${what}需要交互式终端(当前 stdin 不是 TTY)。${hint}`);
|
|
1093
|
+
}
|
|
1094
|
+
async function promptText(message, hint) {
|
|
1095
|
+
requireTty(message, hint ?? "请在终端中运行,或改用对应命令行参数。");
|
|
1017
1096
|
const value = await text({ message });
|
|
1018
1097
|
if (isCancel(value))
|
|
1019
1098
|
bail("已取消");
|
|
1020
1099
|
return String(value ?? "").trim();
|
|
1021
1100
|
}
|
|
1022
|
-
async function promptSecret(message) {
|
|
1101
|
+
async function promptSecret(message, hint) {
|
|
1102
|
+
requireTty(message, hint ?? "请在终端中运行,或改用对应命令行参数。");
|
|
1023
1103
|
const value = await password({ message });
|
|
1024
1104
|
if (isCancel(value))
|
|
1025
1105
|
bail("已取消");
|
|
1026
1106
|
return String(value ?? "").trim();
|
|
1027
1107
|
}
|
|
1028
1108
|
async function promptFormat() {
|
|
1109
|
+
requireTty("接口类型选择", "自动探测失败,请改用 --format <openai-chat|anthropic|openai-responses>。");
|
|
1029
1110
|
const value = await select({
|
|
1030
1111
|
message: "无法自动识别接口类型,请选择",
|
|
1031
1112
|
options: [
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
import { TOOLS, isTool } from "../types.js";
|
|
1
2
|
import { pickSetupTool, runToolFlow } from "./setup-cmd.js";
|
|
2
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* 无子命令时:选择工具 → 连贯启动(未配置则自动引导)。
|
|
5
|
+
*
|
|
6
|
+
* 这里额外声明一个可选位置参数,专门用来接住不匹配任何子命令的输入。
|
|
7
|
+
* 若不声明,commander 会把 `llms nosuchcmd` 报成「too many arguments,
|
|
8
|
+
* expected 0 arguments」——既看不出是命令拼错,也无法给出可用命令提示。
|
|
9
|
+
*/
|
|
3
10
|
export function registerHomeCommand(program) {
|
|
4
|
-
program
|
|
11
|
+
program
|
|
12
|
+
.argument("[command]", `子命令或工具名:${TOOLS.join(" | ")}(省略则交互选择)`)
|
|
13
|
+
.action(async (commandArg) => {
|
|
14
|
+
if (commandArg) {
|
|
15
|
+
// 能走到这里说明它没匹配上任何已注册子命令。
|
|
16
|
+
if (!isTool(commandArg)) {
|
|
17
|
+
throw new Error(`未知命令「${commandArg}」。工具可选:${TOOLS.join("、")};` +
|
|
18
|
+
`完整命令列表:llms --help`);
|
|
19
|
+
}
|
|
20
|
+
await runToolFlow(commandArg);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
5
23
|
const tool = await pickSetupTool();
|
|
6
24
|
await runToolFlow(tool);
|
|
7
25
|
});
|
|
@@ -20,6 +20,7 @@ export function registerLaunchCommand(program) {
|
|
|
20
20
|
.option("-m, --model <id>", "要启用的模型 ID")
|
|
21
21
|
.option("-p, --profile <name>", "指定 profile(默认:包含该模型的 profile / 当前启用)")
|
|
22
22
|
.option("--print-only", "只写入配置,不启动 CLI")
|
|
23
|
+
.option("--save", "把本次模型存为该供应商的默认模型(默认不改动已保存的配置)")
|
|
23
24
|
.option("--dry-run", "只打印计划,不写配置、不启动")
|
|
24
25
|
.option("--json", "JSON 输出")
|
|
25
26
|
.action(async (toolArg, parts = [], opts) => {
|
|
@@ -59,6 +60,7 @@ export function registerLaunchCommand(program) {
|
|
|
59
60
|
binary: resolveBinary(toolArg),
|
|
60
61
|
args: passthrough,
|
|
61
62
|
dryRun: true,
|
|
63
|
+
saved: false,
|
|
62
64
|
};
|
|
63
65
|
if (opts.json) {
|
|
64
66
|
console.log(JSON.stringify(plan, null, 2));
|
|
@@ -77,6 +79,7 @@ export function registerLaunchCommand(program) {
|
|
|
77
79
|
profile: opts.profile,
|
|
78
80
|
args: passthrough,
|
|
79
81
|
printOnly: opts.printOnly,
|
|
82
|
+
save: opts.save,
|
|
80
83
|
});
|
|
81
84
|
if (opts.json) {
|
|
82
85
|
console.log(JSON.stringify({
|
|
@@ -87,6 +90,7 @@ export function registerLaunchCommand(program) {
|
|
|
87
90
|
args: plan.args,
|
|
88
91
|
configPath: plan.configPath,
|
|
89
92
|
applied: plan.applied,
|
|
93
|
+
saved: plan.saved,
|
|
90
94
|
printOnly: Boolean(opts.printOnly),
|
|
91
95
|
}, null, 2));
|
|
92
96
|
return;
|
package/dist/commands/launch.js
CHANGED
|
@@ -26,29 +26,52 @@ export async function launchTool(options) {
|
|
|
26
26
|
model: options.model,
|
|
27
27
|
profile: options.profile,
|
|
28
28
|
});
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
profile.models.default = model;
|
|
34
|
-
saveProfile(tool, profile);
|
|
35
|
-
profile = requireProfile(tool, profile.name);
|
|
29
|
+
// 模型名打错时上游只会返回一个含糊的 404,这里先把疑点点出来。
|
|
30
|
+
if (!profile.models.list.includes(model)) {
|
|
31
|
+
console.error(`注意:模型「${model}」不在 ${tool}/${profile.name} 的模型列表中,仍按原样发给上游。` +
|
|
32
|
+
`确认可用后可加 --save 存为该供应商的默认模型。`);
|
|
36
33
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
// 只有显式 --save 才把本次模型写回 profile;否则仅影响本次写入工具配置。
|
|
35
|
+
let saved = false;
|
|
36
|
+
if (options.save && profile.models.default !== model) {
|
|
37
|
+
const next = {
|
|
38
|
+
...profile,
|
|
39
|
+
models: {
|
|
40
|
+
...profile.models,
|
|
41
|
+
default: model,
|
|
42
|
+
list: profile.models.list.includes(model)
|
|
43
|
+
? profile.models.list
|
|
44
|
+
: [...profile.models.list, model],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
saveProfile(tool, next);
|
|
48
|
+
profile = requireProfile(tool, next.name);
|
|
49
|
+
saved = true;
|
|
41
50
|
}
|
|
42
|
-
|
|
51
|
+
// 传给 adapter 的是一份内存副本:本次用哪个模型就写哪个,但不落盘。
|
|
52
|
+
const effective = profile.models.default === model
|
|
53
|
+
? profile
|
|
54
|
+
: {
|
|
55
|
+
...profile,
|
|
56
|
+
models: {
|
|
57
|
+
...profile.models,
|
|
58
|
+
default: model,
|
|
59
|
+
list: profile.models.list.includes(model)
|
|
60
|
+
? profile.models.list
|
|
61
|
+
: [...profile.models.list, model],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const result = await applyProfile(tool, effective);
|
|
43
65
|
const binary = resolveBinary(tool);
|
|
44
66
|
const args = options.args ?? [];
|
|
45
67
|
const plan = {
|
|
46
68
|
tool,
|
|
47
|
-
profile,
|
|
69
|
+
profile: effective,
|
|
48
70
|
model,
|
|
49
71
|
binary,
|
|
50
72
|
args,
|
|
51
73
|
applied: true,
|
|
74
|
+
saved,
|
|
52
75
|
configPath: result.configPath,
|
|
53
76
|
restartHint: result.restartHint,
|
|
54
77
|
};
|
package/dist/commands/prompts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import { normalizeProxyValue } from "../types.js";
|
|
3
3
|
import { isApiFormat } from "../types.js";
|
|
4
|
+
import { supportsSmallModel } from "../types.js";
|
|
4
5
|
import { formatLabel, supportedFormats } from "../formats/compatibility.js";
|
|
5
6
|
import { getPreset, presetsForTool } from "../presets/index.js";
|
|
6
7
|
import { detectApiFormat } from "../utils/detect-format.js";
|
|
@@ -12,6 +13,20 @@ import { maskSecret } from "../utils/fs.js";
|
|
|
12
13
|
export function isCancel(value) {
|
|
13
14
|
return p.isCancel(value);
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Guard every interactive entry point.
|
|
18
|
+
*
|
|
19
|
+
* @clack/prompts silently waits forever when stdin is not a TTY (pipes, CI,
|
|
20
|
+
* `</dev/null`). The failure mode is "the command hangs", which is far worse
|
|
21
|
+
* than an error, so refuse up front and name the flags that make the command
|
|
22
|
+
* non-interactive.
|
|
23
|
+
*/
|
|
24
|
+
export function requireInteractive(what, hint) {
|
|
25
|
+
if (process.stdin.isTTY)
|
|
26
|
+
return;
|
|
27
|
+
throw new Error(`${what}需要交互式终端,但当前 stdin 不是 TTY。` +
|
|
28
|
+
(hint ? `${hint}` : "请在终端中直接运行,或改用带参数的非交互写法。"));
|
|
29
|
+
}
|
|
15
30
|
export function exitOnCancel(value) {
|
|
16
31
|
if (p.isCancel(value)) {
|
|
17
32
|
p.cancel("已取消");
|
|
@@ -213,6 +228,12 @@ export function formatProfileListLabel(profile, opts) {
|
|
|
213
228
|
return parts.length ? `${display}(${parts.join(" · ")})` : display;
|
|
214
229
|
}
|
|
215
230
|
export async function promptProfileDraft(tool, partial = {}) {
|
|
231
|
+
// 只有在还缺必填项时才需要 TTY;参数齐全的调用(provider add --base-url …)可非交互完成。
|
|
232
|
+
if (partial.baseUrl === undefined ||
|
|
233
|
+
partial.apiKey === undefined ||
|
|
234
|
+
partial.preset === undefined) {
|
|
235
|
+
requireInteractive("添加供应商", `请改用:llms ${tool} provider add --preset custom --base-url <url> --api-key <key> --model <id>。`);
|
|
236
|
+
}
|
|
216
237
|
p.intro(`为 ${tool} 添加供应商配置`);
|
|
217
238
|
const presets = presetsForTool(tool);
|
|
218
239
|
if (presets.length === 0) {
|
|
@@ -345,6 +366,7 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
345
366
|
bridgeMode = undefined;
|
|
346
367
|
}
|
|
347
368
|
else {
|
|
369
|
+
requireInteractive("手动选择接口格式", `自动探测失败,请改用 --format <${supportedFormats(tool).join("|")}> 明确指定。`);
|
|
348
370
|
const upstream = await promptOpenAiCompatibleUpstream(tool, {
|
|
349
371
|
apiFormat: "openai-chat",
|
|
350
372
|
bridgeMode: "chat",
|
|
@@ -377,6 +399,8 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
377
399
|
presetModels: preset.models,
|
|
378
400
|
fixedDefault: partial.model,
|
|
379
401
|
fixedList: partial.models,
|
|
402
|
+
supportsSmall: supportsSmallModel(tool),
|
|
403
|
+
fixedSmall: partial.smallModel,
|
|
380
404
|
});
|
|
381
405
|
const { defaultModel, modelList } = resolved;
|
|
382
406
|
if (resolved.resolvedBaseUrl && resolved.resolvedBaseUrl !== baseUrl) {
|
|
@@ -391,6 +415,7 @@ export async function promptProfileDraft(tool, partial = {}) {
|
|
|
391
415
|
apiKey: apiKey || "",
|
|
392
416
|
models: {
|
|
393
417
|
default: defaultModel,
|
|
418
|
+
smallModel: resolved.smallModel,
|
|
394
419
|
list: Array.from(new Set(modelList)),
|
|
395
420
|
meta: resolved.modelMeta,
|
|
396
421
|
},
|
|
@@ -474,6 +499,8 @@ export async function promptEditProfile(tool, current) {
|
|
|
474
499
|
p.log.success(`已更新「${current.name}」的连接信息`);
|
|
475
500
|
return next;
|
|
476
501
|
}
|
|
502
|
+
const NO_SMALL_MODEL = "__none__";
|
|
503
|
+
const MANUAL_MODEL = "__manual__";
|
|
477
504
|
/**
|
|
478
505
|
* Fetch models from the provider API (when possible), then let the user
|
|
479
506
|
* pick a default + a saved list. Falls back to manual text entry.
|
|
@@ -487,31 +514,87 @@ export async function resolveModelsInteractive(input) {
|
|
|
487
514
|
const list = [...input.fixedList];
|
|
488
515
|
if (!list.includes(input.fixedDefault))
|
|
489
516
|
list.unshift(input.fixedDefault);
|
|
490
|
-
return {
|
|
491
|
-
defaultModel: input.fixedDefault,
|
|
492
|
-
modelList: list,
|
|
493
|
-
modelMeta: collectModelMeta(await metaPromise, list),
|
|
494
|
-
};
|
|
517
|
+
return withSmallModel({ defaultModel: input.fixedDefault, modelList: list }, fixedSmallFor(input), await metaPromise);
|
|
495
518
|
}
|
|
496
519
|
if (input.fixedDefault && !input.fixedList) {
|
|
497
520
|
const fetched = await tryFetchModels(input);
|
|
498
521
|
const list = fetched?.models.length
|
|
499
522
|
? Array.from(new Set([input.fixedDefault, ...fetched.models]))
|
|
500
523
|
: [input.fixedDefault];
|
|
501
|
-
return {
|
|
524
|
+
return withSmallModel({
|
|
502
525
|
defaultModel: input.fixedDefault,
|
|
503
526
|
modelList: list,
|
|
504
527
|
resolvedBaseUrl: fetched?.resolvedBaseUrl,
|
|
505
|
-
|
|
506
|
-
};
|
|
528
|
+
}, fixedSmallFor(input), await metaPromise);
|
|
507
529
|
}
|
|
508
530
|
const catalog = await metaPromise;
|
|
509
531
|
const fetched = await tryFetchModels(input);
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
532
|
+
const selected = fetched && fetched.models.length > 0
|
|
533
|
+
? await selectModelsFromFetched(fetched.models, input, catalog)
|
|
534
|
+
: await manualModelsEntry(input, catalog);
|
|
535
|
+
const smallModel = input.supportsSmall && input.fixedSmall === undefined
|
|
536
|
+
? await promptSmallModel(input, selected, catalog)
|
|
537
|
+
: fixedSmallFor(input);
|
|
538
|
+
return withSmallModel({ ...selected, resolvedBaseUrl: fetched?.resolvedBaseUrl }, smallModel, catalog);
|
|
539
|
+
}
|
|
540
|
+
/** Honor an explicit --small flag; ignored for tools without the capability. */
|
|
541
|
+
function fixedSmallFor(input) {
|
|
542
|
+
if (!input.supportsSmall)
|
|
543
|
+
return undefined;
|
|
544
|
+
return input.fixedSmall?.trim() || undefined;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Fold the lightweight model into the result: it must be part of the saved
|
|
548
|
+
* model list so adapters can declare it (OpenCode needs every referenced model
|
|
549
|
+
* defined inside the provider block).
|
|
550
|
+
*/
|
|
551
|
+
function withSmallModel(base, smallModel, catalog) {
|
|
552
|
+
const modelList = smallModel && !base.modelList.includes(smallModel)
|
|
553
|
+
? [...base.modelList, smallModel]
|
|
554
|
+
: base.modelList;
|
|
555
|
+
return {
|
|
556
|
+
...base,
|
|
557
|
+
modelList,
|
|
558
|
+
smallModel,
|
|
559
|
+
modelMeta: collectModelMeta(catalog, modelList),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Optional step: pick a cheaper model for lightweight tasks (title generation,
|
|
564
|
+
* summaries). Only asked for tools that can act on it.
|
|
565
|
+
*/
|
|
566
|
+
async function promptSmallModel(input, selected, catalog) {
|
|
567
|
+
const candidates = selected.modelList.filter((id) => id !== selected.defaultModel);
|
|
568
|
+
const preferred = input.preferredSmall && candidates.includes(input.preferredSmall)
|
|
569
|
+
? input.preferredSmall
|
|
570
|
+
: undefined;
|
|
571
|
+
const picked = await p.select({
|
|
572
|
+
message: "选择轻量小模型(用于标题生成等低成本任务)",
|
|
573
|
+
options: [
|
|
574
|
+
{
|
|
575
|
+
value: NO_SMALL_MODEL,
|
|
576
|
+
label: "不设置",
|
|
577
|
+
hint: `沿用默认模型 ${selected.defaultModel}`,
|
|
578
|
+
},
|
|
579
|
+
...candidates.map((id) => ({
|
|
580
|
+
value: id,
|
|
581
|
+
label: id,
|
|
582
|
+
hint: joinHints(id === input.preferredSmall ? "当前" : undefined, metadataHint(id, catalog)),
|
|
583
|
+
})),
|
|
584
|
+
{ value: MANUAL_MODEL, label: "手动输入模型 ID" },
|
|
585
|
+
],
|
|
586
|
+
initialValue: preferred ?? NO_SMALL_MODEL,
|
|
587
|
+
});
|
|
588
|
+
exitOnCancel(picked);
|
|
589
|
+
if (picked === NO_SMALL_MODEL)
|
|
590
|
+
return undefined;
|
|
591
|
+
if (picked !== MANUAL_MODEL)
|
|
592
|
+
return picked;
|
|
593
|
+
const manual = await promptText({
|
|
594
|
+
message: "小模型 ID(留空表示不设置)",
|
|
595
|
+
initialValue: input.preferredSmall || "",
|
|
596
|
+
});
|
|
597
|
+
return manual.trim() || undefined;
|
|
515
598
|
}
|
|
516
599
|
/**
|
|
517
600
|
* Best-effort metadata fetch from models.lonae.com.
|