singularity-forge/src/startup-model-validation.ts
2026-05-05 14:46:18 +02:00

110 lines
3.9 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 { error, formatStructuredError } from "./errors.js";
import { getPiDefaultModelAndProvider } from "./pi-migration.js";
interface MinimalModel {
provider: string;
id: string;
}
interface MinimalModelRegistry {
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 availableModels = modelRegistry.getAvailable();
// Check against availableModels (configured + auth'd) rather than getAll()
// so a stale default pointing at an unconfigured provider triggers the
// fallback. Previously a model present in the registry but missing API
// key / OAuth would satisfy configuredExists and survive startup, ending
// up as ctx.model even though it couldn't actually be used.
const configuredExists =
configuredProvider &&
configuredModel &&
availableModels.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).
//
// Model-agnostic selection order:
// 1. Pi migration default (preserves migration from ~/.pi install)
// 2. Any model from the user's previously-chosen provider (provider stickiness)
// 3. First available model in registry order (user-controlled via models.json)
const piDefault = getPiDefaultModelAndProvider();
const preferred =
(piDefault
? availableModels.find(
(m) =>
m.provider === piDefault.provider && m.id === piDefault.model,
)
: undefined) ||
(configuredProvider
? availableModels.find((m) => m.provider === configuredProvider)
: undefined) ||
availableModels[0];
if (preferred) {
const _reason = !configuredModel
? "no model configured"
: `"${configuredProvider}/${configuredModel}" is no longer available`;
process.stderr.write(
formatStructuredError(
error("Model validation fallback applied", {
operation: "validateConfiguredModel",
guidance: `Run 'sf config' or use /model in interactive mode to change. Falling back to ${preferred.provider}/${preferred.id}`,
}),
"[sf]",
),
);
settingsManager.setDefaultModelAndProvider(
preferred.provider,
preferred.id,
);
}
}
if (
settingsManager.getDefaultThinkingLevel() !== "off" &&
!configuredExists
) {
settingsManager.setDefaultThinkingLevel("off");
}
}