fix(cliproxy): normalize stale iflow model aliases

This commit is contained in:
Tam Nhu Tran
2026-03-03 23:26:07 +07:00
parent 34971c8be4
commit 546c4ee4ee
11 changed files with 368 additions and 61 deletions
+7 -9
View File
@@ -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;
+46
View File
@@ -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<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 {
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);
+22 -10
View File
@@ -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,
+56 -25
View File
@@ -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;
}
@@ -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<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', () => {
process.env.CCS_HOME = tempHome;
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
@@ -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);
@@ -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<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
);
});
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');
});
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 () => {
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
writeSettings(settingsPath, {
+7 -17
View File
@@ -262,20 +262,15 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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',
@@ -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');
});
});