Merge pull request #384 from kaitranntt/dev

feat(cliproxy): Claude OAuth provider + skip-local-auth improvements
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-27 22:27:46 -05:00
committed by GitHub
31 changed files with 579 additions and 85 deletions
+1 -1
View File
@@ -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",
+1
View File
@@ -28,6 +28,7 @@ export const CLIPROXY_PROFILES = [
'iflow',
'kiro',
'ghcp',
'claude',
] as const;
export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number];
+2 -1
View File
@@ -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<CLIProxyProvider, AccountInfo[]> {
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
const providers: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
const summary: Record<CLIProxyProvider, AccountInfo[]> = {} as Record<
CLIProxyProvider,
AccountInfo[]
+10
View File
@@ -27,6 +27,7 @@ export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
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<CLIProxyProvider, ProviderOAuthConfig> = {
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<CLIProxyProvider, string[]> = {
iflow: ['iflow-'],
kiro: ['kiro-', 'aws-', 'codewhisperer-'],
ghcp: ['github-copilot-', 'copilot-', 'gh-'],
claude: ['claude-', 'anthropic-'],
};
/**
@@ -150,6 +159,7 @@ export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
iflow: ['iflow'],
kiro: ['kiro', 'codewhisperer'],
ghcp: ['github-copilot', 'copilot'],
claude: ['claude', 'anthropic'],
};
/**
@@ -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}`,
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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);
}
+20 -4
View File
@@ -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('');
}
+1
View File
@@ -299,6 +299,7 @@ const PROVIDER_DISPLAY_NAMES: Record<CLIProxyProvider, string> = {
iflow: 'iFlow',
kiro: 'Kiro (AWS)',
ghcp: 'GitHub Copilot (OAuth)',
claude: 'Claude (Anthropic)',
};
/**
+49
View File
@@ -166,6 +166,55 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
},
],
},
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' },
},
],
},
};
/**
@@ -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: {},
};
}
+10 -1
View File
@@ -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
+1 -1
View File
@@ -61,7 +61,7 @@ export type OAuthAccounts = Record<string, string>;
*/
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") */
+10 -2
View File
@@ -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<CLIProxyProvider, number | null> = {
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<CLIProxyProvider, OAuthFlowType> = {
iflow: 'authorization_code',
kiro: 'authorization_code',
ghcp: 'device_code',
claude: 'authorization_code',
};
/**
@@ -138,7 +145,7 @@ export async function checkOAuthPort(provider: CLIProxyProvider): Promise<OAuthP
* Check OAuth ports for all providers
*/
export async function checkAllOAuthPorts(): Promise<OAuthPortDiagnostic[]> {
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<OAuthPortDiagnostic[]> {
* Check OAuth ports for providers that use Authorization Code flow only
*/
export async function checkAuthCodePorts(): Promise<OAuthPortDiagnostic[]> {
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) {
+1 -1
View File
@@ -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') */
+3 -10
View File
@@ -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 {
+5 -10
View File
@@ -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<void> => {
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
+3 -10
View File
@@ -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<voi
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
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',
@@ -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);
}
});
});
+282
View File
@@ -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 <provider> --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);
});
});
});
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+6 -12
View File
@@ -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();
+5 -10
View File
@@ -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);
@@ -187,9 +187,11 @@ export function AccountItem({
>
<User className="w-4 h-4" />
</div>
<div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
<span
className={cn('font-medium text-sm truncate', privacyMode && PRIVACY_BLUR_CLASS)}
>
{account.email || account.id}
</span>
{account.isDefault && (
@@ -20,6 +20,7 @@ const PROVIDER_IMAGES: Record<string, string> = {
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) */
+31 -8
View File
@@ -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<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' },
};
/** 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 {
+2 -1
View File
@@ -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,
});
+2 -3
View File
@@ -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<AuthFlowState>({
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,
+6 -4
View File
@@ -3,6 +3,8 @@
* Phase 03: REST API Routes & CRUD
*/
import type { CLIProxyProvider } from './provider-config';
const BASE_URL = '/api';
async function request<T>(url: string, options?: RequestInit): Promise<T> {
@@ -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;
+45
View File
@@ -311,4 +311,49 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
],
},
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',
},
],
},
};
+31 -1
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
qwen: 'Qwen',
kiro: 'Kiro (AWS)',
ghcp: 'GitHub Copilot (OAuth)',
claude: 'Claude (Anthropic)',
};
// Map provider to display name