pi-model-manager 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +8 -0
  3. package/README.en.md +206 -0
  4. package/README.md +210 -0
  5. package/atomic-write.ts +100 -0
  6. package/builtin-model-catalog.ts +92 -0
  7. package/claude-code-compat.ts +56 -0
  8. package/common.ts +66 -0
  9. package/compat-settings.ts +25 -0
  10. package/config-value-reference.ts +51 -0
  11. package/configuration-persistence.ts +72 -0
  12. package/header-profile-mutations.ts +46 -0
  13. package/index.ts +82 -0
  14. package/local-proxy-service.ts +313 -0
  15. package/model-mutations.ts +210 -0
  16. package/models-json-manager.ts +238 -0
  17. package/models-json-mutations.ts +90 -0
  18. package/models-json-sync.ts +386 -0
  19. package/openai-responses-payload.ts +80 -0
  20. package/openai-service-tier.ts +36 -0
  21. package/package.json +74 -0
  22. package/presets/builtin-client-headers.ts +23 -0
  23. package/presets/client-headers.ts +126 -0
  24. package/presets/providers.ts +80 -0
  25. package/presets/thinking.ts +81 -0
  26. package/provider-registrar.ts +281 -0
  27. package/request-pipeline.ts +88 -0
  28. package/rescue.ts +69 -0
  29. package/runtime-base-url.ts +54 -0
  30. package/state-cache.ts +55 -0
  31. package/state-document.ts +569 -0
  32. package/state-metadata-store.ts +455 -0
  33. package/state-store.ts +238 -0
  34. package/tui/dashboard.ts +376 -0
  35. package/tui/editor-model.ts +339 -0
  36. package/tui/editor-provider.ts +219 -0
  37. package/tui/header-profiles-panel.ts +307 -0
  38. package/tui/model-list-fetch.ts +259 -0
  39. package/tui/model-picker.ts +266 -0
  40. package/tui/models-json-panel.ts +269 -0
  41. package/tui/persistent-menu.ts +349 -0
  42. package/tui/ui-helpers.ts +289 -0
  43. package/types.ts +165 -0
