Skip to content

Commit 5d3b7ad

Browse files
committed
fix(model): tolerate malformed stored text configs
1 parent ce73ae0 commit 5d3b7ad

5 files changed

Lines changed: 525 additions & 73 deletions

File tree

packages/core/src/services/model/manager.ts

Lines changed: 251 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IModelManager, ModelConfig, TextModelConfig } from './types';
1+
import { IModelManager, ModelConfig, TextModel, TextModelConfig, TextProvider } from './types';
22
import { IStorageProvider } from '../storage/types';
33
import { StorageAdapter } from '../storage/adapter';
44
import { getAllModels, getBuiltinModelIds } from './defaults';
@@ -285,6 +285,252 @@ export class ModelManager implements IModelManager {
285285
}
286286
}
287287

288+
private isRecord(value: unknown): value is Record<string, any> {
289+
return typeof value === 'object' && value !== null && !Array.isArray(value)
290+
}
291+
292+
private toOptionalString(value: unknown): string | undefined {
293+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
294+
}
295+
296+
private getProviderIdFromRaw(raw: Record<string, any>): string | undefined {
297+
const providerId = this.toOptionalString(raw.providerId)
298+
|| this.toOptionalString(raw.providerMeta?.id)
299+
|| this.toOptionalString(raw.modelMeta?.providerId)
300+
|| this.toOptionalString(raw.provider)
301+
302+
return providerId === 'custom' ? 'openai-compatible' : providerId
303+
}
304+
305+
private getModelIdFromRaw(raw: Record<string, any>): string | undefined {
306+
return this.toOptionalString(raw.modelId)
307+
|| this.toOptionalString(raw.modelMeta?.id)
308+
|| this.toOptionalString(raw.defaultModel)
309+
}
310+
311+
private createDisabledPlaceholderConfig(
312+
key: string,
313+
rawConfig: unknown,
314+
error?: unknown
315+
): TextModelConfig {
316+
const raw = this.isRecord(rawConfig) ? rawConfig : {}
317+
const providerId = this.getProviderIdFromRaw(raw) || 'unknown'
318+
const modelId = this.toOptionalString(raw.modelId)
319+
|| this.toOptionalString(raw.modelMeta?.id)
320+
|| this.toOptionalString(raw.defaultModel)
321+
|| 'unknown'
322+
const providerMeta = this.resolvePlaceholderProviderMeta(providerId, raw.providerMeta)
323+
const modelMeta = this.resolvePlaceholderModelMeta(providerId, modelId, raw.modelMeta)
324+
const connectionConfig = this.isRecord(raw.connectionConfig)
325+
? { ...raw.connectionConfig }
326+
: {
327+
...(this.toOptionalString(raw.apiKey) ? { apiKey: raw.apiKey } : {}),
328+
...(this.toOptionalString(raw.baseURL) ? { baseURL: raw.baseURL } : {})
329+
}
330+
const paramOverrides = this.isRecord(raw.paramOverrides)
331+
? { ...raw.paramOverrides }
332+
: this.isRecord(raw.llmParams)
333+
? { ...raw.llmParams }
334+
: {}
335+
336+
console.warn(
337+
`[ModelManager] Failed to normalize model config ${key}, returning disabled placeholder:`,
338+
error
339+
)
340+
341+
return {
342+
id: this.toOptionalString(raw.id) || key,
343+
name: this.toOptionalString(raw.name) || `Invalid Model (${key})`,
344+
enabled: false,
345+
providerId,
346+
modelId,
347+
providerMeta,
348+
modelMeta,
349+
connectionConfig,
350+
paramOverrides
351+
}
352+
}
353+
354+
private buildDefaultBackfilledConfig(
355+
key: string,
356+
rawConfig: unknown
357+
): TextModelConfig | null {
358+
if (!this.isRecord(rawConfig)) {
359+
return null
360+
}
361+
362+
const defaultConfig = this.getDefaultModels()[key]
363+
if (!defaultConfig) {
364+
return null
365+
}
366+
367+
const providerId = this.getProviderIdFromRaw(rawConfig)
368+
|| defaultConfig.providerId
369+
|| defaultConfig.providerMeta.id
370+
const modelId = this.getModelIdFromRaw(rawConfig)
371+
|| defaultConfig.modelId
372+
|| defaultConfig.modelMeta.id
373+
const repairedConfig = {
374+
...defaultConfig,
375+
...rawConfig,
376+
id: this.toOptionalString(rawConfig.id) || defaultConfig.id,
377+
name: this.toOptionalString(rawConfig.name) || defaultConfig.name,
378+
enabled: typeof rawConfig.enabled === 'boolean' ? rawConfig.enabled : defaultConfig.enabled,
379+
providerId,
380+
modelId,
381+
providerMeta: this.isRecord(rawConfig.providerMeta) && rawConfig.providerMeta.id === providerId
382+
? rawConfig.providerMeta as TextProvider
383+
: defaultConfig.providerMeta,
384+
modelMeta: this.isRecord(rawConfig.modelMeta) && rawConfig.modelMeta.id === modelId && rawConfig.modelMeta.providerId === providerId
385+
? rawConfig.modelMeta as TextModel
386+
: defaultConfig.modelMeta,
387+
connectionConfig: this.isRecord(rawConfig.connectionConfig)
388+
? { ...defaultConfig.connectionConfig, ...rawConfig.connectionConfig }
389+
: { ...defaultConfig.connectionConfig },
390+
paramOverrides: this.isRecord(rawConfig.paramOverrides)
391+
? { ...rawConfig.paramOverrides }
392+
: defaultConfig.paramOverrides,
393+
customParamOverrides: this.isRecord(rawConfig.customParamOverrides)
394+
? { ...rawConfig.customParamOverrides }
395+
: defaultConfig.customParamOverrides
396+
}
397+
398+
return this.migrateConfig(repairedConfig)
399+
}
400+
401+
private resolvePlaceholderProviderMeta(
402+
providerId: string,
403+
existingProviderMeta: unknown
404+
): TextProvider {
405+
if (this.isRecord(existingProviderMeta) && existingProviderMeta.id === providerId) {
406+
return {
407+
id: providerId,
408+
name: this.toOptionalString(existingProviderMeta.name) || providerId,
409+
description: this.toOptionalString(existingProviderMeta.description),
410+
corsRestricted: existingProviderMeta.corsRestricted,
411+
requiresApiKey: !!existingProviderMeta.requiresApiKey,
412+
defaultBaseURL: this.toOptionalString(existingProviderMeta.defaultBaseURL) || '',
413+
supportsDynamicModels: !!existingProviderMeta.supportsDynamicModels,
414+
connectionSchema: this.isRecord(existingProviderMeta.connectionSchema)
415+
? existingProviderMeta.connectionSchema as TextProvider['connectionSchema']
416+
: { required: [], optional: [], fieldTypes: {} },
417+
apiKeyUrl: this.toOptionalString(existingProviderMeta.apiKeyUrl)
418+
}
419+
}
420+
421+
try {
422+
return this.registry?.getAdapter(providerId).getProvider() || this.getUnknownProviderMeta(providerId)
423+
} catch {
424+
return this.getUnknownProviderMeta(providerId)
425+
}
426+
}
427+
428+
private resolvePlaceholderModelMeta(
429+
providerId: string,
430+
modelId: string,
431+
existingModelMeta: unknown
432+
): TextModel {
433+
if (
434+
this.isRecord(existingModelMeta) &&
435+
existingModelMeta.id === modelId &&
436+
existingModelMeta.providerId === providerId
437+
) {
438+
return {
439+
id: modelId,
440+
name: this.toOptionalString(existingModelMeta.name) || modelId,
441+
description: this.toOptionalString(existingModelMeta.description),
442+
providerId,
443+
capabilities: this.isRecord(existingModelMeta.capabilities)
444+
? existingModelMeta.capabilities as TextModel['capabilities']
445+
: { supportsTools: false },
446+
parameterDefinitions: Array.isArray(existingModelMeta.parameterDefinitions)
447+
? existingModelMeta.parameterDefinitions
448+
: [],
449+
defaultParameterValues: this.isRecord(existingModelMeta.defaultParameterValues)
450+
? existingModelMeta.defaultParameterValues
451+
: {}
452+
}
453+
}
454+
455+
try {
456+
const adapter = this.registry?.getAdapter(providerId)
457+
return adapter?.getModels().find((model) => model.id === modelId)
458+
|| adapter?.buildDefaultModel(modelId)
459+
|| this.getUnknownModelMeta(providerId, modelId)
460+
} catch {
461+
return this.getUnknownModelMeta(providerId, modelId)
462+
}
463+
}
464+
465+
private getUnknownProviderMeta(providerId: string): TextProvider {
466+
return {
467+
id: providerId,
468+
name: providerId === 'unknown' ? 'Unknown Provider' : `Unknown Provider (${providerId})`,
469+
description: 'This configuration could not be normalized. Please edit or delete it.',
470+
requiresApiKey: false,
471+
defaultBaseURL: '',
472+
supportsDynamicModels: false,
473+
connectionSchema: { required: [], optional: [], fieldTypes: {} }
474+
}
475+
}
476+
477+
private getUnknownModelMeta(providerId: string, modelId: string): TextModel {
478+
return {
479+
id: modelId,
480+
name: modelId === 'unknown' ? 'Unknown Model' : `Unknown Model (${modelId})`,
481+
description: 'This configuration could not be normalized. Please edit or delete it.',
482+
providerId,
483+
capabilities: {
484+
supportsTools: false
485+
},
486+
parameterDefinitions: [],
487+
defaultParameterValues: {}
488+
}
489+
}
490+
491+
private normalizeStoredTextModelConfig(
492+
key: string,
493+
rawConfig: unknown,
494+
registry: ITextAdapterRegistry
495+
): TextModelConfig {
496+
try {
497+
let textConfig: TextModelConfig
498+
499+
if (isTextModelConfig(rawConfig)) {
500+
textConfig = rawConfig as TextModelConfig;
501+
} else if (isLegacyConfig(rawConfig)) {
502+
textConfig = convertLegacyToTextModelConfig(key, rawConfig);
503+
} else {
504+
textConfig = convertLegacyToTextModelConfig(key, rawConfig as ModelConfig);
505+
}
506+
507+
const migrated = this.migrateConfig(textConfig)
508+
return this.normalizeTextModelConfig(
509+
this.patchProviderMeta(this.patchDeepseekConfig(migrated)),
510+
registry,
511+
{ allowLegacyMetadataMismatch: true }
512+
)
513+
} catch (error) {
514+
const defaultBackfilledConfig = this.buildDefaultBackfilledConfig(key, rawConfig)
515+
if (defaultBackfilledConfig) {
516+
try {
517+
return this.normalizeTextModelConfig(
518+
this.patchProviderMeta(this.patchDeepseekConfig(defaultBackfilledConfig)),
519+
registry,
520+
{ allowLegacyMetadataMismatch: true }
521+
)
522+
} catch (repairError) {
523+
console.warn(
524+
`[ModelManager] Failed to repair default model config ${key}, returning disabled placeholder:`,
525+
repairError
526+
)
527+
}
528+
}
529+
530+
return this.createDisabledPlaceholderConfig(key, rawConfig, error)
531+
}
532+
}
533+
288534
private normalizeTextModelConfig(
289535
config: Partial<TextModelConfig> & Pick<TextModelConfig, 'id' | 'name' | 'enabled'>,
290536
registry: ITextAdapterRegistry,
@@ -538,35 +784,11 @@ export class ModelManager implements IModelManager {
538784
async getAllModels(): Promise<TextModelConfig[]> {
539785
await this.ensureInitialized();
540786
const models = await this.getModelsFromStorage();
541-
542-
// 转换为 TextModelConfig 数组(先完成格式/字段迁移)
543-
const migratedConfigs = Object.entries(models).map(([key, config]) => {
544-
let textConfig: TextModelConfig
545-
546-
// 检查是否已经是新格式
547-
if (isTextModelConfig(config)) {
548-
textConfig = config as TextModelConfig;
549-
}
550-
// 传统格式,转换为新格式
551-
else if (isLegacyConfig(config)) {
552-
textConfig = convertLegacyToTextModelConfig(key, config);
553-
}
554-
// 未知格式,尝试转换
555-
else {
556-
textConfig = convertLegacyToTextModelConfig(key, config as ModelConfig);
557-
}
558-
559-
// 读时迁移:合并 customParamOverrides 到 paramOverrides
560-
return this.migrateConfig(textConfig)
561-
});
562-
563787
const registry = await this.getRegistry()
564788

565-
return migratedConfigs.map((cfg) => this.normalizeTextModelConfig(
566-
this.patchProviderMeta(this.patchDeepseekConfig(cfg)),
567-
registry,
568-
{ allowLegacyMetadataMismatch: true }
569-
))
789+
return Object.entries(models).map(([key, config]) =>
790+
this.normalizeStoredTextModelConfig(key, config, registry)
791+
)
570792
}
571793

572794
/**
@@ -581,30 +803,8 @@ export class ModelManager implements IModelManager {
581803
return undefined;
582804
}
583805

584-
let textConfig: TextModelConfig
585-
586-
// 检查是否已经是新格式
587-
if (isTextModelConfig(config)) {
588-
textConfig = config as TextModelConfig;
589-
}
590-
// 传统格式,转换为新格式
591-
else if (isLegacyConfig(config)) {
592-
textConfig = convertLegacyToTextModelConfig(key, config);
593-
}
594-
// 未知格式,尝试转换
595-
else {
596-
textConfig = convertLegacyToTextModelConfig(key, config as ModelConfig);
597-
}
598-
599-
// 读时迁移:合并 customParamOverrides 到 paramOverrides
600-
const migrated = this.migrateConfig(textConfig)
601806
const registry = await this.getRegistry()
602-
603-
return this.normalizeTextModelConfig(
604-
this.patchProviderMeta(this.patchDeepseekConfig(migrated)),
605-
registry,
606-
{ allowLegacyMetadataMismatch: true }
607-
)
807+
return this.normalizeStoredTextModelConfig(key, config, registry)
608808
}
609809

610810
/**

0 commit comments

Comments
 (0)