diff --git a/bun.lock b/bun.lock index a697e9ab..1840e6c3 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@kaitranntt/ccs", diff --git a/config/base-mm.settings.json b/config/base-mm.settings.json new file mode 100644 index 00000000..c3e9452d --- /dev/null +++ b/config/base-mm.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.minimax.io/anthropic", + "ANTHROPIC_AUTH_TOKEN": "YOUR_MINIMAX_API_KEY_HERE", + "ANTHROPIC_MODEL": "MiniMax-M2.1", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2.1", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2.1", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2.1-lightning" + } +} diff --git a/package.json b/package.json index a6a04b10..423cc4a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.12.2", + "version": "7.12.2-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index 85964f0e..82223565 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -99,13 +99,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ category: 'alternative', }, { - id: 'minimax', + id: 'mm', name: 'Minimax', description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)', baseUrl: 'https://api.minimax.io/anthropic', - defaultProfileName: 'minimax', + defaultProfileName: 'mm', defaultModel: 'MiniMax-M2.1', - apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY', + apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE', apiKeyHint: 'Get your API key at platform.minimax.io', category: 'alternative', }, diff --git a/src/ccs.ts b/src/ccs.ts index fc1d1d83..bcfb2cc9 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath, loadSettings } from './utils/config-manager'; -import { validateGlmKey } from './utils/api-key-validator'; +import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; import { @@ -535,7 +535,7 @@ async function main(): Promise { // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); - // Pre-flight validation for GLM/GLMT profiles + // Pre-flight validation for GLM/GLMT/MiniMax profiles if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') { const preflightSettingsPath = getSettingsPath(profileInfo.name); const preflightSettings = loadSettings(preflightSettingsPath); @@ -561,6 +561,31 @@ async function main(): Promise { } } + if (profileInfo.name === 'mm') { + const preflightSettingsPath = getSettingsPath(profileInfo.name); + const preflightSettings = loadSettings(preflightSettingsPath); + const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN']; + + if (apiKey) { + const validation = await validateMiniMaxKey( + apiKey, + preflightSettings.env?.['ANTHROPIC_BASE_URL'] + ); + + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')); + process.exit(1); + } + } + } + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/utils/api-key-validator.ts b/src/utils/api-key-validator.ts index cb732566..cc67c8ac 100644 --- a/src/utils/api-key-validator.ts +++ b/src/utils/api-key-validator.ts @@ -19,53 +19,58 @@ export interface ValidationResult { const DEFAULT_PLACEHOLDERS = [ 'YOUR_GLM_API_KEY_HERE', 'YOUR_KIMI_API_KEY_HERE', + 'YOUR_MINIMAX_API_KEY_HERE', 'YOUR_API_KEY_HERE', 'YOUR-API-KEY-HERE', 'PLACEHOLDER', '', ]; -/** - * Validate GLM API key with quick health check - * - * @param apiKey - The ANTHROPIC_AUTH_TOKEN value - * @param baseUrl - Optional base URL (defaults to Z.AI) - * @param timeoutMs - Timeout in milliseconds (default 2000) - */ -export async function validateGlmKey( +interface ProviderConfig { + name: string; + profile: string; + defaultBaseUrl: string; + path: string; + displayName: string; + dashboardUrl: string; +} + +async function validateProviderKey( apiKey: string, + config: ProviderConfig, baseUrl?: string, timeoutMs = 2000 ): Promise { - // Skip if disabled if (process.env.CCS_SKIP_PREFLIGHT === '1') { return { valid: true }; } - // Basic format check - detect placeholders if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) { return { valid: false, error: 'API key not configured', suggestion: - 'Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/glm.settings.json\n' + - 'Or run: ccs config -> API Profiles -> GLM', + `Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/${config.profile}.settings.json\n` + + `Or run: ccs config -> API Profiles -> ${config.name}`, }; } - // Determine validation endpoint - // Z.AI uses /api/anthropic path, we can test with a minimal request - const targetBase = baseUrl || 'https://api.z.ai'; + const targetBase = baseUrl || config.defaultBaseUrl; let url: URL; try { - url = new URL('/api/anthropic/v1/models', targetBase); + url = new URL(config.path, targetBase); } catch { - // Invalid URL - fail-open return { valid: true }; } return new Promise((resolve) => { - // Determine protocol - use http module for http:// URLs + let resolved = false; + const safeResolve = (result: ValidationResult) => { + if (resolved) return; + resolved = true; + resolve(result); + }; + const isHttps = url.protocol === 'https:'; const httpModule = isHttps ? https : http; const defaultPort = isHttps ? 443 : 80; @@ -85,46 +90,79 @@ export async function validateGlmKey( clearTimeout(timeoutId); if (res.statusCode === 200) { - resolve({ valid: true }); + safeResolve({ valid: true }); } else if (res.statusCode === 401 || res.statusCode === 403) { - resolve({ + safeResolve({ valid: false, - error: 'API key rejected by Z.AI', + error: `API key rejected by ${config.displayName}`, suggestion: - 'Your key may have expired. To fix:\n' + - ' 1. Go to Z.AI dashboard and regenerate your API key\n' + - ' 2. Update ~/.ccs/glm.settings.json with the new key\n' + - ' 3. Or run: ccs config -> API Profiles -> GLM', + `Your key may have expired. To fix:\n` + + ` 1. Go to ${config.dashboardUrl} and regenerate your API key\n` + + ` 2. Update ~/.ccs/${config.profile}.settings.json with new key\n` + + ` 3. Or run: ccs config -> API Profiles -> ${config.name}`, }); } else { - // Other errors (404, 500, etc.) - fail-open, let Claude CLI handle - // Debug log for diagnostics when CCS_DEBUG is set if (process.env.CCS_DEBUG === '1') { console.error( `[CCS-Preflight] Unexpected status ${res.statusCode} from ${url.href} - fail-open` ); } - resolve({ valid: true }); + safeResolve({ valid: true }); } - // Consume response body to free resources res.resume(); }); req.on('error', () => { clearTimeout(timeoutId); - // Network error - fail-open - resolve({ valid: true }); + safeResolve({ valid: true }); }); - // Set timeout after request is created so we can destroy it on timeout const timeoutId = setTimeout(() => { - // Abort request to prevent TCP connection leak req.destroy(); - // Fail-open on timeout - let Claude CLI handle it - resolve({ valid: true }); + safeResolve({ valid: true }); }, timeoutMs); req.end(); }); } + +export async function validateGlmKey( + apiKey: string, + baseUrl?: string, + timeoutMs?: number +): Promise { + return validateProviderKey( + apiKey, + { + name: 'GLM', + profile: 'glm', + defaultBaseUrl: 'https://api.z.ai', + path: '/api/anthropic/v1/models', + displayName: 'Z.AI', + dashboardUrl: 'Z.AI dashboard', + }, + baseUrl, + timeoutMs + ); +} + +export async function validateMiniMaxKey( + apiKey: string, + baseUrl?: string, + timeoutMs?: number +): Promise { + return validateProviderKey( + apiKey, + { + name: 'MiniMax', + profile: 'mm', + defaultBaseUrl: 'https://api.minimax.io', + path: '/anthropic/v1/models', + displayName: 'MiniMax', + dashboardUrl: 'platform.minimax.io', + }, + baseUrl, + timeoutMs + ); +} diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 0fa87466..65595a1a 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -537,6 +537,28 @@ const PRICING_REGISTRY: Record = { cacheReadPerMillion: 0.0, }, + // --------------------------------------------------------------------------- + // MiniMax Models - Source: https://platform.minimax.io/docs/pricing/pay-as-you-go + // --------------------------------------------------------------------------- + 'MiniMax-M2.1': { + inputPerMillion: 0.3, + outputPerMillion: 1.2, + cacheCreationPerMillion: 0.375, + cacheReadPerMillion: 0.03, + }, + 'MiniMax-M2.1-lightning': { + inputPerMillion: 0.3, + outputPerMillion: 2.4, + cacheCreationPerMillion: 0.375, + cacheReadPerMillion: 0.03, + }, + 'MiniMax-M2': { + inputPerMillion: 0.3, + outputPerMillion: 1.2, + cacheCreationPerMillion: 0.375, + cacheReadPerMillion: 0.03, + }, + // --------------------------------------------------------------------------- // DeepSeek Models - Source: better-ccusage // --------------------------------------------------------------------------- diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 686c604f..841855b2 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -8,10 +8,29 @@ import { Router, Request, Response } from 'express'; import ProfileRegistry from '../../auth/profile-registry'; import { isUnifiedMode } from '../../config/unified-config-loader'; +import { + getAllAccountsSummary, + setDefaultAccount as setCliproxyDefault, + removeAccount as removeCliproxyAccount, +} from '../../cliproxy/account-manager'; +import { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); const registry = new ProfileRegistry(); +/** Parse CLIProxy account key format: "provider:accountId" */ +function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const colonIndex = key.indexOf(':'); + if (colonIndex === -1) return null; + + const provider = key.slice(0, colonIndex) as CLIProxyProvider; + const accountId = key.slice(colonIndex + 1); + + if (!providers.includes(provider) || !accountId) return null; + return { provider, accountId }; +} + /** * GET /api/accounts - List accounts from both profiles.json and config.yaml */ @@ -21,8 +40,20 @@ router.get('/', (_req: Request, res: Response): void => { const legacyProfiles = registry.getAllProfiles(); const unifiedAccounts = registry.getAllAccountsUnified(); + // Get CLIProxy OAuth accounts (gemini, codex, agy, etc.) + const cliproxyAccounts = getAllAccountsSummary(); + // Merge profiles: unified config takes precedence - const merged: Record = {}; + const merged: Record< + string, + { + type: string; + created: string; + last_used: string | null; + provider?: string; + displayName?: string; + } + > = {}; // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { @@ -42,6 +73,26 @@ router.get('/', (_req: Request, res: Response): void => { }; } + // Add CLIProxy OAuth accounts + for (const [provider, accounts] of Object.entries(cliproxyAccounts)) { + for (const acct of accounts) { + // Skip accounts with no valid identifier + if (!acct.id) { + continue; + } + // Use unique ID for key to prevent collisions between accounts with same nickname/email + const displayName = acct.nickname || acct.email || acct.id; + const key = `${provider}:${acct.id}`; + merged[key] = { + type: 'cliproxy', + provider, + displayName, + created: acct.createdAt || new Date().toISOString(), + last_used: null, + }; + } + } + // Convert to array format const accounts = Object.entries(merged).map(([name, meta]) => ({ name, @@ -69,6 +120,18 @@ router.post('/default', (req: Request, res: Response): void => { return; } + // Check if this is a CLIProxy account (format: "provider:accountId") + const cliproxyKey = parseCliproxyKey(name); + if (cliproxyKey) { + const success = setCliproxyDefault(cliproxyKey.provider, cliproxyKey.accountId); + if (!success) { + res.status(404).json({ error: `CLIProxy account not found: ${name}` }); + return; + } + res.json({ default: name }); + return; + } + // Use unified config if in unified mode, otherwise use legacy if (isUnifiedMode()) { registry.setDefaultUnified(name); @@ -110,7 +173,7 @@ router.delete('/:name', (req: Request, res: Response): void => { return; } - // Check if trying to delete default + // Check if trying to delete default (for non-CLIProxy accounts) const currentDefault = registry.getDefaultUnified() ?? registry.getDefaultProfile(); if (name === currentDefault) { res @@ -119,7 +182,19 @@ router.delete('/:name', (req: Request, res: Response): void => { return; } - // Delete the profile + // Check if this is a CLIProxy account (format: "provider:accountId") + const cliproxyKey = parseCliproxyKey(name); + if (cliproxyKey) { + const success = removeCliproxyAccount(cliproxyKey.provider, cliproxyKey.accountId); + if (!success) { + res.status(404).json({ error: `CLIProxy account not found: ${name}` }); + return; + } + res.json({ success: true, deleted: name }); + return; + } + + // Delete the profile (legacy/unified) registry.deleteProfile(name); res.json({ success: true, deleted: name }); diff --git a/ui/bun.lock b/ui/bun.lock index 2756da98..c2e29de1 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ui", diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index 45811bfd..ab895f8e 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -23,8 +23,7 @@ const badgeVariants = cva( ); interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} + extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
; diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts index 638aca0b..591260cb 100644 --- a/ui/src/lib/provider-presets.ts +++ b/ui/src/lib/provider-presets.ts @@ -98,15 +98,15 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ category: 'alternative', }, { - id: 'minimax', + id: 'mm', name: 'Minimax', description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)', baseUrl: 'https://api.minimax.io/anthropic', - defaultProfileName: 'minimax', + defaultProfileName: 'mm', badge: '1M context', defaultModel: 'MiniMax-M2.1', requiresApiKey: true, - apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY', + apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE', apiKeyHint: 'Get your API key at platform.minimax.io', category: 'alternative', },