mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 00:24:08 +00:00
Merge pull request #167 from kaitranntt/kai/fix/cliproxy-server-debug
feat(cliproxy): add remote CLIProxy routing with dev merge
This commit is contained in:
@@ -51,6 +51,7 @@ import {
|
||||
import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker';
|
||||
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
|
||||
import { withStartupLock } from './startup-lock';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -126,7 +127,25 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
|
||||
// This filters proxy flags from args and returns resolved config
|
||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args);
|
||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
|
||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
|
||||
remote: cliproxyServerConfig?.remote
|
||||
? {
|
||||
enabled: cliproxyServerConfig.remote.enabled,
|
||||
host: cliproxyServerConfig.remote.host,
|
||||
port: cliproxyServerConfig.remote.port,
|
||||
protocol: cliproxyServerConfig.remote.protocol,
|
||||
auth_token: cliproxyServerConfig.remote.auth_token,
|
||||
}
|
||||
: undefined,
|
||||
local: cliproxyServerConfig?.local
|
||||
? {
|
||||
port: cliproxyServerConfig.local.port,
|
||||
auto_start: cliproxyServerConfig.local.auto_start,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Use resolved port from proxy config (overrides ExecutorConfig)
|
||||
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
@@ -516,14 +535,29 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// 7. Execute Claude CLI with proxied environment
|
||||
// Use remote or local env vars based on mode
|
||||
// When remote is configured (even if using local), pass config for URL rewriting
|
||||
const remoteRewriteConfig =
|
||||
proxyConfig.mode === 'remote' && proxyConfig.host
|
||||
? {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const envVars = useRemoteProxy
|
||||
? getRemoteEnvVars(provider, {
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
})
|
||||
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||
? getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
},
|
||||
cfg.customSettingsPath
|
||||
)
|
||||
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const env = {
|
||||
...process.env,
|
||||
|
||||
@@ -392,6 +392,48 @@ function getGlobalEnvVars(): Record<string, string> {
|
||||
return globalEnvConfig.env;
|
||||
}
|
||||
|
||||
/** Remote proxy configuration for URL rewriting */
|
||||
interface RemoteProxyRewriteConfig {
|
||||
host: string;
|
||||
port?: number;
|
||||
protocol: 'http' | 'https';
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite localhost URLs to remote server URLs.
|
||||
* Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0
|
||||
*/
|
||||
function rewriteLocalhostUrls(
|
||||
envVars: NodeJS.ProcessEnv,
|
||||
provider: CLIProxyProvider,
|
||||
remoteConfig: RemoteProxyRewriteConfig
|
||||
): NodeJS.ProcessEnv {
|
||||
const result = { ...envVars };
|
||||
const baseUrl = result.ANTHROPIC_BASE_URL;
|
||||
|
||||
if (!baseUrl) return result;
|
||||
|
||||
// Check if URL points to localhost (127.0.0.1, localhost, 0.0.0.0)
|
||||
const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i;
|
||||
if (!localhostPattern.test(baseUrl)) return result;
|
||||
|
||||
// Build remote URL with smart port handling
|
||||
const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80;
|
||||
const effectivePort = remoteConfig.port ?? defaultPort;
|
||||
const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`;
|
||||
const remoteBaseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
|
||||
|
||||
result.ANTHROPIC_BASE_URL = remoteBaseUrl;
|
||||
|
||||
// Update auth token if provided
|
||||
if (remoteConfig.authToken) {
|
||||
result.ANTHROPIC_AUTH_TOKEN = remoteConfig.authToken;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective environment variables for provider
|
||||
*
|
||||
@@ -402,15 +444,20 @@ function getGlobalEnvVars(): Record<string, string> {
|
||||
*
|
||||
* All results are merged with global_env vars (telemetry/reporting disables).
|
||||
* User takes full responsibility for custom settings.
|
||||
*
|
||||
* If remoteRewriteConfig is provided, localhost URLs are rewritten to remote server.
|
||||
*/
|
||||
export function getEffectiveEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
customSettingsPath?: string
|
||||
customSettingsPath?: string,
|
||||
remoteRewriteConfig?: RemoteProxyRewriteConfig
|
||||
): NodeJS.ProcessEnv {
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
let envVars: NodeJS.ProcessEnv;
|
||||
|
||||
// Priority 1: Custom settings path (for user-defined variants)
|
||||
if (customSettingsPath) {
|
||||
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
|
||||
@@ -421,7 +468,12 @@ export function getEffectiveEnvVars(
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Custom variant settings found - merge with global env
|
||||
return { ...globalEnv, ...settings.env };
|
||||
envVars = { ...globalEnv, ...settings.env };
|
||||
// Apply remote rewrite if configured
|
||||
if (remoteRewriteConfig) {
|
||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to provider defaults
|
||||
@@ -443,7 +495,12 @@ export function getEffectiveEnvVars(
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// User override found - merge with global env
|
||||
return { ...globalEnv, ...settings.env };
|
||||
envVars = { ...globalEnv, ...settings.env };
|
||||
// Apply remote rewrite if configured
|
||||
if (remoteRewriteConfig) {
|
||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON or structure - fall through to defaults
|
||||
@@ -483,48 +540,89 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
/**
|
||||
* Get environment variables for remote proxy mode.
|
||||
* Uses the remote proxy's provider endpoint as the base URL.
|
||||
* Respects user model settings from custom settings path or provider settings file.
|
||||
*
|
||||
* @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow)
|
||||
* @param remoteConfig Remote proxy connection details
|
||||
* @param customSettingsPath Optional path to user's custom settings file
|
||||
* @returns Environment variables for Claude CLI
|
||||
*/
|
||||
export function getRemoteEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }
|
||||
remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string },
|
||||
customSettingsPath?: string
|
||||
): Record<string, string> {
|
||||
// Build URL with smart port handling - omit if using protocol default
|
||||
const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80;
|
||||
const effectivePort = remoteConfig.port ?? defaultPort;
|
||||
const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`;
|
||||
const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
// Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.)
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
// Load user settings with priority: custom path > user settings file > base config
|
||||
let userEnvVars: Record<string, string> = {};
|
||||
|
||||
// Filter out core env vars from base config to avoid conflicts
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
ANTHROPIC_MODEL: _model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
// Priority 1: Custom settings path (for user-defined variants)
|
||||
if (customSettingsPath) {
|
||||
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
|
||||
if (fs.existsSync(expandedPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(expandedPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to provider defaults
|
||||
console.warn(warn(`Invalid settings file: ${customSettingsPath}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
|
||||
if (Object.keys(userEnvVars).length === 0) {
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to base config
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Base config defaults
|
||||
if (Object.keys(userEnvVars).length === 0) {
|
||||
const models = getModelMapping(provider);
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
// Filter out URL/auth from base config (we'll set those from remote config)
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
userEnvVars = {
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
}
|
||||
|
||||
// Build final env: global + user settings + remote URL/auth override
|
||||
const env: Record<string, string> = {
|
||||
...globalEnv,
|
||||
...additionalEnvVars,
|
||||
...userEnvVars,
|
||||
// Always override URL and auth token with remote config
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
|
||||
return env;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Proxy Target Resolver
|
||||
*
|
||||
* Determines whether CLIProxyAPI requests should go to local or remote
|
||||
* based on unified config. Used by stats-fetcher, auth-routes, and UI.
|
||||
*/
|
||||
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import type { CliproxyServerConfig } from '../config/unified-config-types';
|
||||
|
||||
/** Default CLIProxyAPI port */
|
||||
const DEFAULT_CLIPROXY_PORT = 8317;
|
||||
|
||||
/** Resolved proxy target for making requests */
|
||||
export interface ProxyTarget {
|
||||
/** Target hostname or IP */
|
||||
host: string;
|
||||
/** Target port */
|
||||
port: number;
|
||||
/** Protocol (http/https) */
|
||||
protocol: 'http' | 'https';
|
||||
/** Optional auth token - only send header if defined and non-empty */
|
||||
authToken?: string;
|
||||
/** True if targeting remote server, false if local */
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cliproxy_server configuration from unified config.
|
||||
* Returns undefined if not configured.
|
||||
*/
|
||||
function loadCliproxyServerConfig(): CliproxyServerConfig | undefined {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.cliproxy_server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current CLIProxyAPI target based on unified config.
|
||||
* Returns remote server config if enabled, otherwise localhost.
|
||||
*/
|
||||
export function getProxyTarget(): ProxyTarget {
|
||||
const config = loadCliproxyServerConfig();
|
||||
|
||||
if (config?.remote?.enabled && config.remote?.host) {
|
||||
const protocol = config.remote.protocol ?? 'http';
|
||||
// Default port based on protocol if not specified
|
||||
const defaultPort = protocol === 'https' ? 443 : 80;
|
||||
const port = config.remote.port ?? defaultPort;
|
||||
|
||||
return {
|
||||
host: config.remote.host,
|
||||
port,
|
||||
protocol,
|
||||
authToken: config.remote.auth_token || undefined, // Empty string -> undefined
|
||||
isRemote: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
host: '127.0.0.1',
|
||||
port: config?.local?.port ?? DEFAULT_CLIPROXY_PORT,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for proxy endpoint
|
||||
* @param target Resolved proxy target
|
||||
* @param path Endpoint path (e.g., '/v0/management/usage')
|
||||
*/
|
||||
export function buildProxyUrl(target: ProxyTarget, path: string): string {
|
||||
// Normalize path to ensure leading slash
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${target.protocol}://${target.host}:${target.port}${normalizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build request headers for proxy requests
|
||||
* Handles optional auth token - only adds Authorization header if token is set.
|
||||
*
|
||||
* @param target Resolved proxy target
|
||||
* @param additionalHeaders Extra headers to merge
|
||||
*/
|
||||
export function buildProxyHeaders(
|
||||
target: ProxyTarget,
|
||||
additionalHeaders: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
// Only add auth header if token is configured
|
||||
if (target.authToken) {
|
||||
headers['Authorization'] = `Bearer ${target.authToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Remote Auth Fetcher
|
||||
* Fetches and transforms auth data from remote CLIProxyAPI.
|
||||
*/
|
||||
|
||||
import {
|
||||
getProxyTarget,
|
||||
buildProxyUrl,
|
||||
buildProxyHeaders,
|
||||
ProxyTarget,
|
||||
} from './proxy-target-resolver';
|
||||
|
||||
/** Timeout for remote fetch requests (ms) */
|
||||
const REMOTE_FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Remote auth file from CLIProxyAPI /v0/management/auth-files */
|
||||
interface RemoteAuthFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
email?: string;
|
||||
status: 'active' | 'disabled' | 'unavailable';
|
||||
source: 'file' | 'memory';
|
||||
}
|
||||
|
||||
/** Account info for UI display */
|
||||
export interface RemoteAccountInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
isDefault: boolean;
|
||||
status: 'active' | 'disabled' | 'unavailable';
|
||||
}
|
||||
|
||||
/** Auth status for a provider (UI format) */
|
||||
export interface RemoteAuthStatus {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
authenticated: boolean;
|
||||
tokenFiles: number;
|
||||
accounts: RemoteAccountInfo[];
|
||||
defaultAccount: string | null;
|
||||
source: 'remote';
|
||||
}
|
||||
|
||||
/** Map CLIProxyAPI provider names to CCS internal names */
|
||||
const PROVIDER_MAP: Record<string, string> = {
|
||||
gemini: 'gemini',
|
||||
'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth
|
||||
antigravity: 'agy',
|
||||
codex: 'codex',
|
||||
qwen: 'qwen',
|
||||
iflow: 'iflow',
|
||||
};
|
||||
|
||||
/** Display names for providers */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
gemini: 'Google Gemini',
|
||||
agy: 'AntiGravity',
|
||||
codex: 'Codex',
|
||||
qwen: 'Qwen',
|
||||
iflow: 'iFlow',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch auth status from remote CLIProxyAPI
|
||||
* @throws Error if remote is unreachable or returns error
|
||||
*/
|
||||
export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<RemoteAuthStatus[]> {
|
||||
const proxyTarget = target ?? getProxyTarget();
|
||||
|
||||
if (!proxyTarget.isRemote) {
|
||||
throw new Error('fetchRemoteAuthStatus called but remote mode not enabled');
|
||||
}
|
||||
|
||||
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: buildProxyHeaders(proxyTarget),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('Authentication failed - check auth token in settings');
|
||||
}
|
||||
throw new Error(`Remote returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// Validate response structure
|
||||
if (!data || typeof data !== 'object' || !('files' in data) || !Array.isArray(data.files)) {
|
||||
throw new Error('Invalid response format from remote auth endpoint');
|
||||
}
|
||||
|
||||
return transformRemoteAuthFiles(data.files as RemoteAuthFile[]);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Remote proxy connection timed out');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform CLIProxyAPI auth files to CCS AuthStatus format
|
||||
* @param files Array of auth files from remote API
|
||||
*/
|
||||
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
|
||||
const byProvider = new Map<string, RemoteAuthFile[]>();
|
||||
|
||||
for (const file of files) {
|
||||
const provider = PROVIDER_MAP[file.provider.toLowerCase()];
|
||||
if (!provider) {
|
||||
// Unknown provider, skip (could add logging in debug mode)
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = byProvider.get(provider);
|
||||
if (existing) {
|
||||
existing.push(file);
|
||||
} else {
|
||||
byProvider.set(provider, [file]);
|
||||
}
|
||||
}
|
||||
|
||||
const result: RemoteAuthStatus[] = [];
|
||||
|
||||
for (const [provider, providerFiles] of byProvider) {
|
||||
const activeFiles = providerFiles.filter((f) => f.status === 'active');
|
||||
const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({
|
||||
id: f.id,
|
||||
email: f.email || f.name || 'Unknown',
|
||||
isDefault: idx === 0,
|
||||
status: f.status,
|
||||
}));
|
||||
|
||||
result.push({
|
||||
provider,
|
||||
displayName: PROVIDER_DISPLAY_NAMES[provider] || provider,
|
||||
authenticated: activeFiles.length > 0,
|
||||
tokenFiles: providerFiles.length,
|
||||
accounts,
|
||||
defaultAccount: accounts.find((a) => a.isDefault)?.id || null,
|
||||
source: 'remote',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
* Requires usage-statistics-enabled: true in config.yaml.
|
||||
*/
|
||||
|
||||
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
export interface AccountUsageStats {
|
||||
@@ -95,19 +96,27 @@ interface UsageApiResponse {
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Stats object or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyStats(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyStats | null> {
|
||||
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v0/management/usage');
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -222,20 +231,27 @@ export interface CliproxyModelsResponse {
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Categorized models or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyModels(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyModelsResponse | null> {
|
||||
export async function fetchCliproxyModels(port?: number): Promise<CliproxyModelsResponse | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v1/models');
|
||||
|
||||
// For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Use the internal API key for /v1 endpoints
|
||||
Authorization: 'Bearer ccs-internal-managed',
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -293,19 +309,27 @@ interface ErrorLogsApiResponse {
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Array of error log metadata or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyErrorLogs(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyErrorLog[] | null> {
|
||||
export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErrorLog[] | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v0/management/request-error-logs');
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -329,22 +353,33 @@ export async function fetchCliproxyErrorLogs(
|
||||
*/
|
||||
export async function fetchCliproxyErrorLogContent(
|
||||
name: string,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
port?: number
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
}
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(
|
||||
target,
|
||||
`/v0/management/request-error-logs/${encodeURIComponent(name)}`
|
||||
);
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -362,13 +397,21 @@ export async function fetchCliproxyErrorLogContent(
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns true if proxy is running
|
||||
*/
|
||||
export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise<boolean> {
|
||||
export async function isCliproxyRunning(port?: number): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
||||
|
||||
// Use root endpoint - CLIProxyAPI returns server info at /
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/');
|
||||
|
||||
// Health check - no auth needed for root endpoint
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
removeAccount as removeAccountFn,
|
||||
touchAccount,
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
|
||||
const router = Router();
|
||||
@@ -34,7 +36,15 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Initialize accounts from existing tokens on first request
|
||||
// Check if remote mode is enabled
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
const authStatus = await fetchRemoteAuthStatus(target);
|
||||
res.json({ authStatus, source: 'remote' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Local mode: Initialize accounts from existing tokens on first request
|
||||
initializeAccounts();
|
||||
|
||||
// Fetch CLIProxyAPI usage stats to determine active providers
|
||||
@@ -85,7 +95,17 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
|
||||
res.json({ authStatus });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
// Return appropriate error for remote vs local mode
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(503).json({
|
||||
error: (error as Error).message,
|
||||
authStatus: [],
|
||||
source: 'remote',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,16 +114,40 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
/**
|
||||
* GET /api/cliproxy/accounts - Get all accounts across all providers
|
||||
*/
|
||||
router.get('/accounts', (_req: Request, res: Response) => {
|
||||
router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Initialize accounts from existing tokens
|
||||
// Check if remote mode is enabled
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
const authStatus = await fetchRemoteAuthStatus(target);
|
||||
// Transform RemoteAuthStatus[] to account summary format
|
||||
const accounts = authStatus.flatMap((status) =>
|
||||
status.accounts.map((acc) => ({
|
||||
provider: status.provider,
|
||||
...acc,
|
||||
}))
|
||||
);
|
||||
res.json({ accounts, source: 'remote' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Local mode: Initialize accounts from existing tokens
|
||||
initializeAccounts();
|
||||
|
||||
const accounts = getAllAccountsSummary();
|
||||
res.json({ accounts });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list accounts';
|
||||
res.status(500).json({ error: message });
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(503).json({
|
||||
error: (error as Error).message,
|
||||
accounts: [],
|
||||
source: 'remote',
|
||||
});
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list accounts';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -132,6 +176,15 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => {
|
||||
* POST /api/cliproxy/accounts/:provider/default - Set default account for provider
|
||||
*/
|
||||
router.post('/accounts/:provider/default', (req: Request, res: Response): void => {
|
||||
// Check if remote mode is enabled - account management not available
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({
|
||||
error: 'Account management not available in remote mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider } = req.params;
|
||||
const { accountId } = req.body;
|
||||
|
||||
@@ -166,6 +219,15 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
|
||||
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
|
||||
*/
|
||||
router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => {
|
||||
// Check if remote mode is enabled - account management not available
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({
|
||||
error: 'Account management not available in remote mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider, accountId } = req.params;
|
||||
|
||||
// Validate provider
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
*
|
||||
* Embeds the CLIProxy management.html with auto-authentication.
|
||||
* Uses postMessage to inject credentials into the iframe.
|
||||
* Supports both local and remote CLIProxy server connections.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
|
||||
/** CLIProxyAPI default port */
|
||||
const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
@@ -25,30 +29,94 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [showLoginHint, setShowLoginHint] = useState(true);
|
||||
|
||||
const managementUrl = `http://localhost:${port}/management.html`;
|
||||
// Fetch cliproxy_server config for remote/local mode detection
|
||||
const { data: cliproxyConfig, error: configError } = useQuery<CliproxyServerConfig>({
|
||||
queryKey: ['cliproxy-server-config'],
|
||||
queryFn: () => api.cliproxyServer.get(),
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Log config fetch errors (fallback to local mode on error)
|
||||
useEffect(() => {
|
||||
if (configError) {
|
||||
console.warn('[ControlPanelEmbed] Config fetch failed, using local mode:', configError);
|
||||
}
|
||||
}, [configError]);
|
||||
|
||||
// Calculate URLs and settings based on remote or local mode
|
||||
const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => {
|
||||
const remote = cliproxyConfig?.remote;
|
||||
|
||||
if (remote?.enabled && remote?.host) {
|
||||
const protocol = remote.protocol || 'http';
|
||||
// Use port from config, or default based on protocol (443 for https, 80 for http)
|
||||
const remotePort = remote.port || (protocol === 'https' ? 443 : 80);
|
||||
// Only include port in URL if it's non-standard
|
||||
const portSuffix =
|
||||
(protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80)
|
||||
? ''
|
||||
: `:${remotePort}`;
|
||||
const baseUrl = `${protocol}://${remote.host}${portSuffix}`;
|
||||
|
||||
return {
|
||||
managementUrl: `${baseUrl}/management.html`,
|
||||
checkUrl: `${baseUrl}/`,
|
||||
authToken: remote.auth_token || undefined,
|
||||
isRemote: true,
|
||||
displayHost: `${remote.host}${portSuffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Local mode
|
||||
return {
|
||||
managementUrl: `http://localhost:${port}/management.html`,
|
||||
checkUrl: `http://localhost:${port}/`,
|
||||
authToken: CCS_CONTROL_PANEL_SECRET,
|
||||
isRemote: false,
|
||||
displayHost: `localhost:${port}`,
|
||||
};
|
||||
}, [cliproxyConfig, port]);
|
||||
|
||||
// Check if CLIProxy is running
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const checkConnection = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
const response = await fetch(checkUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy returned an error');
|
||||
setError(
|
||||
isRemote
|
||||
? `Remote CLIProxy at ${displayHost} returned an error`
|
||||
: 'CLIProxy returned an error'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Ignore abort errors (component unmounting)
|
||||
if (e instanceof Error && e.name === 'AbortError') return;
|
||||
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy is not running');
|
||||
setError(
|
||||
isRemote
|
||||
? `Remote CLIProxy at ${displayHost} is not reachable`
|
||||
: 'CLIProxy is not running'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
}, [port]);
|
||||
// Start connection check with timeout
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
checkConnection().finally(() => clearTimeout(timeoutId));
|
||||
|
||||
// Cleanup: abort fetch on unmount
|
||||
return () => controller.abort();
|
||||
}, [checkUrl, isRemote, displayHost]);
|
||||
|
||||
// Handle iframe load - attempt to auto-login via postMessage
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
@@ -57,26 +125,38 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
// Try to inject credentials via postMessage
|
||||
// The management.html needs to listen for this message
|
||||
// If it doesn't support it, user will see the login page
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
if (iframeRef.current?.contentWindow && authToken) {
|
||||
try {
|
||||
// Derive apiBase from checkUrl (remove trailing slash)
|
||||
const apiBase = checkUrl.replace(/\/$/, '');
|
||||
|
||||
// Security: Validate iframe src matches target origin before sending credentials
|
||||
const iframeSrc = iframeRef.current.src;
|
||||
if (!iframeSrc.startsWith(apiBase)) {
|
||||
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send credentials to iframe
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: 'ccs-auto-login',
|
||||
apiBase: `http://localhost:${port}`,
|
||||
managementKey: CCS_CONTROL_PANEL_SECRET,
|
||||
apiBase,
|
||||
managementKey: authToken,
|
||||
},
|
||||
`http://localhost:${port}`
|
||||
apiBase
|
||||
);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Cross-origin restriction - expected if not same origin
|
||||
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
|
||||
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
|
||||
}
|
||||
}
|
||||
}, [port]);
|
||||
}, [checkUrl, authToken]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsConnected(false);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = managementUrl;
|
||||
}
|
||||
@@ -119,15 +199,24 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Login hint banner */}
|
||||
{/* Remote indicator and login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
{isRemote && (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">Remote</span>
|
||||
<span className="text-blue-300 dark:text-blue-700">|</span>
|
||||
</>
|
||||
)}
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
ccs
|
||||
{authToken && authToken.length > 4
|
||||
? `***${authToken.slice(-4)}`
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
@@ -145,7 +234,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function ProviderEditor({
|
||||
authStatus,
|
||||
catalog,
|
||||
logoProvider,
|
||||
isRemoteMode,
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
@@ -124,6 +125,7 @@ export function ProviderEditor({
|
||||
hasChanges={hasChanges}
|
||||
isRawJsonValid={isRawJsonValid}
|
||||
isSaving={saveMutation.isPending}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onRefetch={refetch}
|
||||
onSave={() => saveMutation.mutate()}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Save, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Save, Loader2, RefreshCw, Globe } from 'lucide-react';
|
||||
import { ProviderLogo } from '../provider-logo';
|
||||
import type { SettingsResponse } from './types';
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ProviderEditorHeaderProps {
|
||||
hasChanges: boolean;
|
||||
isRawJsonValid: boolean;
|
||||
isSaving: boolean;
|
||||
isRemoteMode?: boolean;
|
||||
onRefetch: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export function ProviderEditorHeader({
|
||||
hasChanges,
|
||||
isRawJsonValid,
|
||||
isSaving,
|
||||
isRemoteMode,
|
||||
onRefetch,
|
||||
onSave,
|
||||
}: ProviderEditorHeaderProps) {
|
||||
@@ -41,16 +43,31 @@ export function ProviderEditorHeader({
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{displayName}</h2>
|
||||
{data?.path && (
|
||||
{isRemoteMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs gap-1 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
Remote
|
||||
</Badge>
|
||||
)}
|
||||
{!isRemoteMode && data?.path && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{data.path.replace(/^.*\//, '')}
|
||||
{data.path.replace(/^.*[\\/]/, '')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
{isRemoteMode ? (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
Traffic auto-routed to remote server
|
||||
</p>
|
||||
) : (
|
||||
data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface ProviderEditorProps {
|
||||
catalog?: ProviderCatalog;
|
||||
/** Provider type for logo display (defaults to provider) */
|
||||
logoProvider?: string;
|
||||
/** True if using remote CLIProxy mode (hides local paths) */
|
||||
isRemoteMode?: boolean;
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
*
|
||||
* Displays CLIProxy process status with start/stop/restart controls.
|
||||
* Shows: running state, port, session count, uptime, update availability.
|
||||
* In remote mode: shows remote server info instead of local controls.
|
||||
*/
|
||||
|
||||
import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
Activity,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Users,
|
||||
Square,
|
||||
RotateCw,
|
||||
ArrowUp,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, type CliproxyServerConfig } from '@/lib/api-client';
|
||||
import {
|
||||
useProxyStatus,
|
||||
useStartProxy,
|
||||
@@ -48,10 +61,32 @@ export function ProxyStatusWidget() {
|
||||
const startProxy = useStartProxy();
|
||||
const stopProxy = useStopProxy();
|
||||
|
||||
// Fetch cliproxy_server config for remote mode detection
|
||||
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
|
||||
queryKey: ['cliproxy-server-config'],
|
||||
queryFn: () => api.cliproxyServer.get(),
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Determine if remote mode is enabled
|
||||
const remoteConfig = cliproxyConfig?.remote;
|
||||
const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host;
|
||||
|
||||
const isRunning = status?.running ?? false;
|
||||
const isActioning = startProxy.isPending || stopProxy.isPending;
|
||||
const hasUpdate = updateCheck?.hasUpdate ?? false;
|
||||
|
||||
// Build remote display info
|
||||
const remoteDisplayHost = isRemoteMode
|
||||
? (() => {
|
||||
const protocol = remoteConfig.protocol || 'http';
|
||||
const port = remoteConfig.port || (protocol === 'https' ? 443 : 80);
|
||||
const isDefaultPort =
|
||||
(protocol === 'https' && port === 443) || (protocol === 'http' && port === 80);
|
||||
return isDefaultPort ? remoteConfig.host : `${remoteConfig.host}:${port}`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Restart = stop then start
|
||||
const handleRestart = async () => {
|
||||
await stopProxy.mutateAsync();
|
||||
@@ -60,6 +95,43 @@ export function ProxyStatusWidget() {
|
||||
startProxy.mutate();
|
||||
};
|
||||
|
||||
// Remote mode: show remote server info
|
||||
if (isRemoteMode) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-3 transition-colors',
|
||||
'border-blue-500/30 bg-blue-500/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Remote Proxy</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
<Activity className="w-3 h-3 text-blue-600" />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="font-mono">{remoteDisplayHost}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 leading-tight">
|
||||
Traffic auto-routed to remote server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Local mode: show original controls
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -249,7 +249,8 @@ export const api = {
|
||||
},
|
||||
cliproxy: {
|
||||
list: () => request<{ variants: Variant[] }>('/cliproxy'),
|
||||
getAuthStatus: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'),
|
||||
getAuthStatus: () =>
|
||||
request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'),
|
||||
create: (data: CreateVariant) =>
|
||||
request('/cliproxy', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -192,6 +192,7 @@ export function CliproxyPage() {
|
||||
} | null>(null);
|
||||
|
||||
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
||||
const isRemoteMode = authData?.source === 'remote';
|
||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||
|
||||
// Auto-select first provider if nothing selected
|
||||
@@ -338,6 +339,7 @@ export function CliproxyPage() {
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
@@ -365,6 +367,7 @@ export function CliproxyPage() {
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
|
||||
Reference in New Issue
Block a user