Refine router configuration and request handling

This commit is contained in:
musistudio 2026-06-23 13:33:46 +08:00
parent aec04e744f
commit c267bb293f
30 changed files with 2153 additions and 84 deletions

View file

@ -14,6 +14,7 @@ import { gatewayService } from "../server/gateway/service";
import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, testProviderAccountConnector } from "./provider-account-service";
import { detectProviderIcon } from "./provider-icons";
import { fetchProviderManifest } from "./provider-manifest-service";
import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "./local-agent-provider-service";
import { getProviderCatalogModels } from "./provider-model-catalog";
import { getProviderPresets } from "./presets";
import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "./provider-probe";
@ -27,7 +28,7 @@ import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import { getUsageStats } from "./usage-store";
import windowsManager from "./windows";
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
const pluginMarketplace: PluginMarketplaceEntry[] = [
{
@ -74,6 +75,7 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig());
ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, () => existsSync(ONBOARDING_FINISHED_FILE));
ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests());
ipcMain.handle(IPC_CHANNELS.appGetLocalAgentProviderCandidates, () => getLocalAgentProviderCandidates());
ipcMain.handle(IPC_CHANNELS.appGetProfileOpenCommand, async (_event, request: ProfileOpenRequest) => {
return getProfileOpenCommand(await loadAppConfig(), request);
});
@ -93,6 +95,7 @@ ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListF
ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus());
ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter));
ipcMain.handle(IPC_CHANNELS.appFetchProviderManifest, (_event, request: ProviderManifestFetchRequest) => fetchProviderManifest(request));
ipcMain.handle(IPC_CHANNELS.appImportLocalAgentProvider, (_event, request: LocalAgentProviderImportRequest) => importLocalAgentProvider(request));
ipcMain.handle(IPC_CHANNELS.appInstallProxyCertificate, () => proxyService.installCertificate());
ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, (_event, server: GatewayMcpServerConfig) => listMcpServerTools(server));
ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => {

View file

@ -0,0 +1,37 @@
import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportRequest,
LocalAgentProviderImportResult
} from "../shared/app";
import { claudeCodeCandidate, importClaudeCodeProvider } from "./local-agent-providers/claude-code";
import { codexCandidate, importCodexProvider } from "./local-agent-providers/codex";
import { importZcodeProvider, zcodeCandidate } from "./local-agent-providers/zcode";
export { codexDefaultBaseUrl, readCodexAuth } from "./local-agent-providers/codex";
export { localAgentProviderApiKey, type OAuthTokenSet } from "./local-agent-providers/shared";
export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[] {
return [
codexCandidate(),
claudeCodeCandidate(),
zcodeCandidate()
].filter((candidate) => candidate.status !== "missing");
}
export function importLocalAgentProvider(request: LocalAgentProviderImportRequest): LocalAgentProviderImportResult {
const candidate = getLocalAgentProviderCandidates().find((item) => item.id === request.id);
if (!candidate) {
throw new Error("Local agent provider was not found.");
}
if (!candidate.importable) {
throw new Error(candidate.detail || "Local agent login is not importable.");
}
if (candidate.kind === "codex") {
return importCodexProvider(candidate, request.providerNames ?? []);
}
if (candidate.kind === "claude-code") {
return importClaudeCodeProvider(candidate, request.providerNames ?? []);
}
return importZcodeProvider(candidate, request.providerNames ?? []);
}

View file

@ -0,0 +1,160 @@
import os from "node:os";
import path from "node:path";
import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportResult,
ProviderAccountConfig,
ProviderAccountMappingConfig
} from "../../shared/app";
import {
bearerAuthPlugin,
findOauthTokenSet,
missingCandidate,
providerInternalNamePlaceholder,
providerPayload,
readJsonRecord,
uniqueProviderName,
uniqueStrings,
type OAuthTokenSet
} from "./shared";
const claudeDefaultModels = ["claude-sonnet-4-20250514"];
const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({
id,
kind: "quota" as const,
label,
limit: 100,
remaining: `100 - ${path}.utilization`,
resetAt: `${path}.resets_at`,
unit: "%",
used: `${path}.utilization`,
window
});
const claudeCodeAccountMapping: ProviderAccountMappingConfig = {
meters: [
percentLimitMapping("claude_five_hour_quota", "5h quota", "$.five_hour", "5h"),
percentLimitMapping("claude_seven_day_quota", "7d quota", "$.seven_day", "7d"),
percentLimitMapping("claude_oauth_apps_quota", "OAuth apps quota", "$.seven_day_oauth_apps", "7d"),
percentLimitMapping("claude_opus_quota", "Opus quota", "$.seven_day_opus", "7d"),
percentLimitMapping("claude_sonnet_quota", "Sonnet quota", "$.seven_day_sonnet", "7d"),
{
id: "claude_extra_usage",
kind: "quota",
label: "Extra usage",
limit: 100,
remaining: "100 - $.extra_usage.utilization",
unit: "%",
used: "$.extra_usage.utilization",
window: "monthly"
},
{
id: "claude_extra_usage_credits",
kind: "balance",
label: "Extra usage credits",
limit: "$.extra_usage.monthly_limit",
unit: "credits",
used: "$.extra_usage.used_credits",
window: "monthly"
}
]
};
export function claudeCodeCandidate(): LocalAgentProviderCandidate {
const oauth = readClaudeCodeOauth();
if (oauth?.accessToken) {
return {
detail: "Claude Code login detected. Click Import to add it as a gateway provider.",
id: "claude-code-api",
importable: true,
kind: "claude-code",
models: claudeDefaultModels,
name: "Claude Code API",
protocol: "anthropic_messages",
sourceFile: oauth.sourceFile,
status: "available"
};
}
if (oauth?.refreshToken) {
return {
detail: "Claude Code login was detected, but no usable access token was found.",
id: "claude-code-api",
importable: false,
kind: "claude-code",
models: claudeDefaultModels,
name: "Claude Code API",
protocol: "anthropic_messages",
sourceFile: oauth.sourceFile,
status: "locked"
};
}
return missingCandidate("claude-code", "claude-code-api", "Claude Code API", "anthropic_messages", claudeDefaultModels);
}
export function importClaudeCodeProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
const oauth = readClaudeCodeOauth();
const token = oauth?.accessToken;
if (!token) {
throw new Error("Claude Code access token was not found.");
}
const provider = providerPayload(
candidate,
uniqueProviderName(providerNames, "Claude Code API"),
"https://api.anthropic.com",
claudeCodeProviderAccountConfig()
);
const auth = bearerAuthPlugin("claude-code-oauth", token, {
"anthropic-beta": "oauth-2025-04-20"
});
const internalAuth = bearerAuthPlugin("claude-code-oauth-internal", token, {
"anthropic-beta": "oauth-2025-04-20"
}, providerInternalNamePlaceholder);
return {
candidate,
provider,
providerPlugins: [auth, internalAuth]
};
}
function claudeCodeProviderAccountConfig(): ProviderAccountConfig {
return {
connectors: [
{
auth: "provider-api-key",
endpoint: "https://api.anthropic.com/api/oauth/usage",
headers: {
"Content-Type": "application/json",
"anthropic-beta": "oauth-2025-04-20"
},
mapping: claudeCodeAccountMapping,
type: "http-json"
}
],
enabled: true
};
}
function readClaudeCodeOauth(): OAuthTokenSet | undefined {
for (const sourceFile of claudeCredentialFiles()) {
const record = readJsonRecord(sourceFile);
if (!record) {
continue;
}
const credential = findOauthTokenSet(record);
return {
accessToken: credential?.accessToken,
refreshToken: credential?.refreshToken,
sourceFile
};
}
return undefined;
}
function claudeCredentialFiles(): string[] {
return uniqueStrings([
path.join(os.homedir(), ".claude", ".credentials.json"),
path.join(os.homedir(), ".claude", "credentials.json"),
path.join(os.homedir(), ".config", "claude", "credentials.json")
]);
}

View file

@ -0,0 +1,233 @@
import os from "node:os";
import path from "node:path";
import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportResult,
ProviderAccountConfig,
ProviderAccountMappingConfig
} from "../../shared/app";
import {
missingCandidate,
providerInternalNamePlaceholder,
providerNamePlaceholder,
providerNameSlugPlaceholder,
providerPayload,
readBoolean,
readJsonRecord,
readString,
uniqueProviderName,
uniqueStrings,
isRecord,
type OAuthTokenSet
} from "./shared";
export const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex";
const codexAccountBaseUrl = "https://chatgpt.com/backend-api";
const codexDefaultModels = ["gpt-5-codex"];
const codexAccountRateLimitMapping: ProviderAccountMappingConfig = {
meters: [
{
id: "codex_primary_quota",
kind: "quota",
label: "Primary quota",
limit: 100,
remaining: "100 - $.rate_limit.primary_window.used_percent",
resetAt: "$.rate_limit.primary_window.reset_at",
unit: "%",
used: "$.rate_limit.primary_window.used_percent",
window: "primary"
},
{
id: "codex_secondary_quota",
kind: "quota",
label: "Secondary quota",
limit: 100,
remaining: "100 - $.rate_limit.secondary_window.used_percent",
resetAt: "$.rate_limit.secondary_window.reset_at",
unit: "%",
used: "$.rate_limit.secondary_window.used_percent",
window: "secondary"
},
{
id: "codex_individual_limit",
kind: "quota",
label: "Individual limit",
limit: "$.spend_control.individual_limit.limit",
remaining: "$.spend_control.individual_limit.remaining",
resetAt: "$.spend_control.individual_limit.reset_at",
unit: "credits",
used: "$.spend_control.individual_limit.used",
window: "monthly"
},
{
id: "codex_credit_balance",
kind: "balance",
label: "Credit balance",
remaining: "$.credits.balance",
unit: "credits"
}
]
};
const codexAccountTokenUsageMapping: ProviderAccountMappingConfig = {
meters: [
{
id: "codex_lifetime_tokens",
kind: "tokens",
label: "Lifetime tokens",
unit: "tokens",
used: "$.stats.lifetime_tokens"
},
{
id: "codex_peak_daily_tokens",
kind: "tokens",
label: "Peak daily tokens",
unit: "tokens",
used: "$.stats.peak_daily_tokens",
window: "daily"
}
]
};
export function codexCandidate(): LocalAgentProviderCandidate {
const auth = readCodexAuth();
const models = readCodexModels();
if (auth?.refreshToken || auth?.accessToken) {
return {
detail: "ChatGPT login detected. Click Import to add it as a gateway provider.",
id: "codex-api",
importable: true,
kind: "codex",
models,
name: "Codex API",
protocol: "openai_responses",
sourceFile: auth.sourceFile,
status: "available"
};
}
return missingCandidate("codex", "codex-api", "Codex API", "openai_responses", models);
}
export function importCodexProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
const auth = readCodexAuth();
if (!auth?.refreshToken && !auth?.accessToken) {
throw new Error("Codex login token was not found.");
}
const provider = providerPayload(candidate, uniqueProviderName(providerNames, "Codex API"), codexDefaultBaseUrl, codexProviderAccountConfig());
return {
candidate,
provider,
providerPlugins: [
codexOauthPlugin("codex-oauth"),
codexOauthPlugin("codex-oauth-internal", providerInternalNamePlaceholder)
].map((plugin) => ({
...plugin,
...(auth.isFedrampAccount ? { auth: { headers: { "X-OpenAI-Fedramp": "true" } } } : {}),
codexOauth: {
accessToken: auth.accessToken,
...(auth.accountId ? { accountId: auth.accountId } : {}),
refreshIfMissingAccessToken: true,
refreshToken: auth.refreshToken,
required: true
}
}))
};
}
export function readCodexAuth(): OAuthTokenSet | undefined {
const sourceFile = path.join(os.homedir(), ".codex", "auth.json");
const record = readJsonRecord(sourceFile);
if (!record) {
return undefined;
}
const tokens = isRecord(record.tokens) ? record.tokens : {};
const idToken = readString(tokens.id_token) || readString(tokens.idToken);
const idTokenClaims = readCodexIdTokenClaims(idToken);
return {
accountId:
readString(tokens.account_id) ||
readString(tokens.accountId) ||
idTokenClaims.accountId,
accessToken: readString(tokens.access_token) || readString(tokens.accessToken),
isFedrampAccount: idTokenClaims.isFedrampAccount,
refreshToken: readString(tokens.refresh_token) || readString(tokens.refreshToken),
sourceFile
};
}
function codexProviderAccountConfig(): ProviderAccountConfig {
return {
connectors: [
{
auth: "provider-api-key",
endpoint: `${codexAccountBaseUrl}/wham/usage`,
headers: {
"User-Agent": "codex-cli"
},
mapping: codexAccountRateLimitMapping,
type: "http-json"
},
{
auth: "provider-api-key",
endpoint: `${codexAccountBaseUrl}/wham/profiles/me`,
headers: {
"User-Agent": "codex-cli"
},
mapping: codexAccountTokenUsageMapping,
type: "http-json"
}
],
enabled: true
};
}
function codexOauthPlugin(suffix: string, providerName = providerNamePlaceholder): Record<string, unknown> {
return {
key: `ccr-local-agent-${providerNameSlugPlaceholder}-${suffix}`,
providerName,
request: codexBackendRequestTransform()
};
}
function codexBackendRequestTransform(): Record<string, unknown> {
return {
bodyRemove: ["max_output_tokens"]
};
}
function readCodexModels(): string[] {
const modelsFile = path.join(os.homedir(), ".codex", "models_cache.json");
const record = readJsonRecord(modelsFile);
const models = Array.isArray(record?.models)
? record.models.map((model) => isRecord(model) ? readString(model.slug) || readString(model.id) || readString(model.name) : readString(model))
: [];
return uniqueStrings([...models, ...codexDefaultModels]);
}
function readCodexIdTokenClaims(idToken: string | undefined): { accountId?: string; isFedrampAccount?: boolean } {
const payload = readJwtPayload(idToken);
const auth = isRecord(payload?.["https://api.openai.com/auth"])
? payload["https://api.openai.com/auth"]
: {};
return {
accountId: readString(auth.chatgpt_account_id) || readString(auth.account_id) || readString(auth.accountId),
isFedrampAccount: readBoolean(auth.chatgpt_account_is_fedramp)
};
}
function readJwtPayload(jwt: string | undefined): Record<string, unknown> | undefined {
const encoded = jwt?.split(".")[1];
if (!encoded) {
return undefined;
}
try {
const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "=");
const decoded = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
const payload = JSON.parse(decoded) as unknown;
return isRecord(payload) ? payload : undefined;
} catch {
return undefined;
}
}

