fix(profile): close km legacy kimi compatibility gaps

This commit is contained in:
Tam Nhu Tran
2026-02-19 13:02:56 +07:00
parent cf8070b5f0
commit 0bf00b23d9
9 changed files with 343 additions and 66 deletions
+4 -5
View File
@@ -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<Record<string, string>> = 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);
}
+48 -15
View File
@@ -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<Record<string, string>> = 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<string, unknown>;
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<MigrationResult> {
// 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<string, unknown>)[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<MigrationResult> {
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<MigrationResult> {
};
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<string, unknown>)[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;
+17
View File
@@ -10,6 +10,23 @@ const PROFILE_COMPAT_ALIASES: Readonly<Record<string, readonly string[]>> = 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.
+47 -44
View File
@@ -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;
+10
View File
@@ -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);
});
+135
View File
@@ -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);
});
});
+26 -1
View File
@@ -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');
});
});
});
@@ -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');
});
});
+7 -1
View File
@@ -176,6 +176,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
},
];
const LEGACY_PRESET_ALIASES: Readonly<Record<string, string>> = 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 */