@wuyaos/pi-model-config 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +200 -2
- package/extensions/model-config.ts +27 -0
- package/model-config.schema.json +269 -0
- package/package.json +52 -4
- package/src/command.ts +1053 -0
- package/src/config.ts +438 -0
- package/src/context-probe.ts +90 -0
- package/src/discovery.ts +427 -0
- package/src/i18n.ts +824 -0
- package/src/model-editor.ts +326 -0
- package/src/model-test.ts +212 -0
- package/src/panel.ts +477 -0
- package/src/runtime.ts +298 -0
- package/src/selector.ts +433 -0
- package/src/thinking-defaults.ts +39 -0
- package/src/thinking-probe.ts +84 -0
- package/src/transport.ts +94 -0
- package/src/types.ts +150 -0
- package/src/ui-config.ts +44 -0
- package/src/ui.ts +179 -0
package/src/command.ts
ADDED
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { AnyModel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
4
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
authDescription,
|
|
7
|
+
readConfigFiles,
|
|
8
|
+
redactUrl,
|
|
9
|
+
sanitizeError,
|
|
10
|
+
validateHttpUrl,
|
|
11
|
+
validateProviderId,
|
|
12
|
+
writeConfigTransaction,
|
|
13
|
+
} from "./config.ts";
|
|
14
|
+
import { buildDiscoveryCandidates, discoverModelsFromCandidates } from "./discovery.ts";
|
|
15
|
+
import { formatError, mcError, t, type UiLanguage } from "./i18n.ts";
|
|
16
|
+
import {
|
|
17
|
+
showConnectionPanel,
|
|
18
|
+
showPreviewPanel,
|
|
19
|
+
showProviderPanel,
|
|
20
|
+
showConfirmationPanel,
|
|
21
|
+
type MenuPanelState,
|
|
22
|
+
type OverviewPanelState,
|
|
23
|
+
type PanelItem,
|
|
24
|
+
} from "./panel.ts";
|
|
25
|
+
import { candidateFromExternal, candidateFromInstalled, candidateFromManaged, mergeCandidates, selectModels, type ModelPanelState } from "./selector.ts";
|
|
26
|
+
import { CHAT_APIS, CLASSIFIER_APIS, IMAGE_APIS, PROXY_CHAT_APIS } from "./transport.ts";
|
|
27
|
+
import type {
|
|
28
|
+
CandidateModel,
|
|
29
|
+
ManagedModel,
|
|
30
|
+
ModelConfigFile,
|
|
31
|
+
ModelMetadataEntry,
|
|
32
|
+
ModelsJsonFile,
|
|
33
|
+
ModelsJsonProvider,
|
|
34
|
+
ProviderConnectionConfig,
|
|
35
|
+
ProviderDraft,
|
|
36
|
+
} from "./types.ts";
|
|
37
|
+
import { identityOf } from "./types.ts";
|
|
38
|
+
import { loadUiLanguage, saveUiLanguage } from "./ui-config.ts";
|
|
39
|
+
import { secretInput, withCancellableLoader } from "./ui.ts";
|
|
40
|
+
import { showModelTestFlow } from "./model-test.ts";
|
|
41
|
+
import { showContextProbeFlow } from "./context-probe.ts";
|
|
42
|
+
import { editModelParameters } from "./model-editor.ts";
|
|
43
|
+
import { acceptedThinkingMap, resolveThinkingMap, showThinkingProbeFlow } from "./thinking-probe.ts";
|
|
44
|
+
import { defaultChatThinking, protocolThinkingMap } from "./thinking-defaults.ts";
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
export interface StartupIssue {
|
|
48
|
+
providerId?: string;
|
|
49
|
+
error: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function displayError(language: UiLanguage, error: unknown, secrets: Array<string | undefined> = []): string {
|
|
53
|
+
return formatError(language, error, sanitizeError(error, secrets));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function confirmAction(
|
|
57
|
+
ctx: ExtensionCommandContext,
|
|
58
|
+
language: UiLanguage,
|
|
59
|
+
title: string,
|
|
60
|
+
summary: string[],
|
|
61
|
+
details: string[],
|
|
62
|
+
confirmLabel: string,
|
|
63
|
+
severity: "warning" | "error" = "warning",
|
|
64
|
+
): Promise<boolean> {
|
|
65
|
+
const result = await showConfirmationPanel(ctx, {
|
|
66
|
+
title,
|
|
67
|
+
summary,
|
|
68
|
+
details,
|
|
69
|
+
severity,
|
|
70
|
+
defaultActionIndex: 0,
|
|
71
|
+
cancelActionId: "cancel",
|
|
72
|
+
actions: [
|
|
73
|
+
{ id: "cancel", label: t(language, "confirmCancel") },
|
|
74
|
+
{ id: "confirm", label: confirmLabel, shortcut: "ctrl+s", severity },
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
return result === "confirm";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function authModeLabel(language: UiLanguage, draft: ProviderDraft): string {
|
|
81
|
+
if (draft.authMode === "models-json") {
|
|
82
|
+
const envReference = /^\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)$/u.test(draft.apiKeyRaw ?? "");
|
|
83
|
+
return t(language, envReference ? "authEnvOption" : "authLiteralOption");
|
|
84
|
+
}
|
|
85
|
+
return draft.authMode === "reuse-pi" ? t(language, "authReuseOption")
|
|
86
|
+
: draft.authMode === "temporary" ? t(language, "authTemporaryOption") : t(language, "authAnonymousOption");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function discoveryAuthLabel(language: UiLanguage, value: ProviderDraft["authHeader"]): string {
|
|
90
|
+
const key = value === "auto" ? "authHeaderAuto"
|
|
91
|
+
: value === "bearer" ? "authHeaderBearer"
|
|
92
|
+
: value === "x-api-key" ? "authHeaderXApiKey"
|
|
93
|
+
: value === "api-key" ? "authHeaderApiKey"
|
|
94
|
+
: value === "x-goog-api-key" ? "authHeaderGoogle"
|
|
95
|
+
: value === "anthropic" ? "authHeaderAnthropic" : "authHeaderNone";
|
|
96
|
+
return t(language, key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function modelTypeLabel(language: UiLanguage, type: ManagedModel["type"]): string {
|
|
100
|
+
return type === "classifier" ? t(language, "selectorTypeClassifier")
|
|
101
|
+
: type === "image" ? t(language, "selectorTypeImage")
|
|
102
|
+
: t(language, "selectorTypeChat");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseManagedChat(raw: Record<string, unknown>, provider: ModelsJsonProvider, config: ProviderConnectionConfig, applyDefaults = false): ManagedModel | undefined {
|
|
106
|
+
if (typeof raw.id !== "string") return undefined;
|
|
107
|
+
const api = typeof raw.api === "string" ? raw.api : (provider.api ?? config.chatApi ?? "openai-completions");
|
|
108
|
+
const observedMap = typeof raw.thinkingLevelMap === "object" && raw.thinkingLevelMap && !Array.isArray(raw.thinkingLevelMap)
|
|
109
|
+
? raw.thinkingLevelMap as any
|
|
110
|
+
: undefined;
|
|
111
|
+
const resolvedThinking = applyDefaults
|
|
112
|
+
? defaultChatThinking(api, { reasoning: typeof raw.reasoning === "boolean" ? raw.reasoning : undefined, thinkingLevelMap: observedMap }, "existing")
|
|
113
|
+
: { reasoning: raw.reasoning === true, ...(observedMap !== undefined ? { thinkingLevelMap: observedMap } : {}) };
|
|
114
|
+
const thinking = {
|
|
115
|
+
reasoning: resolvedThinking.reasoning,
|
|
116
|
+
...(resolvedThinking.thinkingLevelMap !== undefined ? { thinkingLevelMap: resolvedThinking.thinkingLevelMap } : {}),
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
type: "chat",
|
|
120
|
+
id: raw.id,
|
|
121
|
+
name: typeof raw.name === "string" ? raw.name : raw.id,
|
|
122
|
+
api,
|
|
123
|
+
...(typeof raw.baseUrl === "string" ? { baseUrl: raw.baseUrl } : provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
|
|
124
|
+
...thinking,
|
|
125
|
+
input: Array.isArray(raw.input) ? raw.input.filter((v): v is "text" | "image" => v === "text" || v === "image") : ["text"],
|
|
126
|
+
cost: typeof raw.cost === "object" && raw.cost ? raw.cost as ManagedModel["cost"] : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
127
|
+
contextWindow: typeof raw.contextWindow === "number" ? raw.contextWindow : 128_000,
|
|
128
|
+
maxTokens: typeof raw.maxTokens === "number" ? raw.maxTokens : 16_384,
|
|
129
|
+
...(typeof raw.headers === "object" && raw.headers ? { headers: raw.headers as Record<string, string> } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function draftFrom(providerId: string, provider: ModelsJsonProvider | undefined, config: ProviderConnectionConfig | undefined, isNew: boolean): ProviderDraft {
|
|
134
|
+
const apiKeyRaw = provider?.apiKey;
|
|
135
|
+
return {
|
|
136
|
+
id: providerId,
|
|
137
|
+
name: config?.name ?? provider?.name ?? providerId,
|
|
138
|
+
owned: config?.owned ?? isNew,
|
|
139
|
+
enabled: config?.enabled !== false,
|
|
140
|
+
baseUrl: provider?.baseUrl,
|
|
141
|
+
discoveryUrl: config?.discoveryUrl,
|
|
142
|
+
authMode: config?.authMode ?? (apiKeyRaw ? "models-json" : isNew ? "anonymous" : "reuse-pi"),
|
|
143
|
+
authHeader: config?.discoveryAuthHeader ?? "auto",
|
|
144
|
+
apiKeyRaw,
|
|
145
|
+
proxyUrl: config?.proxyUrl,
|
|
146
|
+
chatApi: config?.chatApi ?? provider?.api ?? "openai-completions",
|
|
147
|
+
classifierApi: config?.classifierApi ?? "typesafe-system-one",
|
|
148
|
+
imageApi: config?.imageApi ?? "openrouter-images",
|
|
149
|
+
defaultType: config?.defaultType ?? "chat",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function inputPositiveInteger(ctx: ExtensionCommandContext, language: UiLanguage, title: string, current: number): Promise<number> {
|
|
154
|
+
const value = await ctx.ui.input(title, String(current));
|
|
155
|
+
if (value === undefined || value.trim() === "") return current;
|
|
156
|
+
const number = Number(value);
|
|
157
|
+
if (!Number.isSafeInteger(number) || number <= 0) throw mcError("MCFG_E_POSITIVE_INTEGER");
|
|
158
|
+
return number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function editUrl(
|
|
162
|
+
ctx: ExtensionCommandContext,
|
|
163
|
+
language: UiLanguage,
|
|
164
|
+
label: string,
|
|
165
|
+
current: string | undefined,
|
|
166
|
+
kind: "base" | "discovery" | "proxy",
|
|
167
|
+
): Promise<string | undefined> {
|
|
168
|
+
const set = t(language, kind === "discovery" ? "discoveryUrlSet" : kind === "proxy" ? "proxySet" : "urlSet");
|
|
169
|
+
const clear = t(language, kind === "discovery" ? "discoveryUrlClear" : kind === "proxy" ? "proxyDisable" : "urlRestore");
|
|
170
|
+
const back = t(language, "back");
|
|
171
|
+
const currentValue = current ? redactUrl(current, language) : kind === "base" ? t(language, "valueDefault") : kind === "proxy" ? t(language, "valueProxyDisabled") : t(language, "valueUnset");
|
|
172
|
+
const action = await ctx.ui.select(`${label}: ${currentValue}`, [set, clear, back]);
|
|
173
|
+
if (!action || action === back) return current;
|
|
174
|
+
if (action === clear) return undefined;
|
|
175
|
+
const value = await ctx.ui.input(label, current ?? "https://example.com/v1");
|
|
176
|
+
if (value === undefined || !value.trim()) return current;
|
|
177
|
+
return validateHttpUrl(value, label);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function configureAuth(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, allowTemporary: boolean): Promise<void> {
|
|
181
|
+
const literal = t(language, "authLiteralOption");
|
|
182
|
+
const env = t(language, "authEnvOption");
|
|
183
|
+
const reuse = t(language, "authReuseOption");
|
|
184
|
+
const temporary = t(language, "authTemporaryOption");
|
|
185
|
+
const anonymous = t(language, "authAnonymousOption");
|
|
186
|
+
const cancel = t(language, "mainCancel");
|
|
187
|
+
const options = [literal, env, reuse, ...(allowTemporary ? [temporary] : []), anonymous, cancel];
|
|
188
|
+
const choice = await ctx.ui.select(t(language, "authTitle"), options);
|
|
189
|
+
if (!choice || choice === cancel) return;
|
|
190
|
+
if (choice === literal) {
|
|
191
|
+
const secret = await secretInput(ctx, language, t(language, "secretApiKey"));
|
|
192
|
+
if (secret === undefined) return;
|
|
193
|
+
if (!secret) throw mcError("MCFG_E_KEY_EMPTY");
|
|
194
|
+
if (/^\[(?:已掩码|masked)\]$/iu.test(secret)) throw mcError("MCFG_E_KEY_MASKED");
|
|
195
|
+
draft.apiKeyRaw = secret.startsWith("$") ? `$${secret}` : secret.startsWith("!") ? `$${secret}` : secret;
|
|
196
|
+
draft.temporaryKey = undefined;
|
|
197
|
+
draft.authMode = "models-json";
|
|
198
|
+
} else if (choice === env) {
|
|
199
|
+
const name = await ctx.ui.input(t(language, "envName"), "PROVIDER_API_KEY");
|
|
200
|
+
if (name === undefined) return;
|
|
201
|
+
const normalized = name.trim();
|
|
202
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(normalized)) throw mcError("MCFG_E_ENV_NAME");
|
|
203
|
+
draft.apiKeyRaw = `$${normalized}`;
|
|
204
|
+
draft.temporaryKey = undefined;
|
|
205
|
+
draft.authMode = "models-json";
|
|
206
|
+
} else if (choice === reuse) {
|
|
207
|
+
draft.apiKeyRaw = undefined;
|
|
208
|
+
draft.temporaryKey = undefined;
|
|
209
|
+
draft.authMode = "reuse-pi";
|
|
210
|
+
} else if (choice === temporary) {
|
|
211
|
+
const secret = await secretInput(ctx, language, t(language, "secretTemporary"));
|
|
212
|
+
if (secret === undefined) return;
|
|
213
|
+
if (!secret) throw mcError("MCFG_E_KEY_EMPTY");
|
|
214
|
+
draft.temporaryKey = secret;
|
|
215
|
+
draft.authMode = "temporary";
|
|
216
|
+
} else {
|
|
217
|
+
draft.apiKeyRaw = undefined;
|
|
218
|
+
draft.temporaryKey = undefined;
|
|
219
|
+
draft.authMode = "anonymous";
|
|
220
|
+
draft.authHeader = "none";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function connectionPanelFields(language: UiLanguage, draft: ProviderDraft): PanelItem[] {
|
|
225
|
+
return [
|
|
226
|
+
{ id: "name", label: t(language, "fieldName", { value: draft.name }), details: [t(language, "panelDescName")] },
|
|
227
|
+
{ id: "id", label: t(language, "fieldId", { value: draft.id }), details: [t(language, "panelDescId")] },
|
|
228
|
+
{ id: "ownership", label: t(language, "fieldOwnership", { value: t(language, draft.owned ? "owned" : "externalOwned") }), details: [t(language, "panelDescOwnership")] },
|
|
229
|
+
{ id: "baseUrl", label: t(language, "fieldBaseUrl", { value: draft.baseUrl ? redactUrl(draft.baseUrl, language) : t(language, "valueDefault") }), details: [t(language, "panelDescBaseUrl")] },
|
|
230
|
+
{ id: "discoveryUrl", label: t(language, "fieldDiscoveryUrl", { value: draft.discoveryUrl ? redactUrl(draft.discoveryUrl, language) : t(language, "valueUnset") }), details: [t(language, "panelDescDiscoveryUrl")] },
|
|
231
|
+
{ id: "auth", label: t(language, "fieldAuth", { value: authModeLabel(language, draft) }), details: [t(language, "panelDescAuth")] },
|
|
232
|
+
{ id: "discoveryAuth", label: t(language, "fieldDiscoveryAuth", { value: discoveryAuthLabel(language, draft.authHeader) }), details: [t(language, "panelDescDiscoveryAuth")] },
|
|
233
|
+
{ id: "proxy", label: t(language, "fieldProxy", { value: draft.proxyUrl ? redactUrl(draft.proxyUrl, language) : t(language, "valueProxyDisabled") }), details: [t(language, "panelDescProxy")] },
|
|
234
|
+
{ id: "defaultType", label: t(language, "fieldDefaultType", { value: modelTypeLabel(language, draft.defaultType) }), details: [t(language, "panelDescDefaultType")] },
|
|
235
|
+
{ id: "chatApi", label: t(language, "fieldChatApi", { value: draft.chatApi }), details: [t(language, "panelDescChatApi")] },
|
|
236
|
+
{ id: "classifierApi", label: t(language, "fieldClassifierApi", { value: draft.classifierApi }), details: [t(language, "panelDescClassifierApi")] },
|
|
237
|
+
{ id: "imageApi", label: t(language, "fieldImageApi", { value: draft.imageApi }), details: [t(language, "panelDescImageApi")] },
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, state: MenuPanelState, canTakeover: boolean): Promise<"models" | "takeover" | "testModels" | "preview" | "back"> {
|
|
242
|
+
while (true) {
|
|
243
|
+
const result = await showConnectionPanel(ctx, language, draft.id, connectionPanelFields(language, draft), state, canTakeover);
|
|
244
|
+
if (result.action !== "field") return result.action;
|
|
245
|
+
try {
|
|
246
|
+
switch (result.fieldId) {
|
|
247
|
+
case "name": {
|
|
248
|
+
const value = await ctx.ui.input(t(language, "providerDisplayName"), draft.name);
|
|
249
|
+
if (value?.trim()) draft.name = value.trim();
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case "id": ctx.ui.notify(t(language, "fieldIdReadonly"), "info"); break;
|
|
253
|
+
case "ownership": {
|
|
254
|
+
const next = !draft.owned;
|
|
255
|
+
if (next && await confirmAction(ctx, language, t(language, "takeoverTitle"), [t(language, "takeoverBody")], [], t(language, "confirmTakeover"))) draft.owned = true;
|
|
256
|
+
if (!next) draft.owned = false;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case "baseUrl": draft.baseUrl = await editUrl(ctx, language, t(language, "baseUrl"), draft.baseUrl, "base"); break;
|
|
260
|
+
case "discoveryUrl": draft.discoveryUrl = await editUrl(ctx, language, t(language, "discoveryUrl"), draft.discoveryUrl, "discovery"); break;
|
|
261
|
+
case "auth": await configureAuth(ctx, language, draft, true); break;
|
|
262
|
+
case "discoveryAuth": {
|
|
263
|
+
const choices = (["auto", "bearer", "x-api-key", "api-key", "x-goog-api-key", "anthropic", "none"] as const)
|
|
264
|
+
.map((mode) => ({ mode, label: discoveryAuthLabel(language, mode) }));
|
|
265
|
+
const value = await ctx.ui.select(t(language, "discoveryAuthTitle"), choices.map((choice) => choice.label));
|
|
266
|
+
const selected = choices.find((choice) => choice.label === value);
|
|
267
|
+
if (selected) draft.authHeader = selected.mode;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case "proxy": draft.proxyUrl = await editUrl(ctx, language, t(language, "proxyUrl"), draft.proxyUrl, "proxy"); break;
|
|
271
|
+
case "defaultType": {
|
|
272
|
+
const choices = (["chat", "classifier", "image"] as const).map((type) => ({ type, label: modelTypeLabel(language, type) }));
|
|
273
|
+
const value = await ctx.ui.select(t(language, "defaultTypeTitle"), choices.map((item) => item.label));
|
|
274
|
+
const selected = choices.find((item) => item.label === value);
|
|
275
|
+
if (selected) draft.defaultType = selected.type;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
case "chatApi": {
|
|
279
|
+
const value = await ctx.ui.select("Chat API", [...CHAT_APIS]);
|
|
280
|
+
if (value) draft.chatApi = value;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "classifierApi": {
|
|
284
|
+
const value = await ctx.ui.select("Classifier API", [...CLASSIFIER_APIS]);
|
|
285
|
+
if (value) draft.classifierApi = value;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case "imageApi": {
|
|
289
|
+
const value = await ctx.ui.select("Image API", [...IMAGE_APIS]);
|
|
290
|
+
if (value) draft.imageApi = value;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch (error) {
|
|
295
|
+
ctx.ui.notify(displayError(language, error, [draft.apiKeyRaw, draft.temporaryKey]), "error");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function installedModels(ctx: ExtensionCommandContext, providerId: string): AnyModel[] {
|
|
301
|
+
return [
|
|
302
|
+
...ctx.modelRegistry.getModelsOfType("chat", providerId),
|
|
303
|
+
...ctx.modelRegistry.getModelsOfType("classifier", providerId),
|
|
304
|
+
...ctx.modelRegistry.getModelsOfType("image", providerId),
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function previousManaged(provider: ModelsJsonProvider | undefined, config: ProviderConnectionConfig | undefined): ManagedModel[] {
|
|
309
|
+
if (!provider || !config) return config?.models ? structuredClone(config.models) : [];
|
|
310
|
+
const ids = new Set(config.managedChatIds ?? []);
|
|
311
|
+
const chat = (provider.models ?? [])
|
|
312
|
+
.filter((raw) => typeof raw.id === "string" && ids.has(raw.id))
|
|
313
|
+
.map((raw) => parseManagedChat(raw, provider, config))
|
|
314
|
+
.filter((model): model is ManagedModel => model !== undefined);
|
|
315
|
+
return [...chat, ...structuredClone(config.models ?? [])];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function validateSelection(
|
|
319
|
+
draft: ProviderDraft,
|
|
320
|
+
selected: ManagedModel[],
|
|
321
|
+
installed: AnyModel[],
|
|
322
|
+
existingProvider: ModelsJsonProvider | undefined,
|
|
323
|
+
): void {
|
|
324
|
+
if (draft.baseUrl) validateHttpUrl(draft.baseUrl, "Base URL");
|
|
325
|
+
if (draft.discoveryUrl) validateHttpUrl(draft.discoveryUrl, "Discovery URL");
|
|
326
|
+
if (draft.proxyUrl) validateHttpUrl(draft.proxyUrl, "Provider proxy URL");
|
|
327
|
+
if (draft.owned) {
|
|
328
|
+
const missingExistingUrl = (existingProvider?.models ?? []).some((model) => typeof model.baseUrl !== "string" && !draft.baseUrl);
|
|
329
|
+
const missingSelectedUrl = selected.some((model) => !model.baseUrl && !draft.baseUrl);
|
|
330
|
+
if (missingExistingUrl || missingSelectedUrl) throw mcError("MCFG_E_BASE_URL_REQUIRED");
|
|
331
|
+
for (const model of existingProvider?.models ?? []) validateHttpUrl(String(model.baseUrl ?? draft.baseUrl), `${draft.id}/${String(model.id)} Base URL`);
|
|
332
|
+
for (const model of selected) validateHttpUrl(model.baseUrl ?? draft.baseUrl!, `${draft.id}/${model.id} Base URL`);
|
|
333
|
+
}
|
|
334
|
+
if (!draft.owned && selected.some((model) => model.type !== "chat")) throw mcError("MCFG_E_NOT_OWNED", { provider: draft.id });
|
|
335
|
+
if (!draft.owned && (draft.proxyUrl || draft.authMode === "anonymous") && !new Set<string>(getBuiltinProviders()).has(draft.id)) {
|
|
336
|
+
throw mcError("MCFG_E_EXTERNAL_TRANSPORT");
|
|
337
|
+
}
|
|
338
|
+
const existingChatApis = new Set([
|
|
339
|
+
...installed.filter((model) => model.type !== "classifier" && model.type !== "image").map((model) => model.api),
|
|
340
|
+
...(existingProvider?.models ?? []).map((model) => typeof model.api === "string" ? model.api : (existingProvider?.api ?? draft.chatApi)),
|
|
341
|
+
]);
|
|
342
|
+
if (draft.proxyUrl) {
|
|
343
|
+
const unsupported = [...existingChatApis].filter((api) => !PROXY_CHAT_APIS.has(api));
|
|
344
|
+
if (unsupported.length) throw mcError("MCFG_E_PROXY_UNSUPPORTED", { identity: `${draft.id}: ${unsupported.join(", ")}` });
|
|
345
|
+
}
|
|
346
|
+
if (draft.authMode === "anonymous") {
|
|
347
|
+
const unsupported = [...existingChatApis].filter((api) => ["pi-messages", "bedrock-converse-stream", "google-generative-ai", "google-vertex"].includes(api));
|
|
348
|
+
if (unsupported.length) throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${draft.id}: ${unsupported.join(", ")}` });
|
|
349
|
+
}
|
|
350
|
+
for (const model of selected) {
|
|
351
|
+
const supported = model.type === "chat" ? CHAT_APIS : model.type === "classifier" ? CLASSIFIER_APIS : IMAGE_APIS;
|
|
352
|
+
if (!supported.has(model.api)) throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${draft.id}/${model.type}/${model.id}`, api: model.api });
|
|
353
|
+
if (draft.proxyUrl && model.type === "chat" && !PROXY_CHAT_APIS.has(model.api)) throw mcError("MCFG_E_PROXY_UNSUPPORTED", { identity: `${draft.id}/${model.type}/${model.id}` });
|
|
354
|
+
if (draft.authMode === "anonymous" && ["pi-messages", "bedrock-converse-stream", "google-generative-ai", "google-vertex"].includes(model.api)) {
|
|
355
|
+
throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${draft.id}/${model.type}/${model.id}` });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (draft.authMode === "models-json" && !draft.apiKeyRaw) throw mcError("MCFG_E_AUTH_REQUIRED");
|
|
359
|
+
if (draft.authMode === "models-json" && /^\[(?:已掩码|masked)\]$/iu.test(draft.apiKeyRaw ?? "")) throw mcError("MCFG_E_KEY_MASKED");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function chatToJson(model: ManagedModel): Record<string, unknown> {
|
|
363
|
+
if (model.type !== "chat") throw mcError("MCFG_E_INTERNAL_CHAT_ONLY");
|
|
364
|
+
const { type: _type, ...value } = model;
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function removeProvider(
|
|
369
|
+
files: { models: ModelsJsonFile; config: ModelConfigFile },
|
|
370
|
+
providerId: string,
|
|
371
|
+
): { models: ModelsJsonFile; config: ModelConfigFile } {
|
|
372
|
+
const models = structuredClone(files.models);
|
|
373
|
+
const config = structuredClone(files.config);
|
|
374
|
+
delete models.providers[providerId];
|
|
375
|
+
delete config.providers[providerId];
|
|
376
|
+
return { models, config };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function applyChanges(
|
|
380
|
+
files: { models: ModelsJsonFile; config: ModelConfigFile },
|
|
381
|
+
providerId: string,
|
|
382
|
+
draft: ProviderDraft,
|
|
383
|
+
selected: ManagedModel[],
|
|
384
|
+
adoptedChatIds: ReadonlySet<string> = new Set(),
|
|
385
|
+
modelMetadata: ModelMetadataEntry[] = [],
|
|
386
|
+
): { models: ModelsJsonFile; config: ModelConfigFile } {
|
|
387
|
+
const models = structuredClone(files.models);
|
|
388
|
+
const config = structuredClone(files.config);
|
|
389
|
+
const oldConfig = config.providers[providerId];
|
|
390
|
+
const oldProvider = models.providers[providerId] ?? {};
|
|
391
|
+
const provider = structuredClone(oldProvider);
|
|
392
|
+
const oldManagedIds = new Set(oldConfig?.managedChatIds ?? []);
|
|
393
|
+
const originalRows = new Map(
|
|
394
|
+
(oldProvider.models ?? []).filter((row) => typeof row.id === "string").map((row) => [row.id as string, row]),
|
|
395
|
+
);
|
|
396
|
+
const managedScopeIds = new Set([...oldManagedIds, ...adoptedChatIds]);
|
|
397
|
+
const unmanagedRows = (oldProvider.models ?? []).filter((row) => typeof row.id !== "string" || !managedScopeIds.has(row.id));
|
|
398
|
+
const selectedChat = selected.filter((model) => model.type === "chat");
|
|
399
|
+
provider.models = [...unmanagedRows, ...selectedChat.map((model) => ({
|
|
400
|
+
...structuredClone(originalRows.get(model.id) ?? {}),
|
|
401
|
+
...chatToJson(model),
|
|
402
|
+
}))];
|
|
403
|
+
if (draft.name) provider.name = draft.name;
|
|
404
|
+
if (draft.baseUrl) provider.baseUrl = draft.baseUrl; else delete provider.baseUrl;
|
|
405
|
+
if (draft.owned) provider.api = draft.chatApi;
|
|
406
|
+
if (draft.authMode === "models-json") provider.apiKey = draft.apiKeyRaw; else delete provider.apiKey;
|
|
407
|
+
models.providers[providerId] = provider;
|
|
408
|
+
|
|
409
|
+
config.providers[providerId] = {
|
|
410
|
+
...(oldConfig ?? {}),
|
|
411
|
+
name: draft.name,
|
|
412
|
+
owned: draft.owned,
|
|
413
|
+
enabled: draft.enabled,
|
|
414
|
+
authMode: draft.authMode as "models-json" | "reuse-pi" | "anonymous",
|
|
415
|
+
discoveryAuthHeader: draft.authHeader,
|
|
416
|
+
chatApi: draft.chatApi,
|
|
417
|
+
classifierApi: draft.classifierApi,
|
|
418
|
+
imageApi: draft.imageApi,
|
|
419
|
+
defaultType: draft.defaultType,
|
|
420
|
+
managedChatIds: selectedChat.map((model) => model.id),
|
|
421
|
+
models: selected.filter((model): model is Extract<ManagedModel, { type: "classifier" | "image" }> => model.type !== "chat"),
|
|
422
|
+
modelMetadata: modelMetadata.map((entry) => structuredClone(entry)),
|
|
423
|
+
};
|
|
424
|
+
if (draft.discoveryUrl) config.providers[providerId]!.discoveryUrl = draft.discoveryUrl; else delete config.providers[providerId]!.discoveryUrl;
|
|
425
|
+
if (draft.proxyUrl) config.providers[providerId]!.proxyUrl = draft.proxyUrl; else delete config.providers[providerId]!.proxyUrl;
|
|
426
|
+
return { models, config };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function buildPreviewDetails(
|
|
430
|
+
language: UiLanguage,
|
|
431
|
+
providerId: string,
|
|
432
|
+
before: ManagedModel[],
|
|
433
|
+
after: ManagedModel[],
|
|
434
|
+
oldConfig: ProviderConnectionConfig | undefined,
|
|
435
|
+
oldProvider: ModelsJsonProvider | undefined,
|
|
436
|
+
draft: ProviderDraft,
|
|
437
|
+
): { providerDetails: string[]; modelDetails: string[] } {
|
|
438
|
+
const oldMap = new Map(before.map((model) => [identityOf(model, providerId), model]));
|
|
439
|
+
const newMap = new Map(after.map((model) => [identityOf(model, providerId), model]));
|
|
440
|
+
const added = [...newMap.keys()].filter((key) => !oldMap.has(key));
|
|
441
|
+
const deleted = [...oldMap.keys()].filter((key) => !newMap.has(key));
|
|
442
|
+
const retained = [...newMap.keys()].filter((key) => oldMap.has(key));
|
|
443
|
+
const modified = retained.filter((key) => JSON.stringify(oldMap.get(key)) !== JSON.stringify(newMap.get(key)));
|
|
444
|
+
const unchanged = retained.length - modified.length;
|
|
445
|
+
const oldConnection = [
|
|
446
|
+
`name=${oldConfig?.name ?? oldProvider?.name ?? providerId}`,
|
|
447
|
+
`owned=${oldConfig?.owned ?? false}`,
|
|
448
|
+
`enabled=${oldConfig?.enabled !== false}`,
|
|
449
|
+
`base=${redactUrl(oldProvider?.baseUrl, language)}`,
|
|
450
|
+
`discovery=${redactUrl(oldConfig?.discoveryUrl, language)}`,
|
|
451
|
+
`auth=${authDescription(language, oldConfig, oldProvider)}`,
|
|
452
|
+
`discoveryAuth=${oldConfig?.discoveryAuthHeader ?? "auto"}`,
|
|
453
|
+
`proxy=${redactUrl(oldConfig?.proxyUrl, language)}`,
|
|
454
|
+
`apis=${oldConfig?.chatApi ?? oldProvider?.api ?? "openai-completions"}/${oldConfig?.classifierApi ?? "typesafe-system-one"}/${oldConfig?.imageApi ?? "openrouter-images"}`,
|
|
455
|
+
`defaults=${oldConfig?.defaultType ?? "chat"},${oldConfig?.defaultContextWindow ?? "legacy-unset"},${oldConfig?.defaultMaxTokens ?? "legacy-unset"}`,
|
|
456
|
+
].join(" | ");
|
|
457
|
+
const isEnvReference = /^\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)$/u.test(draft.apiKeyRaw ?? "");
|
|
458
|
+
const newAuth = draft.authMode === "models-json"
|
|
459
|
+
? isEnvReference ? t(language, "valueEnv", { value: draft.apiKeyRaw! }) : t(language, "valueLiteralMasked")
|
|
460
|
+
: draft.authMode === "reuse-pi" ? t(language, "valueReuseAuth") : t(language, "valueAnonymous");
|
|
461
|
+
const nextConnection = [
|
|
462
|
+
`name=${draft.name}`,
|
|
463
|
+
`owned=${draft.owned}`,
|
|
464
|
+
`enabled=${draft.enabled}`,
|
|
465
|
+
`base=${redactUrl(draft.baseUrl, language)}`,
|
|
466
|
+
`discovery=${redactUrl(draft.discoveryUrl, language)}`,
|
|
467
|
+
`auth=${newAuth}`,
|
|
468
|
+
`discoveryAuth=${draft.authHeader}`,
|
|
469
|
+
`proxy=${redactUrl(draft.proxyUrl, language)}`,
|
|
470
|
+
`apis=${draft.chatApi}/${draft.classifierApi}/${draft.imageApi}`,
|
|
471
|
+
`defaults=${draft.defaultType},legacy-fallback-not-applied`,
|
|
472
|
+
].join(" | ");
|
|
473
|
+
const describe = (key: ReturnType<typeof identityOf>) => {
|
|
474
|
+
const model = newMap.get(key) ?? oldMap.get(key)!;
|
|
475
|
+
return `${modelTypeLabel(language, model.type)}/${model.id}`;
|
|
476
|
+
};
|
|
477
|
+
const settingsChanged = oldConnection !== nextConnection;
|
|
478
|
+
const keyWillUpdate = draft.authMode === "models-json" && oldProvider?.apiKey !== draft.apiKeyRaw;
|
|
479
|
+
return {
|
|
480
|
+
providerDetails: [
|
|
481
|
+
settingsChanged ? t(language, "previewSettingsChanged") : t(language, "previewSettingsUnchanged"),
|
|
482
|
+
...(settingsChanged ? [t(language, "diffConnection", { before: oldConnection, after: nextConnection })] : []),
|
|
483
|
+
...(keyWillUpdate ? [t(language, "previewKeyUpdated")] : []),
|
|
484
|
+
],
|
|
485
|
+
modelDetails: [
|
|
486
|
+
...(added.length ? [t(language, "previewModelsAdded", { items: added.map(describe).join(", ") })] : []),
|
|
487
|
+
...(deleted.length ? [t(language, "previewModelsRemoved", { items: deleted.map(describe).join(", ") })] : []),
|
|
488
|
+
...(modified.length ? [t(language, "previewModelsModified", { items: modified.map(describe).join(", ") })] : []),
|
|
489
|
+
t(language, "previewModelsUnchanged", { count: unchanged }),
|
|
490
|
+
t(language, "diffSafety"),
|
|
491
|
+
],
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export interface ProviderChoice {
|
|
496
|
+
id: string;
|
|
497
|
+
label: string;
|
|
498
|
+
priority: number;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function buildConfiguredProviderChoices(
|
|
502
|
+
language: UiLanguage,
|
|
503
|
+
builtinProviderIds: ReadonlySet<string>,
|
|
504
|
+
models: ModelsJsonFile,
|
|
505
|
+
config: ModelConfigFile,
|
|
506
|
+
): ProviderChoice[] {
|
|
507
|
+
const ids = new Set([...Object.keys(models.providers), ...Object.keys(config.providers)]);
|
|
508
|
+
const choices: ProviderChoice[] = [];
|
|
509
|
+
for (const id of ids) {
|
|
510
|
+
const owned = config.providers[id]?.owned === true;
|
|
511
|
+
const builtin = builtinProviderIds.has(id);
|
|
512
|
+
const name = config.providers[id]?.name ?? models.providers[id]?.name;
|
|
513
|
+
const displayBase = name && name !== id ? `${name} (${id})` : id;
|
|
514
|
+
const display = config.providers[id]?.enabled === false ? `${displayBase} · ${t(language, "providerDisabled")}` : displayBase;
|
|
515
|
+
const status = t(language, builtin ? "providerTagBuiltin" : "providerTagCustom");
|
|
516
|
+
choices.push(owned
|
|
517
|
+
? { id, label: t(language, "providerLabelCustom", { display, status }), priority: 0 }
|
|
518
|
+
: builtin
|
|
519
|
+
? { id, label: t(language, "providerLabelBuiltin", { display, status }), priority: 1 }
|
|
520
|
+
: { id, label: t(language, "providerLabelCustom", { display, status }), priority: 2 });
|
|
521
|
+
}
|
|
522
|
+
return choices.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function buildProviderPanelItems(
|
|
526
|
+
ctx: ExtensionCommandContext,
|
|
527
|
+
language: UiLanguage,
|
|
528
|
+
models: ModelsJsonFile,
|
|
529
|
+
config: ModelConfigFile,
|
|
530
|
+
): PanelItem[] {
|
|
531
|
+
const builtinIds = new Set<string>(getBuiltinProviders());
|
|
532
|
+
const choices = buildConfiguredProviderChoices(language, builtinIds, models, config);
|
|
533
|
+
return choices.map((choice) => {
|
|
534
|
+
const provider = models.providers[choice.id];
|
|
535
|
+
const providerConfig = config.providers[choice.id];
|
|
536
|
+
const installed = installedModels(ctx, choice.id);
|
|
537
|
+
const chat = new Set(installed.filter((model) => model.type !== "classifier" && model.type !== "image").map((model) => model.id));
|
|
538
|
+
const classifier = new Set(installed.filter((model) => model.type === "classifier").map((model) => model.id));
|
|
539
|
+
const image = new Set(installed.filter((model) => model.type === "image").map((model) => model.id));
|
|
540
|
+
for (const model of provider?.models ?? []) if (typeof model.id === "string") chat.add(model.id);
|
|
541
|
+
for (const model of providerConfig?.models ?? []) (model.type === "classifier" ? classifier : image).add(model.id);
|
|
542
|
+
const managedChatIds = new Set(providerConfig?.managedChatIds ?? []);
|
|
543
|
+
const takeoverCount = (provider?.models ?? []).filter((model) => typeof model.id === "string" && !managedChatIds.has(model.id)).length;
|
|
544
|
+
const protocols = new Set<string>();
|
|
545
|
+
if (provider?.api) protocols.add(provider.api);
|
|
546
|
+
if (providerConfig?.chatApi) protocols.add(providerConfig.chatApi);
|
|
547
|
+
if (providerConfig?.classifierApi) protocols.add(providerConfig.classifierApi);
|
|
548
|
+
if (providerConfig?.imageApi) protocols.add(providerConfig.imageApi);
|
|
549
|
+
for (const model of provider?.models ?? []) if (typeof model.api === "string") protocols.add(model.api);
|
|
550
|
+
for (const model of providerConfig?.models ?? []) protocols.add(model.api);
|
|
551
|
+
return {
|
|
552
|
+
id: choice.id,
|
|
553
|
+
label: choice.label,
|
|
554
|
+
canTakeoverExisting: takeoverCount > 0,
|
|
555
|
+
enabled: providerConfig?.enabled !== false,
|
|
556
|
+
details: [
|
|
557
|
+
t(language, "panelSummaryName", { value: providerConfig?.name ?? provider?.name ?? choice.id }),
|
|
558
|
+
t(language, "panelSummaryId", { value: choice.id }),
|
|
559
|
+
t(language, "panelSummaryProtocol", { value: [...protocols].join(", ") || t(language, "valueUnset") }),
|
|
560
|
+
t(language, "panelSummaryBaseUrl", { value: provider?.baseUrl ? redactUrl(provider.baseUrl, language) : t(language, "valueDefault") }),
|
|
561
|
+
t(language, "panelSummaryDiscoveryUrl", { value: providerConfig?.discoveryUrl ? redactUrl(providerConfig.discoveryUrl, language) : t(language, "valueUnset") }),
|
|
562
|
+
t(language, "panelSummaryAuth", { value: authDescription(language, providerConfig, provider) }),
|
|
563
|
+
t(language, "panelSummaryProxy", { value: providerConfig?.proxyUrl ? redactUrl(providerConfig.proxyUrl, language) : t(language, "valueProxyDisabled") }),
|
|
564
|
+
],
|
|
565
|
+
modelDetails: [
|
|
566
|
+
t(language, "panelSummaryModels", { chat: chat.size, classifier: classifier.size, image: image.size }),
|
|
567
|
+
...(takeoverCount ? [t(language, "panelTakeoverCount", { count: takeoverCount })] : []),
|
|
568
|
+
],
|
|
569
|
+
};
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function buildBuiltinProviderChoices(language: UiLanguage, builtinProviderIds: Iterable<string>): ProviderChoice[] {
|
|
574
|
+
return [...new Set(builtinProviderIds)]
|
|
575
|
+
.sort((a, b) => a.localeCompare(b))
|
|
576
|
+
.map((id) => ({ id, label: t(language, "providerLabelBuiltin", { display: id, status: t(language, "providerTagBuiltin") }), priority: 0 }));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function chooseBuiltinProvider(ctx: ExtensionCommandContext, language: UiLanguage): Promise<{ id: string; isNew: false } | undefined> {
|
|
580
|
+
const choices = buildBuiltinProviderChoices(language, getBuiltinProviders());
|
|
581
|
+
const labelToId = new Map(choices.map((choice) => [choice.label, choice.id]));
|
|
582
|
+
const back = t(language, "back");
|
|
583
|
+
const selected = await ctx.ui.select(t(language, "newProviderTitle"), [...choices.map((choice) => choice.label), back]);
|
|
584
|
+
if (!selected || selected === back) return undefined;
|
|
585
|
+
const id = labelToId.get(selected);
|
|
586
|
+
return id ? { id, isNew: false } : undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function chooseCustomProvider(
|
|
590
|
+
ctx: ExtensionCommandContext,
|
|
591
|
+
language: UiLanguage,
|
|
592
|
+
runtimeIds: ReadonlySet<string>,
|
|
593
|
+
models: ModelsJsonFile,
|
|
594
|
+
config: ModelConfigFile,
|
|
595
|
+
): Promise<{ id: string; isNew: boolean } | undefined> {
|
|
596
|
+
const raw = await ctx.ui.input(t(language, "newProviderId"), "my-provider");
|
|
597
|
+
if (raw === undefined) return undefined;
|
|
598
|
+
const id = validateProviderId(raw);
|
|
599
|
+
const existingIds = new Set([...runtimeIds, ...getBuiltinProviders(), ...Object.keys(models.providers), ...Object.keys(config.providers)]);
|
|
600
|
+
if (existingIds.has(id)) {
|
|
601
|
+
ctx.ui.notify(t(language, "providerExists", { id }), "info");
|
|
602
|
+
return { id, isNew: false };
|
|
603
|
+
}
|
|
604
|
+
return { id, isNew: true };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function chooseLanguage(ctx: ExtensionCommandContext, agentDir: string, current: UiLanguage): Promise<UiLanguage> {
|
|
608
|
+
const zh = t(current, "languageZh");
|
|
609
|
+
const en = t(current, "languageEn");
|
|
610
|
+
const choice = await ctx.ui.select(t(current, "languageTitle"), [zh, en, t(current, "mainCancel")]);
|
|
611
|
+
if (!choice || choice === t(current, "mainCancel")) return current;
|
|
612
|
+
const next: UiLanguage = choice === en ? "en" : "zh-CN";
|
|
613
|
+
saveUiLanguage(agentDir, next);
|
|
614
|
+
ctx.ui.notify(t(next, "languageSaved", { language: t(next, next === "en" ? "languageEn" : "languageZh") }), "info");
|
|
615
|
+
return next;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function draftPanelItem(language: UiLanguage, draft: ProviderDraft, selected: ManagedModel[], adoptableCount: number): PanelItem {
|
|
619
|
+
const builtin = new Set<string>(getBuiltinProviders()).has(draft.id);
|
|
620
|
+
const displayBase = draft.name && draft.name !== draft.id ? `${draft.name} (${draft.id})` : draft.id;
|
|
621
|
+
const display = draft.enabled ? displayBase : `${displayBase} · ${t(language, "providerDisabled")}`;
|
|
622
|
+
const chat = selected.filter((model) => model.type === "chat").length;
|
|
623
|
+
const classifier = selected.filter((model) => model.type === "classifier").length;
|
|
624
|
+
const image = selected.filter((model) => model.type === "image").length;
|
|
625
|
+
return {
|
|
626
|
+
id: draft.id,
|
|
627
|
+
label: t(language, builtin ? "providerLabelBuiltin" : "providerLabelCustom", { display, status: t(language, builtin ? "providerTagBuiltin" : "providerTagCustom") }),
|
|
628
|
+
canTakeoverExisting: adoptableCount > 0,
|
|
629
|
+
enabled: draft.enabled,
|
|
630
|
+
details: [
|
|
631
|
+
t(language, "providerEnabledStatus", { value: t(language, draft.enabled ? "providerEnabled" : "providerDisabled") }),
|
|
632
|
+
t(language, "panelSummaryName", { value: draft.name }),
|
|
633
|
+
t(language, "panelSummaryId", { value: draft.id }),
|
|
634
|
+
t(language, "panelSummaryProtocol", { value: [draft.chatApi, draft.classifierApi, draft.imageApi].join(", ") }),
|
|
635
|
+
t(language, "panelSummaryBaseUrl", { value: draft.baseUrl ? redactUrl(draft.baseUrl, language) : t(language, "valueDefault") }),
|
|
636
|
+
t(language, "panelSummaryDiscoveryUrl", { value: draft.discoveryUrl ? redactUrl(draft.discoveryUrl, language) : t(language, "valueUnset") }),
|
|
637
|
+
t(language, "panelSummaryAuth", { value: authModeLabel(language, draft) }),
|
|
638
|
+
t(language, "panelSummaryProxy", { value: draft.proxyUrl ? redactUrl(draft.proxyUrl, language) : t(language, "valueProxyDisabled") }),
|
|
639
|
+
],
|
|
640
|
+
modelDetails: [
|
|
641
|
+
t(language, "panelSummaryModels", { chat, classifier, image }),
|
|
642
|
+
t(language, "detailFetchHint"),
|
|
643
|
+
...(adoptableCount ? [t(language, "panelTakeoverCount", { count: adoptableCount })] : []),
|
|
644
|
+
],
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function chooseProviderToAdd(
|
|
649
|
+
ctx: ExtensionCommandContext,
|
|
650
|
+
language: UiLanguage,
|
|
651
|
+
runtimeIds: ReadonlySet<string>,
|
|
652
|
+
models: ModelsJsonFile,
|
|
653
|
+
config: ModelConfigFile,
|
|
654
|
+
): Promise<{ id: string; isNew: boolean } | undefined> {
|
|
655
|
+
const builtin = t(language, "addBuiltinChoice");
|
|
656
|
+
const custom = t(language, "addCustomChoice");
|
|
657
|
+
const back = t(language, "back");
|
|
658
|
+
const choice = await ctx.ui.select(t(language, "addProviderTitle"), [builtin, custom, back]);
|
|
659
|
+
if (!choice || choice === back) return undefined;
|
|
660
|
+
return choice === builtin
|
|
661
|
+
? chooseBuiltinProvider(ctx, language)
|
|
662
|
+
: chooseCustomProvider(ctx, language, runtimeIds, models, config);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: StartupIssue[] = []): void {
|
|
666
|
+
pi.registerCommand("model-config", {
|
|
667
|
+
description: t("zh-CN", "commandDescription"),
|
|
668
|
+
handler: async (args, ctx) => {
|
|
669
|
+
const agentDir = getAgentDir();
|
|
670
|
+
let language = loadUiLanguage(agentDir);
|
|
671
|
+
const activeSecrets: Array<string | undefined> = [];
|
|
672
|
+
if (ctx.mode !== "tui") {
|
|
673
|
+
ctx.ui.notify(t(language, "tuiRequired"), "error");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
if (args.trim().toLowerCase() === "language") {
|
|
678
|
+
await chooseLanguage(ctx, agentDir, language);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
let startupIssuesReported = false;
|
|
682
|
+
const overviewState: OverviewPanelState = { search: "", focus: "list", actionIndex: 0, detailScroll: 0 };
|
|
683
|
+
mainLoop: while (true) {
|
|
684
|
+
if (!startupIssuesReported && startupIssues.length) {
|
|
685
|
+
const localized = startupIssues.slice(0, 3).map((issue) => {
|
|
686
|
+
const message = displayError(language, issue.error);
|
|
687
|
+
return issue.providerId ? `${issue.providerId}: ${message}` : message;
|
|
688
|
+
}).join("; ");
|
|
689
|
+
const more = startupIssues.length > 3 ? t(language, "startupMore", { count: startupIssues.length - 3 }) : "";
|
|
690
|
+
ctx.ui.notify(t(language, "startupIssues", { issues: localized, more }), "warning");
|
|
691
|
+
startupIssuesReported = true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const modelsPath = join(agentDir, "models.json");
|
|
695
|
+
const configPath = join(agentDir, "model-config.json");
|
|
696
|
+
const files = readConfigFiles(modelsPath, configPath);
|
|
697
|
+
if (files.recoveredTxId) ctx.ui.notify(t(language, "transactionRecovered", { txId: files.recoveredTxId }), "warning");
|
|
698
|
+
const overviewItems = buildProviderPanelItems(ctx, language, files.models, files.config);
|
|
699
|
+
const overview = await showProviderPanel(ctx, language, overviewItems, overviewState);
|
|
700
|
+
if (overview.action === "close") return;
|
|
701
|
+
if (overview.action === "language") {
|
|
702
|
+
language = await chooseLanguage(ctx, agentDir, language);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const runtimeIds = new Set<string>();
|
|
707
|
+
for (const type of ["chat", "classifier", "image"] as const) {
|
|
708
|
+
for (const model of ctx.modelRegistry.getModelsOfType(type)) runtimeIds.add(model.provider);
|
|
709
|
+
}
|
|
710
|
+
if (overview.action === "delete") {
|
|
711
|
+
const providerId = overview.providerId;
|
|
712
|
+
const providerConfig = files.config.providers[providerId];
|
|
713
|
+
const modelsProvider = files.models.providers[providerId];
|
|
714
|
+
const builtin = new Set<string>(getBuiltinProviders()).has(providerId);
|
|
715
|
+
if (ctx.model?.provider === providerId) {
|
|
716
|
+
ctx.ui.notify(t(language, "deleteCurrentModelBlocked"), "warning");
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (!builtin && !providerConfig?.owned && !modelsProvider) {
|
|
720
|
+
ctx.ui.notify(t(language, "deleteThirdPartyBlocked"), "error");
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const confirmed = await confirmAction(ctx, language, t(language, "deleteProviderTitle", { provider: providerId }),
|
|
724
|
+
[t(language, "deleteProviderSummary", { provider: providerId })],
|
|
725
|
+
[
|
|
726
|
+
t(language, "deleteProviderModels"),
|
|
727
|
+
t(language, "deleteProviderOverlay"),
|
|
728
|
+
t(language, "deleteProviderAuthRetained"),
|
|
729
|
+
t(language, "deleteProviderOtherExtension"),
|
|
730
|
+
...(builtin ? [t(language, "deleteProviderBuiltinRestore")] : []),
|
|
731
|
+
],
|
|
732
|
+
t(language, "confirmDeleteProvider"), "error");
|
|
733
|
+
if (!confirmed) continue;
|
|
734
|
+
try {
|
|
735
|
+
const next = removeProvider(files, providerId);
|
|
736
|
+
const transaction = writeConfigTransaction(files.modelsSnapshot, files.configSnapshot, next.models, next.config);
|
|
737
|
+
ctx.ui.notify(t(language, "deleteProviderSuccess", { provider: providerId, count: transaction.backups.length }), "info");
|
|
738
|
+
} catch (error) {
|
|
739
|
+
ctx.ui.notify(displayError(language, error, activeSecrets), "error");
|
|
740
|
+
}
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (overview.action === "toggle") {
|
|
744
|
+
const providerId = overview.providerId;
|
|
745
|
+
const providerConfig = files.config.providers[providerId];
|
|
746
|
+
const modelsProvider = files.models.providers[providerId];
|
|
747
|
+
const builtin = new Set<string>(getBuiltinProviders()).has(providerId);
|
|
748
|
+
const currentlyEnabled = providerConfig?.enabled !== false;
|
|
749
|
+
if (currentlyEnabled && ctx.model?.provider === providerId) {
|
|
750
|
+
ctx.ui.notify(t(language, "disableCurrentModelBlocked"), "warning");
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (currentlyEnabled && !builtin && !providerConfig?.owned && !modelsProvider) {
|
|
754
|
+
ctx.ui.notify(t(language, "disableThirdPartyBlocked"), "error");
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
const nextConfig = structuredClone(files.config);
|
|
759
|
+
nextConfig.providers[providerId] = {
|
|
760
|
+
...(providerConfig ?? { authMode: modelsProvider?.apiKey ? "models-json" : "reuse-pi" }),
|
|
761
|
+
enabled: !currentlyEnabled,
|
|
762
|
+
};
|
|
763
|
+
const transaction = writeConfigTransaction(files.modelsSnapshot, files.configSnapshot, files.models, nextConfig);
|
|
764
|
+
ctx.ui.notify(t(language, "toggleProviderSuccess", { provider: providerId, count: transaction.backups.length }), "info");
|
|
765
|
+
} catch (error) {
|
|
766
|
+
ctx.ui.notify(displayError(language, error, activeSecrets), "error");
|
|
767
|
+
}
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const target = overview.action === "add"
|
|
771
|
+
? await chooseProviderToAdd(ctx, language, runtimeIds, files.models, files.config)
|
|
772
|
+
: "providerId" in overview
|
|
773
|
+
? { id: overview.providerId, isNew: false }
|
|
774
|
+
: undefined;
|
|
775
|
+
if (!target) continue;
|
|
776
|
+
|
|
777
|
+
const oldProvider = files.models.providers[target.id];
|
|
778
|
+
const oldConfig = files.config.providers[target.id];
|
|
779
|
+
const draft = draftFrom(target.id, oldProvider, oldConfig, target.isNew);
|
|
780
|
+
const baselineDraft = structuredClone(draft);
|
|
781
|
+
const managedBefore = previousManaged(oldProvider, oldConfig);
|
|
782
|
+
const baselineSelected = structuredClone(managedBefore);
|
|
783
|
+
const persistedMetadata = new Map((oldConfig?.modelMetadata ?? []).map((entry) => [`${entry.type}\0${entry.id}`, entry]));
|
|
784
|
+
let selectedMetadata: ModelMetadataEntry[] = managedBefore.map((model) => {
|
|
785
|
+
const stored = persistedMetadata.get(`${model.type}\0${model.id}`);
|
|
786
|
+
if (stored) return structuredClone(stored);
|
|
787
|
+
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);
|
|
788
|
+
const explicitThinkingMap = model.type === "chat" && typeof (raw as any)?.thinkingLevelMap === "object" && (raw as any).thinkingLevelMap !== null && !Array.isArray((raw as any).thinkingLevelMap);
|
|
789
|
+
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 };
|
|
790
|
+
});
|
|
791
|
+
const baselineMetadata = structuredClone(selectedMetadata);
|
|
792
|
+
|
|
793
|
+
let selectedDraft = structuredClone(managedBefore);
|
|
794
|
+
const adoptedChatIds = new Set<string>();
|
|
795
|
+
let cachedRemote: CandidateModel[] = [];
|
|
796
|
+
let page: "connection" | "model" | "preview" = "connection";
|
|
797
|
+
let previewOrigin: "connection" | "model" = "model";
|
|
798
|
+
const connectionState: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 };
|
|
799
|
+
const modelState: ModelPanelState = { search: "", focus: "list", actionIndex: 0, detailScroll: 0 };
|
|
800
|
+
const previewState: MenuPanelState = { selectedId: "provider", focus: "list", actionIndex: 0, detailScroll: 0 };
|
|
801
|
+
activeSecrets.push(draft.apiKeyRaw);
|
|
802
|
+
|
|
803
|
+
const adoptableIds = () => {
|
|
804
|
+
const alreadyManaged = new Set(oldConfig?.managedChatIds ?? []);
|
|
805
|
+
return (oldProvider?.models ?? [])
|
|
806
|
+
.filter((model) => typeof model.id === "string" && !alreadyManaged.has(model.id) && !adoptedChatIds.has(model.id))
|
|
807
|
+
.map((model) => model.id as string);
|
|
808
|
+
};
|
|
809
|
+
const selectionFingerprint = (models: ManagedModel[]) => JSON.stringify([...models].sort((a, b) => identityOf(a, target.id).localeCompare(identityOf(b, target.id))));
|
|
810
|
+
const metadataFingerprint = (entries: ModelMetadataEntry[]) => JSON.stringify([...entries].sort((a, b) => `${a.type}\0${a.id}`.localeCompare(`${b.type}\0${b.id}`)));
|
|
811
|
+
const isDirty = () => JSON.stringify(draft) !== JSON.stringify(baselineDraft)
|
|
812
|
+
|| selectionFingerprint(selectedDraft) !== selectionFingerprint(baselineSelected)
|
|
813
|
+
|| metadataFingerprint(selectedMetadata) !== metadataFingerprint(baselineMetadata)
|
|
814
|
+
|| adoptedChatIds.size > 0;
|
|
815
|
+
const preparePreview = async (): Promise<boolean> => {
|
|
816
|
+
if (draft.authMode === "temporary") {
|
|
817
|
+
ctx.ui.notify(t(language, "temporarySaveBlocked"), "warning");
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
try { validateSelection(draft, selectedDraft, installedModels(ctx, target.id), oldProvider); }
|
|
821
|
+
catch (error) { ctx.ui.notify(displayError(language, error, activeSecrets), "error"); return false; }
|
|
822
|
+
if ((managedBefore.length > 0 || adoptedChatIds.size > 0) && selectedDraft.length === 0) {
|
|
823
|
+
const empty = await showConfirmationPanel(ctx, {
|
|
824
|
+
title: t(language, "emptyTitle"), summary: [t(language, "emptyBody")], severity: "error",
|
|
825
|
+
defaultActionIndex: 0, cancelActionId: "cancel",
|
|
826
|
+
actions: [
|
|
827
|
+
{ id: "cancel", label: t(language, "confirmCancel") },
|
|
828
|
+
{ id: "confirm", label: t(language, "confirmEmpty"), shortcut: "ctrl+s", severity: "error" },
|
|
829
|
+
],
|
|
830
|
+
});
|
|
831
|
+
if (empty !== "confirm") return false;
|
|
832
|
+
}
|
|
833
|
+
return true;
|
|
834
|
+
};
|
|
835
|
+
const adoptExisting = async (): Promise<boolean> => {
|
|
836
|
+
const ids = adoptableIds();
|
|
837
|
+
if (!ids.length) { ctx.ui.notify(t(language, "takeoverModelsNone"), "info"); return false; }
|
|
838
|
+
const action = await showConfirmationPanel(ctx, {
|
|
839
|
+
title: t(language, "takeoverModelsTitle"), summary: [t(language, "takeoverModelsBody", { provider: target.id, count: ids.length })],
|
|
840
|
+
details: [t(language, "diffSafety")], severity: "warning", defaultActionIndex: 0, cancelActionId: "cancel",
|
|
841
|
+
actions: [
|
|
842
|
+
{ id: "cancel", label: t(language, "confirmCancel") },
|
|
843
|
+
{ id: "confirm", label: t(language, "confirmTakeover"), shortcut: "ctrl+t", severity: "warning" },
|
|
844
|
+
],
|
|
845
|
+
});
|
|
846
|
+
if (action !== "confirm") return false;
|
|
847
|
+
const parseConfig: ProviderConnectionConfig = oldConfig ?? {
|
|
848
|
+
authMode: draft.authMode === "temporary" ? "reuse-pi" : draft.authMode,
|
|
849
|
+
chatApi: draft.chatApi,
|
|
850
|
+
};
|
|
851
|
+
const selectedKeys = new Set(selectedDraft.map((model) => identityOf(model, target.id)));
|
|
852
|
+
for (const raw of oldProvider?.models ?? []) {
|
|
853
|
+
if (typeof raw.id !== "string" || !ids.includes(raw.id)) continue;
|
|
854
|
+
const model = parseManagedChat(raw, oldProvider!, parseConfig, true);
|
|
855
|
+
if (model && !selectedKeys.has(identityOf(model, target.id))) selectedDraft.push(model);
|
|
856
|
+
adoptedChatIds.add(raw.id);
|
|
857
|
+
}
|
|
858
|
+
return true;
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
providerSession: while (true) {
|
|
862
|
+
if (page === "connection") {
|
|
863
|
+
const connection = await editConnectionPanel(ctx, language, draft, connectionState, adoptableIds().length > 0);
|
|
864
|
+
activeSecrets.push(draft.apiKeyRaw, draft.temporaryKey);
|
|
865
|
+
if (connection === "models") { page = "model"; continue; }
|
|
866
|
+
if (connection === "takeover") { if (await adoptExisting()) page = "model"; continue; }
|
|
867
|
+
if (connection === "testModels") {
|
|
868
|
+
if (!draft.enabled) { ctx.ui.notify(t(language, "testProviderDisabled"), "warning"); continue; }
|
|
869
|
+
if (draft.authMode === "temporary" || isDirty()) { ctx.ui.notify(t(language, "testDraftDirty"), "warning"); continue; }
|
|
870
|
+
const models = installedModels(ctx, target.id);
|
|
871
|
+
if (!models.length) { ctx.ui.notify(t(language, "testNoModels"), "info"); continue; }
|
|
872
|
+
await showModelTestFlow(ctx, language, models, [draft.apiKeyRaw, draft.temporaryKey]);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (connection === "preview") {
|
|
876
|
+
if (await preparePreview()) { previewOrigin = "connection"; page = "preview"; }
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (!isDirty()) continue mainLoop;
|
|
880
|
+
const leave = await showConfirmationPanel(ctx, {
|
|
881
|
+
title: t(language, "dirtyLeaveTitle"), summary: [t(language, "dirtyLeaveBody")], severity: "warning",
|
|
882
|
+
defaultActionIndex: 0, cancelActionId: "edit",
|
|
883
|
+
actions: [
|
|
884
|
+
{ id: "edit", label: t(language, "dirtyReturnEdit") },
|
|
885
|
+
{ id: "preview", label: t(language, "dirtyPreviewSave"), shortcut: "ctrl+s" },
|
|
886
|
+
{ id: "discard", label: t(language, "dirtyDiscard"), shortcut: "ctrl+d", severity: "error" },
|
|
887
|
+
],
|
|
888
|
+
});
|
|
889
|
+
if (leave === "edit") continue;
|
|
890
|
+
if (leave === "discard") continue mainLoop;
|
|
891
|
+
if (await preparePreview()) { previewOrigin = "connection"; page = "preview"; }
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (page === "model") {
|
|
896
|
+
const installedRaw = installedModels(ctx, target.id);
|
|
897
|
+
const installed = installedRaw.map(candidateFromInstalled);
|
|
898
|
+
const managedChatIds = new Set(oldConfig?.managedChatIds ?? []);
|
|
899
|
+
const parseConfig: ProviderConnectionConfig = oldConfig ?? {
|
|
900
|
+
authMode: draft.authMode === "temporary" ? "reuse-pi" : draft.authMode,
|
|
901
|
+
chatApi: draft.chatApi,
|
|
902
|
+
};
|
|
903
|
+
const external = (oldProvider?.models ?? [])
|
|
904
|
+
.filter((model) => typeof model.id === "string" && !managedChatIds.has(model.id))
|
|
905
|
+
.map((model) => parseManagedChat(model, oldProvider!, parseConfig, adoptedChatIds.has(typeof model.id === "string" ? model.id : "")))
|
|
906
|
+
.filter((model): model is ManagedModel => model !== undefined)
|
|
907
|
+
.map((model) => {
|
|
908
|
+
const raw = oldProvider?.models?.find((entry) => entry.id === model.id);
|
|
909
|
+
const explicitMap = typeof raw?.thinkingLevelMap === "object" && raw.thinkingLevelMap !== null && !Array.isArray(raw.thinkingLevelMap);
|
|
910
|
+
const defaultThinkingSource = model.type === "chat"
|
|
911
|
+
? explicitMap ? "existing" as const : adoptedChatIds.has(model.id) && protocolThinkingMap(model.api) ? "protocol" as const : undefined
|
|
912
|
+
: undefined;
|
|
913
|
+
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 };
|
|
914
|
+
return adoptedChatIds.has(model.id) ? candidateFromManaged(model, metadata) : candidateFromExternal(model, metadata);
|
|
915
|
+
});
|
|
916
|
+
const candidates = mergeCandidates(target.id, [...external, ...installed], managedBefore.map((model) => candidateFromManaged(model, persistedMetadata.get(`${model.type}\0${model.id}`))), cachedRemote);
|
|
917
|
+
const selection = await selectModels(ctx, language, target.id, draft, candidates, selectedDraft, modelState, selectedMetadata);
|
|
918
|
+
selectedDraft = selection.selected;
|
|
919
|
+
selectedMetadata = selection.metadata.map((entry) => ({ ...(selectedMetadata.find((old) => old.type === entry.type && old.id === entry.id) ?? {}), ...entry }));
|
|
920
|
+
if (selection.action === "back") { page = "connection"; continue; }
|
|
921
|
+
if (selection.action === "edit") {
|
|
922
|
+
const editTarget = selection.target;
|
|
923
|
+
if (editTarget?.readOnlyReason === "external-config") { ctx.ui.notify(t(language, "editorExternalTakeover"), "warning"); continue; }
|
|
924
|
+
const managedIndex = editTarget ? selectedDraft.findIndex((model) => model.id === editTarget.id && model.type === editTarget.type) : -1;
|
|
925
|
+
if (managedIndex < 0) { ctx.ui.notify(t(language, "editorManagedOnly"), "warning"); continue; }
|
|
926
|
+
const model = selectedDraft[managedIndex]!;
|
|
927
|
+
const metadataIndex = selectedMetadata.findIndex((entry) => entry.type === model.type && entry.id === model.id);
|
|
928
|
+
const metadata = metadataIndex >= 0 ? selectedMetadata[metadataIndex]! : { type: model.type, id: model.id };
|
|
929
|
+
const edited = await editModelParameters(ctx, language, model, metadata);
|
|
930
|
+
if (edited) { selectedDraft[managedIndex] = edited.model; if (metadataIndex >= 0) selectedMetadata[metadataIndex] = edited.metadata; else selectedMetadata.push(edited.metadata); }
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (selection.action === "probeThinking") {
|
|
934
|
+
const id = modelState.selectedId;
|
|
935
|
+
const managedIndex = selectedDraft.findIndex((model) => model.id === id);
|
|
936
|
+
const runtimeModel = installedRaw.find((model) => model.id === id);
|
|
937
|
+
const model = managedIndex >= 0 ? selectedDraft[managedIndex] : undefined;
|
|
938
|
+
if (!model || model.type !== "chat" || !runtimeModel || !draft.enabled || draft.authMode === "temporary" || isDirty()) { ctx.ui.notify(t(language, "thinkingProbeBlocked"), "warning"); continue; }
|
|
939
|
+
const currentCandidate = candidates.find((candidate) => candidate.id === id && candidate.type === "chat");
|
|
940
|
+
const resolved = resolveThinkingMap(currentCandidate?.source === "remote" ? (currentCandidate as any).thinkingLevelMap : undefined, (runtimeModel as any).thinkingLevelMap, model.thinkingLevelMap, model.api);
|
|
941
|
+
if (!resolved.map) { ctx.ui.notify(t(language, "thinkingProbeBlocked"), "warning"); continue; }
|
|
942
|
+
const probe = await showThinkingProbeFlow(ctx, language, runtimeModel, resolved.map, [draft.apiKeyRaw, draft.temporaryKey]);
|
|
943
|
+
if (!probe) continue;
|
|
944
|
+
model.reasoning = probe.accepted.length > 0;
|
|
945
|
+
model.thinkingLevelMap = acceptedThinkingMap(resolved.map, probe.accepted);
|
|
946
|
+
const metadataIndex = selectedMetadata.findIndex((entry) => entry.type === model.type && entry.id === model.id);
|
|
947
|
+
const metadata = { ...(metadataIndex >= 0 ? selectedMetadata[metadataIndex] : { type: model.type, id: model.id }), thinkingSource: "probed" as const, verifiedAt: new Date().toISOString() };
|
|
948
|
+
if (metadataIndex >= 0) selectedMetadata[metadataIndex] = metadata; else selectedMetadata.push(metadata);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
if (selection.action === "probe") {
|
|
952
|
+
const id = modelState.selectedId;
|
|
953
|
+
const managedIndex = selectedDraft.findIndex((model) => model.id === id);
|
|
954
|
+
const runtimeModel = installedRaw.find((model) => model.id === id);
|
|
955
|
+
if (!id || managedIndex < 0 || !runtimeModel || runtimeModel.type === "image" || !draft.enabled || draft.authMode === "temporary" || isDirty()) {
|
|
956
|
+
ctx.ui.notify(t(language, "probeBlocked"), "warning");
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const probe = await showContextProbeFlow(ctx, language, runtimeModel, [draft.apiKeyRaw, draft.temporaryKey]);
|
|
960
|
+
if (!probe?.lower) continue;
|
|
961
|
+
const model = selectedDraft[managedIndex]!;
|
|
962
|
+
if (model.type === "chat" && probe.lower < model.maxTokens) { ctx.ui.notify(t(language, "probeMaxConflict"), "warning"); continue; }
|
|
963
|
+
if (model.type !== "image") model.contextWindow = probe.lower;
|
|
964
|
+
const metadataIndex = selectedMetadata.findIndex((entry) => entry.type === model.type && entry.id === model.id);
|
|
965
|
+
const metadata = { ...(metadataIndex >= 0 ? selectedMetadata[metadataIndex] : { type: model.type, id: model.id }), contextSource: "probed" as const, verifiedAt: probe.verifiedAt, probeLower: probe.lower, probeUpper: probe.upper, probeEstimated: probe.estimated };
|
|
966
|
+
if (metadataIndex >= 0) selectedMetadata[metadataIndex] = metadata; else selectedMetadata.push(metadata);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (selection.action === "fetch") {
|
|
970
|
+
const fallbackBases = [
|
|
971
|
+
...(oldProvider?.models ?? []).map((model) => typeof model.baseUrl === "string" ? model.baseUrl : undefined),
|
|
972
|
+
...installedRaw.map((model) => model.baseUrl),
|
|
973
|
+
];
|
|
974
|
+
const discoveryCandidates = buildDiscoveryCandidates(draft, fallbackBases);
|
|
975
|
+
if (!draft.discoveryUrl) {
|
|
976
|
+
const edit = t(language, "discoveryRequiredEdit");
|
|
977
|
+
const cancel = t(language, "discoveryRequiredCancel");
|
|
978
|
+
if (discoveryCandidates.length) {
|
|
979
|
+
const use = t(language, "discoveryUseCandidates", { url: redactUrl(discoveryCandidates[0], language), count: Math.max(0, discoveryCandidates.length - 1) });
|
|
980
|
+
const choice = await showConfirmationPanel(ctx, {
|
|
981
|
+
title: t(language, "discoveryDerivedTitle"), summary: [use], details: [t(language, "discoveryCandidateSafety")],
|
|
982
|
+
defaultActionIndex: 2, cancelActionId: "cancel",
|
|
983
|
+
actions: [
|
|
984
|
+
{ id: "use", label: t(language, "discoveryUseAction"), shortcut: "ctrl+r" },
|
|
985
|
+
{ id: "edit", label: edit },
|
|
986
|
+
{ id: "cancel", label: cancel },
|
|
987
|
+
],
|
|
988
|
+
});
|
|
989
|
+
if (choice === "edit") page = "connection";
|
|
990
|
+
else if (choice !== "use") continue;
|
|
991
|
+
} else {
|
|
992
|
+
const choice = await showConfirmationPanel(ctx, {
|
|
993
|
+
title: t(language, "discoveryRequired"), summary: [t(language, "discoveryNoCandidate")],
|
|
994
|
+
defaultActionIndex: 1, cancelActionId: "cancel",
|
|
995
|
+
actions: [{ id: "edit", label: edit }, { id: "cancel", label: cancel }],
|
|
996
|
+
});
|
|
997
|
+
if (choice === "edit") page = "connection";
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (page === "connection") continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (!discoveryCandidates.length) { ctx.ui.notify(t(language, "discoveryRequired"), "warning"); continue; }
|
|
1003
|
+
const auth = draft.authMode === "reuse-pi" ? await ctx.modelRegistry.getProviderAuth(target.id) : undefined;
|
|
1004
|
+
const resolvedModelBase = draft.baseUrl ?? fallbackBases.find((value) => { if (!value) return false; try { validateHttpUrl(value, "Base URL"); return true; } catch { return false; } });
|
|
1005
|
+
const discoveryDraft = resolvedModelBase ? { ...draft, baseUrl: resolvedModelBase } : draft;
|
|
1006
|
+
const result = await withCancellableLoader(ctx, t(language, "fetching", { url: redactUrl(discoveryCandidates[0], language) }), (signal) => discoverModelsFromCandidates(discoveryDraft, discoveryCandidates, auth, signal));
|
|
1007
|
+
if (result.cancelled) { ctx.ui.notify(t(language, "fetchCancelled"), "info"); continue; }
|
|
1008
|
+
if (result.error) { ctx.ui.notify(displayError(language, result.error, [draft.apiKeyRaw, draft.temporaryKey]), "error"); continue; }
|
|
1009
|
+
draft.discoveryUrl = result.value!.url;
|
|
1010
|
+
cachedRemote = [...result.value!.models];
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (await preparePreview()) { previewOrigin = "model"; page = "preview"; }
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const details = buildPreviewDetails(language, target.id, managedBefore, selectedDraft, oldConfig, oldProvider, draft);
|
|
1018
|
+
const preview = await showPreviewPanel(ctx, language, target.id, details.providerDetails, details.modelDetails, previewState);
|
|
1019
|
+
if (preview.action === "back") { page = previewOrigin; continue; }
|
|
1020
|
+
if (preview.action === "discard") {
|
|
1021
|
+
const discard = await showConfirmationPanel(ctx, {
|
|
1022
|
+
title: t(language, "discardConfirmTitle"), summary: [t(language, "discardConfirmBody")], severity: "error",
|
|
1023
|
+
defaultActionIndex: 0, cancelActionId: "cancel",
|
|
1024
|
+
actions: [
|
|
1025
|
+
{ id: "cancel", label: t(language, "dirtyReturnEdit") },
|
|
1026
|
+
{ id: "discard", label: t(language, "dirtyDiscard"), shortcut: "ctrl+d", severity: "error" },
|
|
1027
|
+
],
|
|
1028
|
+
});
|
|
1029
|
+
if (discard === "discard") continue mainLoop;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const compatItems = selectedMetadata.filter((entry) => entry.contextSource === "compat" || entry.maxTokensSource === "compat").map((entry) => entry.type + '/' + entry.id);
|
|
1033
|
+
const risk = compatItems.length ? [t(language, "compatSaveRisk", { items: compatItems.join(", ") })] : [];
|
|
1034
|
+
const confirmed = await confirmAction(ctx, language, t(language, "finalSaveTitle"), [t(language, "finalSaveBody"), ...risk], [...details.providerDetails, ...details.modelDetails], t(language, "confirmSave"));
|
|
1035
|
+
if (!confirmed) continue;
|
|
1036
|
+
try {
|
|
1037
|
+
const next = applyChanges(files, target.id, draft, selectedDraft, adoptedChatIds, selectedMetadata);
|
|
1038
|
+
const transaction = writeConfigTransaction(files.modelsSnapshot, files.configSnapshot, next.models, next.config);
|
|
1039
|
+
ctx.ui.notify(t(language, "saveSuccess", { count: transaction.backups.length }), "info");
|
|
1040
|
+
continue mainLoop;
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
ctx.ui.notify(displayError(language, error, activeSecrets), "error");
|
|
1043
|
+
page = previewOrigin;
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
ctx.ui.notify(displayError(language, error, activeSecrets), "error");
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
}
|