@wuyaos/pi-model-config 0.1.4 → 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/README.md CHANGED
@@ -127,7 +127,7 @@ Chat 写入 Pi 标准 `models.json`。Classifier 和 image 通过 Pi 的原生 m
127
127
  - `xai-images`(xAI Images;默认 `https://api.x.ai/v1`;当前将 base64 结果标为 `image/jpeg`,待真实服务商响应验证)
128
128
  - `dashscope-images`(DashScope 同步 multimodal-generation;默认 `https://dashscope.aliyuncs.com/api/v1`)
129
129
 
130
- 这四个新增协议由本插件直接实现请求级 `generateImages()`,**不经过宿主图像管线**。模型级 Base URL 优先,其次服务商 Base URL,最后才采用上列协议默认值;`openrouter-images` 与未知/自定义协议没有默认 URL,必须由用户明确填写。图像响应不计算 token/cost usage(保持为空)。DashScope 固定使用同步 `/services/aigc/multimodal-generation/generation`,解析 `output.choices[0].message.content[].image`(URL 字符串或 `{ url }`);不发送 `X-DashScope-Async`。返回 URL 会经同源重定向、内网地址、30 秒和 10 MiB 限制后下载。
130
+ 这四个新增协议由本插件直接实现请求级 `generateImages()`,**不经过宿主图像管线**。模型级 Base URL 优先,其次服务商 Base URL,最后才采用上列协议默认值;`openrouter-images` 与未知/自定义协议没有默认 URL,必须由用户明确填写。图像响应不计算 token/cost usage(保持为空)。OpenAI 兼容网关的 `data[].url`(包括 `b64_json: null`)及 DashScope 的 `output.choices[0].message.content[].image`(URL 字符串或 `{ url }`)都会下载并转为 data URL;下载不携带认证头,逐跳重验私网/CGNAT/mapped-IPv6,允许公开对象存储跨源跳转,并有 30 秒和 10 MiB 限制。DashScope 不发送 `X-DashScope-Async`。
131
131
 
132
132
  支持的 chat API:
133
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wuyaos/pi-model-config",
3
- "version": "0.1.4",
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",
@@ -155,16 +155,16 @@ function timedSignal(signal: AbortSignal | undefined, timeoutMs: number): { sign
155
155
  const abort = () => controller.abort(signal?.reason);
156
156
  if (signal?.aborted) abort();
157
157
  else signal?.addEventListener("abort", abort, { once: true });
158
- const timer = setTimeout(() => controller.abort(new Error("DashScope image download timed out")), timeoutMs);
158
+ const timer = setTimeout(() => controller.abort(new Error("Image download timed out")), timeoutMs);
159
159
  return { signal: controller.signal, dispose: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); } };
160
160
  }
161
161
 
162
162
  async function readLimitedBody(response: Response, limit: number): Promise<Uint8Array> {
163
163
  const contentLength = Number(response.headers.get("content-length"));
164
- if (Number.isFinite(contentLength) && contentLength > limit) throw new Error(`DashScope image exceeds ${limit} byte limit`);
164
+ if (Number.isFinite(contentLength) && contentLength > limit) throw new Error(`Image download exceeds ${limit} byte limit`);
165
165
  if (!response.body) {
166
166
  const bytes = new Uint8Array(await response.arrayBuffer());
167
- if (bytes.byteLength > limit) throw new Error(`DashScope image exceeds ${limit} byte limit`);
167
+ if (bytes.byteLength > limit) throw new Error(`Image download exceeds ${limit} byte limit`);
168
168
  return bytes;
169
169
  }
170
170
  const reader = response.body.getReader();
@@ -175,7 +175,7 @@ async function readLimitedBody(response: Response, limit: number): Promise<Uint8
175
175
  const next = await reader.read();
176
176
  if (next.done) break;
177
177
  size += next.value.byteLength;
178
- if (size > limit) { await reader.cancel(); throw new Error(`DashScope image exceeds ${limit} byte limit`); }
178
+ if (size > limit) { await reader.cancel(); throw new Error(`Image download exceeds ${limit} byte limit`); }
179
179
  chunks.push(next.value);
180
180
  }
181
181
  } finally {
@@ -187,32 +187,46 @@ async function readLimitedBody(response: Response, limit: number): Promise<Uint8
187
187
  return bytes;
188
188
  }
189
189
 
