@nvae/llmswitch 1.2.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.
@@ -1,5 +1,5 @@
1
1
  import { Option } from "commander";
2
- import { cancel, confirm, isCancel, password, select, text } from "@clack/prompts";
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
- cancel(message);
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
- console.log("日期 请求数 输入tokens 输出tokens Key 供应商 模型");
227
- for (const row of rows) {
228
- console.log(`${row.day} ${String(row.requests).padStart(5)} ${String(row.inputTokens).padStart(10)} ${String(row.outputTokens).padStart(10)} ${row.key.padEnd(8)} ${row.provider.padEnd(12)} ${row.model}`);
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.alive ? "运行中" : data.reachable ? "端口被占用(非本网关)" : "未运行"}`);
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}),并发 ${stats.activeConnections}/${stats.maxConcurrency}`);
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 && item.pathPrefix !== "v1" ? ` path=${item.pathPrefix || "(直连)"}` : ""}${item.headerNames?.length ? ` headers=${item.headerNames.join("/")}` : ""}`);
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 ?? (await promptText("API 地址(base URL)"));
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 ?? (await promptSecret("API Key(本地上游可留空)"));
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(JSON.stringify(publicKeyView(updated), null, 2));
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
- const index = entry.indexOf("/");
922
- if (index <= 0)
923
- return { provider: entry };
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
- .action(() => {
994
+ .option("--json", "JSON 输出")
995
+ .action((opts) => {
956
996
  const config = readGatewayConfig();
957
997
  const limits = parseBridgeRuntimeLimits();
958
- console.log(JSON.stringify({ config, runtimeLimits: limits }, null, 2));
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(JSON.stringify(readGatewayConfig(), null, 2));
1081
+ console.log("已更新网关配置。当前生效值:llms gateway config show");
1013
1082
  });
1014
1083
  }
1015
1084
  // --- prompts ----------------------------------------------------------------
1016
- async function promptText(message) {
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.action(async () => {
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;
@@ -26,29 +26,52 @@ export async function launchTool(options) {
26
26
  model: options.model,
27
27
  profile: options.profile,
28
28
  });
29
- if (profile.models.default !== model) {
30
- if (!profile.models.list.includes(model)) {
31
- profile.models.list.push(model);
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
- else if (!profile.models.list.includes(model)) {
38
- profile.models.list.push(model);
39
- saveProfile(tool, profile);
40
- profile = requireProfile(tool, profile.name);
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
- const result = await applyProfile(tool, profile);
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
  };
@@ -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
- modelMeta: collectModelMeta(await metaPromise, list),
506
- };
528
+ }, fixedSmallFor(input), await metaPromise);
507
529
  }
508
530
  const catalog = await metaPromise;
509
531
  const fetched = await tryFetchModels(input);
510
- if (fetched && fetched.models.length > 0) {
511
- const selected = await selectModelsFromFetched(fetched.models, input, catalog);
512
- return { ...selected, resolvedBaseUrl: fetched.resolvedBaseUrl };
513
- }
514
- return manualModelsEntry(input, catalog);
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.