mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(cliproxy): normalize stale iflow model aliases
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user