From cf8070b5f00d8fa13f86d7324a7bfd85cfc8f78e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Feb 2026 12:43:41 +0700 Subject: [PATCH 1/2] fix(profile): handle km compatibility for legacy kimi api users --- README.md | 4 +- src/api/services/provider-presets.ts | 7 +++- src/auth/profile-detector.ts | 47 +++++++++++++++++------- src/ccs.ts | 9 +++-- src/commands/api-command.ts | 5 ++- src/commands/env-command.ts | 16 ++++++-- src/commands/persist-command.ts | 2 +- src/commands/setup-command.ts | 2 +- src/commands/version-command.ts | 28 ++++++++------ src/config/migration-manager.ts | 41 +++++++++++++++++---- src/delegation/delegation-handler.ts | 2 +- src/delegation/headless-executor.ts | 16 +++++--- src/utils/delegation-validator.ts | 14 +++++-- src/utils/profile-compat.ts | 39 ++++++++++++++++++++ tests/unit/api/provider-presets.test.ts | 18 +++++++++ tests/unit/auth/profile-detector.test.ts | 37 +++++++++++++++++++ tests/unit/utils/profile-compat.test.ts | 33 +++++++++++++++++ 17 files changed, 266 insertions(+), 54 deletions(-) create mode 100644 src/utils/profile-compat.ts create mode 100644 tests/unit/api/provider-presets.test.ts create mode 100644 tests/unit/utils/profile-compat.test.ts 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); + }); + }); +}); From 0bf00b23d9085dde7fedfbed30f7924fee41da92 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Feb 2026 13:02:56 +0700 Subject: [PATCH 2/2] fix(profile): close km legacy kimi compatibility gaps --- src/api/services/provider-presets.ts | 9 +- src/config/migration-manager.ts | 63 ++++++--- src/utils/profile-compat.ts | 17 +++ src/web-server/health/config-checks.ts | 91 ++++++------- tests/unit/api/provider-presets.test.ts | 10 ++ tests/unit/config/migration-manager.test.ts | 135 ++++++++++++++++++++ tests/unit/utils/profile-compat.test.ts | 27 +++- tests/unit/web-server/config-checks.test.ts | 49 +++++++ ui/src/lib/provider-presets.ts | 8 +- 9 files changed, 343 insertions(+), 66 deletions(-) create mode 100644 tests/unit/config/migration-manager.test.ts create mode 100644 tests/unit/web-server/config-checks.test.ts diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index 15daf7b0..80038aa3 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -5,6 +5,8 @@ * Mirrors the UI presets in ui/src/lib/provider-presets.ts */ +import { resolveAliasToCanonical } from '../../utils/profile-compat'; + export type PresetCategory = 'recommended' | 'alternative'; export interface ProviderPreset { @@ -26,9 +28,6 @@ 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 @@ -172,8 +171,8 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ /** Get preset by ID */ export function getPresetById(id: string): ProviderPreset | undefined { - const normalized = id.toLowerCase(); - const canonical = LEGACY_PRESET_ALIASES[normalized] || normalized; + const normalized = id.trim().toLowerCase(); + const canonical = resolveAliasToCanonical(normalized).toLowerCase(); return PROVIDER_PRESETS.find((p) => p.id === canonical); } diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 5c20e4a5..3910a71a 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -16,6 +16,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; import { expandPath } from '../utils/helpers'; +import { resolveAliasToCanonical } from '../utils/profile-compat'; import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types'; import { createEmptyUnifiedConfig } from './unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; @@ -23,9 +24,6 @@ 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. @@ -76,7 +74,8 @@ export function loadMigrationCheckData(): MigrationCheckData { if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) { const legacyProfiles = legacyConfig.profiles as Record; for (const profileName of Object.keys(legacyProfiles)) { - if (!unifiedConfig.profiles[profileName]) { + const targetProfileName = resolveAliasToCanonical(profileName); + if (!unifiedConfig.profiles[targetProfileName]) { needsMigration = true; break; } @@ -185,13 +184,30 @@ 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 sourceName = name.trim(); + const targetName = resolveAliasToCanonical(sourceName); const pathStr = settingsPath as string; const expandedPath = expandPath(pathStr); + const canonicalEntryValue = (oldConfig.profiles as Record)[targetName]; + const canonicalPathFromLegacyConfig = + sourceName !== targetName && typeof canonicalEntryValue === 'string' + ? canonicalEntryValue + : undefined; + + // Deterministic priority: explicit canonical profile wins over legacy alias rename. + if (canonicalPathFromLegacyConfig !== undefined) { + if (canonicalPathFromLegacyConfig !== pathStr) { + warnings.push( + `Skipped ${sourceName}: canonical profile "${targetName}" exists in config.json with different settings (${canonicalPathFromLegacyConfig})` + ); + } + continue; + } + // Verify settings file exists if (!fs.existsSync(expandedPath)) { - warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`); + warnings.push(`Skipped ${sourceName}: settings file not found at ${pathStr}`); continue; } @@ -199,7 +215,7 @@ export async function migrate(dryRun = false): Promise { const existing = unifiedConfig.profiles[targetName].settings; if (existing !== pathStr) { warnings.push( - `Skipped ${name}: target profile "${targetName}" already exists with different settings (${existing})` + `Skipped ${sourceName}: target profile "${targetName}" already exists with different settings (${existing})` ); } continue; @@ -212,11 +228,11 @@ export async function migrate(dryRun = false): Promise { }; unifiedConfig.profiles[targetName] = profile; migratedFiles.push( - `config.json.profiles.${name} → config.yaml.profiles.${targetName} (settings: ${pathStr})` + `config.json.profiles.${sourceName} → config.yaml.profiles.${targetName} (settings: ${pathStr})` ); - if (targetName !== name) { + if (targetName !== sourceName) { warnings.push( - `Renamed legacy API profile "${name}" to "${targetName}" (ccs kimi API profile is now ccs km)` + `Renamed legacy API profile "${sourceName}" to "${targetName}" (ccs kimi API profile is now ccs km)` ); } } @@ -477,16 +493,33 @@ 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 sourceName = name.trim(); + const targetName = resolveAliasToCanonical(sourceName); const pathStr = settingsPath as string; + const canonicalEntryValue = (oldConfig.profiles as Record)[targetName]; + const canonicalPathFromLegacyConfig = + sourceName !== targetName && typeof canonicalEntryValue === 'string' + ? canonicalEntryValue + : undefined; + + // Deterministic priority: explicit canonical profile wins over legacy alias rename. + if (canonicalPathFromLegacyConfig !== undefined) { + if (canonicalPathFromLegacyConfig !== pathStr) { + warnings.push( + `Skipped ${sourceName}: canonical profile "${targetName}" exists in config.json with different settings (${canonicalPathFromLegacyConfig})` + ); + } + continue; + } + // H7: Detect collision - profile exists in both configs if (unifiedConfig.profiles[targetName]) { // Check if settings differ (potential data loss) const existingSettings = unifiedConfig.profiles[targetName].settings; if (existingSettings && existingSettings !== pathStr) { warnings.push( - `Profile "${targetName}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy ${name} (${pathStr})` + `Profile "${targetName}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy ${sourceName} (${pathStr})` ); } continue; @@ -496,7 +529,7 @@ async function migrateProfilesToUnified( // Verify settings file exists if (!fs.existsSync(expandedPath)) { - warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`); + warnings.push(`Skipped ${sourceName}: settings file not found at ${pathStr}`); continue; } @@ -506,9 +539,9 @@ async function migrateProfilesToUnified( settings: pathStr, }; migratedFiles.push(targetName); - if (targetName !== name) { + if (targetName !== sourceName) { warnings.push( - `Renamed legacy API profile "${name}" to "${targetName}" (ccs kimi API profile is now ccs km)` + `Renamed legacy API profile "${sourceName}" to "${targetName}" (ccs kimi API profile is now ccs km)` ); } modified = true; diff --git a/src/utils/profile-compat.ts b/src/utils/profile-compat.ts index 95e2ab11..b1ff6a7b 100644 --- a/src/utils/profile-compat.ts +++ b/src/utils/profile-compat.ts @@ -10,6 +10,23 @@ const PROFILE_COMPAT_ALIASES: Readonly> = Obje km: ['kimi'], }); +/** + * Resolve a legacy alias to its canonical profile name. + * Returns trimmed input when no alias mapping exists. + */ +export function resolveAliasToCanonical(profileName: string): string { + const raw = profileName.trim(); + const normalized = raw.toLowerCase(); + + for (const [canonical, aliases] of Object.entries(PROFILE_COMPAT_ALIASES)) { + if (aliases.includes(normalized)) { + return canonical; + } + } + + return raw; +} + /** * Build lookup candidates for a profile. * Order: exact input -> lowercase form (if different) -> legacy aliases. diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 17fb04ac..f8ff910b 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -87,24 +87,23 @@ export function checkConfigFile(): HealthCheck { } /** - * Check settings files (glm, kimi) + * Check settings files (glm, km with legacy kimi fallback) */ export function checkSettingsFiles(ccsDir: string): HealthCheck[] { const checks: HealthCheck[] = []; - const files = [ - { name: 'glm.settings.json', profile: 'glm' }, - { name: 'kimi.settings.json', profile: 'kimi' }, - ]; + const profiles = ['glm', 'km']; const { DelegationValidator } = require('../../utils/delegation-validator'); - for (const file of files) { - const filePath = path.join(ccsDir, file.name); + for (const profile of profiles) { + const fileName = `${profile}.settings.json`; + const filePath = path.join(ccsDir, fileName); + const validation = DelegationValidator.validate(profile); - if (!fs.existsSync(filePath)) { + if (!validation.valid && validation.error?.includes('Profile not found')) { checks.push({ - id: `settings-${file.profile}`, - name: file.name, + id: `settings-${profile}`, + name: fileName, status: 'info', message: 'Not configured', details: filePath, @@ -112,46 +111,50 @@ export function checkSettingsFiles(ccsDir: string): HealthCheck[] { continue; } - try { - const content = fs.readFileSync(filePath, 'utf8'); - JSON.parse(content); + const resolvedPath = validation.settingsPath || filePath; + const resolvedName = path.basename(resolvedPath); - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'ok', - message: 'Key configured', - details: filePath, - }); - } else if (validation.error && validation.error.includes('placeholder')) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'warning', - message: 'Placeholder key', - details: filePath, - }); - } else { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'ok', - message: 'Valid JSON', - details: filePath, - }); - } - } catch { + if (validation.valid) { checks.push({ - id: `settings-${file.profile}`, - name: file.name, + id: `settings-${profile}`, + name: resolvedName, + status: 'ok', + message: 'Key configured', + details: resolvedPath, + }); + continue; + } + + if (validation.error?.includes('placeholder')) { + checks.push({ + id: `settings-${profile}`, + name: resolvedName, + status: 'warning', + message: 'Placeholder key', + details: resolvedPath, + }); + continue; + } + + if (validation.error?.includes('Failed to parse settings.json')) { + checks.push({ + id: `settings-${profile}`, + name: resolvedName, status: 'error', message: 'Invalid JSON', - details: filePath, + details: resolvedPath, }); + continue; } + + // Keep prior behavior for non-placeholder validation issues (e.g., missing key). + checks.push({ + id: `settings-${profile}`, + name: resolvedName, + status: 'ok', + message: 'Valid JSON', + details: resolvedPath, + }); } return checks; diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index bdcfec8c..b51576c2 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -12,6 +12,16 @@ describe('provider-presets', () => { expect(preset?.id).toBe('km'); }); + it('resolves preset id with extra whitespace', () => { + const preset = getPresetById(' km '); + expect(preset?.id).toBe('km'); + }); + + it('resolves uppercase legacy alias', () => { + 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/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts new file mode 100644 index 00000000..450640d9 --- /dev/null +++ b/tests/unit/config/migration-manager.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { loadMigrationCheckData, migrate } from '../../../src/config/migration-manager'; +import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; + +describe('migration-manager legacy kimi compatibility', () => { + let tempHome: string; + let ccsDir: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-migration-manager-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('prefers explicit canonical km profile over legacy kimi when both exist', async () => { + const kmSettingsPath = path.join(ccsDir, 'km.settings.json'); + const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json'); + + fs.writeFileSync(kmSettingsPath, JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-km' } })); + fs.writeFileSync( + kimiSettingsPath, + JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-kimi' } }) + ); + + // Intentionally place legacy alias first to verify deterministic behavior. + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { + kimi: kimiSettingsPath, + km: kmSettingsPath, + }, + }, + null, + 2 + ) + ); + + const result = await migrate(true); + + expect(result.success).toBe(true); + expect( + result.migratedFiles.some((entry) => + entry.includes(`config.json.profiles.km → config.yaml.profiles.km (settings: ${kmSettingsPath})`) + ) + ).toBe(true); + expect( + result.migratedFiles.some((entry) => + entry.includes(`(settings: ${kimiSettingsPath})`) + ) + ).toBe(false); + expect( + result.warnings.some((warning) => + warning.includes( + 'Skipped kimi: canonical profile "km" exists in config.json with different settings' + ) + ) + ).toBe(true); + }); + + it('renames case-variant legacy Kimi profile key to km', async () => { + const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json'); + fs.writeFileSync( + kimiSettingsPath, + JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-kimi-case-variant' } }) + ); + + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { + Kimi: kimiSettingsPath, + }, + }, + null, + 2 + ) + ); + + const result = await migrate(true); + + expect(result.success).toBe(true); + expect( + result.migratedFiles.some((entry) => + entry.includes('config.json.profiles.Kimi → config.yaml.profiles.km') + ) + ).toBe(true); + }); + + it('treats legacy kimi profile as migrated when unified config already has km', () => { + const unifiedConfig = createEmptyUnifiedConfig(); + unifiedConfig.profiles.km = { + type: 'api', + settings: '~/.ccs/km.settings.json', + }; + saveUnifiedConfig(unifiedConfig); + + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify( + { + profiles: { + kimi: '~/.ccs/kimi.settings.json', + }, + }, + null, + 2 + ) + ); + + const checkData = loadMigrationCheckData(); + expect(checkData.needsMigration).toBe(false); + }); +}); diff --git a/tests/unit/utils/profile-compat.test.ts b/tests/unit/utils/profile-compat.test.ts index c87dc215..52eaa03b 100644 --- a/tests/unit/utils/profile-compat.test.ts +++ b/tests/unit/utils/profile-compat.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import { getProfileLookupCandidates, isLegacyProfileAlias } from '../../../src/utils/profile-compat'; +import { + getProfileLookupCandidates, + isLegacyProfileAlias, + resolveAliasToCanonical, +} from '../../../src/utils/profile-compat'; describe('profile-compat', () => { describe('getProfileLookupCandidates', () => { @@ -14,6 +18,15 @@ describe('profile-compat', () => { it('normalizes uppercase input and still resolves aliases', () => { expect(getProfileLookupCandidates('KM')).toEqual(['KM', 'km', 'kimi']); }); + + it('handles surrounding whitespace', () => { + expect(getProfileLookupCandidates(' km ')).toEqual(['km', 'kimi']); + }); + + it('returns empty candidates for empty input', () => { + expect(getProfileLookupCandidates('')).toEqual([]); + expect(getProfileLookupCandidates(' ')).toEqual([]); + }); }); describe('isLegacyProfileAlias', () => { @@ -30,4 +43,16 @@ describe('profile-compat', () => { expect(isLegacyProfileAlias('glm', 'kimi')).toBe(false); }); }); + + describe('resolveAliasToCanonical', () => { + it('maps legacy kimi alias to canonical km', () => { + expect(resolveAliasToCanonical('kimi')).toBe('km'); + expect(resolveAliasToCanonical('KIMI')).toBe('km'); + }); + + it('keeps canonical and non-aliased names', () => { + expect(resolveAliasToCanonical('km')).toBe('km'); + expect(resolveAliasToCanonical('glm')).toBe('glm'); + }); + }); }); diff --git a/tests/unit/web-server/config-checks.test.ts b/tests/unit/web-server/config-checks.test.ts new file mode 100644 index 00000000..c8202e95 --- /dev/null +++ b/tests/unit/web-server/config-checks.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { checkSettingsFiles } from '../../../src/web-server/health/config-checks'; + +describe('web-server config-checks settings compatibility', () => { + let tempHome: string; + let ccsDir: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-checks-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('reports km as configured when only legacy kimi.settings.json exists', () => { + fs.writeFileSync( + path.join(ccsDir, 'kimi.settings.json'), + JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-live-kimi-compat', + }, + }) + ); + + const checks = checkSettingsFiles(ccsDir); + const kmCheck = checks.find((check) => check.id === 'settings-km'); + + expect(kmCheck).toBeDefined(); + expect(kmCheck?.status).toBe('ok'); + expect(kmCheck?.name).toBe('kimi.settings.json'); + }); +}); diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts index 451f0f9d..08a76334 100644 --- a/ui/src/lib/provider-presets.ts +++ b/ui/src/lib/provider-presets.ts @@ -176,6 +176,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ }, ]; +const LEGACY_PRESET_ALIASES: Readonly> = Object.freeze({ + kimi: 'km', +}); + /** Get presets by category */ export function getPresetsByCategory(category: PresetCategory): ProviderPreset[] { return PROVIDER_PRESETS.filter((p) => p.category === category); @@ -183,7 +187,9 @@ export function getPresetsByCategory(category: PresetCategory): ProviderPreset[] /** Get preset by ID */ export function getPresetById(id: string): ProviderPreset | undefined { - return PROVIDER_PRESETS.find((p) => p.id.toLowerCase() === id.toLowerCase()); + const normalized = id.trim().toLowerCase(); + const canonical = LEGACY_PRESET_ALIASES[normalized] || normalized; + return PROVIDER_PRESETS.find((p) => p.id.toLowerCase() === canonical); } /** Check if a URL matches a known preset */