import { randomUUID } from "node:crypto"; import { chmod, copyFile, mkdir, open, readdir, readFile, rename, unlink, } from "node:fs/promises"; import { readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { fsyncDirectory } from "../settings/durable-write.ts"; import { getAgentDir, VERSION, SettingsManager, type ExtensionAPI, type ExtensionCommandContext, type ProviderConfig, type ProviderModelConfig, } from "@earendil-works/pi-coding-agent"; type ProviderChatModelConfig = Extract; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { getPiHostMode } from "pi-maestro-settings-core/v1"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { NETWORK_RETRY_POLICY } from "pi-maestro-teammate/v1/retry"; import { EFFORT_STATUS_KEY, isThinkingLevel as isCanonicalThinkingLevel, } from "../effort-display.ts"; import { readCompactionSettings } from "../compaction/compaction-settings.ts"; import { lockSettingsResource } from "../settings/resource-lock.ts"; import { deriveCompactionThreshold, type CompactionThresholdReason } from "../compaction/compaction-threshold.ts"; import { showApiModelEditor, type ApiModelFormChoice, type ApiModelFormField, type ApiModelFormValues, type ApiModelHeadlessOptions, } from "../tui/api-model-editor.ts"; import { extractHeadlessArgs, hasHeadlessFields, headlessField, parseHeadlessBoolean, splitCommandArgs, } from "../tui/headless-args.ts"; import { supportsCustomOverlay } from "pi-maestro-settings-core/ui"; import { AGENT_HEADER_PRESETS, customAgentHeaders, expandAgentHeaderPreset, isAgentHeaderPreset, type AgentHeaderPreset, } from "./agent-header-presets.ts"; import { API_RETRY_MAX_RETRIES, API_RETRY_MAX_RETRIES_LIMIT, apiFormatLabel, DEFAULT_THINKING_LEVEL, deleteApiProviderModelSettings, deleteApiProviderSettings, KNOWN_APIS, loadApiProviderSettings, loadApiRetrySettings, loadModelFilters, mutationQueues, normalizeBaseUrl, PROVIDERS, setApiProviderEnabled, configurePresetModelTarget, configureCustomModelTarget, } from "./api-provider-config.ts"; import { API_KEY_POLICIES, isApiKeyPolicy, type ApiKeyEntry, type ApiKeyPolicy, type ApiProviderAction, type ApiProviderId, type ApiProviderSettings, type ApiThinkingLevel, type ProviderDefaults, type SaveApiProviderResult } from "./api-provider-config.ts"; import { lookupBuiltinPricing } from "./cost-backfill.ts"; import { loadVisionDelegationConfig } from "./vision-assist.ts"; import { getTuiLocale } from "../tui/locale.ts"; import { isCacheRetention, isOpenAIFormatApi, isPromptCachePolicy, loadAgentCacheRetention, loadPromptCachePolicy, loadPromptCachePolicySync, promptCacheCompatFlags, type CacheRetention, type PromptCachePolicy, } from "./prompt-cache-policy.ts"; const OPS_CATALOGS = { en: { "value.on": "On", "value.off": "Off", "menu.title": "Choose an action", "menu.list": "View all models", "menu.configure": "Add or edit a model", "menu.provider": "Manage Provider connection (URL / key / headers)", "menu.show": "View model details", "menu.thinking": "Current model thinking default", "menu.vision": "Vision multimodal policy (current: {state})", "menu.toggle": "Enable or disable a Provider", "menu.delete": "Delete a model", "menu.retry": "Automatic retry (current: {state})", "menu.cache": "Prompt cache policy (current: {value})", "menu.cacheAgent": "Agent cache tier (current: {value})", "menu.price": "Backfill model pricing (built-in table + OpenRouter)", "menu.key": "Manage API keys (multi-key / switch / policy)", "menu.logout": "Sign out a Provider", "menu.filter": "Filter models (hide from teammate)", "menu.reset": "Reset a Provider", "menu.export": "Export API configuration to a file", "menu.import": "Import API configuration from a file", "provider.emptyGuide": "No manageable Provider connection yet. Create one via /api-manager configure (Add or edit a model) → add a model and enter a new Provider ID; it becomes manageable here after saving.", "export.empty": "No API Manager managed Provider is configured; nothing to export.", "export.done": "Exported {providers} Providers ({models} models)", "export.saved": "Export file: {path}", "export.secretNote": "The export contains API keys; keep the file safe.", "key.title": "Manage API keys for {provider}", "key.policy": "Key selection policy: {policy}", "key.active": "Active key: {id}", "key.empty": "No multi-key pool configured; this Provider uses the single stored API key.", "key.addPrompt": "Add a new API key", "key.idPrompt": "Key id (unique label)", "key.keyPrompt": "API key", "key.weightPrompt": "Weight for weighted policy (number, default 1)", "key.policyPrompt": "Select key policy", "key.chooseSwitch": "Choose key to activate", "key.switched": "Switched {provider} active key to {id}", "key.added": "Added key {id} to {provider}", "key.removed": "Removed key {id} from {provider}", "key.status.healthy": "healthy", "key.status.cooling": "cooling", "key.status.disabled": "disabled", "import.notFound": "Import file not found: {path}", "import.invalid": "Invalid import file {path}: {message}", "import.done": "Imported {providers} Providers ({models} models) from {path}", "thinking.title": "Default thinking effort (current model)", "saved.backup": "Backup: {path}", "saved.config": "Config: {path}", "compaction.unavailable": "Estimated hard compaction: current model window unavailable", "compaction.hard": "Estimated hard compaction: context exceeds {tokens} tokens ({percent}%)", "compaction.configured": "Configured threshold: {tokens} tokens; {reason}", "compaction.outputWarning": "Notice: the model output limit puts the response clamp ({tokens} tokens, {percent}%) before the hard threshold; automatic pruning moves to the clamp point.", "compaction.nudgeUnreachable": "Notice: the hard compaction threshold precedes the soft warning, so the warning is unreachable.", "compaction.pruneUnreachable": "Notice: the hard compaction threshold precedes soft pruning, so hard compaction may run directly.", "threshold.configured": "determined by the configured compaction reserve", "threshold.ratioFloor": "lowered by the 5% context-window safety floor", "threshold.maxOutput": "lowered by max-output protection", "threshold.capped": "max output is too large; safe reserve capped at 90% of the window", "validation.window": "Max output ({max}) must be smaller than the context window ({window}); otherwise no room remains for input.", "conn.title": "Provider connection · {name}", "conn.section.connection": "Connection (Provider / URL level)", "conn.section.compat": "Compatibility (format level)", "conn.field.providerId": "Provider ID", "conn.field.name": "Provider display name", "conn.field.api": "API format", "conn.field.baseUrl": "Base URL", "conn.field.apiKey": "API key", "conn.field.enabled": "Enabled", "conn.field.headerPreset": "Agent identity preset", "conn.field.headers": "Request headers JSON", "conn.field.authHeader": "Authorization", "conn.field.thinkingFormat": "Thinking format", "conn.field.developerRole": "Developer role", "conn.field.reasoningEffort": "Reasoning effort", "conn.field.maxTokensField": "Output request field", "conn.help.apiKey": "The API key is masked; leave it untouched to preserve the current models.json value.", "conn.help.enabled": "Off unregisters this Provider's models from /model while keeping URL, API key and model config.", "conn.help.headers": "Headers may contain credentials. The form only shows a mask; enter the complete JSON object when editing.", "conn.choice.auto": "Auto", "conn.choice.autoUrl": "Auto (detect from URL)", "conn.choice.bearer": "Bearer", "conn.choice.noSend": "Do not send", "conn.choice.supported": "Supported", "conn.choice.unsupported": "Unsupported", "conn.headerPreset.none": "None (pi default)", "conn.headerPreset.claude-code": "Claude Code CLI", "conn.headerPreset.codex": "Codex CLI", "conn.headerPreset.grok": "Grok CLI", "conn.headerPreset.antigravity": "Antigravity CLI", "conn.headerPreset.opencode": "OpenCode session affinity", "conn.validation.headersJson": "Request headers JSON is invalid", "conn.validation.headersObject": "Request headers must be a JSON object with string keys and values", "conn.validation.apiKeyRequired": "API key is required when no multi-key pool is configured", "conn.confirm": "Save Provider {name} connection?", "conn.preview.provider": "Provider: {value}", "conn.preview.api": "API format: {value}", "conn.preview.baseUrl": "Base URL: {value}", "conn.preview.enabled": "Enabled: {value}", "conn.preview.headerPreset": "Agent identity preset: {value}", "conn.preview.headers": "Headers: {value}", "conn.preview.authorization": "Authorization: {value}", "conn.preview.compat": "Compat: {value}", "conn.preview.modelsKept": "Models: unchanged", "conn.value.none": "none", "conn.saved": "Provider connection updated (models unchanged)", }, "zh-CN": { "value.on": "开启", "value.off": "关闭", "menu.title": "选择操作", "menu.list": "查看全部模型", "menu.configure": "新增或修改模型", "menu.provider": "管理 Provider 连接(URL / key / headers)", "menu.show": "查看模型详情", "menu.thinking": "当前模型默认思考强度", "menu.vision": "Vision 多模态策略(当前:{state})", "menu.toggle": "启用或停用 Provider", "menu.delete": "删除模型", "menu.retry": "自动重试(当前:{state})", "menu.cache": "提示缓存策略(当前:{value})", "menu.cacheAgent": "Agent 缓存档位(当前:{value})", "menu.price": "回填模型价格(内置表 + OpenRouter 在线)", "menu.key": "管理 API Key(多 Key / 切换 / 策略)", "menu.logout": "注销 Provider", "menu.filter": "模型过滤(屏蔽 teammate 可见模型)", "menu.reset": "重置 Provider", "menu.export": "导出 API 配置到文件", "menu.import": "从文件导入 API 配置", "provider.emptyGuide": "暂无可管理的 Provider 连接。请通过 /api-manager configure(新增或修改模型)→ 新增模型时输入新的 Provider ID 来创建连接,保存后即可在此管理。", "export.empty": "尚未配置 API Manager 管理的 Provider,无可导出内容。", "export.done": "已导出 {providers} 个 Provider({models} 个模型)", "export.saved": "导出文件:{path}", "export.secretNote": "导出文件包含 API key,请妥善保管。", "key.title": "管理 {provider} 的 API Key", "key.policy": "Key 选择策略:{policy}", "key.active": "当前激活:{id}", "key.empty": "未配置多 Key 池;该 Provider 仍使用单个已存 API Key。", "key.addPrompt": "新增 API Key", "key.idPrompt": "Key 标识(唯一标签)", "key.keyPrompt": "API Key", "key.weightPrompt": "加权策略权重(数字,默认 1)", "key.policyPrompt": "选择 Key 策略", "key.chooseSwitch": "选择要激活的 Key", "key.switched": "已将 {provider} 的激活 Key 切换为 {id}", "key.added": "已为 {provider} 添加 Key {id}", "key.removed": "已从 {provider} 移除 Key {id}", "key.status.healthy": "健康", "key.status.cooling": "冷却中", "key.status.disabled": "已禁用", "import.notFound": "导入文件不存在:{path}", "import.invalid": "导入文件 {path} 无效:{message}", "import.done": "已从 {path} 导入 {providers} 个 Provider({models} 个模型)", "thinking.title": "默认思考强度(当前 model)", "saved.backup": "备份:{path}", "saved.config": "配置:{path}", "compaction.unavailable": "预计硬压缩:当前模型窗口不可用", "compaction.hard": "预计实际硬压缩:上下文超过 {tokens} Token({percent}%)", "compaction.configured": "配置阈值:{tokens} Token;{reason}", "compaction.outputWarning": "提醒:模型输出上限使响应钳制点({tokens} Token,{percent}%)早于硬阈值,自动剪枝已提前至截断点。", "compaction.nudgeUnreachable": "提醒:当前硬压缩阈值早于软提醒,软提醒不可达。", "compaction.pruneUnreachable": "提醒:当前硬压缩阈值早于软裁剪,可能直接硬压缩。", "threshold.configured": "由压缩配置预留决定", "threshold.ratioFloor": "受上下文窗口 5% 安全底线下调", "threshold.maxOutput": "受单次最大输出保护下调", "threshold.capped": "单次最大输出过大,安全预留已封顶为窗口 90%", "validation.window": "单次最大输出 maxTokens({max})必须小于上下文窗口 contextWindow({window});否则没有空间容纳输入。", "conn.title": "Provider 连接 · {name}", "conn.section.connection": "连接(Provider / URL 级)", "conn.section.compat": "兼容(format 级)", "conn.field.providerId": "Provider ID", "conn.field.name": "Provider 显示名称", "conn.field.api": "API 协议", "conn.field.baseUrl": "Base URL", "conn.field.apiKey": "API key", "conn.field.enabled": "启用", "conn.field.headerPreset": "Agent 身份预设", "conn.field.headers": "请求头 JSON", "conn.field.authHeader": "Authorization", "conn.field.thinkingFormat": "Thinking 格式", "conn.field.developerRole": "Developer 角色", "conn.field.reasoningEffort": "Reasoning effort", "conn.field.maxTokensField": "输出请求字段", "conn.help.apiKey": "API key 仅显示掩码;不编辑即可保留 models.json 中的当前值。", "conn.help.enabled": "关闭后该 Provider 的 models 从 /model 移除,URL、API key 与模型配置保留。", "conn.help.headers": "请求头可能包含凭据,表单仅显示掩码;编辑时需输入完整 JSON 对象。", "conn.choice.auto": "自动", "conn.choice.autoUrl": "自动(按 URL 识别)", "conn.choice.bearer": "Bearer", "conn.choice.noSend": "不发送", "conn.choice.supported": "支持", "conn.choice.unsupported": "不支持", "conn.headerPreset.none": "无(pi 默认)", "conn.headerPreset.claude-code": "Claude Code CLI", "conn.headerPreset.codex": "Codex CLI", "conn.headerPreset.grok": "Grok CLI", "conn.headerPreset.antigravity": "Antigravity CLI", "conn.headerPreset.opencode": "OpenCode 会话路由", "conn.validation.headersJson": "请求头 JSON 无效", "conn.validation.headersObject": "请求头必须是字符串键值的 JSON 对象", "conn.validation.apiKeyRequired": "未配置多 Key 池时 API key 必填", "conn.confirm": "保存 Provider {name} 连接?", "conn.preview.provider": "Provider:{value}", "conn.preview.api": "API 协议:{value}", "conn.preview.baseUrl": "Base URL:{value}", "conn.preview.enabled": "启用状态:{value}", "conn.preview.headerPreset": "Agent 身份预设:{value}", "conn.preview.headers": "请求头:{value}", "conn.preview.authorization": "Authorization:{value}", "conn.preview.compat": "Compat:{value}", "conn.preview.modelsKept": "模型:不变", "conn.value.none": "无", "conn.saved": "已更新 Provider 连接配置(模型未改动)", }, } as const; type OpsCatalogKey = keyof (typeof OPS_CATALOGS)["en"]; function opsText(key: OpsCatalogKey, vars?: Readonly>): string { const locale = getTuiLocale(); const template = OPS_CATALOGS[locale]?.[key] ?? OPS_CATALOGS.en[key]; if (!vars) return template; return template.replace(/\{(\w+)\}/g, (_match, name: string) => vars[name] !== undefined ? String(vars[name]) : `{${name}}`); } export async function removeProviderKey( pi: ExtensionAPI, providerId: string, displayName: string, ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { if (!await isProviderConfigured(providerId, modelsPath)) { ctx.ui.notify(`${displayName} 尚未配置,无需注销。`, "info"); return; } const confirmed = await ctx.ui.confirm( `注销 ${displayName}?`, "将删除该 Provider 的 Base URL、models 和 API key;重新新增必须显式输入独立 URL 和 API key。", ); if (!confirmed) return; const modelIds = await configuredModelIds(providerId, modelsPath); const result = await deleteApiProviderSettings(providerId, modelsPath); await deleteProviderThinkingDefaults(providerId, modelIds, settingsPath, ctx.cwd, defaultsPath); for (const modelId of modelIds) { await clearDeletedDefaultModel(settingsPath, providerId, modelId); } await removeManagedProvider(defaultsPath, providerId); pi.unregisterProvider(providerId); ctx.modelRegistry.refresh(); notifySaved(ctx, displayName, result, "已注销;连接配置和 API key 已移除"); } /** * Hide a provider's models without dropping its registration. `unregisterProvider` * only removes the extension overlay — pi recomposes the provider from its * models.json entry on the next refresh, so the models come straight back. * Registering `models: []` replaces that layer instead, which keeps the * provider's config/auth intact while exposing no models to /model. */ export function suspendProviderRegistration(pi: ExtensionAPI, providerId: string): void { pi.registerProvider(providerId, { models: [] }); } export async function toggleProvider( pi: ExtensionAPI, providerId: string, displayName: string, enabled: boolean, ctx: ExtensionCommandContext, modelsPath: string, ): Promise { if (!await isProviderConfigured(providerId, modelsPath)) { ctx.ui.notify(`${displayName} 尚未配置。`, "warning"); return; } const current = await isProviderEnabled(providerId, modelsPath); if (current === enabled) { ctx.ui.notify(`${displayName} 已经${enabled ? "启用" : "停用"}。`, "info"); return; } if (ctx.hasUI) { const confirmed = await ctx.ui.confirm( `${enabled ? "启用" : "停用"} ${displayName}?`, enabled ? "将重新注册该 Provider,其 models 会恢复到 /model。" : "将从运行时移除该 Provider 的 models,但保留 URL、API key 和全部模型配置。", ); if (!confirmed) return; } const result = await setApiProviderEnabled(providerId, enabled, modelsPath); if (enabled) pi.registerProvider(providerId, configuredProviderRegistration(providerId, modelsPath)); else suspendProviderRegistration(pi, providerId); ctx.modelRegistry.refresh(); notifySaved(ctx, displayName, result, enabled ? "已启用;models 已恢复到 /model" : "已停用;配置仍完整保留"); } export async function resetProvider( pi: ExtensionAPI, providerId: string, displayName: string, ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { if (!await isProviderConfigured(providerId, modelsPath)) { ctx.ui.notify(`${displayName} 尚未配置,无需重置。`, "info"); return; } const confirmed = await ctx.ui.confirm( `重置 ${displayName}?`, "将清除该 Provider 的连接配置、models、API key 和思考强度默认值;不会写入环境变量占位。", ); if (!confirmed) return; const modelIds = await configuredModelIds(providerId, modelsPath); const result = await deleteApiProviderSettings(providerId, modelsPath); await deleteProviderThinkingDefaults(providerId, modelIds, settingsPath, ctx.cwd, defaultsPath); for (const modelId of modelIds) { await clearDeletedDefaultModel(settingsPath, providerId, modelId); } await removeManagedProvider(defaultsPath, providerId); await saveDefaultThinkingLevel(ctx, modelsPath, DEFAULT_THINKING_LEVEL); pi.unregisterProvider(providerId); ctx.modelRegistry.refresh(); notifySaved(ctx, displayName, result, `已重置为未配置;默认思考强度为 ${DEFAULT_THINKING_LEVEL}`); } export async function deleteProvider( pi: ExtensionAPI, providerId: string, displayName: string, ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { if (!await isProviderConfigured(providerId, modelsPath)) { ctx.ui.notify(`${displayName} 尚未配置,无需删除。`, "info"); return; } const modelIds = await configuredModelIds(providerId, modelsPath); const modelId = modelIds.length === 1 ? modelIds[0] : await ctx.ui.select(`选择要删除的 ${displayName} 模型`, modelIds); if (!modelId) return; await deleteProviderModel( pi, providerId, displayName, modelId, ctx, modelsPath, defaultsPath, settingsPath, ); } export async function deleteProviderModel( pi: ExtensionAPI, providerId: string, displayName: string, modelId: string, ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { const modelIds = await configuredModelIds(providerId, modelsPath); const confirmed = await ctx.ui.confirm( `删除 ${displayName}/${modelId}?`, "将删除该模型;同一 Provider 的其他模型与连接配置不受影响。", ); if (!confirmed) return; const result = await deleteApiProviderModelSettings(providerId, modelId, modelsPath); await deleteModelThinkingDefault(providerId, modelId, settingsPath, ctx.cwd, defaultsPath); await clearDeletedDefaultModel(settingsPath, providerId, modelId); if (modelIds.length === 1) { await removeManagedProvider(defaultsPath, providerId); pi.unregisterProvider(providerId); } else { reloadProviderRegistration(pi, ctx, providerId, modelsPath); } ctx.modelRegistry.refresh(); notifySaved(ctx, displayName, result, `已删除 ${modelId};该模型已从 /model 移除`); } export async function listProviders( ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { const root = await readModelsRoot(modelsPath); const retry = await loadApiRetrySettings(settingsPath); const providers = isRecord(root.providers) ? root.providers : {}; const modelLines: string[] = []; const providerLines: string[] = []; for (const preset of PROVIDERS) { const config = providers[preset.id]; if (!isRecord(config)) { providerLines.push(`- ${preset.id}(${preset.name})· 未配置`); continue; } const api = typeof config.api === "string" ? config.api : preset.api; await appendListLines( config, preset.id, preset.name, api, false, providerEnabled(config), modelLines, providerLines, defaultsPath, settingsPath, ctx.cwd, ); } for (const id of managedProviderIdsSync(defaultsPath, modelsPath)) { if (findPreset(id) || !isRecord(providers[id])) continue; const config = providers[id]; const name = typeof config.name === "string" && config.name ? config.name : id; const api = typeof config.api === "string" ? config.api : "?"; await appendListLines( config, id, name, api, true, providerEnabled(config), modelLines, providerLines, defaultsPath, settingsPath, ctx.cwd, ); } ctx.ui.notify([ "API 模型(平铺展示):", ...(modelLines.length > 0 ? modelLines : ["(尚未配置任何模型)"]), "Providers(URL / API key 级配置):", ...providerLines, ...(await renderModelFilterSummary(defaultsPath)), `Pi 全局默认思考强度:${currentDefaultThinkingLevel(ctx, modelsPath, settingsPath)}`, `Provider 自动重试:${retry.enabled ? "开启" : "关闭"} · 最大 ${retry.maxRetries} 次 · 退避上限 ${retry.maxDelayMs ?? NETWORK_RETRY_POLICY.maxDelayMs}ms`, `文件:${modelsPath}`, ].join("\n"), "info"); } export async function appendListLines( config: Record, providerId: string, name: string, api: string, custom: boolean, enabled: boolean, modelLines: string[], providerLines: string[], defaultsPath: string, settingsPath: string, cwd: string, ): Promise { const models = Array.isArray(config.models) ? config.models.filter(isRecord) : []; for (const model of models) { if (typeof model.id !== "string") continue; const level = await loadModelThinkingDefault(providerId, model.id, settingsPath, cwd, defaultsPath); const modelApi = typeof model.api === "string" ? model.api : api; modelLines.push([ `- ${providerId}/${model.id}`, `format: ${apiFormatLabel(modelApi)}`, `ctx ${typeof model.contextWindow === "number" ? model.contextWindow.toLocaleString("en-US") : "?"}`, `max ${typeof model.maxTokens === "number" ? model.maxTokens.toLocaleString("en-US") : "?"}`, `reasoning=${model.reasoning === true ? "on" : "off"}`, `vision=${Array.isArray(model.input) && model.input.includes("image") ? "on" : "off"}`, `default: ${level ?? "global"}`, ].join(" · ")); } providerLines.push([ `- ${providerId}(${name}${custom ? " · 用户定义" : ""})`, enabled ? "启用" : "停用", `format: ${apiFormatLabel(api)}`, typeof config.baseUrl === "string" ? config.baseUrl : "?", authSource(config.apiKey), `${models.length} model`, ].join(" · ")); } export async function showProvider( ctx: ExtensionCommandContext, providerId: string, displayName: string, modelsPath: string, defaultsPath: string, settingsPath: string, preferredModelId?: string, ): Promise { const preset = findPreset(providerId); if (!await isProviderConfigured(providerId, modelsPath)) { const hint = preset ? providerId.replace("maestro-", "") : providerId; ctx.ui.notify(`${displayName}:未配置。使用 /api-manager set ${hint} 新增。`, "info"); return; } const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? root.providers : {}; const config = isRecord(providers[providerId]) ? providers[providerId] : {}; const models = Array.isArray(config.models) ? config.models.filter(isRecord) : []; const api = typeof config.api === "string" ? config.api : preset?.api ?? "?"; if (ctx.hasUI && models.length > 0) { const modelIds = models .map((model) => model.id) .filter((id): id is string => typeof id === "string"); const modelId = preferredModelId ?? (modelIds.length === 1 ? modelIds[0] : await ctx.ui.select(`选择要查看的 ${displayName} 模型`, modelIds)); if (!modelId) return; const model = models.find((entry) => entry.id === modelId) ?? {}; const level = await loadModelThinkingDefault(providerId, modelId, settingsPath, ctx.cwd, defaultsPath); const modelApi = typeof model.api === "string" ? model.api : api; ctx.ui.notify([ displayName, `Provider:${providerId}`, `Provider 状态:${providerEnabled(config) ? "启用" : "停用"}`, `API format:${apiFormatLabel(modelApi)}`, `Base URL:${typeof config.baseUrl === "string" ? config.baseUrl : preset?.baseUrl ?? ""}`, `Model:${modelId}`, `上下文窗口 contextWindow:${typeof model.contextWindow === "number" ? model.contextWindow.toLocaleString("en-US") : "?"} Token(输入+输出总量,本地注册值)`, `单次最大输出 maxTokens:${typeof model.maxTokens === "number" ? model.maxTokens.toLocaleString("en-US") : "?"} Token`, ...(typeof model.contextWindow === "number" && typeof model.maxTokens === "number" ? compactionPreviewLines(ctx.cwd, model.contextWindow, model.maxTokens) : []), `Reasoning:${model.reasoning === true ? "enabled" : "disabled"}`, `多模态(视觉):${Array.isArray(model.input) && model.input.includes("image") ? "enabled" : "disabled"}`, `Default thinking:${level ?? "global"}`, `Auth:${authSource(config.apiKey)}`, `文件:${modelsPath}`, ].join("\n"), "info"); return; } const modelLines = await Promise.all(models.map(async (model) => { const id = typeof model.id === "string" ? model.id : ""; const level = id === "" ? undefined : await loadModelThinkingDefault(providerId, id, settingsPath, ctx.cwd, defaultsPath); return `- ${id} · reasoning=${model.reasoning === true ? "enabled" : "disabled"} · vision=${Array.isArray(model.input) && model.input.includes("image") ? "on" : "off"} · default=${level ?? "global"}`; })); ctx.ui.notify([ displayName, `Provider:${providerId}`, `Provider 状态:${providerEnabled(config) ? "启用" : "停用"}`, `API format:${apiFormatLabel(api)}`, `Base URL:${typeof config.baseUrl === "string" ? config.baseUrl : preset?.baseUrl ?? ""}`, `Models(${models.length}):`, ...modelLines, `Default thinking(Pi 全局):${currentDefaultThinkingLevel(ctx, modelsPath, settingsPath)}`, `Auth:${authSource(config.apiKey)}`, `文件:${modelsPath}`, ].join("\n"), "info"); } export async function writeApiProviderSettings( settings: ApiProviderSettings, modelsPath: string, ): Promise { const defaults = resolveWriteDefaults(settings); const exists = await fileExists(modelsPath); const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? { ...root.providers } : {}; const currentEntry = providers[settings.provider]; const currentProvider = isRecord(currentEntry) ? { ...currentEntry } : {}; const preset = findPreset(settings.provider); const rawModels = Array.isArray(currentProvider.models) ? currentProvider.models : []; if (rawModels.some((model) => !isRecord(model) || typeof model.id !== "string")) { throw new Error(`Provider ${settings.provider} contains malformed model entries; refusing a lossy save`); } const currentModels = rawModels as Record[]; const renameFrom = settings.previousModelId && settings.previousModelId !== settings.modelId ? settings.previousModelId : undefined; const existingIndex = currentModels.findIndex((model) => model.id === (renameFrom ?? settings.modelId)); if (renameFrom && existingIndex < 0) { throw new Error(`Model ${renameFrom} is not configured; cannot rename`); } if (renameFrom) { const collisionIndex = currentModels.findIndex((model, index) => index !== existingIndex && model.id === settings.modelId); if (collisionIndex >= 0) throw new Error(`Model ${settings.modelId} already exists; cannot rename`); } const existingModel = existingIndex >= 0 ? currentModels[existingIndex] : {}; const contextWindow = settings.contextWindow ?? (typeof existingModel.contextWindow === "number" ? existingModel.contextWindow : defaults.contextWindow); const maxTokens = settings.maxTokens ?? (typeof existingModel.maxTokens === "number" ? existingModel.maxTokens : defaults.maxTokens); validateModelWindow(contextWindow, maxTokens); const input = settings.multimodal === undefined ? Array.isArray(existingModel.input) && existingModel.input.every((value) => value === "text" || value === "image") ? [...existingModel.input] // Unknown capability defaults conservatively to text-only, matching // runtime isMultimodalModel and the registration path. : ["text"] : settings.multimodal ? ["text", "image"] : ["text"]; const nextModel: Record = { ...existingModel, id: settings.modelId, name: typeof existingModel.name === "string" && existingModel.name !== renameFrom ? existingModel.name : settings.modelId, reasoning: settings.reasoning, input, contextWindow, maxTokens, }; // Connection/format fields are Provider-level; model entries keep only model-specific settings. delete nextModel.api; delete nextModel.baseUrl; delete nextModel.compat; delete nextModel.headers; if (settings.reasoning) { const thinkingLevelMap: Record = defaults.api === "anthropic-messages" ? { xhigh: "high" } : { off: null, xhigh: "xhigh" }; if (settings.maxThinking) thinkingLevelMap.xhigh = "max"; nextModel.thinkingLevelMap = thinkingLevelMap; } else { delete nextModel.thinkingLevelMap; } const existingCompat = isRecord(existingModel.compat) ? materializeProviderCompat(currentProvider.compat, existingModel.compat) : isRecord(currentProvider.compat) ? { ...currentProvider.compat } : undefined; const existingHeaders = isStringRecord(existingModel.headers) ? { ...existingModel.headers } : isStringRecord(currentProvider.headers) ? { ...currentProvider.headers } : undefined; const nextModels = existingIndex >= 0 ? currentModels.map((model, index) => index === existingIndex ? nextModel : model) : [...currentModels, nextModel]; const nextProvider: Record = { ...currentProvider, baseUrl: settings.baseUrl, api: defaults.api, models: nextModels, }; if (settings.apiKeys && settings.apiKeys.length > 0) { nextProvider.apiKeys = settings.apiKeys; delete nextProvider.apiKey; } else { nextProvider.apiKey = settings.apiKey; delete nextProvider.apiKeys; } if (defaults.compat) { nextProvider.compat = preset ? { ...(existingCompat ?? {}), ...defaults.compat } : { ...defaults.compat }; } else if (!preset && settings.replaceProviderOptions) { delete nextProvider.compat; } else if (existingCompat) { nextProvider.compat = existingCompat; } if (settings.name) nextProvider.name = settings.name; if (settings.headers && Object.keys(settings.headers).length > 0) { nextProvider.headers = { ...settings.headers }; } else if (!preset && settings.replaceProviderOptions) { delete nextProvider.headers; } else if (existingHeaders) { nextProvider.headers = existingHeaders; } // "none" is an explicit choice, so it clears the stored identity instead of // falling through to the preserved value. if (settings.headerPreset !== undefined) { if (settings.headerPreset === "none") delete nextProvider.headerPreset; else nextProvider.headerPreset = settings.headerPreset; } else if (!preset && settings.replaceProviderOptions) { delete nextProvider.headerPreset; } if (settings.authHeader !== undefined) nextProvider.authHeader = settings.authHeader; else if (!preset && settings.replaceProviderOptions) delete nextProvider.authHeader; providers[settings.provider] = nextProvider; return writeModelsRoot({ ...root, providers }, modelsPath, exists); } const CONNECTION_THINKING_FORMAT_OPTIONS: ReadonlyArray<{ label: string; value?: string }> = [ { label: "自动(按 URL 识别,推荐)" }, { label: "openai(reasoning_effort)", value: "openai" }, { label: "openrouter(reasoning.effort)", value: "openrouter" }, { label: "deepseek(thinking.type · 亦适用 api.z.ai 直连)", value: "deepseek" }, { label: "zai(enable_thinking · DashScope 托管 GLM)", value: "zai" }, { label: "qwen(enable_thinking)", value: "qwen" }, { label: "qwen-chat-template(chat_template_kwargs)", value: "qwen-chat-template" }, ]; const CONNECTION_THINKING_FORMAT_OPTIONS_EN: ReadonlyArray<{ label: string; value?: string }> = [ { label: "Auto (detect from URL, recommended)" }, { label: "openai (reasoning_effort)", value: "openai" }, { label: "openrouter (reasoning.effort)", value: "openrouter" }, { label: "deepseek (thinking.type; also direct api.z.ai)", value: "deepseek" }, { label: "zai (enable_thinking; DashScope-hosted GLM)", value: "zai" }, { label: "qwen (enable_thinking)", value: "qwen" }, { label: "qwen-chat-template (chat_template_kwargs)", value: "qwen-chat-template" }, ]; function connectionThinkingFormatOptions(): ReadonlyArray<{ label: string; value?: string }> { return getTuiLocale() === "zh-CN" ? CONNECTION_THINKING_FORMAT_OPTIONS : CONNECTION_THINKING_FORMAT_OPTIONS_EN; } const CONNECTION_HEADER_PRESET_LABEL_KEYS: Readonly> = { none: "conn.headerPreset.none", "claude-code": "conn.headerPreset.claude-code", codex: "conn.headerPreset.codex", grok: "conn.headerPreset.grok", antigravity: "conn.headerPreset.antigravity", opencode: "conn.headerPreset.opencode", }; function connectionHeaderPresetLabel(preset: AgentHeaderPreset): string { return opsText(CONNECTION_HEADER_PRESET_LABEL_KEYS[preset]); } function connectionHeaderPresetChoices(): ApiModelFormChoice[] { return (Object.keys(AGENT_HEADER_PRESETS) as AgentHeaderPreset[]).map((value) => ({ label: connectionHeaderPresetLabel(value), value, })); } function connectionTriStateChoices(): ApiModelFormChoice[] { return [ { label: opsText("conn.choice.auto"), value: "auto" }, { label: opsText("conn.choice.supported"), value: "true" }, { label: opsText("conn.choice.unsupported"), value: "false" }, ]; } function connectionTriStateValue(value: unknown): string { return typeof value === "boolean" ? String(value) : "auto"; } function connectionFormText(values: ApiModelFormValues, id: string): string { const value = values[id]; return typeof value === "string" ? value : ""; } function connectionFormChoicesWithCurrent(current: string, choices: readonly ApiModelFormChoice[]): ApiModelFormChoice[] { return choices.some((choice) => choice.value === current) ? [...choices] : [{ label: `${current}(当前)`, value: current }, ...choices]; } function parseConnectionHeadersForm(value: string): Record { let parsed: unknown; try { parsed = JSON.parse(value || "{}"); } catch { throw new Error(opsText("conn.validation.headersJson")); } if (!isStringRecord(parsed)) throw new Error(opsText("conn.validation.headersObject")); return { ...parsed }; } function setOptionalConnectionCompatString(target: Record, key: string, value: string): void { if (value) target[key] = value; else delete target[key]; } function setOptionalConnectionCompatBoolean(target: Record, key: string, value: string): void { if (value === "auto") delete target[key]; else target[key] = value === "true"; } /** * Manage a Provider's connection-level fields only — Base URL, API format, API * key, enabled state, headers, auth header, compat and display name — without * touching its models. Uses the same overlay form as the model editor so the * whole connection is edited in one pass. Presets use their fixed API; customs * pick from KNOWN_APIS. */ export async function configureProviderConnection( pi: ExtensionAPI, ctx: ExtensionCommandContext, providerId: string, displayName: string, modelsPath: string, defaultsPath: string, headless?: ApiModelHeadlessOptions, ): Promise { if (!supportsCustomOverlay(ctx) && !hasHeadlessFields(headless?.fields)) { ctx.ui.notify( "Provider 连接配置在 RPC/无头模式下需要字段参数,如 /api-manager provider --base-url= --api-key= --enabled=on", "warning", ); return; } const preset = findPreset(providerId); const current = await loadApiProviderSettings(providerId, modelsPath, null); const currentEnabled = await isProviderEnabled(providerId, modelsPath); const currentApi = preset?.api ?? current.api ?? "openai-completions"; const compat = current.compat ?? {}; const fields: ApiModelFormField[] = [ { id: "connection-section", label: opsText("conn.section.connection"), kind: "section", value: "" }, { id: "providerId", label: opsText("conn.field.providerId"), kind: "readonly", value: providerId }, { id: "name", label: opsText("conn.field.name"), kind: "text", value: current.name ?? displayName }, preset ? { id: "api", label: opsText("conn.field.api"), kind: "readonly", value: apiFormatLabel(preset.api) } : { id: "api", label: opsText("conn.field.api"), kind: "choice", value: currentApi, choices: connectionFormChoicesWithCurrent( currentApi, KNOWN_APIS.map((api) => ({ label: apiFormatLabel(api), value: api })), ), }, { id: "baseUrl", label: opsText("conn.field.baseUrl"), kind: "text", value: current.baseUrl }, { id: "apiKey", label: opsText("conn.field.apiKey"), kind: "secret", value: current.apiKey, help: opsText("conn.help.apiKey"), }, { id: "enabled", label: opsText("conn.field.enabled"), kind: "toggle", value: currentEnabled, help: opsText("conn.help.enabled"), }, { id: "headerPreset", label: opsText("conn.field.headerPreset"), kind: "choice", value: current.headerPreset ?? "none", choices: connectionHeaderPresetChoices(), }, { id: "headers", label: opsText("conn.field.headers"), kind: "secret", // Only user-authored headers are shown: the preset field already carries // its own values, and echoing them here would re-stamp the previous // identity after the user switches preset. value: JSON.stringify(customAgentHeaders(current.headers, current.headerPreset)), help: opsText("conn.help.headers"), }, { id: "authHeader", label: opsText("conn.field.authHeader"), kind: "choice", value: connectionTriStateValue(current.authHeader), choices: [ { label: opsText("conn.choice.auto"), value: "auto" }, { label: opsText("conn.choice.bearer"), value: "true" }, { label: opsText("conn.choice.noSend"), value: "false" }, ], }, { id: "compat-section", label: opsText("conn.section.compat"), kind: "section", value: "" }, { id: "thinkingFormat", label: opsText("conn.field.thinkingFormat"), kind: "choice", value: typeof compat.thinkingFormat === "string" ? compat.thinkingFormat : "", choices: connectionFormChoicesWithCurrent( typeof compat.thinkingFormat === "string" ? compat.thinkingFormat : "", [{ label: opsText("conn.choice.autoUrl"), value: "" }, ...connectionThinkingFormatOptions().flatMap((entry) => entry.value ? [{ label: entry.label, value: entry.value }] : [] )], ), }, { id: "supportsDeveloperRole", label: opsText("conn.field.developerRole"), kind: "choice", value: connectionTriStateValue(compat.supportsDeveloperRole), choices: connectionTriStateChoices(), }, { id: "supportsReasoningEffort", label: opsText("conn.field.reasoningEffort"), kind: "choice", value: connectionTriStateValue(compat.supportsReasoningEffort), choices: connectionTriStateChoices(), }, { id: "maxTokensField", label: opsText("conn.field.maxTokensField"), kind: "choice", value: typeof compat.maxTokensField === "string" ? compat.maxTokensField : "", choices: connectionFormChoicesWithCurrent( typeof compat.maxTokensField === "string" ? compat.maxTokensField : "", [ { label: opsText("conn.choice.auto"), value: "" }, { label: "max_completion_tokens", value: "max_completion_tokens" }, { label: "max_tokens", value: "max_tokens" }, ], ), }, ]; const result = await showApiModelEditor(ctx, { title: opsText("conn.title", { name: displayName }), locale: getTuiLocale(), fields, validate: (values) => { const errors: string[] = []; try { normalizeBaseUrl(connectionFormText(values, "baseUrl")); } catch (error) { errors.push(errorMessage(error)); } const apiKey = connectionFormText(values, "apiKey"); if (!apiKey && !current.apiKey && !(current.apiKeys && current.apiKeys.length > 0)) { errors.push(opsText("conn.validation.apiKeyRequired")); } try { parseConnectionHeadersForm(connectionFormText(values, "headers")); } catch (error) { errors.push(errorMessage(error)); } return errors; }, headless: headless?.fields, }); if (!result) return; const nextName = connectionFormText(result.values, "name").trim() || providerId; const api = preset ? preset.api : required(connectionFormText(result.values, "api"), "API format"); const baseUrl = normalizeBaseUrl(connectionFormText(result.values, "baseUrl")); const apiKey = connectionFormText(result.values, "apiKey"); const enabled = result.values.enabled === true; const headerPreset = isAgentHeaderPreset(result.values.headerPreset) ? result.values.headerPreset : "none"; const headers = expandAgentHeaderPreset(headerPreset, parseConnectionHeadersForm(connectionFormText(result.values, "headers"))) ?? {}; const nextCompat = { ...compat }; setOptionalConnectionCompatString(nextCompat, "thinkingFormat", connectionFormText(result.values, "thinkingFormat")); setOptionalConnectionCompatBoolean(nextCompat, "supportsDeveloperRole", connectionFormText(result.values, "supportsDeveloperRole")); setOptionalConnectionCompatBoolean(nextCompat, "supportsReasoningEffort", connectionFormText(result.values, "supportsReasoningEffort")); setOptionalConnectionCompatString(nextCompat, "maxTokensField", connectionFormText(result.values, "maxTokensField")); const authHeaderValue = connectionFormText(result.values, "authHeader"); const authHeader = authHeaderValue === "auto" ? undefined : authHeaderValue === "true"; const confirmed = headless?.assumeYes === true || await ctx.ui.confirm( opsText("conn.confirm", { name: nextName }), [ opsText("conn.preview.provider", { value: providerId }), opsText("conn.preview.api", { value: apiFormatLabel(api) }), opsText("conn.preview.baseUrl", { value: baseUrl }), opsText("conn.preview.enabled", { value: enabled ? opsText("value.on") : opsText("value.off") }), opsText("conn.preview.headerPreset", { value: connectionHeaderPresetLabel(headerPreset) }), opsText("conn.preview.headers", { value: Object.keys(headers).length > 0 ? Object.keys(headers).join(", ") : opsText("conn.value.none"), }), opsText("conn.preview.authorization", { value: authHeader === undefined ? opsText("conn.choice.auto") : authHeader ? opsText("conn.choice.bearer") : opsText("conn.choice.noSend"), }), opsText("conn.preview.compat", { value: Object.keys(nextCompat).length > 0 ? JSON.stringify(nextCompat) : opsText("conn.choice.auto"), }), opsText("conn.preview.modelsKept"), ].join("\n"), ); if (!confirmed) return; let writeResult: SaveApiProviderResult | undefined; await serializeMutation(modelsPath, async () => { const exists = await fileExists(modelsPath); const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? { ...root.providers } : {}; const entry = isRecord(providers[providerId]) ? { ...providers[providerId] } : {}; // Preserve models[] and unmanaged fields; overwrite only connection-level fields. const nextProvider: Record = { ...entry, baseUrl, api, enabled }; nextProvider.name = nextName; if (apiKey) nextProvider.apiKey = apiKey; else if (!(Array.isArray(entry.apiKeys) && entry.apiKeys.length > 0)) delete nextProvider.apiKey; if (headerPreset !== "none") nextProvider.headerPreset = headerPreset; else delete nextProvider.headerPreset; if (Object.keys(headers).length > 0) nextProvider.headers = { ...headers }; else delete nextProvider.headers; if (authHeader !== undefined) nextProvider.authHeader = authHeader; else delete nextProvider.authHeader; if (Object.keys(nextCompat).length > 0) nextProvider.compat = nextCompat; else delete nextProvider.compat; providers[providerId] = nextProvider; writeResult = await writeModelsRoot({ ...root, providers }, modelsPath, exists); }); if (!writeResult) throw new Error("API Provider connection was not written"); reloadProviderRegistration(pi, ctx, providerId, modelsPath); notifySaved(ctx, nextName, writeResult, opsText("conn.saved")); } export async function writeModelsRoot( root: Record, modelsPath: string, exists: boolean, ): Promise { await mkdir(dirname(modelsPath), { recursive: true, mode: 0o700 }); const backupPath = exists ? `${modelsPath}.bak-${Date.now()}-${randomUUID().slice(0, 8)}` : undefined; if (backupPath) { await copyFile(modelsPath, backupPath); // SEC-RV-005(a): backups contain API keys in plaintext — restrict mode. try { await chmod(backupPath, 0o600); } catch { /* best-effort */ } } // SEC-RV-005(b): cap retained backups to the 3 most recent .bak-* files so // plaintext-key backups do not accumulate indefinitely. Best-effort: a // prune error never fails the write. if (backupPath) { try { await pruneOldBackups(modelsPath); } catch { /* best-effort */ } } const temporaryPath = `${modelsPath}.${process.pid}.${randomUUID()}.tmp`; let handle: Awaited> | undefined; try { handle = await open(temporaryPath, "wx", 0o600); await handle.writeFile(`${JSON.stringify(root, null, 2)}\n`, "utf8"); await handle.sync(); await handle.close(); handle = undefined; await rename(temporaryPath, modelsPath); await fsyncDirectory(dirname(modelsPath)); } finally { await handle?.close(); try { await unlink(temporaryPath); } catch (error) { if (!isErrno(error, "ENOENT")) throw error; } } return { path: modelsPath, backupPath }; } /** * SEC-RV-005(b): prune old `.bak-*` backups of `modelsPath` so at most the 3 * most recent are retained. Backup names embed a timestamp: * `${basename}.bak-${Date.now()}-${rand}`, so sorting by the embedded timestamp * descending keeps the newest. Best-effort — caller wraps in try/catch. */ async function pruneOldBackups(modelsPath: string): Promise { const dir = dirname(modelsPath); const base = basename(modelsPath); const prefix = `${base}.bak-`; let entries: string[]; try { entries = await readdir(dir); } catch { return; } const backups = entries .filter((name) => name.startsWith(prefix)) .map((name) => { // name = `${base}.bak-${timestamp}-${rand}` — timestamp is the first // numeric segment after the prefix. Parse it for descending sort. const rest = name.slice(prefix.length); const dash = rest.indexOf("-"); const ts = dash >= 0 ? Number(rest.slice(0, dash)) : Number(rest); return { name, ts: Number.isFinite(ts) ? ts : 0 }; }) .sort((a, b) => b.ts - a.ts); const keep = 3; for (const item of backups.slice(keep)) { try { await unlink(join(dir, item.name)); } catch { /* best-effort */ } } } export async function readModelsRoot(modelsPath: string): Promise> { try { const parsed = JSON.parse(await readFile(modelsPath, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error("models.json root must be an object"); return parsed; } catch (error) { if (isErrno(error, "ENOENT")) return {}; if (error instanceof SyntaxError) { throw new Error(`Unable to parse ${modelsPath}: ${error.message}`, { cause: error }); } throw error; } } export interface ApiKeyRuntimeState { id: string; key: string; enabled: boolean; weight: number; failureCount: number; lastFailureAt?: number; lastFailureStatus?: number; lastUsedAt?: number; } export interface ResolvedApiKey { id: string; key: string; } const DEFAULT_KEY_WEIGHT = 1; const KEY_FAILURE_COOLDOWN_MS = 60_000; function isApiKeyEntryLocal(value: unknown): value is ApiKeyEntry { return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.key === "string"; } function sanitizeApiKeyEntry(entry: ApiKeyEntry): ApiKeyRuntimeState { return { id: entry.id, key: entry.key, enabled: entry.enabled !== false, weight: typeof entry.weight === "number" && Number.isFinite(entry.weight) && entry.weight >= 0 ? entry.weight : DEFAULT_KEY_WEIGHT, failureCount: typeof entry.failureCount === "number" && entry.failureCount >= 0 ? entry.failureCount : 0, lastFailureAt: typeof entry.lastFailureAt === "number" ? entry.lastFailureAt : undefined, lastFailureStatus: typeof entry.lastFailureStatus === "number" ? entry.lastFailureStatus : undefined, lastUsedAt: typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : undefined, }; } export function readApiKeys(config: Record): ApiKeyRuntimeState[] { if (!Array.isArray(config.apiKeys)) return []; return config.apiKeys.filter(isApiKeyEntryLocal).map(sanitizeApiKeyEntry); } export function resolveApiKey( config: Record, policy: ApiKeyPolicy = "sticky", ): ResolvedApiKey | undefined { const keys = readApiKeys(config); if (keys.length === 0) { if (typeof config.apiKey === "string" && config.apiKey.length > 0) { return { id: "legacy", key: config.apiKey }; } return undefined; } const enabled = keys.filter((key) => key.enabled); if (enabled.length === 0) return undefined; const activeId = typeof config.activeKeyId === "string" ? config.activeKeyId : undefined; switch (policy) { case "sticky": return pickSticky(enabled, activeId) ?? pickKey(enabled[0]); case "round-robin": return pickRoundRobin(enabled, activeId); case "weighted": return pickWeighted(enabled); case "failover": return pickFailover(enabled, activeId); default: return pickKey(enabled[0]); } } function pickKey(key: ApiKeyRuntimeState): ResolvedApiKey { return { id: key.id, key: key.key }; } function pickSticky(keys: ApiKeyRuntimeState[], activeId?: string): ResolvedApiKey | undefined { if (activeId) { const found = keys.find((key) => key.id === activeId); if (found) return pickKey(found); } return undefined; } function pickRoundRobin(keys: ApiKeyRuntimeState[], activeId?: string): ResolvedApiKey { if (!activeId) return pickKey(keys[0]); const index = keys.findIndex((key) => key.id === activeId); const next = keys[(index + 1) % keys.length]; return pickKey(next); } function pickWeighted(keys: ApiKeyRuntimeState[]): ResolvedApiKey { const total = keys.reduce((sum, key) => sum + key.weight, 0); if (total <= 0) return pickKey(keys[0]); let point = Math.random() * total; for (const key of keys) { point -= key.weight; if (point <= 0) return pickKey(key); } return pickKey(keys[keys.length - 1]); } function pickFailover(keys: ApiKeyRuntimeState[], activeId?: string): ResolvedApiKey { if (activeId) { const active = keys.find((key) => key.id === activeId); if (active && active.enabled && !isKeyCooling(active)) { return pickKey(active); } } const healthy = keys.filter((key) => !isKeyCooling(key)); if (healthy.length === 0) { const sorted = [...keys].sort((a, b) => { if (a.failureCount !== b.failureCount) return a.failureCount - b.failureCount; return (a.lastFailureAt ?? 0) - (b.lastFailureAt ?? 0); }); return pickKey(sorted[0]); } const sorted = healthy.sort((a, b) => { if (a.failureCount !== b.failureCount) return a.failureCount - b.failureCount; return (a.lastUsedAt ?? 0) - (b.lastUsedAt ?? 0); }); return pickKey(sorted[0]); } function isKeyCooling(key: ApiKeyRuntimeState): boolean { if (key.failureCount === 0) return false; const last = key.lastFailureAt; if (!last) return false; return Date.now() - last < KEY_FAILURE_COOLDOWN_MS * Math.min(key.failureCount, 5); } export function markApiKeyFailed( config: Record, keyId: string, status?: number, ): ApiKeyEntry[] | undefined { if (!Array.isArray(config.apiKeys)) return undefined; let changed = false; const next = (config.apiKeys as unknown[]).map((entry) => { if (!isApiKeyEntryLocal(entry) || entry.id !== keyId) return entry; changed = true; return { ...entry, failureCount: (entry.failureCount ?? 0) + 1, lastFailureAt: Date.now(), lastFailureStatus: status ?? entry.lastFailureStatus, }; }); return changed ? next as ApiKeyEntry[] : undefined; } export function advanceApiKey( config: Record, policy: ApiKeyPolicy = "sticky", excludeKeyId?: string, ): { apiKeys: ApiKeyEntry[]; activeKeyId: string } | undefined { const keys = readApiKeys(config); if (keys.length === 0) return undefined; const enabled = keys.filter((key) => key.enabled); if (enabled.length === 0) return undefined; const currentId = typeof config.activeKeyId === "string" ? config.activeKeyId : undefined; if (policy === "round-robin") { const index = enabled.findIndex((key) => key.id === currentId); const nextIndex = index >= 0 ? (index + 1) % enabled.length : 0; return { apiKeys: config.apiKeys as ApiKeyEntry[], activeKeyId: enabled[nextIndex].id }; } if (policy === "weighted") { const candidates = enabled.filter((key) => key.id !== excludeKeyId); const pick = pickWeighted(candidates.length > 0 ? candidates : enabled); return { apiKeys: config.apiKeys as ApiKeyEntry[], activeKeyId: pick.id }; } let candidates = enabled.filter((key) => key.id !== excludeKeyId && !isKeyCooling(key)); if (candidates.length === 0) candidates = enabled.filter((key) => key.id !== excludeKeyId); if (candidates.length === 0) candidates = enabled; const sorted = candidates.sort((a, b) => { if (a.failureCount !== b.failureCount) return a.failureCount - b.failureCount; return (a.lastFailureAt ?? Infinity) - (b.lastFailureAt ?? Infinity); }); return { apiKeys: config.apiKeys as ApiKeyEntry[], activeKeyId: sorted[0].id }; } export async function updateProviderKeyState( providerId: string, update: (config: Record) => { apiKeys?: ApiKeyEntry[]; activeKeyId?: string; keyPolicy?: ApiKeyPolicy } | undefined, modelsPath: string, ): Promise { let result: SaveApiProviderResult | undefined; await serializeMutation(modelsPath, async () => { const exists = await fileExists(modelsPath); const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? { ...root.providers } : {}; const entry = providers[providerId]; if (!isRecord(entry)) return; const nextConfig = update(entry); if (!nextConfig) return; providers[providerId] = { ...entry, ...nextConfig }; result = await writeModelsRoot({ ...root, providers }, modelsPath, exists); }); return result; } export async function recordApiKeyFailureAndAdvance( providerId: string, keyId: string, status: number, modelsPath: string, ): Promise<{ activeKeyId: string; key: string } | undefined> { if (keyId === "legacy") return undefined; const update = (config: Record) => { const policy = (isApiKeyPolicy(config.keyPolicy) ? config.keyPolicy : "sticky") as ApiKeyPolicy; const nextKeys = markApiKeyFailed(config, keyId, status); if (!nextKeys) return undefined; const advanced = advanceApiKey({ ...config, apiKeys: nextKeys }, policy, keyId); if (!advanced) return undefined; return { apiKeys: advanced.apiKeys, activeKeyId: advanced.activeKeyId }; }; const result = await updateProviderKeyState(providerId, update, modelsPath); if (!result) return undefined; const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? root.providers : {}; const config = isRecord(providers[providerId]) ? providers[providerId] : {}; const resolved = resolveApiKey(config, isApiKeyPolicy(config.keyPolicy) ? config.keyPolicy : "sticky"); if (!resolved) return undefined; return { activeKeyId: config.activeKeyId as string, key: resolved.key }; } export async function serializeMutation(path: string, mutate: () => Promise): Promise { const previous = mutationQueues.get(path) ?? Promise.resolve(); const mutation = previous.catch(() => undefined).then(async () => { const release = await lockSettingsResource(path); try { await mutate(); } finally { await release(); } }); const settled = mutation.then(() => undefined, () => undefined); mutationQueues.set(path, settled); try { await mutation; } finally { if (mutationQueues.get(path) === settled) mutationQueues.delete(path); } } export function findPreset(provider: string): ProviderDefaults | undefined { return PROVIDERS.find((entry) => entry.id === provider); } export function providerDefaults(provider: ApiProviderId): ProviderDefaults { const defaults = findPreset(provider); if (!defaults) throw new Error(`Unsupported API provider: ${provider}`); return defaults; } export interface ProviderWriteDefaults { api: string; contextWindow: number; maxTokens: number; compat?: Record; } /** Resolve protocol/limits for a save: presets use PROVIDERS, user-defined Providers use explicit settings. */ export function resolveWriteDefaults(settings: ApiProviderSettings): ProviderWriteDefaults { const preset = findPreset(settings.provider); if (preset) { return { api: preset.api, contextWindow: settings.contextWindow ?? preset.contextWindow, maxTokens: settings.maxTokens ?? preset.maxTokens, compat: preset.compat, }; } return { api: required(settings.api ?? "", "API type"), contextWindow: settings.contextWindow ?? 128_000, maxTokens: settings.maxTokens ?? 16_384, compat: settings.compat, }; } export function configuredProviderIds(modelsPath: string): Set { try { const parsed = JSON.parse(readFileSync(modelsPath, "utf8")) as unknown; if (!isRecord(parsed)) return new Set(); // Hoisted: TypeScript drops property narrowing inside the filter callback. const providers = parsed.providers; if (!isRecord(providers)) return new Set(); return new Set(PROVIDERS .filter((provider) => isEnabledProviderConfig(providers[provider.id])) .map((provider) => provider.id)); } catch { return new Set(); } } /** Every provider id present in models.json, native ones (e.g. "openai") included. */ export function providerIdsInModels(modelsPath: string): string[] { try { const parsed = JSON.parse(readFileSync(modelsPath, "utf8")) as unknown; if (!isRecord(parsed) || !isRecord(parsed.providers)) return []; // Hoisted: TypeScript drops property narrowing inside the filter callback. const providers = parsed.providers; return Object.keys(providers).filter((id) => isRecord(providers[id])); } catch { return []; } } export function hasEnabledProviderSync(providerId: string, modelsPath: string): boolean { try { const parsed = JSON.parse(readFileSync(modelsPath, "utf8")) as unknown; if (!isRecord(parsed) || !isRecord(parsed.providers)) return false; const provider = parsed.providers[providerId]; return isRecord(provider) && providerEnabled(provider); } catch { return false; } } export function canonicalizeLegacyThinkingLevelMap(value: unknown): { map: Record | undefined; changed: boolean; } { if (!isRecord(value)) return { map: undefined, changed: false }; const map = Object.fromEntries( Object.entries(value).filter((entry): entry is [string, string | null] => typeof entry[1] === "string" || entry[1] === null ), ); return { map, changed: false }; } export function materializeProviderCompat(providerCompat: unknown, modelCompat: unknown): ProviderChatModelConfig["compat"] | undefined { const provider = isRecord(providerCompat) ? { ...providerCompat } : undefined; const model = isRecord(modelCompat) ? { ...modelCompat } : undefined; if (!provider && !model) return undefined; const merged: Record = { ...provider, ...model }; for (const key of ["openRouterRouting", "vercelGatewayRouting"] as const) { const providerRouting = isRecord(provider?.[key]) ? provider[key] : undefined; const modelRouting = isRecord(model?.[key]) ? model[key] : undefined; if (providerRouting || modelRouting) merged[key] = { ...providerRouting, ...modelRouting }; } return merged as ProviderChatModelConfig["compat"]; } export function configuredProviderRegistration( providerId: string, modelsPath: string, hostVersion = VERSION, ): ProviderConfig { const fallbackName = findPreset(providerId)?.name ?? providerId; let config: Record | undefined; try { const root = JSON.parse(readFileSync(modelsPath, "utf8")) as unknown; if (isRecord(root) && isRecord(root.providers)) { const providerConfig = root.providers[providerId]; if (isRecord(providerConfig)) config = providerConfig; } } catch { return { name: fallbackName }; } if (!config || !Array.isArray(config.models)) return { name: fallbackName }; const registration: ProviderConfig = {}; if (typeof config.name === "string") registration.name = config.name; if (typeof config.baseUrl === "string") { try { registration.baseUrl = normalizeBaseUrl(config.baseUrl); } catch { return { name: fallbackName }; } } const keyPolicy = isApiKeyPolicy(config.keyPolicy) ? config.keyPolicy : "sticky"; const resolvedKey = resolveApiKey(config, keyPolicy); if (resolvedKey) registration.apiKey = resolvedKey.key; if (typeof config.api === "string") registration.api = config.api; if (typeof config.streamSimple === "function") registration.streamSimple = config.streamSimple as ProviderConfig["streamSimple"]; if (isStringRecord(config.headers)) registration.headers = { ...config.headers }; if (typeof config.authHeader === "boolean") registration.authHeader = config.authHeader; // models.json upserts all operation types; extension.models replaces the entire catalog. if (getPiHostMode(hostVersion) !== "legacy") return registration; if (isRecord(config.oauth)) registration.oauth = { ...config.oauth } as ProviderConfig["oauth"]; const promptCachePolicy = loadPromptCachePolicySync(join(dirname(modelsPath), "settings.json")); const registrationApi = typeof config.api === "string" ? config.api : undefined; registration.models = config.models.filter(isRecord).flatMap((model) => { if (typeof model.id !== "string" || model.id.length === 0) return []; const normalizedMap = canonicalizeLegacyThinkingLevelMap(model.thinkingLevelMap).map; const input: Array<"text" | "image"> = Array.isArray(model.input) && model.input.every((value) => value === "text" || value === "image") ? [...model.input] : ["text"]; const cost = isCost(model.cost) ? { ...model.cost } // Custom channels rarely carry cost in models.json; fall back to the // built-in pi-ai catalog so the footer shows real spend instead of $0. // Prefer the catalog matching this channel's API driver (e.g. Azure // pricing for azure-openai-responses channels). : (lookupBuiltinPricing(model.id, typeof model.api === "string" ? model.api : registrationApi)?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); const clone: ProviderChatModelConfig = { id: model.id, name: typeof model.name === "string" ? model.name : model.id, reasoning: typeof model.reasoning === "boolean" ? model.reasoning : false, input, cost, contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : 128_000, maxTokens: typeof model.maxTokens === "number" ? model.maxTokens : 16_384, }; if (typeof model.api === "string") clone.api = model.api; if (typeof model.baseUrl === "string") { try { clone.baseUrl = normalizeBaseUrl(model.baseUrl); } catch { return []; } } if (normalizedMap) clone.thinkingLevelMap = normalizedMap; if (isStringRecord(model.headers)) clone.headers = { ...model.headers }; const compat = materializeProviderCompat(config.compat, model.compat); if (compat) clone.compat = compat; // Unified prompt-cache policy: control whether the model advertises the // OpenAI prompt-cache compat flags pi-ai turns into prompt_cache_options / // prompt_cache_retention request parameters (strict gateways reject them). // Anthropic-style cache_control is a separate mechanism and stays untouched. if (isOpenAIFormatApi(clone.api ?? registrationApi)) { clone.compat = { ...(clone.compat ?? {}), ...promptCacheCompatFlags(promptCachePolicy, model.id) }; } return [clone]; }); return registration; } export type ChannelTarget = | { kind: "preset"; preset: ProviderDefaults } | { kind: "custom"; id: string }; export interface ChannelRef { id: string; name: string; } export interface ConfigureModelTarget { modelId: string | null; adding: boolean; } export interface RetryManagerArgs { enabled?: boolean; maxRetries?: number; showOnly?: boolean; } export interface CacheManagerArgs { policy?: PromptCachePolicy; showOnly?: boolean; } export interface CacheAgentManagerArgs { retention?: CacheRetention; showOnly?: boolean; } export interface ParsedManagerArgs { action?: ApiProviderAction; target?: ChannelTarget; /** File path operand for export/import; preserved with original casing. */ filePath?: string; retry?: RetryManagerArgs; cache?: CacheManagerArgs; cacheAgent?: CacheAgentManagerArgs; stats?: StatsManagerArgs; key?: KeyManagerArgs; thinking?: ThinkingManagerArgs; /** `--name=value` 字段参数(kebab→camel 归一后的键);headless 表单提交用。 */ fields?: Record; /** `--yes`:跳过保存前确认。 */ assumeYes?: boolean; /** 提取 `--` 参数后的位置参数(保留原始大小写)。 */ positionals?: string[]; } export interface ThinkingManagerArgs { subAction?: "show" | "save" | "clear"; level?: ApiThinkingLevel; } export interface KeyManagerArgs { subAction?: "status" | "switch" | "add" | "remove" | "policy"; keyId?: string; policy?: ApiKeyPolicy; } export interface StatsManagerArgs { /** `off` is a legacy no-op; the overlay closes via q/Esc. */ off?: boolean; /** `footer on|off|show` sub-command. */ footer?: "on" | "off" | "show"; } export function parseManagerArgs(args: string): ParsedManagerArgs { const { positionals, fields } = extractHeadlessArgs(splitCommandArgs(args)); const parsed = parseManagerPositionalArgs(positionals); const { yes, ...rest } = fields; const assumeYes = yes !== undefined ? (parseHeadlessBoolean(yes) ?? true) : undefined; return { ...parsed, positionals, ...(Object.keys(rest).length > 0 ? { fields: rest } : {}), ...(assumeYes !== undefined ? { assumeYes } : {}), }; } function parseManagerPositionalArgs(values: string[]): ParsedManagerArgs { const normalized = values.map((value) => value.toLowerCase()); if (values.length === 0) return {}; if (normalized[0] === "cache" || normalized[0] === "prompt-cache" || normalized[0] === "promptcache") { if (values.length === 1) return { action: "cache" }; if (normalized[1] === "agent" && values.length === 2) { return { action: "cache-agent" }; } if (normalized[1] === "agent" && values.length === 3) { if (normalized[2] === "show" || normalized[2] === "status") { return { action: "cache-agent", cacheAgent: { showOnly: true } }; } if (isCacheRetention(normalized[2])) { return { action: "cache-agent", cacheAgent: { retention: normalized[2] } }; } throw usageError(); } if ((normalized[1] === "show" || normalized[1] === "status") && values.length === 2) { return { action: "cache", cache: { showOnly: true } }; } if (values.length === 2 && isPromptCachePolicy(normalized[1])) { return { action: "cache", cache: { policy: normalized[1] } }; } throw usageError(); } if (normalized[0] === "retry") { if (values.length === 1) return { action: "retry" }; if ((normalized[1] === "show" || normalized[1] === "status") && values.length === 2) { return { action: "retry", retry: { showOnly: true } }; } if (normalized[1] === "off" || normalized[1] === "disable" || normalized[1] === "disabled") { if (values.length !== 2) throw usageError(); return { action: "retry", retry: { enabled: false } }; } if (normalized[1] === "on" || normalized[1] === "enable" || normalized[1] === "enabled") { if (values.length > 3) throw usageError(); return { action: "retry", retry: { enabled: true, ...(values[2] ? { maxRetries: retryCount(values[2]) } : {}), }, }; } throw usageError(); } if (normalized[0] === "export" || normalized[0] === "import") { if (values.length === 1) return { action: normalized[0] }; if (values.length === 2) return { action: normalized[0], filePath: values[1] }; throw usageError(); } if (normalized[0] === "stats" || normalized[0] === "usage" || normalized[0] === "statistics") { return parseStatsArgs(values, normalized); } if (normalized[0] === "key" || normalized[0] === "keys" || normalized[0] === "apikey" || normalized[0] === "apikeys") { return parseKeyArgs(values, normalized); } if (normalized[0] === "thinking" || normalized[0] === "model-thinking" || normalized[0] === "thinking-default") { return parseThinkingArgs(values, normalized); } if (normalized[0] === "switch-key" || normalized[0] === "switchkey" || normalized[0] === "switch") { if (values.length === 1) return { action: "switch-key" }; if (values.length === 2) return { action: "switch-key", key: { subAction: "switch", keyId: values[1] } }; throw usageError(); } if (values.length === 1) { const action = actionFromArg(normalized[0]); if (action) return { action }; const target = resolveTargetToken(values[0]); if (target) return { action: "configure", target }; throw usageError(); } const action = actionFromArg(normalized[0]); const target = resolveTargetToken(values[1]); if (action && target) return { action, target }; throw usageError(); } export function resolveTargetToken(value: string): ChannelTarget | undefined { const normalized = value.toLowerCase(); const preset = providerFromArg(normalized); if (preset) return { kind: "preset", preset }; if (normalized === "new" || normalized === "custom" || normalized === "add-custom") { return { kind: "custom", id: "" }; } return { kind: "custom", id: value }; } export function usageError(): Error { return new Error( `用法:/api-manager list | thinking [show|save [off|minimal|low|medium|high|xhigh|max]|clear] | retry [show|on [1-${API_RETRY_MAX_RETRIES_LIMIT}]|off] | cache [show|auto|off|on] | cache agent [show|short|long|none] | price [openai|qwen|anthropic|] | stats | stats footer [on|off|show] | key [status|switch |policy |add [--id= --key= --weight=]|remove ] | switch-key | show|set|delete|enable|disable|logout|filter|reset [openai|qwen|anthropic||new] | export [path] | import [path]\n配置表单支持无头字段参数:/api-manager set [--model=] --base-url= --api-key= --context-window= --max-tokens= [--reasoning=on|off] [--thinking=] [--multimodal=on|off] [--yes];自定义 Provider 另支持 --api/--name/--header-preset/--headers/--auth-header/--thinking-format/--developer-role/--reasoning-effort/--max-tokens-field;provider 连接级编辑同样接受这些字段。`, ); } export async function chooseModelToConfigure( providerId: string, displayName: string, ctx: ExtensionCommandContext, modelsPath: string, ): Promise { const modelIds = await configuredModelIds(providerId, modelsPath); if (modelIds.length === 0) return { modelId: null, adding: true }; const addLabel = "➕ 新增模型…"; const choice = await ctx.ui.select( `选择要修改的 ${displayName} 模型`, [...modelIds, addLabel], ); if (!choice) return undefined; if (choice === addLabel) return { modelId: null, adding: true }; return { modelId: choice, adding: false }; } export async function chooseProvider( ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, ): Promise { const options: Array<{ label: string; target: ChannelTarget }> = []; for (const preset of PROVIDERS) { if (!await isProviderConfigured(preset.id, modelsPath)) continue; options.push({ label: numberedOptionLabel( options.length, `${preset.name} · Provider ID: ${preset.id}(${await isProviderEnabled(preset.id, modelsPath) ? "启用" : "停用"})`, ), target: { kind: "preset", preset }, }); } for (const id of managedProviderIdsSync(defaultsPath, modelsPath)) { if (findPreset(id) || !await isProviderConfigured(id, modelsPath)) continue; const name = await channelDisplayName(id, modelsPath); options.push({ label: numberedOptionLabel( options.length, `${name} · Provider ID: ${id}(用户定义 · ${await isProviderEnabled(id, modelsPath) ? "启用" : "停用"})`, ), target: { kind: "custom", id }, }); } if (options.length === 0) { ctx.ui.notify(opsText("provider.emptyGuide"), "info"); return undefined; } const choice = await ctx.ui.select("选择 Provider(连接级操作)", options.map((entry) => entry.label)); return options.find((entry) => entry.label === choice)?.target; } export type GlobalModelPick = | { kind: "model"; providerId: string; modelId: string } | { kind: "new-model" }; export interface GlobalModelOption { label: string; pick: GlobalModelPick; } /** Model picker: lists every configured model under its Provider. */ export async function chooseModelGlobally( ctx: ExtensionCommandContext, action: "configure" | "show" | "delete", modelsPath: string, defaultsPath: string, ): Promise { const options = await buildGlobalModelOptions(action, modelsPath, defaultsPath); if (options.length === 0) { ctx.ui.notify("尚未配置任何模型。", "info"); return undefined; } const title = action === "configure" ? "选择要修改的模型,或新增" : action === "show" ? "选择要查看的模型" : "选择要删除的模型"; const choice = await ctx.ui.select(title, options.map((entry) => entry.label)); return options.find((entry) => entry.label === choice)?.pick; } export async function buildGlobalModelOptions( action: "configure" | "show" | "delete", modelsPath: string, defaultsPath: string, ): Promise { const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? root.providers : {}; const options: GlobalModelOption[] = []; for (const providerId of modelCentricProviderOrder(defaultsPath, modelsPath)) { const config = providers[providerId]; if (!isRecord(config)) continue; const models = Array.isArray(config.models) ? config.models.filter(isRecord) : []; for (const model of models) { if (typeof model.id !== "string") continue; options.push({ label: numberedOptionLabel(options.length, `${providerId} / ${model.id}`), pick: { kind: "model", providerId, modelId: model.id }, }); } } if (action !== "configure") return options; options.push({ label: numberedOptionLabel(options.length, "➕ 新增模型…"), pick: { kind: "new-model" }, }); return options; } /** Presets first, then managed user-defined Providers; models remain a flat list. */ export function modelCentricProviderOrder(defaultsPath: string, modelsPath?: string): string[] { return [ ...PROVIDERS.map((preset) => preset.id), ...managedProviderIdsSync(defaultsPath, modelsPath).filter((id) => !findPreset(id)), ]; } /** Pick the target Provider for a new model, then open its add form. */ export async function configureNewModel( pi: ExtensionAPI, ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, settingsPath: string, headless?: ApiModelHeadlessOptions, ): Promise { const options: Array<{ label: string; target: ChannelTarget }> = []; for (const preset of PROVIDERS) { options.push({ label: numberedOptionLabel( options.length, `${preset.name} · Provider ID: ${preset.id}`, ), target: { kind: "preset", preset }, }); } for (const id of managedProviderIdsSync(defaultsPath, modelsPath)) { if (findPreset(id) || !await isProviderConfigured(id, modelsPath)) continue; const name = await channelDisplayName(id, modelsPath); options.push({ label: numberedOptionLabel(options.length, `${name} · Provider ID: ${id}`), target: { kind: "custom", id }, }); } const customInputLabel = numberedOptionLabel(options.length, "自定义 Provider ID…"); const choice = await ctx.ui.select( "新增模型到哪个 Provider?", [...options.map((entry) => entry.label), customInputLabel], ); if (choice === undefined) return; const target = options.find((entry) => entry.label === choice)?.target; if (!target && choice !== customInputLabel) return; if (choice === customInputLabel) { const providedId = headless?.fields ? headlessField(headless.fields, "provider", "providerId", "id") : undefined; const idInput = providedId ?? await ctx.ui.input("Provider ID", ""); if (idInput === undefined) return; const providerId = normalizeChannelId(idInput); const preset = findPreset(providerId); if (preset) { await configurePresetModelTarget( pi, preset, { modelId: null, adding: true }, ctx, modelsPath, defaultsPath, settingsPath, headless, ); } else { await configureCustomModelTarget( pi, providerId, { modelId: null, adding: true }, ctx, modelsPath, defaultsPath, settingsPath, headless, ); } return; } if (target!.kind === "preset") { await configurePresetModelTarget( pi, target!.preset, { modelId: null, adding: true }, ctx, modelsPath, defaultsPath, settingsPath, headless, ); return; } await configureCustomModelTarget( pi, target!.id, { modelId: null, adding: true }, ctx, modelsPath, defaultsPath, settingsPath, headless, ); } export async function dispatchGlobalModelPick( pi: ExtensionAPI, pick: GlobalModelPick, action: "configure" | "show" | "delete", ctx: ExtensionCommandContext, modelsPath: string, defaultsPath: string, settingsPath: string, headless?: ApiModelHeadlessOptions, ): Promise { if (pick.kind === "new-model") { await configureNewModel(pi, ctx, modelsPath, defaultsPath, settingsPath, headless); return; } const displayName = await channelDisplayName(pick.providerId, modelsPath); if (action === "show") { await showProvider(ctx, pick.providerId, displayName, modelsPath, defaultsPath, settingsPath, pick.modelId); return; } if (action === "delete") { await deleteProviderModel( pi, pick.providerId, displayName, pick.modelId, ctx, modelsPath, defaultsPath, settingsPath, ); return; } const preset = findPreset(pick.providerId); const target: ConfigureModelTarget = { modelId: pick.modelId, adding: false }; if (preset) { await configurePresetModelTarget(pi, preset, target, ctx, modelsPath, defaultsPath, settingsPath, headless); } else { await configureCustomModelTarget(pi, pick.providerId, target, ctx, modelsPath, defaultsPath, settingsPath, headless); } } export function numberedOptionLabel(index: number, label: string): string { return `${index + 1}. ${label}`; } export async function resolveChannelRef( target: ChannelTarget, ctx: ExtensionCommandContext, modelsPath: string, ): Promise { if (target.kind === "preset") return { id: target.preset.id, name: target.preset.name }; if (!target.id) { ctx.ui.notify("请指定 Provider ID。", "warning"); return undefined; } return { id: target.id, name: await channelDisplayName(target.id, modelsPath) }; } export async function channelDisplayName(providerId: string, modelsPath: string): Promise { const preset = findPreset(providerId); if (preset) return preset.name; const root = await readModelsRoot(modelsPath); const config = isRecord(root.providers) && isRecord(root.providers[providerId]) ? root.providers[providerId] : undefined; return typeof config?.name === "string" && config.name ? config.name : providerId; } /** One-line summary of configured model filters, for listProviders output. */ export async function renderModelFilterSummary(defaultsPath: string): Promise { const filters = await loadModelFilters(defaultsPath); const ids = Object.keys(filters).sort(); if (ids.length === 0) return []; return [ "模型过滤(屏蔽 teammate 可见模型):", ...ids.map((id) => { const filter = filters[id]; return `- ${id} · ${filter.mode === "allow" ? "白名单" : "黑名单"} · ${filter.patterns.length} 条规则`; }), ]; } export function normalizeChannelId(value: string): string { const id = required(value, "Provider ID").trim(); if (/\s/.test(id)) throw new Error("Provider ID cannot contain whitespace"); if (id === "__proto__" || id === "prototype" || id === "constructor") { throw new Error(`Provider ID ${id} is reserved`); } return id; } export async function chooseAction( ctx: ExtensionCommandContext, settingsPath: string, visionAgentDir = dirname(settingsPath), ): Promise { const retry = await loadApiRetrySettings(settingsPath); const vision = loadVisionDelegationConfig(visionAgentDir); const choices: Array<{ action: ApiProviderAction; label: string }> = [ { action: "list", label: opsText("menu.list") }, { action: "provider", label: opsText("menu.provider") }, { action: "configure", label: opsText("menu.configure") }, { action: "show", label: opsText("menu.show") }, { action: "thinking", label: opsText("menu.thinking") }, { action: "vision", label: opsText("menu.vision", { state: opsText(vision.enabled ? "value.on" : "value.off") }) }, { action: "toggle", label: opsText("menu.toggle") }, { action: "delete", label: opsText("menu.delete") }, { action: "retry", label: opsText("menu.retry", { state: opsText(retry.enabled ? "value.on" : "value.off") }) }, { action: "cache", label: opsText("menu.cache", { value: await loadPromptCachePolicy(settingsPath) }) }, { action: "cache-agent", label: opsText("menu.cacheAgent", { value: await loadAgentCacheRetention(settingsPath) }) }, { action: "price", label: opsText("menu.price") }, { action: "key", label: opsText("menu.key") }, { action: "stats", label: "📊 用量统计 (热图 / 折线图 · token / 成本 / cache)" }, { action: "export", label: opsText("menu.export") }, { action: "import", label: opsText("menu.import") }, { action: "logout", label: opsText("menu.logout") }, { action: "filter", label: opsText("menu.filter") }, { action: "reset", label: opsText("menu.reset") }, ]; const choice = await ctx.ui.select(opsText("menu.title"), choices.map((entry) => entry.label)); return choices.find((entry) => entry.label === choice)?.action; } function parseThinkingArgs(values: string[], normalized: string[]): ParsedManagerArgs { // forms: // thinking → show current model default // thinking show → show current model default // thinking save [level] → save current/specified level as current model default // thinking → save specified level as current model default // thinking clear → clear current model default if (values.length === 1) return { action: "thinking", thinking: { subAction: "show" } }; if (normalized[1] === "show" || normalized[1] === "status") { if (values.length === 2) return { action: "thinking", thinking: { subAction: "show" } }; throw usageError(); } if (normalized[1] === "save" || normalized[1] === "set" || normalized[1] === "bind" || normalized[1] === "pin") { if (values.length === 2) return { action: "thinking", thinking: { subAction: "save" } }; if (values.length === 3 && isThinkingLevel(normalized[2])) { return { action: "thinking", thinking: { subAction: "save", level: normalized[2] } }; } throw usageError(); } if (normalized[1] === "clear" || normalized[1] === "remove" || normalized[1] === "reset" || normalized[1] === "global") { if (values.length === 2) return { action: "thinking", thinking: { subAction: "clear" } }; throw usageError(); } if (values.length === 2 && isThinkingLevel(normalized[1])) { return { action: "thinking", thinking: { subAction: "save", level: normalized[1] } }; } throw usageError(); } function parseStatsArgs(values: string[], normalized: string[]): ParsedManagerArgs { // forms: // stats → open panel overlay // stats off → (legacy) no-op hint; overlay closes via q/Esc // stats footer on|off|show → toggle/query footer sparkline if (values.length === 1) return { action: "stats" }; if (normalized[1] === "off") return { action: "stats", stats: { off: true } }; if (normalized[1] === "footer") { if (values.length === 2) return { action: "stats", stats: { footer: "show" } }; if (values.length === 3 && (normalized[2] === "on" || normalized[2] === "off" || normalized[2] === "show")) { return { action: "stats", stats: { footer: normalized[2] as "on" | "off" | "show" } }; } throw usageError(); } throw usageError(); } function parseKeyArgs(values: string[], normalized: string[]): ParsedManagerArgs { // forms: // key → status / interactive manager // key status → show key status // key switch → switch active key // key policy → set selection policy // key add → interactive add // key remove → remove key if (values.length === 1) return { action: "key" }; if (normalized[1] === "status" || normalized[1] === "show") { if (values.length === 2) return { action: "key", key: { subAction: "status" } }; throw usageError(); } if (normalized[1] === "switch") { if (values.length === 2) return { action: "key", key: { subAction: "switch" } }; if (values.length === 3) return { action: "key", key: { subAction: "switch", keyId: values[2] } }; throw usageError(); } if (normalized[1] === "policy") { if (values.length === 2) return { action: "key", key: { subAction: "policy" } }; if (values.length === 3 && isApiKeyPolicy(normalized[2])) { return { action: "key", key: { subAction: "policy", policy: normalized[2] } }; } throw usageError(); } if (normalized[1] === "add") { if (values.length === 2) return { action: "key", key: { subAction: "add" } }; throw usageError(); } if (normalized[1] === "remove" || normalized[1] === "rm" || normalized[1] === "delete") { if (values.length === 3) return { action: "key", key: { subAction: "remove", keyId: values[2] } }; throw usageError(); } throw usageError(); } export async function manageProviderKeys( pi: ExtensionAPI, providerId: string, displayName: string, args: { subAction?: "status" | "switch" | "add" | "remove" | "policy"; keyId?: string; policy?: ApiKeyPolicy } | undefined, ctx: ExtensionCommandContext, modelsPath: string, fields?: Record, ): Promise { if (!await isProviderConfigured(providerId, modelsPath)) { ctx.ui.notify(`${displayName} 尚未配置,无法管理 key。`, "warning"); return; } const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? root.providers : {}; const config = isRecord(providers[providerId]) ? providers[providerId] : {}; const keys = readApiKeys(config); const policy = (isApiKeyPolicy(config.keyPolicy) ? config.keyPolicy : "sticky") as ApiKeyPolicy; const activeId = typeof config.activeKeyId === "string" ? config.activeKeyId : undefined; const refresh = (): void => { if (hasEnabledProviderSync(providerId, modelsPath)) { pi.registerProvider(providerId, configuredProviderRegistration(providerId, modelsPath)); ctx.modelRegistry.refresh(); } }; const statusText = (): string => { if (keys.length === 0) return opsText("key.empty"); const lines = keys.map((key) => { const status = key.enabled ? (isKeyCooling(key) ? opsText("key.status.cooling") : opsText("key.status.healthy")) : opsText("key.status.disabled"); const marker = key.id === activeId ? " *" : ""; return ` ${key.id}${marker} · weight=${key.weight} · failures=${key.failureCount} · ${status}`; }); return [opsText("key.policy", { policy }), opsText("key.active", { id: activeId ?? "auto" }), ...lines].join("\n"); }; const doSwitch = async (keyId?: string): Promise => { if (keys.length === 0) { ctx.ui.notify(opsText("key.empty"), "info"); return; } const enabled = keys.filter((key) => key.enabled); if (enabled.length === 0) { ctx.ui.notify("All keys are disabled; enable one before switching.", "warning"); return; } let targetId = keyId; if (!targetId) { const choice = await ctx.ui.select(opsText("key.chooseSwitch"), enabled.map((key) => { const marker = key.id === activeId ? " (current)" : ""; return `${key.id}${marker}`; })); if (!choice) return; targetId = enabled.find((key) => choice === key.id || choice.startsWith(`${key.id} `))?.id; } if (!targetId || !enabled.some((key) => key.id === targetId)) { ctx.ui.notify("Invalid key id", "warning"); return; } const result = await updateProviderKeyState(providerId, () => ({ activeKeyId: targetId }), modelsPath); if (!result) return; refresh(); ctx.ui.notify(opsText("key.switched", { provider: displayName, id: targetId }), "info"); }; const doPolicy = async (nextPolicy?: ApiKeyPolicy): Promise => { if (!nextPolicy) { const choice = await ctx.ui.select(opsText("key.policyPrompt"), API_KEY_POLICIES.map((value) => value)); if (!choice || !isApiKeyPolicy(choice)) return; nextPolicy = choice; } const result = await updateProviderKeyState(providerId, () => ({ keyPolicy: nextPolicy }), modelsPath); if (!result) return; refresh(); ctx.ui.notify(`${displayName} key policy set to ${nextPolicy}`, "info"); }; const doAdd = async (): Promise => { // --id/--key/--weight 字段直接提供时跳过逐项 input(RPC 与 headless 提交)。 const idInput = headlessField(fields ?? {}, "id", "keyId") ?? await ctx.ui.input(opsText("key.idPrompt"), ""); if (idInput === undefined) return; const id = idInput.trim(); if (!id) { ctx.ui.notify("Key id is required", "warning"); return; } if (keys.some((key) => key.id === id)) { ctx.ui.notify(`Key id ${id} already exists`, "warning"); return; } const keyInput = headlessField(fields ?? {}, "key", "apiKey", "secret") ?? await ctx.ui.input(opsText("key.keyPrompt"), ""); if (keyInput === undefined) return; const key = keyInput.trim(); if (!key) { ctx.ui.notify("API key is required", "warning"); return; } const weightInput = headlessField(fields ?? {}, "weight") ?? await ctx.ui.input(opsText("key.weightPrompt"), "1"); if (weightInput === undefined) return; const weight = Number(weightInput.trim()); const newEntry: ApiKeyEntry = { id, key, enabled: true, weight: Number.isFinite(weight) && weight >= 0 ? weight : DEFAULT_KEY_WEIGHT, }; const nextKeys = [...(config.apiKeys as ApiKeyEntry[] ?? []), newEntry]; const result = await updateProviderKeyState( providerId, () => ({ apiKeys: nextKeys, activeKeyId: activeId ?? id }), modelsPath, ); if (!result) return; refresh(); ctx.ui.notify(opsText("key.added", { provider: displayName, id }), "info"); }; const doRemove = async (keyId?: string): Promise => { if (!keyId) { ctx.ui.notify("Usage: /api-manager key remove ", "warning"); return; } const current = keys.find((key) => key.id === keyId); if (!current) { ctx.ui.notify(`Key ${keyId} not found`, "warning"); return; } const confirmed = await ctx.ui.confirm(`Remove key ${keyId}?`, "This cannot be undone."); if (!confirmed) return; const nextKeys = (config.apiKeys as ApiKeyEntry[] ?? []).filter((entry) => entry.id !== keyId); const nextActiveId = activeId === keyId ? (nextKeys.find((entry) => entry.enabled !== false)?.id ?? "") : activeId; const result = await updateProviderKeyState( providerId, () => ({ apiKeys: nextKeys, activeKeyId: nextActiveId || undefined }), modelsPath, ); if (!result) return; refresh(); ctx.ui.notify(opsText("key.removed", { provider: displayName, id: keyId }), "info"); }; const subAction = args?.subAction ?? "status"; if (subAction === "status") { ctx.ui.notify(statusText(), "info"); return; } if (subAction === "switch") { await doSwitch(args?.keyId); return; } if (subAction === "policy") { await doPolicy(args?.policy); return; } if (subAction === "add") { await doAdd(); return; } if (subAction === "remove") { await doRemove(args?.keyId); return; } } export function actionFromArg(value: string): ApiProviderAction | undefined { if (value === "configure" || value === "config" || value === "set" || value === "add" || value === "update") { return "configure"; } if (value === "provider" || value === "connection" || value === "conn") return "provider"; if (value === "delete" || value === "remove") return "delete"; if (value === "enable" || value === "on") return "enable"; if (value === "disable" || value === "off") return "disable"; if (value === "list" || value === "ls") return "list"; if (value === "show" || value === "get") return "show"; if (value === "thinking" || value === "model-thinking" || value === "thinking-default") return "thinking"; if (value === "logout") return "logout"; if (value === "retry") return "retry"; if (value === "cache" || value === "prompt-cache" || value === "promptcache") return "cache"; if (value === "cache-agent" || value === "agent-cache") return "cache-agent"; if (value === "vision") return "vision"; if (value === "nextsuggest" || value === "next-suggest" || value === "suggest") return "nextsuggest"; if (value === "open-code-review" || value === "ocr" || value === "ocr-review" || value === "code-review") return "ocr"; if (value === "enhance") return "enhance"; if (value === "prompt-enhance" || value === "optimize" || value === "prompt-optimize") return "optimize"; if (value === "price" || value === "pricing" || value === "cost") return "price"; if (value === "stats" || value === "usage" || value === "statistics") return "stats"; if (value === "reset") return "reset"; if (value === "filter" || value === "model-filter" || value === "modelfilter") return "filter"; if (value === "key" || value === "keys" || value === "apikey" || value === "apikeys") return "key"; if (value === "switch-key" || value === "switchkey" || value === "switch") return "switch-key"; return undefined; } export function providerFromArg(value: string): ProviderDefaults | undefined { if (value === "openai" || value === "maestro-openai") { return providerDefaults("maestro-openai"); } if (value === "qwen" || value === "maestro-qwen") { return providerDefaults("maestro-qwen"); } if (value === "anthropic" || value === "maestro-anthropic") { return providerDefaults("maestro-anthropic"); } return undefined; } export async function chooseDefaultThinkingLevel( ctx: ExtensionCommandContext, api: string, reasoning: boolean, current: ApiThinkingLevel, maxThinking: boolean, ): Promise { const supported: ApiThinkingLevel[] = reasoning ? api === "openai-responses" ? ["minimal", "low", "medium", "high", "xhigh"] : ["off", "minimal", "low", "medium", "high", "xhigh"] : ["off"]; if (reasoning && maxThinking) supported.push("max"); const fallback = supported.includes(DEFAULT_THINKING_LEVEL) ? DEFAULT_THINKING_LEVEL : supported[0]; const selected = supported.includes(current) ? current : fallback; const options = [selected, ...supported.filter((level) => level !== selected)]; return await ctx.ui.select(opsText("thinking.title"), options) as ApiThinkingLevel | undefined; } export function currentDefaultThinkingLevel( ctx: ExtensionCommandContext, modelsPath: string, settingsPath = join(dirname(modelsPath), "settings.json"), ): ApiThinkingLevel { const manager = SettingsManager.create(ctx.cwd, dirname(settingsPath)); return (manager.getDefaultThinkingLevel() as ApiThinkingLevel | undefined) ?? DEFAULT_THINKING_LEVEL; } export async function saveDefaultThinkingLevel( ctx: ExtensionCommandContext, modelsPath: string, level: ApiThinkingLevel, ): Promise { const manager = SettingsManager.create(ctx.cwd, dirname(modelsPath)); const setDefaultThinkingLevel = manager.setDefaultThinkingLevel.bind(manager) as (value: ApiThinkingLevel) => void; setDefaultThinkingLevel(level); await manager.flush(); const errors = manager.drainErrors(); if (errors.length > 0) { throw new Error(`Unable to save default thinking level: ${errors.map((entry) => entry.error.message).join("; ")}`); } } export async function clearDeletedDefaultModel( settingsPath: string, providerId: string, modelId: string, ): Promise { if (!await fileExists(settingsPath)) return; await serializeMutation(settingsPath, async () => { const root = await readModelsRoot(settingsPath); if (root.defaultProvider !== providerId || root.defaultModel !== modelId) return; const next = { ...root }; delete next.defaultProvider; delete next.defaultModel; await writeModelsRoot(next, settingsPath, true); }); } export async function saveDefaultModelAndThinking( ctx: ExtensionCommandContext, modelsPath: string, // User-defined Providers are not preset ids, and they save defaults through here too. provider: string, modelId: string, isAdding: boolean, settingsPath = join(dirname(modelsPath), "settings.json"), ): Promise { // Per-model thinking defaults are persisted separately (saveModelThinkingDefault) and // consumed by Pi on future startup/model switches; settings.json.defaultThinkingLevel is // only a global fallback, so configuring a model must never overwrite it. Only a newly ADDED model becomes the // default model — editing an existing model leaves the current default untouched so // same-format siblings are not affected. if (!isAdding) return; const manager = SettingsManager.create(ctx.cwd, dirname(settingsPath)); manager.setDefaultModelAndProvider(provider, modelId); await manager.flush(); const errors = manager.drainErrors(); if (errors.length > 0) { throw new Error(`Unable to save default model settings: ${errors.map((entry) => entry.error.message).join("; ")}`); } } export function applyThinkingLevelToActiveModel( pi: ExtensionAPI, ctx: ExtensionCommandContext, providerId: string, modelId: string, level: ThinkingLevel, ): void { if (ctx.model?.provider !== providerId || ctx.model.id !== modelId) return; setPiThinkingLevel(pi, level); } export function setPiThinkingLevel(pi: ExtensionAPI, level: ThinkingLevel): void { pi.setThinkingLevel(level); } export function modelThinkingKey(provider: string, modelId: string): string { return `${encodeURIComponent(provider)}/${encodeURIComponent(modelId)}`; } export function legacyModelThinkingKey(provider: string, modelId: string): string { return `${provider}/${modelId}`; } interface ConfiguredModelPair { provider: string; modelId: string; encodedKey: string; officialKey: string; } export interface LegacyModelThinkingMigrationResult { migratedKeys: string[]; retainedKeys: Array<{ key: string; reason: "invalid" | "unknown" | "ambiguous" | "official-key-collision" }>; } function modelThinkingSettingsManager(cwd: string, settingsPath: string): ReturnType { return SettingsManager.create(cwd, dirname(settingsPath)); } function throwSettingsManagerErrors( manager: ReturnType, action: string, ): void { const errors = manager.drainErrors(); if (errors.length > 0) { throw new Error(`${action}: ${errors.map((entry) => entry.error.message).join("; ")}`); } } async function flushThinkingSettings( manager: ReturnType, action: string, ): Promise { await manager.flush(); throwSettingsManagerErrors(manager, action); } async function configuredModelPairs(modelsPath: string): Promise { const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? root.providers : {}; const pairs: ConfiguredModelPair[] = []; for (const [provider, config] of Object.entries(providers)) { if (!isRecord(config) || !Array.isArray(config.models)) continue; for (const model of config.models) { if (!isRecord(model) || typeof model.id !== "string") continue; pairs.push({ provider, modelId: model.id, encodedKey: modelThinkingKey(provider, model.id), officialKey: legacyModelThinkingKey(provider, model.id), }); } } return pairs; } function uniquePairs(pairs: readonly ConfiguredModelPair[]): ConfiguredModelPair[] { const seen = new Set(); return pairs.filter((pair) => { const identity = `${pair.provider}\0${pair.modelId}`; if (seen.has(identity)) return false; seen.add(identity); return true; }); } /** * Move legacy API Manager model defaults into Pi's official settings store. * * The official key is a raw `provider/modelId` string, so pairs that collapse to * the same key cannot be represented safely. Such entries, plus unknown or * ambiguous raw legacy keys, remain untouched in api-manager.json for manual * recovery/backward compatibility. Settings are flushed before legacy entries * are removed, making retries idempotent and ensuring official values win. * `migratedKeys` reports only values newly written to the official store. */ export async function migrateLegacyModelThinkingDefaults( cwd: string, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { const defaultsRoot = await readModelsRoot(defaultsPath); const legacyDefaults = isRecord(defaultsRoot.modelDefaults) ? defaultsRoot.modelDefaults : {}; const pairs = await configuredModelPairs(modelsPath); const byEncoded = new Map(); const byOfficial = new Map(); for (const pair of pairs) { byEncoded.set(pair.encodedKey, [...(byEncoded.get(pair.encodedKey) ?? []), pair]); byOfficial.set(pair.officialKey, [...(byOfficial.get(pair.officialKey) ?? []), pair]); } const manager = modelThinkingSettingsManager(cwd, settingsPath); const migrated = new Map(); const consumed = new Map(); const retainedKeys: LegacyModelThinkingMigrationResult["retainedKeys"] = []; for (const [key, value] of Object.entries(legacyDefaults)) { if (!isThinkingLevel(value)) { retainedKeys.push({ key, reason: "invalid" }); continue; } const candidates = uniquePairs([ ...(byEncoded.get(key) ?? []), ...(byOfficial.get(key) ?? []), ]); if (candidates.length === 0) { retainedKeys.push({ key, reason: "unknown" }); continue; } if (candidates.length !== 1) { retainedKeys.push({ key, reason: "ambiguous" }); continue; } const pair = candidates[0]; if ((byOfficial.get(pair.officialKey)?.length ?? 0) !== 1) { retainedKeys.push({ key, reason: "official-key-collision" }); continue; } if (manager.getModelThinkingLevel(pair.provider, pair.modelId) === undefined) { manager.setModelThinkingLevel(pair.provider, pair.modelId, value); migrated.set(key, value); } consumed.set(key, value); } await flushThinkingSettings(manager, "Unable to migrate model thinking defaults"); if (consumed.size > 0 && await fileExists(defaultsPath)) { await serializeMutation(defaultsPath, async () => { const current = await readModelsRoot(defaultsPath); if (!isRecord(current.modelDefaults)) return; const nextDefaults = { ...current.modelDefaults }; for (const [key, consumedValue] of consumed) { if (nextDefaults[key] === consumedValue) delete nextDefaults[key]; } const next = { ...current }; if (Object.keys(nextDefaults).length > 0) next.modelDefaults = nextDefaults; else delete next.modelDefaults; await writeModelsRoot(next, defaultsPath, true); }); } return { migratedKeys: [...migrated.keys()], retainedKeys }; } export async function loadModelThinkingDefault( provider: string, modelId: string, settingsPath: string, cwd: string, legacyDefaultsPath?: string, ): Promise { const manager = modelThinkingSettingsManager(cwd, settingsPath); const official = manager.getModelThinkingLevel(provider, modelId); throwSettingsManagerErrors(manager, "Unable to load model thinking default"); if (official !== undefined) return official; if (!legacyDefaultsPath) return undefined; const root = await readModelsRoot(legacyDefaultsPath); if (!isRecord(root.modelDefaults)) return undefined; const value = root.modelDefaults[modelThinkingKey(provider, modelId)] ?? root.modelDefaults[legacyModelThinkingKey(provider, modelId)]; return isThinkingLevel(value) ? value : undefined; } async function removeEncodedLegacyThinkingDefaults( defaultsPath: string | undefined, pairs: ReadonlyArray<{ provider: string; modelId: string }>, ): Promise { if (!defaultsPath || !await fileExists(defaultsPath)) return; await serializeMutation(defaultsPath, async () => { const root = await readModelsRoot(defaultsPath); if (!isRecord(root.modelDefaults)) return; const nextDefaults = { ...root.modelDefaults }; for (const pair of pairs) delete nextDefaults[modelThinkingKey(pair.provider, pair.modelId)]; const next = { ...root }; if (Object.keys(nextDefaults).length > 0) next.modelDefaults = nextDefaults; else delete next.modelDefaults; await writeModelsRoot(next, defaultsPath, true); }); } export async function saveModelThinkingDefault( provider: string, modelId: string, level: ThinkingLevel, settingsPath: string, cwd: string, legacyDefaultsPath?: string, ): Promise { const manager = modelThinkingSettingsManager(cwd, settingsPath); manager.setModelThinkingLevel(provider, modelId, level); await flushThinkingSettings(manager, "Unable to save model thinking default"); await removeEncodedLegacyThinkingDefaults(legacyDefaultsPath, [{ provider, modelId }]); } export async function deleteModelThinkingDefault( provider: string, modelId: string, settingsPath: string, cwd: string, legacyDefaultsPath?: string, ): Promise { const manager = modelThinkingSettingsManager(cwd, settingsPath); manager.removeModelThinkingLevel(provider, modelId); await flushThinkingSettings(manager, "Unable to delete model thinking default"); await removeEncodedLegacyThinkingDefaults(legacyDefaultsPath, [{ provider, modelId }]); } export async function renameModelThinkingDefault( provider: string, oldModelId: string, newModelId: string, settingsPath: string, cwd: string, legacyDefaultsPath?: string, ): Promise { if (oldModelId === newModelId) return; const manager = modelThinkingSettingsManager(cwd, settingsPath); const value = manager.getModelThinkingLevel(provider, oldModelId); if (value === undefined) { throwSettingsManagerErrors(manager, "Unable to load model thinking default for rename"); return; } if (manager.getModelThinkingLevel(provider, newModelId) === undefined) { manager.setModelThinkingLevel(provider, newModelId, value); } manager.removeModelThinkingLevel(provider, oldModelId); await flushThinkingSettings(manager, "Unable to rename model thinking default"); await removeEncodedLegacyThinkingDefaults(legacyDefaultsPath, [ { provider, modelId: oldModelId }, { provider, modelId: newModelId }, ]); } export async function renameDefaultModelRef( ctx: ExtensionCommandContext, modelsPath: string, provider: string, oldModelId: string, newModelId: string, ): Promise { if (oldModelId === newModelId) return; const manager = SettingsManager.create(ctx.cwd, dirname(modelsPath)); if (manager.getDefaultProvider() !== provider || manager.getDefaultModel() !== oldModelId) return; manager.setDefaultModel(newModelId); await manager.flush(); const errors = manager.drainErrors(); if (errors.length > 0) { throw new Error(`Unable to update default model reference: ${errors.map((entry) => entry.error.message).join("; ")}`); } } export async function deleteProviderThinkingDefaults( provider: string, modelIds: readonly string[], settingsPath: string, cwd: string, legacyDefaultsPath?: string, ): Promise { const manager = modelThinkingSettingsManager(cwd, settingsPath); for (const modelId of modelIds) manager.removeModelThinkingLevel(provider, modelId); await flushThinkingSettings(manager, "Unable to delete Provider model thinking defaults"); await removeEncodedLegacyThinkingDefaults( legacyDefaultsPath, modelIds.map((modelId) => ({ provider, modelId })), ); } export function managedProviderIdsSync(defaultsPath: string, modelsPath?: string): string[] { try { const root = JSON.parse(readFileSync(defaultsPath, "utf8")) as unknown; if (!isRecord(root)) return []; if (!("managedProviders" in root) && !("managedChannels" in root)) { // api-manager.json lost its managed section (e.g. an external edit or a // migration that rewrote the file). Fall back to every enabled custom // provider already present in models.json so /api-manager does not // silently forget them; an explicit (even empty) list is honored as-is. if (modelsPath) return inferredManagedProviderIds(modelsPath); return []; } return managedProviderIds(root); } catch { return []; } } /** Enabled non-preset providers present in models.json, as a fallback managed list. */ export function inferredManagedProviderIds(modelsPath: string): string[] { try { const parsed = JSON.parse(readFileSync(modelsPath, "utf8")) as unknown; if (!isRecord(parsed) || !isRecord(parsed.providers)) return []; return Object.entries(parsed.providers) .filter(([id, config]) => !findPreset(id) && isRecord(config) && providerEnabled(config), ) .map(([id]) => id) .sort(); } catch { return []; } } export function managedProviderIds(root: Record): string[] { const current = Array.isArray(root.managedProviders) ? root.managedProviders : []; const legacy = Array.isArray(root.managedChannels) ? root.managedChannels : []; return [...new Set([...current, ...legacy].filter((id): id is string => typeof id === "string"))]; } export function withoutLegacyManagedChannels(root: Record): Record { const next = { ...root }; delete next.managedChannels; return next; } export async function addManagedProvider(defaultsPath: string, id: string): Promise { if (findPreset(id)) return; await serializeMutation(defaultsPath, async () => { const exists = await fileExists(defaultsPath); const root = await readModelsRoot(defaultsPath); const current = managedProviderIds(root); if (current.includes(id) && Array.isArray(root.managedProviders) && !("managedChannels" in root)) return; await writeModelsRoot({ ...withoutLegacyManagedChannels(root), version: 1, managedProviders: current.includes(id) ? current : [...current, id], }, defaultsPath, exists); }); } export async function removeManagedProvider(defaultsPath: string, id: string): Promise { if (!await fileExists(defaultsPath)) return; await serializeMutation(defaultsPath, async () => { const root = await readModelsRoot(defaultsPath); const current = managedProviderIds(root); if (!current.includes(id) && Array.isArray(root.managedProviders) && !("managedChannels" in root)) return; await writeModelsRoot({ ...withoutLegacyManagedChannels(root), managedProviders: current.filter((value) => value !== id), }, defaultsPath, true); }); } export function runtimeSupportsMaxThinking(ctx: ExtensionCommandContext): boolean { return ctx.modelRegistry.getAll().some((model) => { const map = model.thinkingLevelMap as Record | undefined; return map?.xhigh === "max" || map?.max === "max"; }); } export function reloadProviderRegistration( pi: ExtensionAPI, ctx: ExtensionCommandContext, providerId: string, modelsPath: string, ): void { ctx.modelRegistry.refresh(); if (hasEnabledProviderSync(providerId, modelsPath)) { pi.registerProvider(providerId, configuredProviderRegistration(providerId, modelsPath)); } else { suspendProviderRegistration(pi, providerId); } } export async function migrateLegacyProviderThinkingMaps( provider: string, modelsPath: string, ): Promise { await serializeMutation(modelsPath, async () => { const exists = await fileExists(modelsPath); if (!exists) return; const root = await readModelsRoot(modelsPath); if (!isRecord(root.providers) || !isRecord(root.providers[provider])) return; const currentProvider = root.providers[provider]; if (!Array.isArray(currentProvider.models)) return; let changed = false; const models = currentProvider.models.map((model) => { if (!isRecord(model)) return model; const normalized = canonicalizeLegacyThinkingLevelMap(model.thinkingLevelMap); if (!normalized.changed || !normalized.map) return model; changed = true; return { ...model, thinkingLevelMap: normalized.map }; }); if (!changed) return; const providers = { ...root.providers, [provider]: { ...currentProvider, models }, }; await writeModelsRoot({ ...root, providers }, modelsPath, true); }); } export function isEnabledProviderConfig(value: unknown): value is Record { return isRecord(value) && providerEnabled(value); } export function providerEnabled(config: Record): boolean { return config.enabled !== false; } export async function isProviderEnabled(provider: string, modelsPath: string): Promise { const root = await readModelsRoot(modelsPath); return isRecord(root.providers) && isRecord(root.providers[provider]) && providerEnabled(root.providers[provider]); } export async function isProviderConfigured(provider: string, modelsPath: string): Promise { const root = await readModelsRoot(modelsPath); return isRecord(root.providers) && isRecord(root.providers[provider]); } export async function configuredModelIds(provider: string, modelsPath: string): Promise { const root = await readModelsRoot(modelsPath); if (!isRecord(root.providers) || !isRecord(root.providers[provider])) return []; const models = root.providers[provider].models; if (!Array.isArray(models)) return []; return models.filter(isRecord) .map((model) => model.id) .filter((id): id is string => typeof id === "string"); } export function authSource(value: unknown): string { return typeof value === "string" && value ? "models.json 已保存 key" : "未配置"; } export function notifySaved( ctx: ExtensionCommandContext, displayName: string, result: SaveApiProviderResult, suffix: string, ): void { const backup = result.backupPath ? `\n${opsText("saved.backup", { path: result.backupPath })}` : ""; ctx.ui.notify(`${displayName} ${suffix}\n${opsText("saved.config", { path: result.path })}${backup}`, "info"); } export function compactionPreviewLines(projectRoot: string, contextWindow: number, maxTokens: number): string[] { const compaction = readCompactionSettings(projectRoot).effective; const model = deriveCompactionThreshold({ reserveTokens: compaction.reserveTokens, contextWindow, modelMaxTokens: maxTokens, soft: compaction.soft, }); if (!model.usable) return [opsText("compaction.unavailable")]; const configuredThreshold = contextWindow - compaction.reserveTokens; const lines = [ opsText("compaction.hard", { tokens: model.thresholdTokens.toLocaleString(getTuiLocale()), percent: model.thresholdPercent.toFixed(0), }), ]; if (configuredThreshold !== model.thresholdTokens) { lines.push(opsText("compaction.configured", { tokens: configuredThreshold.toLocaleString(getTuiLocale()), reason: providerThresholdReason(model.reason), })); } if (model.soft && model.soft.outputConstrained && model.soft.truncationPointTokens !== undefined) { lines.push(opsText("compaction.outputWarning", { tokens: model.soft.truncationPointTokens.toLocaleString(getTuiLocale()), percent: ((model.soft.truncationPointTokens / contextWindow) * 100).toFixed(0), })); } if (model.soft && !model.soft.nudgeReachable) { lines.push(opsText("compaction.nudgeUnreachable")); } else if (model.soft && !model.soft.pruneReachable) { lines.push(opsText("compaction.pruneUnreachable")); } return lines; } export function providerThresholdReason(reason: CompactionThresholdReason): string { if (reason === "configured") return opsText("threshold.configured"); if (reason === "ratio-floor") return opsText("threshold.ratioFloor"); if (reason === "max-output") return opsText("threshold.maxOutput"); return opsText("threshold.capped"); } export function validateModelWindow(contextWindow: number, maxTokens: number): void { if (maxTokens >= contextWindow) { throw new Error(opsText("validation.window", { max: maxTokens.toLocaleString(getTuiLocale()), window: contextWindow.toLocaleString(getTuiLocale()), })); } } export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export function required(value: string, label: string): string { const trimmed = value.trim(); if (!trimmed) throw new Error(`${label} cannot be empty`); return trimmed; } export function positiveInteger(value: string | number, label: string): number { const parsed = typeof value === "number" ? value : Number(value.trim()); if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new Error(/[\u3400-\u9fff]/.test(label) ? `${label} 必须是大于 0 的整数` : `${label} must be a positive integer`); } return parsed; } export function retryCount(value: string | number): number { const parsed = positiveInteger(value, "最大重试次数"); if (parsed > API_RETRY_MAX_RETRIES_LIMIT) { throw new Error(`最大重试次数必须在 1-${API_RETRY_MAX_RETRIES_LIMIT} 之间`); } return parsed; } export function isPositiveInteger(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0; } export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } export function isStringRecord(value: unknown): value is Record { return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); } export function isCost(value: unknown): value is ProviderModelConfig["cost"] { return isRecord(value) && typeof value.input === "number" && typeof value.output === "number" && typeof value.cacheRead === "number" && typeof value.cacheWrite === "number"; } export function isThinkingLevel(value: unknown): value is ThinkingLevel { return isCanonicalThinkingLevel(value); } export function syncEffortStatus( ctx: Pick | undefined, level: unknown, modelDefault?: unknown, ): void { const setStatus = ctx?.ui?.setStatus; if (typeof setStatus === "function") { if (!isThinkingLevel(level)) { setStatus(EFFORT_STATUS_KEY, undefined); return; } const suffix = isThinkingLevel(modelDefault) ? ` · model=${modelDefault}` : " · model=global"; setStatus(EFFORT_STATUS_KEY, `${level}${suffix}`); } } // max 已是 canonical level(与 Pi runtime 的 ThinkingLevel 一致),不再降级为 xhigh。 export function canonicalThinkingLevel(level: ApiThinkingLevel): ThinkingLevel { return level; } export function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error && error.code === code; } export async function fileExists(path: string): Promise { try { await readFile(path, "utf8"); return true; } catch (error) { if (isErrno(error, "ENOENT")) return false; throw error; } } // --- Configuration export / import ----------------------------------------- export const API_MANAGER_EXPORT_KIND = "pi-maestro-api-manager"; export const API_MANAGER_EXPORT_VERSION = 1; export function defaultApiManagerExportPath(modelsPath: string): string { return join(dirname(modelsPath), "api-manager-export.json"); } /** Provider ids owned by the API Manager: configured presets plus managed user-defined Providers. */ function apiManagerOwnedIds(modelsRoot: Record, defaultsPath: string, modelsPath: string): string[] { const providers = isRecord(modelsRoot.providers) ? modelsRoot.providers : {}; const ids: string[] = []; for (const preset of PROVIDERS) { if (isRecord(providers[preset.id])) ids.push(preset.id); } for (const id of managedProviderIdsSync(defaultsPath, modelsPath)) { if (!findPreset(id) && !ids.includes(id) && isRecord(providers[id])) ids.push(id); } return ids; } function countProviderModels(providers: Record>): number { let count = 0; for (const entry of Object.values(providers)) { if (Array.isArray(entry.models)) count += entry.models.filter(isRecord).length; } return count; } /** Export payload: owned Provider entries verbatim plus official per-model thinking defaults. */ export async function buildApiManagerExport( modelsPath: string, defaultsPath: string, settingsPath: string, cwd: string, ): Promise> { const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? root.providers : {}; const ids = apiManagerOwnedIds(root, defaultsPath, modelsPath); const exported: Record> = {}; const exportedDefaults: Record = {}; const manager = modelThinkingSettingsManager(cwd, settingsPath); for (const id of ids) { const entry = providers[id]; if (!isRecord(entry)) continue; exported[id] = entry; const models = Array.isArray(entry.models) ? entry.models.filter(isRecord) : []; for (const model of models) { if (typeof model.id !== "string") continue; const level = manager.getModelThinkingLevel(id, model.id); if (level !== undefined) exportedDefaults[modelThinkingKey(id, model.id)] = level; } } throwSettingsManagerErrors(manager, "Unable to load model thinking defaults for export"); return { kind: API_MANAGER_EXPORT_KIND, version: API_MANAGER_EXPORT_VERSION, exportedAt: new Date().toISOString(), providers: exported, ...(Object.keys(exportedDefaults).length > 0 ? { modelDefaults: exportedDefaults } : {}), }; } export async function exportApiManagerConfig( ctx: ExtensionCommandContext, exportPath: string, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { const payload = await buildApiManagerExport(modelsPath, defaultsPath, settingsPath, ctx.cwd); const providers = payload.providers as Record>; const providerCount = Object.keys(providers).length; if (providerCount === 0) { ctx.ui.notify(opsText("export.empty"), "info"); return; } const result = await writeModelsRoot(payload, exportPath, await fileExists(exportPath)); ctx.ui.notify([ opsText("export.done", { providers: providerCount, models: countProviderModels(providers) }), opsText("export.saved", { path: result.path }), ...(result.backupPath ? [opsText("saved.backup", { path: result.backupPath })] : []), opsText("export.secretNote"), ].join("\n"), "info"); } export async function importApiManagerConfig( pi: ExtensionAPI, ctx: ExtensionCommandContext, importPath: string, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { if (!await fileExists(importPath)) { ctx.ui.notify(opsText("import.notFound", { path: importPath }), "warning"); return; } let payload: Record; let imported: Record>; try { const parsed = JSON.parse(await readFile(importPath, "utf8")) as unknown; if (!isRecord(parsed)) throw new Error("root must be a JSON object"); if (parsed.kind !== undefined && parsed.kind !== API_MANAGER_EXPORT_KIND) { throw new Error(`kind must be ${API_MANAGER_EXPORT_KIND}`); } if (parsed.version !== undefined && parsed.version !== API_MANAGER_EXPORT_VERSION) { throw new Error(`unsupported version: ${String(parsed.version)}`); } payload = parsed; imported = validateImportedProviders(parsed.providers); } catch (error) { ctx.ui.notify(opsText("import.invalid", { path: importPath, message: errorMessage(error) }), "error"); return; } const importedIds = Object.keys(imported); const removedModels: Array<[string, string]> = []; let result: SaveApiProviderResult | undefined; await serializeMutation(modelsPath, async () => { const exists = await fileExists(modelsPath); const root = await readModelsRoot(modelsPath); const providers = isRecord(root.providers) ? { ...root.providers } : {}; for (const id of importedIds) { const entry = { ...imported[id] }; const current = providers[id]; // An export may omit the API key (e.g. a redacted copy); keep the local // key so an already configured Provider stays usable after the merge. if ((typeof entry.apiKey !== "string" || entry.apiKey === "") && isRecord(current) && typeof current.apiKey === "string" && current.apiKey !== "") { entry.apiKey = current.apiKey; } if (isRecord(current) && Array.isArray(current.models)) { const importedModelIds = new Set( Array.isArray(entry.models) ? entry.models.filter(isRecord).map((model) => model.id) : [], ); for (const model of current.models.filter(isRecord)) { if (typeof model.id === "string" && !importedModelIds.has(model.id)) { removedModels.push([id, model.id]); } } } providers[id] = entry; } result = await writeModelsRoot({ ...root, providers }, modelsPath, exists); }); if (!result) throw new Error("API Manager import was not written"); await applyImportedDefaults( ctx.cwd, imported, removedModels, payload.modelDefaults, modelsPath, defaultsPath, settingsPath, ); for (const [providerId, modelId] of removedModels) { await clearDeletedDefaultModel(settingsPath, providerId, modelId); } for (const id of importedIds) { reloadProviderRegistration(pi, ctx, id, modelsPath); } ctx.ui.notify([ opsText("import.done", { providers: importedIds.length, models: countProviderModels(imported), path: importPath, }), opsText("saved.config", { path: result.path }), ...(result.backupPath ? [opsText("saved.backup", { path: result.backupPath })] : []), ].join("\n"), "info"); } function validateImportedProviders(value: unknown): Record> { if (!isRecord(value)) throw new Error("providers must be a JSON object"); const ids = Object.keys(value); if (ids.length === 0) throw new Error("providers is empty"); const result: Record> = {}; for (const id of ids) { const entry = value[id]; if (!isRecord(entry)) throw new Error(`Provider ${id} must be an object`); normalizeChannelId(id); if (entry.api !== undefined && typeof entry.api !== "string") { throw new Error(`Provider ${id} api must be a string`); } if (!findPreset(id) && typeof entry.api !== "string") { throw new Error(`Provider ${id} requires an api field`); } if (entry.baseUrl !== undefined && typeof entry.baseUrl !== "string") { throw new Error(`Provider ${id} baseUrl must be a string`); } if (entry.apiKey !== undefined && typeof entry.apiKey !== "string") { throw new Error(`Provider ${id} apiKey must be a string`); } if (entry.name !== undefined && typeof entry.name !== "string") { throw new Error(`Provider ${id} name must be a string`); } if (entry.authHeader !== undefined && typeof entry.authHeader !== "boolean") { throw new Error(`Provider ${id} authHeader must be a boolean`); } if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") { throw new Error(`Provider ${id} enabled must be a boolean`); } if (entry.headers !== undefined && !isStringRecord(entry.headers)) { throw new Error(`Provider ${id} headers must be a string map`); } if (entry.compat !== undefined && !isRecord(entry.compat)) { throw new Error(`Provider ${id} compat must be an object`); } if (entry.models !== undefined) { if (!Array.isArray(entry.models)) throw new Error(`Provider ${id} models must be an array`); const seen = new Set(); for (const model of entry.models) { if (!isRecord(model) || typeof model.id !== "string" || model.id.length === 0) { throw new Error(`Provider ${id} has a model entry without a string id`); } if (seen.has(model.id)) throw new Error(`Provider ${id} duplicates model ${model.id}`); seen.add(model.id); if (model.contextWindow !== undefined && !isPositiveInteger(model.contextWindow)) { throw new Error(`Provider ${id} model ${model.id} contextWindow must be a positive integer`); } if (model.maxTokens !== undefined && !isPositiveInteger(model.maxTokens)) { throw new Error(`Provider ${id} model ${model.id} maxTokens must be a positive integer`); } if (model.reasoning !== undefined && typeof model.reasoning !== "boolean") { throw new Error(`Provider ${id} model ${model.id} reasoning must be a boolean`); } } } result[id] = entry; } return result; } function modelPairsFromProviderEntries( providers: Record>, ): ConfiguredModelPair[] { const pairs: ConfiguredModelPair[] = []; for (const [provider, entry] of Object.entries(providers)) { if (!Array.isArray(entry.models)) continue; for (const model of entry.models) { if (!isRecord(model) || typeof model.id !== "string") continue; pairs.push({ provider, modelId: model.id, encodedKey: modelThinkingKey(provider, model.id), officialKey: legacyModelThinkingKey(provider, model.id), }); } } return pairs; } /** Replace imported Providers' defaults in the official store and keep the wire field backward-compatible. */ async function applyImportedDefaults( cwd: string, imported: Record>, removedModels: Array<[string, string]>, incomingDefaults: unknown, modelsPath: string, defaultsPath: string, settingsPath: string, ): Promise { const importedIds = Object.keys(imported); const importedPairs = modelPairsFromProviderEntries(imported); const allPairs = [ ...await configuredModelPairs(modelsPath), ...removedModels.map(([provider, modelId]) => ({ provider, modelId, encodedKey: modelThinkingKey(provider, modelId), officialKey: legacyModelThinkingKey(provider, modelId), })), ]; const byEncoded = new Map(); const byOfficial = new Map(); for (const pair of allPairs) { byEncoded.set(pair.encodedKey, uniquePairs([...(byEncoded.get(pair.encodedKey) ?? []), pair])); byOfficial.set(pair.officialKey, uniquePairs([...(byOfficial.get(pair.officialKey) ?? []), pair])); } const manager = modelThinkingSettingsManager(cwd, settingsPath); const importedIdentities = new Set(importedPairs.map((pair) => `${pair.provider}\0${pair.modelId}`)); for (const pair of uniquePairs([...importedPairs, ...removedModels.map(([provider, modelId]) => ({ provider, modelId, encodedKey: modelThinkingKey(provider, modelId), officialKey: legacyModelThinkingKey(provider, modelId), }))])) { if ((byOfficial.get(pair.officialKey)?.length ?? 0) === 1) { manager.removeModelThinkingLevel(pair.provider, pair.modelId); } } const retainedUnsafe: Record = {}; const incoming = isRecord(incomingDefaults) ? incomingDefaults : {}; for (const [key, value] of Object.entries(incoming)) { if (!isThinkingLevel(value)) continue; const candidates = uniquePairs([ ...(byEncoded.get(key) ?? []), ...(byOfficial.get(key) ?? []), ]).filter((pair) => importedIdentities.has(`${pair.provider}\0${pair.modelId}`)); if (candidates.length !== 1 || (byOfficial.get(candidates[0]?.officialKey ?? "")?.length ?? 0) !== 1) { if (candidates.length > 0) retainedUnsafe[key] = value; continue; } manager.setModelThinkingLevel(candidates[0].provider, candidates[0].modelId, value); } await flushThinkingSettings(manager, "Unable to import model thinking defaults"); await serializeMutation(defaultsPath, async () => { const exists = await fileExists(defaultsPath); const root = await readModelsRoot(defaultsPath); const legacyDefaults = isRecord(root.modelDefaults) ? { ...root.modelDefaults } : {}; for (const pair of importedPairs) delete legacyDefaults[pair.encodedKey]; Object.assign(legacyDefaults, retainedUnsafe); const managed = managedProviderIds(root); for (const id of importedIds) { if (!findPreset(id) && !managed.includes(id)) managed.push(id); } const next: Record = { ...withoutLegacyManagedChannels(root), version: 1, managedProviders: managed, }; if (Object.keys(legacyDefaults).length > 0) next.modelDefaults = legacyDefaults; else delete next.modelDefaults; await writeModelsRoot(next, defaultsPath, exists); }); }