@wuyaos/pi-model-config 0.1.5 → 0.1.6

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/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.6",
4
4
  "type": "module",
5
5
  "description": "Pi provider 连接、远程发现与 chat/classifier/image 模型配置 TUI",
6
6
  "keywords": [
package/src/command.ts CHANGED
@@ -409,7 +409,9 @@ export function applyChanges(
409
409
  const originalRows = new Map(
410
410
  (oldProvider.models ?? []).filter((row) => typeof row.id === "string").map((row) => [row.id as string, row]),
411
411
  );
412
- const managedScopeIds = new Set([...oldManagedIds, ...adoptedChatIds]);
412
+ // models.json rows are managed scope by default: selected rows are rewritten (merging
413
+ // unknown fields), unchecked rows are removed. Only rows without a string id are kept verbatim.
414
+ const managedScopeIds = new Set([...oldManagedIds, ...adoptedChatIds, ...originalRows.keys()]);
413
415
  const unmanagedRows = (oldProvider.models ?? []).filter((row) => typeof row.id !== "string" || !managedScopeIds.has(row.id));
414
416
  const selectedChat = selected.filter((model) => model.type === "chat");
415
417
  provider.models = [...unmanagedRows, ...selectedChat.map((model) => ({
@@ -795,19 +797,48 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
795
797
  const draft = draftFrom(target.id, oldProvider, oldConfig, target.isNew);
796
798
  const baselineDraft = structuredClone(draft);
797
799
  const managedBefore = previousManaged(oldProvider, oldConfig);
798
- const baselineSelected = structuredClone(managedBefore);
799
800
  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
- });
801
+ const parseConfig: ProviderConnectionConfig = oldConfig ?? {
802
+ authMode: draft.authMode === "temporary" ? "reuse-pi" : draft.authMode,
803
+ chatApi: draft.chatApi,
804
+ };
805
+ // models.json rows outside managedChatIds are directly editable and become
806
+ // managed on the next save; only runtime catalog rows stay read-only.
807
+ const managedChatIdSet = new Set(oldConfig?.managedChatIds ?? []);
808
+ const externalModels: ManagedModel[] = [];
809
+ const externalMetadata = new Map<string, ModelMetadataEntry>();
810
+ for (const raw of oldProvider?.models ?? []) {
811
+ if (typeof raw.id !== "string" || managedChatIdSet.has(raw.id)) continue;
812
+ const model = parseManagedChat(raw, oldProvider!, parseConfig, true);
813
+ if (!model) continue;
814
+ externalModels.push(model);
815
+ const explicitMap = typeof raw.thinkingLevelMap === "object" && raw.thinkingLevelMap !== null && !Array.isArray(raw.thinkingLevelMap);
816
+ externalMetadata.set(model.id, persistedMetadata.get(`${model.type}\0${model.id}`) ?? {
817
+ type: model.type,
818
+ id: model.id,
819
+ contextSource: model.type === "image" ? undefined : typeof raw.contextWindow === "number" ? "existing" : "compat",
820
+ maxTokensSource: model.type === "chat" ? typeof raw.maxTokens === "number" ? "existing" : "compat" : undefined,
821
+ thinkingSource: model.type === "chat" ? explicitMap ? "existing" as const : protocolThinkingMap(model.api) ? "protocol" as const : undefined : undefined,
822
+ });
823
+ }
824
+ const metadataEntryFor = (model: ManagedModel, raw: Record<string, unknown> | undefined, fallback: ModelMetadataEntry | undefined): ModelMetadataEntry => fallback ?? {
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" && typeof raw?.thinkingLevelMap === "object" && raw.thinkingLevelMap !== null && !Array.isArray(raw.thinkingLevelMap) ? "existing" as const : undefined,
830
+ };
831
+ let selectedMetadata: ModelMetadataEntry[] = [
832
+ ...managedBefore.map((model) => {
833
+ 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);
834
+ return structuredClone(metadataEntryFor(model, raw as Record<string, unknown> | undefined, persistedMetadata.get(`${model.type}\0${model.id}`)));
835
+ }),
836
+ ...externalModels.map((model) => structuredClone(externalMetadata.get(model.id)!)),
837
+ ];
838
+ const baselineSelected = structuredClone([...managedBefore, ...externalModels]);
807
839
  const baselineMetadata = structuredClone(selectedMetadata);
808
840
 
809
- let selectedDraft = structuredClone(managedBefore);
810
- const adoptedChatIds = new Set<string>();
841
+ let selectedDraft = structuredClone(baselineSelected);
811
842
  let cachedRemote: CandidateModel[] = [];
812
843
  let page: "connection" | "model" | "preview" = "connection";
813
844
  let previewOrigin: "connection" | "model" = "model";
@@ -816,18 +847,14 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
816
847
  const previewState: MenuPanelState = { selectedId: "provider", focus: "list", actionIndex: 0, detailScroll: 0 };
817
848
  activeSecrets.push(draft.apiKeyRaw);
818
849
 
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
- };
850
+ const adoptableIds = () => externalModels
851
+ .filter((model) => !selectedDraft.some((selected) => selected.id === model.id && selected.type === model.type))
852
+ .map((model) => model.id);
825
853
  const selectionFingerprint = (models: ManagedModel[]) => JSON.stringify([...models].sort((a, b) => identityOf(a, target.id).localeCompare(identityOf(b, target.id))));
826
854
  const metadataFingerprint = (entries: ModelMetadataEntry[]) => JSON.stringify([...entries].sort((a, b) => `${a.type}\0${a.id}`.localeCompare(`${b.type}\0${b.id}`)));
827
855
  const isDirty = () => JSON.stringify(draft) !== JSON.stringify(baselineDraft)
828
856
  || selectionFingerprint(selectedDraft) !== selectionFingerprint(baselineSelected)
829
- || metadataFingerprint(selectedMetadata) !== metadataFingerprint(baselineMetadata)
830
- || adoptedChatIds.size > 0;
857
+ || metadataFingerprint(selectedMetadata) !== metadataFingerprint(baselineMetadata);
831
858
  const preparePreview = async (): Promise<boolean> => {
832
859
  if (draft.authMode === "temporary") {
833
860
  ctx.ui.notify(t(language, "temporarySaveBlocked"), "warning");
@@ -835,7 +862,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
835
862
  }
836
863
  try { validateSelection(draft, selectedDraft, installedModels(ctx, target.id), oldProvider); }
837
864
  catch (error) { ctx.ui.notify(displayError(language, error, activeSecrets), "error"); return false; }
838
- if ((managedBefore.length > 0 || adoptedChatIds.size > 0) && selectedDraft.length === 0) {
865
+ if (baselineSelected.length > 0 && selectedDraft.length === 0) {
839
866
  const empty = await showConfirmationPanel(ctx, {
840
867
  title: t(language, "emptyTitle"), summary: [t(language, "emptyBody")], severity: "error",
841
868
  defaultActionIndex: 0, cancelActionId: "cancel",
@@ -849,10 +876,11 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
849
876
  return true;
850
877
  };
851
878
  const adoptExisting = async (): Promise<boolean> => {
852
- const ids = adoptableIds();
853
- if (!ids.length) { ctx.ui.notify(t(language, "takeoverModelsNone"), "info"); return false; }
879
+ const selectedKeys = new Set(selectedDraft.map((model) => identityOf(model, target.id)));
880
+ const missing = externalModels.filter((model) => !selectedKeys.has(identityOf(model, target.id)));
881
+ if (!missing.length) { ctx.ui.notify(t(language, "takeoverModelsNone"), "info"); return false; }
854
882
  const action = await showConfirmationPanel(ctx, {
855
- title: t(language, "takeoverModelsTitle"), summary: [t(language, "takeoverModelsBody", { provider: target.id, count: ids.length })],
883
+ title: t(language, "takeoverModelsTitle"), summary: [t(language, "takeoverModelsBody", { provider: target.id, count: missing.length })],
856
884
  details: [t(language, "diffSafety")], severity: "warning", defaultActionIndex: 0, cancelActionId: "cancel",
857
885
  actions: [
858
886
  { id: "cancel", label: t(language, "confirmCancel") },
@@ -860,16 +888,10 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
860
888
  ],
861
889
  });
862
890
  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);
891
+ for (const model of missing) {
892
+ selectedDraft.push(structuredClone(model));
893
+ const metadata = externalMetadata.get(model.id)!;
894
+ if (!selectedMetadata.some((entry) => entry.type === metadata.type && entry.id === metadata.id)) selectedMetadata.push(structuredClone(metadata));
873
895
  }
874
896
  return true;
875
897
  };
@@ -911,24 +933,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
911
933
  if (page === "model") {
912
934
  const installedRaw = installedModels(ctx, target.id);
913
935
  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
- });
936
+ const external = externalModels.map((model) => candidateFromExternal(model, externalMetadata.get(model.id)));
932
937
  const candidates = mergeCandidates(target.id, [...external, ...installed], managedBefore.map((model) => candidateFromManaged(model, persistedMetadata.get(`${model.type}\0${model.id}`))), cachedRemote);
933
938
  const selection = await selectModels(ctx, language, target.id, draft, candidates, selectedDraft, modelState, selectedMetadata);
934
939
  selectedDraft = selection.selected;
@@ -936,7 +941,6 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
936
941
  if (selection.action === "back") { page = "connection"; continue; }
937
942
  if (selection.action === "edit") {
938
943
  const editTarget = selection.target;
939
- if (editTarget?.readOnlyReason === "external-config") { ctx.ui.notify(t(language, "editorExternalTakeover"), "warning"); continue; }
940
944
  const managedIndex = editTarget ? selectedDraft.findIndex((model) => model.id === editTarget.id && model.type === editTarget.type) : -1;
941
945
  if (managedIndex < 0) { ctx.ui.notify(t(language, "editorManagedOnly"), "warning"); continue; }
942
946
  const model = selectedDraft[managedIndex]!;
@@ -1050,7 +1054,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
1050
1054
  const confirmed = await confirmAction(ctx, language, t(language, "finalSaveTitle"), [t(language, "finalSaveBody"), ...risk], [...details.providerDetails, ...details.modelDetails], t(language, "confirmSave"));
1051
1055
  if (!confirmed) continue;
1052
1056
  try {
1053
- const next = applyChanges(files, target.id, draft, selectedDraft, adoptedChatIds, selectedMetadata);
1057
+ const next = applyChanges(files, target.id, draft, selectedDraft, new Set(externalModels.map((model) => model.id)), selectedMetadata);
1054
1058
  const transaction = writeConfigTransaction(files.modelsSnapshot, files.configSnapshot, next.models, next.config);
1055
1059
  ctx.ui.notify(t(language, "saveSuccess", { count: transaction.backups.length }), "info");
1056
1060
  continue mainLoop;
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: "自定义",
@@ -40,7 +40,7 @@ const ZH = {
40
40
  detailFetchHint: "进入“配置模型”后按 Ctrl+R 获取远程模型列表",
41
41
  detailEditConnection: "[Ctrl+E] 编辑连接",
42
42
  detailConfigureModels: "[Ctrl+M] 配置模型",
43
- detailTakeover: "[Ctrl+T] 接管已有模型",
43
+ detailTakeover: "[Ctrl+T] 全选并纳入管理",
44
44
  detailDisable: "[Ctrl+X] 禁用服务商",
45
45
  detailEnable: "[Ctrl+X] 启用服务商",
46
46
  detailTestModels: "[Ctrl+P] 批量测试模型",
@@ -95,8 +95,7 @@ const ZH = {
95
95
  modelDeselectFiltered: "[Ctrl+D] 取消勾选搜索结果",
96
96
  modelCycleType: "[Ctrl+T] 切换模型类型",
97
97
  modelEditParameters: "[Ctrl+E] 编辑模型参数",
98
- editorExternalTakeover: "此模型来自 models.json;请先在连接页执行“接管已有模型”后再编辑",
99
- editorManagedOnly: "仅当前草稿中已勾选、由本插件管理的模型可编辑参数",
98
+ editorManagedOnly: "仅当前草稿中已勾选的模型可编辑参数;请先勾选再编辑",
100
99
  modelProbeContext: "[Ctrl+K] 探测上下文容量",
101
100
  modelProbeThinking: "[Ctrl+L] 探测思考档位",
102
101
  modelFetch: "[Ctrl+R] 获取远程模型列表",
@@ -112,7 +111,7 @@ const ZH = {
112
111
  discoveryRequiredEdit: "进入编辑连接设置",
113
112
  discoveryRequiredCancel: "取消获取",
114
113
  dirtyLeaveTitle: "放弃尚未保存的更改?",
115
- dirtyLeaveBody: "返回总览会放弃当前服务商的连接草稿、模型选择和接管状态;不会写入配置。",
114
+ dirtyLeaveBody: "返回总览会放弃当前服务商的连接草稿和模型选择;不会写入配置。",
116
115
  confirmCancel: "[Esc] 取消/返回",
117
116
  confirmSave: "[Ctrl+S] 确认保存",
118
117
  confirmEmpty: "[Ctrl+S] 确认清空",
@@ -178,7 +177,7 @@ const ZH = {
178
177
  panelSummaryAuth: "认证方式:{value}",
179
178
  panelSummaryProxy: "专属代理:{value}",
180
179
  panelSummaryModels: "模型:聊天 {chat} · 判定 {classifier} · 图像生成 {image}",
181
- panelTakeoverCount: "尚未接管的 models.json chat:{count}",
180
+ panelTakeoverCount: "尚未纳入管理的 models.json chat:{count}",
182
181
  panelDescName: "设置界面显示名称;不会改变服务商 ID。",
183
182
  panelDescId: "配置标识,只读。provider/model identity 使用此 ID。",
184
183
  panelDescOwnership: "决定 classifier/image 是否由本插件注册;不要接管 Pi 内置服务商。",
@@ -222,11 +221,10 @@ const ZH = {
222
221
  probeMaxConflict: "探测下限小于当前maxTokens;不会自动调整maxTokens,请等待逐模型参数编辑功能",
223
222
  panelModelBaseUrl: "Base URL:{value}",
224
223
  panelModelReadonly: "此模型只读,不能在这里修改或移除。",
225
- panelModelReadonlyExternal: "此模型来自 models.json,但尚未由本插件管理;可先执行“接管已有模型”。",
226
224
  panelModelReadonlyRuntime: "此模型来自 Pi 内置目录或其他运行时扩展;本插件不能把取消勾选伪装成删除。",
227
- takeoverModelsTitle: "接管已有模型?",
228
- takeoverModelsBody: "仅接管服务商 {provider} 在 models.json 中已有的 {count} 个 chat 模型。接管只修改内存草稿;取消、返回或放弃不会写盘,最终确认保存后才更新 managedChatIds。Pi 内置目录和其他运行时扩展模型不在接管范围。",
229
- takeoverModelsNone: "当前服务商没有可接管的 models.json chat 模型",
225
+ takeoverModelsTitle: "全选 models.json 模型?",
226
+ takeoverModelsBody: "将勾选服务商 {provider} 在 models.json 中尚未勾选的 {count} 个模型,并在最终保存后纳入 managedChatIds。此操作只修改内存草稿;取消、返回或放弃不会写盘。Pi 内置目录和其他运行时扩展模型不在范围。",
227
+ takeoverModelsNone: "当前服务商没有未勾选的 models.json 模型",
230
228
  previewConfirmSave: "[Ctrl+S] 确认保存",
231
229
  previewBackEdit: "[Esc] 返回继续编辑",
232
230
  previewDiscard: "[Ctrl+D] 放弃全部更改",
@@ -311,7 +309,6 @@ const ZH = {
311
309
  selectorNarrow: "终端过窄;请扩宽后重试,Esc 返回",
312
310
  selectorNoMatches: " 没有匹配的模型",
313
311
  selectorReadonlyStatus: "此模型不由本插件管理,当前不能在这里修改或移除",
314
- selectorReadonlyExternal: "此模型来自 models.json,需先使用“接管已有模型”后才能修改或移除",
315
312
  selectorReadonlyRuntime: "此模型来自 Pi 内置目录或其他运行时扩展,不能在这里移除",
316
313
  selectorTypeConflict: "不能切换为{type}:{identity} 已存在",
317
314
  selectorReadonlyTag: "只读",
@@ -349,7 +346,7 @@ const EN = {
349
346
  languageEn: "English",
350
347
  languageSaved: "UI language saved: {language} (effective immediately)",
351
348
  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}",
349
+ startupIssues: "Some providers/model entries have configuration problems (invalid entries were skipped; keep editing to fix): {issues}{more}",
353
350
  startupMore: "; {count} more",
354
351
  newProviderTitle: "Choose a Built-in Provider (built-in does not mean authenticated or available)",
355
352
  providerTagCustom: "Custom",
@@ -377,7 +374,7 @@ const EN = {
377
374
  detailFetchHint: "Open Configure models, then press Ctrl+R to fetch the remote model list",
378
375
  detailEditConnection: "[Ctrl+E] Edit connection",
379
376
  detailConfigureModels: "[Ctrl+M] Configure models",
380
- detailTakeover: "[Ctrl+T] Manage existing models",
377
+ detailTakeover: "[Ctrl+T] Select all and manage",
381
378
  detailDisable: "[Ctrl+X] Disable provider",
382
379
  detailEnable: "[Ctrl+X] Enable provider",
383
380
  detailTestModels: "[Ctrl+P] Batch test models",
@@ -432,8 +429,7 @@ const EN = {
432
429
  modelDeselectFiltered: "[Ctrl+D] Uncheck search results",
433
430
  modelCycleType: "[Ctrl+T] Change model type",
434
431
  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",
432
+ editorManagedOnly: "Only checked models in the current draft can be edited; check the row first",
437
433
  modelProbeContext: "[Ctrl+K] Probe context capacity",
438
434
  modelProbeThinking: "[Ctrl+L] Probe thinking levels",
439
435
  modelFetch: "[Ctrl+R] Fetch remote model list",
@@ -449,7 +445,7 @@ const EN = {
449
445
  discoveryRequiredEdit: "Open connection settings",
450
446
  discoveryRequiredCancel: "Cancel fetch",
451
447
  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.",
448
+ dirtyLeaveBody: "Returning to the overview discards this provider's connection draft and model selection. Nothing will be written.",
453
449
  confirmCancel: "[Esc] Cancel / Back",
454
450
  confirmSave: "[Ctrl+S] Confirm Save",
455
451
  confirmEmpty: "[Ctrl+S] Confirm Empty",
@@ -515,7 +511,7 @@ const EN = {
515
511
  panelSummaryAuth: "Authentication: {value}",
516
512
  panelSummaryProxy: "Provider-only proxy: {value}",
517
513
  panelSummaryModels: "Models: Chat {chat} · Classification {classifier} · Image generation {image}",
518
- panelTakeoverCount: "Unmanaged models.json chat models: {count}",
514
+ panelTakeoverCount: "models.json chat models not yet managed: {count}",
519
515
  panelDescName: "Set the display name without changing the provider ID.",
520
516
  panelDescId: "Read-only configuration identity used by provider/model identities.",
521
517
  panelDescOwnership: "Controls classifier/image registration. Never take ownership of a Pi built-in provider.",
@@ -559,11 +555,10 @@ const EN = {
559
555
  probeMaxConflict: "The probe lower bound is below current maxTokens. maxTokens will not be changed automatically; wait for per-model parameter editing.",
560
556
  panelModelBaseUrl: "Base URL: {value}",
561
557
  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
558
  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",
559
+ takeoverModelsTitle: "Select all models.json models?",
560
+ 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.",
561
+ takeoverModelsNone: "This provider has no unchecked models.json models",
567
562
  previewConfirmSave: "[Ctrl+S] Confirm Save",
568
563
  previewBackEdit: "[Esc] Back to Editing",
569
564
  previewDiscard: "[Ctrl+D] Discard All Changes",
@@ -648,7 +643,6 @@ const EN = {
648
643
  selectorNarrow: "Terminal too narrow; resize and retry, or press Esc to go back",
649
644
  selectorNoMatches: " No matching models",
650
645
  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
646
  selectorReadonlyRuntime: "This model comes from Pi's built-in catalog or another runtime extension and cannot be removed here",
653
647
  selectorTypeConflict: "Cannot change to {type}: {identity} already exists",
654
648
  selectorReadonlyTag: "read-only",
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
@@ -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
  }
@@ -405,7 +407,7 @@ class ModelSelector implements Component, Focusable {
405
407
  ...(current.type === "image" ? [t(this.language, "panelModelOutput", { value: current.output.join(", ") || t(this.language, "valueUnset") })] : []),
406
408
  t(this.language, "panelModelCost", { input: current.cost.input, output: current.cost.output, cacheRead: current.cost.cacheRead, cacheWrite: current.cost.cacheWrite }),
407
409
  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")] : []),
410
+ ...(current.readOnly ? [t(this.language, current.readOnlyReason === "runtime-catalog" ? "panelModelReadonlyRuntime" : "panelModelReadonly")] : []),
409
411
  ] : [];
410
412
  const rightAll = detailText.flatMap((line) => wrapTextWithAnsi(this.theme.fg("muted", line), rightWidth));
411
413
  this.detailMaxScroll = Math.max(0, rightAll.length - bodyBudget);
package/src/types.ts CHANGED
@@ -120,9 +120,9 @@ export interface ProviderDraft {
120
120
  export type CandidateModel = ManagedModel & {
121
121
  source: "managed" | "external" | "installed" | "remote";
122
122
  mutableType: boolean;
123
- /** Read-only entries are visible but cannot be toggled until an explicit supported ownership action. */
123
+ /** Read-only entries are visible but cannot be toggled: only runtime catalog rows. */
124
124
  readOnly?: boolean;
125
- readOnlyReason?: "external-config" | "runtime-catalog";
125
+ readOnlyReason?: "runtime-catalog";
126
126
  contextSource?: ModelMetadataSource;
127
127
  maxTokensSource?: ModelMetadataSource;
128
128
  thinkingSource?: ThinkingMetadataSource;