190
- /** Downloads only public HTTP(S) URLs, preserves origin across at most five manual redirects, and sends no API headers. */
191
- export async function downloadDashScopeImage(urlValue: string, fetcher: FetchFunction, signal?: AbortSignal): Promise<{ mimeType: string; data: string }> {
190
+ function sniffImageMime(bytes: Uint8Array): string | undefined {
191
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg";
192
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
193
+ return undefined;
194
+ }
195
+
196
+ export interface DownloadImageUrlOptions {
197
+ fetch?: FetchFunction;
198
+ signal?: AbortSignal;
199
+ timeoutMs?: number;
200
+ sizeLimit?: number;
201
+ }
202
+
203
+ /** Downloads a public image without credentials. Every manually followed redirect is revalidated for SSRF. */
204
+ export async function downloadImageUrl(urlValue: string, options: DownloadImageUrlOptions = {}): Promise<{ mimeType: string; data: string }> {
192
205
  const initial = validatePublicImageUrl(urlValue);
193
- const origin = initial.origin;
194
- const timed = timedSignal(signal, 30_000);
206
+ const fetcher = options.fetch ?? (globalThis.fetch as FetchFunction);
207
+ const timed = timedSignal(options.signal, options.timeoutMs ?? 30_000);
208
+ const sizeLimit = options.sizeLimit ?? 10 * 1024 * 1024;
195
209
  try {
196
210
  let current = initial;
197
211
  for (let redirects = 0; redirects <= 5; redirects++) {
198
212
  const response = await fetcher(current, { method: "GET", redirect: "manual", signal: timed.signal });
199
213
  if (redirectStatus(response.status)) {
200
- if (redirects === 5) throw new Error("DashScope image redirect limit exceeded");
214
+ if (redirects === 5) throw new Error("Image download redirect limit exceeded");
201
215
  const location = response.headers.get("location");
202
- if (!location) throw new Error(`DashScope image redirect ${response.status} lacks Location`);
216
+ if (!location) throw new Error(`Image download redirect ${response.status} lacks Location`);
217
+ // Signed-image CDNs may change public origins; each redirect must still pass URL and SSRF validation.
203
218
  const next = validatePublicImageUrl(new URL(location, current).toString());
204
- if (next.origin !== origin) throw new Error("DashScope image redirect changed origin");
205
219
  await response.body?.cancel();
206
220
  current = next;
207
221
  continue;
208
222
  }
209
- if (!response.ok) throw new Error(`DashScope image download failed: HTTP ${response.status}`);
210
- const bytes = await readLimitedBody(response, 10 * 1024 * 1024);
223
+ if (!response.ok) throw new Error(`Image download failed: HTTP ${response.status}`);
224
+ const bytes = await readLimitedBody(response, sizeLimit);
211
225
  const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
212
- const mimeType = contentType?.startsWith("image/") ? contentType : "image/png";
226
+ const mimeType = contentType?.startsWith("image/") ? contentType : sniffImageMime(bytes) ?? "image/jpeg";
213
227
  return { mimeType, data: dataUrl(mimeType, Buffer.from(bytes).toString("base64")) };
214
228
  }
215
- throw new Error("DashScope image redirect limit exceeded");
229
+ throw new Error("Image download redirect limit exceeded");
216
230
  } finally {
217
231
  timed.dispose();
218
232
  }
@@ -1,5 +1,5 @@
1
1
  import type { AssistantImages, ImageModel, ImagesContext, ImagesOptions } from "@earendil-works/pi-ai";
2
- import { apiKey, downloadDashScopeImage, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
2
+ import { apiKey, downloadImageUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
3
 
4
4
  /** DashScope multimodal-generation synchronous endpoint. Deliberately does not set X-DashScope-Async. */
5
5
  export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
@@ -31,7 +31,7 @@ export async function generateImages(model: ImageModel<string>, context: ImagesC
31
31
  ? String((imageField as Record<string, unknown>).url)
32
32
  : undefined;
33
33
  if (!url) continue;
34
- const image = await downloadDashScopeImage(url, fetcher, options?.signal);
34
+ const image = await downloadImageUrl(url, { fetch: fetcher, signal: options?.signal });
35
35
  result.output.push({ type: "image", mimeType: image.mimeType, data: image.data });
36
36
  }
37
37
  if (!result.output.length) throw new Error("DashScope Images returned no downloadable image URLs");
@@ -1,5 +1,5 @@
1
1
  import type { AssistantImages, ImageModel, ImagesContext, ImagesOptions } from "@earendil-works/pi-ai";
2
- import { apiKey, dataUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
2
+ import { apiKey, dataUrl, downloadImageUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
3
 
4
4
  /** OpenAI Images API; this is a small request-local adapter, not Pi's host image pipeline. */
5
5
  export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
@@ -20,11 +20,19 @@ export async function generateImages(model: ImageModel<string>, context: ImagesC
20
20
  });
21
21
  const json = await responseJson(response, options, activeSecrets) as Record<string, unknown>;
22
22
  const result = imageResult(model);
23
+ const fetcher = fetchFor(options);
23
24
  for (const item of records(json.data)) {
24
- const encoded = typeof item.b64_json === "string" ? item.b64_json : undefined;
25
- if (encoded) result.output.push({ type: "image", data: dataUrl("image/png", encoded), mimeType: "image/png" });
25
+ const encoded = typeof item.b64_json === "string" && item.b64_json ? item.b64_json : undefined;
26
+ if (encoded) {
27
+ result.output.push({ type: "image", data: dataUrl("image/png", encoded), mimeType: "image/png" });
28
+ continue;
29
+ }
30
+ if (typeof item.url === "string" && item.url) {
31
+ const image = await downloadImageUrl(item.url, { fetch: fetcher, signal: options?.signal });
32
+ result.output.push({ type: "image", data: image.data, mimeType: image.mimeType });
33
+ }
26
34
  }
27
- if (!result.output.length) throw new Error("OpenAI Images returned no base64 image data");
35
+ if (!result.output.length) throw new Error("OpenAI Images returned no usable b64_json or url image data");
28
36
  return result;
29
37
  } catch (error) {
30
38
  return failedImageResult(model, error, options, activeSecrets);
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;