fix(model): tolerate malformed stored text configs
This commit is contained in:
1 parent
ce73ae0c8c
commit
5d3b7ada92
5 files changed
+525
-73
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user