From c583f696664e486df47ead72a2376929297995cb Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 29 Jun 2026 20:24:27 +0800 Subject: [PATCH] Refactor router configuration and provider handling --- README.md | 2 +- README_zh.md | 2 +- build/esbuild.config.mjs | 62 +++- .../en/configuration/provider-deeplink.md | 30 +- .../zh/configuration/provider-deeplink.md | 30 +- docs/src/styles/global.css | 6 + package-lock.json | 8 +- package.json | 2 +- src/main/claude-app-gateway-service.ts | 28 +- src/main/cli.ts | 3 +- src/main/presets/index.ts | 2 + src/main/presets/kimi-coding/index.ts | 39 +++ src/main/presets/moonshot/index.ts | 6 +- src/main/profile-launch-service.ts | 4 +- src/main/profile-service.ts | 319 +++++++++++++++++- src/main/provider-account-service.ts | 251 +++++++++++++- src/main/provider-model-catalog.ts | 48 +++ src/main/provider-probe.ts | 60 +++- src/main/zcode-profile-config.ts | 18 + src/renderer/pages/home/App.tsx | 259 ++++++-------- .../pages/home/components/dashboard.tsx | 6 +- src/renderer/pages/home/components/layout.tsx | 46 +-- .../pages/home/components/providers.tsx | 220 +++++++++--- src/renderer/pages/home/components/update.tsx | 6 +- src/renderer/pages/home/shared/external.tsx | 10 +- src/renderer/pages/home/shared/i18n.tsx | 5 + src/renderer/pages/home/shared/options.ts | 1 + src/renderer/pages/home/shared/profiles.ts | 2 +- src/renderer/pages/home/shared/providers.ts | 98 +++++- src/renderer/pages/home/shared/types.ts | 1 + src/server/gateway/service.ts | 130 ++++++- src/server/mcp/fusion-tool-fallback-mcp.ts | 235 +++++++++++++ src/server/mcp/fusion-vision-mcp.ts | 5 +- src/shared/app.ts | 85 +++++ src/shared/i18n.ts | 1 + src/shared/provider-preset-utils.ts | 7 + src/shared/provider-presets.ts | 2 + 37 files changed, 1738 insertions(+), 301 deletions(-) create mode 100644 src/main/presets/kimi-coding/index.ts create mode 100644 src/server/mcp/fusion-tool-fallback-mcp.ts diff --git a/README.md b/README.md index 1e4bde0e..941b7ad5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ CCR runs on your machine, keeps provider configuration in your local config dire - Use one local endpoint for multiple agent tools instead of configuring every client separately. - Route different workloads to different models, such as fast background work, reasoning tasks, long-context requests, image tasks, or web-search-capable models. -- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Mistral, Z.AI, Bailian, and custom providers. +- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. - Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. - Manage everything from a desktop UI instead of editing JSON by hand. - Extend the gateway with plugins, proxy routes, local HTTP backends, and provider deeplinks. diff --git a/README_zh.md b/README_zh.md index 7c94376d..b450f389 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,7 +19,7 @@ CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默 - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 - 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。 -- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Mistral、Z.AI、百炼以及自定义 Provider。 +- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。 - 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。 - 使用桌面 UI 管理配置,减少手写 JSON。 - 通过插件、代理路由、本地 HTTP 后端和 Provider deeplink 扩展网关能力。 diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 9fc975f1..9bdaba7b 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -27,6 +27,15 @@ export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); +const lightweightMcpBundleNames = ["fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; +const lightweightMcpBundleMaxBytes = 128 * 1024; +const forbiddenLightweightMcpInputs = [ + { prefix: "src/main/", reason: "main-process modules can pull in config, Electron, or native storage side effects" }, + { prefix: "src/renderer/", reason: "renderer modules do not belong in stdio MCP subprocesses" }, + { prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" }, + { prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" } +]; +const forbiddenLightweightMcpExternalImports = new Set(["better-sqlite3", "electron"]); const nodeExternals = [ "electron", @@ -112,12 +121,14 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { path.join(projectRoot, "src", "main", "main.ts"), path.join(projectRoot, "src", "main", "browser-preload.ts"), path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"), + path.join(projectRoot, "src", "server", "mcp", "fusion-tool-fallback-mcp.ts"), path.join(projectRoot, "src", "main", "preload.ts") ], external: nodeExternals, format: "cjs", legalComments: "none", logLevel: "info", + metafile: true, minify: mode === "production", outdir: mainOutDir, platform: "node", @@ -225,10 +236,11 @@ export function watchPlugin(name, onEnd) { } export async function buildMain(options = {}) { - await Promise.all([ + const [mainBuildResult] = await Promise.all([ esbuild.build(createMainBuildOptions(options)), esbuild.build(createCliBuildOptions(options)) ]); + validateLightweightMcpBundles(mainBuildResult.metafile); } export async function buildRenderer(options = {}) { @@ -309,6 +321,54 @@ function forbidCliElectronPlugin() { }; } +function validateLightweightMcpBundles(metafile) { + if (!metafile) { + return; + } + + const outputsByName = new Map( + Object.entries(metafile.outputs).map(([outputPath, output]) => [path.basename(outputPath), { output, outputPath }]) + ); + + for (const bundleName of lightweightMcpBundleNames) { + const entry = outputsByName.get(bundleName); + if (!entry) { + continue; + } + + const violations = []; + if (entry.output.bytes > lightweightMcpBundleMaxBytes) { + violations.push(`bundle size ${entry.output.bytes} bytes exceeds ${lightweightMcpBundleMaxBytes} bytes`); + } + + for (const inputPath of Object.keys(entry.output.inputs ?? {})) { + const normalizedInput = normalizeBuildPath(inputPath); + for (const rule of forbiddenLightweightMcpInputs) { + if (normalizedInput.startsWith(rule.prefix)) { + violations.push(`${normalizedInput} (${rule.reason})`); + } + } + } + + for (const imported of entry.output.imports ?? []) { + if (imported.external && forbiddenLightweightMcpExternalImports.has(imported.path)) { + violations.push(`${imported.path} (external native/runtime dependency is not allowed)`); + } + } + + if (violations.length > 0) { + throw new Error([ + `Lightweight MCP bundle ${bundleName} crossed its dependency boundary.`, + ...violations.map((violation) => `- ${violation}`) + ].join("\n")); + } + } +} + +function normalizeBuildPath(value) { + return value.split(path.sep).join("/"); +} + function resolveRendererImport(importPath) { const basePath = path.resolve(rendererRoot, importPath); const candidates = [ diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md index 449133cb..f4a38181 100644 --- a/docs/src/content/docs/en/configuration/provider-deeplink.md +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -10,55 +10,59 @@ lead: Use ccr://provider links to hand provider configuration to CCR, or open th The buttons below open the CCR desktop app's provider import confirmation dialog. Preset buttons do not include API keys; custom provider links may include one. Always confirm the provider name, Base URL, protocol, and models before importing a key.
- + OpenAIResponses / Chat Completions - + AnthropicAnthropic Messages - + Google GeminiGemini Generate Content - + OpenRouterOpenAI compatible gateway - + DeepSeekChat Completions - + Zhipu CodingChina Coding Plan - + Zhipu GeneralChina General Endpoint - + Z.ai CodingGlobal Coding Plan - + Z.ai GeneralGlobal General Endpoint - + MistralChat Completions - + Moonshot KimiChat Completions - + + + Kimi CodeCoding Plan + + Alibaba BailianDashScope compatible - + SiliconFlowChat Completions diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index b0e2f37b..d51e045c 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -10,55 +10,59 @@ lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key;自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型。
- + OpenAI 官方Responses / Chat Completions - + Anthropic 官方Anthropic Messages - + 谷歌 GeminiGemini Generate Content - + OpenRouter 路由OpenAI compatible gateway - + DeepSeek 深度求索Chat Completions - + 智谱 Coding中国 Coding 计划 - + 智谱通用中国通用端点 - + 智谱国际 Coding全球 Coding 计划 - + 智谱国际通用全球通用端点 - + Mistral 官方Chat Completions - + 月之暗面 KimiChat Completions - + + + Kimi CodeCoding Plan + + 阿里百炼DashScope 兼容 - + 硅基流动Chat Completions diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index ab21f4ee..ff83d591 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -1322,6 +1322,12 @@ h1 { --provider-brand-3: #e4ddff; } +.doc-markdown a.provider-import-button.provider-kimi-coding { + --provider-brand: #111237; + --provider-brand-2: #5c6bff; + --provider-brand-3: #dfe4ff; +} + .doc-markdown a.provider-import-button.provider-bailian { --provider-brand: #5a2300; --provider-brand-2: #ff6a00; diff --git a/package-lock.json b/package-lock.json index f3783f20..3208688e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.2", "license": "MIT", "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.1", + "@the-next-ai/ai-gateway": "^1.0.2", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", @@ -2066,9 +2066,9 @@ } }, "node_modules/@the-next-ai/ai-gateway": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.1.tgz", - "integrity": "sha512-o752COZV+AV1NiaO9TO7myr3SoeIHL5Cb9ZpPi74vPm1Eo3gP0oSxOzd48WbTQFN5ZP0YPmsMCzEBjqkyTNa9g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.2.tgz", + "integrity": "sha512-K0UiXwyC1i6dpPtQWDndJ1SgwI+zmfGpwqFUWhnVico4dxCt9eph5d9mt0RHcEduZOv4MRBdWDHyHMpxDgSUCw==", "license": "MIT", "dependencies": { "diff": "^8.0.3", diff --git a/package.json b/package.json index 8cc04161..ab86d4fd 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.1", + "@the-next-ai/ai-gateway": "^1.0.2", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts index 3a566522..89d1e35a 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/src/main/claude-app-gateway-service.ts @@ -10,7 +10,7 @@ import { type ClaudeAppGatewayInferenceModel, type ClaudeAppGatewayModelRouteOptions } from "../shared/claude-app-gateway"; -import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app"; +import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ClaudeAppGatewayApplyResult } from "../shared/app"; import { findModelCatalogEntry } from "../server/gateway/model-catalog"; const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311"; @@ -77,6 +77,14 @@ export type ClaudeAppGatewaySyncResult = { }; export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise { + if (!hasAvailableGatewayModels(config)) { + return { + config, + configChanged: false, + result: skippedClaudeAppGatewayResult(config) + }; + } + const applied = applyClaudeAppGatewayConfig(config); if (applied.config === config) { return { @@ -93,7 +101,25 @@ export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise { const configDir = CONFIGDIR; const config = await loadAppConfig(); + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, profileOptions.profileRef); const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli"); diff --git a/src/main/presets/index.ts b/src/main/presets/index.ts index 424f9412..2c48c596 100644 --- a/src/main/presets/index.ts +++ b/src/main/presets/index.ts @@ -2,6 +2,7 @@ import { anthropicProviderPreset } from "./anthropic"; import { bailianProviderPreset } from "./bailian"; import { deepSeekProviderPreset } from "./deepseek"; import { geminiProviderPreset } from "./gemini"; +import { kimiCodingProviderPreset } from "./kimi-coding"; import { mistralProviderPreset } from "./mistral"; import { moonshotProviderPreset } from "./moonshot"; import { openaiProviderPreset } from "./openai"; @@ -28,6 +29,7 @@ export const providerPresets: ProviderPreset[] = [ geminiProviderPreset, openRouterProviderPreset, deepSeekProviderPreset, + kimiCodingProviderPreset, zhipuCnCodingProviderPreset, zhipuCnGeneralProviderPreset, zaiGlobalCodingProviderPreset, diff --git a/src/main/presets/kimi-coding/index.ts b/src/main/presets/kimi-coding/index.ts new file mode 100644 index 00000000..9ff6dd89 --- /dev/null +++ b/src/main/presets/kimi-coding/index.ts @@ -0,0 +1,39 @@ +import type { ProviderAccountConfig } from "../../../shared/app"; +import type { ProviderPreset } from "../../../shared/provider-presets"; + +const kimiCodingProviderAccountConfig: ProviderAccountConfig = { + connectors: [ + { + auth: "provider-api-key", + endpoint: "https://api.kimi.com/coding/v1/usages", + mapping: { + meters: [] + }, + parser: "kimi-code-usages", + type: "http-json" + } + ], + enabled: true +}; + +export const kimiCodingProviderPreset: ProviderPreset = { + account: kimiCodingProviderAccountConfig, + aliases: ["kimi code", "kimi coding", "kimi coding plan", "kimi-for-coding"], + defaultModelDisplayNames: { + "kimi-for-coding": "K2.7 Code" + }, + defaultModels: ["kimi-for-coding"], + endpoints: [ + { + baseUrl: "https://api.kimi.com/coding/v1", + protocols: ["openai_chat_completions"] + }, + { + baseUrl: "https://api.kimi.com/coding/", + protocols: ["anthropic_messages"] + } + ], + id: "kimi-coding", + name: "Kimi Code - Coding Plan", + websiteUrl: "https://www.kimi.com/code/docs/" +}; diff --git a/src/main/presets/moonshot/index.ts b/src/main/presets/moonshot/index.ts index ea1e75af..c7c9125b 100644 --- a/src/main/presets/moonshot/index.ts +++ b/src/main/presets/moonshot/index.ts @@ -5,7 +5,7 @@ const moonshotProviderAccountConfig: ProviderAccountConfig = { connectors: [ { auth: "provider-api-key", - endpoint: "https://api.moonshot.cn/v1/users/me/balance", + endpoint: "https://api.moonshot.ai/v1/users/me/balance", mapping: { meters: [ { @@ -42,6 +42,10 @@ export const moonshotProviderPreset: ProviderPreset = { aliases: ["kimi", "moonshot"], defaultModels: ["moonshot-v1-8k"], endpoints: [ + { + baseUrl: "https://api.moonshot.ai/v1", + protocols: ["openai_chat_completions"] + }, { baseUrl: "https://api.moonshot.cn/v1", protocols: ["openai_chat_completions"] diff --git a/src/main/profile-launch-service.ts b/src/main/profile-launch-service.ts index ed18cdf4..16b2b486 100644 --- a/src/main/profile-launch-service.ts +++ b/src/main/profile-launch-service.ts @@ -2,7 +2,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult, ProfileRuntimeEntry, ProfileRuntimeStatus, ProfileStopResult } from "../shared/app"; +import { assertAvailableGatewayModels, type AppConfig, type ProfileOpenCommandResult, type ProfileOpenRequest, type ProfileOpenResult, type ProfileRuntimeEntry, type ProfileRuntimeStatus, type ProfileStopResult } from "../shared/app"; import { botGatewayProfileEnv } from "./bot-gateway-env"; import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "./claude-app-gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; @@ -41,6 +41,7 @@ type RunningProfileApp = ProfileRuntimeEntry & { process.once("exit", () => stopClaudeAppBotWorker()); export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise { + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); @@ -54,6 +55,7 @@ export async function getProfileOpenCommand(config: AppConfig, request: ProfileO } export async function openProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise { + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index 3a4521b3..32087bda 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -1,8 +1,8 @@ import { randomBytes } from "node:crypto"; -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, enforceSingleEnabledGlobalProfilePerAgent, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "../shared/app"; +import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "../shared/app"; import { replacePersistedApiKeys } from "./api-key-store"; import { botGatewayProfileEnv } from "./bot-gateway-env"; import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; @@ -15,6 +15,8 @@ const managedRootStart = "# BEGIN CCR managed profile"; const managedRootEnd = "# END CCR managed profile"; const managedProviderStart = "# BEGIN CCR managed Codex provider"; const managedProviderEnd = "# END CCR managed Codex provider"; +const originalBackupSuffix = ".ccr-original"; +const originalMissingSuffix = ".ccr-original-missing"; const fallbackClientToken = "ccr-local"; const privateDirMode = 0o700; const privateExecutableMode = 0o700; @@ -24,13 +26,28 @@ const publicExecutableMode = 0o755; export async function applyProfileConfig(config: AppConfig): Promise { const appliedAt = new Date().toISOString(); const profiles = profileEntries(config); - const profileApiKeys = await ensureProfileApiKeys(config, profiles); const result: ProfileApplyResult = { appliedAt, clients: [], enabled: profiles.some((profile) => profile.enabled) }; + if (!result.enabled) { + result.clients = profiles.map(disabledProfileStatus); + return result; + } + + if (!hasAvailableGatewayModels(config)) { + result.clients = profiles.map((profile) => + profile.enabled + ? unavailableModelStatus(profile, profilePath(profile)) + : disabledProfileStatus(profile) + ); + return result; + } + + const profileApiKeys = await ensureProfileApiKeys(config, profiles); + for (const profile of profiles) { const token = profileApiKeys.get(profile.id) ?? fallbackClientToken; result.clients.push( @@ -56,7 +73,7 @@ export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileCon function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const settingsFile = resolveClaudeCodeSettingsFile(profile); if (!profile.enabled) { - return disabledStatus("claude-code", settingsFile, "Claude Code profile is disabled."); + return restoreDisabledGlobalProfile(profile, settingsFile, "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent); } try { @@ -117,7 +134,12 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str const clientName = codexCompatibleClientName(profile.agent); const configFile = resolveCodexConfigFile(profile); if (!profile.enabled) { - return disabledStatus(profile.agent, configFile, `${clientName} profile is disabled.`); + return restoreDisabledGlobalProfile( + profile, + configFile, + `${clientName} profile is disabled.`, + (content) => isManagedCodexConfigContent(content, sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router") + ); } try { @@ -187,7 +209,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str function applyZcodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const configFile = resolveZcodeConfigFile(profile); if (!profile.enabled) { - return disabledStatus("zcode", configFile, "ZCode profile is disabled."); + return restoreDisabledZcodeProfile(profile, configFile); } try { @@ -942,6 +964,7 @@ function writeFileWithBackup( chmodFileIfRequested(file, options.mode); return { changed: false }; } + ensureOriginalSnapshot(file, previous, options.mode); const backupFile = previous === undefined ? undefined : backupFilePath(file); if (backupFile) { copyFileSync(file, backupFile); @@ -952,6 +975,200 @@ function writeFileWithBackup( return { backupFile, changed: true }; } +type RestoreFileResult = { + backupFile?: string; + changed: boolean; + file: string; + missingBackup: boolean; + restored: boolean; +}; + +function restoreDisabledGlobalProfile( + profile: ProfileConfig, + file: string, + disabledMessage: string, + isManagedContent: (content: string) => boolean +): ProfileClientApplyStatus { + if (!isGlobalProfile(profile)) { + return disabledStatus(profile.agent, file, disabledMessage); + } + + const restoreResult = restoreGlobalConfigFile(file, { isManagedContent, mode: privateFileMode }); + return disabledRestoreStatus(profile.agent, file, disabledMessage, restoreResult, profile.name || profile.id || profile.agent); +} + +function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus { + if (profile.agent === "claude-code") { + return restoreDisabledGlobalProfile(profile, resolveClaudeCodeSettingsFile(profile), "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent); + } + if (profile.agent === "zcode") { + return restoreDisabledZcodeProfile(profile, resolveZcodeConfigFile(profile)); + } + const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + return restoreDisabledGlobalProfile( + profile, + resolveCodexConfigFile(profile), + "Codex profile is disabled.", + (content) => isManagedCodexConfigContent(content, providerId) + ); +} + +function restoreDisabledZcodeProfile(profile: ProfileConfig, configFile: string): ProfileClientApplyStatus { + const disabledMessage = "ZCode profile is disabled."; + if (!isGlobalProfile(profile)) { + return disabledStatus("zcode", configFile, disabledMessage); + } + + const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + const storageRoot = zcodeHomeFromConfigFile(configFile); + const files = [ + configFile, + path.join(storageRoot, "v2", "config.json"), + path.join(storageRoot, "v2", "bots-model-cache.v2.json") + ]; + const results = files.map((file) => + restoreGlobalConfigFile(file, { + isManagedContent: (content) => isManagedZcodeConfigContent(content, providerId), + mode: privateFileMode + }) + ); + const changed = results.some((result) => result.changed); + const restored = results.some((result) => result.restored); + const missingBackup = results.some((result) => result.missingBackup); + return { + backupFile: results.find((result) => result.backupFile)?.backupFile, + client: "zcode", + enabled: false, + message: missingBackup + ? `${disabledMessage} No original ZCode config backup was found for ${profile.name || profile.id || "this profile"}.` + : restored + ? changed + ? "ZCode config was restored from the CCR backup because the global profile is disabled." + : "ZCode config already matches the CCR backup; profile is disabled." + : disabledMessage, + ok: !missingBackup, + path: resolveUserPath(configFile) + }; +} + +function disabledRestoreStatus( + client: ProfileClientKind, + file: string, + disabledMessage: string, + restoreResult: RestoreFileResult, + profileName: string +): ProfileClientApplyStatus { + return { + backupFile: restoreResult.backupFile, + client, + enabled: false, + message: restoreResult.missingBackup + ? `${disabledMessage} No original ${codexCompatibleClientName(client)} config backup was found for ${profileName}.` + : restoreResult.restored + ? restoreResult.changed + ? `${codexCompatibleClientName(client)} config was restored from the CCR backup because the global profile is disabled.` + : `${codexCompatibleClientName(client)} config already matches the CCR backup; profile is disabled.` + : disabledMessage, + ok: !restoreResult.missingBackup, + path: resolveUserPath(file) + }; +} + +function restoreGlobalConfigFile( + file: string, + options: { + isManagedContent: (content: string) => boolean; + mode?: number; + } +): RestoreFileResult { + const current = existsSync(file) ? readFileSync(file, "utf8") : undefined; + const currentManaged = current !== undefined && options.isManagedContent(current); + if (current !== undefined && !currentManaged) { + return { changed: false, file, missingBackup: false, restored: false }; + } + + if (existsSync(originalMissingFilePath(file))) { + if (currentManaged) { + const backupFile = backupCurrentConfigFile(file, options.mode); + rmSync(file, { force: true }); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; + } + return { changed: false, file, missingBackup: false, restored: current === undefined }; + } + + const snapshot = originalSnapshotCandidate(file, options.isManagedContent); + if (!snapshot) { + return { + changed: false, + file, + missingBackup: Boolean(currentManaged), + restored: false + }; + } + + if (current === snapshot.content) { + chmodFileIfRequested(file, options.mode); + return { changed: false, file, missingBackup: false, restored: true }; + } + + const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); + chmodFileIfRequested(file, options.mode); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; +} + +function originalSnapshotCandidate( + file: string, + isManagedContent: (content: string) => boolean +): { content: string; file: string } | undefined { + for (const candidate of [originalBackupFilePath(file), ...backupFiles(file)]) { + if (!existsSync(candidate)) { + continue; + } + const content = readFileSync(candidate, "utf8"); + if (!isManagedContent(content)) { + return { content, file: candidate }; + } + } + return undefined; +} + +function backupCurrentConfigFile(file: string, mode: number | undefined): string { + const backupFile = backupFilePath(file); + copyFileSync(file, backupFile); + chmodFileIfRequested(backupFile, mode); + return backupFile; +} + +function backupFiles(file: string): string[] { + const dir = path.dirname(file); + const prefix = `${path.basename(file)}.ccr-backup-`; + try { + return readdirSync(dir) + .filter((entry) => entry.startsWith(prefix)) + .sort() + .map((entry) => path.join(dir, entry)); + } catch { + return []; + } +} + +function ensureOriginalSnapshot(file: string, previous: string | undefined, mode: number | undefined): void { + const originalBackup = originalBackupFilePath(file); + const originalMissing = originalMissingFilePath(file); + if (existsSync(originalBackup) || existsSync(originalMissing)) { + return; + } + if (previous === undefined) { + writeFileSync(originalMissing, "", "utf8"); + chmodFileIfRequested(originalMissing, mode); + return; + } + copyFileSync(file, originalBackup); + chmodFileIfRequested(originalBackup, mode); +} + function chmodFileIfRequested(file: string, mode: number | undefined): void { if (mode === undefined || process.platform === "win32") { return; @@ -968,6 +1185,14 @@ function backupFilePath(file: string): string { return `${file}.ccr-backup-${timestamp}`; } +function originalBackupFilePath(file: string): string { + return `${file}${originalBackupSuffix}`; +} + +function originalMissingFilePath(file: string): string { + return `${file}${originalMissingSuffix}`; +} + function disabledStatus(client: ProfileClientKind, file: string, message: string): ProfileClientApplyStatus { return { client, @@ -978,6 +1203,81 @@ function disabledStatus(client: ProfileClientKind, file: string, message: string }; } +function unavailableModelStatus(profile: ProfileConfig, file: string): ProfileClientApplyStatus { + return { + client: profile.agent, + enabled: true, + message: NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, + ok: false, + path: resolveUserPath(file) + }; +} + +function disabledProfileMessage(profile: ProfileConfig): string { + if (profile.agent === "claude-code") { + return "Claude Code profile is disabled."; + } + return `${codexCompatibleClientName(profile.agent)} profile is disabled.`; +} + +function isGlobalProfile(profile: ProfileConfig): boolean { + return normalizeProfileScope(profile.scope) === "global"; +} + +function isManagedClaudeCodeSettingsContent(content: string): boolean { + const settings = parseJsonContent(content); + if (!settings) { + return false; + } + const apiKeyHelper = typeof settings.apiKeyHelper === "string" ? settings.apiKeyHelper : ""; + if (apiKeyHelper.includes("ccr-claude-code-api-key-")) { + return true; + } + const env = isRecord(settings.env) ? settings.env : {}; + return typeof env.ANTHROPIC_BASE_URL === "string" && + typeof env.ANTHROPIC_API_BASE_URL === "string" && + typeof env.CLAUDE_AGENT_API_BASE_URL === "string"; +} + +function isManagedCodexConfigContent(content: string, providerId: string): boolean { + if (content.includes(managedRootStart) || content.includes(managedProviderStart)) { + return true; + } + const escapedProvider = escapeRegExp(providerId); + return new RegExp(`^\\s*\\[model_providers\\.(?:${escapedProvider}|${escapeRegExp(tomlQuotedKey(providerId))})\\]`, "m").test(content); +} + +function isManagedZcodeConfigContent(content: string, providerId: string): boolean { + const config = parseJsonContent(content); + if (!config) { + return false; + } + if (isRecord(config.provider) && hasOwn(config.provider, providerId)) { + return true; + } + if (isRecord(config.model) && typeof config.model.main === "string" && config.model.main.startsWith(`${providerId}/`)) { + return true; + } + for (const key of ["defaultModel", "lastUsed", "lastUsedModel"]) { + const modelRef = config[key]; + if (isRecord(modelRef) && modelRef.providerId === providerId) { + return true; + } + } + return Array.isArray(config.providers) && config.providers.some((provider) => + isRecord(provider) && provider.id === providerId + ); +} + +function parseJsonContent(content: string): Record | undefined { + try { + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + function gatewayEndpoint(config: AppConfig): string { const host = config.gateway.host === "0.0.0.0" ? "127.0.0.1" : config.gateway.host || "127.0.0.1"; const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; @@ -1040,6 +1340,9 @@ function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli } function codexCompatibleClientName(agent: ProfileConfig["agent"]): string { + if (agent === "claude-code") { + return "Claude Code"; + } return agent === "zcode" ? "ZCode" : "Codex"; } @@ -1131,6 +1434,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/main/provider-account-service.ts b/src/main/provider-account-service.ts index 52b54787..68ba07e1 100644 --- a/src/main/provider-account-service.ts +++ b/src/main/provider-account-service.ts @@ -82,9 +82,13 @@ export async function getProviderAccountSnapshots( }); const snapshots = await Promise.all( - providers.flatMap((provider) => - providerAccountTargets(provider).map((target) => resolveProviderAccountSnapshot(config, target, options)) - ) + providers.flatMap((provider) => { + const targets = providerAccountTargets(provider); + if (targets.length > 0) { + return targets.map((target) => resolveProviderAccountSnapshot(config, target, options)); + } + return providerAccountUnavailableSnapshots(provider).map((snapshot) => Promise.resolve(snapshot)); + }) ); return snapshots.filter((snapshot): snapshot is ProviderAccountSnapshot => Boolean(snapshot)); } @@ -125,6 +129,17 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR type: "http-json" }; const payload = await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body); + if (connector.parser === "kimi-code-usages") { + const meters = kimiCodeUsageMeters(payload); + return { + meters, + message: meters.length === 0 ? "No usage data available." : undefined, + paths: flattenJsonPaths(payload), + payload, + status: statusFromMeters(meters, [], 1) + }; + } + const meters = connector.mapping.meters .map((meter) => mappedMeterFromPayload(meter, payload)) .filter((meter): meter is ProviderAccountMeter => Boolean(meter)); @@ -298,6 +313,50 @@ function providerAccountTargets(provider: GatewayProviderConfig): ProviderAccoun return providerAccount && providerApiKey(provider) ? [{ account: providerAccount, provider }] : []; } +function providerAccountUnavailableSnapshots(provider: GatewayProviderConfig): ProviderAccountSnapshot[] { + const credentials = activeProviderCredentials(provider); + if (credentials.length > 0) { + return credentials + .map((credential) => providerAccountUnavailableSnapshot(provider, credential.account ?? provider.account, credential)) + .filter((snapshot): snapshot is ProviderAccountSnapshot => Boolean(snapshot)); + } + + const snapshot = providerAccountUnavailableSnapshot(provider, provider.account); + return snapshot ? [snapshot] : []; +} + +function providerAccountUnavailableSnapshot( + provider: GatewayProviderConfig, + account: ProviderAccountConfig | undefined, + credential?: ProviderCredentialConfig +): ProviderAccountSnapshot | undefined { + const providerName = provider.name.trim(); + if (!providerName || !account?.enabled || !providerAccountConnectorsAreDefaultStandard(account.connectors ?? [])) { + return undefined; + } + if (effectiveProviderAccountConfig(provider, account)) { + return undefined; + } + + const message = "No supported account usage endpoint is available for this provider. Configure an HTTP JSON connector or disable account balance."; + return { + credentialId: credential ? providerCredentialRuntimeId(provider, credential) : undefined, + credentialLabel: credential?.name ?? credential?.label ?? credential?.id, + errors: [ + { + message, + source: "unsupported" + } + ], + message, + meters: [], + provider: providerName, + source: "unsupported", + status: "unsupported", + updatedAt: new Date().toISOString() + }; +} + function effectiveProviderAccount(provider: GatewayProviderConfig): ProviderAccountConfig | undefined { return effectiveProviderAccountConfig(provider, provider.account); } @@ -447,6 +506,16 @@ async function resolveHttpJsonConnector( ...(connector.headers ?? {}), ...(request.headers ?? {}) }, connector.method, connector.body); + if (connector.parser === "kimi-code-usages") { + const meters = kimiCodeUsageMeters(payload); + return { + errors: [], + message: meters.length === 0 ? "No usage data available." : undefined, + meters, + source: "http-json" + }; + } + const meters = connector.mapping.meters .map((meter) => mappedMeterFromPayload(meter, payload)) .filter((meter): meter is ProviderAccountMeter => Boolean(meter)); @@ -620,6 +689,172 @@ function normalizeRemoteSnapshot( }; } +function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] { + if (!isRecord(payload)) { + return []; + } + + const meters: ProviderAccountMeter[] = []; + const usage = isRecord(payload.usage) ? kimiCodeUsageMeter(payload.usage, "weekly_quota", "Weekly quota") : undefined; + if (usage) { + meters.push(usage); + } + + if (Array.isArray(payload.limits)) { + const seenIds = new Set(meters.map((meter) => meter.id)); + payload.limits.forEach((item, index) => { + if (!isRecord(item)) { + return; + } + const detail = isRecord(item.detail) ? item.detail : item; + const window = isRecord(item.window) ? item.window : {}; + const label = kimiCodeUsageLimitLabel(item, detail, window, index); + const meter = kimiCodeUsageMeter(detail, uniqueKimiCodeUsageMeterId(kimiCodeUsageMeterId(item, detail, label, index), seenIds), label, item); + if (meter) { + seenIds.add(meter.id); + meters.push(meter); + } + }); + } + + return meters; +} + +function kimiCodeUsageMeter( + data: Record, + id: string, + defaultLabel: string, + fallbackData?: Record +): ProviderAccountMeter | undefined { + const limit = normalizeNumber(data.limit); + let used = normalizeNumber(data.used); + let remaining = normalizeNumber(data.remaining); + if (used === undefined && remaining !== undefined && limit !== undefined) { + used = limit - remaining; + } + if (remaining === undefined && used !== undefined && limit !== undefined) { + remaining = limit - used; + } + if (limit === undefined && used === undefined && remaining === undefined) { + return undefined; + } + + const label = readString(data.name) || readString(data.title) || defaultLabel; + const resetAt = kimiCodeUsageResetAt(data) ?? (fallbackData ? kimiCodeUsageResetAt(fallbackData) : undefined); + if (limit !== undefined && limit > 0) { + const remainingRatio = remaining !== undefined + ? Math.max(0, Math.min(1, remaining / limit)) + : used !== undefined + ? Math.max(0, Math.min(1, (limit - used) / limit)) + : undefined; + const remainingPercent = remainingRatio === undefined ? undefined : remainingRatio * 100; + return { + id, + kind: "quota", + label, + limit: 100, + remaining: remainingPercent, + resetAt, + source: "http-json", + unit: "%", + used: remainingPercent === undefined ? undefined : 100 - remainingPercent + }; + } + + return { + id, + kind: "quota", + label, + limit, + remaining, + resetAt, + source: "http-json", + unit: "quota", + used + }; +} + +function kimiCodeUsageLimitLabel( + item: Record, + detail: Record, + window: Record, + index: number +): string { + const named = readString(item.name) || readString(detail.name) || readString(item.title) || readString(detail.title) || readString(item.scope) || readString(detail.scope); + if (named) { + return named; + } + + const duration = normalizeNumber(window.duration) ?? normalizeNumber(item.duration) ?? normalizeNumber(detail.duration); + const timeUnit = (readString(window.timeUnit) || readString(item.timeUnit) || readString(detail.timeUnit) || "").toUpperCase(); + if (duration && duration > 0) { + if (timeUnit.includes("MINUTE")) { + return duration >= 60 && duration % 60 === 0 ? `${duration / 60}h quota` : `${duration}m quota`; + } + if (timeUnit.includes("HOUR")) { + return `${duration}h quota`; + } + if (timeUnit.includes("DAY")) { + return `${duration}d quota`; + } + return `${duration}s quota`; + } + + return `Limit #${index + 1}`; +} + +function kimiCodeUsageMeterId( + item: Record, + detail: Record, + label: string, + index: number +): string { + const explicit = readString(item.id) || readString(detail.id) || readString(item.name) || readString(detail.name) || readString(item.scope) || readString(detail.scope); + return providerCredentialSlug(explicit || label || `limit-${index + 1}`) || `limit-${index + 1}`; +} + +function uniqueKimiCodeUsageMeterId(id: string, seenIds: Set): string { + if (!seenIds.has(id)) { + return id; + } + let index = 2; + while (seenIds.has(`${id}-${index}`)) { + index += 1; + } + return `${id}-${index}`; +} + +function kimiCodeUsageResetAt(data: Record): string | undefined { + for (const key of ["reset_at", "resetAt", "reset_time", "resetTime"]) { + const value = data[key]; + const timestamp = typeof value === "number" + ? value + : typeof value === "string" && /^\d+$/.test(value.trim()) + ? Number(value) + : undefined; + if (timestamp !== undefined && Number.isFinite(timestamp)) { + const milliseconds = timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); + } + + const dateString = readString(value); + if (dateString) { + const date = new Date(dateString); + return Number.isNaN(date.getTime()) ? dateString : date.toISOString(); + } + } + + for (const key of ["reset_in", "resetIn", "ttl", "window"]) { + const seconds = normalizeNumber(data[key]); + if (seconds && seconds > 0) { + return new Date(Date.now() + seconds * 1000).toISOString(); + } + } + + return undefined; +} + function normalizeRemoteErrors(value: unknown, source: ProviderAccountConnectorSource): ProviderAccountConnectorError[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -773,14 +1008,16 @@ function localAgentProviderPluginMatches(plugin: unknown, provider: GatewayProvi function localCodexAccountCredential(plugin: Record): { apiKey?: string; headers?: Record } { const codexOauth = isRecord(plugin.codexOauth) ? plugin.codexOauth : {}; const codexAuth = readCodexAuth(); + // Imported plugins contain a point-in-time access token. Prefer the live Codex + // auth file so account checks follow tokens refreshed by Codex CLI/App. const apiKey = + codexAuth?.accessToken || readString(codexOauth.accessToken) || - readString(codexOauth.access_token) || - codexAuth?.accessToken; + readString(codexOauth.access_token); const accountId = + codexAuth?.accountId || readString(codexOauth.accountId) || - readString(codexOauth.account_id) || - codexAuth?.accountId; + readString(codexOauth.account_id); const headers = { ...localProviderPluginAuthHeaders(plugin), ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), diff --git a/src/main/provider-model-catalog.ts b/src/main/provider-model-catalog.ts index 55cea18a..e9e465cc 100644 --- a/src/main/provider-model-catalog.ts +++ b/src/main/provider-model-catalog.ts @@ -35,6 +35,7 @@ const presetCatalogProviderIds: Record = { bailian: ["alibaba-cn"], deepseek: ["deepseek"], gemini: ["google"], + "kimi-coding": ["kimi-for-coding"], mistral: ["mistral"], moonshot: ["moonshotai-cn"], openai: ["openai"], @@ -46,10 +47,29 @@ const presetCatalogProviderIds: Record = { "zhipu-cn-general": ["zhipuai"] }; +const presetCatalogModelOverrides: Record; models: string[]; provider?: string; providerName?: string }> = { + "kimi-coding": { + modelDisplayNames: { + "kimi-for-coding": "K2.7 Code" + }, + models: ["kimi-for-coding"], + provider: "kimi-for-coding", + providerName: "Kimi Code" + } +}; + let catalogIndex: CatalogIndex | undefined; export function getProviderCatalogModels(request: ProviderCatalogModelsRequest): ProviderCatalogModelsResult { const index = loadCatalogIndex(); + const modelOverride = providerCatalogModelOverride(request); + if (modelOverride) { + return { + loadedFrom: index.loadedFrom, + ...modelOverride + }; + } + const match = findBestCatalogProviderMatch(index.providers, request); if (!match) { return { @@ -67,6 +87,34 @@ export function getProviderCatalogModels(request: ProviderCatalogModelsRequest): }; } +function providerCatalogModelOverride(request: ProviderCatalogModelsRequest): ProviderCatalogModelsResult | undefined { + const providerPresetId = request.providerPresetId?.trim() || ""; + const providerPresetOverride = presetCatalogModelOverrides[providerPresetId]; + if (providerPresetOverride) { + return { + matchedBy: "provider-id", + modelDisplayNames: providerPresetOverride.modelDisplayNames, + models: providerPresetOverride.models, + provider: providerPresetOverride.provider, + providerName: providerPresetOverride.providerName + }; + } + + const baseUrlPresetId = request.baseUrl ? findProviderPresetByBaseUrl(request.baseUrl)?.id ?? "" : ""; + const baseUrlOverride = presetCatalogModelOverrides[baseUrlPresetId]; + if (baseUrlOverride) { + return { + matchedBy: "base-url", + modelDisplayNames: baseUrlOverride.modelDisplayNames, + models: baseUrlOverride.models, + provider: baseUrlOverride.provider, + providerName: baseUrlOverride.providerName + }; + } + + return undefined; +} + function loadCatalogIndex(): CatalogIndex { if (catalogIndex) { return catalogIndex; diff --git a/src/main/provider-probe.ts b/src/main/provider-probe.ts index 9f3b5dc2..48032f66 100644 --- a/src/main/provider-probe.ts +++ b/src/main/provider-probe.ts @@ -34,12 +34,14 @@ type FetchJsonResult = { type ModelProbeResult = { baseUrl?: string; + modelDisplayNames?: Record; models: string[]; source?: ModelSource; }; type ModelFetchResult = { baseUrl?: string; + modelDisplayNames?: Record; models: string[]; }; @@ -225,6 +227,7 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) return { capabilities: capabilitiesFromProtocolResults(protocolResults), detectedProtocol, + modelDisplayNames: modelProbe.modelDisplayNames, modelSource: modelProbe.source, models: modelProbe.models, normalizedBaseUrl: detectedProtocol @@ -402,11 +405,11 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }, method: "GET" }); - const models = parseModelIds(result.payload, "openai"); - if (models.length > 0) { + const modelList = parseModelList(result.payload, "openai"); + if (modelList.models.length > 0) { return { baseUrl, - models + ...modelList }; } } @@ -424,11 +427,11 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }, method: "GET" }); - const models = parseModelIds(result.payload, "anthropic"); - if (models.length > 0) { + const modelList = parseModelList(result.payload, "anthropic"); + if (modelList.models.length > 0) { return { baseUrl, - models + ...modelList }; } } @@ -446,7 +449,7 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }); return { baseUrl: parsed.geminiBaseUrl, - models: parseModelIds(result.payload, "gemini") + ...parseModelList(result.payload, "gemini") }; } @@ -731,17 +734,41 @@ function geminiHeaders(apiKey: string | undefined): Record { : {}; } -function parseModelIds(payload: unknown, source: ModelSource): string[] { +function parseModelList(payload: unknown, source: ModelSource): Pick { if (!isRecord(payload)) { - return []; + return { + models: [] + }; } const items = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.models) ? payload.models : []; - const models = items - .map((item) => readModelId(item, source)) - .filter((item): item is string => Boolean(item)); + const models: string[] = []; + const modelDisplayNames: Record = {}; - return uniqueStrings(models); + for (const item of items) { + const model = readModelId(item, source); + if (!model) { + continue; + } + models.push(model); + + const displayName = readModelDisplayName(item); + if (displayName && displayName !== model) { + modelDisplayNames[model] = displayName; + } + } + + const uniqueModels = uniqueStrings(models); + const uniqueDisplayNames = Object.fromEntries( + uniqueModels + .map((model) => [model, modelDisplayNames[model]] as const) + .filter((entry): entry is [string, string] => Boolean(entry[1])) + ); + + return { + modelDisplayNames: Object.keys(uniqueDisplayNames).length > 0 ? uniqueDisplayNames : undefined, + models: uniqueModels + }; } function readModelId(value: unknown, source: ModelSource): string | undefined { @@ -771,6 +798,13 @@ function readModelId(value: unknown, source: ModelSource): string | undefined { return rawId; } +function readModelDisplayName(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + return readString(value.display_name) || readString(value.displayName) || readString(value.label); +} + function stripGeminiModelPrefix(value: string): string { return value.replace(/^models\//i, ""); } diff --git a/src/main/zcode-profile-config.ts b/src/main/zcode-profile-config.ts index 073eba29..8ac037c3 100644 --- a/src/main/zcode-profile-config.ts +++ b/src/main/zcode-profile-config.ts @@ -23,6 +23,8 @@ type ZcodeGatewayConfigValues = { const legacyZcodeTomlConfigFile = "~/.zcode/config.toml"; const defaultZcodeConfigFile = "~/.zcode/cli/config.json"; +const originalBackupSuffix = ".ccr-original"; +const originalMissingSuffix = ".ccr-original-missing"; export function resolveZcodeConfigFile(profile: Pick): string { const configured = profile.configFile?.trim(); @@ -227,6 +229,9 @@ function writeJsonFile(file: string, value: Record, options: { if (previous === content) { return { changed: false, file }; } + if (options.backup !== false) { + ensureOriginalSnapshot(file, previous); + } const backupFile = options.backup === false || previous === undefined ? undefined : backupFilePath(file); if (backupFile) { copyFileSync(file, backupFile); @@ -252,6 +257,19 @@ function backupFilePath(file: string): string { return `${file}.ccr-backup-${timestamp}`; } +function ensureOriginalSnapshot(file: string, previous: string | undefined): void { + const originalBackup = `${file}${originalBackupSuffix}`; + const originalMissing = `${file}${originalMissingSuffix}`; + if (existsSync(originalBackup) || existsSync(originalMissing)) { + return; + } + if (previous === undefined) { + writeFileSync(originalMissing, "", "utf8"); + return; + } + copyFileSync(file, originalBackup); +} + function isLegacyZcodeTomlConfigFile(value: string): boolean { return value === legacyZcodeTomlConfigFile || resolveUserPath(value) === path.join(os.homedir(), ".zcode", "config.toml"); diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index de581e43..6173bd2c 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -6,7 +6,7 @@ import { ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft, createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis, copyTextToClipboard, createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft, - createProfileDraftFromProfile, createProviderConfigFromDeepLink, createProviderDraft, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule, + createProfileDraftFromProfile, createProviderDraft, createProviderDraftFromDeepLinkPayload, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule, createVirtualModelDraft, createVirtualModelDraftFromProfile, customProviderPresetId, DEFAULT_TRAY_WIDGETS, detectSystemLanguage, detectSystemTheme, enforceSingleEnabledGlobalProfilePerAgent, ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig, @@ -17,7 +17,7 @@ import { isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, isTraySupportedPlatform, isRoutingRewriteDraftRowValid, - LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists, + LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, @@ -25,11 +25,10 @@ import { providerCredentialsFromDraft, persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, - providerDeepLinkDisplayIcon, - profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, + profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, - ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, resolveProviderDeepLinkIcon, RouterRule, ServerActionBusy, SettingsPageId, + ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, ServerActionBusy, SettingsPageId, routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect, useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, @@ -199,11 +198,11 @@ function App() { const [providerProbeLoading, setProviderProbeLoading] = useState(false); const [providerConnectivityProbe, setProviderConnectivityProbe] = useState(); const [providerConnectivityLoading, setProviderConnectivityLoading] = useState(false); + const [providerImportOpen, setProviderImportOpen] = useState(false); + const [providerImportPayload, setProviderImportPayload] = useState(); const [providerDeepLinkRequest, setProviderDeepLinkRequest] = useState(); const [providerDeepLinkBusy, setProviderDeepLinkBusy] = useState(false); const [providerDeepLinkError, setProviderDeepLinkError] = useState(""); - const [providerDeepLinkIconLoading, setProviderDeepLinkIconLoading] = useState(false); - const [providerDeepLinkModelsLoading, setProviderDeepLinkModelsLoading] = useState(false); const [proxyCertificateChecking, setProxyCertificateChecking] = useState(false); const [proxyEnablePending, setProxyEnablePending] = useState(false); const [providerProbeError, setProviderProbeError] = useState(""); @@ -339,7 +338,7 @@ function App() { }, []); useEffect(() => { - if (!updateDialogOpen || !window.ccr) { + if (!window.ccr) { return; } @@ -366,7 +365,7 @@ function App() { disposed = true; unsubscribe(); }; - }, [updateDialogOpen]); + }, [appInfo.version]); useEffect(() => { if (!window.ccr) { @@ -377,6 +376,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderAddOpen(false); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderEditIndex(undefined); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -386,7 +387,6 @@ function App() { setProviderDeepLinkRequest(request); setProviderDeepLinkError(""); setProviderDeepLinkBusy(false); - setProviderDeepLinkModelsLoading(false); setActiveView("providers"); }; @@ -405,89 +405,12 @@ function App() { }, []); useEffect(() => { - const request = providerDeepLinkRequest; - const payload = request?.provider; - providerDeepLinkIconRequestId.current += 1; - const requestId = providerDeepLinkIconRequestId.current; - - if (!request || !payload || !providerPresetsLoaded || providerDeepLinkDisplayIcon(payload)) { - setProviderDeepLinkIconLoading(false); + const payload = providerDeepLinkRequest?.provider; + if (!payload || !configLoaded || !providerPresetsLoaded) { return; } - - setProviderDeepLinkIconLoading(true); - void resolveProviderDeepLinkIcon(payload) - .then((resolution) => { - if (providerDeepLinkIconRequestId.current !== requestId || !resolution.persistentIcon) { - return; - } - setProviderDeepLinkRequest((current) => { - if (!current?.provider || current.id !== request.id) { - return current; - } - if (current.provider.icon?.trim()) { - return current; - } - return { - ...current, - provider: { - ...current.provider, - icon: resolution.persistentIcon - } - }; - }); - }) - .finally(() => { - if (providerDeepLinkIconRequestId.current === requestId) { - setProviderDeepLinkIconLoading(false); - } - }); - }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.icon, providerPresetsLoaded]); - - useEffect(() => { - const request = providerDeepLinkRequest; - const payload = request?.provider; - providerDeepLinkCatalogModelsRequestId.current += 1; - const requestId = providerDeepLinkCatalogModelsRequestId.current; - const hasApiKey = Boolean(payload?.apiKey?.trim()); - - if (!request || !payload || (!hasApiKey && payload.models.length > 0) || !providerPresetsLoaded) { - setProviderDeepLinkModelsLoading(false); - return; - } - - const modelsPromise = hasApiKey - ? probeProviderDeepLinkPayload(payload).then((probe) => mergeProviderModelLists(probe?.models ?? [])) - : resolveProviderDeepLinkCatalogModels(payload); - - setProviderDeepLinkModelsLoading(true); - void modelsPromise - .then((models) => { - if (providerDeepLinkCatalogModelsRequestId.current !== requestId || models.length === 0) { - return; - } - setProviderDeepLinkRequest((current) => { - if (!current?.provider || current.id !== request.id || (!hasApiKey && current.provider.models.length > 0)) { - return current; - } - return { - ...current, - provider: { - ...current.provider, - models - } - }; - }); - }) - .catch(() => { - // Model discovery is optional; importing performs the same resolution again. - }) - .finally(() => { - if (providerDeepLinkCatalogModelsRequestId.current === requestId) { - setProviderDeepLinkModelsLoading(false); - } - }); - }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.apiKey, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.name, providerPresetsLoaded]); + void openImportProviderDialog(payload); + }, [configLoaded, providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider, providerPresetsLoaded]); useEffect(() => { if (!window.ccr) { @@ -717,8 +640,6 @@ function App() { const onboardingProfileDraftSource = useRef(""); const providerProbeRequestId = useRef(0); const providerConnectivityRequestId = useRef(0); - const providerDeepLinkCatalogModelsRequestId = useRef(0); - const providerDeepLinkIconRequestId = useRef(0); const toastTimer = useRef(); const shouldReduceMotion = useReducedMotion(); @@ -887,6 +808,14 @@ function App() { void checkForAppUpdate(); } + function openUpdateDownloadDialog() { + setUpdateDialogOpen(true); + setUpdateActionError(""); + if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") { + void downloadAppUpdate(); + } + } + async function checkForAppUpdate() { if (updateActionBusyRef.current) { return; @@ -1097,6 +1026,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderDraft(createProviderDraft(draftConfig.Providers)); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -1117,6 +1048,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderEditIndex(index); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderDraft(createProviderDraftFromProvider(provider)); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -1126,6 +1059,63 @@ function App() { setProviderAddOpen(true); } + async function openImportProviderDialog(payload: ProviderDeepLinkPayload) { + if (!providerPresetsLoaded) { + return; + } + const requestId = providerProbeRequestId.current + 1; + providerProbeRequestId.current = requestId; + providerConnectivityRequestId.current += 1; + setProviderDeepLinkBusy(true); + let nextPayload = payload; + let catalogModelDisplayNames: Record | undefined; + let probe: GatewayProviderProbeResult | undefined; + if (nextPayload.models.length === 0) { + const catalogModels = await resolveProviderDeepLinkCatalogModels(nextPayload); + if (providerProbeRequestId.current !== requestId) { + setProviderDeepLinkBusy(false); + return; + } + catalogModelDisplayNames = catalogModels.modelDisplayNames; + if (catalogModels.models.length > 0) { + nextPayload = { + ...nextPayload, + models: catalogModels.models + }; + } + } + probe = await probeProviderDeepLinkPayload(nextPayload); + if (providerProbeRequestId.current !== requestId) { + setProviderDeepLinkBusy(false); + return; + } + if (nextPayload.apiKey?.trim() && probe?.models.length) { + nextPayload = { + ...nextPayload, + models: probe.models + }; + } + + const initialDraftFromPayload = createProviderDraftFromDeepLinkPayload(nextPayload, draftConfig.Providers); + const initialDraft = { + ...initialDraftFromPayload, + modelDisplayNames: mergeModelDisplayNames(initialDraftFromPayload.modelDisplayNames, catalogModelDisplayNames) + }; + setProviderEditIndex(undefined); + setProviderImportOpen(true); + setProviderImportPayload(nextPayload); + setProviderDraft(probe ? applyProviderProbeResult(initialDraft, probe) : initialDraft); + setProviderProbe(probe); + setProviderConnectivityProbe(undefined); + setProviderProbeError(""); + setProviderProbeLoading(false); + setProviderConnectivityLoading(false); + setProviderDeepLinkRequest(undefined); + setProviderDeepLinkError(""); + setProviderDeepLinkBusy(false); + setProviderAddOpen(true); + } + function updateProviderDraft(patch: Partial, resetProbe = false) { const shouldResetProtocolProbe = resetProbe && (patch.baseUrl !== undefined || patch.presetId !== undefined || patch.protocol !== undefined); const shouldResetConnectivityProbe = resetProbe || @@ -1148,6 +1138,7 @@ function App() { return { ...next, + modelDisplayNames: patch.modelDisplayNames, modelsText: mergeProviderModelLists(current.selectedModels, splitLines(next.modelsText)).join("\n"), selectedModels: [], selectedProtocols: patch.selectedProtocols ?? current.selectedProtocols @@ -1430,6 +1421,7 @@ function App() { type: protocol }; const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol); + const wasImport = providerImportOpen; const next = buildConfigUpdate((config) => { if (providerEditIndex === undefined) { @@ -1446,7 +1438,12 @@ function App() { setConfigDraft(next); if (await persistConfig(next, setProviderProbeError)) { setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderAddOpen(false); + if (wasImport) { + showToast(`${copy.text["Imported provider"] ?? "Imported provider"} ${provider.name}`.trim()); + } if (activeView === "onboarding") { setOnboardingStep(getDefaultOnboardingStep(next, onboardingReadiness)); } @@ -1461,10 +1458,15 @@ function App() { return; } + if (request.provider) { + await openImportProviderDialog(request.provider); + return; + } + setProviderDeepLinkBusy(true); setProviderDeepLinkError(""); try { - if (!request.provider && request.manifest) { + if (request.manifest) { if (!window.ccr?.fetchProviderManifest) { throw new Error("Request failed."); } @@ -1477,61 +1479,7 @@ function App() { return; } - let payload = request.provider; - if (!payload) { - setProviderDeepLinkBusy(false); - return; - } - const identityIssue = providerIdentitySafetyIssue({ - baseUrl: payload.baseUrl, - name: payload.name - }); - if (identityIssue) { - throw new Error(identityIssue.message); - } - const iconResolution = await resolveProviderDeepLinkIcon(payload); - if (iconResolution.persistentIcon && iconResolution.persistentIcon !== payload.icon?.trim()) { - payload = { - ...payload, - icon: iconResolution.persistentIcon - }; - } - if (payload.models.length === 0) { - const catalogModels = await resolveProviderDeepLinkCatalogModels(payload); - if (catalogModels.length > 0) { - payload = { - ...payload, - models: catalogModels - }; - } - } - const probe = await probeProviderDeepLinkPayload(payload); - if (payload.apiKey?.trim() && probe?.models.length) { - payload = { - ...payload, - models: probe.models - }; - } - let importedProviderName = payload.name?.trim() || ""; - const next = buildConfigUpdate((config) => { - const provider = createProviderConfigFromDeepLink(payload, config.Providers, probe); - importedProviderName = provider.name; - config.Providers.push(provider); - if (!config.preferredProvider) { - config.preferredProvider = provider.name; - } - return config; - }); - setConfigDraft(next); - const saved = await persistConfig(next, setProviderDeepLinkError); setProviderDeepLinkBusy(false); - if (saved) { - setProviderDeepLinkRequest(undefined); - showToast(`${copy.text["Imported provider"] ?? "Imported provider"} ${importedProviderName}`.trim()); - if (activeView === "onboarding") { - setOnboardingStep(getDefaultOnboardingStep(next, onboardingReadiness)); - } - } } catch (error) { setProviderDeepLinkError(formatError(error)); setProviderDeepLinkBusy(false); @@ -2802,7 +2750,7 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} - onCheckUpdate={openUpdateDialog} + onDownloadUpdate={openUpdateDownloadDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} @@ -2811,6 +2759,7 @@ function App() { sidebarOpen={sidebarOpen} toggleGatewayService={toggleGatewayService} updateActionBusy={Boolean(updateActionBusy)} + updateStatus={updateDialogStatus} visibleNavigation={visibleNavigation} viewProps={{ apiKeys: { @@ -3044,17 +2993,16 @@ function App() { onStopApp: () => void stopProfileApp(profileOpenDialog.profile), profile: profileOpenDialog.profile } : undefined} - providerDeepLink={providerDeepLinkRequest ? { + providerDeepLink={providerDeepLinkRequest && !providerDeepLinkRequest.provider ? { busy: providerDeepLinkBusy, error: providerDeepLinkError, - iconLoading: providerDeepLinkIconLoading, onClose: () => { if (!providerDeepLinkBusy) { setProviderDeepLinkRequest(undefined); } }, onSubmit: confirmProviderDeepLinkImport, - modelsLoading: providerDeepLinkModelsLoading, + presetsLoaded: providerPresetsLoaded, request: providerDeepLinkRequest } : undefined} providerDelete={providerDeleteItem ? { @@ -3068,18 +3016,23 @@ function App() { connectivityProbe: providerConnectivityProbe, draft: providerDraft, error: providerProbeError, + importProvider: providerImportOpen ? providerImportPayload : undefined, onChange: updateProviderDraft, mode: providerEditIndex === undefined ? "add" : "edit", onClose: () => { setProviderAddOpen(false); setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); }, onCheck: checkProviderDraft, onSubmit: submitProviderDraft, probe: providerProbe, probeLoading: providerProbeLoading, providerPlugins: draftConfig.providerPlugins ?? [], - providers: draftConfig.Providers + providers: draftConfig.Providers, + submitLabel: providerImportOpen ? t("Import") : undefined, + title: providerImportOpen ? t("Import Provider") : undefined } : undefined} routingDelete={routingDeleteRule ? { onClose: () => setRoutingDeleteIndex(undefined), diff --git a/src/renderer/pages/home/components/dashboard.tsx b/src/renderer/pages/home/components/dashboard.tsx index ec214d7e..ac4aec43 100644 --- a/src/renderer/pages/home/components/dashboard.tsx +++ b/src/renderer/pages/home/components/dashboard.tsx @@ -1930,7 +1930,7 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{meter ? t(meter.label) : account.source}
: null} + {providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null} @@ -1950,7 +1950,7 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{meter ? t(meter.label) : account.source}
: null} + {providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null}
{meter ? formatProviderAccountMeterValue(meter) : account.status}
@@ -1995,7 +1995,6 @@ function ProviderAccountSinglePanel({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{account.source}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null}
@@ -2039,7 +2038,6 @@ function ProviderAccountSummaryCard({
{providerAccountSnapshotLabel(account)}
- {providerAccountShowSource(dimensions) ?
{account.source}
: null}
{providerAccountShowStatus(dimensions) ? {account.status} : null}
diff --git a/src/renderer/pages/home/components/layout.tsx b/src/renderer/pages/home/components/layout.tsx index bbc3fead..2dfca057 100644 --- a/src/renderer/pages/home/components/layout.tsx +++ b/src/renderer/pages/home/components/layout.tsx @@ -1,9 +1,9 @@ import type { ComponentProps } from "react"; import { AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar, - GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, + AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, - reducedMotionTransition, RefreshCw, ServiceControlButton, Settings, ViewId, + reducedMotionTransition, ServiceControlButton, Settings, ViewId, ViewMotionShell, viewUsesInternalScroll } from "../shared"; import { ApiKeysView } from "./api-keys"; @@ -63,7 +63,7 @@ export function MainLayout({ needsTrafficLightSafeArea, agentAnalysisEnabled, networkCaptureEnabled, - onCheckUpdate, + onDownloadUpdate, onOpenSettings, onSelectNavigationItem, onToggleSidebar, @@ -71,6 +71,7 @@ export function MainLayout({ sidebarOpen, toggleGatewayService, updateActionBusy, + updateStatus, viewProps, requestLogsEnabled, visibleNavigation @@ -85,7 +86,7 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; - onCheckUpdate: () => void; + onDownloadUpdate: () => void; onOpenSettings: () => void; onSelectNavigationItem: (id: NavigationId) => void; onToggleSidebar: () => void; @@ -93,13 +94,20 @@ export function MainLayout({ sidebarOpen: boolean; toggleGatewayService: () => void; updateActionBusy: boolean; + updateStatus: AppUpdateStatus; viewProps: MainViewProps; requestLogsEnabled: boolean; visibleNavigation: MainNavigationItem[]; }) { const windowControlSafeAreaWidth = isMac ? 152 : 88; - const checkForUpdatesLabel = copy.text["Check for updates"] ?? "Check for updates"; - const checkingForUpdatesLabel = copy.text["Checking for updates"] ?? "Checking for updates"; + const showUpdateDownloadButton = + updateStatus.supported && + (updateStatus.state === "available" || updateStatus.state === "downloading" || updateStatus.state === "downloaded"); + const updateDownloadLabel = updateStatus.state === "downloaded" + ? copy.text["Update ready to install"] ?? "Update ready to install" + : updateStatus.state === "downloading" + ? copy.text["Downloading update"] ?? "Downloading update" + : copy.text["Download update"] ?? "Download update"; return ( <> @@ -124,6 +132,19 @@ export function MainLayout({ onClick={toggleGatewayService} state={gatewayStatus.state} /> + {showUpdateDownloadButton ? ( + + ) : null}
-
; @@ -349,9 +351,9 @@ function ProviderAccountListCell({ provider, snapshots }: { provider: GatewayPro {providerAccountSnapshotCredentialLabel(snapshot)}
) : null} - {!meter ? ( + {!meter && fallbackText ? (
- {snapshot.message ?? snapshot.errors?.[0]?.message ?? snapshot.source} + {t(fallbackText)}
) : null}
@@ -422,6 +424,7 @@ export function ProviderDeepLinkDialog({ modelsLoading = false, onClose, onSubmit, + presetsLoaded = true, request }: { busy: boolean; @@ -430,6 +433,7 @@ export function ProviderDeepLinkDialog({ modelsLoading?: boolean; onClose: () => void; onSubmit: () => Promise; + presetsLoaded?: boolean; request: ProviderDeepLinkRequest; }) { const t = useAppText(); @@ -437,6 +441,7 @@ export function ProviderDeepLinkDialog({ const manifest = request.manifest; const displayName = provider ? provider.name?.trim() || inferProviderNameFromBaseUrl(provider.baseUrl) : ""; const providerPreset = provider ? resolveProviderDeepLinkPreset(provider) : undefined; + const showExternalProviderWarnings = Boolean(provider && presetsLoaded && !providerPreset); const providerIconUrl = provider ? providerDeepLinkDisplayIcon(provider) : ""; const modelPreview = provider?.models.slice(0, 8) ?? []; const actionLoading = busy || Boolean(provider && (iconLoading || modelsLoading)); @@ -456,19 +461,23 @@ export function ProviderDeepLinkDialog({ {provider ? (
-
-
- - {t("External provider link")} + {showExternalProviderWarnings ? ( +
+
+ + {t("External provider link")} +
+
+ {t("This provider link came from an external website. Review details before importing.")} +
-
- {t("This provider link came from an external website. Review details before importing.")} + ) : null} + {showExternalProviderWarnings ? ( +
+ + {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")}
-
-
- - {t("Only enter an API key issued for this endpoint. Official provider keys must only be used with official endpoints.")} -
+ ) : null}
@@ -763,6 +772,89 @@ function ProviderPresetIcon({ className, iconUrl: explicitIconUrl, preset }: { c ); } +function ProviderImportHeader({ + draft, + provider, + preset +}: { + draft: AddProviderDraft; + provider: ProviderDeepLinkPayload; + preset?: ProviderPreset; +}) { + const t = useAppText(); + const baseUrl = draft.baseUrl.trim() || provider.baseUrl; + const displayName = draft.name.trim() || provider.name?.trim() || inferProviderNameFromBaseUrl(baseUrl); + const iconUrl = draft.icon.trim() || providerDeepLinkDisplayIcon(provider); + const platformUrl = providerImportPlatformUrl(provider, baseUrl, preset); + + function openPlatform() { + if (!platformUrl) { + return; + } + if (window.ccr?.openExternal) { + void window.ccr.openExternal(platformUrl).catch(() => undefined); + return; + } + window.open(platformUrl, "_blank", "noopener,noreferrer"); + } + + return ( +
+ +
+
{displayName}
+
{baseUrl}
+
+ {platformUrl ? ( + + ) : null} +
+ ); +} + +function providerImportPlatformUrl(provider: ProviderDeepLinkPayload, baseUrl: string, preset: ProviderPreset | undefined): string | undefined { + return normalizedHttpUrl(provider.source) ?? normalizedHttpUrl(preset?.websiteUrl) ?? providerBaseOrigin(baseUrl || provider.baseUrl); +} + +function providerBaseOrigin(value: string): string | undefined { + const url = normalizedHttpUrl(value); + if (!url) { + return undefined; + } + + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +function normalizedHttpUrl(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + + try { + const url = new URL(providerUrlWithDefaultScheme(trimmed)); + if (!["http:", "https:"].includes(url.protocol)) { + return undefined; + } + return url.toString(); + } catch { + return undefined; + } +} + function providerPresetOptionMatchesQuery( option: ProviderPresetComboboxOption, query: string @@ -828,7 +920,7 @@ function LocalAgentProviderImportPanel({ }; }, [mode, providerPlugins, providers]); - if (mode !== "add" || (!loading && candidates.length === 0 && !error)) { + if (mode !== "add" || (candidates.length === 0 && !error)) { return null; } @@ -984,6 +1076,7 @@ export function AddProviderForm({ error, connectivityLoading = false, connectivityProbe, + importProvider, mode, onCheck, onChange, @@ -997,6 +1090,7 @@ export function AddProviderForm({ connectivityProbe?: GatewayProviderProbeResult; draft: AddProviderDraft; error: string; + importProvider?: ProviderDeepLinkPayload; mode: "add" | "edit"; onCheck?: () => Promise; onChange: (patch: Partial, resetProbe?: boolean) => void; @@ -1014,6 +1108,7 @@ export function AddProviderForm({ const hasModelCatalog = Boolean(probe?.models.length); const selectedPreset = findProviderPreset(draft.presetId); const customEndpoint = draft.presetId === customProviderPresetId; + const importMode = Boolean(importProvider); const showBaseUrl = customEndpoint || mode === "edit"; const detectedProtocol = probe?.detectedProtocol ?? draft.protocol; const detectedBaseUrl = probe?.normalizedBaseUrl || draft.baseUrl; @@ -1083,6 +1178,7 @@ export function AddProviderForm({ ...createDefaultProviderAccountDraft(), baseUrl: "", icon: "", + modelDisplayNames: undefined, modelSearch: "", presetId, providerPlugins: [], @@ -1097,6 +1193,7 @@ export function AddProviderForm({ ...createDefaultProviderAccountDraft(), baseUrl: "", icon: "", + modelDisplayNames: undefined, modelSearch: "", presetId, providerPlugins: [], @@ -1114,6 +1211,7 @@ export function AddProviderForm({ ...accountDraft, baseUrl: endpoint?.baseUrl ?? "", icon: "", + modelDisplayNames: preset?.defaultModelDisplayNames, modelSearch: "", modelsText: draft.modelsText.trim() || preset?.defaultModels?.join("\n") || "", name: mode === "add" && preset && generatedName ? uniqueProviderName(providers, t(preset.name)) : draft.name, @@ -1128,19 +1226,25 @@ export function AddProviderForm({ return ( <>
- - - - + {importProvider ? ( + + ) : ( + <> + + + + + + )} onChange({ name: event.target.value })} /> @@ -1172,7 +1276,7 @@ export function AddProviderForm({ {safetyIssue.message}
) : null} - {selectedPreset && !showBaseUrl ? ( + {selectedPreset && !showBaseUrl && !importMode ? (
@@ -1184,6 +1288,7 @@ export function AddProviderForm({ {hasModelCatalog && probe ? (
onChange({ modelSearch })} onSelectedChange={(selectedModels) => onChange({ selectedModels })} @@ -1197,6 +1302,7 @@ export function AddProviderForm({
onChange({ modelsText: models.join("\n") })} placeholder={t("Model name")} value={splitLines(draft.modelsText)} @@ -1206,6 +1312,7 @@ export function AddProviderForm({ ) : ( onChange({ modelsText: models.join("\n") }, true)} placeholder={t("Model name")} value={splitLines(draft.modelsText)} @@ -1869,6 +1976,7 @@ export function AddProviderDialog({ connectivityProbe, draft, error, + importProvider, mode, onCheck, onChange, @@ -1877,13 +1985,16 @@ export function AddProviderDialog({ probe, probeLoading, providerPlugins = [], - providers + providers, + submitLabel, + title }: { canSubmit: boolean; connectivityLoading?: boolean; connectivityProbe?: GatewayProviderProbeResult; draft: AddProviderDraft; error: string; + importProvider?: ProviderDeepLinkPayload; mode: "add" | "edit"; onCheck?: (models: string[]) => Promise; onChange: (patch: Partial, resetProbe?: boolean) => void; @@ -1893,15 +2004,18 @@ export function AddProviderDialog({ probeLoading: boolean; providerPlugins?: unknown[]; providers: GatewayProviderConfig[]; + submitLabel?: string; + title?: string; }) { const t = useAppText(); const [checkConfirmOpen, setCheckConfirmOpen] = useState(false); const [checkConfirmBusy, setCheckConfirmBusy] = useState(false); const [iconDetecting, setIconDetecting] = useState(false); + const [submitting, setSubmitting] = useState(false); const [checkModelSelection, setCheckModelSelection] = useState([]); const [checkResult, setCheckResult] = useState(); const checkModels = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText)); - const submitLoading = probeLoading || connectivityLoading || iconDetecting; + const submitLoading = probeLoading || connectivityLoading || iconDetecting || submitting; const submitDisabled = !canSubmit || submitLoading; function openCheckConfirm() { @@ -1931,22 +2045,31 @@ export function AddProviderDialog({ setCheckResult(undefined); } - function submit() { + async function submit() { if (submitDisabled) { return; } - void onSubmit(); + setSubmitting(true); + try { + const saved = await onSubmit(); + if (!saved) { + setSubmitting(false); + } + } catch (error) { + setSubmitting(false); + throw error; + } } return ( <> - !open && onClose()}> + !open && !submitting && onClose()}>
- {mode === "edit" ? t("Edit Provider") : t("Add Provider")} + {title ?? (mode === "edit" ? t("Edit Provider") : t("Add Provider"))}
-
@@ -1957,6 +2080,7 @@ export function AddProviderDialog({ connectivityProbe={connectivityProbe} draft={draft} error={error} + importProvider={importProvider} mode={mode} onCheck={onCheck ? async () => openCheckConfirm() : undefined} onChange={onChange} @@ -1969,14 +2093,14 @@ export function AddProviderDialog({ - -
@@ -2153,11 +2277,13 @@ function ProviderConnectivityResultGroup({ function ModelTagInput({ ariaLabel, + displayNames, onChange, placeholder, value }: { ariaLabel: string; + displayNames?: Record; onChange: (value: string[]) => void; placeholder: string; value: string[]; @@ -2197,7 +2323,9 @@ function ModelTagInput({
{models.map((model) => ( - {model} + + {displayNames?.[model] ?? model} +