@fanchaozz/provider-manager 0.1.1 → 0.2.1

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