diff --git a/README.md b/README.md index 32dde7df..55389041 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ The dashboard provides visual management for all account types: | **Ollama** | Local | `ccs ollama` | Local open-source models, privacy | | **Ollama Cloud** | API Key | `ccs ollama-cloud` | Cloud-hosted open-source models | | **GLM** | API Key | `ccs glm` | Cost-optimized execution | -| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode | +| **KM (Kimi API)** | API Key | `ccs km` | Long-context, thinking mode | +| **Kimi (OAuth)** | OAuth | `ccs kimi` | Device-code OAuth via CLIProxy | | **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure | | **Minimax** | API Key | `ccs mm` | M2 series, 1M context | | **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning | @@ -139,6 +140,7 @@ ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) ccs ollama # Local Ollama (no API key needed) ccs glm # GLM (API key) +ccs km # Kimi API profile (API key) ``` ### Droid Alias (`argv[0]` pattern) diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index 7daedaa3..15daf7b0 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -26,6 +26,9 @@ export interface ProviderPreset { } export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; +const LEGACY_PRESET_ALIASES: Readonly> = Object.freeze({ + kimi: 'km', +}); /** * Provider presets available via CLI and UI @@ -169,7 +172,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ /** Get preset by ID */ export function getPresetById(id: string): ProviderPreset | undefined { - return PROVIDER_PRESETS.find((p) => p.id === id.toLowerCase()); + const normalized = id.toLowerCase(); + const canonical = LEGACY_PRESET_ALIASES[normalized] || normalized; + return PROVIDER_PRESETS.find((p) => p.id === canonical); } /** Get all preset IDs */ diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index f700b030..c86668ca 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -2,7 +2,7 @@ * Profile Detector * * Determines profile type (settings-based vs account-based) for routing. - * Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility. + * Priority: settings-based profiles (glm/km) checked FIRST for backward compatibility. * * Supports dual-mode configuration: * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists @@ -22,6 +22,7 @@ import { } from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir } from '../utils/config-manager'; +import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; import type { TargetType } from '../targets/target-adapter'; @@ -166,16 +167,26 @@ class ProfileDetector { }; } - // Check API profiles - if (config.profiles?.[profileName]) { - const profile = config.profiles[profileName]; - // Load env from settings file - const settingsEnv = loadSettingsFromFile(profile.settings); + // Check API profiles (supports compatibility aliases, e.g. km -> kimi) + for (const candidate of getProfileLookupCandidates(profileName)) { + if (!config.profiles?.[candidate]) { + continue; + } + + const profile = config.profiles[candidate]; + const settingsPath = profile.settings; + const settingsEnv = loadSettingsFromFile(settingsPath); + const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate); + return { type: 'settings', name: profileName, target: profile.target, + settingsPath, env: settingsEnv, + message: viaLegacyAlias + ? `Using legacy API profile "${candidate}" for "${profileName}".` + : undefined, }; } @@ -308,13 +319,23 @@ class ProfileDetector { }; } - // Priority 3: Check settings-based profiles (glm) - LEGACY FALLBACK - if (config.profiles && config.profiles[profileName]) { - return { - type: 'settings', - name: profileName, - settingsPath: config.profiles[profileName], - }; + // Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK + if (config.profiles) { + for (const candidate of getProfileLookupCandidates(profileName)) { + if (!config.profiles[candidate]) { + continue; + } + + const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate); + return { + type: 'settings', + name: profileName, + settingsPath: config.profiles[candidate], + message: viaLegacyAlias + ? `Using legacy API profile "${candidate}" for "${profileName}".` + : undefined, + }; + } } // Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK diff --git a/src/ccs.ts b/src/ccs.ts index 32e0302c..6706c057 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -9,6 +9,7 @@ import { setGlobalConfigDir, detectCloudSyncPath, } from './utils/config-manager'; +import { expandPath } from './utils/helpers'; import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; @@ -689,7 +690,7 @@ async function main(): Promise { if (profileInfo.type === 'settings' && profileInfo.name === 'glmt') { console.error(fail(`${targetAdapter.displayName} does not support GLMT proxy profiles`)); console.error( - info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)') + info('Use --target claude for glmt, or switch to a direct API profile (glm/km)') ); process.exit(1); } @@ -856,7 +857,7 @@ async function main(): Promise { fail(`${targetAdapter?.displayName || 'Target'} does not support GLMT proxy profiles`) ); console.error( - info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)') + info('Use --target claude for glmt, or switch to a direct API profile (glm/km)') ); process.exit(1); } @@ -865,7 +866,9 @@ async function main(): Promise { } else { // EXISTING FLOW: Settings-based profile (glm) // Use --settings flag (backward compatible) - const expandedSettingsPath = getSettingsPath(profileInfo.name); + const expandedSettingsPath = profileInfo.settingsPath + ? expandPath(profileInfo.settingsPath) + : getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 58108e38..15df8b61 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -447,7 +447,7 @@ async function showHelp(): Promise { console.log(''); console.log(subheader('Options')); console.log( - ` ${color('--preset ', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, kimi, foundry, mm, deepseek, qwen)` + ` ${color('--preset ', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, km, foundry, mm, deepseek, qwen)` ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); @@ -467,7 +467,8 @@ async function showHelp(): Promise { ); console.log(` ${color('glm', 'command')} GLM - Claude via Z.AI`); console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`); - console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`); + console.log(` ${color('km', 'command')} Kimi - Moonshot AI reasoning model`); + console.log(` ${dim(' Legacy alias: --preset kimi (auto-mapped to km)')}`); console.log(` ${color('foundry', 'command')} Azure Foundry - Claude via Microsoft Azure`); console.log(` ${color('mm', 'command')} Minimax - M2 series with 1M context`); console.log(` ${color('deepseek', 'command')} DeepSeek - V3.2 and R1 reasoning (128K)`); diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 6ef1f48a..f18a5f1a 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -14,6 +14,7 @@ import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loade import { expandPath } from '../utils/helpers'; import { getCcsDir } from '../utils/config-manager'; import { ProfileRegistry } from '../auth/profile-registry'; +import { getProfileLookupCandidates } from '../utils/profile-compat'; type ShellType = 'bash' | 'fish' | 'powershell'; type OutputFormat = 'openai' | 'anthropic' | 'raw'; @@ -101,15 +102,22 @@ function isCLIProxyProfile(name: string): boolean { return (CLIPROXY_PROFILES as readonly string[]).includes(name); } -/** Resolve env vars for settings-based profiles (glm, kimi, custom API profiles) */ +/** Resolve env vars for settings-based profiles (glm, km, custom API profiles) */ function resolveSettingsProfile(profileName: string): Record | null { if (!isUnifiedMode()) return null; const config = loadUnifiedConfig(); if (!config) return null; - // Check unified config profiles section - const profileConfig = config.profiles?.[profileName]; + // Check unified config profiles section (supports compatibility aliases, e.g. km -> kimi) + let profileConfig: { type?: string; settings?: string } | undefined; + for (const candidate of getProfileLookupCandidates(profileName)) { + const candidateConfig = config.profiles?.[candidate]; + if (candidateConfig) { + profileConfig = candidateConfig; + break; + } + } if (!profileConfig) return null; if (profileConfig.type !== 'api') { @@ -225,7 +233,7 @@ export async function handleEnvCommand(args: string[]): Promise { if (v !== undefined) envVars[k] = v; } } else { - // Settings-based profile (glm, kimi, custom API) + // Settings-based profile (glm, km, custom API) const resolved = resolveSettingsProfile(profile); if (!resolved) { // Check if it's an account-based profile diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 0a627334..514afa8f 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -390,7 +390,7 @@ async function showHelp(): Promise { ); console.log(''); console.log(subheader('Supported Profile Types')); - console.log(` ${color('API profiles', 'command')} glm, glmt, kimi, custom API profiles`); + console.log(` ${color('API profiles', 'command')} glm, glmt, km, custom API profiles`); console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`); console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`); console.log(` ${dim('Account-based')} Not supported (uses CLAUDE_CONFIG_DIR)`); diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index 5317774c..681ea9ad 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -371,7 +371,7 @@ async function runSetupWizard(force: boolean = false): Promise { console.log(' Use the following commands to create profiles:'); console.log(''); console.log(' ccs api create glm --preset glm'); - console.log(' ccs api create kimi --preset kimi'); + console.log(' ccs api create km --preset km'); console.log(' ccs api create custom --prompt'); console.log(''); console.log(' After creating, edit the settings file to add your API key.'); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index c83cd9d1..3e8e350c 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import { initUI, header, subheader, color, warn } from '../utils/ui'; import { getActiveConfigPath, getCcsDir } from '../utils/config-manager'; import { getVersion } from '../utils/version'; +import { getProfileLookupCandidates } from '../utils/profile-compat'; /** * Handle version command @@ -38,18 +39,23 @@ export async function handleVersionCommand(): Promise { const readyProfiles: string[] = []; // Check for profiles with valid API keys - for (const profile of ['glm', 'kimi']) { - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (fs.existsSync(settingsPath)) { - try { - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN; - if (apiKey && !apiKey.match(/YOUR_.*_API_KEY_HERE/) && !apiKey.match(/sk-test.*/)) { - readyProfiles.push(profile); - } - } catch (_error) { - // Invalid JSON, skip + for (const profile of ['glm', 'km']) { + const settingsPath = getProfileLookupCandidates(profile) + .map((candidate) => path.join(ccsDir, `${candidate}.settings.json`)) + .find((candidatePath) => fs.existsSync(candidatePath)); + + if (!settingsPath) { + continue; + } + + try { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN; + if (apiKey && !apiKey.match(/YOUR_.*_API_KEY_HERE/) && !apiKey.match(/sk-test.*/)) { + readyProfiles.push(profile); } + } catch (_error) { + // Invalid JSON, skip } } diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index e14342a2..5c20e4a5 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -23,6 +23,9 @@ import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unifie import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; +const LEGACY_API_PROFILE_RENAMES: Readonly> = Object.freeze({ + kimi: 'km', +}); /** * Migration result with details about what was migrated. @@ -182,6 +185,7 @@ export async function migrate(dryRun = false): Promise { // config.yaml only stores reference to the settings file if (oldConfig?.profiles) { for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) { + const targetName = LEGACY_API_PROFILE_RENAMES[name] || name; const pathStr = settingsPath as string; const expandedPath = expandPath(pathStr); @@ -191,13 +195,30 @@ export async function migrate(dryRun = false): Promise { continue; } + if (unifiedConfig.profiles[targetName]) { + const existing = unifiedConfig.profiles[targetName].settings; + if (existing !== pathStr) { + warnings.push( + `Skipped ${name}: target profile "${targetName}" already exists with different settings (${existing})` + ); + } + continue; + } + // Store reference to settings file (keep using ~ for portability) const profile: ProfileConfig = { type: 'api', settings: pathStr, }; - unifiedConfig.profiles[name] = profile; - migratedFiles.push(`config.json.profiles.${name} → config.yaml (settings: ${pathStr})`); + unifiedConfig.profiles[targetName] = profile; + migratedFiles.push( + `config.json.profiles.${name} → config.yaml.profiles.${targetName} (settings: ${pathStr})` + ); + if (targetName !== name) { + warnings.push( + `Renamed legacy API profile "${name}" to "${targetName}" (ccs kimi API profile is now ccs km)` + ); + } } } @@ -456,15 +477,16 @@ async function migrateProfilesToUnified( // Migrate API profiles from config.json for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) { + const targetName = LEGACY_API_PROFILE_RENAMES[name] || name; const pathStr = settingsPath as string; // H7: Detect collision - profile exists in both configs - if (unifiedConfig.profiles[name]) { + if (unifiedConfig.profiles[targetName]) { // Check if settings differ (potential data loss) - const existingSettings = unifiedConfig.profiles[name].settings; + const existingSettings = unifiedConfig.profiles[targetName].settings; if (existingSettings && existingSettings !== pathStr) { warnings.push( - `Profile "${name}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy (${pathStr})` + `Profile "${targetName}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy ${name} (${pathStr})` ); } continue; @@ -479,11 +501,16 @@ async function migrateProfilesToUnified( } // Store reference to settings file - unifiedConfig.profiles[name] = { + unifiedConfig.profiles[targetName] = { type: 'api', settings: pathStr, }; - migratedFiles.push(name); + migratedFiles.push(targetName); + if (targetName !== name) { + warnings.push( + `Renamed legacy API profile "${name}" to "${targetName}" (ccs kimi API profile is now ccs km)` + ); + } modified = true; } diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index cfe85138..998e247b 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -320,7 +320,7 @@ export class DelegationHandler { if (!profile) { console.error(fail('No profile specified')); console.error(' Usage: ccs -p "task"'); - console.error(' Examples: ccs glm -p "task", ccs kimi -p "task"'); + console.error(' Examples: ccs glm -p "task", ccs km -p "task"'); process.exit(1); } diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index da95e746..3cf1b45f 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -16,6 +16,7 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; import { getCcsDir, getModelDisplayName } from '../utils/config-manager'; +import { getProfileLookupCandidates } from '../utils/profile-compat'; // Re-export types for consumers export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; @@ -26,7 +27,7 @@ export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executo export class HeadlessExecutor { /** * Execute task via headless Claude CLI - * @param profile - Profile name (glm, kimi, custom) + * @param profile - Profile name (glm, km, custom) * @param enhancedPrompt - Enhanced prompt with context * @param options - Execution options * @returns execution result @@ -63,13 +64,18 @@ export class HeadlessExecutor { ); } - // Get settings path for profile - const settingsPath = path.join(getCcsDir(), `${profile}.settings.json`); + // Get settings path for profile (supports compatibility aliases like km -> kimi) + const ccsDir = getCcsDir(); + const settingsCandidates = getProfileLookupCandidates(profile).map((candidate) => + path.join(ccsDir, `${candidate}.settings.json`) + ); + const settingsPath = settingsCandidates.find((candidatePath) => fs.existsSync(candidatePath)); + const primarySettingsPath = path.join(ccsDir, `${profile}.settings.json`); // Validate settings file exists - if (!fs.existsSync(settingsPath)) { + if (!settingsPath) { throw new Error( - `Settings file not found: ${settingsPath}\nProfile "${profile}" may not be configured.` + `Settings file not found: ${primarySettingsPath}\nProfile "${profile}" may not be configured.` ); } diff --git a/src/utils/delegation-validator.ts b/src/utils/delegation-validator.ts index 93d669dd..1ae2df86 100644 --- a/src/utils/delegation-validator.ts +++ b/src/utils/delegation-validator.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { Settings } from '../types'; import { ValidationResult } from '../types/utils'; import { getCcsDir } from './config-manager'; +import { getProfileLookupCandidates } from './profile-compat'; /** * Extended validation result for delegation profiles @@ -24,19 +25,24 @@ interface DelegationValidationResult extends ValidationResult { export class DelegationValidator { /** * Validate a delegation profile - * @param profileName - Name of profile to validate (e.g., 'glm', 'kimi') + * @param profileName - Name of profile to validate (e.g., 'glm', 'km') * @returns Validation result { valid: boolean, error?: string, settingsPath?: string } */ static validate(profileName: string): DelegationValidationResult { - const settingsPath = path.join(getCcsDir(), `${profileName}.settings.json`); + const ccsDir = getCcsDir(); + const candidateSettingsPath = getProfileLookupCandidates(profileName) + .map((candidate) => path.join(ccsDir, `${candidate}.settings.json`)) + .find((candidatePath) => fs.existsSync(candidatePath)); + const primarySettingsPath = path.join(ccsDir, `${profileName}.settings.json`); + const settingsPath = candidateSettingsPath || primarySettingsPath; // Check if profile directory exists - if (!fs.existsSync(settingsPath)) { + if (!candidateSettingsPath) { return { valid: false, error: `Profile not found: ${profileName}`, suggestion: - `Profile settings missing at: ${settingsPath}\n\n` + + `Profile settings missing at: ${primarySettingsPath}\n\n` + `To set up ${profileName} profile:\n` + ` 1. Copy base settings: cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json\n` + ` 2. Edit settings: Edit ~/.ccs/${profileName}.settings.json\n` + diff --git a/src/utils/profile-compat.ts b/src/utils/profile-compat.ts new file mode 100644 index 00000000..95e2ab11 --- /dev/null +++ b/src/utils/profile-compat.ts @@ -0,0 +1,39 @@ +/** + * Profile compatibility helpers for renamed commands/profiles. + * + * Current compatibility mappings: + * - `km` is the canonical Kimi API profile command + * - `kimi` remains as legacy API profile name in existing user configs + */ + +const PROFILE_COMPAT_ALIASES: Readonly> = Object.freeze({ + km: ['kimi'], +}); + +/** + * Build lookup candidates for a profile. + * Order: exact input -> lowercase form (if different) -> legacy aliases. + */ +export function getProfileLookupCandidates(profileName: string): string[] { + const raw = profileName.trim(); + const normalized = raw.toLowerCase(); + const aliases = PROFILE_COMPAT_ALIASES[normalized] || []; + const ordered = [raw, normalized, ...aliases]; + + return [...new Set(ordered.filter(Boolean))]; +} + +/** + * Check whether a resolved profile name came from a legacy alias. + */ +export function isLegacyProfileAlias(requestedName: string, resolvedName: string): boolean { + const requestedNormalized = requestedName.trim().toLowerCase(); + const resolvedNormalized = resolvedName.trim().toLowerCase(); + + if (requestedNormalized === resolvedNormalized) { + return false; + } + + const aliases = PROFILE_COMPAT_ALIASES[requestedNormalized] || []; + return aliases.includes(resolvedNormalized); +} diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts new file mode 100644 index 00000000..bdcfec8c --- /dev/null +++ b/tests/unit/api/provider-presets.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'bun:test'; +import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; + +describe('provider-presets', () => { + it('resolves canonical km preset id', () => { + const preset = getPresetById('km'); + expect(preset?.id).toBe('km'); + }); + + it('resolves legacy kimi preset alias to km', () => { + const preset = getPresetById('kimi'); + expect(preset?.id).toBe('km'); + }); + + it('treats legacy kimi alias as a valid preset id', () => { + expect(isValidPresetId('kimi')).toBe(true); + }); +}); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 91cefcd0..b8533a7c 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -120,6 +120,43 @@ describe('ProfileDetector', () => { } }); + it('should resolve km to legacy kimi API profile from unified config', () => { + const settingsPath = path.join(tempDir, 'kimi.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ env: { ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo' } }) + ); + + const mockUnifiedConfig = { + version: 2, + profiles: { + kimi: { settings: settingsPath, type: 'api' }, + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + const result = detector.detectProfileType('km'); + expect(result.type).toBe('settings'); + expect(result.name).toBe('km'); + expect(result.settingsPath).toBe(settingsPath); + expect(result.env).toEqual({ ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo' }); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should keep ccs kimi mapped to CLIProxy provider', () => { + const result = detector.detectProfileType('kimi'); + expect(result.type).toBe('cliproxy'); + expect(result.provider).toBe('kimi'); + }); + it('should detect account-based profile from unified config', () => { const mockUnifiedConfig = { version: 2, diff --git a/tests/unit/utils/profile-compat.test.ts b/tests/unit/utils/profile-compat.test.ts new file mode 100644 index 00000000..c87dc215 --- /dev/null +++ b/tests/unit/utils/profile-compat.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'bun:test'; +import { getProfileLookupCandidates, isLegacyProfileAlias } from '../../../src/utils/profile-compat'; + +describe('profile-compat', () => { + describe('getProfileLookupCandidates', () => { + it('returns km candidates with legacy kimi fallback', () => { + expect(getProfileLookupCandidates('km')).toEqual(['km', 'kimi']); + }); + + it('keeps non-aliased profiles unchanged', () => { + expect(getProfileLookupCandidates('glm')).toEqual(['glm']); + }); + + it('normalizes uppercase input and still resolves aliases', () => { + expect(getProfileLookupCandidates('KM')).toEqual(['KM', 'km', 'kimi']); + }); + }); + + describe('isLegacyProfileAlias', () => { + it('returns true for km -> kimi', () => { + expect(isLegacyProfileAlias('km', 'kimi')).toBe(true); + }); + + it('returns false for canonical names', () => { + expect(isLegacyProfileAlias('km', 'km')).toBe(false); + expect(isLegacyProfileAlias('glm', 'glm')).toBe(false); + }); + + it('returns false for unrelated names', () => { + expect(isLegacyProfileAlias('glm', 'kimi')).toBe(false); + }); + }); +});