From 0bf00b23d9085dde7fedfbed30f7924fee41da92 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Feb 2026 13:02:56 +0700 Subject: [PATCH] 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 */