@wuyaos/pi-model-config 0.1.6 → 0.1.8

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 CHANGED
@@ -178,6 +178,24 @@ Chat 写入 Pi 标准 `models.json`。Classifier 和 image 通过 Pi 的原生 m
178
178
 
179
179
  插件是与 Pi 同权限运行的扩展,可以读取模型配置和认证。只从可信来源安装;优先使用 `$ENV` 或 Pi auth,避免长期 literal。
180
180
 
181
+ ## 保存时自动探测参数(auto probe on save)
182
+
183
+ 保存流程进入预览前(`Ctrl+S` 之后、preview 展示之前),插件会对草稿中 metadata 仍标记为 `compat`(即缺少 contextWindow/maxTokens 元数据的)chat 模型逐个串行发送一个最小探测请求:`POST {baseUrl}/chat/completions`,body 为 `{ model, messages: [{ role: "user", content: "用中文写一段约300字的描写" }], max_tokens: 100000 }`,单请求 15 秒超时。每个请求都是真实调用,可能计费;探测期间显示统一盒式进度面板(已完成 X/Y),Esc 可取消剩余请求,已完成的探测结果保留。
184
+
185
+ 推断规则:`finish_reason=length` 且 `usage.completion_tokens_details.reasoning_tokens > 0`(或 `message.reasoning_content` 存在)→ `maxTokens = max(2 × reasoning_tokens, 16384)`,并标 `reasoning=true` + 协议默认 thinkingLevelMap;`finish_reason=length` 无 reasoning → `maxTokens = max(completion_tokens, 16384)`(保守取当前截断点);`finish_reason=stop` → `maxTokens = 32768`;HTTP 错误、超时、取消或不可信响应 → 该模型静默沿用 128K/16K 兼容默认值。contextWindow 无法由探测可靠测出,保留现有值或 128000 默认,但 metadata 也会标记为 `probed` 表示本轮已探测确认。探测成功回填草稿并标 `maxTokensSource/contextSource/thinkingSource="probed"`;最终落盘仍需 preview 确认,preview 的「模型配置」分类会为新增条目显示「参数已自动探测(实测)」来源标记。
186
+
187
+ 凭据与 URL 安全复用 discovery 的规则:URL 必须通过 `validateHttpUrl`(仅 HTTP(S)、禁止嵌入凭据或密钥查询参数);`$ENV`/`$$` 字面量按 discovery 同样方式解析;reuse-pi 的 Pi 凭据仅在与模型地址同 origin 时才随探测请求转发。provider 专属代理同样适用于探测请求。探测密钥通过 `Authorization: Bearer` 头发送且不会出现在任何错误提示中。
188
+
189
+ 该功能默认开启;在 `model-config.json` 顶层设置 `"autoProbeOnSave": false` 可整体关闭:
190
+
191
+ ```json
192
+ {
193
+ "version": 1,
194
+ "autoProbeOnSave": false,
195
+ "providers": {}
196
+ }
197
+ ```
198
+
181
199
  ## 从 jev-use 迁移到原生 classifier
182
200
 
183
201
  本插件不会卸载或禁用 `jev-use`。建议手工迁移:
@@ -14,6 +14,11 @@
14
14
  "version": {
15
15
  "const": 1
16
16
  },
17
+ "autoProbeOnSave": {
18
+ "description": "Pre-save minimal parameter probe for newly checked chat models lacking context/maxTokens metadata. Default true.",
19
+ "type": "boolean",
20
+ "default": true
21
+ },
17
22
  "providers": {
18
23
  "type": "object",
19
24
  "additionalProperties": {
@@ -192,6 +197,14 @@
192
197
  },
193
198
  "uniqueItems": true
194
199
  },
