mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
Merge pull request #580 from kaitranntt/refactor/cliproxy-hardcoded-standardization
refactor(cliproxy): centralize provider capabilities and remove hardcoded defaults
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,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}`,
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user