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.
This commit is contained in:
kaitranntt committed 2025-11-28 19:41:28 -05:00
1 parent 5f71eba6f6
commit efb966c96d
13 files changed
+303 -160

No files matched your search

+4 -4
View File
@@ -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`
+10
View File
@@ -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"
}
}
+10
View File
@@ -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"
}
}
+1 -1
View File
@@ -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 {
+136 -71
View File
@@ -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/<provider>/
* Token storage: ~/.ccs/cliproxy/auth/<provider>/
* 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<CLIProxyProvider, ProviderOAuthConfig> = {
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<boolean> {
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<boolean>((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);
});
+15 -4
View File
@@ -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);
+36 -11
View File
@@ -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,
+70 -63
View File
@@ -24,23 +24,23 @@ export const PROVIDER_CONFIGS: Record<CLIProxyProvider, ProviderConfig> = {
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,
+4
View File
@@ -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,
+11 -1
View File
@@ -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';
}
/**
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)`
+4 -3
View File
@@ -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(