mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
refactor(cliproxy): centralize provider capability registry
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user