From 924e3686c8741986def8474d079cc60ea13eee29 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 12:59:17 +0700 Subject: [PATCH] refactor(cliproxy): centralize provider capability registry --- src/auth/profile-detector.ts | 19 +-- src/cliproxy/provider-capabilities.ts | 113 ++++++++++++++++++ src/cliproxy/remote-auth-fetcher.ts | 34 +----- src/web-server/routes/account-routes.ts | 20 +--- .../cliproxy/provider-capabilities.test.ts | 59 +++++++++ 5 files changed, 189 insertions(+), 56 deletions(-) create mode 100644 src/cliproxy/provider-capabilities.ts create mode 100644 tests/unit/cliproxy/provider-capabilities.test.ts diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index d5db7ae1..f5086f24 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -16,21 +16,14 @@ import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir } from '../utils/config-manager'; +import type { CLIProxyProvider } from '../cliproxy/types'; +import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; /** CLIProxy profile names (OAuth-based, zero config) */ -export const CLIPROXY_PROFILES = [ - 'gemini', - 'codex', - 'agy', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', - 'claude', -] as const; -export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number]; +export const CLIPROXY_PROFILES: readonly CLIProxyProvider[] = CLIPROXY_PROVIDER_IDS; +export type CLIProxyProfileName = CLIProxyProvider; export interface ProfileDetectionResult { type: ProfileType; @@ -200,11 +193,11 @@ class ProfileDetector { } // Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config - if (CLIPROXY_PROFILES.includes(profileName as CLIProxyProfileName)) { + if (isCLIProxyProvider(profileName)) { return { type: 'cliproxy', name: profileName, - provider: profileName as CLIProxyProfileName, + provider: profileName, }; } diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts new file mode 100644 index 00000000..bb377629 --- /dev/null +++ b/src/cliproxy/provider-capabilities.ts @@ -0,0 +1,113 @@ +import type { CLIProxyProvider } from './types'; + +export type OAuthFlowType = 'authorization_code' | 'device_code'; + +export interface ProviderCapabilities { + displayName: string; + oauthFlow: OAuthFlowType; + callbackPort: number | null; + /** + * Alternative provider names used by CLIProxyAPI or stats endpoints. + * These aliases normalize external names to canonical CCS provider IDs. + */ + aliases: readonly string[]; +} + +export const PROVIDER_CAPABILITIES: Record = { + gemini: { + displayName: 'Google Gemini', + oauthFlow: 'authorization_code', + callbackPort: 8085, + aliases: ['gemini-cli'], + }, + codex: { + displayName: 'Codex', + oauthFlow: 'authorization_code', + callbackPort: 1455, + aliases: [], + }, + agy: { + displayName: 'AntiGravity', + oauthFlow: 'authorization_code', + callbackPort: 51121, + aliases: ['antigravity'], + }, + qwen: { + displayName: 'Qwen', + oauthFlow: 'device_code', + callbackPort: null, + aliases: [], + }, + iflow: { + displayName: 'iFlow', + oauthFlow: 'authorization_code', + callbackPort: 11451, + aliases: [], + }, + kiro: { + displayName: 'Kiro (AWS)', + oauthFlow: 'authorization_code', + callbackPort: 9876, + aliases: ['codewhisperer'], + }, + ghcp: { + displayName: 'GitHub Copilot (OAuth)', + oauthFlow: 'device_code', + callbackPort: null, + aliases: ['github-copilot', 'copilot'], + }, + claude: { + displayName: 'Claude', + oauthFlow: 'authorization_code', + callbackPort: 54545, + aliases: ['anthropic'], + }, +}; + +export const CLIPROXY_PROVIDER_IDS = Object.freeze( + Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] +); + +const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS); + +const PROVIDER_ALIAS_MAP: ReadonlyMap = (() => { + 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]); + } + } + return new Map(entries); +})(); + +export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider { + return PROVIDER_ID_SET.has(provider as CLIProxyProvider); +} + +export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities { + return PROVIDER_CAPABILITIES[provider]; +} + +export function getProviderDisplayName(provider: CLIProxyProvider): string { + return PROVIDER_CAPABILITIES[provider].displayName; +} + +export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvider[] { + return CLIPROXY_PROVIDER_IDS.filter( + (provider) => PROVIDER_CAPABILITIES[provider].oauthFlow === flowType + ); +} + +export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType { + return PROVIDER_CAPABILITIES[provider].oauthFlow; +} + +export function getOAuthCallbackPort(provider: CLIProxyProvider): number | null { + return PROVIDER_CAPABILITIES[provider].callbackPort; +} + +export function mapExternalProviderName(providerName: string): CLIProxyProvider | null { + const normalized = providerName.toLowerCase(); + return PROVIDER_ALIAS_MAP.get(normalized) ?? null; +} diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 5de9218e..aaedf04e 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -9,6 +9,8 @@ import { buildManagementHeaders, ProxyTarget, } from './proxy-target-resolver'; +import { getProviderDisplayName, mapExternalProviderName } from './provider-capabilities'; +import type { CLIProxyProvider } from './types'; /** Timeout for remote fetch requests (ms) */ const REMOTE_FETCH_TIMEOUT_MS = 5000; @@ -43,32 +45,6 @@ export interface RemoteAuthStatus { source: 'remote'; } -/** Map CLIProxyAPI provider names to CCS internal names */ -const PROVIDER_MAP: Record = { - gemini: 'gemini', - 'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - kiro: 'kiro', - codewhisperer: 'kiro', // CLIProxyAPI may use 'codewhisperer' for Kiro - ghcp: 'ghcp', - 'github-copilot': 'ghcp', - copilot: 'ghcp', -}; - -/** Display names for providers */ -const PROVIDER_DISPLAY_NAMES: Record = { - gemini: 'Google Gemini', - agy: 'AntiGravity', - codex: 'Codex', - qwen: 'Qwen', - iflow: 'iFlow', - kiro: 'Kiro (AWS)', - ghcp: 'GitHub Copilot (OAuth)', -}; - /** * Fetch auth status from remote CLIProxyAPI * @throws Error if remote is unreachable or returns error @@ -124,10 +100,10 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise(); + const byProvider = new Map(); for (const file of files) { - const provider = PROVIDER_MAP[file.provider.toLowerCase()]; + const provider = mapExternalProviderName(file.provider); if (!provider) { // Unknown provider, skip (could add logging in debug mode) continue; @@ -154,7 +130,7 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] { result.push({ provider, - displayName: PROVIDER_DISPLAY_NAMES[provider] || provider, + displayName: getProviderDisplayName(provider), authenticated: activeFiles.length > 0, tokenFiles: providerFiles.length, accounts, diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 2e8b4e84..20362ff6 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -17,28 +17,20 @@ import { soloAccount, } from '../../cliproxy/account-manager'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; const router = Router(); const registry = new ProfileRegistry(); -/** Valid CLIProxy providers - derived from canonical CLIPROXY_PROFILES */ -const VALID_PROVIDERS: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; - -/** Check if provider is valid */ -function isValidProvider(provider: string): provider is CLIProxyProvider { - return VALID_PROVIDERS.includes(provider as CLIProxyProvider); -} - /** Parse CLIProxy account key format: "provider:accountId" */ function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { const colonIndex = key.indexOf(':'); if (colonIndex === -1) return null; - const provider = key.slice(0, colonIndex) as CLIProxyProvider; + const provider = key.slice(0, colonIndex); const accountId = key.slice(colonIndex + 1); - if (!isValidProvider(provider) || !accountId) return null; + if (!isCLIProxyProvider(provider) || !accountId) return null; return { provider, accountId }; } @@ -239,7 +231,7 @@ router.post('/bulk-pause', (req: Request, res: Response): void => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } @@ -276,7 +268,7 @@ router.post('/bulk-resume', (req: Request, res: Response): void => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } @@ -313,7 +305,7 @@ router.post('/solo', async (req: Request, res: Response): Promise => { return; } - if (!isValidProvider(provider)) { + if (!isCLIProxyProvider(provider)) { res.status(400).json({ error: `Invalid provider: ${provider}` }); return; } diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts new file mode 100644 index 00000000..2e32cb27 --- /dev/null +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test'; +import { + CLIPROXY_PROVIDER_IDS, + getOAuthCallbackPort, + getProviderDisplayName, + getProvidersByOAuthFlow, + isCLIProxyProvider, + mapExternalProviderName, +} from '../../../src/cliproxy/provider-capabilities'; + +describe('provider-capabilities', () => { + it('keeps canonical provider IDs backward-compatible', () => { + expect(CLIPROXY_PROVIDER_IDS).toEqual([ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', + 'claude', + ]); + }); + + it('validates provider IDs', () => { + expect(isCLIProxyProvider('gemini')).toBe(true); + expect(isCLIProxyProvider('ghcp')).toBe(true); + expect(isCLIProxyProvider('not-a-provider')).toBe(false); + expect(isCLIProxyProvider('Gemini')).toBe(false); + }); + + it('returns providers by OAuth flow capability', () => { + expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'ghcp']); + expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ + 'gemini', + 'codex', + 'agy', + 'iflow', + 'kiro', + 'claude', + ]); + }); + + it('maps external provider aliases to canonical IDs', () => { + expect(mapExternalProviderName('gemini-cli')).toBe('gemini'); + expect(mapExternalProviderName('antigravity')).toBe('agy'); + expect(mapExternalProviderName('codewhisperer')).toBe('kiro'); + expect(mapExternalProviderName('github-copilot')).toBe('ghcp'); + expect(mapExternalProviderName('copilot')).toBe('ghcp'); + expect(mapExternalProviderName('anthropic')).toBe('claude'); + expect(mapExternalProviderName('unknown-provider')).toBeNull(); + }); + + it('exposes callback port and display name capabilities', () => { + expect(getOAuthCallbackPort('qwen')).toBeNull(); + expect(getOAuthCallbackPort('gemini')).toBe(8085); + expect(getProviderDisplayName('agy')).toBe('AntiGravity'); + }); +});