@wuyaos/pi-model-config 0.1.6 → 0.1.8
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 +18 -0
- package/model-config.schema.json +13 -0
- package/package.json +1 -1
- package/src/auto-probe.ts +219 -0
- package/src/command.ts +86 -18
- package/src/config.ts +4 -0
- package/src/i18n.ts +26 -0
- package/src/image-apis/common.ts +2 -2
- package/src/panel.ts +27 -2
- package/src/selector.ts +17 -1
- package/src/types.ts +19 -0
package/README.md
CHANGED
|
@@ -178,6 +178,24 @@ Chat 写入 Pi 标准 `models.json`。Classifier 和 image 通过 Pi 的原生 m
|
|
|
178
178
|
|
|
179
179
|
插件是与 Pi 同权限运行的扩展,可以读取模型配置和认证。只从可信来源安装;优先使用 `$ENV` 或 Pi auth,避免长期 literal。
|
|
180
180
|
|
|
181
|
+
## 保存时自动探测参数(auto probe on save)
|
|
182
|
+
|
|
183
|
+
保存流程进入预览前(`Ctrl+S` 之后、preview 展示之前),插件会对草稿中 metadata 仍标记为 `compat`(即缺少 contextWindow/maxTokens 元数据的)chat 模型逐个串行发送一个最小探测请求:`POST {baseUrl}/chat/completions`,body 为 `{ model, messages: [{ role: "user", content: "用中文写一段约300字的描写" }], max_tokens: 100000 }`,单请求 15 秒超时。每个请求都是真实调用,可能计费;探测期间显示统一盒式进度面板(已完成 X/Y),Esc 可取消剩余请求,已完成的探测结果保留。
|
|
184
|
+
|
|
185
|
+
推断规则:`finish_reason=length` 且 `usage.completion_tokens_details.reasoning_tokens > 0`(或 `message.reasoning_content` 存在)→ `maxTokens = max(2 × reasoning_tokens, 16384)`,并标 `reasoning=true` + 协议默认 thinkingLevelMap;`finish_reason=length` 无 reasoning → `maxTokens = max(completion_tokens, 16384)`(保守取当前截断点);`finish_reason=stop` → `maxTokens = 32768`;HTTP 错误、超时、取消或不可信响应 → 该模型静默沿用 128K/16K 兼容默认值。contextWindow 无法由探测可靠测出,保留现有值或 128000 默认,但 metadata 也会标记为 `probed` 表示本轮已探测确认。探测成功回填草稿并标 `maxTokensSource/contextSource/thinkingSource="probed"`;最终落盘仍需 preview 确认,preview 的「模型配置」分类会为新增条目显示「参数已自动探测(实测)」来源标记。
|
|
186
|
+
|
|
187
|
+
凭据与 URL 安全复用 discovery 的规则:URL 必须通过 `validateHttpUrl`(仅 HTTP(S)、禁止嵌入凭据或密钥查询参数);`$ENV`/`$$` 字面量按 discovery 同样方式解析;reuse-pi 的 Pi 凭据仅在与模型地址同 origin 时才随探测请求转发。provider 专属代理同样适用于探测请求。探测密钥通过 `Authorization: Bearer` 头发送且不会出现在任何错误提示中。
|
|
188
|
+
|
|
189
|
+
该功能默认开启;在 `model-config.json` 顶层设置 `"autoProbeOnSave": false` 可整体关闭:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"version": 1,
|
|
194
|
+
"autoProbeOnSave": false,
|
|
195
|
+
"providers": {}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
181
199
|
## 从 jev-use 迁移到原生 classifier
|
|
182
200
|
|
|
183
201
|
本插件不会卸载或禁用 `jev-use`。建议手工迁移:
|
package/model-config.schema.json
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
"version": {
|
|
15
15
|
"const": 1
|
|
16
16
|
},
|
|
17
|
+
"autoProbeOnSave": {
|
|
18
|
+
"description": "Pre-save minimal parameter probe for newly checked chat models lacking context/maxTokens metadata. Default true.",
|
|
19
|
+
"type": "boolean",
|
|
20
|
+
"default": true
|
|
21
|
+
},
|
|
17
22
|
"providers": {
|
|
18
23
|
"type": "object",
|
|
19
24
|
"additionalProperties": {
|
|
@@ -192,6 +197,14 @@
|
|
|
192
197
|
},
|
|
193
198
|
"uniqueItems": true
|
|
194
199
|
},
|
|
200
|
+
"hiddenRemoteIds": {
|
|
201
|
+
"description": "Remote catalog model IDs hidden by the user across discovery refreshes.",
|
|
202
|
+
"type": "array",
|
|
203
|
+
"items": {
|
|
204
|
+
"type": "string"
|
|
205
|
+
},
|
|
206
|
+
"uniqueItems": true
|
|
207
|
+
},
|
|
195
208
|
"models": {
|
|
196
209
|
"type": "array",
|
|
197
210
|
"items": {
|
package/package.json
CHANGED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { validateHttpUrl } from "./config.ts";
|
|
4
|
+
import { MODEL_CONFIG_OVERLAY_OPTIONS, renderPanelBox } from "./panel.ts";
|
|
5
|
+
import { t, type UiLanguage } from "./i18n.ts";
|
|
6
|
+
import { protocolThinkingMap } from "./thinking-defaults.ts";
|
|
7
|
+
import type { ProviderDraft, ThinkingLevelMap } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Defaults applied when the minimal probe request fails or cannot be trusted. */
|
|
10
|
+
export const AUTO_PROBE_TIMEOUT_MS = 15_000;
|
|
11
|
+
export const AUTO_PROBE_MAX_TOKENS_REQUEST = 100_000;
|
|
12
|
+
const PROBE_PROMPT = "用中文写一段约300字的描写";
|
|
13
|
+
|
|
14
|
+
export interface AutoProbeResult {
|
|
15
|
+
/** Inferred output ceiling. Context window is intentionally not probed. */
|
|
16
|
+
maxTokens: number;
|
|
17
|
+
reasoning: boolean;
|
|
18
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProbeChatParamsOptions {
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure inference from a minimal chat-completions response. Returns null whenever the
|
|
31
|
+
* observation is not trustworthy, so callers can fall back to compatibility defaults.
|
|
32
|
+
*/
|
|
33
|
+
export function inferAutoProbeParams(
|
|
34
|
+
payload: {
|
|
35
|
+
finishReason?: unknown;
|
|
36
|
+
reasoningContent?: unknown;
|
|
37
|
+
reasoningTokens?: unknown;
|
|
38
|
+
completionTokens?: unknown;
|
|
39
|
+
},
|
|
40
|
+
api: string,
|
|
41
|
+
): AutoProbeResult | null {
|
|
42
|
+
const reasoningTokens = typeof payload.reasoningTokens === "number" && Number.isFinite(payload.reasoningTokens) && payload.reasoningTokens > 0
|
|
43
|
+
? Math.floor(payload.reasoningTokens)
|
|
44
|
+
: 0;
|
|
45
|
+
const hasReasoningContent = typeof payload.reasoningContent === "string" && payload.reasoningContent.trim().length > 0;
|
|
46
|
+
const reasoning = hasReasoningContent || reasoningTokens > 0;
|
|
47
|
+
const finish = payload.finishReason;
|
|
48
|
+
if (finish === "length") {
|
|
49
|
+
// Truncated mid-reasoning: the model wanted at least its reasoning budget twice.
|
|
50
|
+
const maxTokens = reasoningTokens > 0 ? Math.max(2 * reasoningTokens, 16_384) : Math.max(Math.floor(Number(payload.completionTokens)) || 0, 16_384);
|
|
51
|
+
const protocol = reasoning ? protocolThinkingMap(api) : undefined;
|
|
52
|
+
return { maxTokens, reasoning, ...(protocol ? { thinkingLevelMap: protocol } : {}) };
|
|
53
|
+
}
|
|
54
|
+
if (finish === "stop") return { maxTokens: 32_768, reasoning };
|
|
55
|
+
// tool_calls / content_filter / unknown shapes are not trustworthy for sizing.
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One minimal paid-capable request against an OpenAI-compatible /chat/completions
|
|
61
|
+
* endpoint. Never throws: every failure path resolves to null so the save flow can
|
|
62
|
+
* silently keep compatibility defaults. URL handling reuses validateHttpUrl, matching
|
|
63
|
+
* discovery SSRF rules (HTTP(S) only, no embedded credentials or secret queries).
|
|
64
|
+
*/
|
|
65
|
+
export async function probeChatParams(model: { id: string; api: string }, options: ProbeChatParamsOptions): Promise<AutoProbeResult | null> {
|
|
66
|
+
try {
|
|
67
|
+
const base = validateHttpUrl(options.baseUrl, "Base URL").replace(/\/+$/u, "");
|
|
68
|
+
const url = validateHttpUrl(`${base}/chat/completions`, "Probe URL");
|
|
69
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
70
|
+
if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`;
|
|
71
|
+
const combined = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs ?? AUTO_PROBE_TIMEOUT_MS)]);
|
|
72
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
73
|
+
const response = await fetchImpl(url, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers,
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
model: model.id,
|
|
78
|
+
messages: [{ role: "user", content: PROBE_PROMPT }],
|
|
79
|
+
max_tokens: AUTO_PROBE_MAX_TOKENS_REQUEST,
|
|
80
|
+
}),
|
|
81
|
+
signal: combined,
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok) return null;
|
|
84
|
+
const text = await response.text();
|
|
85
|
+
let body: any;
|
|
86
|
+
try { body = JSON.parse(text); } catch { return null; }
|
|
87
|
+
const choice = Array.isArray(body?.choices) ? body.choices[0] : undefined;
|
|
88
|
+
if (!choice || typeof choice !== "object") return null;
|
|
89
|
+
return inferAutoProbeParams({
|
|
90
|
+
finishReason: choice.finish_reason,
|
|
91
|
+
reasoningContent: choice.message?.reasoning_content,
|
|
92
|
+
reasoningTokens: body?.usage?.completion_tokens_details?.reasoning_tokens,
|
|
93
|
+
completionTokens: body?.usage?.completion_tokens,
|
|
94
|
+
}, model.api);
|
|
95
|
+
} catch {
|
|
96
|
+
// Abort (user Esc or timeout) and any transport/validation failure fall back to defaults.
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function originOf(value: string | undefined): string | undefined {
|
|
102
|
+
if (!value) return undefined;
|
|
103
|
+
try { return new URL(validateHttpUrl(value, "Base URL")).origin; } catch { return undefined; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the credential used by the probe request, applying the same origin
|
|
108
|
+
* containment as discovery for reused Pi credentials: an existing key is never
|
|
109
|
+
* forwarded to a different origin than the one it was issued for.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveProbeApiKey(
|
|
112
|
+
draft: Pick<ProviderDraft, "authMode" | "apiKeyRaw" | "temporaryKey">,
|
|
113
|
+
piAuth: { auth?: { apiKey?: string; baseUrl?: string } } | undefined,
|
|
114
|
+
baseUrl: string,
|
|
115
|
+
): string | undefined {
|
|
116
|
+
if (draft.authMode === "models-json") {
|
|
117
|
+
const raw = draft.apiKeyRaw;
|
|
118
|
+
const envMatch = raw?.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/u);
|
|
119
|
+
if (envMatch) return process.env[envMatch[1] ?? envMatch[2]!];
|
|
120
|
+
if (raw?.startsWith("$$") || raw?.startsWith("$!")) return raw.slice(1);
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
if (draft.authMode === "temporary") return draft.temporaryKey;
|
|
124
|
+
if (draft.authMode === "reuse-pi") {
|
|
125
|
+
const key = piAuth?.auth?.apiKey;
|
|
126
|
+
if (!key) return undefined;
|
|
127
|
+
const authOrigin = originOf(piAuth?.auth?.baseUrl);
|
|
128
|
+
const modelOrigin = originOf(baseUrl);
|
|
129
|
+
if (!authOrigin || !modelOrigin || authOrigin !== modelOrigin) return undefined;
|
|
130
|
+
return key;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Chat models whose metadata still marks context/maxTokens as unverified compat defaults. */
|
|
136
|
+
export function probeTargetsFor(
|
|
137
|
+
models: ReadonlyArray<{ type: string; id: string }>,
|
|
138
|
+
metadata: ReadonlyArray<{ type: string; id: string; contextSource?: string; maxTokensSource?: string }>,
|
|
139
|
+
): Array<{ type: string; id: string }> {
|
|
140
|
+
return models.filter((model) => {
|
|
141
|
+
if (model.type !== "chat") return false;
|
|
142
|
+
const entry = metadata.find((item) => item.type === model.type && item.id === model.id);
|
|
143
|
+
return !entry || entry.contextSource === "compat" || entry.maxTokensSource === "compat";
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ProbeQueueEntry<T> {
|
|
148
|
+
label: string;
|
|
149
|
+
run: (signal: AbortSignal) => Promise<T | null>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ProbeQueueOutcome<T> {
|
|
153
|
+
result: T | null;
|
|
154
|
+
/** True when this entry was skipped because the user pressed Esc earlier. */
|
|
155
|
+
cancelled: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Serial runner shared by the TUI panel and tests; one request at a time, abort stops the rest. */
|
|
159
|
+
export async function runProbeQueue<T>(
|
|
160
|
+
entries: ReadonlyArray<ProbeQueueEntry<T>>,
|
|
161
|
+
signal: AbortSignal,
|
|
162
|
+
onProgress?: (done: number, total: number, current: string) => void,
|
|
163
|
+
): Promise<Array<ProbeQueueOutcome<T>>> {
|
|
164
|
+
const outcomes: Array<ProbeQueueOutcome<T>> = [];
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (signal.aborted) { outcomes.push({ result: null, cancelled: true }); continue; }
|
|
167
|
+
onProgress?.(outcomes.length, entries.length, entry.label);
|
|
168
|
+
const result = await entry.run(signal);
|
|
169
|
+
outcomes.push({ result, cancelled: false });
|
|
170
|
+
onProgress?.(outcomes.length, entries.length, entry.label);
|
|
171
|
+
}
|
|
172
|
+
return outcomes;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
class AutoProbePanel<T> implements Component {
|
|
176
|
+
private controller = new AbortController();
|
|
177
|
+
private done = 0;
|
|
178
|
+
private current = "";
|
|
179
|
+
constructor(
|
|
180
|
+
private tui: TUI,
|
|
181
|
+
private theme: Theme,
|
|
182
|
+
private language: UiLanguage,
|
|
183
|
+
private entries: ReadonlyArray<ProbeQueueEntry<T>>,
|
|
184
|
+
done: (outcomes: Array<ProbeQueueOutcome<T>>) => void,
|
|
185
|
+
) {
|
|
186
|
+
void runProbeQueue(this.entries, this.controller.signal, (completed, _total, label) => {
|
|
187
|
+
this.done = completed;
|
|
188
|
+
this.current = label;
|
|
189
|
+
this.tui.requestRender();
|
|
190
|
+
}).then((outcomes) => {
|
|
191
|
+
this.done = outcomes.length;
|
|
192
|
+
this.current = "";
|
|
193
|
+
this.tui.requestRender();
|
|
194
|
+
done(outcomes);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
handleInput(data: string): void { if (matchesKey(data, Key.escape)) this.controller.abort(new DOMException("Cancelled", "AbortError")); }
|
|
198
|
+
render(width: number): string[] {
|
|
199
|
+
const lines = [t(this.language, "autoProbeProgress", { done: this.done, total: this.entries.length, model: this.current })];
|
|
200
|
+
return renderPanelBox(lines, width, t(this.language, "autoProbePanelTitle"), this.theme);
|
|
201
|
+
}
|
|
202
|
+
invalidate(): void {}
|
|
203
|
+
dispose(): void { this.controller.abort(); }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Unified boxed progress panel for the pre-save auto probe. Shows X/Y completed and
|
|
208
|
+
* lets Esc cancel the remaining (not in-flight) requests; completed results are kept.
|
|
209
|
+
*/
|
|
210
|
+
export async function runAutoProbeFlow<T>(
|
|
211
|
+
ctx: ExtensionCommandContext,
|
|
212
|
+
language: UiLanguage,
|
|
213
|
+
entries: ReadonlyArray<ProbeQueueEntry<T>>,
|
|
214
|
+
): Promise<Array<ProbeQueueOutcome<T>>> {
|
|
215
|
+
return ctx.ui.custom<Array<ProbeQueueOutcome<T>>>(
|
|
216
|
+
(tui, theme, _keys, done) => new AutoProbePanel(tui, theme, language, entries, done),
|
|
217
|
+
MODEL_CONFIG_OVERLAY_OPTIONS,
|
|
218
|
+
);
|
|
219
|
+
}
|
package/src/command.ts
CHANGED
|
@@ -12,12 +12,15 @@ import {
|
|
|
12
12
|
writeConfigTransaction,
|
|
13
13
|
} from "./config.ts";
|
|
14
14
|
import { buildDiscoveryCandidates, discoverModelsFromCandidates } from "./discovery.ts";
|
|
15
|
+
import { createProviderFetch } from "./transport.ts";
|
|
16
|
+
import { probeChatParams, probeTargetsFor, resolveProbeApiKey, runAutoProbeFlow } from "./auto-probe.ts";
|
|
15
17
|
import { formatError, mcError, t, type UiLanguage } from "./i18n.ts";
|
|
16
18
|
import {
|
|
17
19
|
showConnectionPanel,
|
|
18
20
|
showPreviewPanel,
|
|
19
21
|
showProviderPanel,
|
|
20
22
|
showConfirmationPanel,
|
|
23
|
+
showRestoreHiddenPanel,
|
|
21
24
|
type MenuPanelState,
|
|
22
25
|
type OverviewPanelState,
|
|
23
26
|
type PanelItem,
|
|
@@ -36,7 +39,7 @@ import type {
|
|
|
36
39
|
ProviderConnectionConfig,
|
|
37
40
|
ProviderDraft,
|
|
38
41
|
} from "./types.ts";
|
|
39
|
-
import { identityOf } from "./types.ts";
|
|
42
|
+
import { hiddenRemoteKey, identityOf, isHiddenRemote, normalizeHiddenRemoteKeys } from "./types.ts";
|
|
40
43
|
import { loadUiLanguage, saveUiLanguage } from "./ui-config.ts";
|
|
41
44
|
import { secretInput, withCancellableLoader } from "./ui.ts";
|
|
42
45
|
import { showModelTestFlow } from "./model-test.ts";
|
|
@@ -157,6 +160,7 @@ function draftFrom(providerId: string, provider: ModelsJsonProvider | undefined,
|
|
|
157
160
|
classifierApi: config?.classifierApi ?? "typesafe-system-one",
|
|
158
161
|
imageApi: config?.imageApi ?? "openrouter-images",
|
|
159
162
|
defaultType: config?.defaultType ?? "chat",
|
|
163
|
+
hiddenRemoteIds: normalizeHiddenRemoteKeys(config?.hiddenRemoteIds ?? []),
|
|
160
164
|
};
|
|
161
165
|
}
|
|
162
166
|
|
|
@@ -248,9 +252,9 @@ function connectionPanelFields(language: UiLanguage, draft: ProviderDraft): Pane
|
|
|
248
252
|
];
|
|
249
253
|
}
|
|
250
254
|
|
|
251
|
-
async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, state: MenuPanelState, canTakeover: boolean): Promise<"models" | "takeover" | "testModels" | "preview" | "back"> {
|
|
255
|
+
async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLanguage, draft: ProviderDraft, state: MenuPanelState, canTakeover: boolean, hiddenRemoteCount: number): Promise<"models" | "takeover" | "restoreHidden" | "testModels" | "preview" | "back"> {
|
|
252
256
|
while (true) {
|
|
253
|
-
const result = await showConnectionPanel(ctx, language, draft.id, connectionPanelFields(language, draft), state, canTakeover);
|
|
257
|
+
const result = await showConnectionPanel(ctx, language, draft.id, connectionPanelFields(language, draft), state, canTakeover, hiddenRemoteCount);
|
|
254
258
|
if (result.action !== "field") return result.action;
|
|
255
259
|
try {
|
|
256
260
|
switch (result.fieldId) {
|
|
@@ -424,8 +428,10 @@ export function applyChanges(
|
|
|
424
428
|
if (draft.authMode === "models-json") provider.apiKey = draft.apiKeyRaw; else delete provider.apiKey;
|
|
425
429
|
models.providers[providerId] = provider;
|
|
426
430
|
|
|
431
|
+
const normalizedHiddenRemoteIds = normalizeHiddenRemoteKeys(draft.hiddenRemoteIds ?? []);
|
|
432
|
+
const { hiddenRemoteIds: _oldHiddenRemoteIds, ...oldConfigWithoutHiddenRemoteIds } = oldConfig ?? {};
|
|
427
433
|
config.providers[providerId] = {
|
|
428
|
-
...
|
|
434
|
+
...oldConfigWithoutHiddenRemoteIds,
|
|
429
435
|
name: draft.name,
|
|
430
436
|
owned: draft.owned,
|
|
431
437
|
enabled: draft.enabled,
|
|
@@ -436,6 +442,7 @@ export function applyChanges(
|
|
|
436
442
|
imageApi: draft.imageApi,
|
|
437
443
|
defaultType: draft.defaultType,
|
|
438
444
|
managedChatIds: selectedChat.map((model) => model.id),
|
|
445
|
+
...(normalizedHiddenRemoteIds.length ? { hiddenRemoteIds: normalizedHiddenRemoteIds } : {}),
|
|
439
446
|
models: selected.filter((model): model is Extract<ManagedModel, { type: "classifier" | "image" }> => model.type !== "chat"),
|
|
440
447
|
modelMetadata: modelMetadata.map((entry) => structuredClone(entry)),
|
|
441
448
|
};
|
|
@@ -452,6 +459,7 @@ function buildPreviewDetails(
|
|
|
452
459
|
oldConfig: ProviderConnectionConfig | undefined,
|
|
453
460
|
oldProvider: ModelsJsonProvider | undefined,
|
|
454
461
|
draft: ProviderDraft,
|
|
462
|
+
metadata: ReadonlyArray<ModelMetadataEntry> = [],
|
|
455
463
|
): { providerDetails: string[]; modelDetails: string[] } {
|
|
456
464
|
const oldMap = new Map(before.map((model) => [identityOf(model, providerId), model]));
|
|
457
465
|
const newMap = new Map(after.map((model) => [identityOf(model, providerId), model]));
|
|
@@ -492,6 +500,13 @@ function buildPreviewDetails(
|
|
|
492
500
|
const model = newMap.get(key) ?? oldMap.get(key)!;
|
|
493
501
|
return `${modelTypeLabel(language, model.type)}/${model.id}`;
|
|
494
502
|
};
|
|
503
|
+
// Newly added rows whose metadata was just probed get an explicit source marker.
|
|
504
|
+
const describeAdded = (key: ReturnType<typeof identityOf>) => {
|
|
505
|
+
const model = newMap.get(key)!;
|
|
506
|
+
const entry = metadata.find((item) => item.type === model.type && item.id === model.id);
|
|
507
|
+
const probed = entry?.contextSource === "probed" || entry?.maxTokensSource === "probed";
|
|
508
|
+
return `${describe(key)}${probed ? ` · ${t(language, "previewProbedDefaults")}` : ""}`;
|
|
509
|
+
};
|
|
495
510
|
const settingsChanged = oldConnection !== nextConnection;
|
|
496
511
|
const keyWillUpdate = draft.authMode === "models-json" && oldProvider?.apiKey !== draft.apiKeyRaw;
|
|
497
512
|
return {
|
|
@@ -501,7 +516,7 @@ function buildPreviewDetails(
|
|
|
501
516
|
...(keyWillUpdate ? [t(language, "previewKeyUpdated")] : []),
|
|
502
517
|
],
|
|
503
518
|
modelDetails: [
|
|
504
|
-
...(added.length ? [t(language, "previewModelsAdded", { items: added.map(
|
|
519
|
+
...(added.length ? [t(language, "previewModelsAdded", { items: added.map(describeAdded).join(", ") })] : []),
|
|
505
520
|
...(deleted.length ? [t(language, "previewModelsRemoved", { items: deleted.map(describe).join(", ") })] : []),
|
|
506
521
|
...(modified.length ? [t(language, "previewModelsModified", { items: modified.map(describe).join(", ") })] : []),
|
|
507
522
|
t(language, "previewModelsUnchanged", { count: unchanged }),
|
|
@@ -514,6 +529,7 @@ export interface ProviderChoice {
|
|
|
514
529
|
id: string;
|
|
515
530
|
label: string;
|
|
516
531
|
priority: number;
|
|
532
|
+
enabled: boolean;
|
|
517
533
|
}
|
|
518
534
|
|
|
519
535
|
export function buildConfiguredProviderChoices(
|
|
@@ -524,20 +540,21 @@ export function buildConfiguredProviderChoices(
|
|
|
524
540
|
): ProviderChoice[] {
|
|
525
541
|
const ids = new Set([...Object.keys(models.providers), ...Object.keys(config.providers)]);
|
|
526
542
|
const choices: ProviderChoice[] = [];
|
|
527
|
-
for (const id of ids) {
|
|
543
|
+
for (const id of [...ids].sort((a, b) => a.localeCompare(b))) {
|
|
528
544
|
const owned = config.providers[id]?.owned === true;
|
|
529
545
|
const builtin = builtinProviderIds.has(id);
|
|
530
546
|
const name = config.providers[id]?.name ?? models.providers[id]?.name;
|
|
531
547
|
const displayBase = name && name !== id ? `${name} (${id})` : id;
|
|
532
548
|
const display = config.providers[id]?.enabled === false ? `${displayBase} · ${t(language, "providerDisabled")}` : displayBase;
|
|
533
549
|
const status = t(language, builtin ? "providerTagBuiltin" : "providerTagCustom");
|
|
550
|
+
const enabled = config.providers[id]?.enabled !== false;
|
|
534
551
|
choices.push(owned
|
|
535
|
-
? { id, label: t(language, "providerLabelCustom", { display, status }), priority: 0 }
|
|
552
|
+
? { id, label: t(language, "providerLabelCustom", { display, status }), priority: 0, enabled }
|
|
536
553
|
: builtin
|
|
537
|
-
? { id, label: t(language, "providerLabelBuiltin", { display, status }), priority: 1 }
|
|
538
|
-
: { id, label: t(language, "providerLabelCustom", { display, status }), priority: 2 });
|
|
554
|
+
? { id, label: t(language, "providerLabelBuiltin", { display, status }), priority: 1, enabled }
|
|
555
|
+
: { id, label: t(language, "providerLabelCustom", { display, status }), priority: 2, enabled });
|
|
539
556
|
}
|
|
540
|
-
return choices.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id));
|
|
557
|
+
return choices.sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.priority - b.priority || a.id.localeCompare(b.id));
|
|
541
558
|
}
|
|
542
559
|
|
|
543
560
|
function buildProviderPanelItems(
|
|
@@ -591,7 +608,7 @@ function buildProviderPanelItems(
|
|
|
591
608
|
export function buildBuiltinProviderChoices(language: UiLanguage, builtinProviderIds: Iterable<string>): ProviderChoice[] {
|
|
592
609
|
return [...new Set(builtinProviderIds)]
|
|
593
610
|
.sort((a, b) => a.localeCompare(b))
|
|
594
|
-
.map((id) => ({ id, label: t(language, "providerLabelBuiltin", { display: id, status: t(language, "providerTagBuiltin") }), priority: 0 }));
|
|
611
|
+
.map((id) => ({ id, label: t(language, "providerLabelBuiltin", { display: id, status: t(language, "providerTagBuiltin") }), priority: 0, enabled: true }));
|
|
595
612
|
}
|
|
596
613
|
|
|
597
614
|
async function chooseBuiltinProvider(ctx: ExtensionCommandContext, language: UiLanguage): Promise<{ id: string; isNew: false } | undefined> {
|
|
@@ -657,6 +674,7 @@ function draftPanelItem(language: UiLanguage, draft: ProviderDraft, selected: Ma
|
|
|
657
674
|
],
|
|
658
675
|
modelDetails: [
|
|
659
676
|
t(language, "panelSummaryModels", { chat, classifier, image }),
|
|
677
|
+
...(draft.hiddenRemoteIds?.length ? [t(language, "panelHiddenRemoteCount", { count: draft.hiddenRemoteIds.length })] : []),
|
|
660
678
|
t(language, "detailFetchHint"),
|
|
661
679
|
...(adoptableCount ? [t(language, "panelTakeoverCount", { count: adoptableCount })] : []),
|
|
662
680
|
],
|
|
@@ -840,6 +858,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
|
|
|
840
858
|
|
|
841
859
|
let selectedDraft = structuredClone(baselineSelected);
|
|
842
860
|
let cachedRemote: CandidateModel[] = [];
|
|
861
|
+
let fetchInFlight = false;
|
|
843
862
|
let page: "connection" | "model" | "preview" = "connection";
|
|
844
863
|
let previewOrigin: "connection" | "model" = "model";
|
|
845
864
|
const connectionState: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 };
|
|
@@ -873,6 +892,48 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
|
|
|
873
892
|
});
|
|
874
893
|
if (empty !== "confirm") return false;
|
|
875
894
|
}
|
|
895
|
+
if (files.config.autoProbeOnSave !== false && draft.enabled) {
|
|
896
|
+
const wanted = new Set(probeTargetsFor(selectedDraft, selectedMetadata).map((model) => `${model.type}\u0000${model.id}`));
|
|
897
|
+
const targets = selectedDraft
|
|
898
|
+
.map((model, index) => ({ model, index, metadataIndex: selectedMetadata.findIndex((entry) => entry.type === model.type && entry.id === model.id) }))
|
|
899
|
+
.filter(({ model }) => wanted.has(`${model.type}\u0000${model.id}`));
|
|
900
|
+
if (targets.length) {
|
|
901
|
+
try {
|
|
902
|
+
const piAuth = draft.authMode === "reuse-pi" ? await ctx.modelRegistry.getProviderAuth(target.id) : undefined;
|
|
903
|
+
const fetchImpl = draft.proxyUrl ? createProviderFetch(draft.proxyUrl) : globalThis.fetch;
|
|
904
|
+
const entries = targets.map(({ model }) => ({
|
|
905
|
+
label: model.id,
|
|
906
|
+
run: (signal: AbortSignal) => {
|
|
907
|
+
const baseUrl = model.type === "chat" ? model.baseUrl ?? draft.baseUrl : draft.baseUrl;
|
|
908
|
+
if (!baseUrl) return Promise.resolve(null);
|
|
909
|
+
return probeChatParams(model, { fetch: fetchImpl, apiKey: resolveProbeApiKey(draft, piAuth, baseUrl), baseUrl, signal });
|
|
910
|
+
},
|
|
911
|
+
}));
|
|
912
|
+
const outcomes = await runAutoProbeFlow(ctx, language, entries);
|
|
913
|
+
for (let index = 0; index < targets.length; index++) {
|
|
914
|
+
const { model, metadataIndex } = targets[index]!;
|
|
915
|
+
const outcome = outcomes[index]!;
|
|
916
|
+
if (model.type !== "chat" || outcome.cancelled || !outcome.result) continue;
|
|
917
|
+
const result = outcome.result;
|
|
918
|
+
model.maxTokens = result.maxTokens;
|
|
919
|
+
model.reasoning = result.reasoning;
|
|
920
|
+
if (result.reasoning && result.thinkingLevelMap) model.thinkingLevelMap = { ...result.thinkingLevelMap };
|
|
921
|
+
else delete model.thinkingLevelMap;
|
|
922
|
+
const metadata = {
|
|
923
|
+
...(metadataIndex >= 0 ? selectedMetadata[metadataIndex] : { type: model.type, id: model.id }),
|
|
924
|
+
contextSource: "probed" as const,
|
|
925
|
+
maxTokensSource: "probed" as const,
|
|
926
|
+
thinkingSource: "probed" as const,
|
|
927
|
+
verifiedAt: new Date().toISOString(),
|
|
928
|
+
};
|
|
929
|
+
if (metadataIndex >= 0) selectedMetadata[metadataIndex] = metadata; else selectedMetadata.push(metadata);
|
|
930
|
+
}
|
|
931
|
+
if (outcomes.some((outcome) => outcome.cancelled)) ctx.ui.notify(t(language, "autoProbeCancelled"), "info");
|
|
932
|
+
} catch {
|
|
933
|
+
// Probe failures are silent: unprobed models keep compatibility defaults.
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
876
937
|
return true;
|
|
877
938
|
};
|
|
878
939
|
const adoptExisting = async (): Promise<boolean> => {
|
|
@@ -898,10 +959,15 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
|
|
|
898
959
|
|
|
899
960
|
providerSession: while (true) {
|
|
900
961
|
if (page === "connection") {
|
|
901
|
-
const connection = await editConnectionPanel(ctx, language, draft, connectionState, adoptableIds().length > 0);
|
|
962
|
+
const connection = await editConnectionPanel(ctx, language, draft, connectionState, adoptableIds().length > 0, draft.hiddenRemoteIds?.length ?? 0);
|
|
902
963
|
activeSecrets.push(draft.apiKeyRaw, draft.temporaryKey);
|
|
903
964
|
if (connection === "models") { page = "model"; continue; }
|
|
904
965
|
if (connection === "takeover") { if (await adoptExisting()) page = "model"; continue; }
|
|
966
|
+
if (connection === "restoreHidden") {
|
|
967
|
+
const restored = await showRestoreHiddenPanel(ctx, language, draft.hiddenRemoteIds ?? []);
|
|
968
|
+
if (restored.action === "apply") draft.hiddenRemoteIds = (draft.hiddenRemoteIds ?? []).filter((key) => !restored.restored.includes(key));
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
905
971
|
if (connection === "testModels") {
|
|
906
972
|
if (!draft.enabled) { ctx.ui.notify(t(language, "testProviderDisabled"), "warning"); continue; }
|
|
907
973
|
if (draft.authMode === "temporary" || isDirty()) { ctx.ui.notify(t(language, "testDraftDirty"), "warning"); continue; }
|
|
@@ -934,7 +1000,8 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
|
|
|
934
1000
|
const installedRaw = installedModels(ctx, target.id);
|
|
935
1001
|
const installed = installedRaw.map(candidateFromInstalled);
|
|
936
1002
|
const external = externalModels.map((model) => candidateFromExternal(model, externalMetadata.get(model.id)));
|
|
937
|
-
const
|
|
1003
|
+
const visibleRemote = cachedRemote.filter((model) => !isHiddenRemote(model, draft.hiddenRemoteIds ?? []));
|
|
1004
|
+
const candidates = mergeCandidates(target.id, [...external, ...installed], managedBefore.map((model) => candidateFromManaged(model, persistedMetadata.get(`${model.type}\0${model.id}`))), visibleRemote);
|
|
938
1005
|
const selection = await selectModels(ctx, language, target.id, draft, candidates, selectedDraft, modelState, selectedMetadata);
|
|
939
1006
|
selectedDraft = selection.selected;
|
|
940
1007
|
selectedMetadata = selection.metadata.map((entry) => ({ ...(selectedMetadata.find((old) => old.type === entry.type && old.id === entry.id) ?? {}), ...entry }));
|
|
@@ -987,6 +1054,9 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
|
|
|
987
1054
|
continue;
|
|
988
1055
|
}
|
|
989
1056
|
if (selection.action === "fetch") {
|
|
1057
|
+
if (fetchInFlight) continue;
|
|
1058
|
+
fetchInFlight = true;
|
|
1059
|
+
try {
|
|
990
1060
|
const fallbackBases = [
|
|
991
1061
|
...(oldProvider?.models ?? []).map((model) => typeof model.baseUrl === "string" ? model.baseUrl : undefined),
|
|
992
1062
|
...installedRaw.map((model) => model.baseUrl),
|
|
@@ -1029,12 +1099,13 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
|
|
|
1029
1099
|
draft.discoveryUrl = result.value!.url;
|
|
1030
1100
|
cachedRemote = [...result.value!.models];
|
|
1031
1101
|
continue;
|
|
1102
|
+
} finally { fetchInFlight = false; }
|
|
1032
1103
|
}
|
|
1033
1104
|
if (await preparePreview()) { previewOrigin = "model"; page = "preview"; }
|
|
1034
1105
|
continue;
|
|
1035
1106
|
}
|
|
1036
1107
|
|
|
1037
|
-
const details = buildPreviewDetails(language, target.id, managedBefore, selectedDraft, oldConfig, oldProvider, draft);
|
|
1108
|
+
const details = buildPreviewDetails(language, target.id, managedBefore, selectedDraft, oldConfig, oldProvider, draft, selectedMetadata);
|
|
1038
1109
|
const preview = await showPreviewPanel(ctx, language, target.id, details.providerDetails, details.modelDetails, previewState);
|
|
1039
1110
|
if (preview.action === "back") { page = previewOrigin; continue; }
|
|
1040
1111
|
if (preview.action === "discard") {
|
|
@@ -1049,10 +1120,7 @@ export function registerModelConfigCommand(pi: ExtensionAPI, startupIssues: Star
|
|
|
1049
1120
|
if (discard === "discard") continue mainLoop;
|
|
1050
1121
|
continue;
|
|
1051
1122
|
}
|
|
1052
|
-
|
|
1053
|
-
const risk = compatItems.length ? [t(language, "compatSaveRisk", { items: compatItems.join(", ") })] : [];
|
|
1054
|
-
const confirmed = await confirmAction(ctx, language, t(language, "finalSaveTitle"), [t(language, "finalSaveBody"), ...risk], [...details.providerDetails, ...details.modelDetails], t(language, "confirmSave"));
|
|
1055
|
-
if (!confirmed) continue;
|
|
1123
|
+
// Preview is the sole save confirmation: Ctrl+S there commits the reviewed draft.
|
|
1056
1124
|
try {
|
|
1057
1125
|
const next = applyChanges(files, target.id, draft, selectedDraft, new Set(externalModels.map((model) => model.id)), selectedMetadata);
|
|
1058
1126
|
const transaction = writeConfigTransaction(files.modelsSnapshot, files.configSnapshot, next.models, next.config);
|
package/src/config.ts
CHANGED
|
@@ -60,6 +60,9 @@ export function parseModelConfig(source: ConfigSnapshot): ModelConfigFile {
|
|
|
60
60
|
if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.providers)) {
|
|
61
61
|
throw mcError("MCFG_E_CONFIG_SHAPE", { file: "model-config.json" });
|
|
62
62
|
}
|
|
63
|
+
if (parsed.autoProbeOnSave !== undefined && typeof parsed.autoProbeOnSave !== "boolean") {
|
|
64
|
+
throw mcError("MCFG_E_CONFIG_SHAPE", { file: "model-config.json" });
|
|
65
|
+
}
|
|
63
66
|
for (const [id, provider] of Object.entries(parsed.providers)) {
|
|
64
67
|
if (!isRecord(provider)) throw mcError("MCFG_E_PROVIDER_SHAPE", { file: "model-config.json", provider: id });
|
|
65
68
|
if (!new Set(["models-json", "reuse-pi", "anonymous"]).has(String(provider.authMode))) {
|
|
@@ -72,6 +75,7 @@ export function parseModelConfig(source: ConfigSnapshot): ModelConfigFile {
|
|
|
72
75
|
if (provider.imageApi !== undefined && !IMAGE_API_NAMES.includes(String(provider.imageApi) as typeof IMAGE_API_NAMES[number])) {
|
|
73
76
|
throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${id}/image`, api: String(provider.imageApi) });
|
|
74
77
|
}
|
|
78
|
+
if (provider.hiddenRemoteIds !== undefined && (!Array.isArray(provider.hiddenRemoteIds) || provider.hiddenRemoteIds.some((modelId) => typeof modelId !== "string"))) throw mcError("MCFG_E_PROVIDER_SHAPE", { file: "model-config.json", provider: id });
|
|
75
79
|
if (provider.models !== undefined && !Array.isArray(provider.models)) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
|
|
76
80
|
if (Array.isArray(provider.models) && provider.models.some((model) => !isRecord(model))) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
|
|
77
81
|
for (const model of Array.isArray(provider.models) ? provider.models : []) {
|
package/src/i18n.ts
CHANGED
|
@@ -38,6 +38,13 @@ const ZH = {
|
|
|
38
38
|
detailConnectionTitle: "连接摘要",
|
|
39
39
|
detailModelsTitle: "模型概况",
|
|
40
40
|
detailFetchHint: "进入“配置模型”后按 Ctrl+R 获取远程模型列表",
|
|
41
|
+
detailRestoreHidden: "[Ctrl+H] 恢复隐藏模型({count})",
|
|
42
|
+
panelHiddenRemoteCount: "已隐藏远程模型:{count}",
|
|
43
|
+
restoreHiddenTitle: "恢复隐藏远程模型",
|
|
44
|
+
restoreHiddenItemDetail: "勾选后恢复到后续远程获取结果;保存前仅修改内存草稿。",
|
|
45
|
+
restoreHiddenAll: "[Ctrl+A] 全部恢复",
|
|
46
|
+
restoreHiddenApply: "[Ctrl+S] 应用恢复",
|
|
47
|
+
restoreHiddenBack: "[Esc] 返回",
|
|
41
48
|
detailEditConnection: "[Ctrl+E] 编辑连接",
|
|
42
49
|
detailConfigureModels: "[Ctrl+M] 配置模型",
|
|
43
50
|
detailTakeover: "[Ctrl+T] 全选并纳入管理",
|
|
@@ -99,6 +106,7 @@ const ZH = {
|
|
|
99
106
|
modelProbeContext: "[Ctrl+K] 探测上下文容量",
|
|
100
107
|
modelProbeThinking: "[Ctrl+L] 探测思考档位",
|
|
101
108
|
modelFetch: "[Ctrl+R] 获取远程模型列表",
|
|
109
|
+
modelHideRemote: "[Ctrl+Del] 隐藏远程模型",
|
|
102
110
|
modelPreview: "[Ctrl+S] 预览更改",
|
|
103
111
|
modelBack: "[Esc] 返回服务商",
|
|
104
112
|
addProviderTitle: "添加服务商",
|
|
@@ -297,6 +305,10 @@ const ZH = {
|
|
|
297
305
|
previewSettingsUnchanged: "服务商设置无更改",
|
|
298
306
|
previewKeyUpdated: "API 密钥:将更新,内容已隐藏",
|
|
299
307
|
previewModelsAdded: "添加模型:{items}",
|
|
308
|
+
autoProbePanelTitle: "保存前参数探测",
|
|
309
|
+
autoProbeProgress: "探测 {done}/{total}:{model};Esc 取消剩余(已完成项保留)",
|
|
310
|
+
autoProbeCancelled: "已取消自动探测;剩余模型沿用兼容默认值(128K/16K)",
|
|
311
|
+
previewProbedDefaults: "参数已自动探测(实测)",
|
|
300
312
|
previewModelsRemoved: "移除模型:{items}",
|
|
301
313
|
previewModelsModified: "修改模型:{items}",
|
|
302
314
|
previewModelsUnchanged: "未变化模型:{count}",
|
|
@@ -312,6 +324,7 @@ const ZH = {
|
|
|
312
324
|
selectorReadonlyRuntime: "此模型来自 Pi 内置目录或其他运行时扩展,不能在这里移除",
|
|
313
325
|
selectorTypeConflict: "不能切换为{type}:{identity} 已存在",
|
|
314
326
|
selectorReadonlyTag: "只读",
|
|
327
|
+
selectorRemoteHidden: "已隐藏远程模型 {id};保存后后续获取也会隐藏",
|
|
315
328
|
selectorTypeChat: "聊天",
|
|
316
329
|
selectorTypeClassifier: "判定",
|
|
317
330
|
selectorTypeImage: "图像生成",
|
|
@@ -372,6 +385,13 @@ const EN = {
|
|
|
372
385
|
detailConnectionTitle: "Connection Summary",
|
|
373
386
|
detailModelsTitle: "Model Overview",
|
|
374
387
|
detailFetchHint: "Open Configure models, then press Ctrl+R to fetch the remote model list",
|
|
388
|
+
detailRestoreHidden: "[Ctrl+H] Restore hidden models ({count})",
|
|
389
|
+
panelHiddenRemoteCount: "Hidden remote models: {count}",
|
|
390
|
+
restoreHiddenTitle: "Restore hidden remote models",
|
|
391
|
+
restoreHiddenItemDetail: "Checked entries return to future remote fetch results; this only changes the in-memory draft until save.",
|
|
392
|
+
restoreHiddenAll: "[Ctrl+A] Restore all",
|
|
393
|
+
restoreHiddenApply: "[Ctrl+S] Apply restore",
|
|
394
|
+
restoreHiddenBack: "[Esc] Back",
|
|
375
395
|
detailEditConnection: "[Ctrl+E] Edit connection",
|
|
376
396
|
detailConfigureModels: "[Ctrl+M] Configure models",
|
|
377
397
|
detailTakeover: "[Ctrl+T] Select all and manage",
|
|
@@ -433,6 +453,7 @@ const EN = {
|
|
|
433
453
|
modelProbeContext: "[Ctrl+K] Probe context capacity",
|
|
434
454
|
modelProbeThinking: "[Ctrl+L] Probe thinking levels",
|
|
435
455
|
modelFetch: "[Ctrl+R] Fetch remote model list",
|
|
456
|
+
modelHideRemote: "[Ctrl+Del] Hide remote model",
|
|
436
457
|
modelPreview: "[Ctrl+S] Preview changes",
|
|
437
458
|
modelBack: "[Esc] Back to provider",
|
|
438
459
|
addProviderTitle: "Add Provider",
|
|
@@ -631,6 +652,10 @@ const EN = {
|
|
|
631
652
|
previewSettingsUnchanged: "Provider settings unchanged",
|
|
632
653
|
previewKeyUpdated: "API key: will be updated; content is hidden",
|
|
633
654
|
previewModelsAdded: "Add models: {items}",
|
|
655
|
+
autoProbePanelTitle: "Pre-save parameter probe",
|
|
656
|
+
autoProbeProgress: "Probing {done}/{total}: {model}; Esc cancels the rest (completed results are kept)",
|
|
657
|
+
autoProbeCancelled: "Auto-probe cancelled; remaining models keep compatibility defaults (128K/16K)",
|
|
658
|
+
previewProbedDefaults: "parameters auto-probed",
|
|
634
659
|
previewModelsRemoved: "Remove models: {items}",
|
|
635
660
|
previewModelsModified: "Modify models: {items}",
|
|
636
661
|
previewModelsUnchanged: "Unchanged models: {count}",
|
|
@@ -646,6 +671,7 @@ const EN = {
|
|
|
646
671
|
selectorReadonlyRuntime: "This model comes from Pi's built-in catalog or another runtime extension and cannot be removed here",
|
|
647
672
|
selectorTypeConflict: "Cannot change to {type}: {identity} already exists",
|
|
648
673
|
selectorReadonlyTag: "read-only",
|
|
674
|
+
selectorRemoteHidden: "Remote model {id} is hidden and will remain hidden after saving and future fetches",
|
|
649
675
|
selectorTypeChat: "Chat",
|
|
650
676
|
selectorTypeClassifier: "Classification",
|
|
651
677
|
selectorTypeImage: "Image generation",
|
package/src/image-apis/common.ts
CHANGED
|
@@ -130,13 +130,13 @@ function privateHostname(hostname: string): boolean {
|
|
|
130
130
|
const low = Number.parseInt(mappedHex[2]!, 16);
|
|
131
131
|
normalized = `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
|
|
132
132
|
}
|
|
133
|
-
if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
|
|
133
|
+
if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::" || normalized === "0:0:0:0:0:0:0:0" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
|
|
134
134
|
const ipv4 = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u)?.slice(1).map(Number);
|
|
135
135
|
if (ipv4 && ipv4.every((part) => part >= 0 && part <= 255)) {
|
|
136
136
|
const [a, b] = ipv4;
|
|
137
137
|
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
138
138
|
}
|
|
139
|
-
return normalized.startsWith("fc") || normalized.startsWith("fd") ||
|
|
139
|
+
return normalized.startsWith("fc") || normalized.startsWith("fd") || /^(?:fe8|fe9|fea|feb)[0-9a-f]:/u.test(normalized);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
export function validatePublicImageUrl(value: string): URL {
|
package/src/panel.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type ProviderPanelResult =
|
|
|
43
43
|
|
|
44
44
|
export type ConnectionPanelResult =
|
|
45
45
|
| { action: "field"; fieldId: string }
|
|
46
|
-
| { action: "models" | "takeover" | "testModels" | "preview" | "back" };
|
|
46
|
+
| { action: "models" | "takeover" | "restoreHidden" | "testModels" | "preview" | "back" };
|
|
47
47
|
|
|
48
48
|
export type PreviewPanelResult = { action: "save" | "back" | "discard" };
|
|
49
49
|
|
|
@@ -434,11 +434,13 @@ export async function showConnectionPanel(
|
|
|
434
434
|
fields: PanelItem[],
|
|
435
435
|
state: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 },
|
|
436
436
|
canTakeover = false,
|
|
437
|
+
hiddenRemoteCount = 0,
|
|
437
438
|
): Promise<ConnectionPanelResult> {
|
|
438
439
|
const actions: FooterAction[] = [
|
|
439
440
|
{ id: "edit", label: t(language, "connectionModify"), key: Key.enter },
|
|
440
441
|
{ id: "models", label: t(language, "detailConfigureModels"), key: Key.ctrl("m") },
|
|
441
442
|
...(canTakeover ? [{ id: "takeover", label: t(language, "detailTakeover"), key: Key.ctrl("t") }] : []),
|
|
443
|
+
...(hiddenRemoteCount > 0 ? [{ id: "restoreHidden", label: t(language, "detailRestoreHidden", { count: hiddenRemoteCount }), key: Key.ctrl("h") }] : []),
|
|
442
444
|
{ id: "testModels", label: t(language, "detailTestModels"), key: Key.ctrl("p") },
|
|
443
445
|
{ id: "preview", label: t(language, "connectionPreview"), key: Key.ctrl("s") },
|
|
444
446
|
{ id: "back", label: t(language, "connectionBack"), key: Key.escape },
|
|
@@ -448,10 +450,33 @@ export async function showConnectionPanel(
|
|
|
448
450
|
if (result.kind === "back" || result.id === "back") return { action: "back" };
|
|
449
451
|
if (result.kind === "item" && result.id) return { action: "field", fieldId: result.id };
|
|
450
452
|
if (result.id === "edit" && state.selectedId) return { action: "field", fieldId: state.selectedId };
|
|
451
|
-
if (result.id === "models" || result.id === "takeover" || result.id === "testModels" || result.id === "preview") return { action: result.id };
|
|
453
|
+
if (result.id === "models" || result.id === "takeover" || result.id === "restoreHidden" || result.id === "testModels" || result.id === "preview") return { action: result.id };
|
|
452
454
|
return { action: "back" };
|
|
453
455
|
}
|
|
454
456
|
|
|
457
|
+
export async function showRestoreHiddenPanel(
|
|
458
|
+
ctx: ExtensionCommandContext,
|
|
459
|
+
language: UiLanguage,
|
|
460
|
+
hiddenKeys: string[],
|
|
461
|
+
state: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 },
|
|
462
|
+
): Promise<{ action: "apply" | "back"; restored: string[] }> {
|
|
463
|
+
const selected = new Set<string>();
|
|
464
|
+
const actions: FooterAction[] = [
|
|
465
|
+
{ id: "all", label: t(language, "restoreHiddenAll"), key: Key.ctrl("a") },
|
|
466
|
+
{ id: "apply", label: t(language, "restoreHiddenApply"), key: Key.ctrl("s") },
|
|
467
|
+
{ id: "back", label: t(language, "restoreHiddenBack"), key: Key.escape },
|
|
468
|
+
];
|
|
469
|
+
while (true) {
|
|
470
|
+
const items = hiddenKeys.map((key) => ({ id: key, label: `${selected.has(key) ? "[✓]" : "[ ]"} ${key.replace("\u0000", "/")}`, details: [t(language, "restoreHiddenItemDetail")] }));
|
|
471
|
+
const result = await ctx.ui.custom<{ kind: "item" | "action" | "language" | "back"; id?: string }>((tui, theme, _keys, done) =>
|
|
472
|
+
new MenuDetailPanel(tui, theme, language, t(language, "restoreHiddenTitle"), items, actions, state, true, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
473
|
+
if (result.kind === "back" || result.id === "back") return { action: "back", restored: [] };
|
|
474
|
+
if (result.kind === "item" && result.id) { if (selected.has(result.id)) selected.delete(result.id); else selected.add(result.id); continue; }
|
|
475
|
+
if (result.id === "all") { for (const hiddenKey of hiddenKeys) selected.add(hiddenKey); continue; }
|
|
476
|
+
if (result.id === "apply") return { action: "apply", restored: [...selected] };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
455
480
|
export async function showPreviewPanel(
|
|
456
481
|
ctx: ExtensionCommandContext,
|
|
457
482
|
language: UiLanguage,
|
package/src/selector.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { t, type UiLanguage } from "./i18n.ts";
|
|
|
15
15
|
import { COMPAT_CONTEXT_WINDOW, COMPAT_MAX_TOKENS } from "./discovery.ts";
|
|
16
16
|
import { MODEL_CONFIG_OVERLAY_OPTIONS, overlayViewportRows, renderActionFooter, renderPanelBox, type FooterAction } from "./panel.ts";
|
|
17
17
|
import type { CandidateModel, ManagedModel, ManagedModelType, ModelMetadataEntry, ProviderDraft, SelectionResult, ThinkingLevelMap, ThinkingMetadataSource } from "./types.ts";
|
|
18
|
-
import { identityOf } from "./types.ts";
|
|
18
|
+
import { hiddenRemoteKey, identityOf } from "./types.ts";
|
|
19
19
|
import { protocolThinkingMap } from "./thinking-defaults.ts";
|
|
20
20
|
import { describeThinking } from "./model-editor.ts";
|
|
21
21
|
|
|
@@ -218,6 +218,19 @@ class ModelSelector implements Component, Focusable {
|
|
|
218
218
|
this.tui.requestRender();
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
private hideCurrentRemote(): void {
|
|
222
|
+
const row = this.current();
|
|
223
|
+
if (!row || row.source !== "remote") return;
|
|
224
|
+
const hidden = new Set(this.draft.hiddenRemoteIds ?? []);
|
|
225
|
+
hidden.add(hiddenRemoteKey(row));
|
|
226
|
+
this.draft.hiddenRemoteIds = [...hidden].sort((a, b) => a.localeCompare(b));
|
|
227
|
+
this.selected.delete(this.key(row));
|
|
228
|
+
const index = this.rows.indexOf(row);
|
|
229
|
+
if (index >= 0) this.rows.splice(index, 1);
|
|
230
|
+
this.status = t(this.language, "selectorRemoteHidden", { id: row.id });
|
|
231
|
+
this.refresh();
|
|
232
|
+
}
|
|
233
|
+
|
|
221
234
|
private cycleType(): void {
|
|
222
235
|
const row = this.filtered[this.index];
|
|
223
236
|
if (!row?.mutableType) return;
|
|
@@ -277,6 +290,7 @@ class ModelSelector implements Component, Focusable {
|
|
|
277
290
|
{ id: "probe", label: t(this.language, "modelProbeContext"), key: Key.ctrl("k") },
|
|
278
291
|
{ id: "probeThinking", label: t(this.language, "modelProbeThinking"), key: Key.ctrl("l") },
|
|
279
292
|
{ id: "fetch", label: t(this.language, "modelFetch"), key: Key.ctrl("r") },
|
|
293
|
+
{ id: "hideRemote", label: t(this.language, "modelHideRemote"), key: Key.ctrl("delete"), enabled: this.current()?.source === "remote" },
|
|
280
294
|
{ id: "preview", label: t(this.language, "modelPreview"), key: Key.ctrl("s") },
|
|
281
295
|
{ id: "back", label: t(this.language, "modelBack"), key: Key.escape },
|
|
282
296
|
];
|
|
@@ -301,12 +315,14 @@ class ModelSelector implements Component, Focusable {
|
|
|
301
315
|
if (action.id === "selectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row)); this.request(); return; }
|
|
302
316
|
if (action.id === "deselectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row)); this.request(); return; }
|
|
303
317
|
if (action.id === "cycleType") { this.cycleType(); return; }
|
|
318
|
+
if (action.id === "hideRemote") { this.hideCurrentRemote(); return; }
|
|
304
319
|
this.finish(action.id as SelectionResult["action"]);
|
|
305
320
|
}
|
|
306
321
|
|
|
307
322
|
handleInput(data: string): void {
|
|
308
323
|
if (data === "/") { this.state.focus = "search"; this.request(); return; }
|
|
309
324
|
if (matchesKey(data, Key.ctrl("r"))) { this.finish("fetch"); return; }
|
|
325
|
+
if (matchesKey(data, Key.ctrl("delete"))) { this.hideCurrentRemote(); return; }
|
|
310
326
|
if (matchesKey(data, Key.ctrl("e"))) { this.finish("edit"); return; }
|
|
311
327
|
if (matchesKey(data, Key.ctrl("k"))) { this.finish("probe"); return; }
|
|
312
328
|
if (matchesKey(data, Key.ctrl("l"))) { this.finish("probeThinking"); return; }
|
package/src/types.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface ProviderConnectionConfig {
|
|
|
71
71
|
defaultContextWindow?: number;
|
|
72
72
|
defaultMaxTokens?: number;
|
|
73
73
|
managedChatIds?: string[];
|
|
74
|
+
/** Remote catalog keys (`type\0id`) hidden by the user; retained across discovery refreshes. */
|
|
75
|
+
hiddenRemoteIds?: string[];
|
|
74
76
|
models?: Array<ManagedClassifierModel | ManagedImageModel>;
|
|
75
77
|
modelMetadata?: ModelMetadataEntry[];
|
|
76
78
|
}
|
|
@@ -78,6 +80,8 @@ export interface ProviderConnectionConfig {
|
|
|
78
80
|
export interface ModelConfigFile {
|
|
79
81
|
$schema?: string;
|
|
80
82
|
version: 1;
|
|
83
|
+
/** Pre-save minimal parameter probe for newly checked chat models lacking context/max metadata; default true. */
|
|
84
|
+
autoProbeOnSave?: boolean;
|
|
81
85
|
providers: Record<string, ProviderConnectionConfig>;
|
|
82
86
|
[key: string]: unknown;
|
|
83
87
|
}
|
|
@@ -115,6 +119,7 @@ export interface ProviderDraft {
|
|
|
115
119
|
classifierApi: string;
|
|
116
120
|
imageApi: ImageApiName;
|
|
117
121
|
defaultType: ManagedModelType;
|
|
122
|
+
hiddenRemoteIds?: string[];
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
export type CandidateModel = ManagedModel & {
|
|
@@ -142,6 +147,20 @@ export interface ConfigSnapshot {
|
|
|
142
147
|
text?: string;
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
/** Persistent remote-hide identity. Legacy id-only entries are interpreted as chat-only on read. */
|
|
151
|
+
export function hiddenRemoteKey(model: Pick<ManagedModel, "id" | "type">): string { return `${model.type}\u0000${model.id}`; }
|
|
152
|
+
|
|
153
|
+
export function isHiddenRemote(model: Pick<ManagedModel, "id" | "type">, hiddenKeys: readonly string[]): boolean {
|
|
154
|
+
const key = hiddenRemoteKey(model);
|
|
155
|
+
// Compatibility: prior releases stored bare IDs; treating those as chat-only prevents
|
|
156
|
+
// a legacy hidden chat model from also hiding classifier/image models with the same ID.
|
|
157
|
+
return hiddenKeys.includes(key) || model.type === "chat" && hiddenKeys.includes(model.id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function normalizeHiddenRemoteKeys(hiddenKeys: readonly string[]): string[] {
|
|
161
|
+
return [...new Set(hiddenKeys.map((key) => key.includes("\u0000") ? key : hiddenRemoteKey({ type: "chat", id: key })))].sort((a, b) => a.localeCompare(b));
|
|
162
|
+
}
|
|
163
|
+
|
|
145
164
|
export function identityOf(model: Pick<ManagedModel, "id" | "type"> & { provider?: string }, provider?: string): ModelIdentity {
|
|
146
165
|
const p = provider ?? model.provider ?? "";
|
|
147
166
|
return `${p}\u0000${model.type}\u0000${model.id}`;
|