@wuyaos/pi-model-config 0.1.5 → 0.1.7

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.
@@ -192,6 +192,14 @@
192
192
  },
193
193
  "uniqueItems": true
194
194
  },
195
+ "hiddenRemoteIds": {
196
+ "description": "Remote catalog model IDs hidden by the user across discovery refreshes.",
197
+ "type": "array",
198
+ "items": {
199
+ "type": "string"
200
+ },
201
+ "uniqueItems": true
202
+ },
195
203
  "models": {
196
204
  "type": "array",
197
205
  "items": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wuyaos/pi-model-config",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Pi provider 连接、远程发现与 chat/classifier/image 模型配置 TUI",
6
6
  "keywords": [
package/src/command.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  showPreviewPanel,
19
19
  showProviderPanel,
20
20
  showConfirmationPanel,
21
+ showRestoreHiddenPanel,
21
22
  type MenuPanelState,
22
23
  type OverviewPanelState,
23
24
  type PanelItem,
@@ -36,7 +37,7 @@ import type {
36
37
  ProviderConnectionConfig,
37
38
  ProviderDraft,
38
39
  } from "./types.ts";
39
- import { identityOf } from "./types.ts";
40
+ import { hiddenRemoteKey, identityOf, isHiddenRemote, normalizeHiddenRemoteKeys } from "./types.ts";
40
41
  import { loadUiLanguage, saveUiLanguage } from "./ui-config.ts";
41
42
  import { secretInput, withCancellableLoader } from "./ui.ts";
42
43
  import { showModelTestFlow } from "./model-test.ts";
@@ -157,6 +158,7 @@ function draftFrom(providerId: string, provider: ModelsJsonProvider | undefined,
157
158
  classifierApi: config?.classifierApi ?? "typesafe-system-one",
158
159
  imageApi: config?.imageApi ?? "openrouter-images",
159
160
  defaultType: config?.defaultType ?? "chat",
161
+ hiddenRemoteIds: normalizeHiddenRemoteKeys(config?.hiddenRemoteIds ?? []),
160
162
  };
161
163
  }
162
164
 
@@ -248,9 +250,9 @@ function connectionPanelFields(language: UiLanguage, draft: ProviderDraft): Pane
248
250
  ];
249
251
  }
250
252
 