View file

@ -0,0 +1,192 @@
import { existsSync, readFileSync } from "node:fs";
import type {
GatewayProviderProtocol,
LocalAgentProviderCandidate,
LocalAgentProviderKind,
ProviderAccountConfig,
ProviderDeepLinkPayload
} from "../../shared/app";
export type OAuthTokenSet = {
accountId?: string;
accessToken?: string;
isFedrampAccount?: boolean;
refreshToken?: string;
sourceFile: string;
};
export type ApiTokenSet = {
sourceFile: string;
hasSharedLogin?: boolean;
};
export const providerNamePlaceholder = "__CCR_PROVIDER_NAME__";
export const providerNameSlugPlaceholder = "__CCR_PROVIDER_NAME_SLUG__";
export const providerInternalNamePlaceholder = "__CCR_PROVIDER_INTERNAL_NAME__";
export const localAgentProviderApiKey = "ccr-local-agent-login";
export function missingCandidate(
kind: LocalAgentProviderKind,
id: string,
name: string,
protocol: GatewayProviderProtocol,
models: string[]
): LocalAgentProviderCandidate {
return {
detail: "No local login state was found for this agent.",
id,
importable: false,
kind,
models,
name,
protocol,
status: "missing"
};
}
export function providerPayload(
candidate: LocalAgentProviderCandidate,
name: string,
baseUrl: string,
account?: ProviderAccountConfig
): ProviderDeepLinkPayload {
return {
account,
apiKey: localAgentProviderApiKey,
baseUrl,
models: uniqueStrings(candidate.models).slice(0, 24),
name,
protocol: candidate.protocol
};
}
export function bearerAuthPlugin(
suffix: string,
token: string,
headers: Record<string, string> = {},
providerName = providerNamePlaceholder
): Record<string, unknown> {
return {
auth: {
headers: {
authorization: `Bearer ${token}`,
...headers
},
removeHeaders: ["x-api-key"],
strict: true
},
key: `ccr-local-agent-${providerNameSlugPlaceholder}-${suffix}`,
providerName
};
}
export function apiKeyAuthPlugin(
suffix: string,
apiKey: string,
providerName = providerNamePlaceholder
): Record<string, unknown> {
return {
auth: {
headers: {
"x-api-key": apiKey
},
removeHeaders: ["authorization"],
strict: true
},
key: `ccr-local-agent-${providerNameSlugPlaceholder}-${suffix}`,
providerName
};
}
export function cloneProviderAccountConfig(account: ProviderAccountConfig | undefined): ProviderAccountConfig | undefined {
return account ? JSON.parse(JSON.stringify(account)) as ProviderAccountConfig : undefined;
}
export function findOauthTokenSet(value: unknown, depth = 0): { accessToken?: string; refreshToken?: string } | undefined {
if (!isRecord(value) || depth > 5) {
return undefined;
}
const accessToken =
readString(value.accessToken) ||
readString(value.access_token) ||
readString(value.anthropicAccessToken);
const refreshToken =
readString(value.refreshToken) ||
readString(value.refresh_token) ||
readString(value.anthropicRefreshToken);
if (accessToken || refreshToken) {
return { accessToken, refreshToken };
}
for (const child of Object.values(value)) {
const found = findOauthTokenSet(child, depth + 1);
if (found) {
return found;
}
}
return undefined;
}
export function readJsonRecord(file: string): Record<string, unknown> | undefined {
if (!existsSync(file)) {
return undefined;
}
try {
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
export function uniqueProviderName(existingNames: string[], baseName: string): string {
const existing = new Set(existingNames.map((name) => name.trim().toLowerCase()).filter(Boolean));
if (!existing.has(baseName.toLowerCase())) {
return baseName;
}
for (let index = 2; index < 1000; index += 1) {
const candidate = `${baseName} ${index}`;
if (!existing.has(candidate.toLowerCase())) {
return candidate;
}
}
return `${baseName} ${Date.now()}`;
}
export function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export function firstString(values: Array<string | undefined>): string {
return values.find((value): value is string => Boolean(value)) ?? "";
}
export function uniqueStrings(values: Array<string | undefined>): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const item = value?.trim();
if (!item || seen.has(item)) {
continue;
}
seen.add(item);
result.push(item);
}
return result;
}
export function isLoopbackUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
} catch {
return false;
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,250 @@
import os from "node:os";
import path from "node:path";
import type {
LocalAgentProviderCandidate,
LocalAgentProviderImportResult,
ProviderAccountConfig
} from "../../shared/app";
import { findProviderPresetByBaseUrl } from "../presets";
import {
apiKeyAuthPlugin,
cloneProviderAccountConfig,
firstString,
isLoopbackUrl,
isRecord,
missingCandidate,
providerInternalNamePlaceholder,
providerPayload,
readJsonRecord,
readString,
uniqueProviderName,
uniqueStrings,
type ApiTokenSet
} from "./shared";
type ZcodeConfiguredProvider = {
apiKey: string;
baseUrl: string;
models: string[];
name: string;
providerId: string;
sourceFile: string;
};
const zcodeDefaultModels = ["GLM-5.2", "GLM-5-Turbo"];
const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic";
export function zcodeCandidate(): LocalAgentProviderCandidate {
const configuredProvider = readZcodeConfiguredProvider();
const zcodeRuntime = readZcodeRuntime();
const models = configuredProvider?.models.length
? configuredProvider.models
: zcodeRuntime.models.length > 0 ? zcodeRuntime.models : zcodeDefaultModels;
if (configuredProvider) {
return {
detail: "ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.",
id: "zcode-api",
importable: true,
kind: "zcode",
models,
name: "ZCode API",
protocol: "anthropic_messages",
sourceFile: configuredProvider.sourceFile,
status: "available"
};
}
const credentials = readZcodeSharedLogin();
if (credentials?.hasSharedLogin) {
return {
detail: "ZCode login was detected, but no usable provider API key was found in ZCode config.",
id: "zcode-api",
importable: false,
kind: "zcode",
models,
name: "ZCode API",
protocol: "anthropic_messages",
sourceFile: credentials.sourceFile,
status: "locked"
};
}
return missingCandidate("zcode", "zcode-api", "ZCode API", "anthropic_messages", models);
}
export function importZcodeProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult {
const configuredProvider = readZcodeConfiguredProvider();
if (!configuredProvider) {
throw new Error("ZCode provider API key was not found in ZCode config.");
}
const provider = providerPayload(
{ ...candidate, models: configuredProvider.models.length > 0 ? configuredProvider.models : candidate.models },
uniqueProviderName(providerNames, "ZCode API"),
configuredProvider.baseUrl,
zcodeProviderAccountConfig(configuredProvider.baseUrl)
);
return {
candidate,
provider,
providerPlugins: [
apiKeyAuthPlugin("zcode-api-key", configuredProvider.apiKey),
apiKeyAuthPlugin("zcode-api-key-internal", configuredProvider.apiKey, providerInternalNamePlaceholder)
]
};
}
function zcodeProviderAccountConfig(baseUrl: string): ProviderAccountConfig | undefined {
return cloneProviderAccountConfig(findProviderPresetByBaseUrl(baseUrl)?.account);
}
function readZcodeSharedLogin(): ApiTokenSet | undefined {
for (const sourceFile of zcodeCredentialFiles()) {
const record = readJsonRecord(sourceFile);
if (!record) {
continue;
}
const rawToken =
readString(record.zcodejwttoken) ||
readString(record["oauth:zai:access_token"]) ||
readString(record["oauth:zai:refresh_token"]) ||
readString(record["oauth:bigmodel:access_token"]) ||
readString(record["oauth:bigmodel:refresh_token"]) ||
readString(record["oauth:active_provider"]) ||
readString(record.access_token) ||
readString(record.accessToken);
if (rawToken) {
return {
sourceFile,
hasSharedLogin: true
};
}
}
return undefined;
}
function readZcodeConfiguredProvider(): ZcodeConfiguredProvider | undefined {
const candidates = zcodeConfigFiles()
.flatMap((sourceFile) => readZcodeConfiguredProviders(sourceFile));
return candidates.find((provider) => provider.apiKey.trim() && provider.baseUrl.trim());
}
function readZcodeConfiguredProviders(sourceFile: string): ZcodeConfiguredProvider[] {
const record = readJsonRecord(sourceFile);
const providers = isRecord(record?.provider) ? record.provider : undefined;
if (!providers) {
return [];
}
return Object.entries(providers)
.flatMap(([providerId, value]) => {
if (!isRecord(value) || !isZcodeModelProvider(providerId, value)) {
return [];
}
const options = isRecord(value.options) ? value.options : {};
const apiKey = readString(options.apiKey) || readString(options.api_key) || readString(value.apiKey) || readString(value.api_key);
const baseUrl =
readString(options.baseURL) ||
readString(options.baseUrl) ||
readString(isRecord(value.endpoints) ? value.endpoints.baseURL : undefined) ||
readString(isRecord(value.endpoints) ? value.endpoints.baseUrl : undefined);
if (!apiKey || !baseUrl) {
return [];
}
return [{
apiKey,
baseUrl,
models: zcodeProviderModels(value),
name: readString(value.name) || providerId,
providerId,
sourceFile
}];
});
}
function readZcodeRuntime(): { baseUrl: string; models: string[] } {
const cache = readJsonRecord(path.join(os.homedir(), ".zcode", "v2", "bots-model-cache.v2.json"));
const providers = Array.isArray(cache?.providers)
? cache.providers.filter((provider): provider is Record<string, unknown> => isRecord(provider))
: [];
const provider = providers.find((item) => {
const text = [
readString(item.id),
readString(item.name),
readString(isRecord(item.endpoints) ? item.endpoints.baseURL : undefined)
].join(" ").toLowerCase();
return text.includes("zcode") || text.includes("z.ai") || text.includes("bigmodel");
});
const baseUrl = readString(isRecord(provider?.endpoints) ? provider?.endpoints.baseURL : undefined) || zcodeDefaultBaseUrl;
const models = Array.isArray(provider?.models)
? provider.models.map((model) => isRecord(model) ? readString(model.id) || readString(model.name) : readString(model))
: [];
return {
baseUrl,
models: uniqueStrings([...models, ...zcodeDefaultModels])
};
}
function zcodeProviderModels(provider: Record<string, unknown>): string[] {
if (Array.isArray(provider.models)) {
return uniqueStrings(provider.models.map((model) => isRecord(model) ? readString(model.id) || readString(model.name) : readString(model)));
}
if (isRecord(provider.models)) {
return uniqueStrings(Object.entries(provider.models).map(([key, value]) => isRecord(value) ? readString(value.id) || key : key));
}
return [];
}
function isZcodeModelProvider(providerId: string, provider: Record<string, unknown>): boolean {
if (provider.enabled === false || readString(provider.systemDisabledReason)) {
return false;
}
const options = isRecord(provider.options) ? provider.options : {};
const endpoints = isRecord(provider.endpoints) ? provider.endpoints : {};
const baseUrl = firstString([
readString(options.baseURL),
readString(options.baseUrl),
readString(endpoints.baseURL),
readString(endpoints.baseUrl)
]);
const baseUrlText = baseUrl.toLowerCase();
if (isLoopbackUrl(baseUrl)) {
return false;
}
const text = [
providerId,
readString(provider.name),
baseUrlText
].join(" ").toLowerCase();
const matchesZcodeProvider =
text.includes("z.ai") ||
text.includes("zai") ||
text.includes("zcode") ||
text.includes("bigmodel") ||
text.includes("open.bigmodel.cn");
if (!matchesZcodeProvider || text.includes("claude-code-router")) {
return false;
}
const kind = [
readString(provider.kind),
readString(provider.apiFormat),
readString(provider.defaultKind),
readString(isRecord(endpoints.paths) ? endpoints.paths.anthropic : undefined)
].join(" ").toLowerCase();
return kind.includes("anthropic") || baseUrlText.includes("/anthropic");
}
function zcodeCredentialFiles(): string[] {
return uniqueStrings([
path.join(os.homedir(), ".zcode", "v2", "credentials.json"),
path.join(os.homedir(), ".zcode", "credentials.json")
]);
}
function zcodeConfigFiles(): string[] {
return uniqueStrings([
path.join(os.homedir(), ".zcode", "v2", "config.json"),
path.join(os.homedir(), ".zcode", "cli", "config.json")
]);
}

View file

@ -1,3 +1,5 @@
import { fetchWithSystemProxy } from "./system-proxy-fetch";
type ModelPricingSource = "litellm" | "models.dev" | "openrouter";
export type UsageCostInput = {
@ -313,7 +315,7 @@ async function fetchJson(url: string): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const response = await fetch(url, {
const response = await fetchWithSystemProxy(url, {
headers: { accept: "application/json" },
signal: controller.signal
});

View file

@ -28,6 +28,9 @@ import type {
GatewayProviderProbeRequest,
GatewayProviderProbeResult,
GatewayStatus,
LocalAgentProviderCandidate,
LocalAgentProviderImportRequest,
LocalAgentProviderImportResult,
PluginDirectorySelection,
PluginMarketplaceEntry,
ProfileOpenCommandResult,
@ -73,6 +76,7 @@ contextBridge.exposeInMainWorld("ccr", {
getAppInfo: () => ipcRenderer.invoke(IPC_CHANNELS.appGetInfo) as Promise<AppInfo>,
getConfig: () => ipcRenderer.invoke(IPC_CHANNELS.appGetConfig) as Promise<AppConfig>,
getGatewayStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetGatewayStatus) as Promise<GatewayStatus>,
getLocalAgentProviderCandidates: () => ipcRenderer.invoke(IPC_CHANNELS.appGetLocalAgentProviderCandidates) as Promise<LocalAgentProviderCandidate[]>,
getOnboardingFinished: () => ipcRenderer.invoke(IPC_CHANNELS.appGetOnboardingFinished) as Promise<boolean>,
getPendingProviderDeepLinks: () => ipcRenderer.invoke(IPC_CHANNELS.appGetPendingProviderDeepLinks) as Promise<ProviderDeepLinkRequest[]>,
getProfileOpenCommand: (request: ProfileOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appGetProfileOpenCommand, request) as Promise<ProfileOpenCommandResult>,
@ -88,6 +92,7 @@ contextBridge.exposeInMainWorld("ccr", {
getUpdateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise<AppUpdateStatus>,
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise<UsageStatsSnapshot>,
installProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appInstallProxyCertificate) as Promise<ProxyCertificateInstallResult>,
importLocalAgentProvider: (request: LocalAgentProviderImportRequest) => ipcRenderer.invoke(IPC_CHANNELS.appImportLocalAgentProvider, request) as Promise<LocalAgentProviderImportResult>,
listMcpServerTools: (server: GatewayMcpServerConfig) => ipcRenderer.invoke(IPC_CHANNELS.appListMcpServerTools, server) as Promise<GatewayMcpToolInfo[]>,
openBuiltInBrowser: () => ipcRenderer.invoke(IPC_CHANNELS.appOpenBuiltInBrowser) as Promise<void>,
openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => ipcRenderer.invoke(IPC_CHANNELS.appBotGatewayQrWindowOpen, request) as Promise<BotGatewayQrWindowOpenResult>,

View file

@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { loadAppConfig } from "./config";
import { localAgentProviderApiKey, readCodexAuth } from "./local-agent-provider-service";
import { pluginService } from "./plugins/service";
import { getUsageTotalsSince } from "./usage-store";
import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey } from "./presets";
import { fetchWithSystemProxy } from "./system-proxy-fetch";
import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "../shared/provider-url";
import type {
AppConfig,
@ -50,6 +52,11 @@ type ProviderAccountTarget = {
provider: GatewayProviderConfig;
};
type MaterializedProviderAccountRequest = {
headers?: Record<string, string>;
provider: GatewayProviderConfig;
};
const defaultRefreshIntervalMs = 5 * 60 * 1000;
const minRefreshIntervalMs = 30 * 1000;
const maxErrorRefreshIntervalMs = 60 * 1000;
@ -376,10 +383,10 @@ async function resolveConnector(
): Promise<ConnectorResult> {
try {
if (connector.type === "standard") {
return await resolveStandardConnector(provider, connector);
return await resolveStandardConnector(config, provider, connector);
}
if (connector.type === "http-json") {
return await resolveHttpJsonConnector(provider, connector);
return await resolveHttpJsonConnector(config, provider, connector);
}
if (connector.type === "plugin") {
return await resolvePluginConnector(config, provider, connector, now);
@ -394,6 +401,7 @@ async function resolveConnector(
}
async function resolveStandardConnector(
config: AppConfig,
provider: GatewayProviderConfig,
connector: ProviderAccountStandardConnectorConfig
): Promise<ConnectorResult> {
@ -402,7 +410,13 @@ async function resolveStandardConnector(
for (const endpoint of endpoints) {
try {
const payload = await fetchJson(endpoint, provider, connector.auth, connector.headers);
const request = providerAccountConnectorUsesProviderApiKey(connector)
? materializeProviderAccountRequest(config, provider)
: { provider };
const payload = await fetchJson(endpoint, request.provider, connector.auth, {
...(connector.headers ?? {}),
...(request.headers ?? {})
});
const snapshot = normalizeRemoteSnapshot(provider.name, payload, "standard");
if (snapshot.meters.length > 0 || snapshot.status !== "unsupported") {
return {
@ -422,10 +436,17 @@ async function resolveStandardConnector(
}
async function resolveHttpJsonConnector(
config: AppConfig,
provider: GatewayProviderConfig,
connector: ProviderAccountHttpJsonConnectorConfig
): Promise<ConnectorResult> {
const payload = await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body);
const request = providerAccountConnectorUsesProviderApiKey(connector)
? materializeProviderAccountRequest(config, provider)
: { provider };
const payload = await fetchJson(connector.endpoint, request.provider, connector.auth, {
...(connector.headers ?? {}),
...(request.headers ?? {})
}, connector.method, connector.body);
const meters = connector.mapping.meters
.map((meter) => mappedMeterFromPayload(meter, payload))
.filter((meter): meter is ProviderAccountMeter => Boolean(meter));
@ -675,6 +696,140 @@ function normalizeMeter(value: unknown, source: ProviderAccountConnectorSource):
};
}
function materializeProviderAccountRequest(
config: AppConfig,
provider: GatewayProviderConfig
): MaterializedProviderAccountRequest {
if (providerApiKey(provider) !== localAgentProviderApiKey) {
return { provider };
}
const credential = localAgentProviderAccountCredential(config, provider);
if (!credential?.apiKey) {
throw new Error("Local agent account credential was not found. Sign in again, then re-import the local login provider.");
}
return {
headers: credential.headers,
provider: {
...provider,
api_key: credential.apiKey,
apiKey: undefined,
apikey: undefined
}
};
}
function providerAccountConnectorUsesProviderApiKey(
connector: ProviderAccountStandardConnectorConfig | ProviderAccountHttpJsonConnectorConfig
): boolean {
return (connector.auth ?? "provider-api-key") !== "none";
}
function localAgentProviderAccountCredential(
config: AppConfig,
provider: GatewayProviderConfig
): { apiKey?: string; headers?: Record<string, string> } | undefined {
for (const plugin of config.providerPlugins ?? []) {
if (!localAgentProviderPluginMatches(plugin, provider)) {
continue;
}
const key = readString((plugin as { key?: unknown }).key)?.toLowerCase() ?? "";
if (key.includes("codex-oauth")) {
return localCodexAccountCredential(plugin);
}
if (key.includes("claude-code-oauth")) {
return localBearerAccountCredential(plugin);
}
if (key.includes("zcode-api-key")) {
return localApiKeyHeaderAccountCredential(plugin);
}
}
return undefined;
}
function localAgentProviderPluginMatches(plugin: unknown, provider: GatewayProviderConfig): plugin is Record<string, unknown> {
if (!isRecord(plugin)) {
return false;
}
const key = readString(plugin.key)?.toLowerCase() ?? "";
if (!key.startsWith("ccr-local-agent-")) {
return false;
}
const pluginProviderName = readString(plugin.providerName) || readString(plugin.provider);
if (!pluginProviderName) {
return false;
}
const providerNames = new Set([
provider.name,
provider.type ? `${provider.name}::${provider.type}` : ""
].map((value) => value.trim().toLowerCase()).filter(Boolean));
return providerNames.has(pluginProviderName.trim().toLowerCase());
}
function localCodexAccountCredential(plugin: Record<string, unknown>): { apiKey?: string; headers?: Record<string, string> } {
const codexOauth = isRecord(plugin.codexOauth) ? plugin.codexOauth : {};
const codexAuth = readCodexAuth();
const apiKey =
readString(codexOauth.accessToken) ||
readString(codexOauth.access_token) ||
codexAuth?.accessToken;
const accountId =
readString(codexOauth.accountId) ||
readString(codexOauth.account_id) ||
codexAuth?.accountId;
const headers = {
...localProviderPluginAuthHeaders(plugin),
...(accountId ? { "ChatGPT-Account-Id": accountId } : {}),
...(codexAuth?.isFedrampAccount ? { "X-OpenAI-Fedramp": "true" } : {})
};
return {
apiKey,
headers
};
}
function localBearerAccountCredential(plugin: Record<string, unknown>): { apiKey?: string; headers?: Record<string, string> } {
const headers = localProviderPluginAuthHeaders(plugin);
const apiKey = readBearerToken(headers.authorization || headers.Authorization);
return {
apiKey,
headers: withoutHeader(headers, "authorization")
};
}
function localApiKeyHeaderAccountCredential(plugin: Record<string, unknown>): { apiKey?: string; headers?: Record<string, string> } {
const headers = localProviderPluginAuthHeaders(plugin);
const apiKey = headers["x-api-key"] || headers["X-API-Key"];
return {
apiKey,
headers: withoutHeader(headers, "x-api-key")
};
}
function localProviderPluginAuthHeaders(plugin: Record<string, unknown>): Record<string, string> {
const auth = isRecord(plugin.auth) ? plugin.auth : {};
const headers = isRecord(auth.headers) ? auth.headers : {};
return Object.fromEntries(
Object.entries(headers)
.map(([key, value]) => [key, readString(value)] as const)
.filter((entry): entry is readonly [string, string] => Boolean(entry[1]))
);
}
function withoutHeader(headers: Record<string, string>, header: string): Record<string, string> {
const normalized = header.toLowerCase();
return Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== normalized));
}
function readBearerToken(value: string | undefined): string | undefined {
const match = value?.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || undefined;
}
async function fetchJson(
endpoint: string,
provider: GatewayProviderConfig,
@ -704,7 +859,7 @@ async function fetchJson(
requestHeaders["content-type"] = requestHeaders["content-type"] ?? "application/json";
}
const response = await fetch(endpoint, {
const response = await fetchWithSystemProxy(endpoint, {
body: method === "POST" ? JSON.stringify(body ?? {}) : undefined,
headers: requestHeaders,
method

View file

@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { PROVIDER_ICON_CACHE_DIR } from "./constants";
import { fetchWithSystemProxy } from "./system-proxy-fetch";
import type { ProviderIconDetectionRequest, ProviderIconDetectionResult } from "../shared/app";
import { compactProviderUrl, providerUrlWithDefaultScheme } from "../shared/provider-url";
@ -199,7 +200,7 @@ async function fetchWithTimeout(url: string): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), providerIconFetchTimeoutMs);
try {
return await fetch(url, {
return await fetchWithSystemProxy(url, {
headers: {
"User-Agent": providerIconUserAgent,
Accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.9,text/html;q=0.6,*/*;q=0.1"

View file

@ -12,6 +12,7 @@ import type {
GatewayProviderProtocol
} from "../shared/app";
import { providerApiKeySafetyIssue } from "./presets";
import { fetchWithSystemProxy } from "./system-proxy-fetch";
import {
compactProviderUrl,
parseProviderBaseUrl,
@ -632,7 +633,7 @@ async function requestJson(url: string, init: RequestInit): Promise<FetchJsonRes
const timer = setTimeout(() => controller.abort(), probeTimeoutMs);
try {
const response = await fetch(url, {
const response = await fetchWithSystemProxy(url, {
...init,
signal: controller.signal
});

View file

@ -0,0 +1,193 @@
import { ProxyAgent, type Dispatcher } from "undici";
import { loadAppConfig } from "./config";
import { readCurrentSystemUpstreamProxy, systemProxyManager, type UpstreamProxyConfig, type UpstreamProxyServer } from "../server/proxy/system-proxy";
import type { AppConfig } from "../shared/app";
type FetchInitWithDispatcher = RequestInit & {
dispatcher?: Dispatcher;
};
type SystemProxyCache = {
expiresAt: number;
managedEndpointUrl: string;
upstreamProxy?: UpstreamProxyConfig;
};
const proxyRefreshIntervalMs = 30 * 1000;
const fallbackManagedEndpointUrl = "http://127.0.0.1:65535";
const proxyDispatchers = new Map<string, Dispatcher>();
let systemProxyCache: SystemProxyCache | undefined;
let systemProxyReadPromise: Promise<SystemProxyCache> | undefined;
export async function fetchWithSystemProxy(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = requestUrl(input);
if (!url || !isHttpUrl(url) || shouldBypassProxy(url)) {
return fetch(input, init);
}
const proxyUrl = await systemProxyUrlForRequest(url);
if (!proxyUrl) {
return fetch(input, init);
}
return fetch(input, {
...init,
dispatcher: proxyDispatcher(proxyUrl)
} as FetchInitWithDispatcher);
}
export async function getSystemProxyUrlForProtocol(protocol: "http" | "https" = "https"): Promise<string | undefined> {
const cache = await readSystemProxy();
const server = proxyServerForRequest(cache.upstreamProxy, protocol);
return server ? formatProxyUrl(server) : undefined;
}
async function systemProxyUrlForRequest(url: URL): Promise<string | undefined> {
const cache = await readSystemProxy();
const server = proxyServerForRequest(cache.upstreamProxy, url.protocol === "https:" ? "https" : "http");
return server ? formatProxyUrl(server) : undefined;
}
async function readSystemProxy(): Promise<SystemProxyCache> {
const now = Date.now();
if (systemProxyCache && systemProxyCache.expiresAt > now) {
return systemProxyCache;
}
if (systemProxyReadPromise) {
return systemProxyReadPromise;
}
systemProxyReadPromise = readSystemProxyUncached()
.then((cache) => {
systemProxyCache = {
...cache,
expiresAt: Date.now() + proxyRefreshIntervalMs
};
return systemProxyCache;
})
.finally(() => {
systemProxyReadPromise = undefined;
});
return systemProxyReadPromise;
}
async function readSystemProxyUncached(): Promise<Omit<SystemProxyCache, "expiresAt">> {
const { managedEndpointUrl, systemProxyActive } = await readManagedProxyEndpoint();
if (systemProxyActive) {
const managedUpstreamProxy = systemProxyManager.getUpstreamProxy();
if (managedUpstreamProxy) {
return {
managedEndpointUrl,
upstreamProxy: managedUpstreamProxy
};
}
}
try {
return {
managedEndpointUrl,
upstreamProxy: await readCurrentSystemUpstreamProxy(managedEndpointUrl)
};
} catch (error) {
console.warn(`[network] Failed to read system proxy: ${formatError(error)}`);
return { managedEndpointUrl };
}
}
async function readManagedProxyEndpoint(): Promise<{ managedEndpointUrl: string; systemProxyActive: boolean }> {
try {
const config = await loadAppConfig();
if (config.proxy.enabled && config.proxy.systemProxy) {
return {
managedEndpointUrl: managedProxyEndpointUrl(config),
systemProxyActive: true
};
}
} catch (error) {
console.warn(`[network] Failed to read proxy config: ${formatError(error)}`);
}
return {
managedEndpointUrl: fallbackManagedEndpointUrl,
systemProxyActive: false
};
}
function managedProxyEndpointUrl(config: AppConfig): string {
const host = normalizeManagedProxyHost(config.proxy.host);
return `http://${formatProxyHost(host)}:${config.proxy.port}`;
}
function normalizeManagedProxyHost(host: string): string {
const normalized = host.trim();
if (!normalized || normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]") {
return "127.0.0.1";
}
return normalized;
}
function proxyServerForRequest(upstreamProxy: UpstreamProxyConfig | undefined, protocol: "http" | "https"): UpstreamProxyServer | undefined {
if (!upstreamProxy) {
return undefined;
}
if (protocol === "https") {
return upstreamProxy.https ?? upstreamProxy.http;
}
return upstreamProxy.http ?? upstreamProxy.https;
}
function proxyDispatcher(proxyUrl: string): Dispatcher {
const existing = proxyDispatchers.get(proxyUrl);
if (existing) {
return existing;
}
const dispatcher = new ProxyAgent(proxyUrl);
proxyDispatchers.set(proxyUrl, dispatcher);
return dispatcher;
}
function formatProxyUrl(server: UpstreamProxyServer): string {
return `${server.protocol}://${formatProxyHost(server.host)}:${server.port}`;
}
function formatProxyHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function requestUrl(input: RequestInfo | URL): URL | undefined {
try {
if (input instanceof URL) {
return input;
}
if (typeof input === "string") {
return new URL(input);
}
return new URL(input.url);
} catch {
return undefined;
}
}
function isHttpUrl(url: URL): boolean {
return url.protocol === "http:" || url.protocol === "https:";
}
function shouldBypassProxy(url: URL): boolean {
const hostname = normalizeHostname(url.hostname);
return hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("127.") ||
hostname === "0.0.0.0" ||
hostname === "::1" ||
hostname === "0:0:0:0:0:0:0:1";
}
function normalizeHostname(hostname: string): string {
return hostname.trim().toLowerCase().replace(/^\[(.*)]$/, "$1").replace(/\.$/, "");
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -54,6 +54,102 @@ type ProfileActionBusy = {
type UpdateActionBusy = "" | "download" | "install";
const providerNamePlaceholder = "__CCR_PROVIDER_NAME__";
const providerNameSlugPlaceholder = "__CCR_PROVIDER_NAME_SLUG__";
const providerInternalNamePlaceholder = "__CCR_PROVIDER_INTERNAL_NAME__";
const localAgentProviderApiKey = "ccr-local-agent-login";
function materializeProviderPluginTemplates(
templates: unknown[],
providerName: string,
protocol: GatewayProviderConfig["type"]
): unknown[] {
if (templates.length === 0) {
return [];
}
const internalName = protocol ? `${providerName}::${protocol}` : providerName;
const replacements: Record<string, string> = {
[providerInternalNamePlaceholder]: internalName,
[providerNamePlaceholder]: providerName,
[providerNameSlugPlaceholder]: providerNameSlug(providerName)
};
return templates.map((template) => replaceProviderPluginPlaceholders(template, replacements));
}
function replaceProviderPluginPlaceholders(value: unknown, replacements: Record<string, string>): unknown {
if (typeof value === "string") {
return Object.entries(replacements).reduce((result, [search, replacement]) => result.split(search).join(replacement), value);
}
if (Array.isArray(value)) {
return value.map((item) => replaceProviderPluginPlaceholders(item, replacements));
}
if (isPlainRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, replaceProviderPluginPlaceholders(item, replacements)])
);
}
return value;
}
function mergeProviderPlugins(current: unknown[] | undefined, additions: unknown[]): unknown[] | undefined {
if (additions.length === 0) {
return current;
}
const addedKeys = new Set(additions.map(providerPluginKey).filter((key): key is string => Boolean(key)));
const retained = (current ?? []).filter((plugin) => {
const key = providerPluginKey(plugin);
return !key || !addedKeys.has(key);
});
return [...retained, ...additions];
}
function providerPluginKey(value: unknown): string | undefined {
return isPlainRecord(value) && typeof value.key === "string" && value.key.trim() ? value.key.trim() : undefined;
}
function removeLocalAgentProviderPluginsForProvider(
current: unknown[] | undefined,
provider: GatewayProviderConfig | undefined
): unknown[] | undefined {
if (!provider || providerApiKeyValue(provider) !== localAgentProviderApiKey) {
return current;
}
const providerNames = new Set([
provider.name,
provider.type ? `${provider.name}::${provider.type}` : ""
].map((value) => value.trim().toLowerCase()).filter(Boolean));
return (current ?? []).filter((plugin) => !localAgentProviderPluginMatchesProvider(plugin, providerNames));
}
function localAgentProviderPluginMatchesProvider(plugin: unknown, providerNames: Set<string>): boolean {
if (!isPlainRecord(plugin)) {
return false;
}
const key = typeof plugin.key === "string" ? plugin.key.trim().toLowerCase() : "";
if (!key.startsWith("ccr-local-agent-")) {
return false;
}
const pluginProviderName = typeof plugin.providerName === "string"
? plugin.providerName
: typeof plugin.provider === "string"
? plugin.provider
: "";
return providerNames.has(pluginProviderName.trim().toLowerCase());
}
function providerApiKeyValue(provider: GatewayProviderConfig): string {
return provider.api_key || provider.apiKey || provider.apikey || "";
}
function providerNameSlug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "") || "provider";
}
function App() {
const [activeView, setActiveView] = useState<ViewId>("onboarding");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStepId>(() => getDefaultOnboardingStep(fallbackConfig));
@ -941,6 +1037,13 @@ function App() {
if (!window.ccr || !providerFormVisible) {
return;
}
if (providerDraft.providerPlugins.length > 0) {
providerProbeRequestId.current += 1;
setProviderProbe(undefined);
setProviderProbeError("");
setProviderProbeLoading(false);
return;
}
providerProbeRequestId.current += 1;
const requestId = providerProbeRequestId.current;
@ -1006,7 +1109,7 @@ function App() {
setProviderProbeLoading(false);
}
};
}, [activeView, onboardingStep, providerAddOpen, providerDraft.apiKey, providerDraft.baseUrl, providerDraft.presetId, providerDraft.protocol]);
}, [activeView, onboardingStep, providerAddOpen, providerDraft.apiKey, providerDraft.baseUrl, providerDraft.presetId, providerDraft.protocol, providerDraft.providerPlugins]);
async function checkProviderDraft(modelsToCheck?: string[]): Promise<ProviderConnectivityCheckReport> {
const emptyReport: ProviderConnectivityCheckReport = { failed: [], passed: [], results: [] };
@ -1188,6 +1291,7 @@ function App() {
name: providerName,
type: protocol
};
const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol);
const next = buildConfigUpdate((config) => {
if (providerEditIndex === undefined) {
@ -1195,6 +1299,7 @@ function App() {
} else {
config.Providers[providerEditIndex] = provider;
}
config.providerPlugins = mergeProviderPlugins(config.providerPlugins, importedProviderPlugins);
if (!config.preferredProvider) {
config.preferredProvider = provider.name;
}
@ -1297,7 +1402,9 @@ function App() {
async function removeProvider(index: number): Promise<boolean> {
const next = buildConfigUpdate((config) => {
const removedProvider = config.Providers[index];
config.Providers.splice(index, 1);
config.providerPlugins = removeLocalAgentProviderPluginsForProvider(config.providerPlugins, removedProvider);
return config;
});
setConfigDraft(next);
@ -2541,11 +2648,9 @@ function App() {
networkCaptureEnabled={networkCaptureEnabled}
onDownloadUpdate={downloadAppUpdate}
onInstallUpdate={installAppUpdate}
onOpenServerView={() => setActiveView("server")}
onOpenSettings={openSettingsDialog}
onSelectNavigationItem={selectNavigationItem}
onToggleSidebar={() => setSidebarOpen((current) => !current)}
proxyStatus={proxyStatus}
requestLogsEnabled={requestLogsEnabled}
shouldReduceMotion={shouldReduceMotion}
sidebarOpen={sidebarOpen}
@ -2813,6 +2918,7 @@ function App() {
onSubmit: submitProviderDraft,
probe: providerProbe,
probeLoading: providerProbeLoading,
providerPlugins: draftConfig.providerPlugins ?? [],
providers: draftConfig.Providers
} : undefined}
routingDelete={routingDeleteRule ? {

View file

@ -2,7 +2,7 @@ import type { ComponentProps } from "react";
import {
AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, AppUpdateStatus, Button, Check, cn, EndpointTitleBar,
GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase,
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, ProxyStatus,
LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen,
reducedMotionTransition, RefreshCw, ServiceControlButton, Settings, ViewId,
ViewMotionShell, viewUsesInternalScroll
} from "../shared";
@ -67,11 +67,9 @@ export function MainLayout({
networkCaptureEnabled,
onDownloadUpdate,
onInstallUpdate,
onOpenServerView,
onOpenSettings,
onSelectNavigationItem,
onToggleSidebar,
proxyStatus,
shouldReduceMotion,
sidebarOpen,
toggleGatewayService,
@ -93,11 +91,9 @@ export function MainLayout({
networkCaptureEnabled: boolean;
onDownloadUpdate: () => Promise<void>;
onInstallUpdate: () => Promise<void>;
onOpenServerView: () => void;
onOpenSettings: () => void;
onSelectNavigationItem: (id: NavigationId) => void;
onToggleSidebar: () => void;
proxyStatus: ProxyStatus;
shouldReduceMotion: boolean | null;
sidebarOpen: boolean;
toggleGatewayService: () => void;
@ -236,8 +232,6 @@ export function MainLayout({
config={viewProps.server.config as AppConfig}
endpoint={gatewayEndpoint}
gatewayStatus={gatewayStatus}
onOpenSettings={onOpenServerView}
proxyStatus={proxyStatus}
/>
</div>
<div

View file

@ -210,6 +210,7 @@ export function OnboardingView({
onChange={onChangeProvider}
probe={providerProbe}
probeLoading={providerProbeLoading}
providerPlugins={config.providerPlugins ?? []}
providers={config.Providers}
/>
</div>

View file

@ -6,7 +6,7 @@ import {
customProviderPresetId, defaultProviderAccountConfigForPreset, Dialog, DialogBody, DialogContent, DialogFooter,
DialogHeader, DialogTitle, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig,
GatewayProviderProbeResult, getProviderPresets, Globe, inferProviderNameFromBaseUrl, Input, KeyValueRowsControl, Label,
Layers3, LoaderCircle, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint, providerAccountBadgeVariant,
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
@ -15,8 +15,9 @@ import {
providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions,
translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppText, useEffect, useMemo,
useRef, useState, X
useRef, useState, X, isPlainRecord
} from "../shared";
import type { LocalAgentProviderCandidate } from "../../../../shared/app";
export function ProvidersView({ accountSnapshots, addProvider, editProvider, notify, providers, removeProvider }: {
accountSnapshots: ProviderAccountSnapshot[];
addProvider: () => void;
@ -776,6 +777,205 @@ function providerPresetOptionMatchesQuery(
return haystack.includes(query);
}
function LocalAgentProviderImportPanel({
mode,
onChange,
providerPlugins,
providers
}: {
mode: "add" | "edit";
onChange: (patch: Partial<AddProviderDraft>, resetProbe?: boolean) => void;
providerPlugins: unknown[];
providers: GatewayProviderConfig[];
}) {
const t = useAppText();
const [candidates, setCandidates] = useState<LocalAgentProviderCandidate[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [importingId, setImportingId] = useState("");
useEffect(() => {
if (mode !== "add" || !window.ccr?.getLocalAgentProviderCandidates) {
return;
}
let cancelled = false;
setLoading(true);
setError("");
void window.ccr.getLocalAgentProviderCandidates()
.then((items) => {
if (!cancelled) {
setCandidates(items.filter((item) =>
item.status !== "missing" &&
!localAgentProviderAlreadyImported(item, providers, providerPlugins)
));
}
})
.catch((scanError) => {
if (!cancelled) {
setError(scanError instanceof Error ? scanError.message : String(scanError));
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [mode, providerPlugins, providers]);
if (mode !== "add" || (!loading && candidates.length === 0 && !error)) {
return null;
}
async function importCandidate(candidate: LocalAgentProviderCandidate) {
if (!window.ccr?.importLocalAgentProvider || !candidate.importable) {
return;
}
setImportingId(candidate.id);
setError("");
try {
const result = await window.ccr.importLocalAgentProvider({
id: candidate.id,
providerNames: providers.map((provider) => provider.name)
});
const accountDraft = createProviderAccountDraftFromConfig(result.provider.account);
const protocol = result.provider.protocol ?? "openai_chat_completions";
onChange({
...accountDraft,
apiKey: result.provider.apiKey ?? "",
baseUrl: result.provider.baseUrl,
credentials: [],
icon: result.provider.icon ?? "",
modelSearch: "",
modelsText: result.provider.models.join("\n"),
name: result.provider.name?.trim() || inferProviderNameFromBaseUrl(result.provider.baseUrl),
presetId: customProviderPresetId,
providerPlugins: result.providerPlugins,
protocol,
selectedModels: [],
selectedProtocols: [protocol]
}, true);
} catch (importError) {
setError(importError instanceof Error ? importError.message : String(importError));
} finally {
setImportingId("");
}
}
return (
<div className="sm:col-span-2 rounded-md border border-border bg-muted/20 p-3">
<div className="mb-2 flex min-w-0 items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[12px] font-semibold text-foreground">{t("Import local agent login")}</div>
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">{t("CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.")}</div>
</div>
{loading ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" /> : null}
</div>
{candidates.length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{candidates.map((candidate) => {
const iconUrl = localAgentProviderIconUrls[candidate.kind];
const importing = importingId === candidate.id;
return (
<div
className="grid min-h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md border border-border bg-background px-2.5 py-2"
key={candidate.id}
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-muted">
<img alt="" className="h-full w-full object-cover" draggable={false} src={iconUrl} />
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-[12px] font-semibold">{candidate.name}</span>
<Badge variant={candidate.importable ? "success" : candidate.status === "locked" ? "warning" : "outline"}>
{candidate.importable ? t("Ready") : candidate.status === "locked" ? t("Locked") : t("Not found")}
</Badge>
</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground" title={candidate.sourceFile || candidate.detail}>
{candidate.detail ? t(candidate.detail) : candidate.sourceFile || t("No local login state was found for this agent.")}
</div>
</div>
</div>
<Button
className="h-8 px-2"
disabled={!candidate.importable || Boolean(importingId)}
onClick={() => void importCandidate(candidate)}
type="button"
variant={candidate.importable ? "default" : "outline"}
>
<AnimatedIconSwap iconKey={importing ? "importing" : "import"}>
{importing ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</AnimatedIconSwap>
{t("Import")}
</Button>
</div>
);
})}
</div>
) : loading ? (
<div className="rounded-md border border-dashed border-border bg-background px-3 py-4 text-center text-[12px] text-muted-foreground">
{t("Scanning local agent logins")}
</div>
) : null}
{error ? (
<div className="mt-2 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t(error)}</span>
</div>
) : null}
</div>
);
}
const localAgentProviderApiKey = "ccr-local-agent-login";
const localAgentProviderPluginSuffixes: Record<LocalAgentProviderCandidate["kind"], string[]> = {
"claude-code": ["-claude-code-oauth", "-claude-code-oauth-internal"],
codex: ["-codex-oauth", "-codex-oauth-internal"],
zcode: ["-zcode-api-key", "-zcode-api-key-internal"]
};
function localAgentProviderAlreadyImported(
candidate: LocalAgentProviderCandidate,
providers: GatewayProviderConfig[],
providerPlugins: unknown[]
): boolean {
const suffixes = localAgentProviderPluginSuffixes[candidate.kind];
const localProviderNames = new Set(providers
.filter((provider) => provider.api_key === localAgentProviderApiKey)
.flatMap((provider) => [
provider.name,
provider.type ? `${provider.name}::${provider.type}` : ""
])
.map((name) => name.trim().toLowerCase())
.filter(Boolean));
if (localProviderNames.size === 0) {
return false;
}
const candidateProviderExists = providers.some((provider) =>
provider.api_key === localAgentProviderApiKey &&
provider.name.trim().toLowerCase().startsWith(candidate.name.toLowerCase())
);
if (candidateProviderExists) {
return true;
}
return providerPlugins.some((plugin) => {
const key = isPlainRecord(plugin) && typeof plugin.key === "string" ? plugin.key : "";
const providerName = isPlainRecord(plugin) && typeof plugin.providerName === "string" ? plugin.providerName : "";
return (
key.startsWith("ccr-local-agent-") &&
suffixes.some((suffix) => key.endsWith(suffix)) &&
localProviderNames.has(providerName.trim().toLowerCase())
);
});
}
export function AddProviderForm({
draft,
error,
@ -786,6 +986,7 @@ export function AddProviderForm({
onChange,
probe,
probeLoading,
providerPlugins = [],
providers
}: {
connectivityLoading?: boolean;
@ -797,6 +998,7 @@ export function AddProviderForm({
onChange: (patch: Partial<AddProviderDraft>, resetProbe?: boolean) => void;
probe?: GatewayProviderProbeResult;
probeLoading: boolean;
providerPlugins?: unknown[];
providers: GatewayProviderConfig[];
}) {
const t = useAppText();
@ -811,6 +1013,7 @@ export function AddProviderForm({
const detectedProtocol = probe?.detectedProtocol ?? draft.protocol;
const detectedBaseUrl = probe?.normalizedBaseUrl || draft.baseUrl;
const safetyIssue = providerDraftSafetyIssue(draft, detectedBaseUrl);
const localAgentImport = draft.providerPlugins.length > 0;
const providerPresetOptions = [
{ label: t("Select preset provider"), value: "" },
...getProviderPresets().map((preset) => ({ label: t(preset.name), preset, value: preset.id })),
@ -818,6 +1021,7 @@ export function AddProviderForm({
];
const selectableProtocols = providerSelectableProtocolsFromProbe(probe);
const hasConnectivityCheckInputs = Boolean(
!localAgentImport &&
draft.baseUrl.trim() &&
draft.apiKey.trim() &&
mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText)).length > 0
@ -867,6 +1071,7 @@ export function AddProviderForm({
icon: "",
modelSearch: "",
presetId,
providerPlugins: [],
selectedModels: [],
selectedProtocols: []
}, true);
@ -880,6 +1085,7 @@ export function AddProviderForm({
icon: "",
modelSearch: "",
presetId,
providerPlugins: [],
selectedModels: [],
selectedProtocols: []
}, true);
@ -898,6 +1104,7 @@ export function AddProviderForm({
modelsText: draft.modelsText.trim() || preset?.defaultModels?.join("\n") || "",
name: mode === "add" && preset && generatedName ? uniqueProviderName(providers, t(preset.name)) : draft.name,
presetId,
providerPlugins: [],
protocol: endpoint?.protocols[0] ?? draft.protocol,
selectedModels: [],
selectedProtocols: uniqueProviderProtocols(preset?.endpoints.flatMap((item) => item.protocols) ?? endpoint?.protocols ?? [])
@ -907,6 +1114,12 @@ export function AddProviderForm({
return (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<LocalAgentProviderImportPanel
mode={mode}
onChange={onChange}
providerPlugins={[...providerPlugins, ...draft.providerPlugins]}
providers={providers}
/>
<Field label={t("Preset provider")}>
<ProviderPresetCombobox
value={draft.presetId}
@ -992,6 +1205,8 @@ export function AddProviderForm({
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
{t("Checking connection")}
</span>
) : localAgentImport ? (
<span>{t("Local agent login will be connected after saving this provider.")}</span>
) : providerProbeHasSupportedProtocol(connectivityProbe) ? (
<span className="inline-flex items-center gap-1.5 text-foreground">
<Check className="h-3.5 w-3.5" />
@ -1645,6 +1860,7 @@ export function AddProviderDialog({
onSubmit,
probe,
probeLoading,
providerPlugins = [],
providers
}: {
canSubmit: boolean;
@ -1659,6 +1875,7 @@ export function AddProviderDialog({
onSubmit: () => Promise<boolean>;
probe?: GatewayProviderProbeResult;
probeLoading: boolean;
providerPlugins?: unknown[];
providers: GatewayProviderConfig[];
}) {
const t = useAppText();
@ -1719,6 +1936,7 @@ export function AddProviderDialog({
onChange={onChange}
probe={probe}
probeLoading={probeLoading}
providerPlugins={providerPlugins}
providers={providers}
/>
</DialogBody>

View file

@ -2005,9 +2005,12 @@ function TrayPreviewRings({
<div className="min-w-0 rounded-[8px] border border-white/10 bg-white/[.04] p-2">
<div className="mb-2 truncate text-[11px] font-bold text-slate-100">{title}</div>
<div className="grid grid-cols-2 gap-2">
{[74, 91].map((value) => (
<div className="relative aspect-square min-w-0" key={value}>
<PreviewRadialMetric color={value > 80 ? "rgb(45,212,191)" : "rgb(129,140,248)"} label={`${value}%`} value={value / 100} variant={variant === "rings" ? "ring" : variant === "arcs" ? "arc" : "gauge"} />
{[
{ centerUnit: "requests", centerValue: "38", label: "74%", value: 74 },
{ centerUnit: "tokens", centerValue: "9.1K", label: "91%", value: 91 }
].map((item) => (
<div className="relative aspect-square min-w-0" key={item.centerUnit}>
<PreviewRadialMetric centerUnit={item.centerUnit} centerValue={item.centerValue} color={item.value > 80 ? "rgb(45,212,191)" : "rgb(129,140,248)"} label={item.label} value={item.value / 100} variant={variant === "rings" ? "ring" : variant === "arcs" ? "arc" : "gauge"} />
</div>
))}
</div>
@ -2063,11 +2066,15 @@ function TrayPreviewModelShare({
}
function PreviewRadialMetric({
centerUnit,
centerValue,
color,
label,
value,
variant
}: {
centerUnit?: string;
centerValue?: string;
color: string;
label: string;
value: number;
@ -2106,7 +2113,10 @@ function PreviewRadialMetric({
transform={`rotate(${rotation} 20 20)`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-slate-100">{label}</div>
<div className="absolute inset-0 flex min-w-0 flex-col items-center justify-center px-1 text-center leading-none">
<div className="max-w-full truncate text-[11px] font-bold text-slate-100">{centerValue ?? label}</div>
{centerUnit ? <div className="mt-0.5 max-w-full truncate text-[8px] font-semibold uppercase text-slate-400">{centerUnit}</div> : null}
</div>
</div>
);
}

View file

@ -271,6 +271,7 @@ import {
} from "./i18n";
import {
AnimatedDisclosure,
AnimatedIconSwap,
AnimatedFieldSlot,
AnimatedListItem,
AnimatedPopover,
@ -372,9 +373,8 @@ import type { MotionSafeDivAttributes } from "./motion";
import { metricToneBar } from "./common";
import { gatewayEndpointFromConfig, profileAgentLabel, profileAgentLogoUrl } from "./profiles";
import { profileAgentLabel, profileAgentLogoUrl } from "./profiles";
import { routeTargetOptions } from "./providers";
import { endpointDetails } from "./services";
import { createKeyValueDraftRow } from "./virtual-models";
import type { KeyValueDraftRow } from "./types";
@ -663,24 +663,31 @@ export function ServiceControlButton({
export function EndpointTitleBar({
config,
endpoint,
gatewayStatus,
onOpenSettings,
proxyStatus
gatewayStatus
}: {
config: AppConfig;
endpoint: string;
gatewayStatus: GatewayStatus;
onOpenSettings: () => void;
proxyStatus: ProxyStatus;
}) {
const t = useAppText();
const [open, setOpen] = useState(false);
const [copiedKey, setCopiedKey] = useState("");
const copyResetTimer = useRef<number>();
const rootRef = useRef<HTMLDivElement>(null);
const running = gatewayStatus.state === "running";
const statusLabel = running ? t("running") : t("not running");
const value = endpoint.trim() || t("Not configured");
const endpointInfo = endpointDetails(value, config);
const proxyEndpoint = proxyStatus.endpoint || gatewayEndpointFromConfig(config);
const loopbackEndpoint = loopbackEndpointFromStatus(value, config);
const networkEndpoints = running ? gatewayStatus.networkEndpoints ?? [] : [];
async function copyEndpoint(valueToCopy: string, key: string) {
await copyEndpointTextToClipboard(valueToCopy);
setCopiedKey(key);
if (copyResetTimer.current) {
window.clearTimeout(copyResetTimer.current);
}
copyResetTimer.current = window.setTimeout(() => setCopiedKey(""), 1300);
}
useEffect(() => {
if (!open) {
@ -707,6 +714,14 @@ export function EndpointTitleBar({
};
}, [open]);
useEffect(() => {
return () => {
if (copyResetTimer.current) {
window.clearTimeout(copyResetTimer.current);
}
};
}, []);
return (
<div
className="app-no-drag fixed left-1/2 top-2 z-50 w-[min(560px,56vw,calc(100%_-_48px))] min-w-[220px] -translate-x-1/2 max-[720px]:static max-[720px]:w-full max-[720px]:min-w-0 max-[720px]:translate-x-0"
@ -740,43 +755,32 @@ export function EndpointTitleBar({
<AnimatePresence initial={false}>
{open ? (
<AnimatedPopover className="absolute left-1/2 top-full z-50 mt-2 w-[288px] max-w-[calc(100vw-24px)] -translate-x-1/2">
<AnimatedPopover className="absolute left-1/2 top-full z-50 mt-2 w-[340px] max-w-[calc(100vw-24px)] -translate-x-1/2">
<PopoverContent
aria-label={t("Endpoint information")}
className="p-3"
id="endpoint-info-panel"
role="dialog"
>
<div className="mb-2 flex items-center gap-2 border-b border-border/60 pb-2">
<span
aria-hidden="true"
className={cn(
"h-2.5 w-2.5 shrink-0 rounded-full",
running ? "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.16)]" : "bg-muted-foreground/45"
)}
/>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium">{t("Endpoint")}</span>
<Badge variant={running ? "success" : "outline"}>{statusLabel}</Badge>
</div>
<div className="space-y-1.5">
<EndpointInfoRow label="IP" value={endpointInfo.host} />
<EndpointInfoRow label={t("Port")} value={endpointInfo.port} />
<EndpointInfoRow label="Loopback" value={config.gateway.host || "127.0.0.1"} />
<EndpointInfoRow label={t("Proxy")} value={proxyEndpoint} />
<EndpointInfoRow
copied={copiedKey === `loopback:${loopbackEndpoint}`}
label="Loopback"
value={loopbackEndpoint}
onCopy={(valueToCopy) => void copyEndpoint(valueToCopy, `loopback:${loopbackEndpoint}`)}
/>
{networkEndpoints.map((entry, index) => (
<EndpointInfoRow
copied={copiedKey === `network:${entry.interfaceName}:${entry.address}`}
key={`${entry.interfaceName}-${entry.address}`}
label={index === 0 ? "Network" : ""}
meta={entry.interfaceName}
value={entry.endpoint}
onCopy={(valueToCopy) => void copyEndpoint(valueToCopy, `network:${entry.interfaceName}:${entry.address}`)}
/>
))}
</div>
<Button
className="mt-3 h-7 w-full rounded-md border border-border bg-background px-2 text-center text-[12px] font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/25"
onClick={() => {
setOpen(false);
onOpenSettings();
}}
type="button"
unstyled
>
{t("Advanced Settings...")}
</Button>
</PopoverContent>
</AnimatedPopover>
) : null}
@ -785,11 +789,78 @@ export function EndpointTitleBar({
);
}
export function EndpointInfoRow({ label, value }: { label: string; value: string }) {
export function EndpointInfoRow({
copied,
label,
meta,
value,
onCopy
}: {
copied: boolean;
label: string;
meta?: string;
value: string;
onCopy: (value: string) => void;
}) {
const t = useAppText();
return (
<div className="grid grid-cols-[84px_minmax(0,1fr)] items-baseline gap-2 text-[12px]">
<span className="text-right text-muted-foreground">{label}:</span>
<span className="min-w-0 truncate font-medium text-foreground">{value}</span>
<div className="grid grid-cols-[76px_minmax(0,1fr)_24px] items-center gap-2 text-[12px]">
<span className="text-right text-muted-foreground">{label ? `${label}:` : null}</span>
<span className="min-w-0 truncate font-medium text-foreground">
{value}
{meta ? <span className="ml-1 text-[11px] font-normal text-muted-foreground">({meta})</span> : null}
</span>
<Button
aria-label={copied ? t("Copied") : t("Copy")}
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md border outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/25",
copied
? "border-emerald-500/25 bg-emerald-500/10 text-emerald-600"
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={copied ? t("Copied") : t("Copy")}
type="button"
unstyled
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onCopy(value);
}}
>
<AnimatedIconSwap iconKey={copied ? "copied" : "copy"}>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</AnimatedIconSwap>
</Button>
</div>
);
}
function loopbackEndpointFromStatus(endpoint: string, config: AppConfig): string {
try {
const parsed = new URL(endpoint);
return `http://127.0.0.1:${parsed.port || config.gateway.port}`;
} catch {
return `http://127.0.0.1:${config.gateway.port}`;
}
}
async function copyEndpointTextToClipboard(value: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Fall back to a temporary textarea for Electron/file contexts where clipboard permissions vary.
}
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "true");
textarea.style.left = "-9999px";
textarea.style.position = "fixed";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}

View file

@ -188,6 +188,7 @@ export const fallbackGatewayStatus: GatewayStatus = {
coreEndpoint: "http://127.0.0.1:3457",
endpoint: "http://127.0.0.1:3456",
generatedConfigFile: "Browser preview",
networkEndpoints: [],
state: "stopped"
};

View file

@ -226,21 +226,38 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Checking connection": "Checking connection",
"Click Check Connection to verify connectivity with a real model request.": "Click Check Connection to verify connectivity with a real model request.",
"Connection verified": "Connection verified",
"CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.": "CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.",
"Detected": "Detected",
"Detecting protocols": "Detecting protocols",
"Enter API endpoint, API key, and at least one model to enable connectivity check.": "Enter API endpoint, API key, and at least one model to enable connectivity check.",
"Generated output is limited to 1 token for connectivity checks.": "Generated output is limited to 1 token for connectivity checks.",
"Import local agent login": "Import local agent login",
"ChatGPT login detected. Click Import to add it as a gateway provider.": "ChatGPT login detected. Click Import to add it as a gateway provider.",
"Claude Code login detected. Click Import to add it as a gateway provider.": "Claude Code login detected. Click Import to add it as a gateway provider.",
"Claude Code login was detected, but no usable access token was found.": "Claude Code login was detected, but no usable access token was found.",
"Codex auth file was found, but no usable token was detected.": "Codex auth file was found, but no usable token was detected.",
"Claude Code credential file was found, but no usable OAuth token was detected.": "Claude Code credential file was found, but no usable OAuth token was detected.",
"Locked": "Locked",
"Local agent login will be connected after saving this provider.": "Local agent login will be connected after saving this provider.",
"Models to check": "Models to check",
"No available models": "No available models",
"No local login state was found for this agent.": "No local login state was found for this agent.",
"Not found": "Not found",
"No unavailable models": "No unavailable models",
"Press Enter to add": "Press Enter to add",
"Protocols detected": "Protocols detected",
"Ready": "Ready",
"Select at least one protocol.": "Select at least one protocol.",
"Service": "Service",
"Scanning local agent logins": "Scanning local agent logins",
"Start": "Start",
"Start check": "Start check",
"Stop": "Stop",
"This check sends real model requests with your provider API key and may consume account balance.": "This check sends real model requests with your provider API key and may consume account balance.",
"ZCode login detected. Click Import to add it as a gateway provider.": "ZCode login detected. Click Import to add it as a gateway provider.",
"ZCode login was detected, but its local credential is encrypted and cannot be imported automatically.": "ZCode login was detected, but its local credential is encrypted and cannot be imported automatically.",
"ZCode login was detected, but no usable provider API key was found in ZCode config.": "ZCode login was detected, but no usable provider API key was found in ZCode config.",
"ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.": "ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.",
"Unavailable models": "Unavailable models",
"Account Balance": "Account Balance",
"Account component": "Account component",
@ -648,9 +665,18 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Default failure handling": "默认故障处理",
"Default on failure": "默认失败处理",
"Description": "描述",
"CCR scanned this computer for Claude Code, Codex, and ZCode login states. Click Import to add one as a gateway provider.": "CCR 已扫描本机的 Claude Code、Codex 和 ZCode 登录态。点击导入即可添加为网关供应商。",
"Detected": "已检测",
"Detecting protocols": "正在探测协议",
"Enter API endpoint, API key, and at least one model to enable connectivity check.": "填写 API 地址、API Key 和至少一个模型后,才可检测连通性。",
"Import local agent login": "导入本机 Agent 登录态",
"ChatGPT login detected. Click Import to add it as a gateway provider.": "已检测到 ChatGPT 登录态。点击导入即可添加为网关供应商。",
"Claude Code login detected. Click Import to add it as a gateway provider.": "已检测到 Claude Code 登录态。点击导入即可添加为网关供应商。",
"Claude Code login was detected, but no usable access token was found.": "已检测到 Claude Code 登录态,但没有找到可用的 access token。",
"Codex auth file was found, but no usable token was detected.": "已找到 Codex 认证文件,但没有检测到可用 token。",
"Claude Code credential file was found, but no usable OAuth token was detected.": "已找到 Claude Code 凭据文件,但没有检测到可用 OAuth token。",
"Locked": "已加密",
"Local agent login will be connected after saving this provider.": "保存这个供应商后会接入本机 Agent 登录态。",
"Display name": "显示名称",
"Double click to copy": "双击复制",
"Edit": "编辑",
@ -1277,14 +1303,22 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"No fallback models configured": "未配置回退模型",
"No fallback targets configured": "未配置失败降级目标",
"No models configured": "未配置模型",
"No local login state was found for this agent.": "未找到这个 Agent 的本机登录态。",
"No provider credentials configured": "未配置供应商凭据",
"No request rewrite": "不改写请求",
"Not found": "未找到",
"Other / custom API endpoint": "其他 / 自定义 API 地址",
"Pending": "等待中",
"Priority": "优先级",
"Priority only": "仅按优先级",
"Priority spillover": "优先级溢出",
"Ready": "可导入",
"Scanning local agent logins": "正在扫描本机 Agent 登录态",
"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。点击导入即可添加为网关供应商。",
"Zhipu AI (China)": "智谱 AI (国内)",
"Zhipu AI (China) - Coding Plan": "智谱 AI (国内) - Coding Plan",
"Zhipu AI (China) - General Endpoint": "智谱 AI (国内) - 通用端点",

View file

@ -102,6 +102,7 @@ import { cn } from "@/lib/utils";
import appLogoUrl from "../../../../../assets/logo.png";
import claudeCodeLogoUrl from "@/assets/agent-logos/claude-code.png";
import codexLogoUrl from "@/assets/agent-logos/codex.png";
import zcodeLogoUrl from "@/assets/agent-logos/zcode.png";
import onboardingMascotSpriteUrl from "@/assets/onboarding/mascot-transition.svg";
import anthropicProviderIconUrl from "@/assets/provider-icons/anthropic.png";
import bailianProviderIconUrl from "@/assets/provider-icons/bailian.ico";
@ -169,6 +170,7 @@ import type {
GatewayMcpStdioMessageMode,
GatewayMcpToolInfo,
GatewayStatus,
LocalAgentProviderKind,
OverviewMetricKind,
OverviewWidgetConfig,
OverviewWidgetSize,
@ -381,6 +383,12 @@ import { normalizeRouterFallbackConfig } from "./routing";
import { keyValueRowsFromRecord, recordFromKeyValueRows, validateKeyValueRows, virtualModelMatchSummary } from "./virtual-models";
import type { AddProviderDraft, AddRoutingRuleDraft, ModelCatalogItem, ProviderCredentialDraft, ProviderProbeCandidate, ProviderProbeCandidateResult, ProviderUsageFieldTarget, RoutingRewriteDraftRow, RoutingRuleRow, ViewId } from "./types";
export const localAgentProviderIconUrls: Record<LocalAgentProviderKind, string> = {
"claude-code": claudeCodeLogoUrl,
codex: codexLogoUrl,
zcode: zcodeLogoUrl
};
export function createModelCatalogItems(config: AppConfig): ModelCatalogItem[] {
const providerModels = config.Providers.flatMap((provider) => mergeProviderModelLists(provider.models));
const virtualModels = (config.virtualModelProfiles ?? [])
@ -882,6 +890,7 @@ export function createProviderDraft(providers: GatewayProviderConfig[]): AddProv
modelsText: "",
name: uniqueProviderName(providers),
presetId: "",
providerPlugins: [],
protocol: "openai_chat_completions",
selectedModels: [],
selectedProtocols: []
@ -903,6 +912,7 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig)
modelsText: provider.models.join("\n"),
name: provider.name,
presetId: preset?.id ?? customProviderPresetId,
providerPlugins: [],
protocol,
selectedModels: [],
selectedProtocols: selectedProviderProtocolsFromCapabilities(provider.capabilities, protocol)

View file

@ -425,6 +425,7 @@ export type AddProviderDraft = {
modelsText: string;
name: string;
presetId: string;
providerPlugins: unknown[];
protocol: GatewayProviderProtocol;
selectedModels: string[];
selectedProtocols: GatewayProviderProtocol[];

View file

@ -202,23 +202,28 @@ export function RingMetrics({
variant: TrayComponentVariants["rings"];
}) {
const t = useTrayText();
const successRequests = Math.round(totals.requestCount * Math.max(0, Math.min(1, totals.successRate)));
return (
<div className="min-w-0 rounded-[8px] border border-white/10 bg-white/[.04] p-2">
<div className="mb-2 truncate text-[11px] font-bold text-slate-100">{t("Circular metrics")}</div>
<div className="grid grid-cols-2 gap-2">
<RingMetric label={t("Success")} value={totals.successRate} variant={variant} />
<RingMetric label={t("Cache")} value={totals.cacheRatio} variant={variant} />
<RingMetric centerUnit={t("requests")} centerValue={formatCompactNumber(successRequests)} label={t("Success")} value={totals.successRate} variant={variant} />
<RingMetric centerUnit={t("tokens")} centerValue={formatCompactNumber(totals.cacheTokens)} label={t("Cache")} value={totals.cacheRatio} variant={variant} />
</div>
</div>
);
}
function RingMetric({
centerUnit,
centerValue,
label,
value,
variant
}: {
centerUnit: string;
centerValue: string;
label: string;
value: number;
variant: TrayComponentVariants["rings"];
@ -228,7 +233,14 @@ function RingMetric({
return (
<div className="flex min-w-0 flex-col items-center text-center">
<div className="aspect-square w-full min-w-0">
<RadialMetric color={stroke} label={formatPercent(clamped)} value={clamped} variant={variant === "rings" ? "ring" : variant === "arcs" ? "arc" : "gauge"} />
<RadialMetric
centerUnit={centerUnit}
centerValue={centerValue}
color={stroke}
label={`${label} ${formatPercent(clamped)}, ${centerValue} ${centerUnit}`}
value={clamped}
variant={variant === "rings" ? "ring" : variant === "arcs" ? "arc" : "gauge"}
/>
</div>
<div className="mt-1 w-full truncate px-1 text-[10px] font-semibold leading-none text-slate-300">{label}</div>
</div>
@ -377,11 +389,15 @@ function StackedShareBar({ rows }: { rows: ShareChartRow[] }) {
}
export function RadialMetric({
centerUnit,
centerValue,
color,
label,
value,
variant
}: {
centerUnit?: string;
centerValue?: string;
color: string;
label: string;
value: number;
@ -420,7 +436,10 @@ export function RadialMetric({
transform={`rotate(${rotation} 20 20)`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-slate-100">{label}</div>
<div className="absolute inset-0 flex min-w-0 flex-col items-center justify-center px-1 text-center leading-none">
<div className="max-w-full truncate text-[11px] font-bold text-slate-100">{centerValue ?? label}</div>
{centerUnit ? <div className="mt-0.5 max-w-full truncate text-[8px] font-semibold uppercase text-slate-400">{centerUnit}</div> : null}
</div>
</div>
);
}

View file

@ -290,6 +290,19 @@
background: transparent;
}
body.tray-window,
body.tray-window * {
-ms-overflow-style: none;
scrollbar-width: none;
}
body.tray-window::-webkit-scrollbar,
body.tray-window *::-webkit-scrollbar {
display: none;
height: 0;
width: 0;
}
button,
input {
font: inherit;

View file

@ -28,6 +28,9 @@ import type {
GatewayProviderProbeRequest,
GatewayProviderProbeResult,
GatewayStatus,
LocalAgentProviderCandidate,
LocalAgentProviderImportRequest,
LocalAgentProviderImportResult,
PluginDirectorySelection,
PluginMarketplaceEntry,
ProfileOpenCommandResult,
@ -75,6 +78,7 @@ declare global {
getAppInfo: () => Promise<AppInfo>;
getConfig: () => Promise<AppConfig>;
getGatewayStatus: () => Promise<GatewayStatus>;
getLocalAgentProviderCandidates: () => Promise<LocalAgentProviderCandidate[]>;
getOnboardingFinished: () => Promise<boolean>;
getPendingProviderDeepLinks: () => Promise<ProviderDeepLinkRequest[]>;
getProfileOpenCommand: (request: ProfileOpenRequest) => Promise<ProfileOpenCommandResult>;
@ -90,6 +94,7 @@ declare global {
getUpdateStatus: () => Promise<AppUpdateStatus>;
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise<UsageStatsSnapshot>;
installProxyCertificate: () => Promise<ProxyCertificateInstallResult>;
importLocalAgentProvider: (request: LocalAgentProviderImportRequest) => Promise<LocalAgentProviderImportResult>;
listMcpServerTools: (server: GatewayMcpServerConfig) => Promise<GatewayMcpToolInfo[]>;
openBuiltInBrowser: () => Promise<void>;
openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => Promise<BotGatewayQrWindowOpenResult>;

View file

@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { createRequire } from "node:module";
import { networkInterfaces } from "node:os";
import { Readable } from "node:stream";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "node:path";
@ -10,6 +11,7 @@ import type {
ApiKeyLimitConfig,
AppConfig,
GatewayMcpServerConfig,
GatewayNetworkEndpoint,
GatewayProviderCapability,
GatewayProviderConfig,
GatewayProviderProtocol,
@ -35,6 +37,8 @@ import { providerApiKeySafetyIssue } from "../../main/presets";
import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "../../shared/provider-url";
import { backendService } from "../backend-service";
import { RAW_TRACE_SPOOL_DIR } from "../../main/constants";
import { codexDefaultBaseUrl, readCodexAuth } from "../../main/local-agent-provider-service";
import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "../../main/system-proxy-fetch";
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "../mcp/network-capture-mcp";
import { pluginService } from "../../main/plugins/service";
import { proxyService } from "../proxy/service";
@ -187,10 +191,39 @@ const rawTraceSyncHeader = "x-ccr-raw-trace-token";
let warnedMissingCursorOpenAICompatContext = false;
const rawTraceSyncPath = "/__ccr/raw-trace-sync";
const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"];
const gatewayRuntimePatchEntryFile = "next-ai-gateway.ccr-patch.cjs";
const gatewayRuntimePatches = [
{
label: "collect-openai-sse-in-buffered-conversion",
search: 'ro({sourceAdapter:t.adapterKey,targetProvider:R,targetProviderName:_?.name,mode:"buffered"});let on=await Vn(e,R,xe),We=await wo(tt,oe,xe,on,Ke);',
replacement: 'ro({sourceAdapter:t.adapterKey,targetProvider:R,targetProviderName:_?.name,mode:"buffered"});let on=Gn(xe)&&R==="openai"?await yr(xe):await Vn(e,R,xe),We=await wo(tt,oe,xe,on,Ke);'
},
{
label: "track-openai-responses-output-items",
search: 'return{id:`chatcmpl_${(0,pe.randomUUID)()}`,model:"unknown",outputText:"",usage:{},toolCalls:new Map,reasoning:{text:"",summary:"",rawDetails:[]}}',
replacement: 'return{id:`chatcmpl_${(0,pe.randomUUID)()}`,model:"unknown",outputText:"",usage:{},outputItems:[],toolCalls:new Map,reasoning:{text:"",summary:"",rawDetails:[]}}'
},
{
label: "collect-openai-responses-output-item-done",
search: 'if(t==="response.output_text.done"){let r=f(n.text);r&&(e.outputText=r);return}if(t==="response.completed"){',
replacement: 'if(t==="response.output_text.done"){let r=f(n.text);r&&(e.outputText=r);return}if(t==="response.output_item.done"){let r=l(n.item)?n.item:void 0;r&&(e.outputItems||(e.outputItems=[]),e.outputItems.push(r));return}if(t==="response.completed"){'
},
{
label: "merge-empty-completed-openai-response",
search: 'function tp(e){return e.completedResponse?dr({...e.completedResponse}):{id:e.id,object:"chat.completion",model:e.model,choices:[{index:0,message:{role:"assistant",content:e.outputText,...e.reasoning.text?{reasoning_content:e.reasoning.text}:{},...e.reasoning.rawDetails.length>0?{reasoning_details:e.reasoning.rawDetails}:e.reasoning.summary||e.reasoning.encryptedContent?{reasoning_details:UC(e.reasoning)}:{},...e.toolCalls.size>0?{tool_calls:LC(e.toolCalls)}:{}},finish_reason:e.finishReason}],usage:e.usage}}',
replacement: 'function tp(e){if(e.completedResponse){let n={...e.completedResponse},t=Array.isArray(n.output)?n.output:[];if(t.length===0&&Array.isArray(e.outputItems)&&e.outputItems.length>0)n.output=e.outputItems;if(!f(n.output_text)&&e.outputText)n.output_text=e.outputText;if(l(n.usage))n.usage={...e.usage,...n.usage};else n.usage=e.usage;return dr(n)}return{id:e.id,object:"chat.completion",model:e.model,choices:[{index:0,message:{role:"assistant",content:e.outputText,...e.reasoning.text?{reasoning_content:e.reasoning.text}:{},...e.reasoning.rawDetails.length>0?{reasoning_details:e.reasoning.rawDetails}:e.reasoning.summary||e.reasoning.encryptedContent?{reasoning_details:UC(e.reasoning)}:{},...e.toolCalls.size>0?{tool_calls:LC(e.toolCalls)}:{}},finish_reason:e.finishReason}],usage:e.usage}}'
}
] as const;
const apiKeyLimitCounters = new Map<string, ApiKeyWindowCounter>();
const providerCredentialCooldowns = new Map<string, { reason: string; until: number }>();
const providerCredentialCooldownMs = 60_000;
const providerCredentialSpilloverThreshold = 0.8;
const gatewayProviderProtocolFallbackOrder: GatewayProviderProtocol[] = [
"anthropic_messages",
"openai_chat_completions",
"openai_responses",
"gemini_generate_content"
];
class GatewayService {
private child?: ChildProcess;
@ -203,6 +236,7 @@ class GatewayService {
coreEndpoint: "",
endpoint: "",
generatedConfigFile: "",
networkEndpoints: [],
state: "stopped"
};
@ -214,6 +248,7 @@ class GatewayService {
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
endpoint: endpoint(config.gateway.host, config.gateway.port),
generatedConfigFile: config.gateway.generatedConfigFile,
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port),
state: "starting"
};
@ -250,8 +285,10 @@ class GatewayService {
pid: undefined
};
} else {
await proxyService.refreshUpstreamProxyFromCurrentSystem();
const runtimeId = randomUUID();
this.child = spawnGatewayProcess(config, proxyService.getUpstreamProxyUrl("https"), runtimeId);
const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https");
this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId);
writeManagedCoreGatewayMarker(config, this.child, runtimeId);
this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`));
this.child.stderr?.on("data", (chunk) => console.warn(`[gateway] ${chunk.toString().trimEnd()}`));
@ -309,7 +346,12 @@ class GatewayService {
}
getStatus(): GatewayStatus {
return { ...this.status };
return {
...this.status,
networkEndpoints: this.config
? gatewayNetworkEndpoints(this.config.gateway.host, this.config.gateway.port)
: this.status.networkEndpoints
};
}
updateConfig(config: AppConfig): void {
@ -320,7 +362,8 @@ class GatewayService {
...this.status,
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
endpoint: endpoint(config.gateway.host, config.gateway.port),
generatedConfigFile: config.gateway.generatedConfigFile
generatedConfigFile: config.gateway.generatedConfigFile,
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port)
};
}
@ -755,10 +798,11 @@ export const gatewayService = new GatewayService();
function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): void {
mkdirSync(dirname(config.gateway.generatedConfigFile), { recursive: true });
const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig();
const providerPlugins = [
const providerPlugins = withCodexOauthRuntimeDefaults([
...(config.providerPlugins ?? []),
...pluginService.getCoreProviderPlugins()
];
]);
const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins);
const virtualModelProfiles = withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
...(config.virtualModelProfiles ?? []),
...pluginService.getVirtualModelProfiles()
@ -767,7 +811,7 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v
const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint);
const providers = [
...config.Providers
.flatMap(toCoreGatewayProviders)
.flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames)))
.filter((provider): provider is CoreGatewayProvider => Boolean(provider)),
...builtinToolArtifacts.providers
];
@ -812,6 +856,125 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v
writeFileSync(config.gateway.generatedConfigFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] {
const codexAuth = readCodexAuth();
return providerPlugins.map((plugin) => {
if (!isLocalCodexOauthProviderPlugin(plugin)) {
return plugin;
}
const codexOauth = plugin.codexOauth;
const nextCodexOauth = {
...codexOauth,
...(!hasOwn(codexOauth, "accountId") && !hasOwn(codexOauth, "account_id") && codexAuth?.accountId
? { accountId: codexAuth.accountId }
: {})
};
const nextPlugin: Record<string, unknown> = {
...plugin,
codexOauth: nextCodexOauth,
request: withCodexBackendRequestTransform(plugin.request)
};
if (codexAuth?.isFedrampAccount) {
const currentAuth = isRecord(plugin.auth) ? plugin.auth : {};
const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {};
nextPlugin.auth = {
...currentAuth,
headers: {
...currentHeaders,
"X-OpenAI-Fedramp": "true"
}
};
}
return nextPlugin;
});
}
function codexOauthLocalProviderNames(providerPlugins: unknown[]): Set<string> {
const names = new Set<string>();
for (const plugin of providerPlugins) {
if (!isLocalCodexOauthProviderPlugin(plugin)) {
continue;
}
addProviderNameVariants(names, stringValue(plugin.providerName));
}
return names;
}
function withCodexOauthProviderBaseUrl(
provider: GatewayProviderConfig,
codexOauthProviderNames: Set<string>
): GatewayProviderConfig {
if (!codexOauthProviderNames.has(provider.name)) {
return provider;
}
const protocol =
normalizeProviderProtocol(provider.type) ??
normalizeProviderProtocol(provider.provider) ??
inferProtocol(provider);
if (protocol !== "openai_responses") {
return provider;
}
const capabilities = Array.isArray(provider.capabilities)
? provider.capabilities.map((capability) => {
const capabilityProtocol = normalizeProviderProtocol(capability.type);
if (capabilityProtocol !== "openai_responses") {
return capability;
}
return {
...capability,
baseUrl: codexDefaultBaseUrl
};
})
: provider.capabilities;
return {
...provider,
api_base_url: codexDefaultBaseUrl,
baseUrl: codexDefaultBaseUrl,
baseurl: codexDefaultBaseUrl,
capabilities
};
}
function isLocalCodexOauthProviderPlugin(value: unknown): value is Record<string, unknown> & { codexOauth: Record<string, unknown> } {
if (!isRecord(value) || !isRecord(value.codexOauth)) {
return false;
}
const key = stringValue(value.key)?.toLowerCase() ?? "";
return key.startsWith("ccr-local-agent-") && key.includes("codex-oauth");
}
function withCodexBackendRequestTransform(request: unknown): Record<string, unknown> {
const currentRequest = isRecord(request) ? request : {};
const bodyRemove = Array.isArray(currentRequest.bodyRemove)
? currentRequest.bodyRemove.map((item) => stringValue(item)).filter((item): item is string => Boolean(item))
: [];
return {
...currentRequest,
bodyRemove: uniqueStrings([...bodyRemove, "max_output_tokens"])
};
}
function addProviderNameVariants(names: Set<string>, providerName: string | undefined): void {
if (!providerName) {
return;
}
names.add(providerName);
const capabilitySeparatorIndex = providerName.indexOf("::");
if (capabilitySeparatorIndex > 0) {
names.add(providerName.slice(0, capabilitySeparatorIndex));
}
}
function hasOwn(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
function fusionBuiltinToolArtifacts(
profiles: unknown[],
coreEndpoint: string
@ -1516,11 +1679,24 @@ function providerProtocolPreferenceForClient(clientProtocol: GatewayProviderProt
return ["openai_responses", "openai_chat_completions", "anthropic_messages"];
}
if (clientProtocol === "anthropic_messages") {
return ["anthropic_messages", "openai_chat_completions"];
return uniqueProviderProtocols([clientProtocol, ...gatewayProviderProtocolFallbackOrder]);
}
return [clientProtocol];
}
function uniqueProviderProtocols(protocols: GatewayProviderProtocol[]): GatewayProviderProtocol[] {
const seen = new Set<GatewayProviderProtocol>();
const output: GatewayProviderProtocol[] = [];
for (const protocol of protocols) {
if (seen.has(protocol)) {
continue;
}
seen.add(protocol);
output.push(protocol);
}
return output;
}
function findProviderByPublicOrInternalName(config: AppConfig, name: string): GatewayProviderConfig | undefined {
const normalized = name.trim().toLowerCase();
if (!normalized) {
@ -1600,7 +1776,7 @@ async function fetchUpstreamWithFallback(input: {
const hasNextAttempt = index < attempts.length - 1;
try {
const response = await fetch(input.upstreamUrl, {
const response = await fetchWithSystemProxy(input.upstreamUrl, {
body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined,
headers: omitLocalObservabilityHeaders(attempt.headers ?? input.headers),
method: input.method
@ -1973,8 +2149,9 @@ function uniqueStrings(values: Array<string | undefined>): string[] {
function spawnGatewayProcess(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string): ChildProcess {
const gatewayEntry = resolveGatewayEntry();
const patchedGatewayEntry = writePatchedGatewayRuntimeEntry(config, gatewayEntry);
const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId);
return spawn(process.execPath, [gatewayEntry], {
return spawn(process.execPath, [patchedGatewayEntry], {
cwd: dirname(config.gateway.generatedConfigFile),
env,
stdio: ["ignore", "pipe", "pipe"]
@ -1992,6 +2169,47 @@ function resolveGatewayEntry(): string {
return requireFromHere.resolve(gatewayPackageCandidates[0]);
}
function writePatchedGatewayRuntimeEntry(config: AppConfig, gatewayEntry: string): string {
const source = readFileSync(gatewayEntry, "utf8");
const matchingPatches = gatewayRuntimePatches.filter((patch) => source.includes(patch.search));
if (matchingPatches.length === 0) {
return gatewayEntry;
}
if (matchingPatches.length !== gatewayRuntimePatches.length) {
const matchedLabels = matchingPatches.map((patch) => patch.label).join(", ");
const expectedLabels = gatewayRuntimePatches.map((patch) => patch.label).join(", ");
throw new Error(`ai-gateway runtime patch mismatch. Matched: ${matchedLabels || "none"}. Expected: ${expectedLabels}.`);
}
const outputFile = pathJoin(dirname(config.gateway.generatedConfigFile), gatewayRuntimePatchEntryFile);
mkdirSync(dirname(outputFile), { recursive: true });
writeFileSync(outputFile, buildGatewayRuntimePatchEntry(gatewayEntry), "utf8");
return outputFile;
}
function buildGatewayRuntimePatchEntry(gatewayEntry: string): string {
return [
'"use strict";',
'const fs = require("node:fs");',
'const Module = require("node:module");',
'const path = require("node:path");',
`const gatewayEntry = ${JSON.stringify(gatewayEntry)};`,
`const patches = ${JSON.stringify(gatewayRuntimePatches)};`,
'let source = fs.readFileSync(gatewayEntry, "utf8");',
'for (const patch of patches) {',
' if (!source.includes(patch.search)) {',
' throw new Error(`Unable to apply CCR ai-gateway runtime patch: ${patch.label}`);',
' }',
' source = source.replace(patch.search, patch.replacement);',
'}',
'const patchedModule = new Module(gatewayEntry, module.parent);',
'patchedModule.filename = gatewayEntry;',
'patchedModule.paths = Module._nodeModulePaths(path.dirname(gatewayEntry));',
'patchedModule._compile(source, gatewayEntry);',
""
].join("\n");
}
function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
@ -2321,6 +2539,104 @@ function endpoint(host: string, port: number): string {
return `http://${endpointHost}:${port}`;
}
function gatewayNetworkEndpoints(host: string, port: number): GatewayNetworkEndpoint[] {
const normalizedHost = normalizeBindHost(host);
const lanAddresses = physicalLanAddresses();
const addresses = isWildcardBindHost(normalizedHost)
? lanAddresses
: lanAddresses.filter((entry) => entry.address === normalizedHost);
return addresses.map((entry) => ({
address: entry.address,
endpoint: endpoint(entry.address, port),
interfaceName: entry.interfaceName
}));
}
function physicalLanAddresses(): Array<{ address: string; interfaceName: string }> {
const seen = new Set<string>();
const result: Array<{ address: string; interfaceName: string }> = [];
for (const [interfaceName, entries] of Object.entries(networkInterfaces())) {
if (!entries || isVirtualNetworkInterface(interfaceName)) {
continue;
}
for (const entry of entries) {
if (entry.internal || entry.family !== "IPv4" || !isPrivateIpv4(entry.address)) {
continue;
}
const key = `${interfaceName}:${entry.address}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({ address: entry.address, interfaceName });
}
}
return result.sort((left, right) =>
left.interfaceName.localeCompare(right.interfaceName) ||
left.address.localeCompare(right.address, undefined, { numeric: true })
);
}
function normalizeBindHost(host: string): string {
return host.trim().replace(/^\[|\]$/g, "").toLowerCase();
}
function isWildcardBindHost(host: string): boolean {
return host === "" || host === "0.0.0.0" || host === "::" || host === "::0";
}
function isPrivateIpv4(address: string): boolean {
const parts = address.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
return parts[0] === 10 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168);
}
function isVirtualNetworkInterface(interfaceName: string): boolean {
const normalized = interfaceName.toLowerCase();
return [
/^lo\d*$/,
/^awdl\d*$/,
/^llw\d*$/,
/^utun\d*$/,
/^gif\d*$/,
/^stf\d*$/,
/^bridge\d*$/,
/^br-/,
/^docker/,
/^veth/,
/^vmnet/,
/^vbox/,
/^tun\d*$/,
/^tap\d*$/,
/^wg\d*$/,
/\bloopback\b/,
/\bvirtual\b/,
/\bvirtualbox\b/,
/\bvmware\b/,
/\bhyper-v\b/,
/\bvethernet\b/,
/\bwsl\b/,
/\btunnel\b/,
/\btailscale\b/,
/\bzerotier\b/,
/\bwireguard\b/,
/\bhamachi\b/,
/\bparallels\b/,
/\bvpn\b/
].some((pattern) => pattern.test(normalized));
}
async function stopPreviousManagedCoreGateway(config: AppConfig, coreEndpoint: string): Promise<void> {
const marker = readManagedCoreGatewayMarker(config);
const markerRuntimeId = stringValue(marker?.runtimeId);
@ -2449,7 +2765,7 @@ async function readCoreGatewayHealth(coreEndpoint: string): Promise<CoreGatewayH
const timer = setTimeout(() => controller.abort(), 500);
try {
const healthUrl = new URL("/health", coreEndpoint);
const response = await fetch(healthUrl, { signal: controller.signal });
const response = await fetchWithSystemProxy(healthUrl, { signal: controller.signal });
if (!response.ok) {
return undefined;
}

View file

@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import { fetchWithSystemProxy } from "../../main/system-proxy-fetch";
type JsonPrimitive = boolean | null | number | string;
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
@ -250,7 +251,7 @@ async function analyzeVision(args: Record<string, unknown>): Promise<string> {
}
];
const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? readNumber(env("VISION_TIMEOUT_MS")) ?? defaultTimeoutMs, 100, 600000);
const response = await fetch(resolveChatCompletionsUrl(baseUrl), {
const response = await fetchWithSystemProxy(resolveChatCompletionsUrl(baseUrl), {
body: JSON.stringify({ model, messages }),
headers: {
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
@ -613,7 +614,7 @@ function requireEnv(name: string, label: string): string {
}
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
const response = await fetchWithSystemProxy(url, init);
const rawText = await response.text();
const payload = rawText ? parseJson(rawText) : {};
if (!response.ok) {

View file

@ -1,4 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { fetchWithSystemProxy } from "../../main/system-proxy-fetch";
import type {
GatewayMcpRemoteServerConfig,
GatewayMcpServerConfig,
@ -200,7 +201,7 @@ async function listLegacySseMcpServerTools(
let nextId = 1;
try {
const response = await fetch(server.url, {
const response = await fetchWithSystemProxy(server.url, {
headers: mcpHttpHeaders(server, "", false),
method: "GET",
signal: controller.signal
@ -306,7 +307,7 @@ async function postJsonRpc(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), mcpTimeoutMs(server));
try {
const response = await fetch(url, {
const response = await fetchWithSystemProxy(url, {
body: JSON.stringify(message),
headers: mcpHttpHeaders(server, sessionId, true),
method: "POST",

View file

@ -232,6 +232,33 @@ export type ProviderManifestFetchResult = {
url: string;
};
export type LocalAgentProviderKind = "claude-code" | "codex" | "zcode";
export type LocalAgentProviderStatus = "available" | "locked" | "missing";
export type LocalAgentProviderCandidate = {
detail?: string;
id: string;
importable: boolean;
kind: LocalAgentProviderKind;
models: string[];
name: string;
protocol: GatewayProviderProtocol;
sourceFile?: string;
status: LocalAgentProviderStatus;
};
export type LocalAgentProviderImportRequest = {
id: string;
providerNames?: string[];
};
export type LocalAgentProviderImportResult = {
candidate: LocalAgentProviderCandidate;
provider: ProviderDeepLinkPayload;
providerPlugins: unknown[];
};
export type ProviderCatalogModelsRequest = {
baseUrl?: string;
name?: string;
@ -1194,6 +1221,12 @@ export type ClaudeAppGatewayApplyResult = {
requiresRestart: boolean;
};
export type GatewayNetworkEndpoint = {
address: string;
interfaceName: string;
endpoint: string;
};
export type GatewayStatus = {
coreEndpoint: string;
coreManagedExternally?: boolean;
@ -1201,6 +1234,7 @@ export type GatewayStatus = {
generatedConfigFile: string;
lastError?: string;
lastStartedAt?: string;
networkEndpoints: GatewayNetworkEndpoint[];
pid?: number;
state: "stopped" | "starting" | "running" | "error";
};

View file

@ -10,6 +10,7 @@ export const IPC_CHANNELS = {
appGetPendingProviderDeepLinks: "ccr:app:get-pending-provider-deep-links",
appGetProfileOpenCommand: "ccr:app:get-profile-open-command",
appGetProfileRuntimeStatus: "ccr:app:get-profile-runtime-status",
appGetLocalAgentProviderCandidates: "ccr:app:get-local-agent-provider-candidates",
appGetProviderAccountSnapshots: "ccr:app:get-provider-account-snapshots",
appGetProviderCatalogModels: "ccr:app:get-provider-catalog-models",
appGetProviderPresets: "ccr:app:get-provider-presets",
@ -21,6 +22,7 @@ export const IPC_CHANNELS = {
appGetUsageStats: "ccr:app:get-usage-stats",
appFetchProviderManifest: "ccr:app:fetch-provider-manifest",
appInstallProxyCertificate: "ccr:app:install-proxy-certificate",
appImportLocalAgentProvider: "ccr:app:import-local-agent-provider",
appListMcpServerTools: "ccr:app:list-mcp-server-tools",
appOpenBuiltInBrowser: "ccr:app:open-built-in-browser",
appOpenExternal: "ccr:app:open-external",