fix(cliproxy): normalize stale iflow model aliases

This commit is contained in:
Tam Nhu Tran committed 2026-03-03 23:26:07 +07:00
1 parent 34971c8be4
commit 546c4ee4ee
11 files changed
+368 -61

No files matched your search

+7 -9
View File
@@ -25,6 +25,7 @@ import {
MODEL_ENV_VAR_KEYS, MODEL_ENV_VAR_KEYS,
migrateDeniedAntigravityModelAliases, migrateDeniedAntigravityModelAliases,
normalizeModelEnvVarsForProvider, normalizeModelEnvVarsForProvider,
normalizeIFlowLegacyModelAliases,
normalizeModelIdForProvider, normalizeModelIdForProvider,
} from '../model-id-normalizer'; } from '../model-id-normalizer';
@@ -39,8 +40,6 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
/** Replacement prefix matching actual upstream model names */ /** Replacement prefix matching actual upstream model names */
const UPSTREAM_MODEL_PREFIX = 'claude-'; const UPSTREAM_MODEL_PREFIX = 'claude-';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; 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 PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
const REQUIRED_PROVIDER_ENV_KEYS = [ const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_BASE_URL', 'ANTHROPIC_BASE_URL',
@@ -172,8 +171,8 @@ function migrateCodexEffortSuffixes(
} }
/** /**
* Migrate legacy iFlow placeholder model IDs to a real default model. * Migrate legacy iFlow model IDs to current upstream model IDs.
* Example: iflow-default -> qwen3-coder-plus * Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2
*/ */
function migrateIFlowPlaceholderModel( function migrateIFlowPlaceholderModel(
settingsPath: string, settingsPath: string,
@@ -184,14 +183,13 @@ function migrateIFlowPlaceholderModel(
if (!settings.env || typeof settings.env !== 'object') return false; if (!settings.env || typeof settings.env !== 'object') return false;
let migrated = false; let migrated = false;
const normalize = (value: string): string => value.trim().toLowerCase(); const replaceLegacyIFlowModel = (value: string): string =>
const replaceIfPlaceholder = (value: string): string => normalizeIFlowLegacyModelAliases(value);
normalize(value) === IFLOW_PLACEHOLDER_MODEL ? IFLOW_DEFAULT_MODEL : value;
for (const key of MODEL_ENV_VAR_KEYS) { for (const key of MODEL_ENV_VAR_KEYS) {
const value = settings.env[key]; const value = settings.env[key];
if (typeof value !== 'string') continue; if (typeof value !== 'string') continue;
const replaced = replaceIfPlaceholder(value); const replaced = replaceLegacyIFlowModel(value);
if (replaced !== value) { if (replaced !== value) {
settings.env[key] = replaced; settings.env[key] = replaced;
migrated = true; migrated = true;
@@ -206,7 +204,7 @@ function migrateIFlowPlaceholderModel(
for (const key of PRESET_MODEL_KEYS) { for (const key of PRESET_MODEL_KEYS) {
const value = presetRecord[key]; const value = presetRecord[key];
if (typeof value !== 'string') continue; if (typeof value !== 'string') continue;
const replaced = replaceIfPlaceholder(value); const replaced = replaceLegacyIFlowModel(value);
if (replaced !== value) { if (replaced !== value) {
presetRecord[key] = replaced; presetRecord[key] = replaced;
migrated = true; migrated = true;
+46
View File
@@ -31,11 +31,34 @@ const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; /claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking'; const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const IFLOW_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = 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 { function trimModelId(model: string): string {
return model.trim(); 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. * Extract provider segment from `/api/provider/{provider}` request paths.
* *
@@ -62,11 +85,31 @@ export function isCodexProvider(provider: ProviderLike): boolean {
return provider.trim().toLowerCase() === 'codex'; 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. */ /** Normalize Codex effort-suffixed IDs to canonical IDs. */
export function stripCodexEffortSuffix(model: string): string { export function stripCodexEffortSuffix(model: string): string {
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); 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. */ /** Normalize Claude dotted major.minor IDs to hyphenated format. */
export function normalizeClaudeDottedMajorMinor(model: string): string { export function normalizeClaudeDottedMajorMinor(model: string): string {
return model.replace( return model.replace(
@@ -144,6 +187,9 @@ export function getDeniedModelIdReasonForProvider(
*/ */
export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string {
const trimmedModel = trimModelId(model); const trimmedModel = trimModelId(model);
if (isIFlowProvider(provider)) {
return normalizeIFlowLegacyModelAliases(trimmedModel);
}
if (!isAntigravityProvider(provider)) return trimmedModel; if (!isAntigravityProvider(provider)) return trimmedModel;
const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel); const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel);
return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion); return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion);
+22 -10
View File
@@ -20,7 +20,10 @@ import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker'
import { warn } from '../../utils/ui'; import { warn } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager'; import { getCcsDir } from '../../utils/config-manager';
import { validateCompositeTiers } from '../composite-validator'; import { validateCompositeTiers } from '../composite-validator';
import { getDeniedModelIdReasonForProvider } from '../model-id-normalizer'; import {
canonicalizeModelIdForProvider,
getDeniedModelIdReasonForProvider,
} from '../model-id-normalizer';
import { import {
createSettingsFile, createSettingsFile,
createSettingsFileUnified, createSettingsFileUnified,
@@ -116,7 +119,8 @@ export function createVariant(
target: TargetType = 'claude' target: TargetType = 'claude'
): VariantOperationResult { ): VariantOperationResult {
try { try {
const deniedModelReason = getDeniedModelIdReasonForProvider(model, provider); const canonicalModel = canonicalizeModelIdForProvider(model, provider);
const deniedModelReason = getDeniedModelIdReasonForProvider(canonicalModel, provider);
if (deniedModelReason) { if (deniedModelReason) {
return { success: false, error: deniedModelReason }; return { success: false, error: deniedModelReason };
} }
@@ -133,7 +137,7 @@ export function createVariant(
let settingsPath: string; let settingsPath: string;
if (isUnifiedMode()) { if (isUnifiedMode()) {
settingsPath = createSettingsFileUnified(name, provider, model, port); settingsPath = createSettingsFileUnified(name, provider, canonicalModel, port);
saveVariantUnified( saveVariantUnified(
name, name,
provider as CLIProxyProvider, provider as CLIProxyProvider,
@@ -143,7 +147,7 @@ export function createVariant(
target target
); );
} else { } else {
settingsPath = createSettingsFile(name, provider, model, port); settingsPath = createSettingsFile(name, provider, canonicalModel, port);
saveVariantLegacy( saveVariantLegacy(
name, name,
provider, provider,
@@ -157,7 +161,7 @@ export function createVariant(
return { return {
success: true, success: true,
settingsPath, settingsPath,
variant: { provider, model, account, port, target }, variant: { provider, model: canonicalModel, account, port, target },
}; };
} catch (error) { } catch (error) {
return { return {
@@ -253,6 +257,11 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
const existingTarget = existing.target || 'claude'; const existingTarget = existing.target || 'claude';
const targetChanged = updates.target !== undefined && updates.target !== existingTarget; const targetChanged = updates.target !== undefined && updates.target !== existingTarget;
const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; 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) { if (providerChanged && !hasModelUpdate) {
return { return {
@@ -262,9 +271,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
} }
if (hasModelUpdate) { if (hasModelUpdate) {
const providerForModelUpdate = updates.provider ?? (existing.provider as CLIProxyProfileName);
const deniedModelReason = getDeniedModelIdReasonForProvider( const deniedModelReason = getDeniedModelIdReasonForProvider(
updates.model?.trim() || '', canonicalModelUpdate || '',
providerForModelUpdate providerForModelUpdate
); );
if (deniedModelReason) { if (deniedModelReason) {
@@ -279,11 +287,15 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
updateSettingsProviderAndModel( updateSettingsProviderAndModel(
settingsPath, settingsPath,
updates.provider as CLIProxyProfileName, updates.provider as CLIProxyProfileName,
updates.model?.trim() || '', canonicalModelUpdate || '',
existing.port existing.port
); );
} else if (updates.model !== undefined) { } 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, success: true,
variant: { variant: {
provider: updates.provider ?? existing.provider, provider: updates.provider ?? existing.provider,
model: updates.model?.trim() || existing.model, model: canonicalModelUpdate || existing.model,
account: updates.account !== undefined ? updates.account : existing.account, account: updates.account !== undefined ? updates.account : existing.account,
port: existing.port, port: existing.port,
settings: existing.settings, settings: existing.settings,
+56 -25
View File
@@ -10,6 +10,7 @@ import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import { resolveDroidProvider } from '../../targets/droid-provider'; import { resolveDroidProvider } from '../../targets/droid-provider';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { import {
canonicalizeModelIdForProvider,
extractProviderFromPathname, extractProviderFromPathname,
getDeniedModelIdReasonForProvider, getDeniedModelIdReasonForProvider,
} from '../../cliproxy/model-id-normalizer'; } from '../../cliproxy/model-id-normalizer';
@@ -52,6 +53,15 @@ function getDeniedModelReasonForProvider(
return null; 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. * Read config safely with fallback.
* Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). * 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 settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
const { model, opusModel, sonnetModel, haikuModel } = models; const { model, opusModel, sonnetModel, haikuModel } = models;
const providerFromBaseUrl = resolveProviderFromBaseUrl(baseUrl); 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, [ const deniedReason = getDeniedModelReasonForProvider(providerFromBaseUrl, [
model, canonicalModel,
opusModel, canonicalOpusModel,
sonnetModel, canonicalSonnetModel,
haikuModel, canonicalHaikuModel,
]); ]);
if (deniedReason) { if (deniedReason) {
throw new Error(deniedReason); throw new Error(deniedReason);
@@ -117,21 +131,22 @@ export function createSettingsFile(
const droidProvider = resolveDroidProvider({ const droidProvider = resolveDroidProvider({
provider, provider,
baseUrl, baseUrl,
model, model: canonicalModel,
}); });
const settings: Settings = { const settings: Settings = {
env: { env: {
ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_AUTH_TOKEN: apiKey,
...(model && { ANTHROPIC_MODEL: model }), ...(canonicalModel && { ANTHROPIC_MODEL: canonicalModel }),
...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }), ...(canonicalOpusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: canonicalOpusModel }),
...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }), ...(canonicalSonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: canonicalSonnetModel }),
...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }), ...(canonicalHaikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: canonicalHaikuModel }),
CCS_DROID_PROVIDER: droidProvider, CCS_DROID_PROVIDER: droidProvider,
}, },
}; };
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
return `~/.ccs/${name}.settings.json`; return `~/.ccs/${name}.settings.json`;
} }
@@ -161,16 +176,32 @@ export function updateSettingsFile(
const providerForValidation = const providerForValidation =
resolveProviderFromBaseUrl(updates.baseUrl) ?? resolveProviderFromBaseUrl(updates.baseUrl) ??
resolveProviderFromBaseUrl(settings.env?.ANTHROPIC_BASE_URL); resolveProviderFromBaseUrl(settings.env?.ANTHROPIC_BASE_URL);
const deniedReason = getDeniedModelReasonForProvider(providerForValidation, [ const canonicalModel =
updates.model !== undefined ? updates.model : settings.env?.ANTHROPIC_MODEL, updates.model !== undefined
? canonicalizeModelForProvider(providerForValidation, updates.model)
: undefined;
const canonicalOpusModel =
updates.opusModel !== undefined updates.opusModel !== undefined
? updates.opusModel ? canonicalizeModelForProvider(providerForValidation, updates.opusModel)
: settings.env?.ANTHROPIC_DEFAULT_OPUS_MODEL, : undefined;
const canonicalSonnetModel =
updates.sonnetModel !== undefined updates.sonnetModel !== undefined
? updates.sonnetModel ? canonicalizeModelForProvider(providerForValidation, updates.sonnetModel)
: settings.env?.ANTHROPIC_DEFAULT_SONNET_MODEL, : undefined;
const canonicalHaikuModel =
updates.haikuModel !== undefined 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, : settings.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL,
]); ]);
if (deniedReason) { if (deniedReason) {
@@ -189,8 +220,8 @@ export function updateSettingsFile(
if (updates.model !== undefined) { if (updates.model !== undefined) {
settings.env = settings.env || {}; settings.env = settings.env || {};
if (updates.model) { if (canonicalModel) {
settings.env.ANTHROPIC_MODEL = updates.model; settings.env.ANTHROPIC_MODEL = canonicalModel;
} else { } else {
delete settings.env.ANTHROPIC_MODEL; delete settings.env.ANTHROPIC_MODEL;
} }
@@ -199,8 +230,8 @@ export function updateSettingsFile(
// Handle model mapping fields // Handle model mapping fields
if (updates.opusModel !== undefined) { if (updates.opusModel !== undefined) {
settings.env = settings.env || {}; settings.env = settings.env || {};
if (updates.opusModel) { if (canonicalOpusModel) {
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = updates.opusModel; settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = canonicalOpusModel;
} else { } else {
delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL; delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
} }
@@ -208,8 +239,8 @@ export function updateSettingsFile(
if (updates.sonnetModel !== undefined) { if (updates.sonnetModel !== undefined) {
settings.env = settings.env || {}; settings.env = settings.env || {};
if (updates.sonnetModel) { if (canonicalSonnetModel) {
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = updates.sonnetModel; settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = canonicalSonnetModel;
} else { } else {
delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL; delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
} }
@@ -217,8 +248,8 @@ export function updateSettingsFile(
if (updates.haikuModel !== undefined) { if (updates.haikuModel !== undefined) {
settings.env = settings.env || {}; settings.env = settings.env || {};
if (updates.haikuModel) { if (canonicalHaikuModel) {
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = updates.haikuModel; settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = canonicalHaikuModel;
} else { } else {
delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
} }
@@ -234,7 +265,7 @@ export function updateSettingsFile(
const resolvedProvider = resolveDroidProvider({ const resolvedProvider = resolveDroidProvider({
provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER, provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER,
baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL, 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; settings.env.CCS_DROID_PROVIDER = resolvedProvider;
} }
@@ -242,6 +242,51 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); 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<string, string>;
presets: Array<Record<string, string>>;
};
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', () => { it('repairs existing provider settings files that are missing env keys', () => {
process.env.CCS_HOME = tempHome; process.env.CCS_HOME = tempHome;
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
@@ -103,6 +103,16 @@ describe('model-id-normalizer', () => {
'claude-sonnet-4-6' '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', () => { describe('env normalization', () => {
@@ -127,6 +137,21 @@ describe('model-id-normalizer', () => {
expect(unchanged.UNRELATED).toBe('keep-me'); 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', () => { it('flags denylisted antigravity models', () => {
expect(isDeniedAntigravityModelId('claude-opus-4-5-thinking')).toBe(true); expect(isDeniedAntigravityModelId('claude-opus-4-5-thinking')).toBe(true);
expect(isDeniedAntigravityModelId('claude-sonnet-4.5')).toBe(true); expect(isDeniedAntigravityModelId('claude-sonnet-4.5')).toBe(true);
@@ -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_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); 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<string, string>;
};
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');
});
}); });
@@ -71,4 +71,64 @@ describe('route-helpers AGY denylist', () => {
/denylist/i /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<string, string>;
};
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<string, string>;
};
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');
});
}); });
@@ -108,6 +108,52 @@ describe('settings-routes model canonicalization', () => {
expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5'); 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<string, string>;
presets: Array<Record<string, string>>;
};
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 () => { it('canonicalizes AGY preset values on POST /:profile/presets', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
writeSettings(settingsPath, { writeSettings(settingsPath, {
+7 -17
View File
@@ -262,20 +262,15 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'Qwen3 Max', name: 'Qwen3 Max',
description: 'Flagship Qwen model via iFlow', description: 'Flagship Qwen model via iFlow',
}, },
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
description: 'Latest Kimi model via iFlow',
},
{ {
id: 'kimi-k2', id: 'kimi-k2',
name: 'Kimi K2', name: 'Kimi K2',
description: 'Kimi general model', description: 'Kimi model currently available via iFlow',
}, },
{ {
id: 'deepseek-v3.2-chat', id: 'deepseek-v3.2',
name: 'DeepSeek V3.2 Chat', name: 'DeepSeek V3.2',
description: 'Stable DeepSeek chat model', description: 'Current DeepSeek V3.2 model via iFlow',
}, },
{ {
id: 'deepseek-r1', id: 'deepseek-r1',
@@ -283,14 +278,9 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
description: 'Reasoning-focused DeepSeek model', description: 'Reasoning-focused DeepSeek model',
}, },
{ {
id: 'glm-4.7', id: 'glm-4.6',
name: 'GLM 4.7', name: 'GLM 4.6',
description: 'Zhipu GLM 4.7 via iFlow', description: 'Zhipu GLM 4.6 via iFlow',
},
{
id: 'minimax-m2.5',
name: 'MiniMax M2.5',
description: 'MiniMax M2.5 via iFlow',
}, },
{ {
id: 'qwen3-vl-plus', id: 'qwen3-vl-plus',
@@ -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');
});
});