mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
Merge pull request #255 from kaitranntt/dev
feat(minimax): add MiniMax M2.1 support and fix accounts display
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@kaitranntt/ccs",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
+27
-2
@@ -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<void> {
|
||||
// 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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<ValidationResult> {
|
||||
// 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<ValidationResult> {
|
||||
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<ValidationResult> {
|
||||
return validateProviderKey(
|
||||
apiKey,
|
||||
{
|
||||
name: 'MiniMax',
|
||||
profile: 'mm',
|
||||
defaultBaseUrl: 'https://api.minimax.io',
|
||||
path: '/anthropic/v1/models',
|
||||
displayName: 'MiniMax',
|
||||
dashboardUrl: 'platform.minimax.io',
|
||||
},
|
||||
baseUrl,
|
||||
timeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -537,6 +537,28 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<string, { type: string; created: string; last_used: string | null }> = {};
|
||||
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 });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
|
||||
@@ -23,8 +23,7 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user