From 546c4ee4ee006589f179b544c6b73e362b7ebbe6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 23:26:07 +0700 Subject: [PATCH] fix(cliproxy): normalize stale iflow model aliases --- src/cliproxy/config/env-builder.ts | 16 ++-- src/cliproxy/model-id-normalizer.ts | 46 +++++++++++ src/cliproxy/services/variant-service.ts | 32 +++++--- src/web-server/routes/route-helpers.ts | 81 +++++++++++++------ .../cliproxy/env-builder-provider-url.test.ts | 45 +++++++++++ .../unit/cliproxy/model-id-normalizer.test.ts | 25 ++++++ ...cliproxy-stats-routes-model-update.test.ts | 30 +++++++ .../route-helpers-model-denylist.test.ts | 60 ++++++++++++++ ...ings-routes-model-canonicalization.test.ts | 46 +++++++++++ ui/src/lib/model-catalogs.ts | 24 ++---- .../unit/ui/lib/model-catalogs-iflow.test.ts | 24 ++++++ 11 files changed, 368 insertions(+), 61 deletions(-) create mode 100644 ui/tests/unit/ui/lib/model-catalogs-iflow.test.ts diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index fb89ef34..3a985f6b 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -25,6 +25,7 @@ import { MODEL_ENV_VAR_KEYS, migrateDeniedAntigravityModelAliases, normalizeModelEnvVarsForProvider, + normalizeIFlowLegacyModelAliases, normalizeModelIdForProvider, } from '../model-id-normalizer'; @@ -39,8 +40,6 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; -const IFLOW_PLACEHOLDER_MODEL = 'iflow-default'; -const IFLOW_DEFAULT_MODEL = 'qwen3-coder-plus'; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', @@ -172,8 +171,8 @@ function migrateCodexEffortSuffixes( } /** - * Migrate legacy iFlow placeholder model IDs to a real default model. - * Example: iflow-default -> qwen3-coder-plus + * Migrate legacy iFlow model IDs to current upstream model IDs. + * Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2 */ function migrateIFlowPlaceholderModel( settingsPath: string, @@ -184,14 +183,13 @@ function migrateIFlowPlaceholderModel( if (!settings.env || typeof settings.env !== 'object') return false; let migrated = false; - const normalize = (value: string): string => value.trim().toLowerCase(); - const replaceIfPlaceholder = (value: string): string => - normalize(value) === IFLOW_PLACEHOLDER_MODEL ? IFLOW_DEFAULT_MODEL : value; + const replaceLegacyIFlowModel = (value: string): string => + normalizeIFlowLegacyModelAliases(value); for (const key of MODEL_ENV_VAR_KEYS) { const value = settings.env[key]; if (typeof value !== 'string') continue; - const replaced = replaceIfPlaceholder(value); + const replaced = replaceLegacyIFlowModel(value); if (replaced !== value) { settings.env[key] = replaced; migrated = true; @@ -206,7 +204,7 @@ function migrateIFlowPlaceholderModel( for (const key of PRESET_MODEL_KEYS) { const value = presetRecord[key]; if (typeof value !== 'string') continue; - const replaced = replaceIfPlaceholder(value); + const replaced = replaceLegacyIFlowModel(value); if (replaced !== value) { presetRecord[key] = replaced; migrated = true; diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 15e4ad60..b1e363ed 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -31,11 +31,34 @@ const DENIED_ANTIGRAVITY_SONNET_45_REGEX = /claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const IFLOW_LEGACY_MODEL_ALIASES: Readonly> = Object.freeze({ + 'iflow-default': 'qwen3-coder-plus', + 'kimi-k2.5': 'kimi-k2', + 'deepseek-v3.2-chat': 'deepseek-v3.2', + 'glm-4.7': 'glm-4.6', + 'minimax-m2.5': 'qwen3-coder-plus', +}); function trimModelId(model: string): string { return model.trim(); } +function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: string } { + const openParenIndex = model.indexOf('('); + const openBracketIndex = model.indexOf('['); + const suffixIndexCandidates = [openParenIndex, openBracketIndex].filter((index) => index >= 0); + + if (suffixIndexCandidates.length === 0) { + return { baseModel: model, suffix: '' }; + } + + const suffixStart = Math.min(...suffixIndexCandidates); + return { + baseModel: model.slice(0, suffixStart), + suffix: model.slice(suffixStart), + }; +} + /** * Extract provider segment from `/api/provider/{provider}` request paths. * @@ -62,11 +85,31 @@ export function isCodexProvider(provider: ProviderLike): boolean { return provider.trim().toLowerCase() === 'codex'; } +/** Whether provider maps to iFlow model alias canonicalization rules. */ +export function isIFlowProvider(provider: ProviderLike): boolean { + if (typeof provider !== 'string') return false; + return provider.trim().toLowerCase() === 'iflow'; +} + /** Normalize Codex effort-suffixed IDs to canonical IDs. */ export function stripCodexEffortSuffix(model: string): string { return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } +/** + * Normalize known legacy iFlow model aliases to current upstream model IDs. + * Preserves suffixes such as (budget) and [1m]. + */ +export function normalizeIFlowLegacyModelAliases(model: string): string { + const trimmed = trimModelId(model); + const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); + const replacement = IFLOW_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; + if (!replacement) { + return trimmed; + } + return `${replacement}${suffix}`; +} + /** Normalize Claude dotted major.minor IDs to hyphenated format. */ export function normalizeClaudeDottedMajorMinor(model: string): string { return model.replace( @@ -144,6 +187,9 @@ export function getDeniedModelIdReasonForProvider( */ export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { const trimmedModel = trimModelId(model); + if (isIFlowProvider(provider)) { + return normalizeIFlowLegacyModelAliases(trimmedModel); + } if (!isAntigravityProvider(provider)) return trimmedModel; const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion); diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index c16dbe94..f8970270 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -20,7 +20,10 @@ import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker' import { warn } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; import { validateCompositeTiers } from '../composite-validator'; -import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer'; +import { + canonicalizeModelIdForProvider, + getDeniedModelIdReasonForProvider, +} from '../model-id-normalizer'; import { createSettingsFile, createSettingsFileUnified, @@ -116,7 +119,8 @@ export function createVariant( target: TargetType = 'claude' ): VariantOperationResult { try { - const deniedModelReason = getDeniedModelIdReasonForProvider(model, provider); + const canonicalModel = canonicalizeModelIdForProvider(model, provider); + const deniedModelReason = getDeniedModelIdReasonForProvider(canonicalModel, provider); if (deniedModelReason) { return { success: false, error: deniedModelReason }; } @@ -133,7 +137,7 @@ export function createVariant( let settingsPath: string; if (isUnifiedMode()) { - settingsPath = createSettingsFileUnified(name, provider, model, port); + settingsPath = createSettingsFileUnified(name, provider, canonicalModel, port); saveVariantUnified( name, provider as CLIProxyProvider, @@ -143,7 +147,7 @@ export function createVariant( target ); } else { - settingsPath = createSettingsFile(name, provider, model, port); + settingsPath = createSettingsFile(name, provider, canonicalModel, port); saveVariantLegacy( name, provider, @@ -157,7 +161,7 @@ export function createVariant( return { success: true, settingsPath, - variant: { provider, model, account, port, target }, + variant: { provider, model: canonicalModel, account, port, target }, }; } catch (error) { return { @@ -253,6 +257,11 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari const existingTarget = existing.target || 'claude'; const targetChanged = updates.target !== undefined && updates.target !== existingTarget; const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; + const providerForModelUpdate = (updates.provider ?? existing.provider) as CLIProxyProfileName; + const canonicalModelUpdate = + updates.model !== undefined + ? canonicalizeModelIdForProvider(updates.model.trim(), providerForModelUpdate) + : undefined; if (providerChanged && !hasModelUpdate) { return { @@ -262,9 +271,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari } if (hasModelUpdate) { - const providerForModelUpdate = updates.provider ?? (existing.provider as CLIProxyProfileName); const deniedModelReason = getDeniedModelIdReasonForProvider( - updates.model?.trim() || '', + canonicalModelUpdate || '', providerForModelUpdate ); if (deniedModelReason) { @@ -279,11 +287,15 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari updateSettingsProviderAndModel( settingsPath, updates.provider as CLIProxyProfileName, - updates.model?.trim() || '', + canonicalModelUpdate || '', existing.port ); } else if (updates.model !== undefined) { - updateSettingsModel(settingsPath, updates.model, existing.provider as CLIProxyProfileName); + updateSettingsModel( + settingsPath, + canonicalModelUpdate || '', + existing.provider as CLIProxyProfileName + ); } } @@ -326,7 +338,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari success: true, variant: { provider: updates.provider ?? existing.provider, - model: updates.model?.trim() || existing.model, + model: canonicalModelUpdate || existing.model, account: updates.account !== undefined ? updates.account : existing.account, port: existing.port, settings: existing.settings, diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index ba6934b8..8820ef0c 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -10,6 +10,7 @@ import { getClaudeSettingsPath } from '../../utils/claude-config-path'; import { resolveDroidProvider } from '../../targets/droid-provider'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { + canonicalizeModelIdForProvider, extractProviderFromPathname, getDeniedModelIdReasonForProvider, } from '../../cliproxy/model-id-normalizer'; @@ -52,6 +53,15 @@ function getDeniedModelReasonForProvider( return null; } +function canonicalizeModelForProvider( + provider: CLIProxyProvider | null, + value: string | undefined +): string | undefined { + if (typeof value !== 'string') return value; + if (!provider || value.trim().length === 0) return value; + return canonicalizeModelIdForProvider(value, provider); +} + /** * Read config safely with fallback. * Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). @@ -105,11 +115,15 @@ export function createSettingsFile( const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); const { model, opusModel, sonnetModel, haikuModel } = models; const providerFromBaseUrl = resolveProviderFromBaseUrl(baseUrl); + const canonicalModel = canonicalizeModelForProvider(providerFromBaseUrl, model); + const canonicalOpusModel = canonicalizeModelForProvider(providerFromBaseUrl, opusModel); + const canonicalSonnetModel = canonicalizeModelForProvider(providerFromBaseUrl, sonnetModel); + const canonicalHaikuModel = canonicalizeModelForProvider(providerFromBaseUrl, haikuModel); const deniedReason = getDeniedModelReasonForProvider(providerFromBaseUrl, [ - model, - opusModel, - sonnetModel, - haikuModel, + canonicalModel, + canonicalOpusModel, + canonicalSonnetModel, + canonicalHaikuModel, ]); if (deniedReason) { throw new Error(deniedReason); @@ -117,21 +131,22 @@ export function createSettingsFile( const droidProvider = resolveDroidProvider({ provider, baseUrl, - model, + model: canonicalModel, }); const settings: Settings = { env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, - ...(model && { ANTHROPIC_MODEL: model }), - ...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }), - ...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }), - ...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }), + ...(canonicalModel && { ANTHROPIC_MODEL: canonicalModel }), + ...(canonicalOpusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: canonicalOpusModel }), + ...(canonicalSonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: canonicalSonnetModel }), + ...(canonicalHaikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: canonicalHaikuModel }), CCS_DROID_PROVIDER: droidProvider, }, }; + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); return `~/.ccs/${name}.settings.json`; } @@ -161,16 +176,32 @@ export function updateSettingsFile( const providerForValidation = resolveProviderFromBaseUrl(updates.baseUrl) ?? resolveProviderFromBaseUrl(settings.env?.ANTHROPIC_BASE_URL); - const deniedReason = getDeniedModelReasonForProvider(providerForValidation, [ - updates.model !== undefined ? updates.model : settings.env?.ANTHROPIC_MODEL, + const canonicalModel = + updates.model !== undefined + ? canonicalizeModelForProvider(providerForValidation, updates.model) + : undefined; + const canonicalOpusModel = updates.opusModel !== undefined - ? updates.opusModel - : settings.env?.ANTHROPIC_DEFAULT_OPUS_MODEL, + ? canonicalizeModelForProvider(providerForValidation, updates.opusModel) + : undefined; + const canonicalSonnetModel = updates.sonnetModel !== undefined - ? updates.sonnetModel - : settings.env?.ANTHROPIC_DEFAULT_SONNET_MODEL, + ? canonicalizeModelForProvider(providerForValidation, updates.sonnetModel) + : undefined; + const canonicalHaikuModel = updates.haikuModel !== undefined - ? updates.haikuModel + ? canonicalizeModelForProvider(providerForValidation, updates.haikuModel) + : undefined; + const deniedReason = getDeniedModelReasonForProvider(providerForValidation, [ + canonicalModel !== undefined ? canonicalModel : settings.env?.ANTHROPIC_MODEL, + canonicalOpusModel !== undefined + ? canonicalOpusModel + : settings.env?.ANTHROPIC_DEFAULT_OPUS_MODEL, + canonicalSonnetModel !== undefined + ? canonicalSonnetModel + : settings.env?.ANTHROPIC_DEFAULT_SONNET_MODEL, + canonicalHaikuModel !== undefined + ? canonicalHaikuModel : settings.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL, ]); if (deniedReason) { @@ -189,8 +220,8 @@ export function updateSettingsFile( if (updates.model !== undefined) { settings.env = settings.env || {}; - if (updates.model) { - settings.env.ANTHROPIC_MODEL = updates.model; + if (canonicalModel) { + settings.env.ANTHROPIC_MODEL = canonicalModel; } else { delete settings.env.ANTHROPIC_MODEL; } @@ -199,8 +230,8 @@ export function updateSettingsFile( // Handle model mapping fields if (updates.opusModel !== undefined) { settings.env = settings.env || {}; - if (updates.opusModel) { - settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = updates.opusModel; + if (canonicalOpusModel) { + settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = canonicalOpusModel; } else { delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL; } @@ -208,8 +239,8 @@ export function updateSettingsFile( if (updates.sonnetModel !== undefined) { settings.env = settings.env || {}; - if (updates.sonnetModel) { - settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = updates.sonnetModel; + if (canonicalSonnetModel) { + settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = canonicalSonnetModel; } else { delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL; } @@ -217,8 +248,8 @@ export function updateSettingsFile( if (updates.haikuModel !== undefined) { settings.env = settings.env || {}; - if (updates.haikuModel) { - settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = updates.haikuModel; + if (canonicalHaikuModel) { + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = canonicalHaikuModel; } else { delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; } @@ -234,7 +265,7 @@ export function updateSettingsFile( const resolvedProvider = resolveDroidProvider({ provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER, baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL, - model: updates.model ?? settings.env.ANTHROPIC_MODEL, + model: canonicalModel ?? settings.env.ANTHROPIC_MODEL, }); settings.env.CCS_DROID_PROVIDER = resolvedProvider; } diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index cc17f718..8b589ca5 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -242,6 +242,51 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); }); + it('migrates stale iflow model aliases (including kimi-k2.5) to supported IDs', () => { + const iflowSettingsPath = path.join(tempHome, 'iflow.settings.json'); + writeSettings( + iflowSettingsPath, + { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'kimi-k2.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7', + }, + { + presets: [ + { + name: 'legacy-iflow', + default: 'kimi-k2.5', + opus: 'iflow-default', + sonnet: 'deepseek-v3.2-chat', + haiku: 'minimax-m2.5', + }, + ], + } + ); + + const env = getEffectiveEnvVars('iflow', 8317, iflowSettingsPath); + expect(env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + + const persisted = JSON.parse(fs.readFileSync(iflowSettingsPath, 'utf-8')) as { + env: Record; + presets: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + expect(persisted.presets[0]?.default).toBe('kimi-k2'); + expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.sonnet).toBe('deepseek-v3.2'); + expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); + }); + it('repairs existing provider settings files that are missing env keys', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index 6978d7db..2697d992 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -103,6 +103,16 @@ describe('model-id-normalizer', () => { 'claude-sonnet-4-6' ); }); + + it('normalizes known legacy iflow model aliases to supported upstream IDs', () => { + expect(normalizeModelIdForProvider('iflow-default', 'iflow')).toBe('qwen3-coder-plus'); + expect(normalizeModelIdForProvider('kimi-k2.5', 'iflow')).toBe('kimi-k2'); + expect(normalizeModelIdForProvider('kimi-k2.5(8192)', 'iflow')).toBe('kimi-k2(8192)'); + expect(canonicalizeModelIdForProvider('deepseek-v3.2-chat', 'iflow')).toBe('deepseek-v3.2'); + expect(canonicalizeModelIdForProvider('glm-4.7', 'iflow')).toBe('glm-4.6'); + expect(canonicalizeModelIdForProvider('minimax-m2.5', 'iflow')).toBe('qwen3-coder-plus'); + expect(canonicalizeModelIdForProvider('kimi-k2.5', 'gemini')).toBe('kimi-k2.5'); + }); }); describe('env normalization', () => { @@ -127,6 +137,21 @@ describe('model-id-normalizer', () => { expect(unchanged.UNRELATED).toBe('keep-me'); }); + it('normalizes iflow legacy model aliases in env vars', () => { + const input: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'kimi-k2.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7', + }; + + const normalized = normalizeModelEnvVarsForProvider(input, 'iflow'); + expect(normalized.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + }); + it('flags denylisted antigravity models', () => { expect(isDeniedAntigravityModelId('claude-opus-4-5-thinking')).toBe(true); expect(isDeniedAntigravityModelId('claude-sonnet-4.5')).toBe(true); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 28651028..09029bd8 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -147,4 +147,34 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); }); + + it('canonicalizes legacy iflow model IDs to supported upstream IDs', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'iflow.settings.json'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3-coder-plus', + }); + + const response = await fetch(`${baseUrl}/api/cliproxy/models/iflow`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'kimi-k2.5' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { model: string }; + expect(body.model).toBe('kimi-k2'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('kimi-k2'); + }); }); diff --git a/tests/unit/web-server/route-helpers-model-denylist.test.ts b/tests/unit/web-server/route-helpers-model-denylist.test.ts index c84fa8db..54c40cf7 100644 --- a/tests/unit/web-server/route-helpers-model-denylist.test.ts +++ b/tests/unit/web-server/route-helpers-model-denylist.test.ts @@ -71,4 +71,64 @@ describe('route-helpers AGY denylist', () => { /denylist/i ); }); + + it('canonicalizes legacy iflow model IDs on settings create', () => { + createSettingsFile( + 'iflow-profile', + 'http://127.0.0.1:8317/api/provider/iflow', + 'test-token', + { + model: 'kimi-k2.5', + opusModel: 'iflow-default', + sonnetModel: 'deepseek-v3.2-chat', + haikuModel: 'glm-4.7', + } + ); + + const settingsPath = path.join(tempHome, '.ccs', 'iflow-profile.settings.json'); + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + }); + + it('canonicalizes legacy iflow model IDs on settings update', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'iflow-profile.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3-coder-plus', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3-coder-plus', + }, + }, + null, + 2 + ) + '\n' + ); + + updateSettingsFile('iflow-profile', { + model: 'kimi-k2.5', + sonnetModel: 'deepseek-v3.2-chat', + haikuModel: 'minimax-m2.5', + }); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); + }); }); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts index 2cd7e1d8..bf70d518 100644 --- a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -108,6 +108,52 @@ describe('settings-routes model canonicalization', () => { expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5'); }); + it('canonicalizes legacy iflow model IDs on PUT /:profile', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'iflow.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/iflow`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'kimi-k2.5', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v3.2-chat', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.7', + }, + presets: [ + { + name: 'legacy-iflow', + default: 'kimi-k2.5', + opus: 'iflow-default', + sonnet: 'deepseek-v3.2-chat', + haiku: 'minimax-m2.5', + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('kimi-k2'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('glm-4.6'); + expect(persisted.presets[0]?.default).toBe('kimi-k2'); + expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus'); + expect(persisted.presets[0]?.sonnet).toBe('deepseek-v3.2'); + expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); + }); + it('canonicalizes AGY preset values on POST /:profile/presets', async () => { const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); writeSettings(settingsPath, { diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 87a902d0..016d0564 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -262,20 +262,15 @@ export const MODEL_CATALOGS: Record = { name: 'Qwen3 Max', description: 'Flagship Qwen model via iFlow', }, - { - id: 'kimi-k2.5', - name: 'Kimi K2.5', - description: 'Latest Kimi model via iFlow', - }, { id: 'kimi-k2', name: 'Kimi K2', - description: 'Kimi general model', + description: 'Kimi model currently available via iFlow', }, { - id: 'deepseek-v3.2-chat', - name: 'DeepSeek V3.2 Chat', - description: 'Stable DeepSeek chat model', + id: 'deepseek-v3.2', + name: 'DeepSeek V3.2', + description: 'Current DeepSeek V3.2 model via iFlow', }, { id: 'deepseek-r1', @@ -283,14 +278,9 @@ export const MODEL_CATALOGS: Record = { description: 'Reasoning-focused DeepSeek model', }, { - id: 'glm-4.7', - name: 'GLM 4.7', - description: 'Zhipu GLM 4.7 via iFlow', - }, - { - id: 'minimax-m2.5', - name: 'MiniMax M2.5', - description: 'MiniMax M2.5 via iFlow', + id: 'glm-4.6', + name: 'GLM 4.6', + description: 'Zhipu GLM 4.6 via iFlow', }, { id: 'qwen3-vl-plus', diff --git a/ui/tests/unit/ui/lib/model-catalogs-iflow.test.ts b/ui/tests/unit/ui/lib/model-catalogs-iflow.test.ts new file mode 100644 index 00000000..5df1ba6e --- /dev/null +++ b/ui/tests/unit/ui/lib/model-catalogs-iflow.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { MODEL_CATALOGS } from '@/lib/model-catalogs'; + +describe('iflow model catalog compatibility', () => { + it('does not advertise stale iflow model aliases that fail provider routing', () => { + const iflowCatalog = MODEL_CATALOGS.iflow; + expect(iflowCatalog).toBeDefined(); + + const ids = iflowCatalog.models.map((model) => model.id); + expect(ids).not.toContain('kimi-k2.5'); + expect(ids).not.toContain('deepseek-v3.2-chat'); + expect(ids).not.toContain('glm-4.7'); + expect(ids).not.toContain('minimax-m2.5'); + }); + + it('keeps supported iflow model IDs for Kimi and DeepSeek entries', () => { + const iflowCatalog = MODEL_CATALOGS.iflow; + const ids = iflowCatalog.models.map((model) => model.id); + + expect(ids).toContain('kimi-k2'); + expect(ids).toContain('deepseek-v3.2'); + expect(ids).toContain('glm-4.6'); + }); +});