singularity-forge/src/startup-model-validation.ts
Tom Boucher 6e22a20580 fix: defer model validation until after extensions register (#3089)
* fix: defer model validation until after extensions register (#2626)

Extension-provided models (e.g. claude-code/claude-sonnet-4-6) were
silently overwritten on every startup because the model validation ran
before createAgentSession(), which is where extensions register their
models in the ModelRegistry. At validation time, extension models did
not exist in the registry, so the user's valid choice was replaced
with a built-in fallback.

Extract validation into validateConfiguredModel() and call it after
createAgentSession() in both print-mode and interactive-mode paths.

Closes #2626

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: align MinimalSettingsManager interface with SettingsManager

The MinimalSettingsManager interface used `string` for thinking level
types, but SettingsManager uses a specific union type and returns
`undefined`. This caused TS2345 at cli.ts lines 448 and 587.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 14:38:10 -06:00

78 lines
3.1 KiB
TypeScript

/**
* Startup model validation — extracted from cli.ts so it can be called
* AFTER extensions register their models in the ModelRegistry.
*
* Before this extraction (bug #2626), the validation ran before
* createAgentSession(), meaning extension-provided models (e.g.
* claude-code/claude-sonnet-4-6) were not yet in the registry.
* configuredExists was always false for extension models, causing the
* user's valid choice to be silently overwritten with a built-in fallback.
*/
import { getPiDefaultModelAndProvider } from './pi-migration.js'
interface MinimalModel {
provider: string
id: string
}
interface MinimalModelRegistry {
getAll(): MinimalModel[]
getAvailable(): MinimalModel[]
}
type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
interface MinimalSettingsManager {
getDefaultProvider(): string | undefined
getDefaultModel(): string | undefined
getDefaultThinkingLevel(): ThinkingLevel | undefined
setDefaultModelAndProvider(provider: string, modelId: string): void
setDefaultThinkingLevel(level: ThinkingLevel): void
}
/**
* Validate the configured default model against the registry.
*
* If the configured model exists in the registry, this is a no-op — the
* user's choice is preserved. If it does not exist (stale settings from a
* prior install, or genuinely removed model), a fallback is selected and
* written to settings.
*
* IMPORTANT: Call this AFTER createAgentSession() so that extension-
* provided models have been registered in the ModelRegistry.
*/
export function validateConfiguredModel(
modelRegistry: MinimalModelRegistry,
settingsManager: MinimalSettingsManager,
): void {
const configuredProvider = settingsManager.getDefaultProvider()
const configuredModel = settingsManager.getDefaultModel()
const allModels = modelRegistry.getAll()
const availableModels = modelRegistry.getAvailable()
const configuredExists = configuredProvider && configuredModel &&
allModels.some((m) => m.provider === configuredProvider && m.id === configuredModel)
if (!configuredModel || !configuredExists) {
// Model not configured at all, or removed from registry — pick a fallback.
// Only fires when the model is genuinely unknown (not just temporarily unavailable).
const piDefault = getPiDefaultModelAndProvider()
const preferred =
(piDefault
? availableModels.find((m) => m.provider === piDefault.provider && m.id === piDefault.model)
: undefined) ||
availableModels.find((m) => m.provider === 'openai' && m.id === 'gpt-5.4') ||
availableModels.find((m) => m.provider === 'openai') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
availableModels.find((m) => m.provider === 'anthropic') ||
availableModels[0]
if (preferred) {
settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id)
}
}
if (settingsManager.getDefaultThinkingLevel() !== 'off' && !configuredExists) {
settingsManager.setDefaultThinkingLevel('off')
}
}