diff --git a/packages/core/src/services/model/manager.ts b/packages/core/src/services/model/manager.ts index 9f3bd155..9139718c 100644 --- a/packages/core/src/services/model/manager.ts +++ b/packages/core/src/services/model/manager.ts @@ -1,4 +1,4 @@ -import { IModelManager, ModelConfig, TextModelConfig } from './types'; +import { IModelManager, ModelConfig, TextModel, TextModelConfig, TextProvider } from './types'; import { IStorageProvider } from '../storage/types'; import { StorageAdapter } from '../storage/adapter'; import { getAllModels, getBuiltinModelIds } from './defaults'; @@ -285,6 +285,252 @@ export class ModelManager implements IModelManager { } } + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + } + + private toOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined + } + + private getProviderIdFromRaw(raw: Record): string | undefined { + const providerId = this.toOptionalString(raw.providerId) + || this.toOptionalString(raw.providerMeta?.id) + || this.toOptionalString(raw.modelMeta?.providerId) + || this.toOptionalString(raw.provider) + + return providerId === 'custom' ? 'openai-compatible' : providerId + } + + private getModelIdFromRaw(raw: Record): string | undefined { + return this.toOptionalString(raw.modelId) + || this.toOptionalString(raw.modelMeta?.id) + || this.toOptionalString(raw.defaultModel) + } + + private createDisabledPlaceholderConfig( + key: string, + rawConfig: unknown, + error?: unknown + ): TextModelConfig { + const raw = this.isRecord(rawConfig) ? rawConfig : {} + const providerId = this.getProviderIdFromRaw(raw) || 'unknown' + const modelId = this.toOptionalString(raw.modelId) + || this.toOptionalString(raw.modelMeta?.id) + || this.toOptionalString(raw.defaultModel) + || 'unknown' + const providerMeta = this.resolvePlaceholderProviderMeta(providerId, raw.providerMeta) + const modelMeta = this.resolvePlaceholderModelMeta(providerId, modelId, raw.modelMeta) + const connectionConfig = this.isRecord(raw.connectionConfig) + ? { ...raw.connectionConfig } + : { + ...(this.toOptionalString(raw.apiKey) ? { apiKey: raw.apiKey } : {}), + ...(this.toOptionalString(raw.baseURL) ? { baseURL: raw.baseURL } : {}) + } + const paramOverrides = this.isRecord(raw.paramOverrides) + ? { ...raw.paramOverrides } + : this.isRecord(raw.llmParams) + ? { ...raw.llmParams } + : {} + + console.warn( + `[ModelManager] Failed to normalize model config ${key}, returning disabled placeholder:`, + error + ) + + return { + id: this.toOptionalString(raw.id) || key, + name: this.toOptionalString(raw.name) || `Invalid Model (${key})`, + enabled: false, + providerId, + modelId, + providerMeta, + modelMeta, + connectionConfig, + paramOverrides + } + } + + private buildDefaultBackfilledConfig( + key: string, + rawConfig: unknown + ): TextModelConfig | null { + if (!this.isRecord(rawConfig)) { + return null + } + + const defaultConfig = this.getDefaultModels()[key] + if (!defaultConfig) { + return null + } + + const providerId = this.getProviderIdFromRaw(rawConfig) + || defaultConfig.providerId + || defaultConfig.providerMeta.id + const modelId = this.getModelIdFromRaw(rawConfig) + || defaultConfig.modelId + || defaultConfig.modelMeta.id + const repairedConfig = { + ...defaultConfig, + ...rawConfig, + id: this.toOptionalString(rawConfig.id) || defaultConfig.id, + name: this.toOptionalString(rawConfig.name) || defaultConfig.name, + enabled: typeof rawConfig.enabled === 'boolean' ? rawConfig.enabled : defaultConfig.enabled, + providerId, + modelId, + providerMeta: this.isRecord(rawConfig.providerMeta) && rawConfig.providerMeta.id === providerId + ? rawConfig.providerMeta as TextProvider + : defaultConfig.providerMeta, + modelMeta: this.isRecord(rawConfig.modelMeta) && rawConfig.modelMeta.id === modelId && rawConfig.modelMeta.providerId === providerId + ? rawConfig.modelMeta as TextModel + : defaultConfig.modelMeta, + connectionConfig: this.isRecord(rawConfig.connectionConfig) + ? { ...defaultConfig.connectionConfig, ...rawConfig.connectionConfig } + : { ...defaultConfig.connectionConfig }, + paramOverrides: this.isRecord(rawConfig.paramOverrides) + ? { ...rawConfig.paramOverrides } + : defaultConfig.paramOverrides, + customParamOverrides: this.isRecord(rawConfig.customParamOverrides) + ? { ...rawConfig.customParamOverrides } + : defaultConfig.customParamOverrides + } + + return this.migrateConfig(repairedConfig) + } + + private resolvePlaceholderProviderMeta( + providerId: string, + existingProviderMeta: unknown + ): TextProvider { + if (this.isRecord(existingProviderMeta) && existingProviderMeta.id === providerId) { + return { + id: providerId, + name: this.toOptionalString(existingProviderMeta.name) || providerId, + description: this.toOptionalString(existingProviderMeta.description), + corsRestricted: existingProviderMeta.corsRestricted, + requiresApiKey: !!existingProviderMeta.requiresApiKey, + defaultBaseURL: this.toOptionalString(existingProviderMeta.defaultBaseURL) || '', + supportsDynamicModels: !!existingProviderMeta.supportsDynamicModels, + connectionSchema: this.isRecord(existingProviderMeta.connectionSchema) + ? existingProviderMeta.connectionSchema as TextProvider['connectionSchema'] + : { required: [], optional: [], fieldTypes: {} }, + apiKeyUrl: this.toOptionalString(existingProviderMeta.apiKeyUrl) + } + } + + try { + return this.registry?.getAdapter(providerId).getProvider() || this.getUnknownProviderMeta(providerId) + } catch { + return this.getUnknownProviderMeta(providerId) + } + } + + private resolvePlaceholderModelMeta( + providerId: string, + modelId: string, + existingModelMeta: unknown + ): TextModel { + if ( + this.isRecord(existingModelMeta) && + existingModelMeta.id === modelId && + existingModelMeta.providerId === providerId + ) { + return { + id: modelId, + name: this.toOptionalString(existingModelMeta.name) || modelId, + description: this.toOptionalString(existingModelMeta.description), + providerId, + capabilities: this.isRecord(existingModelMeta.capabilities) + ? existingModelMeta.capabilities as TextModel['capabilities'] + : { supportsTools: false }, + parameterDefinitions: Array.isArray(existingModelMeta.parameterDefinitions) + ? existingModelMeta.parameterDefinitions + : [], + defaultParameterValues: this.isRecord(existingModelMeta.defaultParameterValues) + ? existingModelMeta.defaultParameterValues + : {} + } + } + + try { + const adapter = this.registry?.getAdapter(providerId) + return adapter?.getModels().find((model) => model.id === modelId) + || adapter?.buildDefaultModel(modelId) + || this.getUnknownModelMeta(providerId, modelId) + } catch { + return this.getUnknownModelMeta(providerId, modelId) + } + } + + private getUnknownProviderMeta(providerId: string): TextProvider { + return { + id: providerId, + name: providerId === 'unknown' ? 'Unknown Provider' : `Unknown Provider (${providerId})`, + description: 'This configuration could not be normalized. Please edit or delete it.', + requiresApiKey: false, + defaultBaseURL: '', + supportsDynamicModels: false, + connectionSchema: { required: [], optional: [], fieldTypes: {} } + } + } + + private getUnknownModelMeta(providerId: string, modelId: string): TextModel { + return { + id: modelId, + name: modelId === 'unknown' ? 'Unknown Model' : `Unknown Model (${modelId})`, + description: 'This configuration could not be normalized. Please edit or delete it.', + providerId, + capabilities: { + supportsTools: false + }, + parameterDefinitions: [], + defaultParameterValues: {} + } + } + + private normalizeStoredTextModelConfig( + key: string, + rawConfig: unknown, + registry: ITextAdapterRegistry + ): TextModelConfig { + try { + let textConfig: TextModelConfig + + if (isTextModelConfig(rawConfig)) { + textConfig = rawConfig as TextModelConfig; + } else if (isLegacyConfig(rawConfig)) { + textConfig = convertLegacyToTextModelConfig(key, rawConfig); + } else { + textConfig = convertLegacyToTextModelConfig(key, rawConfig as ModelConfig); + } + + const migrated = this.migrateConfig(textConfig) + return this.normalizeTextModelConfig( + this.patchProviderMeta(this.patchDeepseekConfig(migrated)), + registry, + { allowLegacyMetadataMismatch: true } + ) + } catch (error) { + const defaultBackfilledConfig = this.buildDefaultBackfilledConfig(key, rawConfig) + if (defaultBackfilledConfig) { + try { + return this.normalizeTextModelConfig( + this.patchProviderMeta(this.patchDeepseekConfig(defaultBackfilledConfig)), + registry, + { allowLegacyMetadataMismatch: true } + ) + } catch (repairError) { + console.warn( + `[ModelManager] Failed to repair default model config ${key}, returning disabled placeholder:`, + repairError + ) + } + } + + return this.createDisabledPlaceholderConfig(key, rawConfig, error) + } + } + private normalizeTextModelConfig( config: Partial & Pick, registry: ITextAdapterRegistry, @@ -538,35 +784,11 @@ export class ModelManager implements IModelManager { async getAllModels(): Promise { await this.ensureInitialized(); const models = await this.getModelsFromStorage(); - - // 转换为 TextModelConfig 数组(先完成格式/字段迁移) - const migratedConfigs = Object.entries(models).map(([key, config]) => { - let textConfig: TextModelConfig - - // 检查是否已经是新格式 - if (isTextModelConfig(config)) { - textConfig = config as TextModelConfig; - } - // 传统格式,转换为新格式 - else if (isLegacyConfig(config)) { - textConfig = convertLegacyToTextModelConfig(key, config); - } - // 未知格式,尝试转换 - else { - textConfig = convertLegacyToTextModelConfig(key, config as ModelConfig); - } - - // 读时迁移:合并 customParamOverrides 到 paramOverrides - return this.migrateConfig(textConfig) - }); - const registry = await this.getRegistry() - return migratedConfigs.map((cfg) => this.normalizeTextModelConfig( - this.patchProviderMeta(this.patchDeepseekConfig(cfg)), - registry, - { allowLegacyMetadataMismatch: true } - )) + return Object.entries(models).map(([key, config]) => + this.normalizeStoredTextModelConfig(key, config, registry) + ) } /** @@ -581,30 +803,8 @@ export class ModelManager implements IModelManager { return undefined; } - let textConfig: TextModelConfig - - // 检查是否已经是新格式 - if (isTextModelConfig(config)) { - textConfig = config as TextModelConfig; - } - // 传统格式,转换为新格式 - else if (isLegacyConfig(config)) { - textConfig = convertLegacyToTextModelConfig(key, config); - } - // 未知格式,尝试转换 - else { - textConfig = convertLegacyToTextModelConfig(key, config as ModelConfig); - } - - // 读时迁移:合并 customParamOverrides 到 paramOverrides - const migrated = this.migrateConfig(textConfig) const registry = await this.getRegistry() - - return this.normalizeTextModelConfig( - this.patchProviderMeta(this.patchDeepseekConfig(migrated)), - registry, - { allowLegacyMetadataMismatch: true } - ) + return this.normalizeStoredTextModelConfig(key, config, registry) } /** diff --git a/packages/core/tests/unit/model/manager.test.ts b/packages/core/tests/unit/model/manager.test.ts index 9fe12ef7..7520dc07 100644 --- a/packages/core/tests/unit/model/manager.test.ts +++ b/packages/core/tests/unit/model/manager.test.ts @@ -524,6 +524,125 @@ describe('ModelManager', () => { expect(exportedImport?.providerMeta.id).toBe('openai-compatible'); expect(exportedImport?.modelMeta.providerId).toBe('openai-compatible'); }); + + it('should isolate malformed metadata configs instead of failing the full model list', async () => { + await storageProvider.setItem('models', JSON.stringify({ + custom_broken: { + id: 'custom_broken', + name: 'Broken Model', + enabled: true, + providerMeta: {}, + modelMeta: {}, + connectionConfig: { + apiKey: 'keep-key', + baseURL: 'https://broken.example.com/v1' + }, + paramOverrides: { + temperature: 0.2 + } + }, + openai: createTextModelConfig('openai', 'OpenAI') + })); + + const allModels = await modelManager.getAllModels(); + const broken = allModels.find(model => model.id === 'custom_broken'); + const enabled = await modelManager.getEnabledModels(); + + expect(broken).toBeDefined(); + expect(broken?.enabled).toBe(false); + expect(broken?.providerId).toBe('unknown'); + expect(broken?.modelId).toBe('unknown'); + expect(broken?.connectionConfig.apiKey).toBe('keep-key'); + expect(broken?.paramOverrides).toEqual({ temperature: 0.2 }); + expect(enabled.some(model => model.id === 'custom_broken')).toBe(false); + expect(allModels.some(model => model.id === 'openai')).toBe(true); + }); + + it('should repair malformed builtin custom config from defaults while preserving user settings', async () => { + await storageProvider.setItem('models', JSON.stringify({ + custom: { + id: 'custom', + name: 'My Custom Gateway', + enabled: true, + providerMeta: {}, + modelMeta: {}, + connectionConfig: { + apiKey: 'keep-custom-key', + baseURL: 'https://gateway.example.com/v1' + }, + paramOverrides: { + temperature: 0.2 + } + } + })); + + const repaired = await modelManager.getModel('custom'); + + expect(repaired?.enabled).toBe(true); + expect(repaired?.name).toBe('My Custom Gateway'); + expect(repaired?.providerId).toBe('openai-compatible'); + expect(repaired?.providerMeta.id).toBe('openai-compatible'); + expect(repaired?.modelId).toBeTruthy(); + expect(repaired?.modelId).not.toBe('unknown'); + expect(repaired?.modelMeta.id).toBe(repaired?.modelId); + expect(repaired?.modelMeta.providerId).toBe('openai-compatible'); + expect(repaired?.connectionConfig.apiKey).toBe('keep-custom-key'); + expect(repaired?.connectionConfig.baseURL).toBe('https://gateway.example.com/v1'); + expect(repaired?.paramOverrides).toEqual({ temperature: 0.2 }); + }); + + it('should preserve partial builtin custom identity and legacy custom parameter overrides during repair', async () => { + await storageProvider.setItem('models', JSON.stringify({ + custom: { + id: 'custom', + name: 'Partial Custom Gateway', + enabled: true, + providerMeta: {}, + modelMeta: { + id: 'user-selected-model' + }, + connectionConfig: { + apiKey: 'keep-custom-key', + baseURL: 'https://gateway.example.com/v1' + }, + customParamOverrides: { + temperature: 0.3, + top_p: 0.8 + } + } + })); + + const repaired = await modelManager.getModel('custom'); + + expect(repaired?.providerId).toBe('openai-compatible'); + expect(repaired?.modelId).toBe('user-selected-model'); + expect(repaired?.modelMeta.id).toBe('user-selected-model'); + expect(repaired?.modelMeta.providerId).toBe('openai-compatible'); + expect(repaired?.connectionConfig.apiKey).toBe('keep-custom-key'); + expect(repaired?.paramOverrides).toMatchObject({ + temperature: 0.3, + top_p: 0.8 + }); + }); + + it('should return a disabled placeholder for a single malformed model config', async () => { + await storageProvider.setItem('models', JSON.stringify({ + brokenSingle: { + id: 'brokenSingle', + name: 'Broken Single', + providerMeta: {}, + modelMeta: {}, + connectionConfig: {} + } + })); + + const broken = await modelManager.getModel('brokenSingle'); + + expect(broken?.id).toBe('brokenSingle'); + expect(broken?.enabled).toBe(false); + expect(broken?.providerMeta.id).toBe('unknown'); + expect(broken?.modelMeta.id).toBe('unknown'); + }); }); describe('deleteModel', () => { diff --git a/packages/ui/src/components/TextModelEditModal.vue b/packages/ui/src/components/TextModelEditModal.vue index 2f650d34..f820790c 100644 --- a/packages/ui/src/components/TextModelEditModal.vue +++ b/packages/ui/src/components/TextModelEditModal.vue @@ -290,7 +290,7 @@ {{ t('common.cancel') }} - + {{ isEditing ? t('common.save') : t('common.create') }} @@ -368,6 +368,7 @@ const formConnectionStatus = manager.formConnectionStatus const testFormConnection = manager.testFormConnection const isTestingFormConnection = manager.isTestingFormConnection const canTestFormConnection = manager.canTestFormConnection +const canSaveForm = manager.canSaveForm const isSaving = manager.isSaving const isChromeBuiltInProvider = manager.isChromeBuiltInProvider const chromeBuiltInStatus = manager.chromeBuiltInStatus diff --git a/packages/ui/src/composables/model/useTextModelManager.ts b/packages/ui/src/composables/model/useTextModelManager.ts index a91270e3..6d9f210c 100644 --- a/packages/ui/src/composables/model/useTextModelManager.ts +++ b/packages/ui/src/composables/model/useTextModelManager.ts @@ -225,6 +225,13 @@ export function useTextModelManager() { return true }) + const canSaveForm = computed(() => { + if (isSaving.value) return false + if (!form.value.name?.trim()) return false + if (!form.value.providerId?.trim()) return false + if (!form.value.modelId?.trim()) return false + return true + }) const canRefreshModelOptions = computed(() => { return selectedProvider.value?.supportsDynamicModels && isConnectionConfigured.value && !isLoadingModelOptions.value }) @@ -324,6 +331,22 @@ export function useTextModelManager() { } } + const isUnknownIdentity = (value: unknown) => { + return typeof value !== 'string' || value.trim().length === 0 || value === 'unknown' + } + + const getEditableIdentity = (model: TextModelConfig) => { + const providerId = model.providerId ?? model.providerMeta?.id ?? '' + const modelId = model.modelId ?? model.modelMeta?.id ?? '' + const providerExists = providers.value.some(provider => provider.id === providerId) + + if (isUnknownIdentity(providerId) || isUnknownIdentity(modelId) || !providerExists) { + return { providerId: '', modelId: '' } + } + + return { providerId, modelId } + } + const refreshChromeBuiltInStatus = async () => { if (!isChromeBuiltInProvider.value) { chromeBuiltInStatus.value = null @@ -477,21 +500,25 @@ export function useTextModelManager() { id: '', name: `${model.name || id} (Copy)`, enabled: model.enabled, - providerId: model.providerId ?? model.providerMeta?.id ?? 'custom', - modelId: model.modelId ?? model.modelMeta?.id ?? '', + providerId: getEditableIdentity(model).providerId, + modelId: getEditableIdentity(model).modelId, connectionConfig: JSON.parse(JSON.stringify(model.connectionConfig ?? {})) as TextConnectionConfig, paramOverrides: model.paramOverrides ? JSON.parse(JSON.stringify(model.paramOverrides)) : {}, displayMaskedKey: false, originalApiKey: typeof model.connectionConfig?.apiKey === 'string' ? model.connectionConfig.apiKey : undefined, - defaultModel: String(model.modelId ?? model.modelMeta?.id ?? '') + defaultModel: getEditableIdentity(model).modelId } - editingModelMeta.value = model.modelMeta + editingModelMeta.value = form.value.modelId ? model.modelMeta : null - setProvider(form.value.providerId, { - autoSelectFirstModel: false, - resetOverrides: false, - resetConnectionConfig: false - }) + if (form.value.providerId) { + setProvider(form.value.providerId, { + autoSelectFirstModel: false, + resetOverrides: false, + resetConnectionConfig: false + }) + } else { + modelOptions.value = [] + } if (!modelOptions.value.some(option => option.value === form.value.modelId) && form.value.modelId) { modelOptions.value.push({ value: form.value.modelId, label: form.value.modelId }) @@ -533,10 +560,14 @@ export function useTextModelManager() { resetConnectionConfig = true } = options + const repairingMissingProvider = !form.value.providerId && !!providerId + const shouldResetOverrides = resetOverrides && !repairingMissingProvider + const shouldResetConnectionConfig = resetConnectionConfig && !repairingMissingProvider + form.value.providerId = providerId formConnectionStatus.value = null chromeBuiltInDownloadProgress.value = null - if (resetOverrides) { + if (shouldResetOverrides) { form.value.paramOverrides = {} } @@ -553,7 +584,7 @@ export function useTextModelManager() { form.value.connectionConfig = computeConnectionConfig( form.value.connectionConfig, providerMeta, - resetConnectionConfig + shouldResetConnectionConfig ) as TextConnectionConfig if (autoSelectFirstModel && modelOptions.value.length > 0) { @@ -561,7 +592,7 @@ export function useTextModelManager() { form.value.modelId = firstModelId form.value.defaultModel = firstModelId // 切换提供商后自动应用第一个模型的默认参数 - if (firstModelId && providerId) { + if (firstModelId && providerId && !repairingMissingProvider) { advancedParameters.applyDefaultsFromModel(false) } } @@ -614,21 +645,25 @@ export function useTextModelManager() { originalId: model.id, name: model.name, enabled: model.enabled, - providerId: model.providerId ?? model.providerMeta?.id ?? 'custom', - modelId: model.modelId ?? model.modelMeta?.id ?? '', + providerId: getEditableIdentity(model).providerId, + modelId: getEditableIdentity(model).modelId, connectionConfig, paramOverrides: model.paramOverrides ? JSON.parse(JSON.stringify(model.paramOverrides)) : {}, displayMaskedKey: !!rawApiKey, originalApiKey: String(rawApiKey) || undefined, - defaultModel: String(model.modelId ?? model.modelMeta?.id ?? '') + defaultModel: getEditableIdentity(model).modelId } - editingModelMeta.value = model.modelMeta + editingModelMeta.value = form.value.modelId ? model.modelMeta : null - setProvider(form.value.providerId, { - autoSelectFirstModel: false, - resetOverrides: false, - resetConnectionConfig: false - }) + if (form.value.providerId) { + setProvider(form.value.providerId, { + autoSelectFirstModel: false, + resetOverrides: false, + resetConnectionConfig: false + }) + } else { + modelOptions.value = [] + } if (!modelOptions.value.some(option => option.value === form.value.modelId) && form.value.modelId) { modelOptions.value.push({ value: form.value.modelId, label: form.value.modelId }) } @@ -860,6 +895,9 @@ export function useTextModelManager() { const saveForm = async () => { if (isSaving.value) return null + if (!canSaveForm.value) { + throw new Error('No provider or model selected') + } isSaving.value = true try { const customHeaderError = getCustomHeaderValidationMessage() @@ -1010,6 +1048,7 @@ export function useTextModelManager() { form, formReady, isSaving, + canSaveForm, modalTitle, editingModelId, providerOptions, diff --git a/packages/ui/tests/unit/composables/useTextModelManager.spec.ts b/packages/ui/tests/unit/composables/useTextModelManager.spec.ts index 31d05fa9..87899376 100644 --- a/packages/ui/tests/unit/composables/useTextModelManager.spec.ts +++ b/packages/ui/tests/unit/composables/useTextModelManager.spec.ts @@ -110,4 +110,97 @@ describe('useTextModelManager', () => { 'x-auth-token': 'gateway-token', }) }) + + it('treats unknown placeholder configs as unselected drafts and preserves repair inputs', async () => { + const registry = new TextAdapterRegistry() + const brokenConfig: TextModelConfig = { + id: 'broken-model', + name: 'Broken Model', + enabled: false, + providerId: 'unknown', + modelId: 'unknown', + providerMeta: { + id: 'unknown', + name: 'Unknown Provider', + requiresApiKey: false, + defaultBaseURL: '', + supportsDynamicModels: false, + connectionSchema: { required: [], optional: [], fieldTypes: {} }, + }, + modelMeta: { + id: 'unknown', + name: 'Unknown Model', + providerId: 'unknown', + capabilities: { + supportsTools: false, + }, + parameterDefinitions: [], + defaultParameterValues: {}, + }, + connectionConfig: { + apiKey: 'preserve-key', + baseURL: 'https://gateway.example.com/v1', + }, + paramOverrides: { + temperature: 0.2, + }, + } + + const modelManager = { + getAllModels: vi.fn().mockResolvedValue([brokenConfig]), + getModel: vi.fn(async (id: string) => (id === 'broken-model' ? brokenConfig : undefined)), + addModel: vi.fn().mockResolvedValue(undefined), + deleteModel: vi.fn().mockResolvedValue(undefined), + updateModel: vi.fn().mockResolvedValue(undefined), + enableModel: vi.fn().mockResolvedValue(undefined), + disableModel: vi.fn().mockResolvedValue(undefined), + } + const llmService = { + testConnection: vi.fn().mockResolvedValue(undefined), + fetchModelList: vi.fn().mockResolvedValue([]), + } + + const Harness = defineComponent({ + setup() { + const manager = useTextModelManager() + return { manager } + }, + template: '
', + }) + + const wrapper = mount(Harness, { + global: { + provide: { + services: ref({ + modelManager, + llmService, + textAdapterRegistry: registry, + }), + }, + }, + }) + + const manager = (wrapper.vm as any).manager as ReturnType + await manager.prepareForEdit('broken-model') + + expect(manager.form.value.providerId).toBe('') + expect(manager.form.value.modelId).toBe('') + expect(manager.form.value.connectionConfig.apiKey).toBe('pres****-key') + expect(manager.form.value.originalApiKey).toBe('preserve-key') + expect(manager.form.value.connectionConfig.baseURL).toBe('https://gateway.example.com/v1') + expect(manager.form.value.paramOverrides).toEqual({ temperature: 0.2 }) + expect(manager.canSaveForm.value).toBe(false) + expect(manager.canTestFormConnection.value).toBe(false) + + manager.selectProvider('openai-compatible') + await nextTick() + + expect(manager.form.value.providerId).toBe('openai-compatible') + expect(manager.form.value.modelId).not.toBe('') + expect(manager.form.value.connectionConfig.apiKey).toBe('pres****-key') + expect(manager.form.value.originalApiKey).toBe('preserve-key') + expect(manager.form.value.connectionConfig.baseURL).toBe('https://gateway.example.com/v1') + expect(manager.form.value.paramOverrides).toEqual({ temperature: 0.2 }) + expect(manager.canSaveForm.value).toBe(true) + }) })