200
+ "hiddenRemoteIds": {
201
+ "description": "Remote catalog model IDs hidden by the user across discovery refreshes.",
202
+ "type": "array",
203
+ "items": {
204
+ "type": "string"
205
+ },
206
+ "uniqueItems": true
207
+ },
195
208
  "models": {
196
209
  "type": "array",
197
210
  "items": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wuyaos/pi-model-config",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Pi provider 连接、远程发现与 chat/classifier/image 模型配置 TUI",
6
6
  "keywords": [
@@ -0,0 +1,219 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { validateHttpUrl } from "./config.ts";
4
+ import { MODEL_CONFIG_OVERLAY_OPTIONS, renderPanelBox } from "./panel.ts";
5
+ import { t, type UiLanguage } from "./i18n.ts";
6
+ import { protocolThinkingMap } from "./thinking-defaults.ts";
7
+ import type { ProviderDraft, ThinkingLevelMap } from "./types.ts";
8
+
9
+ /** Defaults applied when the minimal probe request fails or cannot be trusted. */
10
+ export const AUTO_PROBE_TIMEOUT_MS = 15_000;
11
+ export const AUTO_PROBE_MAX_TOKENS_REQUEST = 100_000;
12
+ const PROBE_PROMPT = "用中文写一段约300字的描写";
13
+
14
+ export interface AutoProbeResult {
15
+ /** Inferred output ceiling. Context window is intentionally not probed. */
16
+ maxTokens: number;
17
+ reasoning: boolean;
18
+ thinkingLevelMap?: ThinkingLevelMap;
19
+ }
20
+
21
+ export interface ProbeChatParamsOptions {
22
+ fetch?: typeof fetch;
23
+ apiKey?: string;
24
+ baseUrl: string;
25
+ signal: AbortSignal;
26
+ timeoutMs?: number;
27
+ }
28
+
29
+ /**
30
+ * Pure inference from a minimal chat-completions response. Returns null whenever the
31
+ * observation is not trustworthy, so callers can fall back to compatibility defaults.
32
+ */
33
+ export function inferAutoProbeParams(
34
+ payload: {
35
+ finishReason?: unknown;
36
+ reasoningContent?: unknown;
37
+ reasoningTokens?: unknown;
38
+ completionTokens?: unknown;
39
+ },
40
+ api: string,
41
+ ): AutoProbeResult | null {
42
+ const reasoningTokens = typeof payload.reasoningTokens === "number" && Number.isFinite(payload.reasoningTokens) && payload.reasoningTokens > 0
43
+ ? Math.floor(payload.reasoningTokens)
44
+ : 0;
45
+ const hasReasoningContent = typeof payload.reasoningContent === "string" && payload.reasoningContent.trim().length > 0;
46
+ const reasoning = hasReasoningContent || reasoningTokens > 0;
47
+ const finish = payload.finishReason;
48
+ if (finish === "length") {
49
+ // Truncated mid-reasoning: the model wanted at least its reasoning budget twice.
50
+ const maxTokens = reasoningTokens > 0 ? Math.max(2 * reasoningTokens, 16_384) : Math.max(Math.floor(Number(payload.completionTokens)) || 0, 16_384);
51
+ const protocol = reasoning ? protocolThinkingMap(api) : undefined;
52
+ return { maxTokens, reasoning, ...(protocol ? { thinkingLevelMap: protocol } : {}) };
53
+ }
54
+ if (finish === "stop") return { maxTokens: 32_768, reasoning };
55
+ // tool_calls / content_filter / unknown shapes are not trustworthy for sizing.
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * One minimal paid-capable request against an OpenAI-compatible /chat/completions
61
+ * endpoint. Never throws: every failure path resolves to null so the save flow can
62
+ * silently keep compatibility defaults. URL handling reuses validateHttpUrl, matching
63
+ * discovery SSRF rules (HTTP(S) only, no embedded credentials or secret queries).
64
+ */
65
+ export async function probeChatParams(model: { id: string; api: string }, options: ProbeChatParamsOptions): Promise<AutoProbeResult | null> {
66
+ try {
67
+ const base = validateHttpUrl(options.baseUrl, "Base URL").replace(/\/+$/u, "");
68
+ const url = validateHttpUrl(`${base}/chat/completions`, "Probe URL");
69
+ const headers: Record<string, string> = { "content-type": "application/json" };
70
+ if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`;
71
+ const combined = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs ?? AUTO_PROBE_TIMEOUT_MS)]);
72
+ const fetchImpl = options.fetch ?? globalThis.fetch;
73
+ const response = await fetchImpl(url, {
74
+ method: "POST",
75
+ headers,
76
+ body: JSON.stringify({
77
+ model: model.id,
78
+ messages: [{ role: "user", content: PROBE_PROMPT }],
79
+ max_tokens: AUTO_PROBE_MAX_TOKENS_REQUEST,
80
+ }),
81
+ signal: combined,
82
+ });
83
+ if (!response.ok) return null;
84
+ const text = await response.text();
85
+ let body: any;
86
+ try { body = JSON.parse(text); } catch { return null; }
87
+ const choice = Array.isArray(body?.choices) ? body.choices[0] : undefined;
88
+ if (!choice || typeof choice !== "object") return null;
89
+ return inferAutoProbeParams({
90
+ finishReason: choice.finish_reason,
91
+ reasoningContent: choice.message?.reasoning_content,
92
+ reasoningTokens: body?.usage?.completion_tokens_details?.reasoning_tokens,
93
+ completionTokens: body?.usage?.completion_tokens,
94
+ }, model.api);
95
+ } catch {
96
+ // Abort (user Esc or timeout) and any transport/validation failure fall back to defaults.
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function originOf(value: string | undefined): string | undefined {
102
+ if (!value) return undefined;
103
+ try { return new URL(validateHttpUrl(value, "Base URL")).origin; } catch { return undefined; }
104
+ }
105
+
106
+ /**
107
+ * Resolve the credential used by the probe request, applying the same origin
108
+ * containment as discovery for reused Pi credentials: an existing key is never
109
+ * forwarded to a different origin than the one it was issued for.
110
+ */
111
+ export function resolveProbeApiKey(
112
+ draft: Pick<ProviderDraft, "authMode" | "apiKeyRaw" | "temporaryKey">,
113
+ piAuth: { auth?: { apiKey?: string; baseUrl?: string } } | undefined,
114
+ baseUrl: string,
115
+ ): string | undefined {
116
+ if (draft.authMode === "models-json") {
117
+ const raw = draft.apiKeyRaw;
118
+ const envMatch = raw?.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/u);
119
+ if (envMatch) return process.env[envMatch[1] ?? envMatch[2]!];
120
+ if (raw?.startsWith("$$") || raw?.startsWith("$!")) return raw.slice(1);
121
+ return raw;
122
+ }
123
+ if (draft.authMode === "temporary") return draft.temporaryKey;
124
+ if (draft.authMode === "reuse-pi") {
125
+ const key = piAuth?.auth?.apiKey;
126
+ if (!key) return undefined;
127
+ const authOrigin = originOf(piAuth?.auth?.baseUrl);
128
+ const modelOrigin = originOf(baseUrl);
129
+ if (!authOrigin || !modelOrigin || authOrigin !== modelOrigin) return undefined;
130
+ return key;
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ /** Chat models whose metadata still marks context/maxTokens as unverified compat defaults. */
136
+ export function probeTargetsFor(
137
+ models: ReadonlyArray<{ type: string; id: string }>,
138
+ metadata: ReadonlyArray<{ type: string; id: string; contextSource?: string; maxTokensSource?: string }>,
139
+ ): Array<{ type: string; id: string }> {
140
+ return models.filter((model) => {
141
+ if (model.type !== "chat") return false;
142
+ const entry = metadata.find((item) => item.type === model.type && item.id === model.id);
143
+ return !entry || entry.contextSource === "compat" || entry.maxTokensSource === "compat";
144
+ });
145
+ }
146
+
147
+ export interface ProbeQueueEntry<T> {
148
+ label: string;
149
+ run: (signal: AbortSignal) => Promise<T | null>;
150
+ }
151
+
152
+ export interface ProbeQueueOutcome<T> {
153
+ result: T | null;
154
+ /** True when this entry was skipped because the user pressed Esc earlier. */
155
+ cancelled: boolean;
156
+ }
157
+
158
+ /** Serial runner shared by the TUI panel and tests; one request at a time, abort stops the rest. */
159
+ export async function runProbeQueue<T>(
160
+ entries: ReadonlyArray<ProbeQueueEntry<T>>,
161
+ signal: AbortSignal,
162
+ onProgress?: (done: number, total: number, current: string) => void,
163
+ ): Promise<Array<ProbeQueueOutcome<T>>> {
164
+ const outcomes: Array<ProbeQueueOutcome<T>> = [];
165
+ for (const entry of entries) {
166
+ if (signal.aborted) { outcomes.push({ result: null, cancelled: true }); continue; }
167
+ onProgress?.(outcomes.length, entries.length, entry.label);
168
+ const result = await entry.run(signal);
169
+ outcomes.push({ result, cancelled: false });
170
+ onProgress?.(outcomes.length, entries.length, entry.label);
171
+ }
172
+ return outcomes;
173
+ }
174
+
175
+ class AutoProbePanel<T> implements Component {
176
+ private controller = new AbortController();
177
+ private done = 0;
178
+ private current = "";
179
+ constructor(
180
+ private tui: TUI,
181
+ private theme: Theme,
182
+ private language: UiLanguage,
183
+ private entries: ReadonlyArray<ProbeQueueEntry<T>>,
184
+ done: (outcomes: Array<ProbeQueueOutcome<T>>) => void,
185
+ ) {
186
+ void runProbeQueue(this.entries, this.controller.signal, (completed, _total, label) => {
187
+ this.done = completed;
188
+ this.current = label;
189
+ this.tui.requestRender();
190
+ }).then((outcomes) => {
191
+ this.done = outcomes.length;
192
+ this.current = "";
193
+ this.tui.requestRender();
194
+ done(outcomes);
195
+ });
196
+ }
197
+ handleInput(data: string): void { if (matchesKey(data, Key.escape)) this.controller.abort(new DOMException("Cancelled", "AbortError")); }
198
+ render(width: number): string[] {
199
+ const lines = [t(this.language, "autoProbeProgress", { done: this.done, total: this.entries.length, model: this.current })];
200
+ return renderPanelBox(lines, width, t(this.language, "autoProbePanelTitle"), this.theme);
201
+ }
202
+ invalidate(): void {}
203
+ dispose(): void { this.controller.abort(); }
204
+ }
205
+
206
+ /**
207
+ * Unified boxed progress panel for the pre-save auto probe. Shows X/Y completed and
208
+ * lets Esc cancel the remaining (not in-flight) requests; completed results are kept.
209
+ */
210
+ export async function runAutoProbeFlow<T>(
211
+ ctx: ExtensionCommandContext,
212
+ language: UiLanguage,
213
+ entries: ReadonlyArray<ProbeQueueEntry<T>>,
214
+ ): Promise<Array<ProbeQueueOutcome<T>>> {
215
+ return ctx.ui.custom<Array<ProbeQueueOutcome<T>>>(
216
+ (tui, theme, _keys, done) => new AutoProbePanel(tui, theme, language, entries, done),
217
+ MODEL_CONFIG_OVERLAY_OPTIONS,
218
+ );
219
+ }
package/src/command.ts CHANGED
@@ -12,12 +12,15 @@ import {
12
12
  writeConfigTransaction,
13
13
  } from "./config.ts";
14
14
  import { buildDiscoveryCandidates, discoverModelsFromCandidates } from "./discovery.ts";
15
+ import { createProviderFetch } from "./transport.ts";
16
+ import { probeChatParams, probeTargetsFor, resolveProbeApiKey, runAutoProbeFlow } from "./auto-probe.ts";
15
17
  import { formatError, mcError, t, type UiLanguage } from "./i18n.ts";
16
18
  import {
17
19
  showConnectionPanel,
18
20
  showPreviewPanel,
19
21
  showProviderPanel,
20
22
  showConfirmationPanel,
23
+ showRestoreHiddenPanel,
21
24
  type MenuPanelState,
22
25
  type OverviewPanelState,
23
26
  type PanelItem,
@@ -36,7 +39,7 @@ import type {
36
39
  ProviderConnectionConfig,
37
40
  ProviderDraft,
38
41
  } from "./types.ts";
39
- import { identityOf } from "./types.ts";
42
+ import { hiddenRemoteKey, identityOf, isHiddenRemote, normalizeHiddenRemoteKeys } from "./types.ts";
40
43
  import { loadUiLanguage, saveUiLanguage } from "./ui-config.ts";
41
44
  import { secretInput, withCancellableLoader } from "./ui.ts";
42
45
  import { showModelTestFlow } from "./model-test.ts";
@@ -157,6 +160,7 @@ function draftFrom(providerId: string, provider: ModelsJsonProvider | undefined,
157
160
  classifierApi: config?.classifierApi ?? "typesafe-system-one",
158
161
  imageApi: config?.imageApi ?? "openrouter-images",
159
162
  defaultType: config?.defaultType ?? "chat",
163
+ hiddenRemoteIds: normalizeHiddenRemoteKeys(config?.hiddenRemoteIds ?? []),
160
164
  };
161
165
  }
162
166
 
@@ -248,9 +252,9 @@ function connectionPanelFields(language: UiLanguage, draft: ProviderDraft): Pane
248
252
  ];
249
253
  }
250
254
 
251
- async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, state: MenuPanelState, canTakeover: boolean): Promise<"models" | "takeover" | "testModels" | "preview" | "back"> {
255
+ async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, state: MenuPanelState, canTakeover: boolean, hiddenRemoteCount: number): Promise<"models" | "takeover" | "restoreHidden" | "testModels" | "preview" | "back"> {
252
256
  while (true) {
253
- const result = await showConnectionPanel(ctx, language, draft.id, connectionPanelFields(language, draft), state, canTakeover);
257
+ const result = await showConnectionPanel(ctx, language, draft.id, connectionPanelFields(language, draft), state, canTakeover, hiddenRemoteCount);
254
258
  if (result.action !== "field") return result.action;
255
259
  try {
256
260
  switch (result.fieldId) {
@@ -424,8 +428,10 @@ export function applyChanges(
424
428
  if (draft.authMode === "models-json") provider.apiKey = draft.apiKeyRaw; else delete provider.apiKey;
425
429
  models.providers[providerId] = provider;
426
430
 
431
+ const normalizedHiddenRemoteIds = normalizeHiddenRemoteKeys(draft.hiddenRemoteIds ?? []);
432
+ const { hiddenRemoteIds: _oldHiddenRemoteIds, ...oldConfigWithoutHiddenRemoteIds } = oldConfig ?? {};
427
433
  config.providers[providerId] = {
428
- ...(oldConfig ?? {}),
434
+ ...oldConfigWithoutHiddenRemoteIds,
429
435
  name: draft.name,
430
436
  owned: draft.owned,
431
437
  enabled: draft.enabled,
@@ -436,6 +442,7 @@ export function applyChanges(
436
442
  imageApi: draft.imageApi,
437
443
  defaultType: draft.defaultType,
438
444
  managedChatIds: selectedChat.map((model) => model.id),
445
+ ...(normalizedHiddenRemoteIds.length ? { hiddenRemoteIds: normalizedHiddenRemoteIds } : {}),
439
446
  models: selected.filter((model): model is Extract<ManagedModel, { type: "classifier" | "image" }> => model.type !== "chat"),
440
447
  modelMetadata: modelMetadata.map((entry) => structuredClone(entry)),
441
448
  };
@@ -452,6 +459,7 @@ function buildPreviewDetails(
452
459
  oldConfig: ProviderConnectionConfig | undefined,
453
460
  oldProvider: ModelsJsonProvider | undefined,
454
461
  draft: ProviderDraft,
462
+ metadata: ReadonlyArray<ModelMetadataEntry> = [],
455
463
  ): { providerDetails: string[]; modelDetails: string[] } {
456
464
  const oldMap = new Map(before.map((model) => [identityOf(model, providerId), model]));
457
465
  const newMap = new Map(after.map((model) => [identityOf(model, providerId), model]));
@@ -492,6 +500,13 @@ function buildPreviewDetails(
492
500
  const model = newMap.get(key) ?? oldMap.get(key)!;
493
501
  return `${modelTypeLabel(language, model.type)}/${model.id}`;
494
502
  };
503
+ // Newly added rows whose metadata was just probed get an explicit source marker.
504
+ const describeAdded = (key: ReturnType<typeof identityOf>) => {
505
+ const model = newMap.get(key)!;
506
+ const entry = metadata.find((item) => item.type === model.type && item.id === model.id);
507
+ const probed = entry?.contextSource === "probed" || entry?.maxTokensSource === "probed";
508
+ return `${describe(key)}${probed ? ` · ${t(language, "previewProbedDefaults")}` : ""}`;
509
+ };
495
510
  const settingsChanged = oldConnection !== nextConnection;
496
511
  const keyWillUpdate = draft.authMode === "models-json" && oldProvider?.apiKey !== draft.apiKeyRaw;
497
512
  return {
@@ -501,7 +516,7 @@ function buildPreviewDetails(
501
516
  ...(keyWillUpdate ? [t(language, "previewKeyUpdated")] : []),
502
517
  ],
503
518
  modelDetails: [
504
- ...(added.length ? [t(language, "previewModelsAdded", { items: added.map(describe).join(", ") })] : []),
519
+ ...(added.length ? [t(language, "previewModelsAdded", { items: added.map(describeAdded).join(", ") })] : []),
505
520
  ...(deleted.length ? [t(language, "previewModelsRemoved", { items: deleted.map(describe).join(", ") })] : []),
506
521
  ...(modified.length ? [t(language, "previewModelsModified", { items: modified.map(describe).join(", ") })] : []),
507
522
  t(language, "previewModelsUnchanged", { count: unchanged }),
@@ -514,6 +529,7 @@ export interface ProviderChoice {
514
529
  id: string;
515
530
  label: string;
516
531
  priority: number;
532
+ enabled: boolean;
517
533
  }
518
534
 
519
535
  export function buildConfiguredProviderChoices(
@@ -524,20 +540,21 @@ export function buildConfiguredProviderChoices(
524
540
  ): ProviderChoice[] {
525
541
  const ids = new Set([...Object.keys(models.providers), ...Object.keys(config.providers)]);
526
542
  const choices: ProviderChoice[] = [];
527
- for (const id of ids) {
543
+ for (const id of [...ids].sort((a, b) => a.localeCompare(b))) {
528
544
  const owned = config.providers[id]?.owned === true;
529
545
  const builtin = builtinProviderIds.has(id);
530
546
  const name = config.providers[id]?.name ?? models.providers[id]?.name;
531
547
  const displayBase = name && name !== id ? `${name} (${id})` : id;
532
548
  const display = config.providers[id]?.enabled === false ? `${displayBase} · ${t(language, "providerDisabled")}` : displayBase;
533
549
  const status = t(language, builtin ? "providerTagBuiltin" : "providerTagCustom");
550
+ const enabled = config.providers[id]?.enabled !== false;
534
551
  choices.push(owned
535
- ? { id, label: t(language, "providerLabelCustom", { display, status }), priority: 0 }
552
+ ? { id, label: t(language, "providerLabelCustom", { display, status }), priority: 0, enabled }
536
553
  : builtin
537
- ? { id, label: t(language, "providerLabelBuiltin", { display, status }), priority: 1 }
538
- : { id, label: t(language, "providerLabelCustom", { display, status }), priority: 2 });
554
+ ? { id, label: t(language, "providerLabelBuiltin", { display, status }), priority: 1, enabled }
555
+ : { id, label: t(language, "providerLabelCustom", { display, status }), priority: 2, enabled });
539
556
  }
540
- return choices.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
557
+ return choices.sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.priority - b.priority || a.id.localeCompare(b.id));
541
558
  }
542
559
 
543
560
  function buildProviderPanelItems(
@@ -591,7 +608,7 @@ function buildProviderPanelItems(
591
608
  export function buildBuiltinProviderChoices(language: UiLanguage, builtinProviderIds: Iterable<string>): ProviderChoice[] {
592
609
  return [...new Set(builtinProviderIds)]
593
610
  .sort((a, b) => a.localeCompare(b))
594
- .map((id) => ({ id, label: t(language, "providerLabelBuiltin", { display: id, status: t(language, "providerTagBuiltin") }), priority: 0 }));
611
+ .map((id) => ({ id, label: t(language, "providerLabelBuiltin", { display: id, status: t(language, "providerTagBuiltin") }), priority: 0, enabled: true }));
595
612
  }
596
613
 
597
614
  async function chooseBuiltinProvider(ctx: ExtensionCommandContext, language: UiLanguage): Promise<{ id: string; isNew: false } | undefined> {
@@ -657,6 +674,7 @@ function draftPanelItem(language: UiLanguage, draft: ProviderDraft, selected: Ma
657
674
  ],
658
675
  modelDetails: [
659
676
  t(language, "panelSummaryModels", { chat, classifier, image }),
677
+ ...(draft.hiddenRemoteIds?.length ? [t(language, "panelHiddenRemoteCount", { count: draft.hiddenRemoteIds.length })] : []),
660
678
  t(language, "detailFetchHint"),
661
679
  ...(adoptableCount ? [t(language, "panelTakeoverCount", { count: adoptableCount })] : []),
662
680
  ],
@@ -840,6 +858,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
840
858
 
841
859
  let selectedDraft = structuredClone(baselineSelected);
842
860
  let cachedRemote: CandidateModel[] = [];
861
+ let fetchInFlight = false;
843
862
  let page: "connection" | "model" | "preview" = "connection";
844
863
  let previewOrigin: "connection" | "model" = "model";
845
864
  const connectionState: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 };
@@ -873,6 +892,48 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
873
892
  });
874
893
  if (empty !== "confirm") return false;
875
894
  }
895
+ if (files.config.autoProbeOnSave !== false && draft.enabled) {
896
+ const wanted = new Set(probeTargetsFor(selectedDraft, selectedMetadata).map((model) => `${model.type}\u0000${model.id}`));
897
+ const targets = selectedDraft
898
+ .map((model, index) => ({ model, index, metadataIndex: selectedMetadata.findIndex((entry) => entry.type === model.type && entry.id === model.id) }))
899
+ .filter(({ model }) => wanted.has(`${model.type}\u0000${model.id}`));
900
+ if (targets.length) {
901
+ try {
902
+ const piAuth = draft.authMode === "reuse-pi" ? await ctx.modelRegistry.getProviderAuth(target.id) : undefined;
903
+ const fetchImpl = draft.proxyUrl ? createProviderFetch(draft.proxyUrl) : globalThis.fetch;
904
+ const entries = targets.map(({ model }) => ({
905
+ label: model.id,
906
+ run: (signal: AbortSignal) => {
907
+ const baseUrl = model.type === "chat" ? model.baseUrl ?? draft.baseUrl : draft.baseUrl;
908
+ if (!baseUrl) return Promise.resolve(null);
909
+ return probeChatParams(model, { fetch: fetchImpl, apiKey: resolveProbeApiKey(draft, piAuth, baseUrl), baseUrl, signal });
910
+ },
911
+ }));
912
+ const outcomes = await runAutoProbeFlow(ctx, language, entries);
913
+ for (let index = 0; index < targets.length; index++) {
914
+ const { model, metadataIndex } = targets[index]!;
915
+ const outcome = outcomes[index]!;
916
+ if (model.type !== "chat" || outcome.cancelled || !outcome.result) continue;
917
+ const result = outcome.result;
918
+ model.maxTokens = result.maxTokens;
919
+ model.reasoning = result.reasoning;
920
+ if (result.reasoning && result.thinkingLevelMap) model.thinkingLevelMap = { ...result.thinkingLevelMap };
921
+ else delete model.thinkingLevelMap;
922
+ const metadata = {
923
+ ...(metadataIndex >= 0 ? selectedMetadata[metadataIndex] : { type: model.type, id: model.id }),
924
+ contextSource: "probed" as const,
925
+ maxTokensSource: "probed" as const,
926
+ thinkingSource: "probed" as const,
927
+ verifiedAt: new Date().toISOString(),
928
+ };
929
+ if (metadataIndex >= 0) selectedMetadata[metadataIndex] = metadata; else selectedMetadata.push(metadata);
930
+ }
931
+ if (outcomes.some((outcome) => outcome.cancelled)) ctx.ui.notify(t(language, "autoProbeCancelled"), "info");
932
+ } catch {
933
+ // Probe failures are silent: unprobed models keep compatibility defaults.
934
+ }
935
+ }
936
+ }
876
937
  return true;
877
938
  };
878
939
  const adoptExisting = async (): Promise<boolean> => {
@@ -898,10 +959,15 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
898
959
 
899
960
  providerSession: while (true) {
900
961
  if (page === "connection") {
901
- const connection = await editConnectionPanel(ctx, language, draft, connectionState, adoptableIds().length > 0);
962
+ const connection = await editConnectionPanel(ctx, language, draft, connectionState, adoptableIds().length > 0, draft.hiddenRemoteIds?.length ?? 0);
902
963
  activeSecrets.push(draft.apiKeyRaw, draft.temporaryKey);
903
964
  if (connection === "models") { page = "model"; continue; }
904
965
  if (connection === "takeover") { if (await adoptExisting()) page = "model"; continue; }
966
+ if (connection === "restoreHidden") {
967
+ const restored = await showRestoreHiddenPanel(ctx, language, draft.hiddenRemoteIds ?? []);
968
+ if (restored.action === "apply") draft.hiddenRemoteIds = (draft.hiddenRemoteIds ?? []).filter((key) => !restored.restored.includes(key));
969
+ continue;
970
+ }
905
971
  if (connection === "testModels") {
906
972
  if (!draft.enabled) { ctx.ui.notify(t(language, "testProviderDisabled"), "warning"); continue; }
907
973
  if (draft.authMode === "temporary" || isDirty()) { ctx.ui.notify(t(language, "testDraftDirty"), "warning"); continue; }
@@ -934,7 +1000,8 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
934
1000
  const installedRaw = installedModels(ctx, target.id);
935
1001
  const installed = installedRaw.map(candidateFromInstalled);
936
1002
  const external = externalModels.map((model) => candidateFromExternal(model, externalMetadata.get(model.id)));
937
- const candidates = mergeCandidates(target.id, [...external, ...installed], managedBefore.map((model) => candidateFromManaged(model, persistedMetadata.get(`${model.type}\0${model.id}`))), cachedRemote);
1003
+ const visibleRemote = cachedRemote.filter((model) => !isHiddenRemote(model, draft.hiddenRemoteIds ?? []));
1004
+ const candidates = mergeCandidates(target.id, [...external, ...installed], managedBefore.map((model) => candidateFromManaged(model, persistedMetadata.get(`${model.type}\0${model.id}`))), visibleRemote);
938
1005
  const selection = await selectModels(ctx, language, target.id, draft, candidates, selectedDraft, modelState, selectedMetadata);
939
1006
  selectedDraft = selection.selected;
940
1007
  selectedMetadata = selection.metadata.map((entry) => ({ ...(selectedMetadata.find((old) => old.type === entry.type && old.id === entry.id) ?? {}), ...entry }));
@@ -987,6 +1054,9 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
987
1054
  continue;
988
1055
  }
989
1056
  if (selection.action === "fetch") {
1057
+ if (fetchInFlight) continue;
1058
+ fetchInFlight = true;
1059
+ try {
990
1060
  const fallbackBases = [
991
1061
  ...(oldProvider?.models ?? []).map((model) => typeof model.baseUrl === "string" ? model.baseUrl : undefined),
992
1062
  ...installedRaw.map((model) => model.baseUrl),
@@ -1029,12 +1099,13 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
1029
1099
  draft.discoveryUrl = result.value!.url;
1030
1100
  cachedRemote = [...result.value!.models];
1031
1101
  continue;
1102
+ } finally { fetchInFlight = false; }
1032
1103
  }
1033
1104
  if (await preparePreview()) { previewOrigin = "model"; page = "preview"; }
1034
1105
  continue;
1035
1106
  }
1036
1107
 
1037
- const details = buildPreviewDetails(language, target.id, managedBefore, selectedDraft, oldConfig, oldProvider, draft);
1108
+ const details = buildPreviewDetails(language, target.id, managedBefore, selectedDraft, oldConfig, oldProvider, draft, selectedMetadata);
1038
1109
  const preview = await showPreviewPanel(ctx, language, target.id, details.providerDetails, details.modelDetails, previewState);
1039
1110
  if (preview.action === "back") { page = previewOrigin; continue; }
1040
1111
  if (preview.action === "discard") {
@@ -1049,10 +1120,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
1049
1120
  if (discard === "discard") continue mainLoop;
1050
1121
  continue;
1051
1122
  }
1052
- const compatItems = selectedMetadata.filter((entry) => entry.contextSource === "compat" || entry.maxTokensSource === "compat").map((entry) => entry.type + '/' + entry.id);
1053
- const risk = compatItems.length ? [t(language, "compatSaveRisk", { items: compatItems.join(", ") })] : [];
1054
- const confirmed = await confirmAction(ctx, language, t(language, "finalSaveTitle"), [t(language, "finalSaveBody"), ...risk], [...details.providerDetails, ...details.modelDetails], t(language, "confirmSave"));
1055
- if (!confirmed) continue;
1123
+ // Preview is the sole save confirmation: Ctrl+S there commits the reviewed draft.
1056
1124
  try {
1057
1125
  const next = applyChanges(files, target.id, draft, selectedDraft, new Set(externalModels.map((model) => model.id)), selectedMetadata);
1058
1126
  const transaction = writeConfigTransaction(files.modelsSnapshot, files.configSnapshot, next.models, next.config);
package/src/config.ts CHANGED
@@ -60,6 +60,9 @@ export function parseModelConfig(source: ConfigSnapshot): ModelConfigFile {
60
60
  if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.providers)) {
61
61
  throw mcError("MCFG_E_CONFIG_SHAPE", { file: "model-config.json" });
62
62
  }
63
+ if (parsed.autoProbeOnSave !== undefined && typeof parsed.autoProbeOnSave !== "boolean") {
64
+ throw mcError("MCFG_E_CONFIG_SHAPE", { file: "model-config.json" });
65
+ }
63
66
  for (const [id, provider] of Object.entries(parsed.providers)) {
64
67
  if (!isRecord(provider)) throw mcError("MCFG_E_PROVIDER_SHAPE", { file: "model-config.json", provider: id });
65
68
  if (!new Set(["models-json", "reuse-pi", "anonymous"]).has(String(provider.authMode))) {
@@ -72,6 +75,7 @@ export function parseModelConfig(source: ConfigSnapshot): ModelConfigFile {
72
75
  if (provider.imageApi !== undefined && !IMAGE_API_NAMES.includes(String(provider.imageApi) as typeof IMAGE_API_NAMES[number])) {
73
76
  throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${id}/image`, api: String(provider.imageApi) });
74
77
  }
78
+ if (provider.hiddenRemoteIds !== undefined && (!Array.isArray(provider.hiddenRemoteIds) || provider.hiddenRemoteIds.some((modelId) => typeof modelId !== "string"))) throw mcError("MCFG_E_PROVIDER_SHAPE", { file: "model-config.json", provider: id });
75
79
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
76
80
  if (Array.isArray(provider.models) && provider.models.some((model) => !isRecord(model))) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
77
81
  for (const model of Array.isArray(provider.models) ? provider.models : []) {
package/src/i18n.ts CHANGED
@@ -38,6 +38,13 @@ const ZH = {
38
38
  detailConnectionTitle: "连接摘要",
39
39
  detailModelsTitle: "模型概况",
40
40
  detailFetchHint: "进入“配置模型”后按 Ctrl+R 获取远程模型列表",
41
+ detailRestoreHidden: "[Ctrl+H] 恢复隐藏模型({count})",
42
+ panelHiddenRemoteCount: "已隐藏远程模型:{count}",
43
+ restoreHiddenTitle: "恢复隐藏远程模型",
44
+ restoreHiddenItemDetail: "勾选后恢复到后续远程获取结果;保存前仅修改内存草稿。",
45
+ restoreHiddenAll: "[Ctrl+A] 全部恢复",
46
+ restoreHiddenApply: "[Ctrl+S] 应用恢复",
47
+ restoreHiddenBack: "[Esc] 返回",
41
48
  detailEditConnection: "[Ctrl+E] 编辑连接",
42
49
  detailConfigureModels: "[Ctrl+M] 配置模型",
43
50
  detailTakeover: "[Ctrl+T] 全选并纳入管理",
@@ -99,6 +106,7 @@ const ZH = {
99
106
  modelProbeContext: "[Ctrl+K] 探测上下文容量",
100
107
  modelProbeThinking: "[Ctrl+L] 探测思考档位",
101
108
  modelFetch: "[Ctrl+R] 获取远程模型列表",
109
+ modelHideRemote: "[Ctrl+Del] 隐藏远程模型",
102
110
  modelPreview: "[Ctrl+S] 预览更改",
103
111
  modelBack: "[Esc] 返回服务商",
104
112
  addProviderTitle: "添加服务商",
@@ -297,6 +305,10 @@ const ZH = {
297
305
  previewSettingsUnchanged: "服务商设置无更改",
298
306
  previewKeyUpdated: "API 密钥:将更新,内容已隐藏",
299
307
  previewModelsAdded: "添加模型:{items}",
308
+ autoProbePanelTitle: "保存前参数探测",
309
+ autoProbeProgress: "探测 {done}/{total}:{model};Esc 取消剩余(已完成项保留)",
310
+ autoProbeCancelled: "已取消自动探测;剩余模型沿用兼容默认值(128K/16K)",
311
+ previewProbedDefaults: "参数已自动探测(实测)",
300
312
  previewModelsRemoved: "移除模型:{items}",
301
313
  previewModelsModified: "修改模型:{items}",
302
314
  previewModelsUnchanged: "未变化模型:{count}",
@@ -312,6 +324,7 @@ const ZH = {
312
324
  selectorReadonlyRuntime: "此模型来自 Pi 内置目录或其他运行时扩展,不能在这里移除",
313
325
  selectorTypeConflict: "不能切换为{type}:{identity} 已存在",
314
326
  selectorReadonlyTag: "只读",
327
+ selectorRemoteHidden: "已隐藏远程模型 {id};保存后后续获取也会隐藏",
315
328
  selectorTypeChat: "聊天",
316
329
  selectorTypeClassifier: "判定",
317
330
  selectorTypeImage: "图像生成",
@@ -372,6 +385,13 @@ const EN = {
372
385
  detailConnectionTitle: "Connection Summary",
373
386
  detailModelsTitle: "Model Overview",
374
387
  detailFetchHint: "Open Configure models, then press Ctrl+R to fetch the remote model list",
388
+ detailRestoreHidden: "[Ctrl+H] Restore hidden models ({count})",
389
+ panelHiddenRemoteCount: "Hidden remote models: {count}",
390
+ restoreHiddenTitle: "Restore hidden remote models",
391
+ restoreHiddenItemDetail: "Checked entries return to future remote fetch results; this only changes the in-memory draft until save.",
392
+ restoreHiddenAll: "[Ctrl+A] Restore all",
393
+ restoreHiddenApply: "[Ctrl+S] Apply restore",
394
+ restoreHiddenBack: "[Esc] Back",
375
395
  detailEditConnection: "[Ctrl+E] Edit connection",
376
396
  detailConfigureModels: "[Ctrl+M] Configure models",
377
397
  detailTakeover: "[Ctrl+T] Select all and manage",
@@ -433,6 +453,7 @@ const EN = {
433
453
  modelProbeContext: "[Ctrl+K] Probe context capacity",
434
454
  modelProbeThinking: "[Ctrl+L] Probe thinking levels",
435
455
  modelFetch: "[Ctrl+R] Fetch remote model list",
456
+ modelHideRemote: "[Ctrl+Del] Hide remote model",
436
457
  modelPreview: "[Ctrl+S] Preview changes",
437
458
  modelBack: "[Esc] Back to provider",
438
459
  addProviderTitle: "Add Provider",
@@ -631,6 +652,10 @@ const EN = {
631
652
  previewSettingsUnchanged: "Provider settings unchanged",
632
653
  previewKeyUpdated: "API key: will be updated; content is hidden",
633
654
  previewModelsAdded: "Add models: {items}",
655
+ autoProbePanelTitle: "Pre-save parameter probe",
656
+ autoProbeProgress: "Probing {done}/{total}: {model}; Esc cancels the rest (completed results are kept)",
657
+ autoProbeCancelled: "Auto-probe cancelled; remaining models keep compatibility defaults (128K/16K)",
658
+ previewProbedDefaults: "parameters auto-probed",
634
659
  previewModelsRemoved: "Remove models: {items}",
635
660
  previewModelsModified: "Modify models: {items}",
636
661
  previewModelsUnchanged: "Unchanged models: {count}",
@@ -646,6 +671,7 @@ const EN = {
646
671
  selectorReadonlyRuntime: "This model comes from Pi's built-in catalog or another runtime extension and cannot be removed here",
647
672
  selectorTypeConflict: "Cannot change to {type}: {identity} already exists",
648
673
  selectorReadonlyTag: "read-only",
674
+ selectorRemoteHidden: "Remote model {id} is hidden and will remain hidden after saving and future fetches",
649
675
  selectorTypeChat: "Chat",
650
676
  selectorTypeClassifier: "Classification",
651
677
  selectorTypeImage: "Image generation",
@@ -130,13 +130,13 @@ function privateHostname(hostname: string): boolean {
130
130
  const low = Number.parseInt(mappedHex[2]!, 16);
131
131
  normalized = `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
132
132
  }
133
- if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
133
+ if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::" || normalized === "0:0:0:0:0:0:0:0" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
134
134
  const ipv4 = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u)?.slice(1).map(Number);
135
135
  if (ipv4 && ipv4.every((part) => part >= 0 && part <= 255)) {
136
136
  const [a, b] = ipv4;
137
137
  return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
138
138
  }
139
- return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:");
139
+ return normalized.startsWith("fc") || normalized.startsWith("fd") || /^(?:fe8|fe9|fea|feb)[0-9a-f]:/u.test(normalized);
140
140
  }
141
141
 
142
142
  export function validatePublicImageUrl(value: string): URL {
package/src/panel.ts CHANGED
@@ -43,7 +43,7 @@ export type ProviderPanelResult =
43
43
 
44
44
  export type ConnectionPanelResult =
45
45
  | { action: "field"; fieldId: string }
46
- | { action: "models" | "takeover" | "testModels" | "preview" | "back" };
46
+ | { action: "models" | "takeover" | "restoreHidden" | "testModels" | "preview" | "back" };
47
47
 
48
48
  export type PreviewPanelResult = { action: "save" | "back" | "discard" };
49
49
 
@@ -434,11 +434,13 @@ export async function showConnectionPanel(
434
434
  fields: PanelItem[],
435
435
  state: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 },
436
436
  canTakeover = false,
437
+ hiddenRemoteCount = 0,
437
438
  ): Promise<ConnectionPanelResult> {
438
439
  const actions: FooterAction[] = [
439
440
  { id: "edit", label: t(language, "connectionModify"), key: Key.enter },
440
441
  { id: "models", label: t(language, "detailConfigureModels"), key: Key.ctrl("m") },
441
442
  ...(canTakeover ? [{ id: "takeover", label: t(language, "detailTakeover"), key: Key.ctrl("t") }] : []),
443
+ ...(hiddenRemoteCount > 0 ? [{ id: "restoreHidden", label: t(language, "detailRestoreHidden", { count: hiddenRemoteCount }), key: Key.ctrl("h") }] : []),
442
444
  { id: "testModels", label: t(language, "detailTestModels"), key: Key.ctrl("p") },
443
445
  { id: "preview", label: t(language, "connectionPreview"), key: Key.ctrl("s") },
444
446
  { id: "back", label: t(language, "connectionBack"), key: Key.escape },
@@ -448,10 +450,33 @@ export async function showConnectionPanel(
448
450
  if (result.kind === "back" || result.id === "back") return { action: "back" };
449
451
  if (result.kind === "item" && result.id) return { action: "field", fieldId: result.id };
450
452
  if (result.id === "edit" && state.selectedId) return { action: "field", fieldId: state.selectedId };
451
- if (result.id === "models" || result.id === "takeover" || result.id === "testModels" || result.id === "preview") return { action: result.id };
453
+ if (result.id === "models" || result.id === "takeover" || result.id === "restoreHidden" || result.id === "testModels" || result.id === "preview") return { action: result.id };
452
454
  return { action: "back" };
453
455
  }
454
456
 
457
+ export async function showRestoreHiddenPanel(
458
+ ctx: ExtensionCommandContext,
459
+ language: UiLanguage,
460
+ hiddenKeys: string[],
461
+ state: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 },
462
+ ): Promise<{ action: "apply" | "back"; restored: string[] }> {
463
+ const selected = new Set<string>();
464
+ const actions: FooterAction[] = [
465
+ { id: "all", label: t(language, "restoreHiddenAll"), key: Key.ctrl("a") },
466
+ { id: "apply", label: t(language, "restoreHiddenApply"), key: Key.ctrl("s") },
467
+ { id: "back", label: t(language, "restoreHiddenBack"), key: Key.escape },
468
+ ];
469
+ while (true) {
470
+ const items = hiddenKeys.map((key) => ({ id: key, label: `${selected.has(key) ? "[✓]" : "[ ]"} ${key.replace("\u0000", "/")}`, details: [t(language, "restoreHiddenItemDetail")] }));
471
+ const result = await ctx.ui.custom<{ kind: "item" | "action" | "language" | "back"; id?: string }>((tui, theme, _keys, done) =>
472
+ new MenuDetailPanel(tui, theme, language, t(language, "restoreHiddenTitle"), items, actions, state, true, done), MODEL_CONFIG_OVERLAY_OPTIONS);
473
+ if (result.kind === "back" || result.id === "back") return { action: "back", restored: [] };
474
+ if (result.kind === "item" && result.id) { if (selected.has(result.id)) selected.delete(result.id); else selected.add(result.id); continue; }
475
+ if (result.id === "all") { for (const hiddenKey of hiddenKeys) selected.add(hiddenKey); continue; }
476
+ if (result.id === "apply") return { action: "apply", restored: [...selected] };
477
+ }
478
+ }
479
+
455
480
  export async function showPreviewPanel(
456
481
  ctx: ExtensionCommandContext,
457
482
  language: UiLanguage,
package/src/selector.ts CHANGED
@@ -15,7 +15,7 @@ import { t, type UiLanguage } from "./i18n.ts";
15
15
  import { COMPAT_CONTEXT_WINDOW, COMPAT_MAX_TOKENS } from "./discovery.ts";
16
16
  import { MODEL_CONFIG_OVERLAY_OPTIONS, overlayViewportRows, renderActionFooter, renderPanelBox, type FooterAction } from "./panel.ts";
17
17
  import type { CandidateModel, ManagedModel, ManagedModelType, ModelMetadataEntry, ProviderDraft, SelectionResult, ThinkingLevelMap, ThinkingMetadataSource } from "./types.ts";
18
- import { identityOf } from "./types.ts";
18
+ import { hiddenRemoteKey, identityOf } from "./types.ts";
19
19
  import { protocolThinkingMap } from "./thinking-defaults.ts";
20
20
  import { describeThinking } from "./model-editor.ts";
21
21
 
@@ -218,6 +218,19 @@ class ModelSelector implements Component, Focusable {
218
218
  this.tui.requestRender();
219
219
  }
220
220
 
221
+ private hideCurrentRemote(): void {
222
+ const row = this.current();
223
+ if (!row || row.source !== "remote") return;
224
+ const hidden = new Set(this.draft.hiddenRemoteIds ?? []);
225
+ hidden.add(hiddenRemoteKey(row));
226
+ this.draft.hiddenRemoteIds = [...hidden].sort((a, b) => a.localeCompare(b));
227
+ this.selected.delete(this.key(row));
228
+ const index = this.rows.indexOf(row);
229
+ if (index >= 0) this.rows.splice(index, 1);
230
+ this.status = t(this.language, "selectorRemoteHidden", { id: row.id });
231
+ this.refresh();
232
+ }
233
+
221
234
  private cycleType(): void {
222
235
  const row = this.filtered[this.index];
223
236
  if (!row?.mutableType) return;
@@ -277,6 +290,7 @@ class ModelSelector implements Component, Focusable {
277
290
  { id: "probe", label: t(this.language, "modelProbeContext"), key: Key.ctrl("k") },
278
291
  { id: "probeThinking", label: t(this.language, "modelProbeThinking"), key: Key.ctrl("l") },
279
292
  { id: "fetch", label: t(this.language, "modelFetch"), key: Key.ctrl("r") },
293
+ { id: "hideRemote", label: t(this.language, "modelHideRemote"), key: Key.ctrl("delete"), enabled: this.current()?.source === "remote" },
280
294
  { id: "preview", label: t(this.language, "modelPreview"), key: Key.ctrl("s") },
281
295
  { id: "back", label: t(this.language, "modelBack"), key: Key.escape },
282
296
  ];
@@ -301,12 +315,14 @@ class ModelSelector implements Component, Focusable {
301
315
  if (action.id === "selectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row)); this.request(); return; }
302
316
  if (action.id === "deselectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row)); this.request(); return; }
303
317
  if (action.id === "cycleType") { this.cycleType(); return; }
318
+ if (action.id === "hideRemote") { this.hideCurrentRemote(); return; }
304
319
  this.finish(action.id as SelectionResult["action"]);
305
320
  }
306
321
 
307
322
  handleInput(data: string): void {
308
323
  if (data === "/") { this.state.focus = "search"; this.request(); return; }
309
324
  if (matchesKey(data, Key.ctrl("r"))) { this.finish("fetch"); return; }
325
+ if (matchesKey(data, Key.ctrl("delete"))) { this.hideCurrentRemote(); return; }
310
326
  if (matchesKey(data, Key.ctrl("e"))) { this.finish("edit"); return; }
311
327
  if (matchesKey(data, Key.ctrl("k"))) { this.finish("probe"); return; }
312
328
  if (matchesKey(data, Key.ctrl("l"))) { this.finish("probeThinking"); return; }
package/src/types.ts CHANGED
@@ -71,6 +71,8 @@ export interface ProviderConnectionConfig {
71
71
  defaultContextWindow?: number;
72
72
  defaultMaxTokens?: number;
73
73
  managedChatIds?: string[];
74
+ /** Remote catalog keys (`type\0id`) hidden by the user; retained across discovery refreshes. */
75
+ hiddenRemoteIds?: string[];
74
76
  models?: Array<ManagedClassifierModel | ManagedImageModel>;
75
77
  modelMetadata?: ModelMetadataEntry[];
76
78
  }
@@ -78,6 +80,8 @@ export interface ProviderConnectionConfig {
78
80
  export interface ModelConfigFile {
79
81
  $schema?: string;
80
82
  version: 1;
83
+ /** Pre-save minimal parameter probe for newly checked chat models lacking context/max metadata; default true. */
84
+ autoProbeOnSave?: boolean;
81
85
  providers: Record<string, ProviderConnectionConfig>;
82
86
  [key: string]: unknown;
83
87
  }
@@ -115,6 +119,7 @@ export interface ProviderDraft {
115
119
  classifierApi: string;
116
120
  imageApi: ImageApiName;
117
121
  defaultType: ManagedModelType;
122
+ hiddenRemoteIds?: string[];
118
123
  }
119
124
 
120
125
  export type CandidateModel = ManagedModel & {
@@ -142,6 +147,20 @@ export interface ConfigSnapshot {
142
147
  text?: string;
143
148
  }
144
149
 
150
+ /** Persistent remote-hide identity. Legacy id-only entries are interpreted as chat-only on read. */
151
+ export function hiddenRemoteKey(model: Pick<ManagedModel, "id" | "type">): string { return `${model.type}\u0000${model.id}`; }
152
+
153
+ export function isHiddenRemote(model: Pick<ManagedModel, "id" | "type">, hiddenKeys: readonly string[]): boolean {
154
+ const key = hiddenRemoteKey(model);
155
+ // Compatibility: prior releases stored bare IDs; treating those as chat-only prevents
156
+ // a legacy hidden chat model from also hiding classifier/image models with the same ID.
157
+ return hiddenKeys.includes(key) || model.type === "chat" && hiddenKeys.includes(model.id);
158
+ }
159
+
160
+ export function normalizeHiddenRemoteKeys(hiddenKeys: readonly string[]): string[] {
161
+ return [...new Set(hiddenKeys.map((key) => key.includes("\u0000") ? key : hiddenRemoteKey({ type: "chat", id: key })))].sort((a, b) => a.localeCompare(b));
162
+ }
163
+
145
164
  export function identityOf(model: Pick<ManagedModel, "id" | "type"> & { provider?: string }, provider?: string): ModelIdentity {
146
165
  const p = provider ?? model.provider ?? "";
147
166
  return `${p}\u0000${model.type}\u0000${model.id}`;