refactor(cliproxy): centralize provider capability registry

This commit is contained in:
Tam Nhu Tran
2026-02-12 12:59:17 +07:00
parent e68ae5b166
commit 924e3686c8
5 changed files with 189 additions and 56 deletions
+6 -13
View File
@@ -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,
};
}
+113
View File
@@ -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<CLIProxyProvider, ProviderCapabilities> = {
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<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]);
}
}
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;
}
+5 -29
View File
@@ -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<string, string> = {
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<string, string> = {
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<Remot
* @param files Array of auth files from remote API
*/
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
const byProvider = new Map<string, RemoteAuthFile[]>();
const byProvider = new Map<CLIProxyProvider, RemoteAuthFile[]>();
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,
+6 -14
View File
@@ -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<void> => {
return;
}
if (!isValidProvider(provider)) {
if (!isCLIProxyProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
@@ -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');
});
});