import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { resolveModelScopeWithDiagnostics, type ModelRuntime, type ScopedModel, } from "@earendil-works/pi-coding-agent"; import type { Api, Model } from "@earendil-works/pi-ai"; /** * Model scoping shared by the UI selector and AgentSession startup. * * The `enabledModels` setting uses the same syntax as pi's `--models` flag: * globs matched with minimatch against `provider/modelId` or a bare `modelId`, * fuzzy matching for non-glob patterns, plus an optional `:thinkingLevel` suffix * (`anthropic/*:high`). Exact string comparison silently drops every model * behind a pattern like `my-gateway/*` (#307), so delegate to pi's own resolver * instead of reimplementing the matching rules here. */ export interface ModelScopeResult { /** Models the UI should offer, in resolver order (all available when unscoped). */ visible: readonly Model[]; /** SDK-native scope retained for AgentSession model cycling and extensions. */ scopedModels: readonly ScopedModel[]; /** `provider/modelId` → thinking level pinned with a `:level` pattern suffix. */ thinkingLevelPins: Record; /** Resolver diagnostics, e.g. a pattern that matched no model. */ warnings: string[]; } export interface InitialModelScopeOptions { requestedModel?: { provider: string; modelId: string }; defaultModel?: { provider: string; modelId: string }; thinkingLevel?: ThinkingLevel; } export interface InitialModelScopeResult { model?: Model; thinkingLevel?: ThinkingLevel; scopedModels: ScopedModel[]; } function matchesModel( model: { provider: string; id: string }, ref: { provider: string; modelId: string }, ): boolean { return model.provider === ref.provider && model.id === ref.modelId; } /** * Resolve the visible model list for `patterns`. * * Falls back to every available model when no patterns are configured or when * the patterns resolve to nothing, so a stale or typo'd setting can never leave * the UI without any selectable model. */ export async function resolveVisibleModels( modelRuntime: ModelRuntime, patterns: string[] | undefined, ): Promise { const cleaned = (patterns ?? []).map((pattern) => pattern.trim()).filter(Boolean); if (cleaned.length === 0) { return { visible: await modelRuntime.getAvailable(), scopedModels: [], thinkingLevelPins: {}, warnings: [], }; } const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(cleaned, modelRuntime); const warnings = diagnostics.map((diagnostic) => diagnostic.message); if (scopedModels.length === 0) { return { visible: await modelRuntime.getAvailable(), scopedModels: [], thinkingLevelPins: {}, warnings, }; } // `anthropic/*:high` pins a thinking level on every model the glob matched. // pi applies the pin of the model a new session starts with; report them all // so the client can look up whichever model it pre-selects. const thinkingLevelPins: Record = {}; for (const scoped of scopedModels) { if (scoped.thinkingLevel) { thinkingLevelPins[`${scoped.model.provider}/${scoped.model.id}`] = scoped.thinkingLevel; } } return { visible: scopedModels.map((scoped) => scoped.model), scopedModels, thinkingLevelPins, warnings, }; } /** * Select the model and thinking level used to create a new AgentSession. * * This mirrors pi's startup rule: prefer an explicit selection, otherwise use * the saved default when it is in scope, then the first resolver-ordered model. * A scoped-model thinking pin is applied unless the caller supplied an explicit * thinking level. */ export function selectInitialModelScope( scope: ModelScopeResult, options: InitialModelScopeOptions = {}, ): InitialModelScopeResult { const requestedRef = options.requestedModel; const defaultRef = options.defaultModel; const requested = requestedRef ? scope.visible.find((model) => matchesModel(model, requestedRef)) : undefined; if (requestedRef && !requested) { throw new Error( `Model is not available in the enabled scope: ${requestedRef.provider}/${requestedRef.modelId}`, ); } const requestedScoped = requested ? scope.scopedModels.find((scoped) => scoped.model === requested || matchesModel(scoped.model, { provider: requested.provider, modelId: requested.id })) : undefined; const defaultScoped = !requested && defaultRef ? scope.scopedModels.find((scoped) => matchesModel(scoped.model, defaultRef)) : undefined; const fallbackScoped = !requested ? (defaultScoped ?? scope.scopedModels[0]) : undefined; const defaultVisible = !requested && !fallbackScoped && defaultRef ? scope.visible.find((model) => matchesModel(model, defaultRef)) : undefined; const selectedModel = requested ?? fallbackScoped?.model ?? defaultVisible; const scopedSelection = requestedScoped ?? fallbackScoped; const thinkingLevel = options.thinkingLevel ?? scopedSelection?.thinkingLevel; return { ...(selectedModel ? { model: selectedModel } : {}), ...(thinkingLevel ? { thinkingLevel } : {}), scopedModels: [...scope.scopedModels], }; }