import type { ModelDescriptor, ModelController } from '@/contracts/model'; import type { Provider } from '@/contracts/provider'; import type { ReasoningEffort, ReasoningLevelDescriptor } from '@/contracts/reasoning'; /** * The provider factory receives the model descriptor and the resolved reasoning effort * (saga p7m8jp.2). The effort is the authoritative active value the controller has * committed; `undefined` means the model has no selectable effort surface (the provider * applies its own adapter default). */ export type ProviderFactory = (model: ModelDescriptor, reasoningEffort?: string) => Provider; /** Per-model reasoning metadata (saga p7m8jp.2). Keyed by descriptor ID. */ export interface ModelReasoningMeta { /** Supported effort IDs (non-empty, unique). Absent → no effort surface. */ efforts?: string[]; /** The model's configured default effort. Absent → adapter default. */ defaultEffort?: string; } /** Options for constructing a {@link SessionModelController}. */ export interface SessionModelControllerOptions { models: ModelDescriptor[]; active: ModelDescriptor; createProvider: ProviderFactory; queueProvider: (provider: Provider) => void; /** * Per-model reasoning metadata keyed by descriptor ID (saga p7m8jp.2). Each entry carries * the supported effort IDs and the model's configured default. Absent for a descriptor → * that model has no selectable effort surface. */ reasoning?: Record; /** * Session-stable cache key reused across initial and replacement Responses providers * (saga p7m8jp.2). When supplied, the factory receives it via the effort parameter's * companion — but since the factory signature takes only `(model, effort)`, the caller * closes over the key. The controller itself does not use it; it's documented here for the * wiring layer to pass through. */ promptCacheKey?: string; } /** * Model catalog plus deferred provider rebinding for one agent session. * * The single authority for `{ active model, active effort }` (saga p7m8jp.2). Every setter * validates first, constructs the next provider from the latest combined state, then * atomically commits/queues it. A failure leaves the descriptor, effort, and queued * provider unchanged. Changes apply at the next provider-call boundary via * `AgentSession.queueProvider()` and never interrupt active streaming. */ export class SessionModelController implements ModelController { private readonly models: ModelDescriptor[]; private readonly meta: Map; private readonly createProvider: ProviderFactory; private readonly queueProvider: (provider: Provider) => void; private activeModel: ModelDescriptor; private activeEffort: string | undefined; constructor(opts: SessionModelControllerOptions) { if (opts.models.length === 0) throw new Error('model catalog must not be empty'); const ids = new Set(); for (const model of opts.models) { validateDescriptor(model); if (ids.has(model.id)) throw new Error(`duplicate model selection "${model.id}"`); ids.add(model.id); } const active = opts.models.find((model) => model.id === opts.active.id); if (active === undefined) { throw new Error(`active model "${opts.active.id}" is not in the catalog`); } validateDescriptor(opts.active); this.models = opts.models.map((model) => ({ ...model })); this.activeModel = { ...active }; this.createProvider = opts.createProvider; this.queueProvider = opts.queueProvider; this.meta = new Map(); if (opts.reasoning !== undefined) { for (const [id, meta] of Object.entries(opts.reasoning)) { this.meta.set(id, { ...meta }); } } // Resolve the initial active effort from the active model's configured default. this.activeEffort = this.resolveDefaultEffort(this.activeModel.id); } list(): { models: ModelDescriptor[]; active: ModelDescriptor } { return { models: this.models.map((m) => ({ ...m })), active: { ...this.activeModel } }; } set(id: string): { previous: ModelDescriptor; active: ModelDescriptor; effective: 'next_provider_call' } { const model = this.models.find((entry) => entry.id === id); if (model === undefined) throw new Error(`unknown model selection "${id}"`); if (model.id === this.activeModel.id) { return { previous: { ...model }, active: { ...model }, effective: 'next_provider_call' }; } // Switching models chooses that model alias's configured default effort (saga p7m8jp.2). const newEffort = this.resolveDefaultEffort(model.id); let provider: Provider; try { provider = this.createProvider(model, newEffort); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`failed to create provider for "${id}": ${message}`); } const previous = { ...this.activeModel }; this.queueProvider(provider); this.activeModel = { ...model }; this.activeEffort = newEffort; return { previous, active: { ...model }, effective: 'next_provider_call' }; } listReasoningLevels(): { model: ModelDescriptor; levels: ReasoningLevelDescriptor[]; active: ReasoningEffort; effective: 'next_provider_call'; } { const meta = this.meta.get(this.activeModel.id); if (meta === undefined || meta.efforts === undefined || meta.efforts.length === 0) { throw new Error(`model "${this.activeModel.id}" has no reasoning effort support`); } const levels = meta.efforts.map((id) => effortDescriptor(id)); const active = this.activeEffort ?? meta.defaultEffort ?? meta.efforts[0]!; if (!meta.efforts.includes(active)) { throw new Error(`active effort "${active}" is not in the support list for "${this.activeModel.id}"`); } return { model: { ...this.activeModel }, levels, active, effective: 'next_provider_call', }; } setReasoningEffort(model: string, effort: ReasoningEffort): { model: ModelDescriptor; previous: ReasoningEffort; active: ReasoningEffort; effective: 'next_provider_call'; } { // Optimistic concurrency precondition: the model ID must match the active descriptor. if (model !== this.activeModel.id) { throw new Error(`model precondition "${model}" does not match active model "${this.activeModel.id}"`); } const meta = this.meta.get(this.activeModel.id); if (meta === undefined || meta.efforts === undefined || meta.efforts.length === 0) { throw new Error(`model "${this.activeModel.id}" has no reasoning effort support`); } if (!meta.efforts.includes(effort)) { throw new Error(`unknown reasoning effort "${effort}" for model "${this.activeModel.id}" (supported: ${meta.efforts.join(', ')})`); } // Idempotent set: same effort → acknowledged result without provider reconstruction. if (this.activeEffort === effort) { return { model: { ...this.activeModel }, previous: effort, active: effort, effective: 'next_provider_call', }; } let provider: Provider; try { provider = this.createProvider(this.activeModel, effort); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`failed to create provider for effort "${effort}": ${message}`); } const previous = this.activeEffort ?? meta.defaultEffort ?? meta.efforts[0]!; this.queueProvider(provider); this.activeEffort = effort; return { model: { ...this.activeModel }, previous, active: effort, effective: 'next_provider_call', }; } /** Resolve a model's configured default effort (saga p7m8jp.2). */ private resolveDefaultEffort(modelId: string): string | undefined { const meta = this.meta.get(modelId); if (meta === undefined) return undefined; return meta.defaultEffort; } } /** Build a human-readable label from an effort ID (capitalize first letter). */ function effortDescriptor(id: string): ReasoningLevelDescriptor { const label = id.charAt(0).toUpperCase() + id.slice(1); return { id, label }; } function validateDescriptor(model: ModelDescriptor): void { if (model.id.length === 0 || model.provider.length === 0 || model.model.length === 0) { throw new Error('model descriptor requires non-empty id, provider, and model'); } if (model.context_window !== undefined && (!Number.isInteger(model.context_window) || model.context_window < 0)) { throw new Error(`invalid context window for model "${model.id}"`); } }