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 (
<>
-