From efb966c96dc7d30629e52665ad4b383d55cfc046 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 28 Nov 2025 19:41:28 -0500 Subject: [PATCH] feat(cliproxy): unified config for concurrent gemini/codex usage - Add unified CLIProxy config supporting all providers concurrently - Move CLIProxy files to ~/.ccs/cliproxy/ subdirectory - Use flat auth directory structure for OAuth tokens - Add provider-specific URL routing via /api/provider/{provider} - Add base settings templates for gemini and codex - Fix model registration with proper auth file discovery Enables users to run `ccs gemini` and `ccs codex` concurrently without config conflicts. --- CHANGELOG.md | 8 +- config/base-codex.settings.json | 10 ++ config/base-gemini.settings.json | 10 ++ src/auth/profile-detector.ts | 2 +- src/cliproxy/auth-handler.ts | 207 ++++++++++++++++++++---------- src/cliproxy/binary-manager.ts | 19 ++- src/cliproxy/cliproxy-executor.ts | 47 +++++-- src/cliproxy/config-generator.ts | 133 ++++++++++--------- src/cliproxy/index.ts | 4 + src/cliproxy/platform-detector.ts | 12 +- src/cliproxy/types.ts | 2 +- src/commands/help-command.ts | 2 +- src/management/doctor.ts | 7 +- 13 files changed, 303 insertions(+), 160 deletions(-) create mode 100644 config/base-codex.settings.json create mode 100644 config/base-gemini.settings.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a933c608..74665029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/) ### Added - **CLIProxy OAuth Profiles**: Three new zero-config profiles powered by CLIProxyAPI - `ccs gemini` - Google Gemini via OAuth (zero config) - - `ccs chatgpt` - ChatGPT/OpenAI Codex via OAuth (zero config) + - `ccs codex` - OpenAI Codex via OAuth (zero config) - `ccs qwen` - Alibaba Qwen via OAuth (zero config) - **Download-on-Demand Binary**: CLIProxyAPI binary (~15MB) downloads automatically on first use @@ -25,7 +25,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/) - **CLIProxy Diagnostics** in `ccs doctor`: - Binary installation status + version - Config file validation - - OAuth status per provider (gemini/chatgpt/qwen) + - OAuth status per provider (gemini/codex/qwen) - Port 8317 availability check - **Enhanced Error Messages** (`src/utils/error-manager.ts`): @@ -44,7 +44,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/) ### Changed - **Profile Detection**: New priority order - 1. CLIProxy profiles (gemini, chatgpt, qwen) + 1. CLIProxy profiles (gemini, codex, qwen) 2. Settings-based profiles (glm, glmt, kimi) 3. Account-based profiles (work, personal) 4. Default Claude CLI @@ -56,7 +56,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/) - **Default Port**: 8317 (TCP polling for readiness, no PROXY_READY signal) - **Model Mappings**: - Gemini: gemini-2.0-flash (opus: thinking-exp, haiku: flash-lite) - - ChatGPT: gpt-4o (opus: o1, haiku: gpt-4o-mini) + - Codex: gpt-4o (opus: o1, haiku: gpt-4o-mini) - Qwen: qwen-max (sonnet: qwen-plus, haiku: qwen-turbo) - **Storage**: - Binary: `~/.ccs/bin/cliproxyapi` diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json new file mode 100644 index 00000000..cf2915ad --- /dev/null +++ b/config/base-codex.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "gpt-5.1-codex-max", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.1-codex-max-high", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.1-codex-max", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.1-codex-mini-high" + } +} diff --git a/config/base-gemini.settings.json b/config/base-gemini.settings.json new file mode 100644 index 00000000..d5a1000f --- /dev/null +++ b/config/base-gemini.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gemini", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "gemini-2.5-pro", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-2.5-pro", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-2.5-flash" + } +} diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 6a577fc1..100b94a7 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -14,7 +14,7 @@ import { Config, Settings, ProfileMetadata } from '../types'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default'; /** CLIProxy profile names (OAuth-based, zero config) */ -export const CLIPROXY_PROFILES = ['gemini', 'chatgpt', 'qwen'] as const; +export const CLIPROXY_PROFILES = ['gemini', 'codex', 'qwen'] as const; export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number]; export interface ProfileDetectionResult { diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index c95ff167..ba364599 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -1,23 +1,47 @@ /** * Auth Handler for CLIProxyAPI * - * Manages OAuth authentication for CLIProxy providers (Gemini, ChatGPT, Qwen). + * Manages OAuth authentication for CLIProxy providers (Gemini, Codex, Qwen). * CLIProxyAPI handles OAuth internally - we just need to: - * 1. Check if auth exists (token files in auth-dir) + * 1. Check if auth exists (token files in CCS auth directory) * 2. Trigger OAuth flow by spawning binary with auth flag - * 3. Provide headless fallback (display URL for manual auth) + * 3. Auto-detect headless environments (SSH, no DISPLAY) + * 4. Use -no-browser flag for headless, display OAuth URL for manual auth * - * Token storage: ~/.ccs/cliproxy-auth// + * Token storage: ~/.ccs/cliproxy/auth// + * Each provider has its own directory to avoid conflicts. */ import * as fs from 'fs'; import * as path from 'path'; import { spawn } from 'child_process'; import { ProgressIndicator } from '../utils/progress-indicator'; -import { getAuthDir } from './config-generator'; +import { getProviderAuthDir, generateConfig } from './config-generator'; import { ensureCLIProxyBinary } from './binary-manager'; import { CLIProxyProvider } from './types'; +/** + * Detect if running in a headless environment (no browser available) + */ +function isHeadlessEnvironment(): boolean { + // SSH session + if (process.env.SSH_TTY || process.env.SSH_CLIENT || process.env.SSH_CONNECTION) { + return true; + } + + // No display (Linux/X11) + if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { + return true; + } + + // Non-interactive (piped stdin) + if (!process.stdin.isTTY) { + return true; + } + + return false; +} + /** * Auth status for a provider */ @@ -60,21 +84,21 @@ const OAUTH_CONFIGS: Record = { displayName: 'Google Gemini', authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', scopes: ['https://www.googleapis.com/auth/generative-language'], - authFlag: '--auth-gemini', + authFlag: '-login', }, - chatgpt: { - provider: 'chatgpt', - displayName: 'ChatGPT/OpenAI', + codex: { + provider: 'codex', + displayName: 'Codex', authUrl: 'https://auth.openai.com/authorize', scopes: ['openid', 'profile'], - authFlag: '--auth-codex', + authFlag: '-codex-login', }, qwen: { provider: 'qwen', displayName: 'Alibaba Qwen', authUrl: 'https://auth.aliyun.com/oauth2/authorize', scopes: ['dashscope'], - authFlag: '--auth-qwen', + authFlag: '-qwen-login', }, }; @@ -93,11 +117,13 @@ export function getOAuthConfig(provider: CLIProxyProvider): ProviderOAuthConfig * Get token directory for provider */ export function getProviderTokenDir(provider: CLIProxyProvider): string { - return path.join(getAuthDir(), provider); + return getProviderAuthDir(provider); } /** * Check if provider has valid authentication + * CLIProxyAPI stores OAuth tokens as JSON files in the auth directory. + * We check for any .json files that could contain tokens. */ export function isAuthenticated(provider: CLIProxyProvider): boolean { const tokenDir = getProviderTokenDir(provider); @@ -106,13 +132,16 @@ export function isAuthenticated(provider: CLIProxyProvider): boolean { return false; } - // Check for any token files (CLIProxyAPI stores tokens with various names) - const files = fs.readdirSync(tokenDir); - const tokenFiles = files.filter( - (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' - ); - - return tokenFiles.length > 0; + // Check for any token files created by CLIProxyAPI + try { + const files = fs.readdirSync(tokenDir); + const tokenFiles = files.filter( + (f) => f.endsWith('.json') || f.endsWith('.token') || f === 'credentials' + ); + return tokenFiles.length > 0; + } catch { + return false; + } } /** @@ -152,7 +181,7 @@ export function getAuthStatus(provider: CLIProxyProvider): AuthStatus { * Get auth status for all providers */ export function getAllAuthStatus(): AuthStatus[] { - const providers: CLIProxyProvider[] = ['gemini', 'chatgpt', 'qwen']; + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'qwen']; return providers.map(getAuthStatus); } @@ -180,14 +209,17 @@ export function clearAuth(provider: CLIProxyProvider): boolean { /** * Trigger OAuth flow for provider - * Opens browser for user authentication + * Auto-detects headless environment and uses -no-browser flag accordingly */ export async function triggerOAuth( provider: CLIProxyProvider, options: { verbose?: boolean; headless?: boolean } = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, headless = false } = options; + const { verbose = false } = options; + + // Auto-detect headless if not explicitly set + const headless = options.headless ?? isHeadlessEnvironment(); const log = (msg: string) => { if (verbose) { @@ -208,91 +240,124 @@ export async function triggerOAuth( const tokenDir = getProviderTokenDir(provider); fs.mkdirSync(tokenDir, { recursive: true, mode: 0o700 }); - // Headless mode: display manual instructions + // Generate config file (CLIProxyAPI requires it even for auth) + const configPath = generateConfig(provider); + log(`Config generated: ${configPath}`); + + // Build args: config + auth flag + optional -no-browser for headless + const args = ['-config', configPath, oauthConfig.authFlag]; if (headless) { - console.log(''); - console.log(`[i] Headless mode: Manual authentication required for ${oauthConfig.displayName}`); - console.log(''); - console.log('Instructions:'); - console.log(` 1. Run CLIProxyAPI binary with auth flag on a machine with browser:`); - console.log(` ${binaryPath} ${oauthConfig.authFlag}`); - console.log(''); - console.log(` 2. Complete OAuth in browser`); - console.log(''); - console.log(` 3. Copy token files from CLIProxyAPI auth directory to:`); - console.log(` ${tokenDir}`); - console.log(''); - return false; + args.push('-no-browser'); } - // Standard mode: spawn binary with auth flag - const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`); - spinner.start(); + // Show appropriate message + console.log(''); + if (headless) { + console.log(`[i] Headless mode detected - manual authentication required`); + console.log(`[i] ${oauthConfig.displayName} will display an OAuth URL below`); + console.log(''); + } else { + console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`); + console.log('[i] Complete the login in your browser.'); + console.log(''); + } - console.log(''); - console.log(`[i] Opening browser for ${oauthConfig.displayName} authentication...`); - console.log('[i] Complete the login in your browser.'); - console.log(''); + const spinner = new ProgressIndicator(`Authenticating with ${oauthConfig.displayName}`); + if (!headless) { + spinner.start(); + } return new Promise((resolve) => { - // Spawn CLIProxyAPI with auth flag - // Note: CLIProxyAPI handles the OAuth flow internally - const authProcess = spawn(binaryPath, [oauthConfig.authFlag], { - stdio: verbose ? 'inherit' : ['ignore', 'pipe', 'pipe'], + // Spawn CLIProxyAPI with auth flag (and -no-browser if headless) + const authProcess = spawn(binaryPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, - // Set auth directory for CLIProxyAPI CLI_PROXY_AUTH_DIR: tokenDir, }, }); let stderrData = ''; + let urlDisplayed = false; - if (!verbose) { - authProcess.stdout?.on('data', (data: Buffer) => { - log(`stdout: ${data.toString().trim()}`); - }); + authProcess.stdout?.on('data', (data: Buffer) => { + const output = data.toString(); + log(`stdout: ${output.trim()}`); - authProcess.stderr?.on('data', (data: Buffer) => { - stderrData += data.toString(); - log(`stderr: ${data.toString().trim()}`); - }); - } + // In headless mode, display OAuth URLs prominently + if (headless) { + const lines = output.split('\n'); + for (const line of lines) { + // Look for URLs in the output + const urlMatch = line.match(/https?:\/\/[^\s]+/); + if (urlMatch && !urlDisplayed) { + console.log(` ${urlMatch[0]}`); + console.log(''); + console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)'); + urlDisplayed = true; + } + } + } + }); - // Timeout after 2 minutes + authProcess.stderr?.on('data', (data: Buffer) => { + const output = data.toString(); + stderrData += output; + log(`stderr: ${output.trim()}`); + + // Also check stderr for URLs (some tools output there) + if (headless && !urlDisplayed) { + const urlMatch = output.match(/https?:\/\/[^\s]+/); + if (urlMatch) { + console.log(` ${urlMatch[0]}`); + console.log(''); + console.log('[i] Waiting for authentication... (press Ctrl+C to cancel)'); + urlDisplayed = true; + } + } + }); + + // Timeout after 5 minutes for headless (user needs time to copy URL) + const timeoutMs = headless ? 300000 : 120000; const timeout = setTimeout(() => { - spinner.fail('Authentication timeout'); + if (!headless) spinner.fail('Authentication timeout'); authProcess.kill(); - console.error('[X] OAuth timed out after 2 minutes'); + console.error(`[X] OAuth timed out after ${headless ? 5 : 2} minutes`); console.error(''); - console.error('Troubleshooting:'); - console.error(' - Make sure a browser is available'); - console.error(' - Try running with --verbose for details'); - console.error(` - For headless systems, use: ccs ${provider} --auth --headless`); + if (!headless) { + console.error('Troubleshooting:'); + console.error(' - Make sure a browser is available'); + console.error(' - Try running with --verbose for details'); + } resolve(false); - }, 120000); + }, timeoutMs); authProcess.on('exit', (code) => { clearTimeout(timeout); if (code === 0) { - spinner.succeed(`Authenticated with ${oauthConfig.displayName}`); + if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`); // Verify token was created if (isAuthenticated(provider)) { console.log('[OK] Authentication successful'); resolve(true); } else { - spinner.fail('Authentication incomplete'); + if (!headless) spinner.fail('Authentication incomplete'); console.error('[X] Token not found after authentication'); console.error(' The OAuth flow may have been cancelled'); resolve(false); } } else { - spinner.fail('Authentication failed'); + if (!headless) spinner.fail('Authentication failed'); console.error(`[X] CLIProxyAPI auth exited with code ${code}`); - if (stderrData) { - console.error(` ${stderrData.trim()}`); + if (stderrData && !urlDisplayed) { + console.error(` ${stderrData.trim().split('\n')[0]}`); + } + // Show headless hint if we detected headless environment + if (headless && !urlDisplayed) { + console.error(''); + console.error('[i] No OAuth URL was displayed. Try with --verbose for details.'); } resolve(false); } @@ -300,7 +365,7 @@ export async function triggerOAuth( authProcess.on('error', (error) => { clearTimeout(timeout); - spinner.fail('Authentication error'); + if (!headless) spinner.fail('Authentication error'); console.error(`[X] Failed to start auth process: ${error.message}`); resolve(false); }); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 304da600..7366d63a 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -16,8 +16,8 @@ import * as https from 'https'; import * as http from 'http'; import * as crypto from 'crypto'; import * as zlib from 'zlib'; -import { getCcsDir } from '../utils/config-manager'; import { ProgressIndicator } from '../utils/progress-indicator'; +import { getBinDir } from './config-generator'; import { BinaryInfo, BinaryManagerConfig, @@ -30,6 +30,7 @@ import { getDownloadUrl, getChecksumsUrl, getExecutableName, + getArchiveBinaryName, CLIPROXY_VERSION, } from './platform-detector'; @@ -37,7 +38,7 @@ import { const DEFAULT_CONFIG: BinaryManagerConfig = { version: CLIPROXY_VERSION, releaseUrl: 'https://github.com/router-for-me/CLIProxyAPI/releases/download', - binPath: path.join(getCcsDir(), 'bin'), + binPath: getBinDir(), maxRetries: 3, verbose: false, }; @@ -400,6 +401,7 @@ export class BinaryManager { return new Promise((resolve, reject) => { const destDir = this.config.binPath; const execName = getExecutableName(); + const archiveBinaryName = getArchiveBinaryName(); // Read and decompress const gunzip = zlib.createGunzip(); @@ -440,7 +442,11 @@ export class BinaryManager { if (name && size > 0) { // Extract just the filename (handle directories) const baseName = path.basename(name); - if (baseName === execName || baseName === 'CLIProxyAPI') { + if ( + baseName === execName || + baseName === archiveBinaryName || + baseName === 'cli-proxy-api' + ) { currentFile = { name: baseName, size }; fileBuffer = Buffer.alloc(0); bytesRead = 0; @@ -504,6 +510,7 @@ export class BinaryManager { return new Promise((resolve, reject) => { const destDir = this.config.binPath; const execName = getExecutableName(); + const archiveBinaryName = getArchiveBinaryName(); const buffer = fs.readFileSync(archivePath); // Find End of Central Directory record (EOCD) @@ -540,7 +547,11 @@ export class BinaryManager { const baseName = path.basename(fileName); // Check if this is the executable we want - if (baseName === execName || baseName === 'CLIProxyAPI.exe') { + if ( + baseName === execName || + baseName === archiveBinaryName || + baseName === 'cli-proxy-api.exe' + ) { // Read from local file header const localOffset = localHeaderOffset; const localSig = buffer.readUInt32LE(localOffset); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index afa95907..c9ed7ba0 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -23,7 +23,7 @@ import { getProviderConfig, CLIPROXY_DEFAULT_PORT, } from './config-generator'; -import { ensureAuth, isAuthenticated } from './auth-handler'; +import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; /** Default executor configuration */ @@ -115,30 +115,51 @@ export async function execClaudeWithCLIProxy( throw error; } - // 2. Ensure OAuth completed (if provider requires it) + // 2. Handle authentication flags + const forceAuth = args.includes('--auth'); + const forceHeadless = args.includes('--headless'); + const forceLogout = args.includes('--logout'); + + // Handle --logout: clear auth and exit + if (forceLogout) { + const { clearAuth } = await import('./auth-handler'); + if (clearAuth(provider)) { + console.log(`[OK] Logged out from ${providerConfig.displayName}`); + } else { + console.log(`[i] No authentication found for ${providerConfig.displayName}`); + } + process.exit(0); + } + + // 3. Ensure OAuth completed (if provider requires it) if (providerConfig.requiresOAuth) { log(`Checking authentication for ${provider}`); - // Check for --auth flag to force re-auth - const forceAuth = args.includes('--auth'); - const headless = args.includes('--headless'); - if (forceAuth || !isAuthenticated(provider)) { - const authSuccess = await ensureAuth(provider, { verbose, headless }); + // Pass headless only if explicitly set; otherwise let auth-handler auto-detect + const { triggerOAuth } = await import('./auth-handler'); + const authSuccess = await triggerOAuth(provider, { + verbose, + ...(forceHeadless ? { headless: true } : {}), + }); if (!authSuccess) { throw new Error(`Authentication required for ${providerConfig.displayName}`); } + // If --auth was explicitly passed, exit after auth (don't start Claude) + if (forceAuth) { + process.exit(0); + } } else { log(`${provider} already authenticated`); } } - // 3. Generate config file + // 4. Generate config file log(`Generating config for ${provider}`); const configPath = generateConfig(provider, cfg.port); log(`Config written: ${configPath}`); - // 4. Spawn CLIProxyAPI binary + // 5. Spawn CLIProxyAPI binary const proxyArgs = ['--config', configPath]; log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); @@ -199,12 +220,16 @@ export async function execClaudeWithCLIProxy( log(`Claude env: ANTHROPIC_BASE_URL=${envVars.ANTHROPIC_BASE_URL}`); log(`Claude env: ANTHROPIC_MODEL=${envVars.ANTHROPIC_MODEL}`); + // Filter out CCS-specific flags before passing to Claude CLI + const ccsFlags = ['--auth', '--headless', '--logout']; + const claudeArgs = args.filter((arg) => !ccsFlags.includes(arg)); + const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); let claude: ChildProcess; if (needsShell) { - const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + const cmdString = [claudeCli, ...claudeArgs].map(escapeShellArg).join(' '); claude = spawn(cmdString, { stdio: 'inherit', windowsHide: true, @@ -212,7 +237,7 @@ export async function execClaudeWithCLIProxy( env, }); } else { - claude = spawn(claudeCli, args, { + claude = spawn(claudeCli, claudeArgs, { stdio: 'inherit', windowsHide: true, env, diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 85907846..c3aecf4f 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -24,23 +24,23 @@ export const PROVIDER_CONFIGS: Record = { name: 'gemini', displayName: 'Gemini', models: { - defaultModel: 'gemini-2.0-flash', - claudeModel: 'gemini-2.0-flash', - opusModel: 'gemini-2.0-flash-thinking-exp', - sonnetModel: 'gemini-2.0-flash', - haikuModel: 'gemini-2.0-flash-lite', + defaultModel: 'gemini-2.5-pro', + claudeModel: 'gemini-2.5-pro', + opusModel: 'gemini-3-pro-preview', + sonnetModel: 'gemini-2.5-pro', + haikuModel: 'gemini-2.5-flash', }, requiresOAuth: true, }, - chatgpt: { - name: 'chatgpt', - displayName: 'ChatGPT', + codex: { + name: 'codex', + displayName: 'Codex', models: { - defaultModel: 'gpt-4o', - claudeModel: 'gpt-4o', - opusModel: 'o1', - sonnetModel: 'gpt-4o', - haikuModel: 'gpt-4o-mini', + defaultModel: 'gpt-5.1-codex-max', + claudeModel: 'gpt-5.1-codex-max', + opusModel: 'gpt-5.1-codex-max-high', + sonnetModel: 'gpt-5.1-codex-max', + haikuModel: 'gpt-5.1-codex-mini-high', }, requiresOAuth: true, }, @@ -77,31 +77,57 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin } /** - * Get auth directory for CLIProxyAPI + * Get CLIProxy base directory + * All CLIProxy-related files are stored under ~/.ccs/cliproxy/ + */ +export function getCliproxyDir(): string { + return path.join(getCcsDir(), 'cliproxy'); +} + +/** + * Get auth directory for provider + * All providers use a FLAT auth directory structure for unified config. + * CLIProxyAPI stores OAuth tokens directly in auth/ (not subdirectories). + * This enables all providers to be discovered and used concurrently. + */ +export function getProviderAuthDir(_provider: CLIProxyProvider): string { + // Use flat structure - all auth files in same directory for unified discovery + // Provider param kept for API compatibility (CLIProxyAPI handles via auth file type field) + return path.join(getCliproxyDir(), 'auth'); +} + +/** + * Get base auth directory for CLIProxyAPI */ export function getAuthDir(): string { - return path.join(getCcsDir(), 'cliproxy-auth'); + return path.join(getCliproxyDir(), 'auth'); } /** * Get config file path */ export function getConfigPath(): string { - return path.join(getCcsDir(), 'cliproxy.config.yaml'); + return path.join(getCliproxyDir(), 'config.yaml'); } /** - * Generate config.yaml content for provider + * Get binary directory path */ -function generateConfigContent( - provider: CLIProxyProvider, - port: number = CLIPROXY_DEFAULT_PORT -): string { - const authDir = getAuthDir(); +export function getBinDir(): string { + return path.join(getCliproxyDir(), 'bin'); +} - // Base config (always present) - let config = `# CLIProxyAPI config generated by CCS -# Provider: ${provider} +/** + * Generate UNIFIED config.yaml content for ALL providers + * This enables concurrent usage of gemini/codex/qwen without config conflicts. + * CLIProxyAPI routes requests by model name to the appropriate provider. + */ +function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { + const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories + + // Unified config with all providers + const config = `# CLIProxyAPI unified config generated by CCS +# Supports: gemini, codex, qwen (concurrent usage) # Generated: ${new Date().toISOString()} port: ${port} @@ -113,60 +139,38 @@ usage-statistics-enabled: false api-keys: - "${CCS_INTERNAL_API_KEY}" -# OAuth tokens stored here +# OAuth tokens stored in subdirectories (gemini/, codex/, qwen/) +# CLIProxyAPI auto-discovers auth files in subdirectories auth-dir: "${authDir}" -`; - // Provider-specific config - switch (provider) { - case 'gemini': - config += ` -# Gemini configuration (OAuth-managed) -# API keys will be loaded from auth-dir after OAuth -gemini-api-key: [] +# All providers configured - routes by model name +# No provider-specific sections needed - OAuth auth files provide credentials `; - break; - - case 'chatgpt': - config += ` -# ChatGPT/Codex configuration (OAuth-managed) -# API keys will be loaded from auth-dir after OAuth -codex-api-key: [] -`; - break; - - case 'qwen': - config += ` -# Qwen configuration (API key required) -openai-compatibility: - - name: "qwen" - base-url: "https://dashscope.aliyuncs.com/compatible-mode/v1" - api-key-entries: [] -`; - break; - } return config; } /** - * Generate config.yaml file for provider - * @returns Path to generated config file + * Generate unified config.yaml file (supports all providers concurrently) + * Only regenerates if config doesn't exist. + * @returns Path to config file */ export function generateConfig( provider: CLIProxyProvider, port: number = CLIPROXY_DEFAULT_PORT ): string { const configPath = getConfigPath(); - const configContent = generateConfigContent(provider, port); - // Ensure directories exist - const authDir = getAuthDir(); + // Ensure provider auth directory exists + const authDir = getProviderAuthDir(provider); fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.mkdirSync(authDir, { recursive: true }); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); - // Write config with secure permissions (0600) - fs.writeFileSync(configPath, configContent, { mode: 0o600 }); + // Only generate config if it doesn't exist (unified config serves all providers) + if (!fs.existsSync(configPath)) { + const configContent = generateUnifiedConfigContent(port); + fs.writeFileSync(configPath, configContent, { mode: 0o600 }); + } return configPath; } @@ -190,6 +194,8 @@ export function deleteConfig(): void { /** * Get environment variables for Claude CLI + * Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing. + * This enables concurrent gemini/codex usage - each session routes to its provider via URL path. */ export function getClaudeEnvVars( provider: CLIProxyProvider, @@ -198,7 +204,8 @@ export function getClaudeEnvVars( const models = getModelMapping(provider); return { - ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, + // Provider-specific endpoint - routes to correct provider via URL path + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, ANTHROPIC_AUTH_TOKEN: CCS_INTERNAL_API_KEY, ANTHROPIC_MODEL: models.claudeModel, ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 7531e49e..260b445b 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -28,6 +28,7 @@ export { getDownloadUrl, getChecksumsUrl, getExecutableName, + getArchiveBinaryName, isPlatformSupported, getPlatformDescription, CLIPROXY_VERSION, @@ -47,8 +48,11 @@ export { getClaudeEnvVars, getProviderConfig, getModelMapping, + getCliproxyDir, + getProviderAuthDir, getAuthDir, getConfigPath, + getBinDir, configExists, deleteConfig, PROVIDER_CONFIGS, diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 197efe65..cfafa280 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -62,10 +62,20 @@ export function detectPlatform(): PlatformInfo { /** * Get executable name based on platform * @returns Binary executable name (with .exe on Windows) + * Note: The actual binary inside the archive is named 'cli-proxy-api' */ export function getExecutableName(): string { const platform = detectPlatform(); - return platform.os === 'windows' ? 'CLIProxyAPI.exe' : 'CLIProxyAPI'; + return platform.os === 'windows' ? 'cli-proxy-api.exe' : 'cli-proxy-api'; +} + +/** + * Get the name of the binary inside the archive + * @returns Binary name as it appears in the tar.gz/zip + */ +export function getArchiveBinaryName(): string { + const platform = detectPlatform(); + return platform.os === 'windows' ? 'cli-proxy-api.exe' : 'cli-proxy-api'; } /** diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 3bd16abf..cf7ccc87 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -108,7 +108,7 @@ export interface DownloadResult { /** * Supported CLIProxy providers */ -export type CLIProxyProvider = 'gemini' | 'chatgpt' | 'qwen'; +export type CLIProxyProvider = 'gemini' | 'codex' | 'qwen'; /** * CLIProxy config.yaml structure (minimal) diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 4c0d087b..b514c032 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -28,7 +28,7 @@ export function handleHelpCommand(): void { console.log(colored('Model Switching:', 'cyan')); console.log(` ${colored('ccs', 'yellow')} Use default Claude account`); console.log( - ` ${colored('ccs chatgpt', 'yellow')} ChatGPT via OAuth (zero config)` + ` ${colored('ccs codex', 'yellow')} Codex via OAuth (zero config)` ); console.log( ` ${colored('ccs gemini', 'yellow')} Gemini via OAuth (zero config)` diff --git a/src/management/doctor.ts b/src/management/doctor.ts index e353c7fd..944f77fb 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -14,6 +14,7 @@ import { getCLIProxyPath, isPortAvailable, getAllAuthStatus, + getConfigPath, CLIPROXY_VERSION, CLIPROXY_DEFAULT_PORT, } from '../cliproxy'; @@ -783,15 +784,15 @@ class Doctor { // 2. Config file exists? const configSpinner = ora('Checking CLIProxy config').start(); - const configPath = path.join(this.ccsDir, 'cliproxy.config.yaml'); + const configPath = getConfigPath(); if (fs.existsSync(configPath)) { configSpinner.succeed( - ` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy.config.yaml` + ` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy/config.yaml` ); this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { status: 'OK', - info: 'cliproxy.config.yaml', + info: 'cliproxy/config.yaml', }); } else { configSpinner.info(