mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Refine provider docs and gateway API key handling
This commit is contained in:
@@ -2,12 +2,12 @@
|
||||
title: One click import
|
||||
pageTitle: One click import
|
||||
eyebrow: Detailed Configuration
|
||||
lead: Use ccr://provider links to hand provider configuration to CCR, or open the import confirmation page with preset provider buttons.
|
||||
lead: Quickly add common model providers, review the details, and save them without filling everything in by hand.
|
||||
---
|
||||
|
||||
## One-Click Import
|
||||
|
||||
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.
|
||||
Choose a provider below to get started. CCR shows what will be added before saving it; when using a custom entry point, make sure the source is one you trust.
|
||||
|
||||
<div class="provider-import-grid" aria-label="Preset provider import buttons">
|
||||
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&base_url=https%3A%2F%2Fapi.openai.com%2Fv1&protocol=openai_responses&models=gpt-5.5%2Cgpt-5.5-pro%2Cgpt-5.5-instant%2Cgpt-5.4-mini" aria-label="Import OpenAI provider">
|
||||
@@ -50,9 +50,13 @@ The buttons below open the CCR desktop app's provider import confirmation dialog
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Mistral</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Moonshot+Kimi&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Moonshot Kimi provider">
|
||||
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Kimi+API+%28China%29&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Kimi API China provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Moonshot Kimi</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API (China)</span><span class="provider-import-meta">China platform</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot-global" href="ccr://provider?name=Kimi+API+%28Global%29&base_url=https%3A%2F%2Fapi.moonshot.ai%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Kimi API Global provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API (Global)</span><span class="provider-import-meta">Global platform</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-kimi-coding" href="ccr://provider?name=Kimi+Code+-+Coding+Plan&base_url=https%3A%2F%2Fapi.kimi.com%2Fcoding%2Fv1&protocol=openai_chat_completions&models=kimi-for-coding" aria-label="Import Kimi Code Coding Plan provider">
|
||||
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
title: 一键导入供应商
|
||||
pageTitle: 一键导入供应商
|
||||
eyebrow: 详细配置
|
||||
lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 preset 供应商按钮快速打开导入确认页。
|
||||
lead: 快速添加常见模型供应商,确认无误后即可保存,减少手动配置的繁琐步骤。
|
||||
---
|
||||
|
||||
## 一键导入
|
||||
|
||||
下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key;自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型。
|
||||
选择下面的供应商即可开始添加。CCR 会先显示即将添加的内容,确认无误后再保存;使用自定义入口时,请确保来源可信。
|
||||
|
||||
<div class="provider-import-grid" aria-label="Preset provider import buttons">
|
||||
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&base_url=https%3A%2F%2Fapi.openai.com%2Fv1&protocol=openai_responses&models=gpt-5.5%2Cgpt-5.5-pro%2Cgpt-5.5-instant%2Cgpt-5.4-mini" aria-label="导入 OpenAI 官方供应商">
|
||||
@@ -50,9 +50,13 @@ lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Mistral 官方</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Moonshot+Kimi&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入月之暗面 Kimi 供应商">
|
||||
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Kimi+API+%28China%29&base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入 Kimi API 国内供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">月之暗面 Kimi</span><span class="provider-import-meta">Chat Completions</span></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API(国内)</span><span class="provider-import-meta">国内平台</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-moonshot-global" href="ccr://provider?name=Kimi+API+%28Global%29&base_url=https%3A%2F%2Fapi.moonshot.ai%2Fv1&protocol=openai_chat_completions&models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入 Kimi API 海外供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
<span class="provider-import-copy"><span class="provider-import-name">Kimi API(海外)</span><span class="provider-import-meta">海外平台</span></span>
|
||||
</a>
|
||||
<a class="provider-import-button provider-kimi-coding" href="ccr://provider?name=Kimi+Code+-+Coding+Plan&base_url=https%3A%2F%2Fapi.kimi.com%2Fcoding%2Fv1&protocol=openai_chat_completions&models=kimi-for-coding" aria-label="导入 Kimi Code Coding Plan 供应商">
|
||||
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
|
||||
|
||||
@@ -80,7 +80,7 @@ const resolvedNavItems = navItems.map((item, index) => {
|
||||
};
|
||||
});
|
||||
const homeHref = withBase(locale === "en" ? "/en/" : "/");
|
||||
const faviconHref = withBase("/favicon.svg");
|
||||
const faviconHref = withBase("/ccr-icon.png");
|
||||
const logoSrc = withBase("/logo.png");
|
||||
const sidebarIcons = {
|
||||
rocket: Rocket,
|
||||
@@ -177,7 +177,7 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" href={faviconHref} type="image/svg+xml" />
|
||||
<link rel="icon" href={faviconHref} type="image/png" />
|
||||
{
|
||||
resolvedLanguageOptions.map((option) => (
|
||||
<link
|
||||
|
||||
@@ -1322,6 +1322,12 @@ h1 {
|
||||
--provider-brand-3: #e4ddff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-moonshot-global {
|
||||
--provider-brand: #0f1f42;
|
||||
--provider-brand-2: #4f7cff;
|
||||
--provider-brand-3: #dce7ff;
|
||||
}
|
||||
|
||||
.doc-markdown a.provider-import-button.provider-kimi-coding {
|
||||
--provider-brand: #111237;
|
||||
--provider-brand-2: #5c6bff;
|
||||
|
||||
+31
-10
@@ -1,4 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config-store";
|
||||
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
|
||||
@@ -98,6 +98,7 @@ const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([
|
||||
"legacy-image"
|
||||
]);
|
||||
const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1";
|
||||
const GENERATED_GATEWAY_API_KEY_ID = "local-gateway";
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
APIKEY: "",
|
||||
@@ -356,8 +357,9 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
||||
const gatewayConfig = picked.gateway ?? {};
|
||||
const corePort = gatewayConfig.corePort ?? nextPort(port);
|
||||
const configFileApiKeys = normalizeApiKeys(picked.APIKEYS, picked.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
|
||||
const persistedApiKeys = await loadPersistedApiKeys();
|
||||
const apiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]);
|
||||
const persistedApiKeys = (await loadPersistedApiKeys()).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
|
||||
const loadedApiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]);
|
||||
const apiKeys = ensureGatewayApiKeys(loadedApiKeys);
|
||||
const config: AppConfig = withSingleEnabledGlobalProfiles({
|
||||
...DEFAULT_CONFIG,
|
||||
...picked,
|
||||
@@ -413,11 +415,11 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
||||
},
|
||||
routerEndpoint: endpoint
|
||||
});
|
||||
const shouldMigrateApiKeys = hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0;
|
||||
if (shouldMigrateApiKeys) {
|
||||
const shouldPersistApiKeys = loadedApiKeys.length === 0 || hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0;
|
||||
if (shouldPersistApiKeys) {
|
||||
await replacePersistedApiKeys(apiKeys);
|
||||
}
|
||||
if (loadedRawConfig.source !== "sqlite" || shouldMigrateApiKeys) {
|
||||
if (loadedRawConfig.source !== "sqlite" || shouldPersistApiKeys) {
|
||||
await writeSanitizedConfig(config);
|
||||
}
|
||||
return config;
|
||||
@@ -427,10 +429,16 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
||||
console.warn(`[config] Failed to load API keys: ${formatError(storeError)}`);
|
||||
return [] as ApiKeyConfig[];
|
||||
});
|
||||
const apiKeys = ensureGatewayApiKeys(persistedApiKeys.filter((apiKey) => !isDefaultSeedApiKey(apiKey)));
|
||||
if (persistedApiKeys.length === 0) {
|
||||
await replacePersistedApiKeys(apiKeys).catch((storeError) => {
|
||||
console.warn(`[config] Failed to persist generated API key: ${formatError(storeError)}`);
|
||||
});
|
||||
}
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
APIKEY: persistedApiKeys[0]?.key ?? "",
|
||||
APIKEYS: persistedApiKeys
|
||||
APIKEY: apiKeys[0]?.key ?? "",
|
||||
APIKEYS: apiKeys
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -438,7 +446,7 @@ export async function loadAppConfig(): Promise<AppConfig> {
|
||||
export async function saveAppConfig(config: AppConfig): Promise<AppConfig> {
|
||||
const normalizedConfig = withSingleEnabledGlobalProfiles(config);
|
||||
assertProviderApiKeysAreSafe(normalizedConfig);
|
||||
const apiKeys = normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
|
||||
const apiKeys = ensureGatewayApiKeys(normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)));
|
||||
await replacePersistedApiKeys(apiKeys);
|
||||
await writeSanitizedConfig({
|
||||
...normalizedConfig,
|
||||
@@ -564,7 +572,7 @@ function providerCredentialApiKey(credential: ProviderCredentialConfig): string
|
||||
}
|
||||
|
||||
export async function saveApiKeysConfig(apiKeys: ApiKeyConfig[]): Promise<AppConfig> {
|
||||
const normalized = normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
|
||||
const normalized = ensureGatewayApiKeys(normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey)));
|
||||
await replacePersistedApiKeys(normalized);
|
||||
return loadAppConfig();
|
||||
}
|
||||
@@ -2516,6 +2524,19 @@ function normalizeApiKeys(value: ApiKeyConfig[] | undefined, legacyKey: string |
|
||||
return uniqueApiKeyConfigs([...(value ?? []), ...(legacyKey ? [createApiKeyConfig(legacyKey, value?.length ?? 0)] : [])]);
|
||||
}
|
||||
|
||||
function ensureGatewayApiKeys(apiKeys: ApiKeyConfig[]): ApiKeyConfig[] {
|
||||
return apiKeys.length ? apiKeys : [createGeneratedGatewayApiKey()];
|
||||
}
|
||||
|
||||
function createGeneratedGatewayApiKey(): ApiKeyConfig {
|
||||
return {
|
||||
createdAt: new Date().toISOString(),
|
||||
id: GENERATED_GATEWAY_API_KEY_ID,
|
||||
key: `sk-ccr-${randomBytes(32).toString("base64url")}`,
|
||||
name: "Local Gateway"
|
||||
};
|
||||
}
|
||||
|
||||
function createApiKeyConfig(key: string, index: number): ApiKeyConfig {
|
||||
return {
|
||||
createdAt: new Date(0).toISOString(),
|
||||
|
||||
@@ -523,14 +523,49 @@ async function loadPluginModule(modulePath: string): Promise<unknown> {
|
||||
}
|
||||
|
||||
function resolvePluginModule(modulePath: string): string {
|
||||
const expanded = expandHome(modulePath);
|
||||
const resolved = requireFromHere.resolve(resolveLocalModulePath(modulePath, "Plugin module"));
|
||||
assertJavaScriptModulePath(resolved, "Plugin module");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveLocalModulePath(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${label} path is required.`);
|
||||
}
|
||||
|
||||
const expanded = expandHome(trimmed);
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return requireFromHere.resolve(expanded);
|
||||
return expanded;
|
||||
}
|
||||
if (expanded.startsWith(".")) {
|
||||
return requireFromHere.resolve(path.resolve(CONFIGDIR, expanded));
|
||||
if (isProtocolSpecifier(expanded)) {
|
||||
throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`);
|
||||
}
|
||||
return requireFromHere.resolve(expanded, { paths: [CONFIGDIR, process.cwd()] });
|
||||
if (!expanded.startsWith(".")) {
|
||||
throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`);
|
||||
}
|
||||
|
||||
const resolved = path.resolve(CONFIGDIR, expanded);
|
||||
if (!isPathInside(resolved, CONFIGDIR)) {
|
||||
throw new Error(`${label} relative paths must stay inside the CCR config directory.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertJavaScriptModulePath(resolved: string, label: string): void {
|
||||
const extension = path.extname(resolved).toLowerCase();
|
||||
if (![".cjs", ".js", ".mjs"].includes(extension)) {
|
||||
throw new Error(`${label} must resolve to a JavaScript module file.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isProtocolSpecifier(value: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
|
||||
}
|
||||
|
||||
function isPathInside(file: string, root: string): boolean {
|
||||
const relative = path.relative(root, file);
|
||||
return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function normalizeLoadedPlugin(moduleValue: unknown): LoadedPlugin {
|
||||
|
||||
@@ -14,5 +14,6 @@ export const anthropicProviderPreset: ProviderPreset = {
|
||||
name: "Anthropic",
|
||||
officialApiKeyPatterns: [
|
||||
{ flags: "i", source: "^sk-ant-[a-z0-9_-]+$" }
|
||||
]
|
||||
],
|
||||
websiteUrl: "https://www.anthropic.com/"
|
||||
};
|
||||
|
||||
@@ -10,5 +10,6 @@ export const bailianProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "bailian",
|
||||
name: "Alibaba Bailian"
|
||||
name: "Alibaba Bailian",
|
||||
websiteUrl: "https://bailian.console.aliyun.com/"
|
||||
};
|
||||
|
||||
@@ -47,5 +47,6 @@ export const deepSeekProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "deepseek",
|
||||
name: "DeepSeek"
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://www.deepseek.com/"
|
||||
};
|
||||
|
||||
@@ -13,5 +13,6 @@ export const geminiProviderPreset: ProviderPreset = {
|
||||
name: "Google Gemini",
|
||||
officialApiKeyPatterns: [
|
||||
{ flags: "i", source: "^AIza[a-z0-9_-]{20,}$" }
|
||||
]
|
||||
],
|
||||
websiteUrl: "https://gemini.google.com/"
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { deepSeekProviderPreset } from "./deepseek";
|
||||
import { geminiProviderPreset } from "./gemini";
|
||||
import { kimiCodingProviderPreset } from "./kimi-coding";
|
||||
import { mistralProviderPreset } from "./mistral";
|
||||
import { moonshotProviderPreset } from "./moonshot";
|
||||
import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "./moonshot";
|
||||
import { openaiProviderPreset } from "./openai";
|
||||
import { openRouterProviderPreset } from "./openrouter";
|
||||
import { siliconFlowProviderPreset } from "./siliconflow";
|
||||
@@ -35,7 +35,8 @@ export const providerPresets: ProviderPreset[] = [
|
||||
zaiGlobalCodingProviderPreset,
|
||||
zaiGlobalGeneralProviderPreset,
|
||||
mistralProviderPreset,
|
||||
moonshotProviderPreset,
|
||||
moonshotChinaProviderPreset,
|
||||
moonshotGlobalProviderPreset,
|
||||
bailianProviderPreset,
|
||||
siliconFlowProviderPreset
|
||||
];
|
||||
|
||||
@@ -26,14 +26,16 @@ export const kimiCodingProviderPreset: ProviderPreset = {
|
||||
endpoints: [
|
||||
{
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
protocols: ["openai_chat_completions"]
|
||||
protocols: ["openai_chat_completions"],
|
||||
websiteUrl: "https://www.kimi.com/code?aff=ccr"
|
||||
},
|
||||
{
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
protocols: ["anthropic_messages"]
|
||||
protocols: ["anthropic_messages"],
|
||||
websiteUrl: "https://www.kimi.com/code?aff=ccr"
|
||||
}
|
||||
],
|
||||
id: "kimi-coding",
|
||||
name: "Kimi Code - Coding Plan",
|
||||
websiteUrl: "https://www.kimi.com/code/docs/"
|
||||
websiteUrl: "https://www.kimi.com/code?aff=ccr"
|
||||
};
|
||||
|
||||
@@ -41,5 +41,6 @@ export const mistralProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "mistral",
|
||||
name: "Mistral"
|
||||
name: "Mistral",
|
||||
websiteUrl: "https://mistral.ai/"
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ProviderAccountConfig } from "../../../shared/app";
|
||||
import type { ProviderPreset } from "../../../shared/provider-presets";
|
||||
|
||||
const moonshotProviderAccountConfig: ProviderAccountConfig = {
|
||||
const moonshotGlobalProviderAccountConfig: ProviderAccountConfig = {
|
||||
connectors: [
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
@@ -37,20 +37,70 @@ const moonshotProviderAccountConfig: ProviderAccountConfig = {
|
||||
enabled: true
|
||||
};
|
||||
|
||||
export const moonshotProviderPreset: ProviderPreset = {
|
||||
account: moonshotProviderAccountConfig,
|
||||
aliases: ["kimi", "moonshot"],
|
||||
const moonshotChinaProviderAccountConfig: ProviderAccountConfig = {
|
||||
connectors: [
|
||||
{
|
||||
auth: "provider-api-key",
|
||||
endpoint: "https://api.moonshot.cn/v1/users/me/balance",
|
||||
mapping: {
|
||||
meters: [
|
||||
{
|
||||
id: "balance",
|
||||
kind: "balance",
|
||||
label: "Balance",
|
||||
remaining: "$.data.available_balance",
|
||||
unit: "CNY"
|
||||
},
|
||||
{
|
||||
id: "voucher_balance",
|
||||
kind: "balance",
|
||||
label: "Voucher balance",
|
||||
remaining: "$.data.voucher_balance",
|
||||
unit: "CNY"
|
||||
},
|
||||
{
|
||||
id: "cash_balance",
|
||||
kind: "balance",
|
||||
label: "Cash balance",
|
||||
remaining: "$.data.cash_balance",
|
||||
unit: "CNY"
|
||||
}
|
||||
]
|
||||
},
|
||||
type: "http-json"
|
||||
}
|
||||
],
|
||||
enabled: true
|
||||
};
|
||||
|
||||
export const moonshotChinaProviderPreset: ProviderPreset = {
|
||||
account: moonshotChinaProviderAccountConfig,
|
||||
aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"],
|
||||
defaultModels: ["moonshot-v1-8k"],
|
||||
endpoints: [
|
||||
{
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
protocols: ["openai_chat_completions"],
|
||||
websiteUrl: "https://platform.kimi.com/?aff=ccr"
|
||||
}
|
||||
],
|
||||
id: "moonshot",
|
||||
name: "Kimi API (China)",
|
||||
websiteUrl: "https://platform.kimi.com/?aff=ccr"
|
||||
};
|
||||
|
||||
export const moonshotGlobalProviderPreset: ProviderPreset = {
|
||||
account: moonshotGlobalProviderAccountConfig,
|
||||
aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"],
|
||||
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"]
|
||||
protocols: ["openai_chat_completions"],
|
||||
websiteUrl: "https://platform.kimi.ai/?aff=ccr"
|
||||
}
|
||||
],
|
||||
id: "moonshot",
|
||||
name: "Moonshot Kimi"
|
||||
id: "moonshot-global",
|
||||
name: "Kimi API (Global)",
|
||||
websiteUrl: "https://platform.kimi.ai/?aff=ccr"
|
||||
};
|
||||
|
||||
@@ -14,5 +14,6 @@ export const openaiProviderPreset: ProviderPreset = {
|
||||
name: "OpenAI",
|
||||
officialApiKeyPatterns: [
|
||||
{ flags: "i", source: "^sk-(?:proj|svcacct)-[a-z0-9_-]+$" }
|
||||
]
|
||||
],
|
||||
websiteUrl: "https://openai.com/"
|
||||
};
|
||||
|
||||
@@ -51,5 +51,6 @@ export const openRouterProviderPreset: ProviderPreset = {
|
||||
name: "OpenRouter",
|
||||
officialApiKeyPatterns: [
|
||||
{ flags: "i", source: "^sk-or-v1-[a-z0-9_-]+$" }
|
||||
]
|
||||
],
|
||||
websiteUrl: "https://openrouter.ai/"
|
||||
};
|
||||
|
||||
@@ -47,5 +47,6 @@ export const siliconFlowProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "siliconflow",
|
||||
name: "SiliconFlow"
|
||||
name: "SiliconFlow",
|
||||
websiteUrl: "https://siliconflow.cn/"
|
||||
};
|
||||
|
||||
@@ -58,5 +58,6 @@ export const zaiGlobalCodingProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "zai-global-coding",
|
||||
name: "Z.ai (Global) - Coding Plan"
|
||||
name: "Z.ai (Global) - Coding Plan",
|
||||
websiteUrl: "https://z.ai/"
|
||||
};
|
||||
|
||||
@@ -54,5 +54,6 @@ export const zaiGlobalGeneralProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "zai-global-general",
|
||||
name: "Z.ai (Global) - General Endpoint"
|
||||
name: "Z.ai (Global) - General Endpoint",
|
||||
websiteUrl: "https://z.ai/"
|
||||
};
|
||||
|
||||
@@ -58,5 +58,6 @@ export const zhipuCnCodingProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "zhipu-cn-coding",
|
||||
name: "Zhipu AI (China) - Coding Plan"
|
||||
name: "Zhipu AI (China) - Coding Plan",
|
||||
websiteUrl: "https://www.bigmodel.cn/"
|
||||
};
|
||||
|
||||
@@ -54,5 +54,6 @@ export const zhipuCnGeneralProviderPreset: ProviderPreset = {
|
||||
}
|
||||
],
|
||||
id: "zhipu-cn-general",
|
||||
name: "Zhipu AI (China) - General Endpoint"
|
||||
name: "Zhipu AI (China) - General Endpoint",
|
||||
websiteUrl: "https://www.bigmodel.cn/"
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ const presetCatalogProviderIds: Record<string, string[]> = {
|
||||
"kimi-coding": ["kimi-for-coding"],
|
||||
mistral: ["mistral"],
|
||||
moonshot: ["moonshotai-cn"],
|
||||
"moonshot-global": ["moonshotai"],
|
||||
openai: ["openai"],
|
||||
openrouter: ["openrouter"],
|
||||
siliconflow: ["siliconflow-cn"],
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const rpcEndpoint = "/api/ccr/rpc";
|
||||
const webAuthHeader = "x-ccr-web-auth";
|
||||
const webAuthQueryParam = "ccr_web_token";
|
||||
const webAuthStorageKey = "ccr.webAuthToken";
|
||||
const webAuthToken = readWebAuthToken();
|
||||
|
||||
type RpcResponse =
|
||||
| { ok: true; value: unknown }
|
||||
@@ -8,7 +12,8 @@ async function rpc(method: string, args: unknown[] = []): Promise<unknown> {
|
||||
const response = await fetch(rpcEndpoint, {
|
||||
body: JSON.stringify({ args, method }),
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
"content-type": "application/json",
|
||||
...(webAuthToken ? { [webAuthHeader]: webAuthToken } : {})
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
@@ -27,6 +32,46 @@ async function rpc(method: string, args: unknown[] = []): Promise<unknown> {
|
||||
return payload.value;
|
||||
}
|
||||
|
||||
function readWebAuthToken(): string {
|
||||
const tokenFromUrl = readWebAuthTokenFromUrl();
|
||||
if (tokenFromUrl) {
|
||||
writeStoredWebAuthToken(tokenFromUrl);
|
||||
return tokenFromUrl;
|
||||
}
|
||||
return readStoredWebAuthToken();
|
||||
}
|
||||
|
||||
function readWebAuthTokenFromUrl(): string {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const token = url.searchParams.get(webAuthQueryParam)?.trim() ?? "";
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
url.searchParams.delete(webAuthQueryParam);
|
||||
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
return token;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredWebAuthToken(): string {
|
||||
try {
|
||||
return window.sessionStorage.getItem(webAuthStorageKey)?.trim() ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredWebAuthToken(token: string): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(webAuthStorageKey, token);
|
||||
} catch {
|
||||
// The in-memory token still works for the current page load if storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function noopSubscription(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -80,10 +82,19 @@ type RpcRequest = {
|
||||
|
||||
type RpcHandler = (...args: unknown[]) => Promise<unknown> | unknown;
|
||||
|
||||
type WebManagementSecurityContext = {
|
||||
allowIpLiteralHosts: boolean;
|
||||
allowedHostnames: Set<string>;
|
||||
authToken: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const defaultWebHost = "127.0.0.1";
|
||||
const defaultWebPort = 3458;
|
||||
const onboardingFinishedAtSettingKey = "onboardingFinishedAt";
|
||||
const maxRpcBodyBytes = 8 * 1024 * 1024;
|
||||
const webAuthHeader = "x-ccr-web-auth";
|
||||
const webAuthQueryParam = "ccr_web_token";
|
||||
const staticRoot = path.resolve(__dirname, "..", "renderer");
|
||||
const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html");
|
||||
const rendererAssetsRoot = path.join(staticRoot, "assets");
|
||||
@@ -111,8 +122,14 @@ const pluginMarketplace: PluginMarketplaceEntry[] = [
|
||||
export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise<WebManagementServerRuntime> {
|
||||
const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost;
|
||||
const requestedPort = options.port ?? readEnvPort("CCR_WEB_PORT") ?? defaultWebPort;
|
||||
const authToken = randomBytes(32).toString("base64url");
|
||||
let security: WebManagementSecurityContext | undefined;
|
||||
const server = createServer((request, response) => {
|
||||
void handleRequest(request, response).catch((error) => {
|
||||
if (!security) {
|
||||
sendJson(response, 503, { error: { message: "CCR web management server is not ready." }, ok: false });
|
||||
return;
|
||||
}
|
||||
void handleRequest(request, response, security).catch((error) => {
|
||||
sendJson(response, 500, { error: { message: formatError(error) }, ok: false });
|
||||
});
|
||||
});
|
||||
@@ -120,7 +137,9 @@ export async function startWebManagementServer(options: WebManagementServerOptio
|
||||
const listenedPort = await listenWithFallback(server, requestedPort, host);
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : listenedPort;
|
||||
const url = `http://${formatListenHost(host)}:${port}/`;
|
||||
const baseUrl = `http://${formatListenHost(host)}:${port}/`;
|
||||
const url = urlWithWebAuthToken(baseUrl, authToken);
|
||||
security = createWebManagementSecurityContext(host, port, authToken);
|
||||
|
||||
if (options.startGateway !== false) {
|
||||
await startConfiguredServices("web startup");
|
||||
@@ -141,10 +160,15 @@ export async function startWebManagementServer(options: WebManagementServerOptio
|
||||
};
|
||||
}
|
||||
|
||||
async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
async function handleRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise<void> {
|
||||
if (!isAllowedWebRequestHost(request, security)) {
|
||||
sendText(response, 403, "Forbidden host");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = requestUrl(request);
|
||||
if (url.pathname === "/api/ccr/rpc") {
|
||||
await handleRpcRequest(request, response);
|
||||
await handleRpcRequest(request, response, security);
|
||||
return;
|
||||
}
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
@@ -162,11 +186,23 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
sendText(response, 404, "Not found");
|
||||
}
|
||||
|
||||
async function handleRpcRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
async function handleRpcRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise<void> {
|
||||
if (request.method !== "POST") {
|
||||
sendJson(response, 405, { error: { message: "RPC only supports POST." }, ok: false });
|
||||
return;
|
||||
}
|
||||
if (!isJsonRequest(request)) {
|
||||
sendJson(response, 415, { error: { message: "RPC requests must use application/json." }, ok: false });
|
||||
return;
|
||||
}
|
||||
if (!isAllowedWebRequestOrigin(request, security)) {
|
||||
sendJson(response, 403, { error: { message: "Forbidden RPC origin." }, ok: false });
|
||||
return;
|
||||
}
|
||||
if (!hasValidWebAuthToken(request, security)) {
|
||||
sendJson(response, 401, { error: { message: "CCR web authentication token is missing or invalid." }, ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: RpcRequest;
|
||||
try {
|
||||
@@ -498,7 +534,8 @@ function sendBuffer(response: ServerResponse, status: number, body: Buffer, cont
|
||||
response.writeHead(status, {
|
||||
"cache-control": "no-store",
|
||||
"content-length": body.length,
|
||||
"content-type": contentType
|
||||
"content-type": contentType,
|
||||
"x-content-type-options": "nosniff"
|
||||
});
|
||||
if (headOnly) {
|
||||
response.end();
|
||||
@@ -512,7 +549,8 @@ function sendJson(response: ServerResponse, status: number, payload: unknown): v
|
||||
response.writeHead(status, {
|
||||
"cache-control": "no-store",
|
||||
"content-length": body.length,
|
||||
"content-type": "application/json; charset=utf-8"
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-content-type-options": "nosniff"
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
@@ -525,6 +563,109 @@ function requestUrl(request: IncomingMessage): URL {
|
||||
return new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
||||
}
|
||||
|
||||
function createWebManagementSecurityContext(host: string, port: number, authToken: string): WebManagementSecurityContext {
|
||||
const normalizedHost = normalizeHostname(host);
|
||||
const allowedHostnames = new Set<string>(["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]);
|
||||
if (normalizedHost) {
|
||||
allowedHostnames.add(normalizedHost);
|
||||
}
|
||||
return {
|
||||
allowIpLiteralHosts: isWildcardBindHost(normalizedHost),
|
||||
allowedHostnames,
|
||||
authToken,
|
||||
port
|
||||
};
|
||||
}
|
||||
|
||||
function urlWithWebAuthToken(value: string, authToken: string): string {
|
||||
const url = new URL(value);
|
||||
url.searchParams.set(webAuthQueryParam, authToken);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isAllowedWebRequestHost(request: IncomingMessage, security: WebManagementSecurityContext): boolean {
|
||||
const hostname = requestHostname(request);
|
||||
return Boolean(hostname && isAllowedWebHostname(hostname, security));
|
||||
}
|
||||
|
||||
function isAllowedWebRequestOrigin(request: IncomingMessage, security: WebManagementSecurityContext): boolean {
|
||||
const origin = readHeaderValue(request.headers.origin);
|
||||
if (origin && !isAllowedWebOriginValue(origin, security)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const referer = readHeaderValue(request.headers.referer);
|
||||
if (!origin && referer && !isAllowedWebOriginValue(referer, security)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isAllowedWebOriginValue(value: string, security: WebManagementSecurityContext): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const port = url.port ? Number(url.port) : url.protocol === "http:" ? 80 : url.protocol === "https:" ? 443 : undefined;
|
||||
return url.protocol === "http:" &&
|
||||
port === security.port &&
|
||||
isAllowedWebHostname(normalizeHostname(url.hostname), security);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedWebHostname(hostname: string, security: WebManagementSecurityContext): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
return security.allowedHostnames.has(normalized) ||
|
||||
(security.allowIpLiteralHosts && Boolean(net.isIP(normalized)));
|
||||
}
|
||||
|
||||
function requestHostname(request: IncomingMessage): string | undefined {
|
||||
const host = readHeaderValue(request.headers.host);
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return normalizeHostname(new URL(`http://${host}`).hostname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonRequest(request: IncomingMessage): boolean {
|
||||
const contentType = readHeaderValue(request.headers["content-type"])?.toLowerCase() ?? "";
|
||||
return contentType.split(";")[0]?.trim() === "application/json";
|
||||
}
|
||||
|
||||
function hasValidWebAuthToken(request: IncomingMessage, security: WebManagementSecurityContext): boolean {
|
||||
const token = readHeaderValue(request.headers[webAuthHeader]);
|
||||
return constantTimeEquals(token, security.authToken);
|
||||
}
|
||||
|
||||
function constantTimeEquals(value: string | undefined, expected: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const valueBuffer = Buffer.from(value);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
return valueBuffer.length === expectedBuffer.length && timingSafeEqual(valueBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
function readHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return readString(value[0]);
|
||||
}
|
||||
return readString(value);
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function isWildcardBindHost(host: string): boolean {
|
||||
return host === "" || host === "0.0.0.0" || host === "::" || host === "::0";
|
||||
}
|
||||
|
||||
function readRequestBody(request: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
@@ -147,6 +147,13 @@ function providerNameSlug(value: string): string {
|
||||
.replace(/^-+|-+$/g, "") || "provider";
|
||||
}
|
||||
|
||||
async function loadProviderAccountSnapshots(forceRefresh = false): Promise<ProviderAccountSnapshot[]> {
|
||||
if (!window.ccr) {
|
||||
return [];
|
||||
}
|
||||
return window.ccr.getProviderAccountSnapshots(undefined, forceRefresh ? { forceRefresh: true } : undefined);
|
||||
}
|
||||
|
||||
function extensionActionIndexes(index: number, groupIndexes?: number[]): number[] {
|
||||
const indexes = groupIndexes?.length ? groupIndexes : [index];
|
||||
return [...new Set(indexes.filter((item) => Number.isInteger(item) && item >= 0))];
|
||||
@@ -252,6 +259,7 @@ function App() {
|
||||
const [usageRange, setUsageRange] = useState<UsageStatsRange>("7d");
|
||||
const [usageStats, setUsageStats] = useState<UsageStatsSnapshot>(fallbackUsageStats);
|
||||
const [providerAccountSnapshots, setProviderAccountSnapshots] = useState<ProviderAccountSnapshot[]>([]);
|
||||
const [providerAccountRefreshing, setProviderAccountRefreshing] = useState(false);
|
||||
const updateActionBusyRef = useRef(false);
|
||||
const resolvedLanguage = languagePreference === "system" ? systemLanguage : languagePreference;
|
||||
const copy = appCopy[resolvedLanguage];
|
||||
@@ -443,7 +451,7 @@ function App() {
|
||||
|
||||
let cancelled = false;
|
||||
const refreshProviderAccounts = () => {
|
||||
void window.ccr?.getProviderAccountSnapshots()
|
||||
void loadProviderAccountSnapshots()
|
||||
.then((snapshots) => {
|
||||
if (!cancelled) {
|
||||
setProviderAccountSnapshots(snapshots);
|
||||
@@ -463,6 +471,20 @@ function App() {
|
||||
};
|
||||
}, [draftConfig.Providers]);
|
||||
|
||||
async function refreshProviderAccountsNow() {
|
||||
if (providerAccountRefreshing) {
|
||||
return;
|
||||
}
|
||||
setProviderAccountRefreshing(true);
|
||||
try {
|
||||
setProviderAccountSnapshots(await loadProviderAccountSnapshots(true));
|
||||
} catch {
|
||||
setProviderAccountSnapshots([]);
|
||||
} finally {
|
||||
setProviderAccountRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const requestLogsEnabled = Boolean(draftConfig.observability.requestLogs);
|
||||
const agentAnalysisEnabled = Boolean(draftConfig.observability.agentAnalysis);
|
||||
const agentAnalysisFilterKey = JSON.stringify({
|
||||
@@ -2813,6 +2835,8 @@ function App() {
|
||||
onWidgetsChange: changeOverviewWidgets,
|
||||
overviewWidgets: normalizeOverviewWidgets(draftConfig.overviewWidgets),
|
||||
providerAccounts: providerAccountSnapshots,
|
||||
providerAccountRefreshing,
|
||||
refreshProviderAccounts: () => void refreshProviderAccountsNow(),
|
||||
setUsageRange,
|
||||
usageRange,
|
||||
usageStats
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes,
|
||||
formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterTitle, formatProviderAccountMeterValue,
|
||||
formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor,
|
||||
LabelList, LayoutGroup, Line, MeasuringStrategy, MetricCard, MetricTone,
|
||||
LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricCard, MetricTone,
|
||||
metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets,
|
||||
OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions,
|
||||
OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus,
|
||||
PointerSensor, primaryProviderAccountMeter, providerAccountBadgeVariant, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass,
|
||||
PointerSensor, primaryProviderAccountMeter, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass,
|
||||
providerAccountSnapshotKey, providerAccountSnapshotLabel,
|
||||
ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select,
|
||||
SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass,
|
||||
@@ -26,6 +26,8 @@ export function OverviewView({
|
||||
onWidgetsChange,
|
||||
overviewWidgets,
|
||||
providerAccounts,
|
||||
providerAccountRefreshing = false,
|
||||
refreshProviderAccounts,
|
||||
setUsageRange,
|
||||
usageRange,
|
||||
usageStats
|
||||
@@ -33,6 +35,8 @@ export function OverviewView({
|
||||
onWidgetsChange: (widgets: OverviewWidgetConfig[]) => void;
|
||||
overviewWidgets: OverviewWidgetConfig[];
|
||||
providerAccounts: ProviderAccountSnapshot[];
|
||||
providerAccountRefreshing?: boolean;
|
||||
refreshProviderAccounts?: () => void | Promise<void>;
|
||||
setUsageRange: (range: UsageStatsRange) => void;
|
||||
usageRange: UsageStatsRange;
|
||||
usageStats: UsageStatsSnapshot;
|
||||
@@ -248,6 +252,8 @@ export function OverviewView({
|
||||
>
|
||||
<OverviewWidgetRenderer
|
||||
providerAccounts={providerAccounts}
|
||||
providerAccountRefreshing={providerAccountRefreshing}
|
||||
refreshProviderAccounts={refreshProviderAccounts}
|
||||
usageRange={usageRange}
|
||||
usageStats={usageStats}
|
||||
widget={widget}
|
||||
@@ -267,6 +273,8 @@ export function OverviewView({
|
||||
{activeWidget ? (
|
||||
<OverviewWidgetDragOverlay
|
||||
providerAccounts={providerAccounts}
|
||||
providerAccountRefreshing={providerAccountRefreshing}
|
||||
refreshProviderAccounts={refreshProviderAccounts}
|
||||
usageRange={usageRange}
|
||||
usageStats={usageStats}
|
||||
widget={activeWidget}
|
||||
@@ -554,11 +562,15 @@ function SortableOverviewWidget({
|
||||
|
||||
function OverviewWidgetDragOverlay({
|
||||
providerAccounts,
|
||||
providerAccountRefreshing = false,
|
||||
refreshProviderAccounts,
|
||||
usageRange,
|
||||
usageStats,
|
||||
widget
|
||||
}: {
|
||||
providerAccounts: ProviderAccountSnapshot[];
|
||||
providerAccountRefreshing?: boolean;
|
||||
refreshProviderAccounts?: () => void | Promise<void>;
|
||||
usageRange: UsageStatsRange;
|
||||
usageStats: UsageStatsSnapshot;
|
||||
widget: OverviewWidgetConfig;
|
||||
@@ -567,6 +579,8 @@ function OverviewWidgetDragOverlay({
|
||||
<div className={cn("pointer-events-none overflow-hidden opacity-95 shadow-2xl", overviewWidgetOverlaySizeClass(widget.size))}>
|
||||
<OverviewWidgetRenderer
|
||||
providerAccounts={providerAccounts}
|
||||
providerAccountRefreshing={providerAccountRefreshing}
|
||||
refreshProviderAccounts={refreshProviderAccounts}
|
||||
usageRange={usageRange}
|
||||
usageStats={usageStats}
|
||||
widget={widget}
|
||||
@@ -802,11 +816,15 @@ function overviewWidgetResizeCursor(axis: OverviewWidgetResizeAxis): string {
|
||||
|
||||
function OverviewWidgetRenderer({
|
||||
providerAccounts,
|
||||
providerAccountRefreshing = false,
|
||||
refreshProviderAccounts,
|
||||
usageRange,
|
||||
usageStats,
|
||||
widget
|
||||
}: {
|
||||
providerAccounts: ProviderAccountSnapshot[];
|
||||
providerAccountRefreshing?: boolean;
|
||||
refreshProviderAccounts?: () => void | Promise<void>;
|
||||
usageRange: UsageStatsRange;
|
||||
usageStats: UsageStatsSnapshot;
|
||||
widget: OverviewWidgetConfig;
|
||||
@@ -816,7 +834,7 @@ function OverviewWidgetRenderer({
|
||||
if (widget.type === "system-status") {
|
||||
content = <SystemStatusBar usageRange={usageRange} usageStats={usageStats} variant={widget.variant === "compact" ? "compact" : "timeline"} />;
|
||||
} else if (widget.type === "account-balance") {
|
||||
content = <ProviderAccountsOverview accountProvider={widget.accountProvider} accounts={providerAccounts} dimensions={dimensions} variant={overviewAccountVariant(widget.variant)} />;
|
||||
content = <ProviderAccountsOverview accountProvider={widget.accountProvider} accounts={providerAccounts} dimensions={dimensions} refreshing={providerAccountRefreshing} variant={overviewAccountVariant(widget.variant)} onRefresh={refreshProviderAccounts} />;
|
||||
} else if (widget.type === "metric") {
|
||||
content = <OverviewMetricWidget metric={widget.metric ?? "requests"} totals={usageStats.totals} variant={overviewMetricVariant(widget.variant)} />;
|
||||
} else if (widget.type === "usage-trend") {
|
||||
@@ -952,19 +970,19 @@ function UsageTrendWidget({
|
||||
<Bar barSize={12} dataKey="requestCount" fill="#2563eb" name={t("Requests")} radius={[3, 3, 0, 0]} yAxisId="requests">
|
||||
<LabelList content={<RequestHealthBarLabel />} dataKey="requestCount" />
|
||||
</Bar>
|
||||
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke="#be123c" strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
</>
|
||||
) : null}
|
||||
{variant === "area" ? (
|
||||
<>
|
||||
<Area dataKey="totalTokens" fill="#0f766e" fillOpacity={0.18} name={t("Total tokens")} stroke="#0f766e" strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
<Area dataKey="cacheTokens" fill="#be123c" fillOpacity={0.12} name={t("Cache tokens")} stroke="#be123c" strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
<Area dataKey="cacheTokens" fill={overviewCacheColor} fillOpacity={0.12} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
</>
|
||||
) : null}
|
||||
{variant === "line" ? (
|
||||
<>
|
||||
<Line dataKey="totalTokens" dot={false} name={t("Total tokens")} stroke="#0f766e" strokeWidth={2.5} type="monotone" yAxisId="tokens" />
|
||||
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke="#be123c" strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
|
||||
</>
|
||||
) : null}
|
||||
{variant === "bar" ? (
|
||||
@@ -1185,7 +1203,7 @@ function TokenMixOverviewWidget({
|
||||
const tokenMix = [
|
||||
{ color: "#2563eb", name: t("Input"), value: totals.inputTokens },
|
||||
{ color: "#d97706", name: t("Output"), value: totals.outputTokens },
|
||||
{ color: "#be123c", name: t("Cache"), value: totals.cacheTokens }
|
||||
{ color: overviewCacheColor, name: t("Cache"), value: totals.cacheTokens }
|
||||
];
|
||||
const total = tokenMix.reduce((sum, item) => sum + item.value, 0);
|
||||
const showLegend = dimensions.height >= 2 && dimensions.width >= 2;
|
||||
@@ -1665,6 +1683,8 @@ function overviewWidgetOverlaySizeClass(size: OverviewWidgetSize): string {
|
||||
|
||||
type OverviewWidgetDimensions = { height: 1 | 2 | 3 | 4; width: 1 | 2 | 3 | 4 };
|
||||
|
||||
const overviewCacheColor = "#6366f1";
|
||||
|
||||
function overviewWidgetDimensions(size: OverviewWidgetSize): OverviewWidgetDimensions {
|
||||
const [widthText, heightText] = size.split(":");
|
||||
const width = overviewWidgetDimensionValue(widthText);
|
||||
@@ -1752,7 +1772,7 @@ function overviewMetricDatum(metric: OverviewMetricKind, totals: UsageTotals, tr
|
||||
return { label: translate("Output tokens"), ratio: totals.totalTokens > 0 ? totals.outputTokens / totals.totalTokens : 0, tone: "amber", value: formatCompactNumber(totals.outputTokens) };
|
||||
}
|
||||
if (metric === "cache-tokens") {
|
||||
return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "rose", value: formatCompactNumber(totals.cacheTokens) };
|
||||
return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "indigo", value: formatCompactNumber(totals.cacheTokens) };
|
||||
}
|
||||
if (metric === "cache-ratio") {
|
||||
return { label: translate("Cache ratio"), ratio: totals.cacheRatio, tone: "indigo", value: formatPercent(totals.cacheRatio) };
|
||||
@@ -1897,11 +1917,15 @@ function ProviderAccountsOverview({
|
||||
accountProvider,
|
||||
accounts,
|
||||
dimensions,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
variant = "cards"
|
||||
}: {
|
||||
accountProvider?: string;
|
||||
accounts: ProviderAccountSnapshot[];
|
||||
dimensions: OverviewWidgetDimensions;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
refreshing?: boolean;
|
||||
variant?: OverviewAccountVariant;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
@@ -1921,7 +1945,7 @@ function ProviderAccountsOverview({
|
||||
{t("No account balance connectors configured")}
|
||||
</div>
|
||||
) : isSingleAccount ? (
|
||||
<ProviderAccountSinglePanel account={visibleAccounts[0]} dimensions={dimensions} variant={variant} />
|
||||
<ProviderAccountSinglePanel account={visibleAccounts[0]} dimensions={dimensions} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />
|
||||
) : variant === "compact" ? (
|
||||
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions))}>
|
||||
{visibleAccounts.map((account) => {
|
||||
@@ -1931,10 +1955,11 @@ function ProviderAccountsOverview({
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{providerAccountShowStatus(dimensions) ? <Badge variant={providerAccountBadgeVariant(account.status)}>{account.status}</Badge> : null}
|
||||
{meter ? <div className="mt-1 text-[12px] font-semibold">{formatProviderAccountMeterValue(meter)}</div> : null}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
|
||||
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
|
||||
{meter ? <div className="text-[12px] font-semibold">{formatProviderAccountMeterValue(meter)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1951,8 +1976,12 @@ function ProviderAccountsOverview({
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-[12px] font-semibold">
|
||||
{meter ? <span>{formatProviderAccountMeterValue(meter)}</span> : null}
|
||||
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-[12px] font-semibold">{meter ? formatProviderAccountMeterValue(meter) : account.status}</div>
|
||||
</div>
|
||||
{progress !== undefined ? (
|
||||
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted">
|
||||
@@ -1966,7 +1995,7 @@ function ProviderAccountsOverview({
|
||||
) : (
|
||||
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions))}>
|
||||
{visibleAccounts.map((account) => {
|
||||
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} variant={variant} />;
|
||||
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -1978,10 +2007,14 @@ function ProviderAccountsOverview({
|
||||
function ProviderAccountSinglePanel({
|
||||
account,
|
||||
dimensions,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
variant
|
||||
}: {
|
||||
account: ProviderAccountSnapshot;
|
||||
dimensions: OverviewWidgetDimensions;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
refreshing?: boolean;
|
||||
variant: OverviewAccountVariant;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
@@ -1995,8 +2028,9 @@ function ProviderAccountSinglePanel({
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className={cn("truncate font-semibold", dimensions.height <= 1 ? "text-[12px]" : "text-[13px]")}>{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
{providerAccountShowStatus(dimensions) ? <Badge variant={providerAccountBadgeVariant(account.status)}>{account.status}</Badge> : null}
|
||||
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
|
||||
</div>
|
||||
{showQuotaVisual ? (
|
||||
<ProviderAccountQuotaVisual account={account} dimensions={dimensions} meters={quotaMeters} variant={variant} />
|
||||
@@ -2021,10 +2055,14 @@ function ProviderAccountSinglePanel({
|
||||
function ProviderAccountSummaryCard({
|
||||
account,
|
||||
dimensions,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
variant
|
||||
}: {
|
||||
account: ProviderAccountSnapshot;
|
||||
dimensions: OverviewWidgetDimensions;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
refreshing?: boolean;
|
||||
variant: OverviewAccountVariant;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
@@ -2038,8 +2076,9 @@ function ProviderAccountSummaryCard({
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
|
||||
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
|
||||
</div>
|
||||
{providerAccountShowStatus(dimensions) ? <Badge variant={providerAccountBadgeVariant(account.status)}>{account.status}</Badge> : null}
|
||||
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
|
||||
</div>
|
||||
{showQuotaVisual ? (
|
||||
<div className="mt-2 min-h-0 overflow-hidden">
|
||||
@@ -2065,6 +2104,52 @@ function ProviderAccountSummaryCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAccountRefreshButton({
|
||||
account,
|
||||
onRefresh,
|
||||
refreshing = false
|
||||
}: {
|
||||
account: ProviderAccountSnapshot;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
refreshing?: boolean;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const label = refreshing ? t("Refreshing account") : t("Refresh account");
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className="m-0 inline-flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent p-0 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
disabled={refreshing || !onRefresh}
|
||||
title={`${label} (${account.status})`}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onRefresh?.();
|
||||
}}
|
||||
>
|
||||
{refreshing ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatProviderAccountRefreshTime(account: ProviderAccountSnapshot, t: (value: string) => string): string {
|
||||
return `${t("Last updated")}: ${formatProviderAccountUpdatedAt(account.updatedAt) || "-"}`;
|
||||
}
|
||||
|
||||
function formatProviderAccountUpdatedAt(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function ProviderAccountMeterLine({
|
||||
account,
|
||||
dimensions,
|
||||
@@ -2394,7 +2479,11 @@ function providerAccountShowSource(dimensions: OverviewWidgetDimensions): boolea
|
||||
return dimensions.height >= 2 && dimensions.width >= 2;
|
||||
}
|
||||
|
||||
function providerAccountShowStatus(dimensions: OverviewWidgetDimensions): boolean {
|
||||
function providerAccountShowRefreshTime(dimensions: OverviewWidgetDimensions): boolean {
|
||||
return dimensions.height >= 2 && dimensions.width >= 2;
|
||||
}
|
||||
|
||||
function providerAccountShowRefresh(dimensions: OverviewWidgetDimensions): boolean {
|
||||
return dimensions.width >= 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -629,6 +629,8 @@ function ProviderPresetCombobox({
|
||||
const filteredOptions = normalizedQuery
|
||||
? options.filter((option) => providerPresetOptionMatchesQuery(option, normalizedQuery))
|
||||
: options;
|
||||
const selectedExternalUrl = providerPresetOptionPlatformUrl(selected);
|
||||
const selectedDetail = providerPresetOptionDetail(selected, t);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -662,29 +664,66 @@ function ProviderPresetCombobox({
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
setOpen((current) => !current);
|
||||
}
|
||||
|
||||
function openSelectedExternalUrl() {
|
||||
if (!selectedExternalUrl) {
|
||||
return;
|
||||
}
|
||||
openExternalUrl(selectedExternalUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0" ref={rootRef}>
|
||||
<button
|
||||
<div
|
||||
aria-controls="provider-preset-options"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
"flex h-8 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-background px-3 text-left text-[12px] font-medium shadow-[inset_0_1px_1px_rgba(0,0,0,0.03)] outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/25",
|
||||
open && "border-ring/35 bg-muted/40"
|
||||
"flex min-h-[62px] w-full min-w-0 cursor-pointer items-center gap-3 rounded-md border border-border bg-background px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 hover:bg-muted/20 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/25",
|
||||
open && "border-ring/35 bg-muted/30"
|
||||
)}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onClick={toggleOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ProviderPresetIcon className="h-4 w-4 rounded-[4px]" iconUrl={selected?.iconUrl} preset={selected?.preset} />
|
||||
<span className="min-w-0 flex-1 truncate">{selected ? selected.label : t("Select preset provider")}</span>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
<ProviderPresetIcon className="h-10 w-10 rounded-md" iconUrl={selected?.iconUrl} preset={selected?.preset} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-semibold text-foreground">{selected ? selected.label : t("Select preset provider")}</div>
|
||||
{selectedDetail ? (
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={selectedDetail}>{selectedDetail}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedExternalUrl ? (
|
||||
<Button
|
||||
aria-label={t("Open provider website")}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openSelectedExternalUrl();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
size="iconSm"
|
||||
title={t("Open provider website")}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
@@ -794,11 +833,7 @@ function ProviderImportHeader({
|
||||
if (!platformUrl) {
|
||||
return;
|
||||
}
|
||||
if (window.ccr?.openExternal) {
|
||||
void window.ccr.openExternal(platformUrl).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
window.open(platformUrl, "_blank", "noopener,noreferrer");
|
||||
openExternalUrl(platformUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -825,7 +860,15 @@ function ProviderImportHeader({
|
||||
}
|
||||
|
||||
function providerImportPlatformUrl(provider: ProviderDeepLinkPayload, baseUrl: string, preset: ProviderPreset | undefined): string | undefined {
|
||||
return normalizedHttpUrl(provider.source) ?? normalizedHttpUrl(preset?.websiteUrl) ?? providerBaseOrigin(baseUrl || provider.baseUrl);
|
||||
return normalizedHttpUrl(provider.source) ?? providerPresetWebsiteUrlForBaseUrl(preset, baseUrl || provider.baseUrl);
|
||||
}
|
||||
|
||||
function openExternalUrl(url: string) {
|
||||
if (window.ccr?.openExternal) {
|
||||
void window.ccr.openExternal(url).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function providerBaseOrigin(value: string): string | undefined {
|
||||
@@ -874,6 +917,76 @@ function providerPresetOptionMatchesQuery(
|
||||
return haystack.includes(query);
|
||||
}
|
||||
|
||||
function providerPresetOptionDetail(option: ProviderPresetComboboxOption | undefined, t: (value: string) => string): string {
|
||||
if (!option) {
|
||||
return "";
|
||||
}
|
||||
if (option.preset) {
|
||||
return primaryProviderPresetEndpoint(option.preset)?.baseUrl ?? option.preset.websiteUrl ?? "";
|
||||
}
|
||||
if (option.value === customProviderPresetId) {
|
||||
return t("API endpoint");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function providerPresetOptionPlatformUrl(option: ProviderPresetComboboxOption | undefined): string | undefined {
|
||||
if (!option?.preset) {
|
||||
return undefined;
|
||||
}
|
||||
return providerPresetWebsiteUrlForEndpoint(option.preset, primaryProviderPresetEndpoint(option.preset));
|
||||
}
|
||||
|
||||
function providerPresetWebsiteUrlForBaseUrl(preset: ProviderPreset | undefined, baseUrl: string): string | undefined {
|
||||
if (!preset) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedBaseUrl = baseUrl.trim();
|
||||
const endpoint = normalizedBaseUrl
|
||||
? preset.endpoints.find((item) => item.baseUrl.trim() === normalizedBaseUrl)
|
||||
: undefined;
|
||||
return providerPresetWebsiteUrlForEndpoint(preset, endpoint);
|
||||
}
|
||||
|
||||
function providerPresetWebsiteUrlForEndpoint(
|
||||
preset: ProviderPreset,
|
||||
endpoint: ReturnType<typeof primaryProviderPresetEndpoint> | undefined
|
||||
): string | undefined {
|
||||
return normalizedHttpUrl(endpoint?.websiteUrl) ?? normalizedHttpUrl(preset.websiteUrl);
|
||||
}
|
||||
|
||||
function providerDraftNameShouldFollowPreset(
|
||||
name: string,
|
||||
previousPreset: ProviderPreset | undefined,
|
||||
t: (value: string) => string
|
||||
): boolean {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || /^provider-\d+$/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (!previousPreset) {
|
||||
return false;
|
||||
}
|
||||
return [previousPreset.name, t(previousPreset.name)].some((baseName) =>
|
||||
providerNameMatchesGeneratedPresetName(trimmed, baseName)
|
||||
);
|
||||
}
|
||||
|
||||
function providerNameMatchesGeneratedPresetName(name: string, baseName: string): boolean {
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
const normalizedBaseName = baseName.trim().toLowerCase();
|
||||
if (!normalizedBaseName) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedName === normalizedBaseName) {
|
||||
return true;
|
||||
}
|
||||
if (!normalizedName.startsWith(`${normalizedBaseName} `)) {
|
||||
return false;
|
||||
}
|
||||
return /^\d+$/.test(normalizedName.slice(normalizedBaseName.length + 1));
|
||||
}
|
||||
|
||||
function LocalAgentProviderImportPanel({
|
||||
mode,
|
||||
onChange,
|
||||
@@ -1209,7 +1322,8 @@ export function AddProviderForm({
|
||||
|
||||
const preset = findProviderPreset(presetId);
|
||||
const endpoint = preset ? primaryProviderPresetEndpoint(preset) : undefined;
|
||||
const generatedName = !draft.name.trim() || /^provider-\d+$/i.test(draft.name.trim());
|
||||
const previousPreset = findProviderPreset(draft.presetId);
|
||||
const generatedName = providerDraftNameShouldFollowPreset(draft.name, previousPreset, t);
|
||||
const accountDraft = createProviderAccountDraftFromConfig(defaultProviderAccountConfigForPreset(presetId));
|
||||
onChange({
|
||||
...accountDraft,
|
||||
@@ -1240,13 +1354,14 @@ export function AddProviderForm({
|
||||
providerPlugins={[...providerPlugins, ...draft.providerPlugins]}
|
||||
providers={providers}
|
||||
/>
|
||||
<Field label={t("Preset provider")}>
|
||||
<div className="block min-w-0 space-y-1 sm:col-span-2">
|
||||
<span className="block truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{t("Select preset provider")}</span>
|
||||
<ProviderPresetCombobox
|
||||
value={draft.presetId}
|
||||
onChange={updatePreset}
|
||||
options={providerPresetOptions}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Field label={t("Name")}>
|
||||
|
||||
@@ -268,6 +268,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Account component": "Account component",
|
||||
"All accounts": "All accounts",
|
||||
"All credentials": "All credentials",
|
||||
"Last updated": "Last updated",
|
||||
"Refresh account": "Refresh account",
|
||||
"Refreshing account": "Refreshing account",
|
||||
"Add widget": "Add widget",
|
||||
"Analysis component": "Analysis component",
|
||||
"Arc": "Arc",
|
||||
@@ -953,6 +956,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Account component": "账户组件",
|
||||
"All accounts": "所有账户",
|
||||
"All credentials": "全部凭据",
|
||||
"Last updated": "上次更新",
|
||||
"Refresh account": "刷新账户",
|
||||
"Refreshing account": "正在刷新账户",
|
||||
"Add widget": "添加组件",
|
||||
"Analysis component": "分析组件",
|
||||
"Arc": "弧形",
|
||||
@@ -1385,11 +1391,13 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Priority spillover": "优先级溢出",
|
||||
"Ready": "可导入",
|
||||
"Scanning local agent logins": "正在扫描本机 Agent 登录态",
|
||||
"Select preset provider": "选择预设供应商",
|
||||
"Select preset provider": "选择 预设供应商",
|
||||
"ZCode login detected. Click Import to add it as a gateway provider.": "已检测到 ZCode 登录态。点击导入即可添加为网关供应商。",
|
||||
"ZCode login was detected, but its local credential is encrypted and cannot be imported automatically.": "已检测到 ZCode 登录态,但本机凭据已加密,无法自动导入。",
|
||||
"ZCode login was detected, but no usable provider API key was found in ZCode config.": "已检测到 ZCode 登录态,但 ZCode 配置中没有找到可用的供应商 API key。",
|
||||
"ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.": "已在本机 ZCode 配置中检测到供应商 API key。点击导入即可添加为网关供应商。",
|
||||
"Kimi API (China)": "Kimi API(国内)",
|
||||
"Kimi API (Global)": "Kimi API(海外)",
|
||||
"Kimi Code - Coding Plan": "Kimi Code - Coding Plan",
|
||||
"Zhipu AI (China)": "智谱 AI (国内)",
|
||||
"Zhipu AI (China) - Coding Plan": "智谱 AI (国内) - Coding Plan",
|
||||
|
||||
@@ -328,6 +328,7 @@ export const providerPresetIconUrls: Record<string, string> = {
|
||||
"kimi-coding": moonshotProviderIconUrl,
|
||||
mistral: mistralProviderIconUrl,
|
||||
moonshot: moonshotProviderIconUrl,
|
||||
"moonshot-global": moonshotProviderIconUrl,
|
||||
openai: openaiProviderIconUrl,
|
||||
openrouter: openrouterProviderIconUrl,
|
||||
siliconflow: siliconflowProviderIconUrl,
|
||||
|
||||
@@ -1571,7 +1571,10 @@ export function cloneProviderAccountConnectors(connectors: ProviderAccountConnec
|
||||
return JSON.parse(JSON.stringify(connectors)) as ProviderAccountConnectorConfig[];
|
||||
}
|
||||
|
||||
export function defaultProviderAccountConfigForPreset(_presetId: string | undefined): ProviderAccountConfig | undefined {
|
||||
export function defaultProviderAccountConfigForPreset(presetId: string | undefined): ProviderAccountConfig | undefined {
|
||||
if (presetId === "kimi-coding") {
|
||||
return cloneProviderAccountConfig(findProviderPreset(presetId)?.account ?? defaultProviderAccountConfig);
|
||||
}
|
||||
// Keep the advanced settings default on the standard endpoint; main resolves preset-specific connectors at runtime.
|
||||
return cloneProviderAccountConfig(defaultProviderAccountConfig);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app";
|
||||
import { CONFIGDIR } from "../../main/constants";
|
||||
|
||||
type HeaderValue = string | string[] | undefined;
|
||||
|
||||
@@ -99,8 +102,9 @@ export class ClaudeCodeRouterPlugin {
|
||||
}
|
||||
|
||||
try {
|
||||
delete requireFromHere.cache[requireFromHere.resolve(routerPath)];
|
||||
const loaded = requireFromHere(routerPath) as unknown;
|
||||
const resolvedRouterPath = resolveCustomRouterModule(routerPath);
|
||||
delete requireFromHere.cache[resolvedRouterPath];
|
||||
const loaded = requireFromHere(resolvedRouterPath) as unknown;
|
||||
const customRouter = typeof loaded === "function" ? loaded : readDefaultFunction(loaded);
|
||||
if (!customRouter) {
|
||||
request.log.warn(`Custom router does not export a function: ${routerPath}`);
|
||||
@@ -115,6 +119,62 @@ export class ClaudeCodeRouterPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCustomRouterModule(routerPath: string): string {
|
||||
const resolved = requireFromHere.resolve(resolveLocalModulePath(routerPath, "Custom router"));
|
||||
assertJavaScriptModulePath(resolved, "Custom router");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveLocalModulePath(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${label} path is required.`);
|
||||
}
|
||||
|
||||
const expanded = expandHome(trimmed);
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
if (isProtocolSpecifier(expanded)) {
|
||||
throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`);
|
||||
}
|
||||
if (!expanded.startsWith(".")) {
|
||||
throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`);
|
||||
}
|
||||
|
||||
const resolved = path.resolve(CONFIGDIR, expanded);
|
||||
if (!isPathInside(resolved, CONFIGDIR)) {
|
||||
throw new Error(`${label} relative paths must stay inside the CCR config directory.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertJavaScriptModulePath(resolved: string, label: string): void {
|
||||
const extension = path.extname(resolved).toLowerCase();
|
||||
if (![".cjs", ".js", ".mjs"].includes(extension)) {
|
||||
throw new Error(`${label} must resolve to a JavaScript module file.`);
|
||||
}
|
||||
}
|
||||
|
||||
function expandHome(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isProtocolSpecifier(value: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value);
|
||||
}
|
||||
|
||||
function isPathInside(file: string, root: string): boolean {
|
||||
const relative = path.relative(root, file);
|
||||
return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function resolveConfiguredRouteDecision(
|
||||
request: MutableRequestLike,
|
||||
config: AppConfig,
|
||||
|
||||
@@ -3139,12 +3139,9 @@ function applyCors(response: ServerResponse, config?: AppConfig): void {
|
||||
async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise<ApiKeyAuthorizationResult> {
|
||||
let apiKeys = await configuredApiKeys(config);
|
||||
if (apiKeys.length === 0) {
|
||||
if (isLoopbackBindHost(config.gateway.host)) {
|
||||
return { ok: true };
|
||||
}
|
||||
sendJson(response, 403, {
|
||||
error: {
|
||||
message: "Configure at least one CCR API key before listening on a non-loopback gateway host."
|
||||
message: "CCR API key is not initialized. Save a gateway API key or restart CCR to generate one."
|
||||
}
|
||||
});
|
||||
return { ok: false };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
@@ -7,6 +7,9 @@ import forge from "node-forge";
|
||||
import { CERTDIR, PROXY_CA_CERT_DER_FILE, PROXY_CA_CERT_FILE, PROXY_CA_KEY_FILE } from "../../main/constants";
|
||||
|
||||
const pki = forge.pki;
|
||||
const certificateDirectoryMode = 0o700;
|
||||
const certificateFileMode = 0o644;
|
||||
const privateKeyFileMode = 0o600;
|
||||
|
||||
export type CertificateAuthority = {
|
||||
cert: forge.pki.Certificate;
|
||||
@@ -25,8 +28,10 @@ type SubjectAltName = {
|
||||
};
|
||||
|
||||
export function ensureProxyCertificateAuthority(): void {
|
||||
mkdirSync(CERTDIR, { recursive: true });
|
||||
mkdirSync(CERTDIR, { mode: certificateDirectoryMode, recursive: true });
|
||||
securePathPermissions(CERTDIR, certificateDirectoryMode);
|
||||
if (existsSync(PROXY_CA_CERT_FILE) && existsSync(PROXY_CA_KEY_FILE)) {
|
||||
secureCertificateAuthorityFilePermissions();
|
||||
ensureProxyCertificateDerFile();
|
||||
return;
|
||||
}
|
||||
@@ -68,8 +73,9 @@ export function ensureProxyCertificateAuthority(): void {
|
||||
]);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), "utf8");
|
||||
writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), "utf8");
|
||||
writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), { encoding: "utf8", mode: certificateFileMode });
|
||||
writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), { encoding: "utf8", mode: privateKeyFileMode });
|
||||
secureCertificateAuthorityFilePermissions();
|
||||
ensureProxyCertificateDerFile();
|
||||
}
|
||||
|
||||
@@ -214,7 +220,21 @@ function ensureProxyCertificateDerFile(): boolean {
|
||||
|
||||
function writeProxyCertificateDerFile(cert: forge.pki.Certificate): void {
|
||||
const der = forge.asn1.toDer(pki.certificateToAsn1(cert)).getBytes();
|
||||
writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary"));
|
||||
writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary"), { mode: certificateFileMode });
|
||||
securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode);
|
||||
}
|
||||
|
||||
function secureCertificateAuthorityFilePermissions(): void {
|
||||
securePathPermissions(PROXY_CA_CERT_FILE, certificateFileMode);
|
||||
securePathPermissions(PROXY_CA_KEY_FILE, privateKeyFileMode);
|
||||
securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode);
|
||||
}
|
||||
|
||||
function securePathPermissions(file: string, mode: number): void {
|
||||
if (process.platform === "win32" || !existsSync(file)) {
|
||||
return;
|
||||
}
|
||||
chmodSync(file, mode);
|
||||
}
|
||||
|
||||
function createSerialNumber(): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ProviderPresetEndpoint = {
|
||||
baseUrl: string;
|
||||
label?: string;
|
||||
protocols: GatewayProviderProtocol[];
|
||||
websiteUrl?: string;
|
||||
};
|
||||
|
||||
export type ProviderOfficialKeyPattern = {
|
||||
|
||||
Reference in New Issue
Block a user