@@ -0,0 +1,307 @@
1
+ // tui/header-profiles-panel.ts
2
+ //
3
+ // 管理可复用的客户端请求头。接入编辑器只选择已存在的请求头,
4
+ // 不在模型内部新增一次性 JSON,避免配置分散。
5
+
6
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { formatUnknownError, parseStringRecordJson } from "../common.ts";
8
+ import { deleteRequestHeaderProfileConfiguration, saveRequestHeaderProfileConfiguration } from "../header-profile-mutations.ts";
9
+ import { CLAUDE_CODE_CLIENT_HEADERS, CODEX_CLI_CLIENT_HEADERS } from "../presets/builtin-client-headers.ts";
10
+ import {
11
+ countModelsUsingRequestHeaderProfile,
12
+ createRequestHeaderProfileDraft,
13
+ createRequestHeaderProfileDraftFromStored,
14
+ validateRequestHeaderProfileDraft,
15
+ } from "../state-document.ts";
16
+ import { readState } from "../state-store.ts";
17
+ import type { RequestHeaderProfileDraft, StoredClientHeaderCapture, StoredRequestHeaderProfile } from "../types.ts";
18
+ import { padLabel, showPersistentFormMenu, showPersistentShortcutMenu, type MenuCursor } from "./persistent-menu.ts";
19
+
20
+ interface ProfileRow {
21
+ profileId: string;
22
+ label: string;
23
+ }
24
+
25
+ interface FieldRow {
26
+ id: string;
27
+ label: string;
28
+ value: string;
29
+ }
30
+
31
+ interface HeaderTemplate {
32
+ id: string;
33
+ label: string;
34
+ description: string;
35
+ headers: Record<string, string>;
36
+ }
37
+
38
+ const HEADER_TEMPLATES: HeaderTemplate[] = [
39
+ {
40
+ id: "codex-cli",
41
+ label: "Codex",
42
+ description: "OpenAI 兼容端点使用的终端 Codex TUI 请求头。",
43
+ headers: CODEX_CLI_CLIENT_HEADERS,
44
+ },
45
+ {
46
+ id: "claude-code",
47
+ label: "ClaudeCode",
48
+ description: "Anthropic 兼容端点常用的 ClaudeCode 请求头。",
49
+ headers: CLAUDE_CODE_CLIENT_HEADERS,
50
+ },
51
+ {
52
+ id: "empty",
53
+ label: "清空",
54
+ description: "清空当前请求头,重新手动填写。",
55
+ headers: {},
56
+ },
57
+ ];
58
+
59
+ function buildRows(profileIds: string[]): ProfileRow[] {
60
+ return profileIds.map((profileId) => ({ profileId, label: "" }));
61
+ }
62
+
63
+ function formatCapturedAt(capturedAt: string): string {
64
+ return capturedAt.replace("T", " ").replace(/\.\d{3}Z$/, "Z").slice(0, 19);
65
+ }
66
+
67
+ function formatCaptureLine(label: string, capture: StoredClientHeaderCapture | undefined): string {
68
+ if (!capture) return ` ${padLabel(label, 12)} 未抓包`;
69
+ return ` ${padLabel(label, 12)} 已抓包 ${formatCapturedAt(capture.capturedAt)} · ${Object.keys(capture.headers).length} headers`;
70
+ }
71
+
72
+ function formatProfileRow(profileId: string, profile: StoredRequestHeaderProfile, usedCount: number): string {
73
+ return `${padLabel(profileId, 22)} ${padLabel(`${Object.keys(profile.headers).length} headers`, 12)} ${padLabel(`${usedCount} models`, 10)} ${profile.name}`.trimEnd();
74
+ }
75
+
76
+ function formatProfileDetailLines(profileId: string, profile: StoredRequestHeaderProfile, usedCount: number): string[] {
77
+ const headerNames = Object.keys(profile.headers);
78
+ const preview = headerNames.length > 0
79
+ ? headerNames.slice(0, 6).join(", ") + (headerNames.length > 6 ? ", ..." : "")
80
+ : "<empty>";
81
+ return [
82
+ `${profile.name} (${profileId})`,
83
+ ` usage ${usedCount} models`,
84
+ ` headers ${preview}`,
85
+ ];
86
+ }
87
+
88
+ function buildEditorRows(draft: RequestHeaderProfileDraft): FieldRow[] {
89
+ return [
90
+ { id: "profileId", label: "请求头 ID", value: draft.profileId || "<未填写>" },
91
+ { id: "profileName", label: "请求头名称", value: draft.profileName || "<未填写>" },
92
+ { id: "template", label: "快速模板", value: "选择后填充" },
93
+ { id: "headers", label: "请求头 JSON", value: Object.keys(draft.headers).length > 0 ? `${Object.keys(draft.headers).length}项` : "未配置" },
94
+ ];
95
+ }
96
+
97
+ function notifyValidationErrors(ctx: ExtensionCommandContext, errors: string[]): void {
98
+ ctx.ui.notify(`请求头无效:\n${errors.map((error) => `- ${error}`).join("\n")}`, "warning");
99
+ }
100
+
101
+ function formatHeadersEditorText(headers: Record<string, string>): string {
102
+ if (Object.keys(headers).length > 0) return JSON.stringify(headers, null, 2);
103
+ return `{
104
+ // 可先返回上一级选择“快速模板”,也可在这里填写:
105
+ // "user-agent": "custom-client/1.0"
106
+ }
107
+ `;
108
+ }
109
+
110
+ async function editHeaders(ctx: ExtensionCommandContext, draft: RequestHeaderProfileDraft): Promise<void> {
111
+ ctx.ui.notify("结构:JSON 对象;key 是客户端 header 名,value 是字符串。复杂示例请用“快速模板”。", "info");
112
+ while (true) {
113
+ const answer = await ctx.ui.editor("请求头 JSON", formatHeadersEditorText(draft.headers));
114
+ if (answer === undefined) return;
115
+ try {
116
+ draft.headers = parseStringRecordJson(answer, "请求头");
117
+ return;
118
+ } catch (error) {
119
+ ctx.ui.notify(`请求头 JSON 无效:${formatUnknownError(error)}`, "warning");
120
+ }
121
+ }
122
+ }
123
+
124
+ async function applyTemplate(ctx: ExtensionCommandContext, draft: RequestHeaderProfileDraft): Promise<void> {
125
+ const labels = HEADER_TEMPLATES.map((template) => `${template.label} — ${template.description}`);
126
+ const picked = await ctx.ui.select("选择请求头模板", labels);
127
+ if (!picked) return;
128
+ const index = labels.indexOf(picked);
129
+ if (index < 0) return;
130
+ const template = HEADER_TEMPLATES[index]!;
131
+ draft.headers = { ...template.headers };
132
+ ctx.ui.notify(
133
+ template.id === "empty"
134
+ ? "已清空请求头"
135
+ : `已填充模板:${template.label},可继续进入“请求头 JSON”微调。`,
136
+ "info",
137
+ );
138
+ }
139
+
140
+ async function editField(ctx: ExtensionCommandContext, draft: RequestHeaderProfileDraft, fieldId: string): Promise<void> {
141
+ if (fieldId === "profileId") {
142
+ const value = await ctx.ui.input("请求头 ID(接入引用这个 ID;字母/数字/._-)", draft.profileId);
143
+ if (value !== undefined) draft.profileId = value.trim();
144
+ return;
145
+ }
146
+ if (fieldId === "profileName") {
147
+ const value = await ctx.ui.input("请求头名称(显示用)", draft.profileName);
148
+ if (value !== undefined) draft.profileName = value.trim();
149
+ return;
150
+ }
151
+ if (fieldId === "template") {
152
+ await applyTemplate(ctx, draft);
153
+ return;
154
+ }
155
+ if (fieldId === "headers") {
156
+ await editHeaders(ctx, draft);
157
+ return;
158
+ }
159
+ }
160
+
161
+ async function editProfile(
162
+ ctx: ExtensionCommandContext,
163
+ draft: RequestHeaderProfileDraft,
164
+ titlePrefix: string,
165
+ ): Promise<{ action: "save"; draft: RequestHeaderProfileDraft } | { action: "cancel" }> {
166
+ const cursor: MenuCursor = { index: 0 };
167
+ while (true) {
168
+ const rows = buildEditorRows(draft);
169
+ const menuRows = rows.map((row) => ({ id: row.id, label: `${padLabel(row.label, 16)}${row.value}` }));
170
+ const action = await showPersistentFormMenu(
171
+ ctx,
172
+ titlePrefix,
173
+ "",
174
+ menuRows,
175
+ cursor,
176
+ {
177
+ summaryLines: [
178
+ "认证类敏感 header 会被拒绝;API key 请放在接入配置中",
179
+ ],
180
+ footer: "↑↓ 选择 Enter 编辑 Ctrl+S 保存并同步 Esc 返回",
181
+ },
182
+ );
183
+ if (action.type === "cancel") return { action: "cancel" };
184
+ if (action.type === "save") return { action: "save", draft };
185
+ await editField(ctx, draft, action.id);
186
+ }
187
+ }
188
+
189
+ async function saveProfile(
190
+ pi: ExtensionAPI,
191
+ ctx: ExtensionCommandContext,
192
+ draft: RequestHeaderProfileDraft,
193
+ oldProfileId: string | undefined,
194
+ ): Promise<boolean> {
195
+ const state = await readState();
196
+ const errors = validateRequestHeaderProfileDraft(draft, state, oldProfileId);
197
+ if (errors.length > 0) {
198
+ notifyValidationErrors(ctx, errors);
199
+ return false;
200
+ }
201
+ try {
202
+ await saveRequestHeaderProfileConfiguration(pi, ctx, state, draft, oldProfileId);
203
+ return true;
204
+ } catch (error) {
205
+ ctx.ui.notify(`保存失败:${formatUnknownError(error)}`, "error");
206
+ return false;
207
+ }
208
+ }
209
+
210
+ async function deleteProfile(pi: ExtensionAPI, ctx: ExtensionCommandContext, profileId: string): Promise<boolean> {
211
+ const state = await readState();
212
+ const profile = state.requestHeaderProfiles[profileId];
213
+ if (!profile) {
214
+ ctx.ui.notify(`请求头不存在:${profileId}`, "warning");
215
+ return true;
216
+ }
217
+ const affected = countModelsUsingRequestHeaderProfile(state, profileId);
218
+ const ok = await ctx.ui.confirm(
219
+ `删除请求头 ${profileId}`,
220
+ `将删除 ${profile.name}。${affected > 0 ? `\n${affected} 个模型会自动改回“自动推荐”。` : ""}`,
221
+ );
222
+ if (!ok) return false;
223
+ try {
224
+ await deleteRequestHeaderProfileConfiguration(pi, ctx, state, profileId);
225
+ return true;
226
+ } catch (error) {
227
+ ctx.ui.notify(`删除失败:${formatUnknownError(error)}`, "error");
228
+ return false;
229
+ }
230
+ }
231
+
232
+ async function editStoredProfile(pi: ExtensionAPI, ctx: ExtensionCommandContext, profileId: string): Promise<boolean> {
233
+ const state = await readState();
234
+ const profile = state.requestHeaderProfiles[profileId];
235
+ if (!profile) {
236
+ ctx.ui.notify(`请求头不存在:${profileId}`, "warning");
237
+ return true;
238
+ }
239
+ const outcome = await editProfile(ctx, createRequestHeaderProfileDraftFromStored(profileId, profile), `编辑请求头 ${profileId}`);
240
+ if (outcome.action !== "save") return false;
241
+ return saveProfile(pi, ctx, outcome.draft, profileId);
242
+ }
243
+
244
+ type HeaderProfileShortcut = "new-profile" | "delete-profile";
245
+
246
+ export async function runHeaderProfilesPanel(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
247
+ const cursor: MenuCursor = { index: 0 };
248
+ while (true) {
249
+ const state = await readState();
250
+ const profileIds = Object.keys(state.requestHeaderProfiles).sort((a, b) => a.localeCompare(b));
251
+ const rows = buildRows(profileIds).map((row) => {
252
+ const profile = state.requestHeaderProfiles[row.profileId]!;
253
+ const usedCount = countModelsUsingRequestHeaderProfile(state, row.profileId);
254
+ return {
255
+ ...row,
256
+ label: formatProfileRow(row.profileId, profile, usedCount),
257
+ };
258
+ });
259
+ const action = await showPersistentShortcutMenu<HeaderProfileShortcut>(
260
+ ctx,
261
+ "/model-manager / 请求头",
262
+ "",
263
+ rows.map((row, index) => ({ id: `${index}`, label: row.label })),
264
+ cursor,
265
+ [
266
+ { input: "n", shortcut: "new-profile" },
267
+ { input: "d", shortcut: "delete-profile" },
268
+ ],
269
+ {
270
+ summaryLines: [
271
+ "Built-in captures",
272
+ formatCaptureLine("ClaudeCode", state.clientHeaderCaptures["claude-code"]),
273
+ formatCaptureLine("Codex", state.clientHeaderCaptures["codex-cli"]),
274
+ `Custom profiles: ${rows.length}`,
275
+ ],
276
+ tableHeader: `${padLabel("自定义 ID", 22)} ${padLabel("Headers", 12)} ${padLabel("Used", 10)} 名称`,
277
+ getDetailLines: (selectedRow) => {
278
+ const row = rows[Number.parseInt(selectedRow?.id ?? "", 10)];
279
+ const profile = row ? state.requestHeaderProfiles[row.profileId] : undefined;
280
+ return row && profile
281
+ ? formatProfileDetailLines(row.profileId, profile, countModelsUsingRequestHeaderProfile(state, row.profileId))
282
+ : [];
283
+ },
284
+ footer: "↑↓ 选择 Enter 编辑请求头 n 新建自定义 d 删除 Esc 返回",
285
+ emptyLabel: "暂无自定义请求头;按 n 新建",
286
+ },
287
+ );
288
+ if (action.type === "cancel") return;
289
+ if (action.type === "shortcut") {
290
+ if (action.shortcut === "new-profile") {
291
+ const outcome = await editProfile(ctx, createRequestHeaderProfileDraft(), "新建请求头");
292
+ if (outcome.action === "save") await saveProfile(pi, ctx, outcome.draft, undefined);
293
+ continue;
294
+ }
295
+ const selectedProfileId = rows[cursor.index]?.profileId;
296
+ if (!selectedProfileId) {
297
+ ctx.ui.notify("没有可删除的请求头。", "info");
298
+ continue;
299
+ }
300
+ await deleteProfile(pi, ctx, selectedProfileId);
301
+ continue;
302
+ }
303
+ const row = rows[Number.parseInt(action.id, 10)];
304
+ if (!row) return;
305
+ await editStoredProfile(pi, ctx, row.profileId);
306
+ }
307
+ }
@@ -0,0 +1,259 @@
1
+ // 从上游 API 拉取模型列表(OpenAI / Anthropic / Google)。
2
+ // 拉取失败 / 取消时给用户 fallback 到手动输入。
3
+
4
+ import { ModelRuntime, type ProviderConfig } from "@earendil-works/pi-coding-agent";
5
+ import { formatUnknownError, isObjectRecord } from "../common.ts";
6
+ import { openTemporaryLocalProxyRoute, type TemporaryLocalProxyRoute } from "../local-proxy-service.ts";
7
+ import { getClientHeadersForProfile } from "../presets/client-headers.ts";
8
+ import { resolveRuntimeBaseUrl } from "../runtime-base-url.ts";
9
+ import type { ApiKind, BuiltInClientHeaderProfileId, ClientHeaderProfileId, ModelListFetchOutcome, StoredClientHeaderCapture } from "../types.ts";
10
+
11
+ const MODEL_LIST_TIMEOUT_MS = 10_000;
12
+ const TEMP_PROVIDER_ID = "pi-model-manager-fetch";
13
+ const TEMP_MODEL_ID = "__model_list_probe__";
14
+
15
+ // 临时 runtime 只解析本次表单中的认证,不得读取或写入用户认证文件。
16
+ const emptyCredentialStore = {
17
+ async read(): Promise<undefined> {
18
+ return undefined;
19
+ },
20
+ async list(): Promise<readonly []> {
21
+ return [];
22
+ },
23
+ async modify(): Promise<undefined> {
24
+ return undefined;
25
+ },
26
+ async delete(): Promise<void> {
27
+ return undefined;
28
+ },
29
+ };
30
+
31
+ function escapeRegExp(value: string): string {
32
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+
35
+ function redactSecrets(message: string, secrets: string[]): string {
36
+ let redacted = message;
37
+ for (const secret of secrets) {
38
+ if (secret.length < 3) continue;
39
+ redacted = redacted.replace(new RegExp(escapeRegExp(secret), "g"), "REDACTED");
40
+ }
41
+ redacted = redacted.replace(/([?&]key=)[^&\s]+/gi, "$1REDACTED");
42
+ redacted = redacted.replace(/(Authorization:\s*Bearer\s+)[^\s,;]+/gi, "$1REDACTED");
43
+ redacted = redacted.replace(/((?:x-api-key|api-key)\s*[:=]\s*)[^\s,;]+/gi, "$1REDACTED");
44
+ return redacted;
45
+ }
46
+
47
+ function buildOpenAIUrl(baseUrl: string, api: Extract<ApiKind, "openai-completions" | "openai-responses">): string {
48
+ return `${resolveRuntimeBaseUrl(api, baseUrl)}/models`;
49
+ }
50
+
51
+ function buildAnthropicUrl(baseUrl: string): string {
52
+ // 根路径(无 path)追加 /v1/models,自定义后缀直接追加 /models
53
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
54
+ try {
55
+ const parsed = new URL(trimmed);
56
+ if ((parsed.pathname === "" || parsed.pathname === "/") && !parsed.search && !parsed.hash) {
57
+ return `${trimmed}/v1/models`;
58
+ }
59
+ } catch {
60
+ return `${trimmed}/models`;
61
+ }
62
+ return `${trimmed}/models`;
63
+ }
64
+
65
+ /** 从 baseUrl 提取主机根路径(不含路径部分)。
66
+ * 例:"https://api.deepseek.com/anthropic" → "https://api.deepseek.com" */
67
+ function deriveOrigin(url: string): string {
68
+ try {
69
+ const parsed = new URL(url);
70
+ return `${parsed.protocol}//${parsed.host}`;
71
+ } catch {
72
+ return url;
73
+ }
74
+ }
75
+
76
+ function buildGoogleUrl(baseUrl: string, apiKey: string): string {
77
+ const url = new URL(`${baseUrl.replace(/\/+$/, "")}/models`);
78
+ url.searchParams.set("key", apiKey);
79
+ return url.toString();
80
+ }
81
+
82
+ function extractModelIds(envelope: unknown, api: ApiKind): string[] {
83
+ if (!isObjectRecord(envelope)) return [];
84
+ if (api === "google-generative-ai") {
85
+ const models = envelope.models;
86
+ if (!Array.isArray(models)) return [];
87
+ return models
88
+ .map((m) => isObjectRecord(m) && typeof m.name === "string" ? m.name.replace(/^models\//, "") : undefined)
89
+ .filter((id): id is string => Boolean(id));
90
+ }
91
+ const data = envelope.data;
92
+ if (!Array.isArray(data)) return [];
93
+ return data
94
+ .map((m) => isObjectRecord(m) && typeof m.id === "string" ? m.id : undefined)
95
+ .filter((id): id is string => Boolean(id));
96
+ }
97
+
98
+ interface ResolvedFetchAuth {
99
+ apiKey: string;
100
+ headers: Record<string, string>;
101
+ redactionSecrets: string[];
102
+ }
103
+
104
+ function collectSensitiveHeaderValues(headers: Record<string, string> | undefined): string[] {
105
+ if (!headers) return [];
106
+ return Object.entries(headers)
107
+ .filter(([name]) => /authorization|cookie|api-key|x-api-key|secret|token|key/i.test(name))
108
+ .map(([, value]) => value);
109
+ }
110
+
111
+ async function resolveFetchAuth(
112
+ params: FetchModelIdsParams,
113
+ profileHeaders: Record<string, string> | undefined,
114
+ ): Promise<ResolvedFetchAuth> {
115
+ const providerConfig: ProviderConfig = {
116
+ name: "pi-model-manager model list probe",
117
+ baseUrl: resolveRuntimeBaseUrl(params.api, params.baseUrl),
118
+ apiKey: params.apiKey.trim(),
119
+ api: params.api,
120
+ authHeader: params.authHeader,
121
+ headers: profileHeaders,
122
+ models: [{
123
+ id: TEMP_MODEL_ID,
124
+ name: TEMP_MODEL_ID,
125
+ api: params.api,
126
+ reasoning: false,
127
+ input: ["text"],
128
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
129
+ contextWindow: 1,
130
+ maxTokens: 1,
131
+ }],
132
+ };
133
+
134
+ const runtime = await ModelRuntime.create({
135
+ credentials: emptyCredentialStore,
136
+ modelsPath: null,
137
+ allowModelNetwork: false,
138
+ });
139
+ runtime.registerProvider(TEMP_PROVIDER_ID, providerConfig);
140
+ const model = runtime.getModel(TEMP_PROVIDER_ID, TEMP_MODEL_ID);
141
+ if (!model) throw new Error("临时模型注册失败,无法解析模型列表认证");
142
+
143
+ const resolvedAuth = await runtime.getAuth(model);
144
+ if (!resolvedAuth) throw new Error("请求认证解析失败");
145
+ const apiKey = resolvedAuth.auth.apiKey;
146
+ if (!apiKey) throw new Error("API key 未配置或解析为空,无法拉取模型列表");
147
+
148
+ const headers: Record<string, string> = {};
149
+ for (const [name, value] of Object.entries(resolvedAuth.auth.headers ?? {})) {
150
+ if (typeof value === "string") headers[name] = value;
151
+ }
152
+ return {
153
+ apiKey,
154
+ headers,
155
+ redactionSecrets: [params.apiKey.trim(), apiKey, ...collectSensitiveHeaderValues(headers)].filter(Boolean),
156
+ };
157
+ }
158
+ interface ModelListProxyConfig {
159
+ providerId: string;
160
+ proxyUrl: string;
161
+ }
162
+
163
+ async function requestModelIds(
164
+ url: string,
165
+ headers: Record<string, string>,
166
+ api: ApiKind,
167
+ proxyConfig: ModelListProxyConfig | undefined,
168
+ ): Promise<string[]> {
169
+ const controller = new AbortController();
170
+ const timeout = setTimeout(() => controller.abort(new Error("模型列表请求超时")), MODEL_LIST_TIMEOUT_MS);
171
+ let temporaryProxyRoute: TemporaryLocalProxyRoute | undefined;
172
+ try {
173
+ temporaryProxyRoute = proxyConfig
174
+ ? await openTemporaryLocalProxyRoute(proxyConfig.providerId, url, proxyConfig.proxyUrl)
175
+ : undefined;
176
+ const response = await fetch(temporaryProxyRoute?.url ?? url, { headers, signal: controller.signal });
177
+ const text = await response.text();
178
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text.slice(0, 240)}`);
179
+ return extractModelIds(JSON.parse(text), api).sort((a, b) => a.localeCompare(b));
180
+ } finally {
181
+ temporaryProxyRoute?.close();
182
+ clearTimeout(timeout);
183
+ }
184
+ }
185
+
186
+ export interface FetchModelIdsParams {
187
+ providerId: string;
188
+ api: ApiKind;
189
+ baseUrl: string;
190
+ apiKey: string;
191
+ authHeader?: boolean;
192
+ clientHeaderProfile: ClientHeaderProfileId;
193
+ customClientHeaders: Record<string, string>;
194
+ httpProxyEnabled: boolean;
195
+ httpProxyUrl: string;
196
+ clientHeaderCaptures?: Partial<Record<BuiltInClientHeaderProfileId, StoredClientHeaderCapture>>;
197
+ }
198
+
199
+
200
+ export async function fetchModelIds(params: FetchModelIdsParams): Promise<ModelListFetchOutcome> {
201
+ const redactionSecrets = [params.apiKey.trim()].filter(Boolean);
202
+ try {
203
+ const profileHeaders = getClientHeadersForProfile(
204
+ params.clientHeaderProfile,
205
+ params.api,
206
+ params.customClientHeaders,
207
+ params.clientHeaderCaptures ?? {},
208
+ );
209
+ const auth = await resolveFetchAuth(params, profileHeaders);
210
+ redactionSecrets.push(...auth.redactionSecrets);
211
+ const headers: Record<string, string> = { Accept: "application/json", ...auth.headers };
212
+ const proxyConfig = params.httpProxyEnabled
213
+ ? { providerId: params.providerId, proxyUrl: params.httpProxyUrl }
214
+ : undefined;
215
+
216
+ if (params.api === "google-generative-ai") {
217
+ const modelIds = await requestModelIds(
218
+ buildGoogleUrl(resolveRuntimeBaseUrl(params.api, params.baseUrl), auth.apiKey),
219
+ headers,
220
+ params.api,
221
+ proxyConfig,
222
+ );
223
+ return { status: "loaded", modelIds };
224
+ }
225
+ if (params.api === "anthropic-messages") {
226
+ // 优先 x-api-key(多数 Anthropic 兼容端点),失败 fallback 到 Bearer
227
+ const apiKeyHeaders = { ...headers, "x-api-key": auth.apiKey, "anthropic-version": headers["anthropic-version"] ?? "2023-06-01" };
228
+ try {
229
+ const modelIds = await requestModelIds(buildAnthropicUrl(params.baseUrl), apiKeyHeaders, params.api, proxyConfig);
230
+ return { status: "loaded", modelIds };
231
+ } catch {
232
+ const bearerHeaders = { ...headers, Authorization: `Bearer ${auth.apiKey}`, "anthropic-version": headers["anthropic-version"] ?? "2023-06-01" };
233
+ delete (bearerHeaders as any)["x-api-key"];
234
+ try {
235
+ const modelIds = await requestModelIds(buildAnthropicUrl(params.baseUrl), bearerHeaders, params.api, proxyConfig);
236
+ return { status: "loaded", modelIds };
237
+ } catch {
238
+ // 最终 fallback:尝试派生主机根路径,用 OpenAI 格式 /v1/models 拉取
239
+ // 兼容 DeepSeek 等同时提供 Anthropic 代理 + OpenAI 模型列表端点的服务
240
+ const origin = deriveOrigin(params.baseUrl);
241
+ const fallbackUrl = `${origin}/v1/models`;
242
+ const fallbackHeaders = { ...headers, Authorization: `Bearer ${auth.apiKey}` };
243
+ const modelIds = await requestModelIds(fallbackUrl, fallbackHeaders, params.api, proxyConfig);
244
+ return { status: "loaded", modelIds };
245
+ }
246
+ }
247
+ }
248
+ // openai-completions / openai-responses
249
+ const modelIds = await requestModelIds(
250
+ buildOpenAIUrl(params.baseUrl, params.api),
251
+ { ...headers, Authorization: `Bearer ${auth.apiKey}` },
252
+ params.api,
253
+ proxyConfig,
254
+ );
255
+ return { status: "loaded", modelIds };
256
+ } catch (error) {
257
+ return { status: "failed", message: redactSecrets(formatUnknownError(error), redactionSecrets) };
258
+ }
259
+ }