diff --git a/packages/core/src/models/modelsConfig.test.ts b/packages/core/src/models/modelsConfig.test.ts index 1159f639238..9d5434b52fa 100644 --- a/packages/core/src/models/modelsConfig.test.ts +++ b/packages/core/src/models/modelsConfig.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ModelsConfig } from './modelsConfig.js'; import { AuthType } from '../core/contentGenerator.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; @@ -1331,6 +1331,123 @@ describe('ModelsConfig', () => { expect(modelsConfig.getGenerationConfig().model).toBe('custom-model'); }); + it('recomputes raw model modalities instead of carrying provider multimodal defaults', async () => { + const modelProvidersConfig: ModelProvidersConfig = { + openai: [ + { + id: 'qwen3.6-plus', + name: 'Qwen 3.6 Plus', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + envKey: 'DASHSCOPE_API_KEY', + generationConfig: { + contextWindowSize: 12345, + modalities: { image: true, video: true }, + }, + }, + ], + }; + + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + modelProvidersConfig, + }); + + await modelsConfig.switchModel(AuthType.USE_OPENAI, 'qwen3.6-plus'); + expect(modelsConfig.getGenerationConfig().modalities).toEqual({ + image: true, + video: true, + }); + + await modelsConfig.setModel('qwen3.7-max'); + + expect(modelsConfig.getModel()).toBe('qwen3.7-max'); + expect(modelsConfig.getGenerationConfig().modalities).toEqual({}); + expect(modelsConfig.getGenerationConfigSources()['modalities']).toEqual({ + kind: 'computed', + detail: 'auto-detected from model', + }); + expect(modelsConfig.getGenerationConfig().contextWindowSize).not.toBe( + 12345, + ); + expect( + modelsConfig.getGenerationConfigSources()['contextWindowSize'], + ).toEqual({ + kind: 'computed', + detail: 'auto-detected from model', + }); + }); + + it('notifies the owner to refresh after a raw model switch', async () => { + const onModelChange = vi.fn(); + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'qwen3.6-plus', + modalities: { image: true, video: true }, + }, + onModelChange, + }); + + await modelsConfig.setModel('qwen3.7-max'); + + expect(onModelChange).toHaveBeenCalledWith(AuthType.USE_OPENAI, true); + }); + + it('preserves explicitly configured modalities during raw model switches', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'custom-vision-model', + modalities: { image: true }, + }, + generationConfigSources: { + modalities: { + kind: 'settings', + settingsPath: 'model.generationConfig.modalities', + }, + }, + }); + + await modelsConfig.setModel('custom-vision-model-v2'); + + expect(modelsConfig.getGenerationConfig().modalities).toEqual({ + image: true, + }); + expect(modelsConfig.getGenerationConfigSources()['modalities']).toEqual({ + kind: 'settings', + settingsPath: 'model.generationConfig.modalities', + }); + }); + + it('rolls back raw model state when owner refresh fails', async () => { + const modelsConfig = new ModelsConfig({ + initialAuthType: AuthType.USE_OPENAI, + generationConfig: { + model: 'qwen3.6-plus', + modalities: { image: true, video: true }, + }, + generationConfigSources: { + modalities: { + kind: 'computed', + detail: 'auto-detected from model', + }, + }, + onModelChange: async () => { + throw new Error('refresh failed'); + }, + }); + + await expect(modelsConfig.setModel('qwen3.7-max')).rejects.toThrow( + 'refresh failed', + ); + + expect(modelsConfig.getModel()).toBe('qwen3.6-plus'); + expect(modelsConfig.getGenerationConfig().modalities).toEqual({ + image: true, + video: true, + }); + }); + it('should maintain consistency between currentModelId and _generationConfig.model during updateCredentials', () => { const modelsConfig = new ModelsConfig({ initialAuthType: AuthType.USE_OPENAI, diff --git a/packages/core/src/models/modelsConfig.ts b/packages/core/src/models/modelsConfig.ts index 0c90931905b..30a8f236ded 100644 --- a/packages/core/src/models/modelsConfig.ts +++ b/packages/core/src/models/modelsConfig.ts @@ -350,12 +350,61 @@ export class ModelsConfig { } // Raw model override: update generation config in-place - this.strictModelProviderSelection = false; - this._generationConfig.model = newModel; - this.generationConfigSources['model'] = { - kind: 'programmatic', - detail: metadata?.reason || 'setModel', - }; + const rollbackSnapshot = this.createStateSnapshotForRollback(); + try { + this.strictModelProviderSelection = false; + this._generationConfig.model = newModel; + this.generationConfigSources['model'] = { + kind: 'programmatic', + detail: metadata?.reason || 'setModel', + }; + this.applyRawModelDerivedDefaults(newModel); + + if (this.onModelChange && this.currentAuthType) { + await this.onModelChange(this.currentAuthType, true); + } + } catch (error) { + this.rollbackToStateSnapshot(rollbackSnapshot); + throw error; + } + } + + /** + * Raw model switches keep the current credentials, but model-derived + * generation defaults must follow the new model. Otherwise a switch from a + * multimodal registry model to a text-only raw model can keep stale image + * support and send unsupported inline media. + */ + private applyRawModelDerivedDefaults(modelId: string): void { + if (this.shouldUpdateModelDerivedDefault('modalities')) { + this._generationConfig.modalities = defaultModalities(modelId); + this.generationConfigSources['modalities'] = { + kind: 'computed', + detail: 'auto-detected from model', + }; + } + + if (this.shouldUpdateModelDerivedDefault('contextWindowSize')) { + this._generationConfig.contextWindowSize = tokenLimit(modelId, 'input'); + this.generationConfigSources['contextWindowSize'] = { + kind: 'computed', + detail: 'auto-detected from model', + }; + } + } + + private shouldUpdateModelDerivedDefault( + field: 'modalities' | 'contextWindowSize', + ): boolean { + const source = this.generationConfigSources[field]; + return ( + source === undefined || + source.kind === 'computed' || + source.kind === 'default' || + source.kind === 'modelProviders' || + source.kind === 'programmatic' || + source.kind === 'unknown' + ); } /**