diff --git a/package.json b/package.json index ce1cfac0..b4451619 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.28.2", + "version": "7.28.2-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index bde28265..d5db7ae1 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -28,6 +28,7 @@ export const CLIPROXY_PROFILES = [ 'iflow', 'kiro', 'ghcp', + 'claude', ] as const; export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number]; diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 069a93f7..57495035 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CLIProxyProvider } from './types'; +import { CLIPROXY_PROFILES } from '../auth/profile-detector'; import { getCliproxyDir, getAuthDir } from './config-generator'; import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; @@ -946,7 +947,7 @@ export async function soloAccount( * Get summary of all accounts across providers */ export function getAllAccountsSummary(): Record { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; const summary: Record = {} as Record< CLIProxyProvider, AccountInfo[] diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 42e3cc9e..3ebdff0a 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -27,6 +27,7 @@ export const OAUTH_CALLBACK_PORTS: Partial> = { codex: 1455, agy: 51121, iflow: 11451, + claude: 54545, // qwen: Device Code Flow - no callback port // ghcp: Device Code Flow - no callback port }; @@ -121,6 +122,13 @@ export const OAUTH_CONFIGS: Record = { scopes: ['copilot'], authFlag: '--github-copilot-login', }, + claude: { + provider: 'claude', + displayName: 'Claude (Anthropic)', + authUrl: 'https://console.anthropic.com/oauth/authorize', + scopes: ['user:inference', 'user:profile'], + authFlag: '--claude-login', + }, }; /** @@ -136,6 +144,7 @@ export const PROVIDER_AUTH_PREFIXES: Record = { iflow: ['iflow-'], kiro: ['kiro-', 'aws-', 'codewhisperer-'], ghcp: ['github-copilot-', 'copilot-', 'gh-'], + claude: ['claude-', 'anthropic-'], }; /** @@ -150,6 +159,7 @@ export const PROVIDER_TYPE_VALUES: Record = { iflow: ['iflow'], kiro: ['kiro', 'codewhisperer'], ghcp: ['github-copilot', 'copilot'], + claude: ['claude', 'anthropic'], }; /** diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 232fe8c9..89df2ab2 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -35,6 +35,7 @@ export async function refreshToken( case 'iflow': case 'kiro': case 'ghcp': + case 'claude': return { success: false, error: `Token refresh not yet implemented for ${provider}`, diff --git a/src/cliproxy/auth/token-expiry-checker.ts b/src/cliproxy/auth/token-expiry-checker.ts index d3833173..9a6665ee 100644 --- a/src/cliproxy/auth/token-expiry-checker.ts +++ b/src/cliproxy/auth/token-expiry-checker.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CLIProxyProvider } from '../types'; +import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { getProviderAccounts, getAccountTokenPath } from '../account-manager'; /** Preemptive refresh time: refresh tokens 45 minutes before expiry */ @@ -113,7 +114,7 @@ export function getTokenExpiryInfo( * @returns Array of token expiry info, excluding invalid tokens */ export function getAllTokenExpiryInfo(): TokenExpiryInfo[] { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; const results: TokenExpiryInfo[] = []; for (const provider of providers) { diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 1e31273c..41ef2092 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CLIProxyProvider } from '../types'; +import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { getProviderAuthDir } from '../config-generator'; import { getProviderAccounts, getDefaultAccount } from '../account-manager'; import { @@ -145,7 +146,7 @@ export function getAuthStatus(provider: CLIProxyProvider): AuthStatus { * Get auth status for all providers */ export function getAllAuthStatus(): AuthStatus[] { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; return providers.map(getAuthStatus); } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index bea75a25..96b1e14b 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -498,7 +498,16 @@ export async function execClaudeWithCLIProxy( } // 3. Ensure OAuth completed (if provider requires it) - if (providerConfig.requiresOAuth) { + // Skip local OAuth check when using remote proxy with auth token + // The remote proxy has its own OAuth sessions and handles authentication + // Note: Trim authToken to reject whitespace-only values + const remoteAuthToken = proxyConfig.authToken?.trim(); + const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; + if (skipLocalAuth) { + log(`Using remote proxy authentication (skipping local OAuth)`); + } + + if (providerConfig.requiresOAuth && !skipLocalAuth) { log(`Checking authentication for ${provider}`); if (forceAuth || !isAuthenticated(provider)) { @@ -549,7 +558,8 @@ export async function execClaudeWithCLIProxy( // 3b. Preflight quota check - auto-switch to account with quota before launch // Uses quota-manager for caching, tier priority, and cooldown support - if (provider === 'agy') { + // Skip for remote proxy - quota is managed on the remote server + if (provider === 'agy' && !skipLocalAuth) { const preflight = await preflightCheck(provider); if (!preflight.proceed) { @@ -571,11 +581,13 @@ export async function execClaudeWithCLIProxy( // 4. First-run model configuration (interactive) // For supported providers, prompt user to select model on first run // Pass customSettingsPath for CLIProxy variants - if (supportsModelConfig(provider)) { + // Skip for remote proxy - model is configured on the remote server + if (supportsModelConfig(provider) && !skipLocalAuth) { await configureProviderModel(provider, false, cfg.customSettingsPath); // false = only if not configured } // 5. Check for known broken models and warn user + // Show warning for both local and remote modes - user should be aware of model issues const currentModel = getCurrentModel(provider, cfg.customSettingsPath); if (currentModel && isModelBroken(provider, currentModel)) { const modelEntry = findModel(provider, currentModel); @@ -586,7 +598,11 @@ export async function execClaudeWithCLIProxy( if (issueUrl) { console.error(` Tracking: ${issueUrl}`); } - console.error(` Run "ccs ${provider} --config" to change model.`); + if (skipLocalAuth) { + console.error(' Note: Model may be overridden by remote proxy configuration.'); + } else { + console.error(` Run "ccs ${provider} --config" to change model.`); + } console.error(''); } diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index a4652417..a5772bf1 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -299,6 +299,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { iflow: 'iFlow', kiro: 'Kiro (AWS)', ghcp: 'GitHub Copilot (OAuth)', + claude: 'Claude (Anthropic)', }; /** diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 78c86d38..7f035350 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -166,6 +166,55 @@ export const MODEL_CATALOG: Partial> = }, ], }, + claude: { + provider: 'claude', + displayName: 'Claude (Anthropic)', + defaultModel: 'claude-sonnet-4-5-20250514', + models: [ + { + id: 'claude-opus-4-5-20250220', + name: 'Claude Opus 4.5', + description: 'Most capable Claude model', + thinking: { + type: 'budget', + min: 1024, + max: 128000, + zeroAllowed: false, + dynamicAllowed: true, + }, + }, + { + id: 'claude-sonnet-4-5-20250514', + name: 'Claude Sonnet 4.5', + description: 'Balanced performance and speed', + thinking: { + type: 'budget', + min: 1024, + max: 128000, + zeroAllowed: false, + dynamicAllowed: true, + }, + }, + { + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + description: 'Previous generation Sonnet', + thinking: { + type: 'budget', + min: 1024, + max: 128000, + zeroAllowed: false, + dynamicAllowed: true, + }, + }, + { + id: 'claude-haiku-4-5-20250514', + name: 'Claude Haiku 4.5', + description: 'Fast and efficient', + thinking: { type: 'none' }, + }, + ], + }, }; /** diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 4294b956..1fb0ec65 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -132,7 +132,7 @@ export function saveVariantUnified( if (!config.cliproxy) { config.cliproxy = { oauth_accounts: {}, - providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'], + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude'], variants: {}, }; } diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 9759bba2..3ecc56bb 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -118,8 +118,17 @@ export interface DownloadResult { * - iflow: iFlow via OAuth * - kiro: Kiro (AWS CodeWhisperer) via OAuth * - ghcp: GitHub Copilot via Device Code (OAuth through CLIProxyAPIPlus) + * - claude: Claude (Anthropic) via OAuth */ -export type CLIProxyProvider = 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; +export type CLIProxyProvider = + | 'gemini' + | 'codex' + | 'agy' + | 'qwen' + | 'iflow' + | 'kiro' + | 'ghcp' + | 'claude'; /** * CLIProxy backend selection diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index f2d3618e..0df03c27 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -61,7 +61,7 @@ export type OAuthAccounts = Record; */ export interface CLIProxyVariantConfig { /** Base provider to use */ - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; /** Account nickname (references oauth_accounts) */ account?: string; /** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */ diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts index 262d963d..d50c16cf 100644 --- a/src/management/oauth-port-diagnostics.ts +++ b/src/management/oauth-port-diagnostics.ts @@ -8,7 +8,11 @@ * - Gemini: 8085 * - Codex: 1455 * - Agy: 51121 + * - iFlow: 11451 + * - Kiro: 9876 + * - Claude: 54545 * - Qwen: Device Code Flow (no port needed) + * - GHCP: Device Code Flow (no port needed) */ import { @@ -21,6 +25,7 @@ import { BindingTestResult, } from '../utils/port-utils'; import { CLIProxyProvider } from '../cliproxy/types'; +import { CLIPROXY_PROFILES } from '../auth/profile-detector'; /** * OAuth callback ports for each provider @@ -34,6 +39,7 @@ export const OAUTH_CALLBACK_PORTS: Record = { iflow: 11451, // Authorization Code Flow kiro: 9876, // Authorization Code Flow ghcp: null, // Device Code Flow - no callback port + claude: 54545, // Authorization Code Flow (Anthropic OAuth) }; /** @@ -52,6 +58,7 @@ export const OAUTH_FLOW_TYPES: Record = { iflow: 'authorization_code', kiro: 'authorization_code', ghcp: 'device_code', + claude: 'authorization_code', }; /** @@ -138,7 +145,7 @@ export async function checkOAuthPort(provider: CLIProxyProvider): Promise { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; const results: OAuthPortDiagnostic[] = []; for (const provider of providers) { @@ -153,7 +160,8 @@ export async function checkAllOAuthPorts(): Promise { * Check OAuth ports for providers that use Authorization Code flow only */ export async function checkAuthCodePorts(): Promise { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'kiro']; + // Filter providers that use authorization_code flow (DRY: derive from OAUTH_FLOW_TYPES) + const providers = CLIPROXY_PROFILES.filter((p) => OAUTH_FLOW_TYPES[p] === 'authorization_code'); const results: OAuthPortDiagnostic[] = []; for (const provider of providers) { diff --git a/src/types/config.ts b/src/types/config.ts index a00195a3..db910441 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -18,7 +18,7 @@ export interface ProfilesConfig { */ export interface CLIProxyVariantConfig { /** CLIProxy provider to use */ - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude'; /** Path to settings.json with custom model configuration (optional) */ settings?: string; /** Account identifier for multi-account support (optional, defaults to 'default') */ diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index d87d6592..cfc3ef34 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -17,20 +17,13 @@ import { soloAccount, } from '../../cliproxy/account-manager'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; const router = Router(); const registry = new ProfileRegistry(); -/** Valid CLIProxy providers */ -const VALID_PROVIDERS: CLIProxyProvider[] = [ - 'gemini', - 'codex', - 'agy', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', -]; +/** 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 { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 5485b178..295d7070 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -35,19 +35,12 @@ import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; const router = Router(); -// Valid providers list -const validProviders: CLIProxyProvider[] = [ - 'gemini', - 'codex', - 'agy', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', -]; +// Valid providers list - derived from canonical CLIPROXY_PROFILES +const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles @@ -78,6 +71,8 @@ router.get('/', async (_req: Request, res: Response): Promise => { iflow: 'iflow', kiro: 'kiro', copilot: 'ghcp', // CLIProxyAPI returns 'copilot', we map to 'ghcp' + anthropic: 'claude', // CLIProxyAPI returns 'anthropic', we map to 'claude' + claude: 'claude', }; // Update lastUsedAt for providers with recent activity diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index ebd77966..032a86a0 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -14,6 +14,7 @@ import { } from '../../cliproxy/stats-fetcher'; import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { getCliproxyWritablePath, getCliproxyConfigPath, @@ -517,16 +518,8 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise => { const { provider, accountId } = req.params; - // Validate provider - const validProviders: CLIProxyProvider[] = [ - 'agy', - 'gemini', - 'codex', - 'qwen', - 'iflow', - 'kiro', - 'ghcp', - ]; + // Validate provider - use canonical CLIPROXY_PROFILES + const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; if (!validProviders.includes(provider as CLIProxyProvider)) { res.status(400).json({ error: 'Invalid provider', diff --git a/tests/unit/cliproxy/backend-ui-provider-arrays-sync.test.ts b/tests/unit/cliproxy/backend-ui-provider-arrays-sync.test.ts new file mode 100644 index 00000000..50f48dea --- /dev/null +++ b/tests/unit/cliproxy/backend-ui-provider-arrays-sync.test.ts @@ -0,0 +1,40 @@ +/** + * Provider Sync Test + * + * Validates that backend CLIPROXY_PROFILES and UI CLIPROXY_PROVIDERS stay in sync. + * This test catches mismatches when adding new providers. + */ + +import { describe, expect, test } from 'bun:test'; +import { CLIPROXY_PROFILES } from '../../../src/auth/profile-detector'; + +// UI providers (must manually sync - this test validates the sync) +const UI_CLIPROXY_PROVIDERS = [ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', + 'claude', +] as const; + +describe('Provider Sync', () => { + test('backend CLIPROXY_PROFILES matches UI CLIPROXY_PROVIDERS', () => { + const backend = [...CLIPROXY_PROFILES].sort(); + const ui = [...UI_CLIPROXY_PROVIDERS].sort(); + + expect(backend).toEqual(ui); + }); + + test('both arrays have same length', () => { + expect(CLIPROXY_PROFILES.length).toBe(UI_CLIPROXY_PROVIDERS.length); + }); + + test('UI array contains all backend providers', () => { + for (const provider of CLIPROXY_PROFILES) { + expect(UI_CLIPROXY_PROVIDERS).toContain(provider); + } + }); +}); diff --git a/tests/unit/cliproxy/skip-local-auth.test.ts b/tests/unit/cliproxy/skip-local-auth.test.ts new file mode 100644 index 00000000..f507f40f --- /dev/null +++ b/tests/unit/cliproxy/skip-local-auth.test.ts @@ -0,0 +1,282 @@ +/** + * Unit tests for skip-local-auth functionality when using remote proxy with auth token + * + * When --proxy-host and --proxy-auth-token are provided together, the system should + * skip local OAuth checks because the remote proxy handles authentication. + * + * Implementation uses: const remoteAuthToken = proxyConfig.authToken?.trim(); + * const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; + */ +import { describe, it, expect } from 'bun:test'; + +/** + * Helper to compute skipLocalAuth exactly as the implementation does + * Mirrors logic from cliproxy-executor.ts lines 503-505 + */ +function computeSkipLocalAuth( + useRemoteProxy: boolean, + proxyConfig: { authToken?: string | null } +): boolean { + const remoteAuthToken = proxyConfig.authToken?.trim(); + return useRemoteProxy && !!remoteAuthToken; +} + +describe('skip-local-auth logic', () => { + describe('skipLocalAuth flag determination', () => { + it('should skip local auth when both useRemoteProxy and authToken are truthy', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: 'test-token-123' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(true); + }); + + it('should NOT skip local auth when useRemoteProxy is false', () => { + const useRemoteProxy = false; + const proxyConfig = { authToken: 'test-token-123' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should NOT skip local auth when authToken is undefined', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: undefined }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should NOT skip local auth when authToken is empty string', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: '' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should NOT skip local auth when both are falsy', () => { + const useRemoteProxy = false; + const proxyConfig = { authToken: undefined }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + }); + + describe('authToken edge cases', () => { + it('should NOT skip local auth when authToken is whitespace-only', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: ' ' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should NOT skip local auth when authToken is tabs and newlines', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: '\t\n\r' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should NOT skip local auth when authToken is null', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: null }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(false); + }); + + it('should skip local auth when authToken has leading/trailing whitespace but valid content', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: ' valid-token-123 ' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(true); + }); + + it('should skip local auth when authToken contains special characters', () => { + const useRemoteProxy = true; + const proxyConfig = { authToken: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test' }; + + const skipLocalAuth = computeSkipLocalAuth(useRemoteProxy, proxyConfig); + + expect(skipLocalAuth).toBe(true); + }); + }); + + describe('OAuth check bypass scenarios', () => { + it('should document that OAuth is skipped for remote proxy with auth', () => { + // This test documents the expected behavior: + // When using remote proxy with auth token, the remote server + // already has its own OAuth sessions, so local OAuth is unnecessary + const scenario = { + useRemoteProxy: true, + authToken: 'bearer-token', + providerRequiresOAuth: true, + }; + + const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken; + const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth; + + expect(skipLocalAuth).toBeTruthy(); + expect(shouldTriggerLocalOAuth).toBe(false); + }); + + it('should document that OAuth runs when no remote proxy', () => { + const scenario = { + useRemoteProxy: false, + authToken: undefined, + providerRequiresOAuth: true, + }; + + const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken; + const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth; + + expect(skipLocalAuth).toBeFalsy(); + expect(shouldTriggerLocalOAuth).toBe(true); + }); + + it('should document that OAuth runs when remote proxy has no auth token', () => { + // Edge case: remote proxy configured but no auth token + // This should fall back to local OAuth + const scenario = { + useRemoteProxy: true, + authToken: undefined, + providerRequiresOAuth: true, + }; + + const skipLocalAuth = scenario.useRemoteProxy && scenario.authToken; + const shouldTriggerLocalOAuth = scenario.providerRequiresOAuth && !skipLocalAuth; + + expect(skipLocalAuth).toBeFalsy(); + expect(shouldTriggerLocalOAuth).toBe(true); + }); + }); + + describe('preflight quota check bypass', () => { + it('should skip preflight for agy provider when using remote proxy with auth', () => { + const provider = 'agy'; + const skipLocalAuth = true; + + const shouldRunPreflight = provider === 'agy' && !skipLocalAuth; + + expect(shouldRunPreflight).toBe(false); + }); + + it('should run preflight for agy provider when using local mode', () => { + const provider = 'agy'; + const skipLocalAuth = false; + + const shouldRunPreflight = provider === 'agy' && !skipLocalAuth; + + expect(shouldRunPreflight).toBe(true); + }); + + it('should not run preflight for non-agy providers regardless of mode', () => { + const providers = ['gemini', 'codex', 'ghcp', 'kiro']; + + for (const provider of providers) { + const shouldRunPreflight = provider === 'agy' && !false; + expect(shouldRunPreflight).toBe(false); + } + }); + }); + + describe('model configuration bypass', () => { + it('should skip model config when using remote proxy with auth', () => { + const supportsModelConfig = true; + const skipLocalAuth = true; + + const shouldConfigureModel = supportsModelConfig && !skipLocalAuth; + + expect(shouldConfigureModel).toBe(false); + }); + + it('should run model config when using local mode', () => { + const supportsModelConfig = true; + const skipLocalAuth = false; + + const shouldConfigureModel = supportsModelConfig && !skipLocalAuth; + + expect(shouldConfigureModel).toBe(true); + }); + }); + + describe('broken model warning behavior', () => { + it('should show broken model warning in BOTH remote and local modes', () => { + // Updated behavior: warnings always shown (with different messaging for remote) + // Remote users need to know about broken models too + const currentModel = 'some-broken-model'; + const isModelBroken = true; + + // Warning should show regardless of skipLocalAuth + const shouldWarnRemote = currentModel && isModelBroken; // skipLocalAuth=true + const shouldWarnLocal = currentModel && isModelBroken; // skipLocalAuth=false + + expect(shouldWarnRemote).toBe(true); + expect(shouldWarnLocal).toBe(true); + }); + + it('should show different message for remote vs local mode', () => { + const skipLocalAuth = true; + const currentModel = 'some-broken-model'; + const isModelBroken = true; + + // When remote: "Note: Model may be overridden by remote proxy configuration." + // When local: "Run ccs --config to change model." + const remoteMessage = skipLocalAuth + ? 'Note: Model may be overridden by remote proxy configuration.' + : 'Run "ccs provider --config" to change model.'; + + expect(remoteMessage).toContain('remote proxy'); + }); + }); + + describe('CI/CD workflow scenarios', () => { + it('should enable headless CI workflow with remote proxy', () => { + // Simulate GitHub Actions workflow configuration + const workflowConfig = { + headless: true, + proxyHost: 'proxy.example.com', + proxyPort: 443, + proxyProtocol: 'https', + proxyAuthToken: 'github-secret-token', + remoteOnly: true, + }; + + // Determine if this configuration should skip local OAuth + const useRemoteProxy = !!workflowConfig.proxyHost; + const skipLocalAuth = useRemoteProxy && !!workflowConfig.proxyAuthToken; + + expect(useRemoteProxy).toBe(true); + expect(skipLocalAuth).toBe(true); + }); + + it('should require local OAuth when no proxy configured', () => { + // Simulate local development without proxy + const localConfig = { + headless: false, + proxyHost: undefined, + proxyAuthToken: undefined, + }; + + const useRemoteProxy = !!localConfig.proxyHost; + const skipLocalAuth = useRemoteProxy && !!localConfig.proxyAuthToken; + + expect(useRemoteProxy).toBe(false); + expect(skipLocalAuth).toBe(false); + }); + }); +}); diff --git a/ui/public/assets/providers/claude.svg b/ui/public/assets/providers/claude.svg new file mode 100644 index 00000000..62dc0db1 --- /dev/null +++ b/ui/public/assets/providers/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index fa8fc3fb..6ad1cce9 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -13,15 +13,14 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { usePrivacy } from '@/contexts/privacy-context'; - -const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'] as const; +import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; const schema = z.object({ name: z .string() .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), - provider: z.enum(providers, { message: 'Provider is required' }), + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), model: z.string().optional(), account: z.string().optional(), }); @@ -33,15 +32,10 @@ interface CliproxyDialogProps { onClose: () => void; } -const providerOptions = [ - { value: 'gemini', label: 'Google Gemini' }, - { value: 'codex', label: 'OpenAI Codex' }, - { value: 'agy', label: 'Antigravity' }, - { value: 'qwen', label: 'Alibaba Qwen' }, - { value: 'iflow', label: 'iFlow' }, - { value: 'kiro', label: 'Kiro (AWS)' }, - { value: 'ghcp', label: 'GitHub Copilot (OAuth)' }, -]; +const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ + value: id, + label: getProviderDisplayName(id), +})); export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { const createMutation = useCreateVariant(); diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx index 6b5bb51a..a2bcf5e5 100644 --- a/ui/src/components/cliproxy/cliproxy-header.tsx +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -10,6 +10,7 @@ import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { cn } from '@/lib/utils'; +import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; interface VersionInfo { currentVersion: string; @@ -135,16 +136,10 @@ export function CliproxyHeader({ .catch(() => {}); // Silently fail }, []); - const providers = [ - { id: 'claude', displayName: 'Claude' }, - { id: 'gemini', displayName: 'Gemini' }, - { id: 'codex', displayName: 'Codex' }, - { id: 'agy', displayName: 'Agy' }, - { id: 'qwen', displayName: 'Qwen' }, - { id: 'iflow', displayName: 'iFlow' }, - { id: 'kiro', displayName: 'Kiro' }, - { id: 'ghcp', displayName: 'GitHub Copilot' }, - ]; + const providers = CLIPROXY_PROVIDERS.map((id) => ({ + id, + displayName: getProviderDisplayName(id), + })); const getProviderStatus = (providerId: string) => { const status = authData?.authStatus.find((s) => s.provider === providerId); diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index ec71ce8a..0fa29685 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -187,9 +187,11 @@ export function AccountItem({ > -
+
- + {account.email || account.id} {account.isDefault && ( diff --git a/ui/src/components/cliproxy/provider-logo.tsx b/ui/src/components/cliproxy/provider-logo.tsx index 66ad0420..ee762285 100644 --- a/ui/src/components/cliproxy/provider-logo.tsx +++ b/ui/src/components/cliproxy/provider-logo.tsx @@ -20,6 +20,7 @@ const PROVIDER_IMAGES: Record = { iflow: '/assets/providers/iflow.png', kiro: '/assets/providers/kiro.png', ghcp: '/assets/providers/copilot.svg', + claude: '/assets/providers/claude.svg', }; /** Provider color configuration (for fallback only - no background for image logos) */ diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 4c530fe7..96c9d633 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -1,19 +1,42 @@ /** * Constants for Quick Setup Wizard + * Provider display info with custom ordering for wizard UI. + * Provider IDs must match CLIPROXY_PROVIDERS from provider-config.ts */ import type { ProviderOption } from './types'; +import type { CLIProxyProvider } from '@/lib/provider-config'; -export const PROVIDERS: ProviderOption[] = [ - { id: 'gemini', name: 'Google Gemini', description: 'Gemini Pro/Flash models' }, - { id: 'codex', name: 'OpenAI Codex', description: 'GPT-4 and codex models' }, - { id: 'agy', name: 'Antigravity', description: 'Antigravity AI models' }, - { id: 'qwen', name: 'Alibaba Qwen', description: 'Qwen Code models' }, - { id: 'iflow', name: 'iFlow', description: 'iFlow AI models' }, - { id: 'kiro', name: 'Kiro (AWS)', description: 'AWS CodeWhisperer models' }, - { id: 'ghcp', name: 'GitHub Copilot (OAuth)', description: 'GitHub Copilot via OAuth' }, +/** Provider display info for wizard - ordered by recommendation */ +const PROVIDER_INFO: Record = { + 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' }, +}; + +/** Wizard display order - most recommended first */ +const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ + 'agy', + 'claude', + 'gemini', + 'codex', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', ]; +export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({ + id, + name: PROVIDER_INFO[id].name, + description: PROVIDER_INFO[id].description, +})); + export const ALL_STEPS = ['provider', 'auth', 'variant', 'success']; export function getStepProgress(step: string): number { diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 29e98e76..0b894eff 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -22,6 +22,7 @@ import { useCancelAuth, } from '@/hooks/use-cliproxy'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; +import type { CLIProxyProvider } from '@/lib/provider-config'; import { applyDefaultPreset } from '@/lib/preset-utils'; import { usePrivacy } from '@/contexts/privacy-context'; import { toast } from 'sonner'; @@ -136,7 +137,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { try { await createMutation.mutateAsync({ name: variantName, - provider: selectedProvider as 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow', + provider: selectedProvider as CLIProxyProvider, model: modelName || undefined, account: selectedAccount?.id, }); diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index a91a05c3..bc9c46af 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -7,6 +7,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api } from '@/lib/api-client'; +import { isValidProvider } from '@/lib/provider-config'; interface AuthFlowState { provider: string | null; @@ -14,8 +15,6 @@ interface AuthFlowState { error: string | null; } -const VALID_PROVIDERS = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; - export function useCliproxyAuthFlow() { const [state, setState] = useState({ provider: null, @@ -35,7 +34,7 @@ export function useCliproxyAuthFlow() { const startAuth = useCallback( async (provider: string) => { - if (!VALID_PROVIDERS.includes(provider)) { + if (!isValidProvider(provider)) { setState({ provider: null, isAuthenticating: false, diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 6f052997..77b73065 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -3,6 +3,8 @@ * Phase 03: REST API Routes & CRUD */ +import type { CLIProxyProvider } from './provider-config'; + const BASE_URL = '/api'; async function request(url: string, options?: RequestInit): Promise { @@ -47,7 +49,7 @@ export interface UpdateProfile { export interface Variant { name: string; - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + provider: CLIProxyProvider; settings: string; account?: string; port?: number; @@ -56,13 +58,13 @@ export interface Variant { export interface CreateVariant { name: string; - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + provider: CLIProxyProvider; model?: string; account?: string; } export interface UpdateVariant { - provider?: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + provider?: CLIProxyProvider; model?: string; account?: string; } @@ -72,7 +74,7 @@ export interface OAuthAccount { id: string; email?: string; nickname?: string; - provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp'; + provider: CLIProxyProvider; isDefault: boolean; tokenFile: string; createdAt: string; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 09c056ba..9c6065a1 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -311,4 +311,49 @@ export const MODEL_CATALOGS: Record = { }, ], }, + claude: { + provider: 'claude', + displayName: 'Claude (Anthropic)', + defaultModel: 'claude-sonnet-4-5-20250514', + models: [ + { + id: 'claude-opus-4-5-20250220', + name: 'Claude Opus 4.5', + description: 'Most capable Claude model', + presetMapping: { + default: 'claude-opus-4-5-20250220', + opus: 'claude-opus-4-5-20250220', + sonnet: 'claude-sonnet-4-5-20250514', + haiku: 'claude-haiku-4-5-20250514', + }, + }, + { + id: 'claude-sonnet-4-5-20250514', + name: 'Claude Sonnet 4.5', + description: 'Balanced performance and speed', + presetMapping: { + default: 'claude-sonnet-4-5-20250514', + opus: 'claude-opus-4-5-20250220', + sonnet: 'claude-sonnet-4-5-20250514', + haiku: 'claude-haiku-4-5-20250514', + }, + }, + { + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + description: 'Previous generation Sonnet', + presetMapping: { + default: 'claude-sonnet-4-20250514', + opus: 'claude-opus-4-5-20250220', + sonnet: 'claude-sonnet-4-20250514', + haiku: 'claude-haiku-4-5-20250514', + }, + }, + { + id: 'claude-haiku-4-5-20250514', + name: 'Claude Haiku 4.5', + description: 'Fast and efficient', + }, + ], + }, }; diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 989479a2..f0b8743e 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -1,16 +1,44 @@ /** * Provider Configuration - * Shared constants for provider branding and assets + * Shared constants for CLIProxy providers - SINGLE SOURCE OF TRUTH for UI + * + * When adding a new provider, update CLIPROXY_PROVIDERS array and related mappings. */ +/** + * Canonical list of CLIProxy provider IDs + * This is the UI's single source of truth for valid providers. + * Must stay in sync with backend's CLIPROXY_PROFILES in src/auth/profile-detector.ts + */ +export const CLIPROXY_PROVIDERS = [ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', + 'claude', +] as const; + +/** Union type for CLIProxy provider IDs */ +export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number]; + +/** Check if a string is a valid CLIProxy provider */ +export function isValidProvider(provider: string): provider is CLIProxyProvider { + return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider); +} + // Map provider names to asset filenames (only providers with actual logos) export const PROVIDER_ASSETS: Record = { gemini: '/assets/providers/gemini-color.svg', agy: '/assets/providers/agy.png', codex: '/assets/providers/openai.svg', qwen: '/assets/providers/qwen-color.svg', + iflow: '/assets/providers/iflow.png', kiro: '/assets/providers/kiro.png', ghcp: '/assets/providers/copilot.svg', + claude: '/assets/providers/claude.svg', }; // Provider brand colors @@ -23,6 +51,7 @@ export const PROVIDER_COLORS: Record = { qwen: '#6236FF', kiro: '#4d908e', // Dark Cyan (AWS-inspired) ghcp: '#43aa8b', // Seaweed (GitHub-inspired) + claude: '#D97757', // Anthropic brand color (matches SVG) }; // Provider display names @@ -35,6 +64,7 @@ const PROVIDER_NAMES: Record = { qwen: 'Qwen', kiro: 'Kiro (AWS)', ghcp: 'GitHub Copilot (OAuth)', + claude: 'Claude (Anthropic)', }; // Map provider to display name