mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-10 01:28:25 +00:00
Refine router configuration and request handling
This commit is contained in:
parent
aec04e744f
commit
c267bb293f
30 changed files with 2153 additions and 84 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
37
src/main/local-agent-provider-service.ts
Normal file
37
src/main/local-agent-provider-service.ts
Normal 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 ?? []);
|
||||
}
|
||||
160
src/main/local-agent-providers/claude-code.ts
Normal file
160
src/main/local-agent-providers/claude-code.ts
Normal 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")
|
||||
]);
|
||||
}
|
||||
233
src/main/local-agent-providers/codex.ts
Normal file
233
src/main/local-agent-providers/codex.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
192
src/main/local-agent-providers/shared.ts
Normal file
192
src/main/local-agent-providers/shared.ts
Normal 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);
|
||||
}
|
||||
250
src/main/local-agent-providers/zcode.ts
Normal file
250
src/main/local-agent-providers/zcode.ts
Normal 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")
|
||||
]);
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
193
src/main/system-proxy-fetch.ts
Normal file
193
src/main/system-proxy-fetch.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 ? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ export function OnboardingView({
|
|||
onChange={onChangeProvider}
|
||||
probe={providerProbe}
|
||||
probeLoading={providerProbeLoading}
|
||||
providerPlugins={config.providerPlugins ?? []}
|
||||
providers={config.Providers}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (国内) - 通用端点",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ export type AddProviderDraft = {
|
|||
modelsText: string;
|
||||
name: string;
|
||||
presetId: string;
|
||||
providerPlugins: unknown[];
|
||||
protocol: GatewayProviderProtocol;
|
||||
selectedModels: string[];
|
||||
selectedProtocols: GatewayProviderProtocol[];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
5
src/renderer/types/electron.d.ts
vendored
5
src/renderer/types/electron.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue