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-minimax.settings.json b/config/base-minimax.settings.json new file mode 100644 index 00000000..c3e9452d --- /dev/null +++ b/config/base-minimax.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/src/ccs.ts b/src/ccs.ts index fc1d1d83..b3745b8c 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 === 'minimax') { + 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 minimax "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..c36f28fe 100644 --- a/src/utils/api-key-validator.ts +++ b/src/utils/api-key-validator.ts @@ -128,3 +128,107 @@ export async function validateGlmKey( req.end(); }); } + +/** + * Validate MiniMax API key with quick health check + * + * @param apiKey - The ANTHROPIC_AUTH_TOKEN value + * @param baseUrl - Optional base URL (defaults to MiniMax) + * @param timeoutMs - Timeout in milliseconds (default 2000) + */ +export async function validateMiniMaxKey( + apiKey: string, + 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/minimax.settings.json\n' + + 'Or run: ccs config -> API Profiles -> MiniMax', + }; + } + + // Determine validation endpoint + // MiniMax uses /anthropic path, we can test with a minimal request + const targetBase = baseUrl || 'https://api.minimax.io'; + let url: URL; + try { + url = new URL('/anthropic/v1/models', targetBase); + } catch { + // Invalid URL - fail-open + return { valid: true }; + } + + return new Promise((resolve) => { + // Determine protocol - use http module for http:// URLs + const isHttps = url.protocol === 'https:'; + const httpModule = isHttps ? https : http; + const defaultPort = isHttps ? 443 : 80; + + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || defaultPort, + path: url.pathname, + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'User-Agent': 'CCS-Preflight/1.0', + }, + }; + + const req = httpModule.request(options, (res) => { + clearTimeout(timeoutId); + + if (res.statusCode === 200) { + resolve({ valid: true }); + } else if (res.statusCode === 401 || res.statusCode === 403) { + resolve({ + valid: false, + error: 'API key rejected by MiniMax', + suggestion: + 'Your key may have expired. To fix:\n' + + ' 1. Go to platform.minimax.io and regenerate your API key\n' + + ' 2. Update ~/.ccs/minimax.settings.json with new key\n' + + ' 3. Or run: ccs config -> API Profiles -> MiniMax', + }); + } 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 }); + } + + // Consume response body to free resources + res.resume(); + }); + + req.on('error', () => { + clearTimeout(timeoutId); + // Network error - fail-open + resolve({ 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 }); + }, timeoutMs); + + req.end(); + }); +} 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/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
;