251
- async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, state: MenuPanelState, canTakeover: boolean): Promise<"models" | "takeover" | "testModels" | "preview" | "back"> {
253
+ async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, state: MenuPanelState, canTakeover: boolean, hiddenRemoteCount: number): Promise<"models" | "takeover" | "restoreHidden" | "testModels" | "preview" | "back"> {
252
254
  while (true) {
253
- const result = await showConnectionPanel(ctx, language, draft.id, connectionPanelFields(language, draft), state, canTakeover);
255
+ const result = await showConnectionPanel(ctx, language, draft.id, connectionPanelFields(language, draft), state, canTakeover, hiddenRemoteCount);
254
256
  if (result.action !== "field") return result.action;
255
257
  try {
256
258
  switch (result.fieldId) {
@@ -409,7 +411,9 @@ export function applyChanges(
409
411
  const originalRows = new Map(
410
412
  (oldProvider.models ?? []).filter((row) => typeof row.id === "string").map((row) => [row.id as string, row]),
411
413
  );
412
- const managedScopeIds = new Set([...oldManagedIds, ...adoptedChatIds]);
414
+ // models.json rows are managed scope by default: selected rows are rewritten (merging
415
+ // unknown fields), unchecked rows are removed. Only rows without a string id are kept verbatim.
416
+ const managedScopeIds = new Set([...oldManagedIds, ...adoptedChatIds, ...originalRows.keys()]);
413
417
  const unmanagedRows = (oldProvider.models ?? []).filter((row) => typeof row.id !== "string" || !managedScopeIds.has(row.id));
414
418
  const selectedChat = selected.filter((model) => model.type === "chat");
415
419
  provider.models = [...unmanagedRows, ...selectedChat.map((model) => ({
@@ -422,8 +426,10 @@ export function applyChanges(
422
426
  if (draft.authMode === "models-json") provider.apiKey = draft.apiKeyRaw; else delete provider.apiKey;
423
427
  models.providers[providerId] = provider;
424
428
 
429
+ const normalizedHiddenRemoteIds = normalizeHiddenRemoteKeys(draft.hiddenRemoteIds ?? []);
430
+ const { hiddenRemoteIds: _oldHiddenRemoteIds, ...oldConfigWithoutHiddenRemoteIds } = oldConfig ?? {};
425
431
  config.providers[providerId] = {
426
- ...(oldConfig ?? {}),
432
+ ...oldConfigWithoutHiddenRemoteIds,
427
433
  name: draft.name,
428
434
  owned: draft.owned,
429
435
  enabled: draft.enabled,
@@ -434,6 +440,7 @@ export function applyChanges(
434
440
  imageApi: draft.imageApi,
435
441
  defaultType: draft.defaultType,
436
442
  managedChatIds: selectedChat.map((model) => model.id),
443
+ ...(normalizedHiddenRemoteIds.length ? { hiddenRemoteIds: normalizedHiddenRemoteIds } : {}),
437
444
  models: selected.filter((model): model is Extract<ManagedModel, { type: "classifier" | "image" }> => model.type !== "chat"),
438
445
  modelMetadata: modelMetadata.map((entry) => structuredClone(entry)),
439
446
  };
@@ -512,6 +519,7 @@ export interface ProviderChoice {
512
519
  id: string;
513
520
  label: string;
514
521
  priority: number;
522
+ enabled: boolean;
515
523
  }
516
524
 
517
525
  export function buildConfiguredProviderChoices(
@@ -522,20 +530,21 @@ export function buildConfiguredProviderChoices(
522
530
  ): ProviderChoice[] {
523
531
  const ids = new Set([...Object.keys(models.providers), ...Object.keys(config.providers)]);
524
532
  const choices: ProviderChoice[] = [];
525
- for (const id of ids) {
533
+ for (const id of [...ids].sort((a, b) => a.localeCompare(b))) {
526
534
  const owned = config.providers[id]?.owned === true;
527
535
  const builtin = builtinProviderIds.has(id);
528
536
  const name = config.providers[id]?.name ?? models.providers[id]?.name;
529
537
  const displayBase = name && name !== id ? `${name} (${id})` : id;
530
538
  const display = config.providers[id]?.enabled === false ? `${displayBase} · ${t(language, "providerDisabled")}` : displayBase;
531
539
  const status = t(language, builtin ? "providerTagBuiltin" : "providerTagCustom");
540
+ const enabled = config.providers[id]?.enabled !== false;
532
541
  choices.push(owned
533
- ? { id, label: t(language, "providerLabelCustom", { display, status }), priority: 0 }
542
+ ? { id, label: t(language, "providerLabelCustom", { display, status }), priority: 0, enabled }
534
543
  : builtin
535
- ? { id, label: t(language, "providerLabelBuiltin", { display, status }), priority: 1 }
536
- : { id, label: t(language, "providerLabelCustom", { display, status }), priority: 2 });
544
+ ? { id, label: t(language, "providerLabelBuiltin", { display, status }), priority: 1, enabled }
545
+ : { id, label: t(language, "providerLabelCustom", { display, status }), priority: 2, enabled });
537
546
  }
538
- return choices.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
547
+ return choices.sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.priority - b.priority || a.id.localeCompare(b.id));
539
548
  }
540
549
 
541
550
  function buildProviderPanelItems(
@@ -589,7 +598,7 @@ function buildProviderPanelItems(
589
598
  export function buildBuiltinProviderChoices(language: UiLanguage, builtinProviderIds: Iterable<string>): ProviderChoice[] {
590
599
  return [...new Set(builtinProviderIds)]
591
600
  .sort((a, b) => a.localeCompare(b))
592
- .map((id) => ({ id, label: t(language, "providerLabelBuiltin", { display: id, status: t(language, "providerTagBuiltin") }), priority: 0 }));
601
+ .map((id) => ({ id, label: t(language, "providerLabelBuiltin", { display: id, status: t(language, "providerTagBuiltin") }), priority: 0, enabled: true }));
593
602
  }
594
603
 
595
604
  async function chooseBuiltinProvider(ctx: ExtensionCommandContext, language: UiLanguage): Promise<{ id: string; isNew: false } | undefined> {
@@ -655,6 +664,7 @@ function draftPanelItem(language: UiLanguage, draft: ProviderDraft, selected: Ma
655
664
  ],
656
665
  modelDetails: [
657
666
  t(language, "panelSummaryModels", { chat, classifier, image }),
667
+ ...(draft.hiddenRemoteIds?.length ? [t(language, "panelHiddenRemoteCount", { count: draft.hiddenRemoteIds.length })] : []),
658
668
  t(language, "detailFetchHint"),
659
669
  ...(adoptableCount ? [t(language, "panelTakeoverCount", { count: adoptableCount })] : []),
660
670
  ],
@@ -795,20 +805,50 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
795
805
  const draft = draftFrom(target.id, oldProvider, oldConfig, target.isNew);
796
806
  const baselineDraft = structuredClone(draft);
797
807
  const managedBefore = previousManaged(oldProvider, oldConfig);
798
- const baselineSelected = structuredClone(managedBefore);
799
808
  const persistedMetadata = new Map((oldConfig?.modelMetadata ?? []).map((entry) => [`${entry.type}\0${entry.id}`, entry]));
800
- let selectedMetadata: ModelMetadataEntry[] = managedBefore.map((model) => {
801
- const stored = persistedMetadata.get(`${model.type}\0${model.id}`);
802
- if (stored) return structuredClone(stored);
803
- const raw = model.type === "chat" ? oldProvider?.models?.find((entry) => entry.id === model.id) : oldConfig?.models?.find((entry) => entry.type === model.type && entry.id === model.id);
804
- const explicitThinkingMap = model.type === "chat" && typeof (raw as any)?.thinkingLevelMap === "object" && (raw as any).thinkingLevelMap !== null && !Array.isArray((raw as any).thinkingLevelMap);
805
- return { type: model.type, id: model.id, contextSource: model.type === "image" ? undefined : typeof (raw as any)?.contextWindow === "number" ? "existing" : "compat", maxTokensSource: model.type === "chat" ? typeof (raw as any)?.maxTokens === "number" ? "existing" : "compat" : undefined, thinkingSource: explicitThinkingMap ? "existing" : undefined };
806
- });
809
+ const parseConfig: ProviderConnectionConfig = oldConfig ?? {
810
+ authMode: draft.authMode === "temporary" ? "reuse-pi" : draft.authMode,
811
+ chatApi: draft.chatApi,
812
+ };
813
+ // models.json rows outside managedChatIds are directly editable and become
814
+ // managed on the next save; only runtime catalog rows stay read-only.
815
+ const managedChatIdSet = new Set(oldConfig?.managedChatIds ?? []);
816
+ const externalModels: ManagedModel[] = [];
817
+ const externalMetadata = new Map<string, ModelMetadataEntry>();
818
+ for (const raw of oldProvider?.models ?? []) {
819
+ if (typeof raw.id !== "string" || managedChatIdSet.has(raw.id)) continue;
820
+ const model = parseManagedChat(raw, oldProvider!, parseConfig, true);
821
+ if (!model) continue;
822
+ externalModels.push(model);
823
+ const explicitMap = typeof raw.thinkingLevelMap === "object" && raw.thinkingLevelMap !== null && !Array.isArray(raw.thinkingLevelMap);
824
+ externalMetadata.set(model.id, persistedMetadata.get(`${model.type}\0${model.id}`) ?? {
825
+ type: model.type,
826
+ id: model.id,
827
+ contextSource: model.type === "image" ? undefined : typeof raw.contextWindow === "number" ? "existing" : "compat",
828
+ maxTokensSource: model.type === "chat" ? typeof raw.maxTokens === "number" ? "existing" : "compat" : undefined,
829
+ thinkingSource: model.type === "chat" ? explicitMap ? "existing" as const : protocolThinkingMap(model.api) ? "protocol" as const : undefined : undefined,
830
+ });
831
+ }
832
+ const metadataEntryFor = (model: ManagedModel, raw: Record<string, unknown> | undefined, fallback: ModelMetadataEntry | undefined): ModelMetadataEntry => fallback ?? {
833
+ type: model.type,
834
+ id: model.id,
835
+ contextSource: model.type === "image" ? undefined : typeof raw?.contextWindow === "number" ? "existing" : "compat",
836
+ maxTokensSource: model.type === "chat" ? typeof raw?.maxTokens === "number" ? "existing" : "compat" : undefined,
837
+ thinkingSource: model.type === "chat" && typeof raw?.thinkingLevelMap === "object" && raw.thinkingLevelMap !== null && !Array.isArray(raw.thinkingLevelMap) ? "existing" as const : undefined,
838
+ };
839
+ let selectedMetadata: ModelMetadataEntry[] = [
840
+ ...managedBefore.map((model) => {
841
+ const raw = model.type === "chat" ? oldProvider?.models?.find((entry) => entry.id === model.id) : oldConfig?.models?.find((entry) => entry.type === model.type && entry.id === model.id);
842
+ return structuredClone(metadataEntryFor(model, raw as Record<string, unknown> | undefined, persistedMetadata.get(`${model.type}\0${model.id}`)));
843
+ }),
844
+ ...externalModels.map((model) => structuredClone(externalMetadata.get(model.id)!)),
845
+ ];
846
+ const baselineSelected = structuredClone([...managedBefore, ...externalModels]);
807
847
  const baselineMetadata = structuredClone(selectedMetadata);
808
848
 
809
- let selectedDraft = structuredClone(managedBefore);
810
- const adoptedChatIds = new Set<string>();
849
+ let selectedDraft = structuredClone(baselineSelected);
811
850
  let cachedRemote: CandidateModel[] = [];
851
+ let fetchInFlight = false;
812
852
  let page: "connection" | "model" | "preview" = "connection";
813
853
  let previewOrigin: "connection" | "model" = "model";
814
854
  const connectionState: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 };
@@ -816,18 +856,14 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
816
856
  const previewState: MenuPanelState = { selectedId: "provider", focus: "list", actionIndex: 0, detailScroll: 0 };
817
857
  activeSecrets.push(draft.apiKeyRaw);
818
858
 
819
- const adoptableIds = () => {
820
- const alreadyManaged = new Set(oldConfig?.managedChatIds ?? []);
821
- return (oldProvider?.models ?? [])
822
- .filter((model) => typeof model.id === "string" && !alreadyManaged.has(model.id) && !adoptedChatIds.has(model.id))
823
- .map((model) => model.id as string);
824
- };
859
+ const adoptableIds = () => externalModels
860
+ .filter((model) => !selectedDraft.some((selected) => selected.id === model.id && selected.type === model.type))
861
+ .map((model) => model.id);
825
862
  const selectionFingerprint = (models: ManagedModel[]) => JSON.stringify([...models].sort((a, b) => identityOf(a, target.id).localeCompare(identityOf(b, target.id))));
826
863
  const metadataFingerprint = (entries: ModelMetadataEntry[]) => JSON.stringify([...entries].sort((a, b) => `${a.type}\0${a.id}`.localeCompare(`${b.type}\0${b.id}`)));
827
864
  const isDirty = () => JSON.stringify(draft) !== JSON.stringify(baselineDraft)
828
865
  || selectionFingerprint(selectedDraft) !== selectionFingerprint(baselineSelected)
829
- || metadataFingerprint(selectedMetadata) !== metadataFingerprint(baselineMetadata)
830
- || adoptedChatIds.size > 0;
866
+ || metadataFingerprint(selectedMetadata) !== metadataFingerprint(baselineMetadata);
831
867
  const preparePreview = async (): Promise<boolean> => {
832
868
  if (draft.authMode === "temporary") {
833
869
  ctx.ui.notify(t(language, "temporarySaveBlocked"), "warning");
@@ -835,7 +871,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
835
871
  }
836
872
  try { validateSelection(draft, selectedDraft, installedModels(ctx, target.id), oldProvider); }
837
873
  catch (error) { ctx.ui.notify(displayError(language, error, activeSecrets), "error"); return false; }
838
- if ((managedBefore.length > 0 || adoptedChatIds.size > 0) && selectedDraft.length === 0) {
874
+ if (baselineSelected.length > 0 && selectedDraft.length === 0) {
839
875
  const empty = await showConfirmationPanel(ctx, {
840
876
  title: t(language, "emptyTitle"), summary: [t(language, "emptyBody")], severity: "error",
841
877
  defaultActionIndex: 0, cancelActionId: "cancel",
@@ -849,10 +885,11 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
849
885
  return true;
850
886
  };
851
887
  const adoptExisting = async (): Promise<boolean> => {
852
- const ids = adoptableIds();
853
- if (!ids.length) { ctx.ui.notify(t(language, "takeoverModelsNone"), "info"); return false; }
888
+ const selectedKeys = new Set(selectedDraft.map((model) => identityOf(model, target.id)));
889
+ const missing = externalModels.filter((model) => !selectedKeys.has(identityOf(model, target.id)));
890
+ if (!missing.length) { ctx.ui.notify(t(language, "takeoverModelsNone"), "info"); return false; }
854
891
  const action = await showConfirmationPanel(ctx, {
855
- title: t(language, "takeoverModelsTitle"), summary: [t(language, "takeoverModelsBody", { provider: target.id, count: ids.length })],
892
+ title: t(language, "takeoverModelsTitle"), summary: [t(language, "takeoverModelsBody", { provider: target.id, count: missing.length })],
856
893
  details: [t(language, "diffSafety")], severity: "warning", defaultActionIndex: 0, cancelActionId: "cancel",
857
894
  actions: [
858
895
  { id: "cancel", label: t(language, "confirmCancel") },
@@ -860,26 +897,25 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
860
897
  ],
861
898
  });
862
899
  if (action !== "confirm") return false;
863
- const parseConfig: ProviderConnectionConfig = oldConfig ?? {
864
- authMode: draft.authMode === "temporary" ? "reuse-pi" : draft.authMode,
865
- chatApi: draft.chatApi,
866
- };
867
- const selectedKeys = new Set(selectedDraft.map((model) => identityOf(model, target.id)));
868
- for (const raw of oldProvider?.models ?? []) {
869
- if (typeof raw.id !== "string" || !ids.includes(raw.id)) continue;
870
- const model = parseManagedChat(raw, oldProvider!, parseConfig, true);
871
- if (model && !selectedKeys.has(identityOf(model, target.id))) selectedDraft.push(model);
872
- adoptedChatIds.add(raw.id);
900
+ for (const model of missing) {
901
+ selectedDraft.push(structuredClone(model));
902
+ const metadata = externalMetadata.get(model.id)!;
903
+ if (!selectedMetadata.some((entry) => entry.type === metadata.type && entry.id === metadata.id)) selectedMetadata.push(structuredClone(metadata));
873
904
  }
874
905
  return true;
875
906
  };
876
907
 
877
908
  providerSession: while (true) {
878
909
  if (page === "connection") {
879
- const connection = await editConnectionPanel(ctx, language, draft, connectionState, adoptableIds().length > 0);
910
+ const connection = await editConnectionPanel(ctx, language, draft, connectionState, adoptableIds().length > 0, draft.hiddenRemoteIds?.length ?? 0);
880
911
  activeSecrets.push(draft.apiKeyRaw, draft.temporaryKey);
881
912
  if (connection === "models") { page = "model"; continue; }
882
913
  if (connection === "takeover") { if (await adoptExisting()) page = "model"; continue; }
914
+ if (connection === "restoreHidden") {
915
+ const restored = await showRestoreHiddenPanel(ctx, language, draft.hiddenRemoteIds ?? []);
916
+ if (restored.action === "apply") draft.hiddenRemoteIds = (draft.hiddenRemoteIds ?? []).filter((key) => !restored.restored.includes(key));
917
+ continue;
918
+ }
883
919
  if (connection === "testModels") {
884
920
  if (!draft.enabled) { ctx.ui.notify(t(language, "testProviderDisabled"), "warning"); continue; }
885
921
  if (draft.authMode === "temporary" || isDirty()) { ctx.ui.notify(t(language, "testDraftDirty"), "warning"); continue; }
@@ -911,32 +947,15 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
911
947
  if (page === "model") {
912
948
  const installedRaw = installedModels(ctx, target.id);
913
949
  const installed = installedRaw.map(candidateFromInstalled);
914
- const managedChatIds = new Set(oldConfig?.managedChatIds ?? []);
915
- const parseConfig: ProviderConnectionConfig = oldConfig ?? {
916
- authMode: draft.authMode === "temporary" ? "reuse-pi" : draft.authMode,
917
- chatApi: draft.chatApi,
918
- };
919
- const external = (oldProvider?.models ?? [])
920
- .filter((model) => typeof model.id === "string" && !managedChatIds.has(model.id))
921
- .map((model) => parseManagedChat(model, oldProvider!, parseConfig, adoptedChatIds.has(typeof model.id === "string" ? model.id : "")))
922
- .filter((model): model is ManagedModel => model !== undefined)
923
- .map((model) => {
924
- const raw = oldProvider?.models?.find((entry) => entry.id === model.id);
925
- const explicitMap = typeof raw?.thinkingLevelMap === "object" && raw.thinkingLevelMap !== null && !Array.isArray(raw.thinkingLevelMap);
926
- const defaultThinkingSource = model.type === "chat"
927
- ? explicitMap ? "existing" as const : adoptedChatIds.has(model.id) && protocolThinkingMap(model.api) ? "protocol" as const : undefined
928
- : undefined;
929
- const metadata = persistedMetadata.get(`${model.type}\0${model.id}`) ?? { type: model.type, id: model.id, contextSource: model.type === "image" ? undefined : "compat", maxTokensSource: model.type === "chat" ? "compat" : undefined, thinkingSource: defaultThinkingSource };
930
- return adoptedChatIds.has(model.id) ? candidateFromManaged(model, metadata) : candidateFromExternal(model, metadata);
931
- });
932
- const candidates = mergeCandidates(target.id, [...external, ...installed], managedBefore.map((model) => candidateFromManaged(model, persistedMetadata.get(`${model.type}\0${model.id}`))), cachedRemote);
950
+ const external = externalModels.map((model) => candidateFromExternal(model, externalMetadata.get(model.id)));
951
+ const visibleRemote = cachedRemote.filter((model) => !isHiddenRemote(model, draft.hiddenRemoteIds ?? []));
952
+ const candidates = mergeCandidates(target.id, [...external, ...installed], managedBefore.map((model) => candidateFromManaged(model, persistedMetadata.get(`${model.type}\0${model.id}`))), visibleRemote);
933
953
  const selection = await selectModels(ctx, language, target.id, draft, candidates, selectedDraft, modelState, selectedMetadata);
934
954
  selectedDraft = selection.selected;
935
955
  selectedMetadata = selection.metadata.map((entry) => ({ ...(selectedMetadata.find((old) => old.type === entry.type && old.id === entry.id) ?? {}), ...entry }));
936
956
  if (selection.action === "back") { page = "connection"; continue; }
937
957
  if (selection.action === "edit") {
938
958
  const editTarget = selection.target;
939
- if (editTarget?.readOnlyReason === "external-config") { ctx.ui.notify(t(language, "editorExternalTakeover"), "warning"); continue; }
940
959
  const managedIndex = editTarget ? selectedDraft.findIndex((model) => model.id === editTarget.id && model.type === editTarget.type) : -1;
941
960
  if (managedIndex < 0) { ctx.ui.notify(t(language, "editorManagedOnly"), "warning"); continue; }
942
961
  const model = selectedDraft[managedIndex]!;
@@ -983,6 +1002,9 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
983
1002
  continue;
984
1003
  }
985
1004
  if (selection.action === "fetch") {
1005
+ if (fetchInFlight) continue;
1006
+ fetchInFlight = true;
1007
+ try {
986
1008
  const fallbackBases = [
987
1009
  ...(oldProvider?.models ?? []).map((model) => typeof model.baseUrl === "string" ? model.baseUrl : undefined),
988
1010
  ...installedRaw.map((model) => model.baseUrl),
@@ -1025,6 +1047,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
1025
1047
  draft.discoveryUrl = result.value!.url;
1026
1048
  cachedRemote = [...result.value!.models];
1027
1049
  continue;
1050
+ } finally { fetchInFlight = false; }
1028
1051
  }
1029
1052
  if (await preparePreview()) { previewOrigin = "model"; page = "preview"; }
1030
1053
  continue;
@@ -1045,12 +1068,9 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
1045
1068
  if (discard === "discard") continue mainLoop;
1046
1069
  continue;
1047
1070
  }
1048
- const compatItems = selectedMetadata.filter((entry) => entry.contextSource === "compat" || entry.maxTokensSource === "compat").map((entry) => entry.type + '/' + entry.id);
1049
- const risk = compatItems.length ? [t(language, "compatSaveRisk", { items: compatItems.join(", ") })] : [];
1050
- const confirmed = await confirmAction(ctx, language, t(language, "finalSaveTitle"), [t(language, "finalSaveBody"), ...risk], [...details.providerDetails, ...details.modelDetails], t(language, "confirmSave"));
1051
- if (!confirmed) continue;
1071
+ // Preview is the sole save confirmation: Ctrl+S there commits the reviewed draft.
1052
1072
  try {
1053
- const next = applyChanges(files, target.id, draft, selectedDraft, adoptedChatIds, selectedMetadata);
1073
+ const next = applyChanges(files, target.id, draft, selectedDraft, new Set(externalModels.map((model) => model.id)), selectedMetadata);
1054
1074
  const transaction = writeConfigTransaction(files.modelsSnapshot, files.configSnapshot, next.models, next.config);
1055
1075
  ctx.ui.notify(t(language, "saveSuccess", { count: transaction.backups.length }), "info");
1056
1076
  continue mainLoop;
package/src/config.ts CHANGED
@@ -72,6 +72,7 @@ export function parseModelConfig(source: ConfigSnapshot): ModelConfigFile {
72
72
  if (provider.imageApi !== undefined && !IMAGE_API_NAMES.includes(String(provider.imageApi) as typeof IMAGE_API_NAMES[number])) {
73
73
  throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${id}/image`, api: String(provider.imageApi) });
74
74
  }
75
+ 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
76
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
76
77
  if (Array.isArray(provider.models) && provider.models.some((model) => !isRecord(model))) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
77
78
  for (const model of Array.isArray(provider.models) ? provider.models : []) {
package/src/i18n.ts CHANGED
@@ -12,7 +12,7 @@ const ZH = {
12
12
  languageEn: "English",
13
13
  languageSaved: "界面语言已保存:{language}(立即生效)",
14
14
  tuiRequired: "/model-config 需要交互式 TUI;RPC/print/json 模式不支持自定义多选界面",
15
- startupIssues: "部分服务商因配置错误未注册,可继续编辑:{issues}{more}",
15
+ startupIssues: "部分服务商/模型条目存在配置问题(无效条目已跳过,可继续编辑修复):{issues}{more}",
16
16
  startupMore: ";另有 {count} 项",
17
17
  newProviderTitle: "选择内置服务商(内置不代表已认证或可用)",
18
18
  providerTagCustom: "自定义",
@@ -38,9 +38,16 @@ 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
- detailTakeover: "[Ctrl+T] 接管已有模型",
50
+ detailTakeover: "[Ctrl+T] 全选并纳入管理",
44
51
  detailDisable: "[Ctrl+X] 禁用服务商",
45
52
  detailEnable: "[Ctrl+X] 启用服务商",
46
53
  detailTestModels: "[Ctrl+P] 批量测试模型",
@@ -95,11 +102,11 @@ const ZH = {
95
102
  modelDeselectFiltered: "[Ctrl+D] 取消勾选搜索结果",
96
103
  modelCycleType: "[Ctrl+T] 切换模型类型",
97
104
  modelEditParameters: "[Ctrl+E] 编辑模型参数",
98
- editorExternalTakeover: "此模型来自 models.json;请先在连接页执行“接管已有模型”后再编辑",
99
- editorManagedOnly: "仅当前草稿中已勾选、由本插件管理的模型可编辑参数",
105
+ editorManagedOnly: "仅当前草稿中已勾选的模型可编辑参数;请先勾选再编辑",
100
106
  modelProbeContext: "[Ctrl+K] 探测上下文容量",
101
107
  modelProbeThinking: "[Ctrl+L] 探测思考档位",
102
108
  modelFetch: "[Ctrl+R] 获取远程模型列表",
109
+ modelHideRemote: "[Ctrl+Del] 隐藏远程模型",
103
110
  modelPreview: "[Ctrl+S] 预览更改",
104
111
  modelBack: "[Esc] 返回服务商",
105
112
  addProviderTitle: "添加服务商",
@@ -112,7 +119,7 @@ const ZH = {
112
119
  discoveryRequiredEdit: "进入编辑连接设置",
113
120
  discoveryRequiredCancel: "取消获取",
114
121
  dirtyLeaveTitle: "放弃尚未保存的更改?",
115
- dirtyLeaveBody: "返回总览会放弃当前服务商的连接草稿、模型选择和接管状态;不会写入配置。",
122
+ dirtyLeaveBody: "返回总览会放弃当前服务商的连接草稿和模型选择;不会写入配置。",
116
123
  confirmCancel: "[Esc] 取消/返回",
117
124
  confirmSave: "[Ctrl+S] 确认保存",
118
125
  confirmEmpty: "[Ctrl+S] 确认清空",
@@ -178,7 +185,7 @@ const ZH = {
178
185
  panelSummaryAuth: "认证方式:{value}",
179
186
  panelSummaryProxy: "专属代理:{value}",
180
187
  panelSummaryModels: "模型:聊天 {chat} · 判定 {classifier} · 图像生成 {image}",
181
- panelTakeoverCount: "尚未接管的 models.json chat:{count}",
188
+ panelTakeoverCount: "尚未纳入管理的 models.json chat:{count}",
182
189
  panelDescName: "设置界面显示名称;不会改变服务商 ID。",
183
190
  panelDescId: "配置标识,只读。provider/model identity 使用此 ID。",
184
191
  panelDescOwnership: "决定 classifier/image 是否由本插件注册;不要接管 Pi 内置服务商。",
@@ -222,11 +229,10 @@ const ZH = {
222
229
  probeMaxConflict: "探测下限小于当前maxTokens;不会自动调整maxTokens,请等待逐模型参数编辑功能",
223
230
  panelModelBaseUrl: "Base URL:{value}",
224
231
  panelModelReadonly: "此模型只读,不能在这里修改或移除。",
225
- panelModelReadonlyExternal: "此模型来自 models.json,但尚未由本插件管理;可先执行“接管已有模型”。",
226
232
  panelModelReadonlyRuntime: "此模型来自 Pi 内置目录或其他运行时扩展;本插件不能把取消勾选伪装成删除。",
227
- takeoverModelsTitle: "接管已有模型?",
228
- takeoverModelsBody: "仅接管服务商 {provider} 在 models.json 中已有的 {count} 个 chat 模型。接管只修改内存草稿;取消、返回或放弃不会写盘,最终确认保存后才更新 managedChatIds。Pi 内置目录和其他运行时扩展模型不在接管范围。",
229
- takeoverModelsNone: "当前服务商没有可接管的 models.json chat 模型",
233
+ takeoverModelsTitle: "全选 models.json 模型?",
234
+ takeoverModelsBody: "将勾选服务商 {provider} 在 models.json 中尚未勾选的 {count} 个模型,并在最终保存后纳入 managedChatIds。此操作只修改内存草稿;取消、返回或放弃不会写盘。Pi 内置目录和其他运行时扩展模型不在范围。",
235
+ takeoverModelsNone: "当前服务商没有未勾选的 models.json 模型",
230
236
  previewConfirmSave: "[Ctrl+S] 确认保存",
231
237
  previewBackEdit: "[Esc] 返回继续编辑",
232
238
  previewDiscard: "[Ctrl+D] 放弃全部更改",
@@ -311,10 +317,10 @@ const ZH = {
311
317
  selectorNarrow: "终端过窄;请扩宽后重试,Esc 返回",
312
318
  selectorNoMatches: " 没有匹配的模型",
313
319
  selectorReadonlyStatus: "此模型不由本插件管理,当前不能在这里修改或移除",
314
- selectorReadonlyExternal: "此模型来自 models.json,需先使用“接管已有模型”后才能修改或移除",
315
320
  selectorReadonlyRuntime: "此模型来自 Pi 内置目录或其他运行时扩展,不能在这里移除",
316
321
  selectorTypeConflict: "不能切换为{type}:{identity} 已存在",
317
322
  selectorReadonlyTag: "只读",
323
+ selectorRemoteHidden: "已隐藏远程模型 {id};保存后后续获取也会隐藏",
318
324
  selectorTypeChat: "聊天",
319
325
  selectorTypeClassifier: "判定",
320
326
  selectorTypeImage: "图像生成",
@@ -349,7 +355,7 @@ const EN = {
349
355
  languageEn: "English",
350
356
  languageSaved: "UI language saved: {language} (effective immediately)",
351
357
  tuiRequired: "/model-config requires the interactive TUI; custom multi-select is unavailable in RPC/print/JSON modes",
352
- startupIssues: "Some providers were not registered because of configuration errors. You can continue editing: {issues}{more}",
358
+ startupIssues: "Some providers/model entries have configuration problems (invalid entries were skipped; keep editing to fix): {issues}{more}",
353
359
  startupMore: "; {count} more",
354
360
  newProviderTitle: "Choose a Built-in Provider (built-in does not mean authenticated or available)",
355
361
  providerTagCustom: "Custom",
@@ -375,9 +381,16 @@ const EN = {
375
381
  detailConnectionTitle: "Connection Summary",
376
382
  detailModelsTitle: "Model Overview",
377
383
  detailFetchHint: "Open Configure models, then press Ctrl+R to fetch the remote model list",
384
+ detailRestoreHidden: "[Ctrl+H] Restore hidden models ({count})",
385
+ panelHiddenRemoteCount: "Hidden remote models: {count}",
386
+ restoreHiddenTitle: "Restore hidden remote models",
387
+ restoreHiddenItemDetail: "Checked entries return to future remote fetch results; this only changes the in-memory draft until save.",
388
+ restoreHiddenAll: "[Ctrl+A] Restore all",
389
+ restoreHiddenApply: "[Ctrl+S] Apply restore",
390
+ restoreHiddenBack: "[Esc] Back",
378
391
  detailEditConnection: "[Ctrl+E] Edit connection",
379
392
  detailConfigureModels: "[Ctrl+M] Configure models",
380
- detailTakeover: "[Ctrl+T] Manage existing models",
393
+ detailTakeover: "[Ctrl+T] Select all and manage",
381
394
  detailDisable: "[Ctrl+X] Disable provider",
382
395
  detailEnable: "[Ctrl+X] Enable provider",
383
396
  detailTestModels: "[Ctrl+P] Batch test models",
@@ -432,11 +445,11 @@ const EN = {
432
445
  modelDeselectFiltered: "[Ctrl+D] Uncheck search results",
433
446
  modelCycleType: "[Ctrl+T] Change model type",
434
447
  modelEditParameters: "[Ctrl+E] Edit model parameters",
435
- editorExternalTakeover: "This model comes from models.json. Use Manage existing models on the connection page before editing it.",
436
- editorManagedOnly: "Only checked models managed by this plugin in the current draft can be edited",
448
+ editorManagedOnly: "Only checked models in the current draft can be edited; check the row first",
437
449
  modelProbeContext: "[Ctrl+K] Probe context capacity",
438
450
  modelProbeThinking: "[Ctrl+L] Probe thinking levels",
439
451
  modelFetch: "[Ctrl+R] Fetch remote model list",
452
+ modelHideRemote: "[Ctrl+Del] Hide remote model",
440
453
  modelPreview: "[Ctrl+S] Preview changes",
441
454
  modelBack: "[Esc] Back to provider",
442
455
  addProviderTitle: "Add Provider",
@@ -449,7 +462,7 @@ const EN = {
449
462
  discoveryRequiredEdit: "Open connection settings",
450
463
  discoveryRequiredCancel: "Cancel fetch",
451
464
  dirtyLeaveTitle: "Discard unsaved changes?",
452
- dirtyLeaveBody: "Returning to the overview discards this provider's connection draft, model selection, and takeover state. Nothing will be written.",
465
+ dirtyLeaveBody: "Returning to the overview discards this provider's connection draft and model selection. Nothing will be written.",
453
466
  confirmCancel: "[Esc] Cancel / Back",
454
467
  confirmSave: "[Ctrl+S] Confirm Save",
455
468
  confirmEmpty: "[Ctrl+S] Confirm Empty",
@@ -515,7 +528,7 @@ const EN = {
515
528
  panelSummaryAuth: "Authentication: {value}",
516
529
  panelSummaryProxy: "Provider-only proxy: {value}",
517
530
  panelSummaryModels: "Models: Chat {chat} · Classification {classifier} · Image generation {image}",
518
- panelTakeoverCount: "Unmanaged models.json chat models: {count}",
531
+ panelTakeoverCount: "models.json chat models not yet managed: {count}",
519
532
  panelDescName: "Set the display name without changing the provider ID.",
520
533
  panelDescId: "Read-only configuration identity used by provider/model identities.",
521
534
  panelDescOwnership: "Controls classifier/image registration. Never take ownership of a Pi built-in provider.",
@@ -559,11 +572,10 @@ const EN = {
559
572
  probeMaxConflict: "The probe lower bound is below current maxTokens. maxTokens will not be changed automatically; wait for per-model parameter editing.",
560
573
  panelModelBaseUrl: "Base URL: {value}",
561
574
  panelModelReadonly: "This model is read-only and cannot be changed or removed here.",
562
- panelModelReadonlyExternal: "This model is in models.json but is not managed by this plugin yet; use Manage existing models first.",
563
575
  panelModelReadonlyRuntime: "This model comes from Pi's built-in catalog or another runtime extension; unchecking it cannot be presented as deletion.",
564
- takeoverModelsTitle: "Manage existing models?",
565
- takeoverModelsBody: "This takes ownership only of the {count} existing chat model(s) for provider {provider} in models.json. It changes the in-memory draft only; cancel, back, or discard writes nothing. managedChatIds changes only after final save. Pi built-in catalogs and other runtime-extension models are outside this scope.",
566
- takeoverModelsNone: "This provider has no models.json chat models available for takeover",
576
+ takeoverModelsTitle: "Select all models.json models?",
577
+ takeoverModelsBody: "This checks the {count} unchecked model(s) of provider {provider} in models.json and includes them in managedChatIds after the final save. It changes the in-memory draft only; cancel, back, or discard writes nothing. Pi built-in catalogs and other runtime-extension models are outside this scope.",
578
+ takeoverModelsNone: "This provider has no unchecked models.json models",
567
579
  previewConfirmSave: "[Ctrl+S] Confirm Save",
568
580
  previewBackEdit: "[Esc] Back to Editing",
569
581
  previewDiscard: "[Ctrl+D] Discard All Changes",
@@ -648,10 +660,10 @@ const EN = {
648
660
  selectorNarrow: "Terminal too narrow; resize and retry, or press Esc to go back",
649
661
  selectorNoMatches: " No matching models",
650
662
  selectorReadonlyStatus: "This model is not managed by this plugin and cannot be changed or removed here",
651
- selectorReadonlyExternal: "This model comes from models.json; use Manage existing models before changing or removing it",
652
663
  selectorReadonlyRuntime: "This model comes from Pi's built-in catalog or another runtime extension and cannot be removed here",
653
664
  selectorTypeConflict: "Cannot change to {type}: {identity} already exists",
654
665
  selectorReadonlyTag: "read-only",
666
+ selectorRemoteHidden: "Remote model {id} is hidden and will remain hidden after saving and future fetches",
655
667
  selectorTypeChat: "Chat",
656
668
  selectorTypeClassifier: "Classification",
657
669
  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/runtime.ts CHANGED
@@ -159,34 +159,61 @@ function normalizeChatModels(providerId: string, provider: ModelsJsonProvider, c
159
159
  }));
160
160
  }
161
161
 
162
- function validateRuntimeConfig(providerId: string, config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider | undefined): void {
162
+ function validateRuntimeConfig(providerId: string, config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider | undefined, issues: ProviderRegistrationIssue[] = []): void {
163
163
  if (config.discoveryUrl) validateHttpUrl(config.discoveryUrl, `${providerId} Discovery URL`);
164
164
  if (config.proxyUrl) validateHttpUrl(config.proxyUrl, `${providerId}.proxyUrl`);
165
165
  const chatApis = new Set<string>();
166
166
  for (const model of modelsProvider?.models ?? []) {
167
- if (typeof model.id !== "string" || !model.id) throw mcError("MCFG_E_MODEL_SHAPE", { file: "models.json", provider: providerId });
167
+ if (typeof model.id !== "string" || !model.id) {
168
+ issues.push({ providerId, error: mcError("MCFG_E_MODEL_SHAPE", { file: "models.json", provider: providerId }) });
169
+ continue;
170
+ }
168
171
  if (model.type === "classifier" || model.type === "image") {
169
- throw mcError("MCFG_E_TYPE_API", { identity: `${providerId}/chat/${model.id}`, api: String(model.api ?? model.type) });
172
+ issues.push({ providerId, error: mcError("MCFG_E_TYPE_API", { identity: `${providerId}/chat/${model.id}`, api: String(model.api ?? model.type) }) });
173
+ continue;
170
174
  }
171
175
  const api = typeof model.api === "string" ? model.api : (modelsProvider?.api ?? config.chatApi ?? "openai-completions");
172
- if (!CHAT_APIS.has(api)) throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${providerId}/chat/${model.id}`, api });
176
+ if (!CHAT_APIS.has(api)) {
177
+ issues.push({ providerId, error: mcError("MCFG_E_UNSUPPORTED_API", { identity: `${providerId}/chat/${model.id}`, api }) });
178
+ continue;
179
+ }
173
180
  chatApis.add(api);
174
- if (config.owned) validateHttpUrl(String(model.baseUrl ?? modelsProvider?.baseUrl ?? ""), `${providerId}/${model.id} Base URL`);
175
- if (config.owned && (typeof model.contextWindow !== "number" || typeof model.maxTokens !== "number")) throw mcError("MCFG_E_MODEL_SHAPE", { file: "models.json", provider: providerId });
181
+ // owned chat 缺失 context/max 由 normalizeChatModels 补 Pi 兼容默认(128000/16384),
182
+ // 不再作为错误阻断注册;来源标记在保存时记录为 compat。
183
+ if (config.owned) {
184
+ try { validateHttpUrl(String(model.baseUrl ?? modelsProvider?.baseUrl ?? ""), `${providerId}/${model.id} Base URL`); }
185
+ catch (error) { issues.push({ providerId, error }); continue; }
186
+ }
176
187
  }
177
188
  for (const model of config.models ?? []) {
178
189
  const supported = model.type === "classifier" ? CLASSIFIER_APIS : model.type === "image" ? IMAGE_APIS : undefined;
179
- if (!supported) throw mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId });
180
- if (!supported.has(model.api)) throw mcError("MCFG_E_TYPE_API", { identity: `${providerId}/${model.type}/${model.id}`, api: model.api });
190
+ if (!supported) {
191
+ issues.push({ providerId, error: mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId }) });
192
+ continue;
193
+ }
194
+ if (!supported.has(model.api)) {
195
+ issues.push({ providerId, error: mcError("MCFG_E_TYPE_API", { identity: `${providerId}/${model.type}/${model.id}`, api: model.api }) });
196
+ continue;
197
+ }
198
+ if (config.authMode === "anonymous" && model.type === "image" && SELF_IMAGE_APIS.has(model.api)) {
199
+ issues.push({ providerId, error: mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${providerId}/image/${model.id}` }) });
200
+ continue;
201
+ }
202
+ if (config.owned && model.type === "classifier" && typeof model.contextWindow !== "number") {
203
+ issues.push({ providerId, error: mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId }) });
204
+ continue;
205
+ }
181
206
  const effectiveBaseUrl = model.type === "image"
182
207
  ? resolveImageBaseUrl(model.api, model.baseUrl, modelsProvider?.baseUrl)
183
208
  : model.baseUrl ?? modelsProvider?.baseUrl;
184
- if (config.owned && !effectiveBaseUrl) throw mcError("MCFG_E_BASE_URL_REQUIRED");
185
- if (config.owned && effectiveBaseUrl) validateHttpUrl(effectiveBaseUrl, `${providerId}/${model.id} Base URL`);
186
- if (config.authMode === "anonymous" && model.type === "image" && SELF_IMAGE_APIS.has(model.api)) {
187
- throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${providerId}/image/${model.id}` });
209
+ if (config.owned && !effectiveBaseUrl) {
210
+ issues.push({ providerId, error: mcError("MCFG_E_BASE_URL_REQUIRED") });
211
+ continue;
212
+ }
213
+ if (config.owned && effectiveBaseUrl) {
214
+ try { validateHttpUrl(effectiveBaseUrl, `${providerId}/${model.id} Base URL`); }
215
+ catch (error) { issues.push({ providerId, error }); continue; }
188
216
  }
189
- if (config.owned && model.type === "classifier" && typeof model.contextWindow !== "number") throw mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId });
190
217
  }
191
218
  if (!config.owned && (config.models?.length ?? 0) > 0) {
192
219
  throw mcError("MCFG_E_NOT_OWNED", { provider: providerId });
@@ -202,14 +229,17 @@ function validateRuntimeConfig(providerId: string, config: ProviderConnectionCon
202
229
  }
203
230
 
204
231
  function registerOwnedProvider(pi: ExtensionAPI, providerId: string, config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider, language: UiLanguage): void {
205
- const chatModels = normalizeChatModels(providerId, modelsProvider, config);
206
- const extraModels = (config.models ?? []).map((model) => ({
207
- ...model,
208
- provider: providerId,
209
- baseUrl: model.type === "image"
210
- ? resolveImageBaseUrl(model.api, model.baseUrl, modelsProvider.baseUrl) ?? ""
211
- : model.baseUrl ?? modelsProvider.baseUrl ?? "",
212
- }));
232
+ const chatModels = normalizeChatModels(providerId, modelsProvider, config)
233
+ .filter((model) => typeof model.id === "string" && model.id && CHAT_APIS.has(model.api) && typeof model.contextWindow === "number" && typeof model.maxTokens === "number");
234
+ const extraModels = (config.models ?? [])
235
+ .map((model) => ({
236
+ ...model,
237
+ provider: providerId,
238
+ baseUrl: model.type === "image"
239
+ ? resolveImageBaseUrl(model.api, model.baseUrl, modelsProvider.baseUrl) ?? ""
240
+ : model.baseUrl ?? modelsProvider.baseUrl ?? "",
241
+ }))
242
+ .filter((model) => typeof model.id === "string" && model.id && (model.type !== "classifier" || typeof model.contextWindow === "number"));
213
243
  const allModels = [...chatModels, ...extraModels];
214
244
  if (allModels.length === 0) return;
215
245
  const chatApis = new Set(chatModels.map((model) => model.api));
@@ -297,7 +327,7 @@ export function registerConfiguredProviders(
297
327
  registerDisabledProvider(pi, providerId, config, modelsProvider);
298
328
  continue;
299
329
  }
300
- validateRuntimeConfig(providerId, config, modelsProvider);
330
+ validateRuntimeConfig(providerId, config, modelsProvider, issues);
301
331
  if (config.owned) {
302
332
  if (!modelsProvider) throw mcError("MCFG_E_OWNED_PROVIDER_MISSING", { provider: providerId });
303
333
  registerOwnedProvider(pi, providerId, config, modelsProvider, language);
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
 
@@ -76,8 +76,10 @@ export function candidateFromManaged(model: ManagedModel, metadata?: ModelMetada
76
76
  return { ...cloneManaged(model), source: "managed", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined) } as CandidateModel;
77
77
  }
78
78
 
79
+ /** A models.json row belongs to this provider configuration and is immediately editable.
80
+ * It becomes managed on the next save; only runtime catalog rows remain read-only. */
79
81
  export function candidateFromExternal(model: ManagedModel, metadata?: ModelMetadataEntry): CandidateModel {
80
- return { ...cloneManaged(model), source: "external", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined), readOnly: true, readOnlyReason: "external-config" } as CandidateModel;
82
+ return { ...cloneManaged(model), source: "external", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined) } as CandidateModel;
81
83
  }
82
84
 
83
85
  export function mergeCandidates(provider: string, installed: CandidateModel[], managed: CandidateModel[], remote: CandidateModel[]): CandidateModel[] {
@@ -206,7 +208,7 @@ class ModelSelector implements Component, Focusable {
206
208
  const row = this.filtered[this.index];
207
209
  if (!row) return;
208
210
  if (row.readOnly) {
209
- this.status = t(this.language, row.readOnlyReason === "external-config" ? "selectorReadonlyExternal" : row.readOnlyReason === "runtime-catalog" ? "selectorReadonlyRuntime" : "selectorReadonlyStatus");
211
+ this.status = t(this.language, row.readOnlyReason === "runtime-catalog" ? "selectorReadonlyRuntime" : "selectorReadonlyStatus");
210
212
  this.tui.requestRender();
211
213
  return;
212
214
  }
@@ -216,6 +218,19 @@ class ModelSelector implements Component, Focusable {
216
218
  this.tui.requestRender();
217
219
  }
218
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
+
219
234
  private cycleType(): void {
220
235
  const row = this.filtered[this.index];
221
236
  if (!row?.mutableType) return;
@@ -275,6 +290,7 @@ class ModelSelector implements Component, Focusable {
275
290
  { id: "probe", label: t(this.language, "modelProbeContext"), key: Key.ctrl("k") },
276
291
  { id: "probeThinking", label: t(this.language, "modelProbeThinking"), key: Key.ctrl("l") },
277
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" },
278
294
  { id: "preview", label: t(this.language, "modelPreview"), key: Key.ctrl("s") },
279
295
  { id: "back", label: t(this.language, "modelBack"), key: Key.escape },
280
296
  ];
@@ -299,12 +315,14 @@ class ModelSelector implements Component, Focusable {
299
315
  if (action.id === "selectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row)); this.request(); return; }
300
316
  if (action.id === "deselectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row)); this.request(); return; }
301
317
  if (action.id === "cycleType") { this.cycleType(); return; }
318
+ if (action.id === "hideRemote") { this.hideCurrentRemote(); return; }
302
319
  this.finish(action.id as SelectionResult["action"]);
303
320
  }
304
321
 
305
322
  handleInput(data: string): void {
306
323
  if (data === "/") { this.state.focus = "search"; this.request(); return; }
307
324
  if (matchesKey(data, Key.ctrl("r"))) { this.finish("fetch"); return; }
325
+ if (matchesKey(data, Key.ctrl("delete"))) { this.hideCurrentRemote(); return; }
308
326
  if (matchesKey(data, Key.ctrl("e"))) { this.finish("edit"); return; }
309
327
  if (matchesKey(data, Key.ctrl("k"))) { this.finish("probe"); return; }
310
328
  if (matchesKey(data, Key.ctrl("l"))) { this.finish("probeThinking"); return; }
@@ -405,7 +423,7 @@ class ModelSelector implements Component, Focusable {
405
423
  ...(current.type === "image" ? [t(this.language, "panelModelOutput", { value: current.output.join(", ") || t(this.language, "valueUnset") })] : []),
406
424
  t(this.language, "panelModelCost", { input: current.cost.input, output: current.cost.output, cacheRead: current.cost.cacheRead, cacheWrite: current.cost.cacheWrite }),
407
425
  t(this.language, "panelModelBaseUrl", { value: current.baseUrl ?? t(this.language, "valueDefault") }),
408
- ...(current.readOnly ? [t(this.language, current.readOnlyReason === "external-config" ? "panelModelReadonlyExternal" : current.readOnlyReason === "runtime-catalog" ? "panelModelReadonlyRuntime" : "panelModelReadonly")] : []),
426
+ ...(current.readOnly ? [t(this.language, current.readOnlyReason === "runtime-catalog" ? "panelModelReadonlyRuntime" : "panelModelReadonly")] : []),
409
427
  ] : [];
410
428
  const rightAll = detailText.flatMap((line) => wrapTextWithAnsi(this.theme.fg("muted", line), rightWidth));
411
429
  this.detailMaxScroll = Math.max(0, rightAll.length - bodyBudget);
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
  }
@@ -115,14 +117,15 @@ export interface ProviderDraft {
115
117
  classifierApi: string;
116
118
  imageApi: ImageApiName;
117
119
  defaultType: ManagedModelType;
120
+ hiddenRemoteIds?: string[];
118
121
  }
119
122
 
120
123
  export type CandidateModel = ManagedModel & {
121
124
  source: "managed" | "external" | "installed" | "remote";
122
125
  mutableType: boolean;
123
- /** Read-only entries are visible but cannot be toggled until an explicit supported ownership action. */
126
+ /** Read-only entries are visible but cannot be toggled: only runtime catalog rows. */
124
127
  readOnly?: boolean;
125
- readOnlyReason?: "external-config" | "runtime-catalog";
128
+ readOnlyReason?: "runtime-catalog";
126
129
  contextSource?: ModelMetadataSource;
127
130
  maxTokensSource?: ModelMetadataSource;
128
131
  thinkingSource?: ThinkingMetadataSource;
@@ -142,6 +145,20 @@ export interface ConfigSnapshot {
142
145
  text?: string;
143
146
  }
144
147
 
148
+ /** Persistent remote-hide identity. Legacy id-only entries are interpreted as chat-only on read. */
149
+ export function hiddenRemoteKey(model: Pick<ManagedModel, "id" | "type">): string { return `${model.type}\u0000${model.id}`; }
150
+
151
+ export function isHiddenRemote(model: Pick<ManagedModel, "id" | "type">, hiddenKeys: readonly string[]): boolean {
152
+ const key = hiddenRemoteKey(model);
153
+ // Compatibility: prior releases stored bare IDs; treating those as chat-only prevents
154
+ // a legacy hidden chat model from also hiding classifier/image models with the same ID.
155
+ return hiddenKeys.includes(key) || model.type === "chat" && hiddenKeys.includes(model.id);
156
+ }
157
+
158
+ export function normalizeHiddenRemoteKeys(hiddenKeys: readonly string[]): string[] {
159
+ return [...new Set(hiddenKeys.map((key) => key.includes("\u0000") ? key : hiddenRemoteKey({ type: "chat", id: key })))].sort((a, b) => a.localeCompare(b));
160
+ }
161
+
145
162
  export function identityOf(model: Pick<ManagedModel, "id" | "type"> & { provider?: string }, provider?: string): ModelIdentity {
146
163
  const p = provider ?? model.provider ?? "";
147
164
  return `${p}\u0000${model.type}\u0000${model.id}`;