diff --git a/.releaserc.json b/.releaserc.json index 7c3d52d7..29c4836c 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -18,8 +18,9 @@ "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" }], ["@semantic-release/github", { - "successComment": false, - "failComment": false + "successComment": ":tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@${nextRelease.channel || 'latest'})](https://www.npmjs.com/package/ccs-claude-code-switcher)\n- [GitHub release](${releases.filter(r => r.name === 'GitHub release').map(r => r.url)[0] || url})", + "failComment": false, + "releasedLabels": ["released"] }] ] } diff --git a/config/base-qwen.settings.json b/config/base-qwen.settings.json new file mode 100644 index 00000000..03b3e36c --- /dev/null +++ b/config/base-qwen.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qwen", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "qwen3-coder", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3-coder", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-coder" + } +} diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 70880391..2886090d 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', 'codex', 'agy'] as const; +export const CLIPROXY_PROFILES = ['gemini', 'codex', 'agy', 'qwen'] as const; export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number]; export interface ProfileDetectionResult { @@ -23,7 +23,7 @@ export interface ProfileDetectionResult { settingsPath?: string; profile?: Settings | ProfileMetadata; message?: string; - /** For cliproxy variants: the underlying provider (gemini, codex, agy) */ + /** For cliproxy variants: the underlying provider (gemini, codex, agy, qwen) */ provider?: CLIProxyProfileName; } @@ -89,7 +89,7 @@ class ProfileDetector { * Detect profile type and return routing information * * Priority order: - * 0. Hardcoded CLIProxy profiles (gemini, codex, agy) + * 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen) * 1. User-defined CLIProxy variants (config.cliproxy section) * 2. Settings-based profiles (config.profiles section) * 3. Account-based profiles (profiles.json) @@ -100,7 +100,7 @@ class ProfileDetector { return this.resolveDefaultProfile(); } - // Priority 0: Check CLIProxy profiles (gemini, codex, agy) - OAuth-based, zero config + // Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config if (CLIPROXY_PROFILES.includes(profileName as CLIProxyProfileName)) { return { type: 'cliproxy', diff --git a/src/ccs.ts b/src/ccs.ts index 231e5741..54c63046 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -295,7 +295,7 @@ async function main(): Promise { const profileInfo = detector.detectProfileType(profile); if (profileInfo.type === 'cliproxy') { - // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy) or user-defined variants + // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath }); diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 29de6b71..22cbabe6 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -100,6 +100,13 @@ const OAUTH_CONFIGS: Record = { scopes: ['api'], authFlag: '--antigravity-login', }, + qwen: { + provider: 'qwen', + displayName: 'Qwen Code', + authUrl: 'https://chat.qwen.ai/api/v1/oauth2/device/code', + scopes: ['openid', 'profile', 'email', 'model.completion'], + authFlag: '--qwen-login', + }, }; /** @@ -129,6 +136,7 @@ const PROVIDER_AUTH_PREFIXES: Record = { gemini: ['gemini-', 'google-'], codex: ['codex-', 'openai-'], agy: ['antigravity-', 'agy-'], + qwen: ['qwen-'], }; /** @@ -139,6 +147,7 @@ const PROVIDER_TYPE_VALUES: Record = { gemini: ['gemini'], codex: ['codex'], agy: ['antigravity'], + qwen: ['qwen'], }; /** @@ -256,7 +265,7 @@ export function getAuthStatus(provider: CLIProxyProvider): AuthStatus { * Get auth status for all providers */ export function getAllAuthStatus(): AuthStatus[] { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy']; + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen']; return providers.map(getAuthStatus); } diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 7366d63a..9f685314 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -6,6 +6,7 @@ * - Verifies SHA256 checksum * - Extracts and caches binary locally * - Supports retry logic with exponential backoff + * - Auto-checks for updates on startup (fetches latest from GitHub API) * * Pattern: Mirrors npm install behavior (fast check, download only when needed) */ @@ -17,7 +18,7 @@ import * as http from 'http'; import * as crypto from 'crypto'; import * as zlib from 'zlib'; import { ProgressIndicator } from '../utils/progress-indicator'; -import { getBinDir } from './config-generator'; +import { getBinDir, getCliproxyDir } from './config-generator'; import { BinaryInfo, BinaryManagerConfig, @@ -31,12 +32,33 @@ import { getChecksumsUrl, getExecutableName, getArchiveBinaryName, - CLIPROXY_VERSION, + CLIPROXY_FALLBACK_VERSION, } from './platform-detector'; +/** Cache duration for version check (1 hour in milliseconds) */ +const VERSION_CACHE_DURATION_MS = 60 * 60 * 1000; + +/** GitHub API URL for latest release */ +const GITHUB_API_LATEST_RELEASE = + 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest'; + +/** Version cache file structure */ +interface VersionCache { + latestVersion: string; + checkedAt: number; +} + +/** Update check result */ +interface UpdateCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + fromCache: boolean; +} + /** Default configuration */ const DEFAULT_CONFIG: BinaryManagerConfig = { - version: CLIPROXY_VERSION, + version: CLIPROXY_FALLBACK_VERSION, releaseUrl: 'https://github.com/router-for-me/CLIProxyAPI/releases/download', binPath: getBinDir(), maxRetries: 3, @@ -56,7 +78,7 @@ export class BinaryManager { } /** - * Ensure binary is available (download if missing) + * Ensure binary is available (download if missing, update if outdated) * @returns Path to executable binary */ async ensureBinary(): Promise { @@ -65,16 +87,241 @@ export class BinaryManager { // Check if binary already exists if (fs.existsSync(binaryPath)) { this.log(`Binary exists: ${binaryPath}`); - return binaryPath; + + // Check for updates in background (non-blocking for UX) + try { + const updateResult = await this.checkForUpdates(); + if (updateResult.hasUpdate) { + console.log( + `[i] CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ); + console.log(`[i] Updating CLIProxyAPI...`); + + // Delete old binary and download new version + this.deleteBinary(); + this.config.version = updateResult.latestVersion; + await this.downloadAndInstall(); + } + } catch (error) { + // Silent fail - don't block startup if update check fails + const err = error as Error; + this.log(`Update check failed (non-blocking): ${err.message}`); + } + + return this.getBinaryPath(); } // Download, verify, extract this.log('Binary not found, downloading...'); + + // Check latest version before first download + try { + const latestVersion = await this.fetchLatestVersion(); + if (latestVersion && this.isNewerVersion(latestVersion, this.config.version)) { + this.log(`Using latest version: ${latestVersion} (instead of ${this.config.version})`); + this.config.version = latestVersion; + } + } catch { + // Use pinned version if API fails + this.log(`Using pinned version: ${this.config.version}`); + } + await this.downloadAndInstall(); return binaryPath; } + /** + * Check for updates by comparing installed version with latest release + * Uses cache to avoid hitting GitHub API on every run + */ + async checkForUpdates(): Promise { + const currentVersion = this.getInstalledVersion(); + + // Try cache first + const cachedVersion = this.getCachedLatestVersion(); + if (cachedVersion) { + this.log(`Using cached version: ${cachedVersion}`); + return { + hasUpdate: this.isNewerVersion(cachedVersion, currentVersion), + currentVersion, + latestVersion: cachedVersion, + fromCache: true, + }; + } + + // Fetch from GitHub API + const latestVersion = await this.fetchLatestVersion(); + this.cacheLatestVersion(latestVersion); + + return { + hasUpdate: this.isNewerVersion(latestVersion, currentVersion), + currentVersion, + latestVersion, + fromCache: false, + }; + } + + /** + * Fetch latest version from GitHub API + */ + private async fetchLatestVersion(): Promise { + const response = await this.fetchJson(GITHUB_API_LATEST_RELEASE); + + // Extract version from tag_name (format: "v6.5.27" or "6.5.27") + const tagName = response.tag_name as string; + if (!tagName) { + throw new Error('No tag_name in GitHub API response'); + } + + return tagName.replace(/^v/, ''); + } + + /** + * Fetch JSON from URL (for GitHub API) + */ + private fetchJson(url: string): Promise> { + return new Promise((resolve, reject) => { + const options = { + headers: { + 'User-Agent': 'CCS-CLIProxyAPI-Updater/1.0', + Accept: 'application/vnd.github.v3+json', + }, + }; + + const handleResponse = (res: http.IncomingMessage) => { + if (res.statusCode === 301 || res.statusCode === 302) { + const redirectUrl = res.headers.location; + if (!redirectUrl) { + reject(new Error('Redirect without location header')); + return; + } + this.fetchJson(redirectUrl).then(resolve).catch(reject); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`GitHub API error: HTTP ${res.statusCode}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error('Invalid JSON from GitHub API')); + } + }); + res.on('error', reject); + }; + + const req = https.get(url, options, handleResponse); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('GitHub API timeout (10s)')); + }); + }); + } + + /** + * Get installed version from version file or fallback to pinned + */ + private getInstalledVersion(): string { + const versionFile = path.join(this.config.binPath, '.version'); + if (fs.existsSync(versionFile)) { + try { + return fs.readFileSync(versionFile, 'utf8').trim(); + } catch { + return this.config.version; + } + } + return this.config.version; + } + + /** + * Save installed version to file + */ + private saveInstalledVersion(version: string): void { + const versionFile = path.join(this.config.binPath, '.version'); + try { + fs.writeFileSync(versionFile, version, 'utf8'); + } catch { + // Silent fail - not critical + } + } + + /** + * Get cached latest version if still valid + */ + private getCachedLatestVersion(): string | null { + const cachePath = this.getVersionCachePath(); + if (!fs.existsSync(cachePath)) { + return null; + } + + try { + const content = fs.readFileSync(cachePath, 'utf8'); + const cache: VersionCache = JSON.parse(content); + + // Check if cache is still valid + if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { + return cache.latestVersion; + } + + // Cache expired + return null; + } catch { + return null; + } + } + + /** + * Cache latest version for future checks + */ + private cacheLatestVersion(version: string): void { + const cachePath = this.getVersionCachePath(); + const cache: VersionCache = { + latestVersion: version, + checkedAt: Date.now(), + }; + + try { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); + } catch { + // Silent fail - caching is optional + } + } + + /** + * Get path to version cache file + */ + private getVersionCachePath(): string { + return path.join(getCliproxyDir(), '.version-cache.json'); + } + + /** + * Compare semver versions (true if latest > current) + */ + private isNewerVersion(latest: string, current: string): boolean { + const latestParts = latest.split('.').map((p) => parseInt(p, 10) || 0); + const currentParts = current.split('.').map((p) => parseInt(p, 10) || 0); + + // Pad arrays to same length + while (latestParts.length < 3) latestParts.push(0); + while (currentParts.length < 3) currentParts.push(0); + + for (let i = 0; i < 3; i++) { + if (latestParts[i] > currentParts[i]) return true; + if (latestParts[i] < currentParts[i]) return false; + } + + return false; // Equal versions + } + /** * Get full path to binary executable */ @@ -114,7 +361,7 @@ export class BinaryManager { * Download and install binary */ private async downloadAndInstall(): Promise { - const platform = detectPlatform(); + const platform = detectPlatform(this.config.version); const downloadUrl = getDownloadUrl(this.config.version); const checksumsUrl = getChecksumsUrl(this.config.version); @@ -178,6 +425,9 @@ export class BinaryManager { this.log(`Set executable permissions: ${binaryPath}`); } + // Save installed version for future update checks + this.saveInstalledVersion(this.config.version); + console.log(`[OK] CLIProxyAPI v${this.config.version} installed successfully`); } catch (error) { spinner.fail('Installation failed'); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index d95b839a..695adace 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -80,7 +80,7 @@ async function waitForProxyReady( * Execute Claude CLI with CLIProxy (main entry point) * * @param claudeCli Path to Claude CLI executable - * @param provider CLIProxy provider (gemini, codex, agy) + * @param provider CLIProxy provider (gemini, codex, agy, qwen) * @param args Arguments to pass to Claude CLI * @param config Optional executor configuration */ diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index e7e4ed64..11354a1e 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -30,6 +30,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { gemini: 'Gemini', codex: 'Codex', agy: 'Antigravity', + qwen: 'Qwen Code', }; /** @@ -112,7 +113,7 @@ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): str // Unified config with all providers const config = `# CLIProxyAPI unified config generated by CCS -# Supports: gemini, codex, agy (concurrent usage) +# Supports: gemini, codex, agy, qwen (concurrent usage) # Generated: ${new Date().toISOString()} port: ${port} diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index cfafa280..7a5d87ef 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -7,8 +7,14 @@ import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './types'; -/** CLIProxyAPI version pinned for stability */ -export const CLIPROXY_VERSION = '6.5.27'; +/** + * CLIProxyAPI fallback version (used when GitHub API unavailable) + * Auto-update fetches latest from GitHub; this is only a safety net + */ +export const CLIPROXY_FALLBACK_VERSION = '6.5.27'; + +/** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */ +export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION; /** * Platform mapping from Node.js values to CLIProxyAPI naming @@ -26,9 +32,10 @@ const ARCH_MAP: Record = { /** * Detect current platform and return binary info + * @param version Optional version for binaryName (defaults to fallback) * @throws Error if platform is unsupported */ -export function detectPlatform(): PlatformInfo { +export function detectPlatform(version: string = CLIPROXY_FALLBACK_VERSION): PlatformInfo { const nodePlatform = process.platform; const nodeArch = process.arch; @@ -49,7 +56,7 @@ export function detectPlatform(): PlatformInfo { } const extension: ArchiveExtension = os === 'windows' ? 'zip' : 'tar.gz'; - const binaryName = `CLIProxyAPI_${CLIPROXY_VERSION}_${os}_${arch}.${extension}`; + const binaryName = `CLIProxyAPI_${version}_${os}_${arch}.${extension}`; return { os, @@ -80,21 +87,21 @@ export function getArchiveBinaryName(): string { /** * Get download URL for current platform - * @param version Optional version override (defaults to CLIPROXY_VERSION) + * @param version Version to download (defaults to fallback version) * @returns Full GitHub release download URL */ -export function getDownloadUrl(version: string = CLIPROXY_VERSION): string { - const platform = detectPlatform(); +export function getDownloadUrl(version: string = CLIPROXY_FALLBACK_VERSION): string { + const platform = detectPlatform(version); const baseUrl = `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${version}`; return `${baseUrl}/${platform.binaryName}`; } /** * Get checksums.txt URL for version - * @param version Optional version override + * @param version Version to get checksums for (defaults to fallback version) * @returns Full URL to checksums.txt */ -export function getChecksumsUrl(version: string = CLIPROXY_VERSION): string { +export function getChecksumsUrl(version: string = CLIPROXY_FALLBACK_VERSION): string { return `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${version}/checksums.txt`; } diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index d83abf2f..86c20be7 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -110,8 +110,9 @@ export interface DownloadResult { * - gemini: Google Gemini via OAuth * - codex: OpenAI Codex via OAuth * - agy: Antigravity via OAuth (short name for easy usage) + * - qwen: Qwen Code via OAuth (qwen3-coder) */ -export type CLIProxyProvider = 'gemini' | 'codex' | 'agy'; +export type CLIProxyProvider = 'gemini' | 'codex' | 'agy' | 'qwen'; /** * CLIProxy config.yaml structure (minimal) diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 3cecbd09..07b406d5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -54,6 +54,7 @@ export function handleHelpCommand(): void { console.log( ` ${colored('ccs agy', 'yellow')} Antigravity (gemini-3-pro-preview)` ); + console.log(` ${colored('ccs qwen', 'yellow')} Qwen Code (qwen3-coder)`); console.log(''); console.log(` ${colored('ccs --auth', 'yellow')} Authenticate only`); console.log(` ${colored('ccs --logout', 'yellow')} Clear authentication`); diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index b9083b57..2062e3d1 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -208,12 +208,25 @@ export class GlmtProxy { const duration = Date.now() - startTime; this.log(`Request failed after ${duration}ms: ${err.message}`); - res.writeHead(500, { 'Content-Type': 'application/json' }); + // Parse error to provide clearer messages + const errorInfo = this.parseUpstreamError(err); + + // Check if headers already sent (streaming mode may have started) + if (res.headersSent) { + // Headers already sent, write error as SSE event and close + this.log('Headers already sent, writing error as SSE event'); + res.write(`event: error\n`); + res.write(`data: ${JSON.stringify({ error: errorInfo })}\n\n`); + res.end(); + return; + } + + res.writeHead(errorInfo.statusCode, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ error: { - type: 'proxy_error', - message: err.message, + type: errorInfo.type, + message: errorInfo.message, }, }) ); @@ -526,6 +539,80 @@ export class GlmtProxy { console.error(`[glmt-proxy] ${message}`); } } + + /** + * Parse upstream error and return user-friendly error info + */ + private parseUpstreamError(err: Error): { + statusCode: number; + type: string; + message: string; + } { + const errorMessage = err.message; + + // Check for 401 Unauthorized / auth token missing + if ( + errorMessage.includes('401') || + errorMessage.toLowerCase().includes('unauthorized') || + errorMessage.toLowerCase().includes('authorization token missing') || + errorMessage.toLowerCase().includes('invalid token') + ) { + return { + statusCode: 401, + type: 'authentication_error', + message: + 'Authorization token missing or invalid. Please check your ANTHROPIC_AUTH_TOKEN environment variable in ~/.ccs/config.json or settings.json.', + }; + } + + // Check for 403 Forbidden + if (errorMessage.includes('403') || errorMessage.toLowerCase().includes('forbidden')) { + return { + statusCode: 403, + type: 'permission_error', + message: 'Access forbidden. Your token may not have permission to access this resource.', + }; + } + + // Check for 429 Rate limit + if (errorMessage.includes('429') || errorMessage.toLowerCase().includes('rate limit')) { + return { + statusCode: 429, + type: 'rate_limit_error', + message: 'Rate limit exceeded. Please wait before making more requests.', + }; + } + + // Check for timeout + if (errorMessage.toLowerCase().includes('timeout')) { + return { + statusCode: 504, + type: 'timeout_error', + message: 'Request timed out. The upstream server took too long to respond.', + }; + } + + // Check for connection errors + if ( + errorMessage.toLowerCase().includes('econnrefused') || + errorMessage.toLowerCase().includes('enotfound') || + errorMessage.toLowerCase().includes('connection') + ) { + return { + statusCode: 502, + type: 'connection_error', + message: + 'Failed to connect to upstream server. Please check your network and ANTHROPIC_BASE_URL configuration.', + }; + } + + // Default: proxy error + return { + statusCode: 500, + type: 'proxy_error', + message: errorMessage, + }; + } } // Main entry point diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 34d878b0..17bb0366 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -754,7 +754,7 @@ class Doctor { } /** - * Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy) + * Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy, qwen) */ private async checkCLIProxy(): Promise { // 1. Binary installed? diff --git a/src/types/config.ts b/src/types/config.ts index 5b0689c6..decad645 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -17,8 +17,8 @@ export interface ProfilesConfig { * Example: "flash" → gemini provider with gemini-2.5-flash model */ export interface CLIProxyVariantConfig { - /** CLIProxy provider to use (gemini, codex, agy) */ - provider: 'gemini' | 'codex' | 'agy'; + /** CLIProxy provider to use (gemini, codex, agy, qwen) */ + provider: 'gemini' | 'codex' | 'agy' | 'qwen'; /** Path to settings.json with custom model configuration */ settings: string; }