diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index b1e363ed..d291def2 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -227,9 +227,9 @@ export function normalizeModelIdForRouting(model: string, provider: ProviderLike if (isAntigravityProvider(provider)) { return normalizeModelIdForProvider(trimmedModel, provider); } - // Explicit non-AGY provider routes should pass through unchanged. + // Explicit provider routes still apply provider-specific canonicalization. if (typeof provider === 'string' && provider.trim().length > 0) { - return trimmedModel; + return normalizeModelIdForProvider(trimmedModel, provider); } const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedThinking); diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 8820ef0c..7a5e42b2 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -40,6 +40,19 @@ function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null { } } +function resolveProviderForModelCanonicalization( + baseUrl: unknown, + providerHint: unknown +): CLIProxyProvider | null { + if (typeof providerHint === 'string' && providerHint.trim().length > 0) { + const fromProviderHint = mapExternalProviderName(providerHint); + if (fromProviderHint) { + return fromProviderHint; + } + } + return resolveProviderFromBaseUrl(baseUrl); +} + function getDeniedModelReasonForProvider( provider: CLIProxyProvider | null, values: Array @@ -114,12 +127,24 @@ export function createSettingsFile( ): string { 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, [ + const providerForModelCanonicalization = resolveProviderForModelCanonicalization( + baseUrl, + provider + ); + const canonicalModel = canonicalizeModelForProvider(providerForModelCanonicalization, model); + const canonicalOpusModel = canonicalizeModelForProvider( + providerForModelCanonicalization, + opusModel + ); + const canonicalSonnetModel = canonicalizeModelForProvider( + providerForModelCanonicalization, + sonnetModel + ); + const canonicalHaikuModel = canonicalizeModelForProvider( + providerForModelCanonicalization, + haikuModel + ); + const deniedReason = getDeniedModelReasonForProvider(providerForModelCanonicalization, [ canonicalModel, canonicalOpusModel, canonicalSonnetModel, @@ -174,8 +199,11 @@ export function updateSettingsFile( const settings = loadSettings(settingsPath); const providerForValidation = - resolveProviderFromBaseUrl(updates.baseUrl) ?? - resolveProviderFromBaseUrl(settings.env?.ANTHROPIC_BASE_URL); + resolveProviderForModelCanonicalization(updates.baseUrl, updates.provider) ?? + resolveProviderForModelCanonicalization( + settings.env?.ANTHROPIC_BASE_URL, + updates.provider ?? settings.env?.CCS_DROID_PROVIDER + ); const canonicalModel = updates.model !== undefined ? canonicalizeModelForProvider(providerForValidation, updates.model) diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index 2697d992..6d4cc361 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -52,6 +52,8 @@ describe('model-id-normalizer', () => { ); expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6'); expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6'); + expect(normalizeModelIdForRouting('kimi-k2.5', 'iflow')).toBe('kimi-k2'); + expect(normalizeModelIdForRouting('kimi-k2.5(8192)', 'iflow')).toBe('kimi-k2(8192)'); expect(normalizeModelIdForRouting('claude-sonnet-4-6-thinking', 'claude')).toBe( 'claude-sonnet-4-6-thinking' ); diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 1e35be92..7f591232 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -303,6 +303,29 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('normalizes legacy iFlow aliases on explicit iflow provider routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/iflow/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'kimi-k2.5', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe('kimi-k2'); + } finally { + proxy.stop(); + } + }); + it('preserves other tool properties during sanitization', async () => { const proxy = new ToolSanitizationProxy({ upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, 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 54c40cf7..02aea513 100644 --- a/tests/unit/web-server/route-helpers-model-denylist.test.ts +++ b/tests/unit/web-server/route-helpers-model-denylist.test.ts @@ -31,17 +31,12 @@ describe('route-helpers AGY denylist', () => { it('rejects denylisted AGY models on settings create', () => { expect(() => - createSettingsFile( - 'agy-denied', - 'http://127.0.0.1:8317/api/provider/agy', - 'test-token', - { - model: 'claude-sonnet-4.5', - opusModel: 'claude-opus-4.5', - sonnetModel: 'claude-sonnet-4.5', - haikuModel: 'claude-haiku-4.5', - } - ) + createSettingsFile('agy-denied', 'http://127.0.0.1:8317/api/provider/agy', 'test-token', { + model: 'claude-sonnet-4.5', + opusModel: 'claude-opus-4.5', + sonnetModel: 'claude-sonnet-4.5', + haikuModel: 'claude-haiku-4.5', + }) ).toThrow(/denylist/i); }); @@ -73,19 +68,39 @@ describe('route-helpers AGY denylist', () => { }); 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 create with root URL + provider hint', () => { createSettingsFile( - 'iflow-profile', - 'http://127.0.0.1:8317/api/provider/iflow', + 'iflow-profile-root', + 'http://127.0.0.1:8317', 'test-token', { model: 'kimi-k2.5', opusModel: 'iflow-default', sonnetModel: 'deepseek-v3.2-chat', haikuModel: 'glm-4.7', - } + }, + 'iflow' ); - const settingsPath = path.join(tempHome, '.ccs', 'iflow-profile.settings.json'); + const settingsPath = path.join(tempHome, '.ccs', 'iflow-profile-root.settings.json'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { env: Record; }; @@ -131,4 +146,41 @@ describe('route-helpers AGY denylist', () => { expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); }); + + it('canonicalizes legacy iflow model IDs on settings update with root URL + provider hint', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'iflow-root.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', + 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-root', { + model: 'kimi-k2.5', + sonnetModel: 'deepseek-v3.2-chat', + haikuModel: 'minimax-m2.5', + provider: 'iflow', + }); + + 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'); + }); });