@fanchaozz/provider-manager 0.1.1 → 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/ui.ts CHANGED
@@ -14,7 +14,6 @@ import {
14
14
  addProviderFlow,
15
15
  editProviderFlow,
16
16
  deleteProviderFlow,
17
- addModelFlow,
18
17
  editModelFlow,
19
18
  deleteModelFlow,
20
19
  syncFlow,
@@ -33,6 +32,9 @@ type ModelRow = {
33
32
  reasoning: boolean;
34
33
  input: string[];
35
34
  hasApiKey: boolean;
35
+ // 详情面板需要从 raw ModelConfig 透传
36
+ thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;
37
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
36
38
  };
37
39
 
38
40
  type ProviderRow = {
@@ -182,17 +184,13 @@ function buildProviders(ctx: ExtensionCommandContext, json: ModelsJson): { provi
182
184
  // 只看 models.json 里的自定义 provider;内置 provider 走 pi 的 /model,不在插件覆盖范围
183
185
  const customIds = Object.keys(json.providers).sort();
184
186
 
187
+ // 本插件只管理 models.json 里的 url+apiKey 自定义 provider(无 OAuth)。
188
+ // 直接从 json.providers[pid].apiKey 自检,避开 pi runtime 的多路径判断。
189
+ // source 标识 key 来源:models.json_key(明文)、models.json_env($ENV)、models.json_command(!cmd)、empty(未设)。
185
190
  const auth = new Map<string, { hasKey: boolean; source?: string }>();
186
- const authStatus = (ctx.modelRegistry as any).getProviderAuthStatus;
187
- if (typeof authStatus === "function") {
188
- for (const pid of customIds) {
189
- try {
190
- const s = authStatus(pid);
191
- auth.set(pid, { hasKey: !!s?.ok, source: s?.source });
192
- } catch {
193
- auth.set(pid, { hasKey: false });
194
- }
195
- }
191
+ for (const pid of customIds) {
192
+ const apiKey = json.providers[pid]?.apiKey;
193
+ auth.set(pid, inspectApiKey(apiKey));
196
194
  }
197
195
 
198
196
  const providers: ProviderRow[] = customIds.map((pid) => {
@@ -208,6 +206,9 @@ function buildProviders(ctx: ExtensionCommandContext, json: ModelsJson): { provi
208
206
  reasoning: !!m.reasoning,
209
207
  input: m.input ?? ["text"],
210
208
  hasApiKey: auth.get(pid)?.hasKey ?? false,
209
+ // 详情面板需要这些字段
210
+ thinkingLevelMap: m.thinkingLevelMap,
211
+ cost: m.cost,
211
212
  })),
212
213
  };
213
214
  });
@@ -278,7 +279,7 @@ class Dashboard {
278
279
  this.onClose();
279
280
  return;
280
281
  }
281
- if (matchesKey(data, "tab")) {
282
+ if (matchesKey(data, "left") || matchesKey(data, "right")) {
282
283
  this.pane = this.pane === "provider" ? "model" : "provider";
283
284
  this.invalidate();
284
285
  return;
@@ -300,22 +301,8 @@ class Dashboard {
300
301
  this.setIndex(0);
301
302
  } else if (data === "G") {
302
303
  this.setIndex(items.length - 1);
303
- } else if (data === "n") {
304
- // n: 新增。若没 provider 则强制切到 provider pane addProviderFlow
305
- if (this.pane === "provider") {
306
- void this.runForm(addProviderFlow);
307
- } else {
308
- const cur = this.providers[this.providerIndex];
309
- if (cur) {
310
- void this.runForm(addModelFlow, cur.id);
311
- } else {
312
- // 无 provider:切到 provider pane 再走 add
313
- this.pane = "provider";
314
- this.invalidate();
315
- this.ctx.ui.notify("先新建 provider:按 n 添加", "info");
316
- }
317
- }
318
- } else if (data === "e") {
304
+ } else if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
305
+ // Enter 选中项的编辑(与原 'e' 行为一致)。model 仅允许 edit,不允许 new
319
306
  if (this.pane === "provider" && this.providers[this.providerIndex]) {
320
307
  const id = this.providers[this.providerIndex].id;
321
308
  void this.runForm(editProviderFlow, id);
@@ -324,6 +311,13 @@ class Dashboard {
324
311
  const m = prov?.models[this.modelIndex];
325
312
  if (prov && m) void this.runForm(editModelFlow, prov.id, m.id);
326
313
  }
314
+ } else if (data === "n") {
315
+ // n: 新增 provider。仅 provider 面板支持;model 面板的 n 跳到 sync 提示。
316
+ if (this.pane === "provider") {
317
+ void this.runForm(addProviderFlow);
318
+ } else {
319
+ this.ctx.ui.notify("model 不能直接新增,请用 sync(按 y)", "info");
320
+ }
327
321
  } else if (data === "d") {
328
322
  const prov = this.providers[this.providerIndex];
329
323
  if (this.pane === "provider" && prov) {
@@ -357,16 +351,8 @@ class Dashboard {
357
351
  const json = await readModelsJson();
358
352
  this.json = json;
359
353
  this.auth = new Map();
360
- const authStatus = this.ctx.modelRegistry.getProviderAuthStatus;
361
- if (typeof authStatus === "function") {
362
- for (const pid of Object.keys(json.providers)) {
363
- try {
364
- const s = (authStatus as any)(pid);
365
- this.auth.set(pid, { hasKey: !!s?.ok, source: s?.source });
366
- } catch {
367
- this.auth.set(pid, { hasKey: false });
368
- }
369
- }
354
+ for (const pid of Object.keys(json.providers)) {
355
+ this.auth.set(pid, inspectApiKey(json.providers[pid]?.apiKey));
370
356
  }
371
357
  this.invalidate();
372
358
  }
@@ -457,19 +443,18 @@ class Dashboard {
457
443
  const th = this.theme;
458
444
  const lines: string[] = [];
459
445
 
460
- // 1. Header
461
- lines.push(th.fg("accent", th.bold(" provider-manager ")) + th.fg("borderMuted", "─".repeat(Math.max(0, width - 20))));
462
- lines.push("");
446
+ // 1. Header (title + stats)
447
+ lines.push(this.renderTitleBar(width, th));
463
448
 
464
449
  if (this.initError) {
465
450
  lines.push(th.fg("error", ` ⚠ ${this.initError}`));
466
451
  lines.push(th.fg("dim", " 按 q 退出,修复 models.json 后 /providers 重开"));
467
452
  } else if (this.providers.length === 0) {
468
- lines.push(th.fg("dim", " (no providers found)"));
469
- lines.push(th.fg("dim", " 按 n 新建 provider,或检查 ~/.pi/agent/models.json"));
453
+ lines.push(...this.renderEmptyState(th));
470
454
  } else {
471
455
  // 2. Body: 两栏
472
- const colWidth = Math.max(20, Math.floor((width - 3) / 2));
456
+ lines.push("");
457
+ const colWidth = Math.max(24, Math.floor((width - 3) / 2));
473
458
  const leftLines = this.renderProviderColumn(colWidth, th);
474
459
  const rightLines = this.renderModelColumn(colWidth, th);
475
460
  const rows = Math.max(leftLines.length, rightLines.length);
@@ -477,13 +462,11 @@ class Dashboard {
477
462
  for (let r = 0; r < rows; r++) {
478
463
  const l = leftLines[r] ?? "";
479
464
  const rr = rightLines[r] ?? "";
480
- // 关键:visiblePad 只看可见宽度(剥掉 [tag]...[/tag]),不再被主题标签吃掉 padding
481
465
  lines.push(visiblePad(l, colWidth) + sep + rr);
482
466
  }
483
- lines.push("");
484
467
 
485
468
  // 3. Detail
486
- lines.push(th.fg("borderMuted", "─".repeat(width)));
469
+ lines.push("");
487
470
  lines.push(...this.renderDetail(width, th));
488
471
  }
489
472
 
@@ -492,24 +475,76 @@ class Dashboard {
492
475
  if (this.help) {
493
476
  lines.push(...this.renderHelp(width, th));
494
477
  } else {
495
- lines.push(th.fg("dim", " ↑↓/jk nav · Tab pane · n new · e edit · d del · y sync · t test · T test-all · ? help · q close"));
478
+ const parts = ["↑↓/jk nav", "←→ pane"];
479
+ if (this.pane === "provider") parts.push("n new", "Enter edit", "y sync");
480
+ else parts.push("Enter edit", "y sync", "t test", "T test-all");
481
+ parts.push("d del", "? help", "q close");
482
+ lines.push(th.fg("dim", " " + parts.join(" · ")));
496
483
  }
497
484
  this.cachedWidth = width;
498
485
  this.cachedLines = lines;
499
486
  return lines;
500
487
  }
501
488
 
489
+ /** title bar:左侧包名+粗体,右侧 stats(providers/models/authed) */
490
+ private renderTitleBar(width: number, th: any): string {
491
+ const totalModels = this.providers.reduce((s, p) => s + p.models.length, 0);
492
+ const authed = Array.from(this.auth.values()).filter(a => a?.hasKey).length;
493
+ const stats = this.providers.length === 0
494
+ ? "no providers"
495
+ : `${this.providers.length}P · ${totalModels}M${authed > 0 ? ` · ${authed}✓` : ""}`;
496
+ const title = th.fg("accent", th.bold(" provider-manager "));
497
+ const right = th.fg("dim", " " + stats + " ");
498
+ const titleW = 18; // " provider-manager " visible length
499
+ const rightW = visibleWidthStrippingTheme(right);
500
+ const fill = Math.max(2, width - titleW - rightW);
501
+ return title + th.fg("borderMuted", "─".repeat(fill)) + right;
502
+ }
503
+
504
+ /** 无 provider 时的空态提示 */
505
+ private renderEmptyState(th: any): string[] {
506
+ const out: string[] = [];
507
+ out.push("");
508
+ out.push(th.fg("dim", " ┌──────────────────────────────────────────────────┐"));
509
+ out.push(th.fg("dim", " │ (no providers found) │"));
510
+ out.push(th.fg("dim", " │ │"));
511
+ out.push(th.fg("dim", " │ Press ") + th.fg("accent", "n") + th.fg("dim", " to add the first provider. │"));
512
+ out.push(th.fg("dim", " │ Or check ~/.pi/agent/models.json. │"));
513
+ out.push(th.fg("dim", " └──────────────────────────────────────────────────┘"));
514
+ return out;
515
+ }
516
+
502
517
  private renderProviderColumn(width: number, th: any): string[] {
503
518
  const lines: string[] = [];
504
- const headerTag = this.pane === "provider" ? th.fg("accent", th.bold("▸ Providers")) : th.fg("muted", " Providers");
505
- lines.push(truncateToWidth(headerTag, width));
506
- lines.push("");
519
+ const totalModels = this.providers.reduce((s, p) => s + p.models.length, 0);
520
+ const authed = Array.from(this.auth.values()).filter(a => a?.hasKey).length;
521
+ const stats = ` ${this.providers.length}·${authed}✓ ${totalModels}m `;
522
+ // ▸ 之前硬编码在 headText 里,inactive 时 trimStart() 不能去掉它(不是空白),导致头部 2 空格+▸ 与下面
523
+ // 非 cursor 行的 2 空格+内容 错 1 个字符。现在按 pane 动态生成。
524
+ const headActive = this.pane === "provider";
525
+ const headPrefix = headActive ? "▸ " : " ";
526
+ const headBase = "Providers";
527
+ // 先按 plain 文本 truncate,再 th.fg 整行包色(同 model 列)
528
+ const headPlain = truncateToWidth(headPrefix + headBase + stats, width);
529
+ const head = (headActive ? th.fg("accent", th.bold(headPlain)) : th.fg("muted", th.bold(headPlain)));
530
+ lines.push(head);
531
+ // 下划线长度 = head 实际可见宽度
532
+ lines.push(th.fg("borderMuted", "─".repeat(Math.min(width, headPrefix.length + headBase.length + stats.length))));
507
533
  this.providers.forEach((p, i) => {
508
534
  const sel = i === this.providerIndex;
509
- const arrow = sel && this.pane === "provider" ? th.fg("accent", "▸ ") : " ";
535
+ const isActivePane = sel && this.pane === "provider";
536
+ const arrow = isActivePane ? th.fg("accent", "▸ ") : " ";
510
537
  const nameTh = sel ? th.bold(p.id) : p.id;
511
- const cntThemed = th.fg("dim", ` (${p.models.length})`);
512
- lines.push(visiblePad(arrow + nameTh + cntThemed, width));
538
+ // 认证状态图标:✓ (有 key) / ✗ ( key) / 空格 (无 status)
539
+ const auth = this.auth.get(p.id);
540
+ let authIcon = " ";
541
+ if (auth) authIcon = auth.hasKey ? th.fg("success", "✓ ") : th.fg("error", "✗ ");
542
+ // model 数量
543
+ const cnt = th.fg("dim", ` ${p.models.length}m`);
544
+ // 0 model 提示
545
+ const warn = p.models.length === 0 ? th.fg("warning", " ⚠") : "";
546
+ const line = arrow + nameTh + authIcon + cnt + warn;
547
+ lines.push(visiblePad(line, width));
513
548
  });
514
549
  return lines;
515
550
  }
@@ -518,19 +553,35 @@ class Dashboard {
518
553
  const lines: string[] = [];
519
554
  const provider = this.providers[this.providerIndex];
520
555
  const models = provider?.models ?? [];
521
- const headerTag = this.pane === "model" ? th.fg("accent", th.bold(`▸ Models (${provider?.id ?? "?"})`)) : th.fg("muted", ` Models (${provider?.id ?? "?"})`);
522
- lines.push(truncateToWidth(headerTag, width));
523
- lines.push("");
556
+ const rCount = models.filter(m => m.reasoning).length;
557
+ const iCount = models.filter(m => m.input.includes("image")).length;
558
+ const stats = models.length > 0 ? ` ${models.length}m · ${rCount}R · ${iCount}I ` : " 0m ";
559
+ // ▸ 由 pane 决定,不在 headPlain 里。同 provider 列。
560
+ const isHeadActive = this.pane === "model" && !!provider;
561
+ const headPrefix = isHeadActive ? "▸ " : " ";
562
+ const headBase = provider ? `Models (${provider.id})` : "Models";
563
+ // 先按 plain 文本 truncate(避免 ANSI 字符撑爆宽度),最后整行包色
564
+ const headPlain = truncateToWidth(headPrefix + headBase + stats, width);
565
+ const headColored = isHeadActive ? th.fg("accent", th.bold(headPlain)) : th.fg("muted", th.bold(headPlain));
566
+ lines.push(headColored);
567
+ // 下划线长度 = head 可见宽度
568
+ lines.push(th.fg("borderMuted", "─".repeat(Math.min(width, headPrefix.length + headBase.length + stats.length))));
569
+
524
570
  if (models.length === 0) {
525
571
  lines.push(th.fg("dim", " (no models)"));
572
+ lines.push(th.fg("dim", " Press ") + th.fg("accent", "y") + th.fg("dim", " to sync from remote"));
573
+ return lines;
526
574
  }
527
575
  models.forEach((m, i) => {
528
576
  const sel = i === this.modelIndex;
529
- const arrow = sel ? (this.pane === "model" ? "▸ " : " ") : " ";
530
- const flags = [m.reasoning && "R", m.input.includes("image") && "I"].filter(Boolean).join("");
577
+ const isActivePane = sel && this.pane === "model";
578
+ // plain text,末尾才 th.fg 整行包色(避免 ANSI truncateToWidth 计入宽度)
579
+ const arrow = isActivePane ? "▸ " : " ";
580
+ const rFlag = m.reasoning ? "R" : "-";
581
+ const iFlag = m.input.includes("image") ? "I" : "-";
582
+ const flagStr = ` [${rFlag}${iFlag}]`;
531
583
  const ctx2 = m.contextWindow ? ` ${formatNum(m.contextWindow)}c` : "";
532
584
  const max2 = m.maxTokens ? ` ${formatNum(m.maxTokens)}m` : "";
533
- const flagStr = flags ? ` [${flags}]` : "";
534
585
  const raw = arrow + m.id + flagStr + ctx2 + max2;
535
586
  const line = truncateToWidth(raw, width);
536
587
  lines.push(sel ? th.fg("accent", line) : line);
@@ -543,53 +594,96 @@ class Dashboard {
543
594
  if (this.pane === "provider") {
544
595
  const p = this.providers[this.providerIndex];
545
596
  if (!p) return [th.fg("dim", " (no provider selected)")];
546
- lines.push(th.fg("accent", th.bold("Provider: ")) + p.id);
547
- lines.push(` displayName: ${p.displayName}`);
548
- lines.push(` source: models.json (custom)`);
549
- lines.push(` models: ${p.models.length}`);
550
597
  const auth = this.auth.get(p.id);
551
- if (auth) lines.push(` auth: ${auth.hasKey ? th.fg("success", "✓ ") + (auth.source ?? "ok") : th.fg("error", "✗ no key")}`);
552
- // 原始 json(来自 models.json 的话)
553
- const raw = this.json?.providers?.[p.id];
598
+ const authIcon = auth
599
+ ? (auth.hasKey ? th.fg("success", "✓ ") : th.fg("error", "✗ "))
600
+ : th.fg("dim", " ");
601
+ // 大标题
602
+ lines.push(th.fg("accent", th.bold(` ${authIcon} Provider: `)) + th.bold(p.id));
603
+ lines.push("");
604
+ // Identity
605
+ lines.push(th.fg("muted", " Identity"));
606
+ lines.push(` displayName: ${p.displayName || th.fg("dim", "(unset)")}`);
607
+ lines.push(` source: models.json (custom)`);
608
+ lines.push(` models: ${p.models.length}`);
609
+ // raw config
610
+ const raw = this.json?.providers?.[p.id] as any;
554
611
  if (raw) {
555
- lines.push(th.fg("muted", " raw (from models.json):"));
556
- const summary = summarizeProviderRaw(raw);
557
- lines.push(...summary.map((l) => th.fg("dim", " " + l)));
612
+ lines.push("");
613
+ lines.push(th.fg("muted", " Endpoint"));
614
+ lines.push(` baseUrl: ${raw.baseUrl || th.fg("dim", "(unset)")}`);
615
+ lines.push(` api: ${raw.api || th.fg("dim", "(unset)")}`);
616
+ if (raw.proxy) lines.push(` proxy: ${raw.proxy}`);
617
+ lines.push("");
618
+ lines.push(th.fg("muted", " Auth"));
619
+ lines.push(` apiKey: ${maskApiKey(raw.apiKey)}`);
620
+ lines.push(` authHeader: ${raw.authHeader ? "yes" : "no"}`);
621
+ if (auth) {
622
+ // 自检:仅描述 models.json 里 apiKey 字段状态(不是 pi 的认证是否有效;那是 t/T 测的)
623
+ const statusText = auth.hasKey ? "set" : "empty";
624
+ const statusColor = auth.hasKey ? th.fg("success", "✓ set") : th.fg("warning", "✗ empty");
625
+ lines.push(` apiKey status: ${statusColor}${auth.source && auth.source !== "empty" ? th.fg("dim", " (" + auth.source + ")") : ""}`);
626
+ }
558
627
  }
559
628
  } else {
560
629
  const p = this.providers[this.providerIndex];
561
630
  const m = p?.models[this.modelIndex];
562
631
  if (!m) return [th.fg("dim", " (no model selected)")];
563
- lines.push(th.fg("accent", th.bold("Model: ")) + `${p.id} / ${m.id}`);
564
- lines.push(` reasoning: ${m.reasoning ? "yes" : "no"}`);
565
- lines.push(` input: ${m.input.join(", ") || "(none)"}`);
566
- lines.push(` context: ${m.contextWindow?.toLocaleString() ?? "?"}`);
567
- lines.push(` max output: ${m.maxTokens?.toLocaleString() ?? "?"}`);
568
- // raw
569
- const rawModel = this.json?.providers?.[p.id]?.models?.find((mm: any) => mm.id === m.id);
570
- if (rawModel) {
571
- lines.push(th.fg("muted", " raw (from models.json):"));
572
- lines.push(...summarizeModelRaw(rawModel).map((l) => th.fg("dim", " " + l)));
632
+ lines.push(th.fg("accent", th.bold(` Model: `)) + `${p.id} / ${m.id}`);
633
+ lines.push("");
634
+ lines.push(th.fg("muted", " Capabilities"));
635
+ lines.push(` reasoning: ${m.reasoning ? th.fg("accent", "yes") : th.fg("dim", "no")}`);
636
+ lines.push(` input: ${m.input.join(", ") || th.fg("dim", "(none)")}`);
637
+ lines.push("");
638
+ lines.push(th.fg("muted", " Limits"));
639
+ lines.push(` context: ${m.contextWindow?.toLocaleString() ?? th.fg("dim", "?")}`);
640
+ lines.push(` max output: ${m.maxTokens?.toLocaleString() ?? th.fg("dim", "?")}`);
641
+ // thinking level map:单行显示 enabled 的 level 名字(`low, medium, max`)。无任何 enabled 时跳过
642
+ // 详情面板真值来自 m.thinkingLevelMap(buildProviders 已从 ModelConfig 透传)
643
+ const tlm = m.thinkingLevelMap;
644
+ if (tlm && typeof tlm === "object") {
645
+ const enabled = (Object.entries(tlm) as [string, string | null][])
646
+ .filter(([, v]) => v !== null && v !== undefined)
647
+ .map(([k]) => k);
648
+ if (enabled.length) {
649
+ lines.push("");
650
+ lines.push(` Thinking levels: ${th.fg("text", enabled.join(", "))}`);
651
+ }
652
+ }
653
+ // cost
654
+ const cost = m.cost;
655
+ if (cost) {
656
+ lines.push("");
657
+ lines.push(th.fg("muted", " Cost"));
658
+ lines.push(` input: $${cost.input}/M`);
659
+ lines.push(` output: $${cost.output}/M`);
660
+ if (cost.cacheRead) lines.push(` cache read: $${cost.cacheRead}/M`);
661
+ if (cost.cacheWrite) lines.push(` cache write: $${cost.cacheWrite}/M`);
573
662
  }
574
663
  }
575
664
  return lines.map((l) => truncateToWidth(l, width));
576
665
  }
577
666
 
578
667
  private renderHelp(width: number, th: any): string[] {
579
- return [
668
+ const lines: string[] = [
580
669
  th.fg("accent", "Key bindings"),
581
670
  " ↑/↓ or j/k navigate in current pane",
582
671
  " g / G jump to top / bottom",
583
- " Tab switch between Providers and Models pane",
584
- " n new provider (or model on model pane)",
585
- " e edit selected provider / model",
672
+ " ← / → switch between Providers and Models pane",
673
+ " Enter edit selected provider / model",
586
674
  " d delete (with confirm)",
587
675
  " y sync — fetch remote models for selected provider",
588
676
  " ? toggle this help",
589
677
  " q / Esc close dashboard",
590
- "",
591
- th.fg("dim", " y sync · t test current model · T test all in provider"),
592
- ].map((l) => truncateToWidth(l, width));
678
+ ];
679
+ // 按面板增补特有项
680
+ if (this.pane === "provider") {
681
+ lines.splice(5, 0, " n new provider (models are added via sync)");
682
+ } else {
683
+ lines.splice(5, 0, " t / T test current model / test all in provider");
684
+ }
685
+ lines.push("", th.fg("dim", " y sync"));
686
+ return lines.map((l) => truncateToWidth(l, width));
593
687
  }
594
688
  }
595
689
 
@@ -599,27 +693,27 @@ function formatNum(n: number): string {
599
693
  return String(n);
600
694
  }
601
695
 
602
- function summarizeProviderRaw(raw: any): string[] {
603
- const lines: string[] = [];
604
- if (raw.baseUrl) lines.push(`baseUrl: ${raw.baseUrl}`);
605
- if (raw.api) lines.push(`api: ${raw.api}`);
606
- if (raw.apiKey) lines.push(`apiKey: ${maskApiKey(raw.apiKey)}`);
607
- if (raw.authHeader) lines.push(`authHeader: ${raw.authHeader}`);
608
- if (raw.headers && Object.keys(raw.headers).length) lines.push(`headers: ${Object.keys(raw.headers).length} entries`);
609
- return lines;
610
- }
611
-
612
- function summarizeModelRaw(raw: any): string[] {
613
- const lines: string[] = [];
614
- if (raw.name) lines.push(`name: ${raw.name}`);
615
- if (raw.baseUrl) lines.push(`baseUrl: ${raw.baseUrl}`);
616
- if (raw.api) lines.push(`api: ${raw.api}`);
617
- if (raw.cost) lines.push(`cost: in ${raw.cost.input} / out ${raw.cost.output}`);
618
- if (raw.thinkingLevelMap) {
619
- const keys = Object.keys(raw.thinkingLevelMap);
620
- lines.push(`thinkingLevelMap: ${keys.length} levels`);
696
+ /**
697
+ * 检查 models.json 里 provider.apiKey 的状态。
698
+ * 返回 { hasKey, source }:source 标识 key 的来源类型。
699
+ * - "models_json_key" 明文 API key
700
+ * - "models_json_env" $ENV_VAR ${ENV_VAR} 插值
701
+ * - "models.json_command" !shell-command 动态取 key
702
+ * - "empty" 未设
703
+ */
704
+ function inspectApiKey(apiKey: unknown): { hasKey: boolean; source?: string } {
705
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
706
+ return { hasKey: false, source: "empty" };
707
+ }
708
+ // !command 动态取 key
709
+ if (apiKey.startsWith("!")) {
710
+ return { hasKey: true, source: "models.json_command" };
711
+ }
712
+ // $ENV 或 ${ENV}
713
+ if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(apiKey)) {
714
+ return { hasKey: true, source: "models.json_env" };
621
715
  }
622
- return lines;
716
+ return { hasKey: true, source: "models_json_key" };
623
717
  }
624
718
 
625
719
  // ============================================================================