fix(model): tolerate malformed stored text configs

This commit is contained in:
linshen committed 2026-05-18 21:38:27 +08:00
1 parent ce73ae0c8c
commit 5d3b7ada92
5 files changed
+525 -73

No files matched your search

+251 -51
View File
@@ -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<string, any> {
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, any>): 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, any>): 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<TextModelConfig> & Pick<TextModelConfig, 'id' | 'name' | 'enabled'>,
registry: ITextAdapterRegistry,
@@ -538,35 +784,11 @@ export class ModelManager implements IModelManager {
async getAllModels(): Promise<TextModelConfig[]> {
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)
}
/**
@@ -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', () => {
@@ -290,7 +290,7 @@
<NSpace>
<NButton @click="handleCancel">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="isSaving" @click="handleSubmit">
<NButton type="primary" :loading="isSaving" :disabled="!canSaveForm" @click="handleSubmit">
{{ isEditing ? t('common.save') : t('common.create') }}
</NButton>
</NSpace>
@@ -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
@@ -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,
@@ -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: '<div />',
})
const wrapper = mount(Harness, {
global: {
provide: {
services: ref({
modelManager,
llmService,
textAdapterRegistry: registry,
}),
},
},
})
const manager = (wrapper.vm as any).manager as ReturnType<typeof useTextModelManager>
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)
})
})