Merge pull request #580 from kaitranntt/refactor/cliproxy-hardcoded-standardization

refactor(cliproxy): centralize provider capabilities and remove hardcoded defaults
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-18 21:03:22 +07:00
committed by GitHub
27 changed files with 722 additions and 284 deletions
+33 -56
View File
@@ -5,7 +5,16 @@
*/
import { CLIProxyProvider } from '../types';
import { AccountInfo } from '../account-manager';
import type { AccountInfo } from '../account-manager';
import {
buildProviderMap,
CLIPROXY_PROVIDER_IDS,
getOAuthCallbackPort,
getCLIProxyCallbackProviderName,
getCLIProxyAuthUrlProviderName,
getProviderAuthFilePrefixes,
getProviderTokenTypeValues,
} from '../provider-capabilities';
/**
* Kiro authentication methods supported by CLIProxyAPIPlus.
@@ -90,17 +99,17 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
* - GHCP: Device Code Flow (polling-based, NO callback port needed)
* - Kimi: Device Code Flow (polling-based, NO callback port needed)
*/
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
gemini: 8085,
codex: 1455,
agy: 51121,
iflow: 11451,
claude: 54545,
// kiro: Device Code Flow - no callback port
// qwen: Device Code Flow - no callback port
// ghcp: Device Code Flow - no callback port
// kimi: Device Code Flow - no callback port
};
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> =
CLIPROXY_PROVIDER_IDS.reduce(
(acc, provider) => {
const callbackPort = getOAuthCallbackPort(provider);
if (callbackPort !== null) {
acc[provider] = callbackPort;
}
return acc;
},
{} as Partial<Record<CLIProxyProvider, number>>
);
/**
* Auth status for a provider
@@ -215,66 +224,34 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
* CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
* Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
*/
export const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini-', 'google-'],
codex: ['codex-', 'openai-'],
agy: ['antigravity-', 'agy-'],
qwen: ['qwen-'],
iflow: ['iflow-'],
kiro: ['kiro-', 'aws-', 'codewhisperer-'],
ghcp: ['github-copilot-', 'copilot-', 'gh-'],
claude: ['claude-', 'anthropic-'],
kimi: ['kimi-'],
};
export const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = buildProviderMap(
(provider) => [...getProviderAuthFilePrefixes(provider)]
);
/**
* Provider type values inside token JSON files
* CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
*/
export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini'],
codex: ['codex'],
agy: ['antigravity'],
qwen: ['qwen'],
iflow: ['iflow'],
kiro: ['kiro', 'codewhisperer'],
ghcp: ['github-copilot', 'copilot'],
claude: ['claude', 'anthropic'],
kimi: ['kimi'],
};
export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = buildProviderMap(
(provider) => [...getProviderTokenTypeValues(provider)]
);
/**
* Maps CCS provider names to CLIProxyAPI callback provider names
* Used when submitting OAuth callbacks to CLIProxyAPI management endpoint
*/
export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record<CLIProxyProvider, string> = {
gemini: 'gemini',
codex: 'codex',
agy: 'antigravity',
kiro: 'kiro',
ghcp: 'copilot',
claude: 'anthropic',
qwen: 'qwen',
iflow: 'iflow',
kimi: 'kimi',
};
export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record<CLIProxyProvider, string> = buildProviderMap(
(provider) => getCLIProxyCallbackProviderName(provider)
);
/**
* Maps CCS provider names to CLIProxyAPI auth-url endpoint prefixes.
* Used for GET /v0/management/${prefix}-auth-url endpoints.
* These differ from callback names for some providers (e.g., gemini-cli vs gemini).
*/
export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record<CLIProxyProvider, string> = {
gemini: 'gemini-cli',
codex: 'codex',
agy: 'antigravity',
kiro: 'kiro',
ghcp: 'github',
claude: 'anthropic',
qwen: 'qwen',
iflow: 'iflow',
kimi: 'kimi',
};
export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record<CLIProxyProvider, string> = buildProviderMap(
(provider) => getCLIProxyAuthUrlProviderName(provider)
);
/**
* Get OAuth config for provider
+55 -33
View File
@@ -108,42 +108,53 @@ function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken {
* Read Gemini token from CLIProxy auth directory
* Returns credentials with source path, or null if no valid token found
*/
function readCliproxyGeminiCreds(): GeminiCredsWithSource | null {
function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
const authDir = getProviderAuthDir('gemini');
if (!fs.existsSync(authDir)) return null;
// Try to find default account's token file
const defaultAccount = getDefaultAccount('gemini');
let tokenPath: string | null = null;
const normalizedAccountId = accountId?.trim();
const accounts = getProviderAccounts('gemini');
if (defaultAccount) {
tokenPath = path.join(authDir, defaultAccount.tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
// Account-specific refresh path (used by background worker)
if (normalizedAccountId) {
const targetAccount = accounts.find((account) => account.id === normalizedAccountId);
if (!targetAccount) {
return null;
}
tokenPath = path.join(authDir, targetAccount.tokenFile);
}
// Fallback: find any gemini token file by prefix or type
if (!tokenPath) {
const accounts = getProviderAccounts('gemini');
if (accounts.length > 0) {
if (!normalizedAccountId) {
// Try to find default account's token file
const defaultAccount = getDefaultAccount('gemini');
if (defaultAccount) {
tokenPath = path.join(authDir, defaultAccount.tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
// Fallback: find any gemini account token file
if (!tokenPath && accounts.length > 0) {
tokenPath = path.join(authDir, accounts[0].tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
}
// Last fallback: scan directory for gemini token files
if (!tokenPath) {
try {
const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(authDir, file);
if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) {
tokenPath = filePath;
break;
// Last fallback: scan directory for gemini token files
if (!tokenPath) {
try {
const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(authDir, file);
if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) {
tokenPath = filePath;
break;
}
}
} catch {
// Directory read failed - continue to return null
return null;
}
} catch {
// Directory read failed - continue to return null
return null;
}
}
@@ -172,13 +183,19 @@ function readCliproxyGeminiCreds(): GeminiCredsWithSource | null {
* Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json
* Returns credentials with source path for correct write-back
*/
function readGeminiCreds(): GeminiCredsWithSource | null {
function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
// 1. Try CLIProxy auth directory first (CCS-managed tokens)
const cliproxyResult = readCliproxyGeminiCreds();
const cliproxyResult = readCliproxyGeminiCreds(accountId);
if (cliproxyResult) {
return cliproxyResult;
}
// Account-scoped refresh is only supported for CLIProxy account files.
// Do not fall back to ~/.gemini for a specific accountId.
if (accountId?.trim()) {
return null;
}
// 2. Fall back to standard Gemini CLI location
const oauthPath = getGeminiOAuthPath();
if (!fs.existsSync(oauthPath)) {
@@ -249,8 +266,8 @@ function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string |
/**
* Check if Gemini token is expired or expiring soon
*/
export function isGeminiTokenExpiringSoon(): boolean {
const result = readGeminiCreds();
export function isGeminiTokenExpiringSoon(accountId?: string): boolean {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return true; // No token = needs auth
}
@@ -263,14 +280,15 @@ export function isGeminiTokenExpiringSoon(): boolean {
/**
* Refresh Gemini access token using refresh_token
* @param accountId Optional account ID for account-scoped refresh
* @returns Result with success status, optional error, and expiry time
*/
export async function refreshGeminiToken(): Promise<{
export async function refreshGeminiToken(accountId?: string): Promise<{
success: boolean;
error?: string;
expiresAt?: number;
}> {
const result = readGeminiCreds();
const result = readGeminiCreds(accountId);
if (!result || !result.creds.refresh_token) {
return { success: false, error: 'No refresh token available' };
}
@@ -334,19 +352,23 @@ export async function refreshGeminiToken(): Promise<{
/**
* Ensure Gemini token is valid, refreshing if needed
* @param verbose Log progress if true
* @param accountId Optional account ID for account-scoped refresh
* @returns true if token is valid (or was refreshed), false if refresh failed
*/
export async function ensureGeminiTokenValid(verbose = false): Promise<{
export async function ensureGeminiTokenValid(
verbose = false,
accountId?: string
): Promise<{
valid: boolean;
refreshed: boolean;
error?: string;
}> {
const result = readGeminiCreds();
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return { valid: false, refreshed: false, error: 'No Gemini credentials found' };
}
if (!isGeminiTokenExpiringSoon()) {
if (!isGeminiTokenExpiringSoon(accountId)) {
return { valid: true, refreshed: false };
}
@@ -355,7 +377,7 @@ export async function ensureGeminiTokenValid(verbose = false): Promise<{
console.log('[i] Gemini token expired or expiring soon, refreshing...');
}
const refreshResult = await refreshGeminiToken();
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
if (verbose) {
console.log('[OK] Gemini token refreshed successfully');
+41 -36
View File
@@ -11,6 +11,11 @@
*/
import { CLIProxyProvider } from '../../types';
import { getProviderAccounts } from '../../account-manager';
import {
getTokenRefreshOwnership,
isRefreshDelegatedToCLIProxy,
} from '../../provider-capabilities';
import { refreshGeminiToken } from '../gemini-token-refresh';
/** Token refresh result */
@@ -22,64 +27,64 @@ export interface ProviderRefreshResult {
delegated?: boolean;
}
/**
* Providers where CLIProxyAPIPlus owns token refresh.
* CLIProxyAPIPlus runs background refresh automatically (e.g. kiro: every 1 min).
* CCS should not attempt to refresh these — just trust CLIProxy.
*/
const CLIPROXY_DELEGATED_REFRESH: CLIProxyProvider[] = [
'codex',
'agy',
'kiro',
'ghcp',
'qwen',
'iflow',
'kimi',
];
function assertNever(value: never): never {
throw new Error(`Unhandled token refresh ownership: ${String(value)}`);
}
/**
* Check if a provider's token refresh is delegated to CLIProxy
*/
export function isRefreshDelegated(provider: CLIProxyProvider): boolean {
return CLIPROXY_DELEGATED_REFRESH.includes(provider);
return isRefreshDelegatedToCLIProxy(provider);
}
/**
* Refresh token for a specific provider and account
* @param provider Provider to refresh
* @param _accountId Account ID (currently unused, multi-account not yet implemented)
* @param accountId Account ID used to refresh the correct provider token
* @returns Refresh result with success status and optional error
*/
export async function refreshToken(
provider: CLIProxyProvider,
_accountId: string
accountId: string
): Promise<ProviderRefreshResult> {
switch (provider) {
case 'gemini':
return await refreshGeminiTokenWrapper();
const normalizedAccountId = accountId.trim();
if (!normalizedAccountId) {
return {
success: false,
error: 'Account ID is required for token refresh',
};
}
case 'codex':
case 'agy':
case 'qwen':
case 'iflow':
case 'kiro':
case 'ghcp':
case 'kimi':
const hasAccount = getProviderAccounts(provider).some(
(account) => account.id === normalizedAccountId
);
if (!hasAccount) {
return {
success: false,
error: `Account not found for ${provider}: ${normalizedAccountId}`,
};
}
if (provider === 'gemini') {
return await refreshGeminiTokenWrapper(normalizedAccountId);
}
const ownership = getTokenRefreshOwnership(provider);
switch (ownership) {
case 'cliproxy':
// CLIProxyAPIPlus handles refresh for these providers automatically.
// No action needed from CCS — report success with delegated flag.
return { success: true, delegated: true };
case 'claude':
case 'unsupported':
case 'ccs':
// Non-gemini CCS-owned refresh paths are not implemented yet.
return {
success: false,
error: `Token refresh not yet implemented for ${provider}`,
};
default:
return {
success: false,
error: `Unknown provider: ${provider}`,
};
return assertNever(ownership);
}
}
@@ -87,8 +92,8 @@ export async function refreshToken(
* Wrapper for Gemini token refresh
* Converts gemini-token-refresh.ts format to provider-refreshers format
*/
async function refreshGeminiTokenWrapper(): Promise<ProviderRefreshResult> {
const result = await refreshGeminiToken();
async function refreshGeminiTokenWrapper(accountId: string): Promise<ProviderRefreshResult> {
const result = await refreshGeminiToken(accountId);
if (!result.success) {
return {
+22 -5
View File
@@ -16,24 +16,41 @@ import type {
RemoteModelInfo,
GetModelDefinitionsResponse,
} from './management-api-types';
import { CLIPROXY_DEFAULT_PORT } from './config/port-manager';
/** Default timeout for management operations (longer than health check) */
const DEFAULT_TIMEOUT_MS = 5000;
/** Default port for HTTP protocol */
const DEFAULT_HTTP_PORT = 8317;
/** Default port for HTTPS protocol */
const DEFAULT_HTTPS_PORT = 443;
/** Avoid duplicate warnings for repeated invalid port inputs */
const WARNED_INVALID_PORTS = new Set<string>();
function isValidPort(port: number | undefined): port is number {
return port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535;
}
/**
* Get effective port based on config and protocol.
*/
function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number {
if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) {
if (isValidPort(port)) {
return port;
}
return protocol === 'https' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
const fallbackPort = protocol === 'https' ? DEFAULT_HTTPS_PORT : CLIPROXY_DEFAULT_PORT;
if (port !== undefined) {
const warningKey = `${protocol}:${String(port)}`;
if (!WARNED_INVALID_PORTS.has(warningKey)) {
WARNED_INVALID_PORTS.add(warningKey);
console.warn(
`[management-api-client] Invalid port "${String(port)}", using default ${fallbackPort}`
);
}
}
return fallbackPort;
}
/**
+141 -12
View File
@@ -1,11 +1,23 @@
import type { CLIProxyProvider } from './types';
export type OAuthFlowType = 'authorization_code' | 'device_code';
export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported';
export interface ProviderCapabilities {
displayName: string;
description: string;
oauthFlow: OAuthFlowType;
callbackPort: number | null;
/** Provider name expected by CLIProxyAPI callback endpoint payload. */
callbackProviderName: string;
/** Provider name prefix used by CLIProxyAPI auth URL endpoint. */
authUrlProviderName: string;
/** Who owns token refresh logic for this provider. */
refreshOwnership: TokenRefreshOwnership;
/** Filename prefixes used to identify auth tokens for this provider. */
authFilePrefixes: readonly string[];
/** Token JSON "type" values accepted for this provider. */
tokenTypeValues: readonly string[];
/**
* Alternative provider names used by CLIProxyAPI or stats endpoints.
* These aliases normalize external names to canonical CCS provider IDs.
@@ -16,56 +28,110 @@ export interface ProviderCapabilities {
export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilities> = {
gemini: {
displayName: 'Google Gemini',
description: 'Gemini Pro/Flash models',
oauthFlow: 'authorization_code',
callbackPort: 8085,
callbackProviderName: 'gemini',
authUrlProviderName: 'gemini-cli',
refreshOwnership: 'ccs',
authFilePrefixes: ['gemini-', 'google-'],
tokenTypeValues: ['gemini'],
aliases: ['gemini-cli'],
},
codex: {
displayName: 'Codex',
displayName: 'OpenAI Codex',
description: 'GPT-4 and codex models',
oauthFlow: 'authorization_code',
callbackPort: 1455,
callbackProviderName: 'codex',
authUrlProviderName: 'codex',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['codex-', 'openai-'],
tokenTypeValues: ['codex'],
aliases: [],
},
agy: {
displayName: 'AntiGravity',
displayName: 'Antigravity',
description: 'Antigravity AI models',
oauthFlow: 'authorization_code',
callbackPort: 51121,
callbackProviderName: 'antigravity',
authUrlProviderName: 'antigravity',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['antigravity-', 'agy-'],
tokenTypeValues: ['antigravity'],
aliases: ['antigravity'],
},
qwen: {
displayName: 'Qwen',
displayName: 'Alibaba Qwen',
description: 'Qwen Code models',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'qwen',
authUrlProviderName: 'qwen',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['qwen-'],
tokenTypeValues: ['qwen'],
aliases: [],
},
iflow: {
displayName: 'iFlow',
description: 'iFlow AI models',
oauthFlow: 'authorization_code',
callbackPort: 11451,
callbackProviderName: 'iflow',
authUrlProviderName: 'iflow',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['iflow-'],
tokenTypeValues: ['iflow'],
aliases: [],
},
kiro: {
displayName: 'Kiro (AWS)',
description: 'AWS CodeWhisperer models',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kiro',
authUrlProviderName: 'kiro',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'],
tokenTypeValues: ['kiro', 'codewhisperer'],
aliases: ['codewhisperer'],
},
ghcp: {
displayName: 'GitHub Copilot (OAuth)',
description: 'GitHub Copilot via OAuth',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'copilot',
authUrlProviderName: 'github',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'],
tokenTypeValues: ['github-copilot', 'copilot'],
aliases: ['github-copilot', 'copilot'],
},
claude: {
displayName: 'Claude',
displayName: 'Claude (Anthropic)',
description: 'Claude Opus/Sonnet models',
oauthFlow: 'authorization_code',
callbackPort: 54545,
callbackProviderName: 'anthropic',
authUrlProviderName: 'anthropic',
refreshOwnership: 'unsupported',
authFilePrefixes: ['claude-', 'anthropic-'],
tokenTypeValues: ['claude', 'anthropic'],
aliases: ['anthropic'],
},
kimi: {
displayName: 'Kimi (Moonshot)',
description: 'Moonshot AI K2/K2.5 models',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kimi',
authUrlProviderName: 'kimi',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['kimi-'],
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
},
};
@@ -74,18 +140,53 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze(
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
);
export function buildProviderMap<T>(
valueFor: (provider: CLIProxyProvider) => T
): Record<CLIProxyProvider, T> {
return CLIPROXY_PROVIDER_IDS.reduce(
(acc, provider) => {
acc[provider] = valueFor(provider);
return acc;
},
{} as Record<CLIProxyProvider, T>
);
}
const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS);
const PROVIDER_ALIAS_MAP: ReadonlyMap<string, CLIProxyProvider> = (() => {
const entries: Array<[string, CLIProxyProvider]> = [];
for (const provider of CLIPROXY_PROVIDER_IDS) {
entries.push([provider, provider]);
for (const alias of PROVIDER_CAPABILITIES[provider].aliases) {
entries.push([alias.toLowerCase(), provider]);
export function buildProviderAliasMap(
capabilities: Record<CLIProxyProvider, ProviderCapabilities> = PROVIDER_CAPABILITIES
): ReadonlyMap<string, CLIProxyProvider> {
const aliasMap = new Map<string, CLIProxyProvider>();
const providers = Object.keys(capabilities) as CLIProxyProvider[];
const registerAlias = (alias: string, provider: CLIProxyProvider): void => {
const normalized = alias.trim().toLowerCase();
if (!normalized) {
return;
}
const existingProvider = aliasMap.get(normalized);
if (existingProvider && existingProvider !== provider) {
throw new Error(
`Provider alias collision for "${normalized}": ${existingProvider} and ${provider}`
);
}
aliasMap.set(normalized, provider);
};
for (const provider of providers) {
registerAlias(provider, provider);
for (const alias of capabilities[provider].aliases) {
registerAlias(alias, provider);
}
}
return new Map(entries);
})();
return aliasMap;
}
const PROVIDER_ALIAS_MAP: ReadonlyMap<string, CLIProxyProvider> = buildProviderAliasMap();
export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider {
return PROVIDER_ID_SET.has(provider as CLIProxyProvider);
@@ -99,6 +200,10 @@ export function getProviderDisplayName(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].displayName;
}
export function getProviderDescription(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].description;
}
export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvider[] {
return CLIPROXY_PROVIDER_IDS.filter(
(provider) => PROVIDER_CAPABILITIES[provider].oauthFlow === flowType
@@ -113,6 +218,30 @@ export function getOAuthCallbackPort(provider: CLIProxyProvider): number | null
return PROVIDER_CAPABILITIES[provider].callbackPort;
}
export function getCLIProxyCallbackProviderName(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].callbackProviderName;
}
export function getCLIProxyAuthUrlProviderName(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].authUrlProviderName;
}
export function getTokenRefreshOwnership(provider: CLIProxyProvider): TokenRefreshOwnership {
return PROVIDER_CAPABILITIES[provider].refreshOwnership;
}
export function isRefreshDelegatedToCLIProxy(provider: CLIProxyProvider): boolean {
return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy';
}
export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] {
return PROVIDER_CAPABILITIES[provider].authFilePrefixes;
}
export function getProviderTokenTypeValues(provider: CLIProxyProvider): readonly string[] {
return PROVIDER_CAPABILITIES[provider].tokenTypeValues;
}
export function mapExternalProviderName(providerName: string): CLIProxyProvider | null {
const normalized = providerName.toLowerCase();
return PROVIDER_ALIAS_MAP.get(normalized) ?? null;
+3 -2
View File
@@ -3,6 +3,7 @@ import * as path from 'path';
import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
import { isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir, getCcsDirSource } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
// Get version from package.json (same as version-command.ts)
const VERSION = JSON.parse(
@@ -345,7 +346,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
// CLI Proxy configuration flags (new)
printSubSection('CLI Proxy Configuration', [
['--proxy-host <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', 'Proxy port (default: 8317)'],
['--proxy-port <port>', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
['--proxy-timeout <ms>', 'Connection timeout in ms (default: 2000)'],
@@ -421,7 +422,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
console.log(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`);
console.log(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`);
console.log(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`);
console.log(` ${dim('Port: 8317 (default)')}`);
console.log(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`);
console.log('');
// Shared Data
+4 -3
View File
@@ -24,6 +24,7 @@ import {
} from '../config/unified-config-loader';
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
/** Custom error for user cancellation (Ctrl+C) */
class UserCancelledError extends Error {
@@ -226,7 +227,7 @@ async function configureRemoteProxy(rl: readline.Interface): Promise<{
])) as 'http' | 'https';
// Port (optional) - with validation
const defaultPort = protocol === 'https' ? '443' : '80';
const defaultPort = protocol === 'https' ? '443' : String(CLIPROXY_DEFAULT_PORT);
const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`);
let port: number | undefined;
if (portStr) {
@@ -318,7 +319,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
auto_start: false,
},
local: {
port: 8317,
port: CLIPROXY_DEFAULT_PORT,
auto_start: false, // Disable local auto-start when using remote
},
};
@@ -341,7 +342,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
auth_token: '',
},
local: {
port: 8317,
port: CLIPROXY_DEFAULT_PORT,
auto_start: true,
},
};
+1 -1
View File
@@ -328,7 +328,7 @@ export interface ProxyRemoteConfig {
* Remote proxy port.
* Optional - defaults based on protocol:
* - HTTPS: 443
* - HTTP: 80
* - HTTP: 8317
* When empty/undefined, uses protocol default.
*/
port?: number;
@@ -43,6 +43,7 @@ import {
DEFAULT_BACKEND,
} from '../../cliproxy/platform-detector';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
const router = Router();
@@ -208,7 +209,7 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise<void>
// Proxy running but no session lock - legacy/untracked instance
res.json({
running: true,
port: 8317, // Default port
port: CLIPROXY_DEFAULT_PORT,
sessionCount: 0, // Unknown sessions
// No pid/startedAt since we don't have session lock
});
@@ -0,0 +1,58 @@
/**
* Default Port Sync Test
*
* Keeps backend and UI default ports in sync while allowing independent modules.
*/
import { describe, expect, test } from 'bun:test';
import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../src/cursor/cursor-models';
import {
CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS,
getProviderDescription as getBackendProviderDescription,
getProviderDisplayName as getBackendProviderDisplayName,
getProvidersByOAuthFlow,
} from '../../../src/cliproxy/provider-capabilities';
import {
CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT,
DEFAULT_CURSOR_PORT as UI_CURSOR_DEFAULT_PORT,
} from '../../../ui/src/lib/default-ports';
import {
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
PROVIDER_METADATA as UI_PROVIDER_METADATA,
} from '../../../ui/src/lib/provider-config';
function sorted(values: readonly string[]): string[] {
return [...values].sort((a, b) => a.localeCompare(b));
}
describe('Default Port Sync', () => {
test('CLIProxy default port is synced between backend and UI', () => {
expect(UI_CLIPROXY_DEFAULT_PORT).toBe(BACKEND_CLIPROXY_DEFAULT_PORT);
});
test('Cursor default port is synced between backend and UI', () => {
expect(UI_CURSOR_DEFAULT_PORT).toBe(BACKEND_CURSOR_DEFAULT_PORT);
});
test('CLIProxy provider IDs are synced between backend and UI', () => {
expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS));
});
test('Device code providers are synced between backend and UI', () => {
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
});
test('Provider display names are synced between backend and UI', () => {
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
}
});
test('Provider descriptions are synced between backend and UI', () => {
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
expect(UI_PROVIDER_METADATA[provider].description).toBe(getBackendProviderDescription(provider));
}
});
});
@@ -1,7 +1,7 @@
/**
* Unit tests for management-api-client module
*/
import { describe, it, expect, beforeEach, mock } from 'bun:test';
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
import { ManagementApiClient } from '../../../src/cliproxy/management-api-client';
import type {
ManagementClientConfig,
@@ -76,6 +76,18 @@ describe('management-api-client', () => {
const client = new ManagementApiClient(configNoPort);
expect(client.getBaseUrl()).toBe('https://localhost');
});
it('should warn and fall back when configured port is invalid', () => {
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
const client = new ManagementApiClient({ ...config, port: 99999 });
expect(client.getBaseUrl()).toBe('http://localhost:8317');
expect(warnSpy).toHaveBeenCalledWith(
'[management-api-client] Invalid port "99999", using default 8317'
);
warnSpy.mockRestore();
});
});
describe('error code mapping', () => {
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'bun:test';
import {
buildProviderAliasMap,
CLIPROXY_PROVIDER_IDS,
getOAuthCallbackPort,
getOAuthFlowType,
PROVIDER_CAPABILITIES,
getProviderDisplayName,
getProvidersByOAuthFlow,
isCLIProxyProvider,
@@ -68,7 +70,25 @@ describe('provider-capabilities', () => {
expect(getOAuthCallbackPort('qwen')).toBeNull();
expect(getOAuthCallbackPort('kiro')).toBeNull();
expect(getOAuthCallbackPort('gemini')).toBe(8085);
expect(getProviderDisplayName('agy')).toBe('AntiGravity');
expect(getProviderDisplayName('agy')).toBe('Antigravity');
});
it('throws when provider aliases collide across providers', () => {
const capabilitiesWithCollision = {
...PROVIDER_CAPABILITIES,
gemini: {
...PROVIDER_CAPABILITIES.gemini,
aliases: ['shared-alias'],
},
codex: {
...PROVIDER_CAPABILITIES.codex,
aliases: ['shared-alias'],
},
};
expect(() =>
buildProviderAliasMap(capabilitiesWithCollision as typeof PROVIDER_CAPABILITIES)
).toThrow(/shared-alias/i);
});
it('keeps diagnostics flow metadata in sync with provider capabilities', () => {
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'bun:test';
import {
API_BASE_URL,
API_CONFLICT_ERROR_CODE,
ApiConflictError,
isApiConflictError,
withApiBase,
} from '../../ui/src/lib/api-client';
describe('ui api-client helpers', () => {
it('normalizes relative paths with API base prefix', () => {
expect(withApiBase('/cliproxy/status')).toBe('/api/cliproxy/status');
expect(withApiBase('cliproxy/status')).toBe('/api/cliproxy/status');
});
it('preserves paths that already include API base', () => {
expect(withApiBase('/api/cliproxy/status')).toBe('/api/cliproxy/status');
expect(withApiBase('/api')).toBe('/api');
});
it('handles empty and absolute URLs safely', () => {
expect(withApiBase('')).toBe(API_BASE_URL);
expect(withApiBase('https://example.com/api')).toBe('https://example.com/api');
});
it('identifies typed API conflict errors', () => {
const conflict = new ApiConflictError('conflict');
expect(conflict.code).toBe(API_CONFLICT_ERROR_CODE);
expect(isApiConflictError(conflict)).toBe(true);
expect(isApiConflictError(new Error('plain'))).toBe(false);
});
});
@@ -9,11 +9,9 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { api, withApiBase } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client';
/** CLIProxyAPI default port */
const CLIPROXY_DEFAULT_PORT = 8317;
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
interface AuthTokensResponse {
apiKey: { value: string; isCustom: boolean };
@@ -26,7 +24,8 @@ interface ControlPanelEmbedProps {
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
const [iframeRevision, setIframeRevision] = useState(0);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [showLoginHint, setShowLoginHint] = useState(true);
@@ -42,7 +41,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
const { data: authTokens } = useQuery<AuthTokensResponse>({
queryKey: ['auth-tokens-raw'],
queryFn: async () => {
const response = await fetch('/api/settings/auth/tokens/raw');
const response = await fetch(withApiBase('/settings/auth/tokens/raw'));
if (!response.ok) throw new Error('Failed to fetch auth tokens');
return response.json();
},
@@ -62,8 +61,8 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
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);
// Use port from config, or default based on protocol (443 for https, 8317 for http)
const remotePort = remote.port || (protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT);
// Only include port in URL if it's non-standard
const portSuffix =
(protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80)
@@ -91,6 +90,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
};
}, [cliproxyConfig, authTokens, port]);
const iframeLoaded = loadedUrl === managementUrl;
const isLoading = !iframeLoaded;
// Check if CLIProxy is running
useEffect(() => {
const controller = new AbortController();
@@ -132,48 +134,53 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
return () => controller.abort();
}, [checkUrl, isRemote, displayHost]);
// Handle iframe load - attempt to auto-login via postMessage
const handleIframeLoad = useCallback(() => {
setIsLoading(false);
// 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 && 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,
managementKey: authToken,
},
apiBase
);
} catch (e) {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
}
const postAutoLoginCredentials = useCallback(() => {
// Auto-login can only run when iframe has loaded and authToken is available.
if (!iframeLoaded || !iframeRef.current?.contentWindow || !authToken) {
return;
}
}, [checkUrl, 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,
managementKey: authToken,
},
apiBase
);
} catch (e) {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
}
}, [authToken, checkUrl, iframeLoaded]);
// Retry auto-login when token/checkUrl arrive after iframe onLoad.
useEffect(() => {
postAutoLoginCredentials();
}, [postAutoLoginCredentials]);
// Handle iframe load - mark ready then let effect post credentials.
const handleIframeLoad = useCallback(() => {
setLoadedUrl(managementUrl);
}, [managementUrl]);
const handleRefresh = () => {
setIsLoading(true);
setLoadedUrl(null);
setIframeRevision((value) => value + 1);
setError(null);
setIsConnected(false);
if (iframeRef.current) {
iframeRef.current.src = managementUrl;
}
};
// Show error state if CLIProxy is not running
@@ -266,6 +273,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
{/* Iframe */}
<iframe
key={`${managementUrl}:${iframeRevision}`}
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
@@ -16,7 +16,7 @@ import {
useCreatePreset,
useDeletePreset,
} from '@/hooks/use-cliproxy';
import { CLIPROXY_PORT } from '@/lib/preset-utils';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { usePrivacy } from '@/contexts/privacy-context';
import { useProviderEditor } from './use-provider-editor';
import { CustomPresetDialog } from './custom-preset-dialog';
@@ -117,7 +117,7 @@ export function ProviderEditor({
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
const handleApplyPreset = (updates: Record<string, string>) => {
const effectivePort = port ?? CLIPROXY_PORT;
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
@@ -127,7 +127,7 @@ export function ProviderEditor({
};
const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => {
const effectivePort = port ?? CLIPROXY_PORT;
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
@@ -5,6 +5,7 @@
import { useState, useMemo, useCallback } from 'react';
import { useCopilot } from '@/hooks/use-copilot';
import { isApiConflictError } from '@/lib/api-client';
import { toast } from 'sonner';
import type { ModelPreset } from './types';
@@ -171,7 +172,7 @@ export function useCopilotConfigForm() {
setLocalOverrides({});
setRawJsonEdits(null);
} catch (error) {
if ((error as Error).message === 'CONFLICT') {
if (isApiConflictError(error)) {
setConflictDialog(true);
} else {
toast.error('Failed to save settings');
+7 -16
View File
@@ -5,20 +5,11 @@
*/
import type { ProviderOption } from './types';
import type { CLIProxyProvider } from '@/lib/provider-config';
/** Provider display info for wizard - ordered by recommendation */
const PROVIDER_INFO: Record<CLIProxyProvider, { name: string; description: string }> = {
agy: { name: 'Antigravity', description: 'Antigravity AI models' },
claude: { name: 'Claude (Anthropic)', description: 'Claude Opus/Sonnet models' },
gemini: { name: 'Google Gemini', description: 'Gemini Pro/Flash models' },
codex: { name: 'OpenAI Codex', description: 'GPT-4 and codex models' },
qwen: { name: 'Alibaba Qwen', description: 'Qwen Code models' },
iflow: { name: 'iFlow', description: 'iFlow AI models' },
kiro: { name: 'Kiro (AWS)', description: 'AWS CodeWhisperer models' },
ghcp: { name: 'GitHub Copilot (OAuth)', description: 'GitHub Copilot via OAuth' },
kimi: { name: 'Kimi (Moonshot)', description: 'Moonshot AI K2/K2.5 models' },
};
import {
type CLIProxyProvider,
getProviderDescription,
getProviderDisplayName,
} from '@/lib/provider-config';
/** Wizard display order - most recommended first */
const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
@@ -35,8 +26,8 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({
id,
name: PROVIDER_INFO[id].name,
description: PROVIDER_INFO[id].description,
name: getProviderDisplayName(id),
description: getProviderDescription(id),
}));
export const ALL_STEPS = ['provider', 'auth', 'variant', 'success'];
+13 -14
View File
@@ -6,8 +6,7 @@
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
import { ApiConflictError, withApiBase } from '@/lib/api-client';
// Types
export interface CopilotStatus {
@@ -80,31 +79,31 @@ export interface CopilotRawSettings {
// API functions
async function fetchCopilotStatus(): Promise<CopilotStatus> {
const res = await fetch(`${API_BASE}/copilot/status`);
const res = await fetch(withApiBase('/copilot/status'));
if (!res.ok) throw new Error('Failed to fetch copilot status');
return res.json();
}
async function fetchCopilotConfig(): Promise<CopilotConfig> {
const res = await fetch(`${API_BASE}/copilot/config`);
const res = await fetch(withApiBase('/copilot/config'));
if (!res.ok) throw new Error('Failed to fetch copilot config');
return res.json();
}
async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> {
const res = await fetch(`${API_BASE}/copilot/models`);
const res = await fetch(withApiBase('/copilot/models'));
if (!res.ok) throw new Error('Failed to fetch copilot models');
return res.json();
}
async function fetchCopilotRawSettings(): Promise<CopilotRawSettings> {
const res = await fetch(`${API_BASE}/copilot/settings/raw`);
const res = await fetch(withApiBase('/copilot/settings/raw'));
if (!res.ok) throw new Error('Failed to fetch copilot raw settings');
return res.json();
}
async function updateCopilotConfig(config: Partial<CopilotConfig>): Promise<{ success: boolean }> {
const res = await fetch(`${API_BASE}/copilot/config`, {
const res = await fetch(withApiBase('/copilot/config'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@@ -117,12 +116,12 @@ async function saveCopilotRawSettings(data: {
settings: CopilotRawSettings['settings'];
expectedMtime?: number;
}): Promise<{ success: boolean; mtime: number }> {
const res = await fetch(`${API_BASE}/copilot/settings/raw`, {
const res = await fetch(withApiBase('/copilot/settings/raw'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.status === 409) throw new Error('CONFLICT');
if (res.status === 409) throw new ApiConflictError('Copilot raw settings changed externally');
if (!res.ok) throw new Error('Failed to save copilot raw settings');
return res.json();
}
@@ -135,31 +134,31 @@ export interface CopilotAuthResult {
}
async function startCopilotAuth(): Promise<CopilotAuthResult> {
const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' });
const res = await fetch(withApiBase('/copilot/auth/start'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to start auth');
return res.json();
}
async function startCopilotDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> {
const res = await fetch(`${API_BASE}/copilot/daemon/start`, { method: 'POST' });
const res = await fetch(withApiBase('/copilot/daemon/start'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to start daemon');
return res.json();
}
async function stopCopilotDaemon(): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_BASE}/copilot/daemon/stop`, { method: 'POST' });
const res = await fetch(withApiBase('/copilot/daemon/stop'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to stop daemon');
return res.json();
}
async function fetchCopilotInfo(): Promise<CopilotInfo> {
const res = await fetch(`${API_BASE}/copilot/info`);
const res = await fetch(withApiBase('/copilot/info'));
if (!res.ok) throw new Error('Failed to fetch copilot info');
return res.json();
}
async function installCopilotApi(version?: string): Promise<CopilotInstallResult> {
const res = await fetch(`${API_BASE}/copilot/install`, {
const res = await fetch(withApiBase('/copilot/install'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(version ? { version } : {}),
+12 -13
View File
@@ -6,8 +6,7 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
import { ApiConflictError, withApiBase } from '@/lib/api-client';
export interface CursorStatus {
enabled: boolean;
@@ -59,25 +58,25 @@ interface CursorAuthResult {
}
async function fetchCursorStatus(): Promise<CursorStatus> {
const res = await fetch(`${API_BASE}/cursor/status`);
const res = await fetch(withApiBase('/cursor/status'));
if (!res.ok) throw new Error('Failed to fetch cursor status');
return res.json();
}
async function fetchCursorConfig(): Promise<CursorConfig> {
const res = await fetch(`${API_BASE}/cursor/settings`);
const res = await fetch(withApiBase('/cursor/settings'));
if (!res.ok) throw new Error('Failed to fetch cursor config');
return res.json();
}
async function fetchCursorModels(): Promise<CursorModelsResponse> {
const res = await fetch(`${API_BASE}/cursor/models`);
const res = await fetch(withApiBase('/cursor/models'));
if (!res.ok) throw new Error('Failed to fetch cursor models');
return res.json();
}
async function fetchCursorRawSettings(): Promise<CursorRawSettings> {
const res = await fetch(`${API_BASE}/cursor/settings/raw`);
const res = await fetch(withApiBase('/cursor/settings/raw'));
if (!res.ok) throw new Error('Failed to fetch cursor raw settings');
return res.json();
}
@@ -85,7 +84,7 @@ async function fetchCursorRawSettings(): Promise<CursorRawSettings> {
async function updateCursorConfig(
updates: Partial<CursorConfig>
): Promise<{ success: boolean; cursor: CursorConfig }> {
const res = await fetch(`${API_BASE}/cursor/settings`, {
const res = await fetch(withApiBase('/cursor/settings'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -98,18 +97,18 @@ async function saveCursorRawSettings(data: {
settings: CursorRawSettings['settings'];
expectedMtime?: number;
}): Promise<{ success: boolean; mtime: number }> {
const res = await fetch(`${API_BASE}/cursor/settings/raw`, {
const res = await fetch(withApiBase('/cursor/settings/raw'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.status === 409) throw new Error('CONFLICT');
if (res.status === 409) throw new ApiConflictError('Cursor raw settings changed externally');
if (!res.ok) throw new Error('Failed to save cursor raw settings');
return res.json();
}
async function autoDetectCursorAuth(): Promise<CursorAuthResult> {
const res = await fetch(`${API_BASE}/cursor/auth/auto-detect`, { method: 'POST' });
const res = await fetch(withApiBase('/cursor/auth/auto-detect'), { method: 'POST' });
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Auto-detect failed' }));
throw new Error(error.error || 'Auto-detect failed');
@@ -121,7 +120,7 @@ async function importCursorAuthManual(data: {
accessToken: string;
machineId: string;
}): Promise<CursorAuthResult> {
const res = await fetch(`${API_BASE}/cursor/auth/import`, {
const res = await fetch(withApiBase('/cursor/auth/import'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -134,13 +133,13 @@ async function importCursorAuthManual(data: {
}
async function startCursorDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> {
const res = await fetch(`${API_BASE}/cursor/daemon/start`, { method: 'POST' });
const res = await fetch(withApiBase('/cursor/daemon/start'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to start cursor daemon');
return res.json();
}
async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_BASE}/cursor/daemon/stop`, { method: 'POST' });
const res = await fetch(withApiBase('/cursor/daemon/stop'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to stop cursor daemon');
return res.json();
}
+81 -9
View File
@@ -5,20 +5,92 @@
import type { CLIProxyProvider } from './provider-config';
const BASE_URL = '/api';
export const API_BASE_URL = '/api';
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
export class ApiConflictError extends Error {
readonly code = API_CONFLICT_ERROR_CODE;
constructor(message = 'Resource modified externally') {
super(message);
this.name = 'ApiConflictError';
}
}
export function isApiConflictError(error: unknown): error is Error & { code: string } {
return (
error instanceof Error &&
'code' in error &&
(error as { code?: unknown }).code === API_CONFLICT_ERROR_CODE
);
}
export function withApiBase(path: string): string {
if (!path) {
return API_BASE_URL;
}
if (/^https?:\/\//i.test(path)) {
return path;
}
if (path === API_BASE_URL || path.startsWith(`${API_BASE_URL}/`)) {
return path;
}
return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
}
async function parseErrorMessage(response: Response): Promise<string> {
const fallbackMessage = `Request failed (${response.status}${response.statusText ? ` ${response.statusText}` : ''})`;
const bodyText = await response.text();
if (!bodyText) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(bodyText) as { error?: string; message?: string };
if (parsed.error?.trim()) {
return parsed.error;
}
if (parsed.message?.trim()) {
return parsed.message;
}
return fallbackMessage;
} catch {
return bodyText.trim() || fallbackMessage;
}
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
const res = await fetch(withApiBase(url), {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || res.statusText);
throw new Error(await parseErrorMessage(res));
}
return res.json();
if (res.status === 204) {
return undefined as T;
}
const contentType = res.headers.get('content-type')?.toLowerCase() ?? '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
const bodyText = await res.text();
if (!bodyText) {
return undefined as T;
}
try {
return JSON.parse(bodyText) as T;
} catch {
return bodyText as T;
}
}
// Types
@@ -486,12 +558,12 @@ export const api = {
// Config YAML for Config tab
getConfigYaml: async (): Promise<string> => {
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`);
const res = await fetch(withApiBase('/cliproxy/config.yaml'));
if (!res.ok) throw new Error('Failed to load config');
return res.text();
},
saveConfigYaml: async (content: string): Promise<void> => {
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`, {
const res = await fetch(withApiBase('/cliproxy/config.yaml'), {
method: 'PUT',
headers: { 'Content-Type': 'application/yaml' },
body: content,
@@ -506,7 +578,7 @@ export const api = {
getAuthFiles: () => request<{ files: AuthFile[] }>('/cliproxy/auth-files'),
getAuthFile: async (name: string): Promise<string> => {
const res = await fetch(
`${BASE_URL}/cliproxy/auth-files/download?name=${encodeURIComponent(name)}`
withApiBase(`/cliproxy/auth-files/download?name=${encodeURIComponent(name)}`)
);
if (!res.ok) throw new Error('Failed to load auth file');
return res.text();
@@ -588,7 +660,7 @@ export const api = {
list: () => request<{ files: CliproxyErrorLog[] }>('/cliproxy/error-logs'),
/** Get content of a specific error log */
getContent: async (name: string): Promise<string> => {
const res = await fetch(`${BASE_URL}/cliproxy/error-logs/${encodeURIComponent(name)}`);
const res = await fetch(withApiBase(`/cliproxy/error-logs/${encodeURIComponent(name)}`));
if (!res.ok) throw new Error('Failed to load error log');
return res.text();
},
+9
View File
@@ -0,0 +1,9 @@
/**
* UI-side default ports.
*
* Keep UI defaults explicit to preserve frontend decoupling from backend build internals.
* Sync is enforced by backend/UI parity tests in `tests/unit/cliproxy`.
*/
export const CLIPROXY_DEFAULT_PORT = 8317;
export const DEFAULT_CURSOR_PORT = 20129;
+4 -5
View File
@@ -4,9 +4,8 @@
*/
import { MODEL_CATALOGS } from './model-catalogs';
/** CLIProxy port - should match the backend configuration */
export const CLIPROXY_PORT = 8317;
import { CLIPROXY_DEFAULT_PORT } from './default-ports';
export { CLIPROXY_DEFAULT_PORT } from './default-ports';
/** Default fallback API key if fetch fails */
const DEFAULT_API_KEY = 'ccs-internal-managed';
@@ -31,7 +30,7 @@ async function fetchEffectiveApiKey(): Promise<string> {
* Uses the first model's presetMapping or falls back to using defaultModel for all tiers
*
* @param provider - The provider ID (e.g., 'gemini', 'codex', 'agy')
* @param port - Optional custom port (defaults to CLIPROXY_PORT)
* @param port - Optional custom port (defaults to CLIPROXY_DEFAULT_PORT)
* @returns Object with success status and applied preset name
*/
export async function applyDefaultPreset(
@@ -53,7 +52,7 @@ export async function applyDefaultPreset(
// Fetch effective API key (respects user customization)
const effectiveApiKey = await fetchEffectiveApiKey();
const effectivePort = port ?? CLIPROXY_PORT;
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
const settings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
+54 -9
View File
@@ -30,6 +30,50 @@ export function isValidProvider(provider: string): provider is CLIProxyProvider
return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider);
}
interface ProviderMetadata {
displayName: string;
description: string;
}
export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = {
agy: {
displayName: 'Antigravity',
description: 'Antigravity AI models',
},
claude: {
displayName: 'Claude (Anthropic)',
description: 'Claude Opus/Sonnet models',
},
gemini: {
displayName: 'Google Gemini',
description: 'Gemini Pro/Flash models',
},
codex: {
displayName: 'OpenAI Codex',
description: 'GPT-4 and codex models',
},
qwen: {
displayName: 'Alibaba Qwen',
description: 'Qwen Code models',
},
iflow: {
displayName: 'iFlow',
description: 'iFlow AI models',
},
kiro: {
displayName: 'Kiro (AWS)',
description: 'AWS CodeWhisperer models',
},
ghcp: {
displayName: 'GitHub Copilot (OAuth)',
description: 'GitHub Copilot via OAuth',
},
kimi: {
displayName: 'Kimi (Moonshot)',
description: 'Moonshot AI K2/K2.5 models',
},
};
// Map provider names to asset filenames (only providers with actual logos)
export const PROVIDER_ASSETS: Record<string, string> = {
gemini: '/assets/providers/gemini-color.svg',
@@ -59,16 +103,10 @@ export const PROVIDER_COLORS: Record<string, string> = {
// Provider display names
const PROVIDER_NAMES: Record<string, string> = {
gemini: 'Gemini',
agy: 'Antigravity',
codex: 'Codex',
...Object.fromEntries(
CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
),
vertex: 'Vertex AI',
iflow: 'iFlow',
qwen: 'Qwen',
kiro: 'Kiro (AWS)',
ghcp: 'GitHub Copilot (OAuth)',
claude: 'Claude (Anthropic)',
kimi: 'Kimi (Moonshot)',
};
// Map provider to display name
@@ -76,6 +114,13 @@ export function getProviderDisplayName(provider: string): string {
return PROVIDER_NAMES[provider.toLowerCase()] || provider;
}
/** Map provider to user-facing short description */
export function getProviderDescription(provider: string): string {
const normalized = provider.toLowerCase();
if (!isValidProvider(normalized)) return '';
return PROVIDER_METADATA[normalized].description;
}
/**
* Providers that use Device Code OAuth flow instead of Authorization Code flow.
* Device Code flow requires displaying a user code for manual entry at provider's website.
+19 -5
View File
@@ -24,6 +24,8 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCursor } from '@/hooks/use-cursor';
import { DEFAULT_CURSOR_PORT } from '@/lib/default-ports';
import { isApiConflictError } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -77,7 +79,7 @@ function buildConfigDraft(config?: {
haiku_model?: string;
}): CursorConfigDraft {
return {
port: String(config?.port ?? 20129),
port: String(config?.port ?? DEFAULT_CURSOR_PORT),
auto_start: config?.auto_start ?? false,
ghost_mode: config?.ghost_mode ?? true,
model: config?.model?.trim() || 'gpt-5.3-codex',
@@ -390,6 +392,16 @@ export function CursorPage() {
};
const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => {
if (modelsLoading) {
toast.error('Models are still loading. Please wait before applying a preset.');
return;
}
if (models.length === 0) {
toast.error('No models available yet. Start the daemon and refresh first.');
return;
}
const fallbackModel = effectiveModel || currentModel || models[0]?.id || 'gpt-5.3-codex';
const codex53 = pickModelByAliases(
models,
@@ -559,11 +571,10 @@ export function CursorPage() {
}
return true;
} catch (error) {
const message = (error as Error).message || 'Failed to save raw settings';
if (message === 'CONFLICT') {
if (isApiConflictError(error)) {
toast.error('Raw settings changed externally. Refresh and retry.');
} else {
toast.error(message);
toast.error((error as Error).message || 'Failed to save raw settings');
}
return false;
}
@@ -767,7 +778,7 @@ export function CursorPage() {
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>Port</span>
<span>{status?.port ?? config?.port ?? 20129}</span>
<span>{status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}</span>
</div>
</div>
</div>
@@ -867,6 +878,7 @@ export function CursorPage() {
size="sm"
className="text-xs h-7 gap-1"
onClick={() => applyPreset('codex53')}
disabled={modelsLoading || models.length === 0}
title="OpenAI-only mapping: GPT-5.3 Codex / Codex Max / GPT-5 Mini"
>
<Zap className="w-3 h-3" />
@@ -877,6 +889,7 @@ export function CursorPage() {
size="sm"
className="text-xs h-7 gap-1"
onClick={() => applyPreset('claude46')}
disabled={modelsLoading || models.length === 0}
title="Claude-first mapping: Opus 4.6 / Sonnet 4.5 / Haiku 4.5"
>
<Zap className="w-3 h-3" />
@@ -887,6 +900,7 @@ export function CursorPage() {
size="sm"
className="text-xs h-7 gap-1"
onClick={() => applyPreset('gemini3')}
disabled={modelsLoading || models.length === 0}
title="Gemini-first mapping: Gemini 3 Pro + Gemini 3 Flash"
>
<Zap className="w-3 h-3" />
+31 -9
View File
@@ -24,6 +24,8 @@ import { LocalProxyCard } from './local-proxy-card';
import { RemoteProxyCard } from './remote-proxy-card';
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import { api } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { toast } from 'sonner';
/** LocalStorage key for debug mode preference */
const DEBUG_MODE_KEY = 'ccs_debug_mode';
@@ -165,7 +167,7 @@ export default function ProxySection() {
const portInput = config.remote.port !== undefined ? config.remote.port.toString() : '';
const authTokenInput = config.remote.auth_token ?? '';
const managementKeyInput = config.remote.management_key ?? '';
const localPortInput = (config.local.port ?? 8317).toString();
const localPortInput = (config.local.port ?? CLIPROXY_DEFAULT_PORT).toString();
const displayHost = editedHost ?? hostInput;
const displayPort = editedPort ?? portInput;
@@ -183,12 +185,24 @@ export default function ProxySection() {
};
const savePort = () => {
const portStr = editedPort ?? displayPort;
const port = portStr === '' ? undefined : parseInt(portStr, 10);
const effectivePort = port && !isNaN(port) && port > 0 ? port : undefined;
const portStr = (editedPort ?? displayPort).trim();
if (portStr === '') {
if (config.remote.port !== undefined) {
saveConfig({ remote: { ...remoteConfig, port: undefined } });
}
setEditedPort(null);
return;
}
if (effectivePort !== config.remote.port) {
saveConfig({ remote: { ...remoteConfig, port: effectivePort } });
const parsedPort = Number(portStr);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
toast.error('Port must be an integer between 1 and 65535, or empty for default');
setEditedPort(null);
return;
}
if (parsedPort !== config.remote.port) {
saveConfig({ remote: { ...remoteConfig, port: parsedPort } });
}
setEditedPort(null);
};
@@ -210,9 +224,17 @@ export default function ProxySection() {
};
const saveLocalPort = () => {
const port = parseInt(editedLocalPort ?? displayLocalPort, 10);
if (!isNaN(port) && port !== config.local.port) {
saveConfig({ local: { ...config.local, port } });
const localPortStr = (editedLocalPort ?? displayLocalPort).trim();
const parsedPort = localPortStr === '' ? CLIPROXY_DEFAULT_PORT : Number(localPortStr);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
toast.error('Local port must be an integer between 1 and 65535');
setEditedLocalPort(null);
return;
}
if (parsedPort !== config.local.port) {
saveConfig({ local: { ...config.local, port: parsedPort } });
}
setEditedLocalPort(null);
};
@@ -5,6 +5,7 @@
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import type { CliproxyServerConfig } from '../../types';
interface LocalProxyCardProps {
@@ -34,11 +35,12 @@ export function LocalProxyCard({
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">Port</label>
<Input
type="number"
type="text"
inputMode="numeric"
value={displayLocalPort}
onChange={(e) => setEditedLocalPort(e.target.value)}
onChange={(e) => setEditedLocalPort(e.target.value.replace(/\D/g, ''))}
onBlur={onSaveLocalPort}
placeholder="8317"
placeholder={`${CLIPROXY_DEFAULT_PORT}`}
className="font-mono max-w-32"
disabled={saving}
/>
@@ -13,6 +13,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types';
interface RemoteProxyCardProps {
@@ -59,7 +60,8 @@ export function RemoteProxyCard({
const remoteConfig = config.remote;
// HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL)
const getDefaultPort = (protocol: 'http' | 'https') => (protocol === 'https' ? 443 : 8317);
const getDefaultPort = (protocol: 'http' | 'https') =>
protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT;
return (
<div className="space-y-4 p-4 rounded-lg border bg-muted/30">