@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 +1 -1
- package/package.json +1 -1
- package/src/command.ts +57 -53
- package/src/i18n.ts +16 -22
- package/src/image-apis/common.ts +29 -15
- package/src/image-apis/dashscope.ts +2 -2
- package/src/image-apis/openai.ts +12 -4
- package/src/runtime.ts +52 -22
- package/src/selector.ts +5 -3
- package/src/types.ts +2 -2
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(保持为空)。
|
|
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
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
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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(
|
|
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
|
-
|
|
821
|
-
|
|
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 (
|
|
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
|
|
853
|
-
|
|
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:
|
|
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
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
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,
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
229
|
-
takeoverModelsNone: "
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
565
|
-
takeoverModelsBody: "This
|
|
566
|
-
takeoverModelsNone: "This provider has no models.json
|
|
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/image-apis/common.ts
CHANGED
|
@@ -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("
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
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("
|
|
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(`
|
|
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(`
|
|
210
|
-
const bytes = await readLimitedBody(response,
|
|
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/
|
|
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("
|
|
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,
|
|
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
|
|
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");
|
package/src/image-apis/openai.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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)
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
175
|
-
|
|
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)
|
|
180
|
-
|
|
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)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
: model.
|
|
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)
|
|
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 === "
|
|
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 === "
|
|
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
|
|
123
|
+
/** Read-only entries are visible but cannot be toggled: only runtime catalog rows. */
|
|
124
124
|
readOnly?: boolean;
|
|
125
|
-
readOnlyReason?: "
|
|
125
|
+
readOnlyReason?: "runtime-catalog";
|
|
126
126
|
contextSource?: ModelMetadataSource;
|
|
127
127
|
maxTokensSource?: ModelMetadataSource;
|
|
128
128
|
thinkingSource?: ThinkingMetadataSource;
|