/**
 * Model selection and dynamic routing for autonomous mode unit dispatch.
 * Handles complexity-based routing, model resolution across providers,
 * and fallback chains.
 */
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { unitPhaseLabel } from "./auto-dashboard.js";
import { isModelBlocked } from "./blocked-models.js";
import {
	classifyUnitComplexity,
	extractTaskMetadata,
	tierLabel,
} from "./complexity-classifier.js";
import { getErrorMessage } from "./error-utils.js";
import { getLedger, getProjectTotals } from "./metrics.js";
import { routesFor } from "./model-registry.js";
import {
	adjustToolSet,
	escalateTier,
	getEligibleModels,
	loadCapabilityOverrides,
	resolveModelForComplexity,
} from "./model-router.js";
import {
	filterModelsByProviderModelAllow,
	isModelInEnabledList,
	isProviderAllowedByLists,
	isProviderAllowedForAdvisor,
	resolveDynamicRoutingConfig,
	resolveModelWithFallbacksForUnit,
	resolvePersistModelChanges,
} from "./preferences-models.js";
import { getSessionModelOverride } from "./session-model-override.js";
import { readStickyModelForUnit } from "./slice-routing-cache.js";
import { resolveUokFlags } from "./uok/flags.js";
import { applyModelPolicyFilter } from "./uok/model-policy.js";
import {
	DEFAULT_MODEL_ROLE_CONSTRAINTS,
	isSameRootVendor,
} from "./uok/model-role-policy.js";
import { logWarning } from "./workflow-logger.js";
import { getRequiredWorkflowToolsForAutoUnit } from "./workflow-tools.js";

/**
 * Read the operator's enabledModels allowlist from ~/.sf/agent/settings.json.
 *
 * Returns undefined when:
 *   - the file is missing or unreadable (→ no constraint)
 *   - enabledModels is absent or empty (→ no constraint)
 *
 * IO errors are silently swallowed — a missing settings.json must never
 * prevent autonomous dispatch.
 *
 * Set SF_BYPASS_ENABLED_MODELS=1 to disable the allowlist check entirely
 * (emergency escape hatch when the allowlist gets misconfigured).
 */
function readEnabledModels() {
	if (process.env.SF_BYPASS_ENABLED_MODELS === "1") return undefined;
	try {
		const settingsPath = join(homedir(), ".sf", "agent", "settings.json");
		const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
		return Array.isArray(settings?.enabledModels) &&
			settings.enabledModels.length > 0
			? settings.enabledModels
			: undefined;
	} catch {
		return undefined; // settings missing or unreadable → no allowlist constraint
	}
}
/**
 * Thrown when the model-policy gate rejects every candidate model for a unit
 * dispatch (#4959 / #4681 / #4850).  The auto-loop catches this specifically
 * to classify the unit as `blocked` rather than counting it as a retryable
 * iteration error — pre-send policy denial is a configuration problem, not a
 * transient runtime failure, so retrying just burns the consecutive-error
 * budget toward a hard stop.
 */
export class ModelPolicyDispatchBlockedError extends Error {
	unitType;
	unitId;
	reasons;
	constructor(unitType, unitId, reasons) {
		const summary =
			reasons.length === 0
				? "no candidate models"
				: reasons
						.slice(0, 4)
						.map((r) => `${r.provider}/${r.modelId} (${r.reason})`)
						.join("; ");
		super(
			`Model policy denied dispatch for ${unitType}/${unitId} before prompt send. Rejected: ${summary}`,
		);
		this.name = "ModelPolicyDispatchBlockedError";
		this.unitType = unitType;
		this.unitId = unitId;
		this.reasons = reasons;
	}
}
// Baseline active-tool set per-`pi` instance, captured the first time
// `selectAndApplyModel` runs against that instance during an auto session
// and re-applied before each subsequent dispatch.  WeakMap so that test
// fakes / disposed sessions are garbage-collected normally.  See
// #4959 / #4681 cross-unit poisoning notes at the call site below.
//
// LIFECYCLE: the baseline is tied to a single auto session, NOT to the
// lifetime of the `pi` instance (which can outlive many auto runs and have
// the user mutate tools between them).  `clearToolBaseline` MUST be called
// at auto start AND auto stop so that a second `/autonomous` run on the same
// `pi` does not silently restore a stale snapshot from the prior run and
// undo any tool changes the user made between sessions.
const TOOL_BASELINE = new WeakMap();
// ── Worker model id cache ────────────────────────────────────────────────────
// Stores the most-recently resolved model id for the "worker" role so that
// subsequent adversary/reviewer dispatches can apply the lineage-diverse filter
// without requiring an out-of-band state store.  A simple module-level string
// is sufficient: SF's auto loop is single-threaded and adversary/reviewer units
// always run after the worker within the same process.
let _lastWorkerModelId = null;
/**
 * Return the most-recently cached worker model id, or null if unknown.
 * Exported for tests only.
 * @returns {string|null}
 */
export function _getLastWorkerModelId() {
	return _lastWorkerModelId;
}
/**
 * Reset the worker model id cache.
 * Exported for tests only — use to prevent state bleed between test cases.
 */
export function _resetLastWorkerModelId() {
	_lastWorkerModelId = null;
}
/**
 * Drop the captured tool baseline for `pi` so the next `selectAndApplyModel`
 * call re-captures from the live active set.  Wired into `startAuto` and
 * `stopAuto` in `auto.ts` to bound the baseline to a single auto session.
 *
 * Safe to call when no baseline is recorded (no-op).
 */
export function clearToolBaseline(pi) {
	TOOL_BASELINE.delete(pi);
}
function reapplyThinkingLevel(pi, level) {
	if (!level) return;
	pi.setThinkingLevel(level);
}
function restoreToolBaseline(pi) {
	const key = pi;
	const baseline = TOOL_BASELINE.get(key);
	if (baseline === undefined) {
		// First call: capture the canonical pre-dispatch tool set.  At autonomous mode
		// start the active set has not yet been narrowed for any provider.
		// Guarded against test fakes that omit getActiveTools — record an empty
		// baseline so subsequent calls don't keep re-probing.
		const initial =
			typeof pi.getActiveTools === "function" ? pi.getActiveTools() : [];
		TOOL_BASELINE.set(key, [...initial]);
		return;
	}
	// Restore baseline before the next unit reads getActiveTools / applies
	// post-selection adjustToolSet.  Older fakes that omit setActiveTools are
	// tolerated — the test asserts call order on real fakes.
	if (typeof pi.setActiveTools === "function") {
		pi.setActiveTools([...baseline]);
	}
}
const BARE_MODEL_FAMILY_PRIORITY = [
	{ match: /^glm-/i, providers: ["zai", "opencode-go", "ollama-cloud"] },
	{
		match: /^kimi-/i,
		providers: ["kimi-coding", "ollama-cloud", "opencode-go"],
	},
	{
		match: /^MiniMax-|^minimax-/i,
		providers: ["minimax", "opencode-go", "ollama-cloud"],
	},
	{
		match: /^mimo-|^xiaomi-/i,
		providers: ["xiaomi", "opencode-go"],
	},
];
function resolveFamilyPreferredBareModel(modelId, candidates) {
	const rule = BARE_MODEL_FAMILY_PRIORITY.find((r) => r.match.test(modelId));
	if (!rule) return undefined;
	// Use model registry to get preferred wire ids for this canonical model.
	// routesFor returns all known routes; we filter to preferred providers below.
	const canonicalId =
		candidates.find((m) => m.id.toLowerCase() === modelId.toLowerCase())
			?.canonical_id ?? modelId;
	const preferredWireIds = routesFor(canonicalId).map((r) => r.wire_id);
	for (const provider of rule.providers) {
		const providerCandidates = candidates.filter(
			(m) => m.provider.toLowerCase() === provider.toLowerCase(),
		);
		if (preferredWireIds.length > 0) {
			for (const preferredId of preferredWireIds) {
				const match = providerCandidates.find(
					(m) => m.id.toLowerCase() === preferredId.toLowerCase(),
				);
				if (match) return match;
			}
		}
		const match = providerCandidates[0];
		if (match) return match;
	}
	return undefined;
}
function bareModelIdAliases(modelId) {
	const lower = modelId.toLowerCase();
	const aliases = new Set([lower]);
	if (
		lower === "kimi-for-coding" ||
		lower === "kimi-k2.6" ||
		lower === "kimi-k2.6:cloud" ||
		lower === "kimi-k2.6-cloud" ||
		lower === "moonshotai/kimi-k2.6"
	) {
		aliases.add("kimi-for-coding");
		aliases.add("kimi-k2.6");
		aliases.add("kimi-k2.6:cloud");
		aliases.add("kimi-k2.6-cloud");
		aliases.add("moonshotai/kimi-k2.6");
	}
	if (
		lower === "kimi-k2.5" ||
		lower === "kimi-k2.5:cloud" ||
		lower === "moonshotai/kimi-k2.5"
	) {
		aliases.add("kimi-k2.5");
		aliases.add("kimi-k2.5:cloud");
		aliases.add("moonshotai/kimi-k2.5");
	}
	return aliases;
}
function matchesBareModelId(candidateId, requestedId) {
	return bareModelIdAliases(requestedId).has(candidateId.toLowerCase());
}
/**
 * Resolve preferred model configuration for a unit type from preferences or dynamic routing.
 * Returns undefined if no explicit config and autonomous mode is disabled or flat-rate provider detected.
 */
export function resolvePreferredModelConfig(
	unitType,
	autoModeStartModel,
	isAutoMode = true,
) {
	const explicitConfig = resolveModelWithFallbacksForUnit(unitType, {
		autoBenchmark: false,
	});
	if (explicitConfig) return explicitConfig;
	// In interactive mode, don't synthesize a routing-based model config.
	// The user's session model (/model) should be used as-is (#3962).
	if (!isAutoMode) return undefined;
	const routingConfig = resolveDynamicRoutingConfig();
	if (!routingConfig.enabled || !routingConfig.tier_models) {
		if (
			autoModeStartModel &&
			isFlatRateProvider(
				autoModeStartModel.provider,
				autoModeStartModel.flatRateCtx,
			)
		)
			return undefined;
		return resolveModelWithFallbacksForUnit(unitType);
	}
	// Don't synthesize a routing config for flat-rate providers (#3453).
	if (
		autoModeStartModel &&
		isFlatRateProvider(
			autoModeStartModel.provider,
			autoModeStartModel.flatRateCtx,
		)
	)
		return undefined;
	const ceilingModel =
		routingConfig.tier_models.heavy ??
		(autoModeStartModel
			? `${autoModeStartModel.provider}/${autoModeStartModel.id}`
			: undefined);
	if (!ceilingModel) return undefined;
	return {
		primary: ceilingModel,
		fallbacks: [],
	};
}
/**
 * Apply the `lineage-diverse-from-worker` filter to a candidate model list.
 *
 * Purpose: remove candidates whose root-vendor lineage matches the worker
 * model, preventing rubber-stamp reviews from lineage-twin models.
 *
 * Fail-open: when filtering would remove ALL candidates, returns the original
 * list unchanged (better a same-vendor reviewer than no reviewer) and sets
 * `fellBack: true` in the result.
 *
 * @param {Array<{provider: string, id: string}>} candidates
 * @param {string} workerModelId  — the resolved id of the worker model
 * @returns {{ filtered: Array, fellBack: boolean }}
 */
export function applyLineageDiverseFilter(candidates, workerModelId) {
	if (!workerModelId || String(workerModelId).length === 0) {
		return { filtered: candidates, fellBack: false };
	}
	const kept = candidates.filter((m) => !isSameRootVendor(m.id, workerModelId));
	if (kept.length === 0) {
		return { filtered: candidates, fellBack: true };
	}
	return { filtered: kept, fellBack: false };
}

/**
 * Select and apply the appropriate model for a unit dispatch.
 * Handles: per-unit-type model preferences, dynamic complexity routing,
 * provider/model resolution, fallback chains, and start-model re-application.
 *
 * Returns routing metadata for metrics tracking.
 */
export async function selectAndApplyModel(
	ctx,
	pi,
	unitType,
	unitId,
	basePath,
	prefs,
	verbose,
	autoModeStartModel,
	retryContext,
	/** When false (interactive/guided-flow), skip dynamic routing and use the session model.
	 *  Dynamic routing only applies in autonomous mode where cost optimization is expected. (#3962) */
	isAutoMode = true,
	/** Explicit /model pin captured at bootstrap for long-running auto loops. */
	sessionModelOverride,
	/** Thinking level captured at autonomous mode start and re-applied after model swaps. */
	autoModeStartThinkingLevel,
	/**
	 * The model-role for this dispatch (e.g. "worker", "reviewer", "adversary").
	 * When "adversary" or "reviewer" and the role's constraints include
	 * "lineage-diverse-from-worker", candidates sharing root-vendor lineage with the
	 * most-recently dispatched worker model are filtered out.
	 * Callers that don't carry role context pass undefined (no-op).
	 */
	role,
	/**
	 * Explicit worker model id to use for the lineage-diverse filter instead of
	 * the module-level cache.  Useful when the caller has direct access to the
	 * worker model (e.g. in tests or future callers that thread it explicitly).
	 */
	workerModelId,
) {
	// ── Restore active-tool baseline before policy evaluation (#4959, #4681, #4850) ──
	// Per-unit narrowing at the bottom of this function calls
	// `pi.setActiveTools(finalToolNames)` and monotonically narrows the active
	// set across units.  Without restoration, a previously-dispatched unit on a
	// narrow-API provider (e.g. openai-completions) leaves the active set
	// missing tools that the next unit's selected model fully supports, but
	// `pi.getActiveTools()` snapshot-as-hard-gate (the old behaviour) blocked
	// dispatch with "tool policy denied" anyway.
	//
	// The baseline is captured once per `pi` instance via a WeakMap and
	// re-applied here so each unit starts from a clean slate.  Soft adaptation
	// (adjustToolSet at the bottom of this function) still trims for the
	// selected model.
	restoreToolBaseline(pi);
	const uokFlags = resolveUokFlags(prefs);
	const persistModelChanges = resolvePersistModelChanges();
	const effectiveSessionModelOverride =
		sessionModelOverride === undefined
			? getSessionModelOverride(ctx.sessionManager.getSessionId())
			: (sessionModelOverride ?? undefined);
	// Enrich the start model with a flat-rate context up front so routing
	// synthesis and the dispatch-time guard see the same signals (built-in
	// list + user `flat_rate_providers` preference + externalCli auto-
	// detection).  The dispatch-time primary-model check below builds its
	// own per-provider context when it has a resolved primary model.
	if (autoModeStartModel) {
		autoModeStartModel = {
			...autoModeStartModel,
			flatRateCtx: buildFlatRateContext(
				autoModeStartModel.provider,
				ctx,
				prefs,
			),
		};
	}
	const modelConfig = effectiveSessionModelOverride
		? undefined
		: resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode);
	const explicitPhaseModelConfig = effectiveSessionModelOverride
		? undefined
		: resolveModelWithFallbacksForUnit(unitType, {
				autoBenchmark: false,
			});
	let routing = null;
	let appliedModel = null;
	if (modelConfig) {
		// ─── Provider Allowlist (outer gate) ──────────────────────────────
		// When `allowed_providers` is set in preferences, filter the candidate
		// set BEFORE any other selection logic runs — both models.* resolution
		// and dynamic routing will only see providers in the allowlist. This
		// prevents routing from silently picking a provider the user doesn't
		// have keys for (or has explicitly excluded), which caused repeated
		// 400 "model not supported" dispatch failures in dr-repo.
		const rawAvailable = ctx.modelRegistry.getAvailable();
		const allowed = prefs?.allowed_providers;
		const blocked = prefs?.blocked_providers;
		const providerAllowedModels = rawAvailable.filter((m) =>
			isProviderAllowedByLists(m.provider, allowed, blocked),
		);
		if (allowed && allowed.length > 0 && providerAllowedModels.length === 0) {
			throw new Error(
				`allowed_providers filter rejected every available model. ` +
					`Configured providers: [${allowed.join(", ")}]. ` +
					`Either add a provider to allowed_providers or remove the pref.`,
			);
		}
		const availableModels = filterModelsByProviderModelAllow(
			providerAllowedModels,
			prefs?.provider_model_allow,
			prefs?.provider_model_block,
		);
		const modelPolicyTraceId = `model:${ctx.sessionManager.getSessionId()}:${Date.now()}`;
		const modelPolicyTurnId = `${unitType}:${unitId}`;
		let policyAllowedModelKeys = null;
		// ─── Dynamic Model Routing ─────────────────────────────────────────
		// Dynamic routing (complexity-based downgrading) only applies in autonomous mode.
		// Interactive/guided-flow dispatches use the user's session model directly,
		// respecting their /model selection without silent downgrades (#3962).
		const routingConfig = resolveDynamicRoutingConfig();
		if (!isAutoMode) {
			routingConfig.enabled = false;
		}
		// burn-max defaults to quality-first dispatch (no downgrade routing).
		if (prefs?.token_profile === "burn-max") {
			routingConfig.enabled = false;
		}
		let effectiveModelConfig = modelConfig;
		let routingTierLabel = "";
		let routingEligibleModels = availableModels;
		const taskMetadataForPolicy =
			unitType === "execute-task"
				? extractTaskMetadata(unitId, basePath)
				: undefined;
		let policyDenyReasons = [];
		if (uokFlags.modelPolicy) {
			// Use the workflow-spec required-tool subset for the unit type rather
			// than the live `pi.getActiveTools()` snapshot (#4959).  The active set
			// is poisoned by per-unit narrowing for narrow-API providers — using it
			// as a hard gate promotes soft adaptation (adjustToolSet) into a layering
			// violation that throws before dispatch.  The smaller workflow-required
			// subset reflects what the unit actually needs; soft adaptation post-
			// selection still trims provider-incompatible tools.
			const requiredTools = getRequiredWorkflowToolsForAutoUnit(unitType);
			const policy = applyModelPolicyFilter(availableModels, {
				basePath,
				traceId: modelPolicyTraceId,
				turnId: modelPolicyTurnId,
				unitType,
				taskMetadata: taskMetadataForPolicy,
				currentProvider: ctx.model?.provider,
				allowCrossProvider: routingConfig.cross_provider !== false,
				requiredTools,
			});
			routingEligibleModels = policy.eligible;
			policyAllowedModelKeys = new Set(
				policy.eligible.map(
					(m) => `${m.provider.toLowerCase()}/${m.id.toLowerCase()}`,
				),
			);
			policyDenyReasons = policy.decisions
				.filter((d) => !d.allowed)
				.map((d) => ({
					provider: d.provider,
					modelId: d.modelId,
					reason: d.reason,
				}));
			if (routingEligibleModels.length === 0) {
				throw new ModelPolicyDispatchBlockedError(
					unitType,
					unitId,
					policyDenyReasons,
				);
			}
		}
		// ── Lineage-diverse filter (adversary / reviewer roles) ───────────────────
		// When the dispatching role is "adversary" or "reviewer" and its constraint
		// set includes "lineage-diverse-from-worker", remove candidates that share
		// root-vendor lineage with the worker model to prevent rubber-stamp reviews.
		// Fail-open: if filtering would leave zero candidates, log a warning and keep
		// the full eligible set so the dispatch is never completely blocked.
		//
		// lineageBlockKeys: provider/id keys that are blocked by the lineage filter.
		// This is checked in the resolution loop REGARDLESS of whether the UOK model
		// policy gate is active, ensuring the constraint is enforced even when
		// model policy is disabled (e.g. in tests or when explicitly turned off).
		let lineageAllowedKeys = null;
		{
			const normalizedRole = String(role ?? "").toLowerCase();
			const isLineageRole =
				normalizedRole === "adversary" || normalizedRole === "reviewer";
			if (isLineageRole) {
				const roleConstraints =
					DEFAULT_MODEL_ROLE_CONSTRAINTS[normalizedRole] ?? [];
				const needsLineageFilter = roleConstraints.includes(
					"lineage-diverse-from-worker",
				);
				if (needsLineageFilter) {
					const effectiveWorkerModelId =
						workerModelId ?? _lastWorkerModelId ?? null;
					if (
						effectiveWorkerModelId &&
						String(effectiveWorkerModelId).length > 0
					) {
						// Use availableModels as the base for filtering to cover both
						// the policy-enabled path (routingEligibleModels) and the
						// policy-disabled path (availableModels) in the resolution loop.
						const baseForFilter = uokFlags.modelPolicy
							? routingEligibleModels
							: availableModels;
						const { filtered, fellBack } = applyLineageDiverseFilter(
							baseForFilter,
							effectiveWorkerModelId,
						);
						if (fellBack) {
							logWarning(
								"dispatch",
								`lineage-diverse-from-worker: all ${baseForFilter.length} candidates share ` +
									`root-vendor with worker model "${effectiveWorkerModelId}" for role "${normalizedRole}" — ` +
									`falling back to unfiltered set to avoid blocking dispatch`,
							);
						} else {
							// Build an allow-set of keys to enforce in the resolution loop.
							const allowedKeys = new Set(
								filtered.map(
									(m) => `${m.provider.toLowerCase()}/${m.id.toLowerCase()}`,
								),
							);
							lineageAllowedKeys = allowedKeys;
							// Also update routingEligibleModels for the routing tier selection
							// (so the routing tier picks from the filtered set).
							if (uokFlags.modelPolicy) {
								routingEligibleModels = filtered;
								// Rebuild the policy-allowed key set to reflect the filtered list.
								if (policyAllowedModelKeys) {
									policyAllowedModelKeys = allowedKeys;
								}
							}
						}
					}
				}
			}
		}
		// Disable routing for flat-rate providers like GitHub Copilot (#3453).
		// All models cost the same per request, so downgrading to a cheaper
		// model provides no cost benefit — it only degrades quality.
		// Fail-closed: if primary model can't be resolved, fall back to
		// provider-level signals rather than allowing unwanted downgrades.
		if (routingConfig.enabled) {
			const primaryModel = resolveModelId(
				modelConfig.primary,
				routingEligibleModels,
				ctx.model?.provider,
			);
			if (primaryModel) {
				const primaryFlatRateCtx = buildFlatRateContext(
					primaryModel.provider,
					ctx,
					prefs,
				);
				if (isFlatRateProvider(primaryModel.provider, primaryFlatRateCtx)) {
					routingConfig.enabled = false;
				}
			} else if (
				(autoModeStartModel &&
					isFlatRateProvider(
						autoModeStartModel.provider,
						autoModeStartModel.flatRateCtx,
					)) ||
				(ctx.model?.provider &&
					isFlatRateProvider(
						ctx.model.provider,
						buildFlatRateContext(ctx.model.provider, ctx, prefs),
					))
			) {
				// Primary model unresolvable but provider signals indicate flat-rate —
				// disable routing to prevent quality degradation.
				routingConfig.enabled = false;
			}
		}
		if (routingConfig.enabled) {
			let budgetPct;
			if (routingConfig.budget_pressure !== false) {
				const budgetCeiling = prefs?.budget_ceiling;
				if (budgetCeiling !== undefined && budgetCeiling > 0) {
					const currentLedger = getLedger();
					const totalCost = currentLedger
						? getProjectTotals(currentLedger.units).cost
						: 0;
					budgetPct = totalCost / budgetCeiling;
				}
			}
			const isHook = unitType.startsWith("hook/");
			const shouldClassify = !isHook || routingConfig.hooks !== false;
			if (shouldClassify) {
				// Get session model mode for routing floor/cap
				const { getAutoSession } = await import("./auto/session.js");
				const session = getAutoSession();
				const modelMode = session?.modelMode;
				let classification = classifyUnitComplexity(
					unitType,
					unitId,
					basePath,
					budgetPct,
					taskMetadataForPolicy,
					modelMode,
				);
				const availableModelIds = routingEligibleModels.map((m) => m.id);
				// Escalate tier on retry when escalate_on_failure is enabled (default: true)
				if (
					retryContext?.isRetry &&
					retryContext.previousTier &&
					routingConfig.escalate_on_failure !== false
				) {
					const escalated = escalateTier(retryContext.previousTier);
					if (escalated) {
						classification = {
							...classification,
							tier: escalated,
							reason: "escalated after failure",
						};
						// Always notify on tier escalation — model changes should be visible (#3962)
						ctx.ui.notify(
							`Tier escalation: ${retryContext.previousTier} → ${escalated} (retry after failure)`,
							"info",
						);
					}
				}
				// Load user capability overrides from preferences (D-17: deep-merged with built-in profiles)
				const capabilityOverrides = loadCapabilityOverrides(prefs ?? {});
				// Fire before_model_select hook (ADR-004, D-03)
				// Hook can override model selection entirely by returning { modelId }
				let hookOverride;
				if (routingConfig.hooks !== false && !explicitPhaseModelConfig) {
					const eligible = getEligibleModels(
						classification.tier,
						availableModelIds,
						routingConfig,
					);
					const hookResult = await pi.emitBeforeModelSelect({
						unitType,
						unitId,
						classification: {
							tier: classification.tier,
							reason: classification.reason,
							downgraded: classification.downgraded,
						},
						taskMetadata: classification.taskMetadata,
						eligibleModels: eligible,
						phaseConfig: modelConfig
							? {
									primary: modelConfig.primary,
									fallbacks: modelConfig.fallbacks ?? [],
								}
							: undefined,
					});
					if (hookResult?.modelId) {
						hookOverride = hookResult.modelId;
					}
				}
				let routingResult;
				if (hookOverride) {
					// Hook override bypasses capability scoring entirely
					routingResult = {
						modelId: hookOverride,
						fallbacks: [
							...(modelConfig?.fallbacks ?? []).filter(
								(f) => f !== hookOverride,
							),
							...(modelConfig?.primary && modelConfig.primary !== hookOverride
								? [modelConfig.primary]
								: []),
						],
						tier: classification.tier,
						wasDowngraded: hookOverride !== modelConfig?.primary,
						reason: `hook override: ${hookOverride}`,
						selectionMethod: "tier-only",
					};
				} else {
					// Slice-sticky hint: prefer the model that previously succeeded
					// on a sibling unit in this slice when its capability score is
					// within window of the winner. Cleared on executor refusal so a
					// failing model does not re-attach to the slice.
					const stickyHint = readStickyModelForUnit(basePath, unitType, unitId);
					routingResult = resolveModelForComplexity(
						classification,
						modelConfig,
						routingConfig,
						availableModelIds,
						unitType,
						classification.taskMetadata,
						capabilityOverrides,
						stickyHint,
					);
				}
				if (routingResult.wasDowngraded) {
					effectiveModelConfig = {
						primary: routingResult.modelId,
						fallbacks: routingResult.fallbacks,
					};
					// Always notify on model downgrade — users should see when their
					// model selection is overridden, not just in verbose mode (#3962).
					if (
						routingResult.selectionMethod === "capability-scored" &&
						routingResult.capabilityScores
					) {
						const tierLbl = tierLabel(classification.tier);
						const scores = Object.entries(routingResult.capabilityScores)
							.sort(([, a], [, b]) => b - a)
							.map(([id, score]) => `${id}: ${score.toFixed(1)}`)
							.join(", ");
						ctx.ui.notify(
							`Dynamic routing [${tierLbl}]: ${routingResult.modelId} (capability-scored) — ${scores}`,
							"info",
						);
					} else {
						ctx.ui.notify(
							`Dynamic routing [${tierLabel(classification.tier)}]: ${routingResult.modelId} (${classification.reason})`,
							"info",
						);
					}
				}
				routingTierLabel = ` [${tierLabel(classification.tier)}]`;
				routing = {
					tier: classification.tier,
					modelDowngraded: routingResult.wasDowngraded,
				};
			}
		}
		const modelsToTry = [
			effectiveModelConfig.primary,
			...effectiveModelConfig.fallbacks,
		];
		// ── enabledModels allowlist (operator gate) ──────────────────────────
		// Read once per unit dispatch; undefined = no constraint (open).
		// SF_BYPASS_ENABLED_MODELS=1 disables the check entirely — emergency
		// escape hatch when the allowlist gets misconfigured and autonomous
		// can't find a viable fallback.
		const enabledModels = readEnabledModels();
		let enabledModelsFilteredCount = 0;
		let enabledModelsResolvedCount = 0; // models that existed in the pool (with or without allowlist)
		let attemptedPolicyEligible = false;
		for (const modelId of modelsToTry) {
			const resolutionPool = uokFlags.modelPolicy
				? routingEligibleModels
				: availableModels;
			const model = resolveModelId(
				modelId,
				resolutionPool,
				ctx.model?.provider,
			);
			if (!model) {
				if (verbose)
					ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info");
				continue;
			}
			enabledModelsResolvedCount++;
			// ── Enforce operator enabledModels allowlist ──────────────────────
			// Applied as the first model-level filter so disallowed providers
			// never reach policy/routing checks. enabledModels is only set when
			// the operator has an explicit allowlist; otherwise this is a no-op.
			if (
				enabledModels !== undefined &&
				!isModelInEnabledList(model.provider, model.id, enabledModels)
			) {
				ctx.ui.notify(
					`Model ${model.provider}/${model.id} not in enabledModels allowlist; trying next fallback.`,
					"info",
				);
				enabledModelsFilteredCount++;
				continue;
			}
			// Enforce lineage-diverse-from-worker: skip candidates whose root vendor
			// matches the worker's.  lineageAllowedKeys is populated only when the
			// constraint applies (adversary / reviewer with a known worker model id).
			if (lineageAllowedKeys) {
				const key = `${model.provider.toLowerCase()}/${model.id.toLowerCase()}`;
				if (!lineageAllowedKeys.has(key)) {
					if (verbose) {
						ctx.ui.notify(
							`Lineage filter: skipping ${model.provider}/${model.id} (same root vendor as worker); trying next candidate.`,
							"info",
						);
					}
					continue;
				}
			}
			if (policyAllowedModelKeys) {
				const key = `${model.provider.toLowerCase()}/${model.id.toLowerCase()}`;
				if (!policyAllowedModelKeys.has(key)) {
					if (verbose) {
						ctx.ui.notify(
							`Model policy denied ${model.provider}/${model.id}; trying fallback.`,
							"warning",
						);
					}
					continue;
				}
				attemptedPolicyEligible = true;
			}
			// Skip models the provider has previously rejected for this account
			// (issue #4513).  The block is persisted in .sf/runtime/blocked-models.json
			// so it survives /autonomous restarts — without this, the same dead model
			// gets reselected after every restart.
			if (isModelBlocked(basePath, model.provider, model.id)) {
				ctx.ui.notify(
					`Skipping blocked model ${model.provider}/${model.id} (provider rejected it for this account).`,
					"warning",
				);
				continue;
			}
			// Enforce advisor_allowed_providers for advisory subagents (advisory dispatch
			// policy — see preferences-models.ts isProviderAllowedForAdvisor).  When
			// advisor_allowed_providers is set, only those providers may be used for
			// subagent units; otherwise falls back to standard allowed/blocked lists.
			if (
				(unitType === "subagent" || unitType.startsWith("subagent/")) &&
				prefs &&
				!isProviderAllowedForAdvisor(model.provider, prefs)
			) {
				ctx.ui.notify(
					`Skipping ${model.provider}/${model.id} for ${unitType} — provider not in advisor_allowed_providers.`,
					"warning",
				);
				continue;
			}
			// Warn if the ID is ambiguous across providers
			if (!modelId.includes("/")) {
				const providers = availableModels
					.filter((m) => m.id === modelId)
					.map((m) => m.provider);
				if (providers.length > 1 && model.provider !== ctx.model?.provider) {
					ctx.ui.notify(
						`Model ID "${modelId}" exists in multiple providers (${providers.join(", ")}). ` +
							`Resolved to ${model.provider}. Use "provider/model" format for explicit targeting.`,
						"warning",
					);
				}
			}
			const ok = await pi.setModel(model, { persist: persistModelChanges });
			if (ok) {
				appliedModel = model;
				// Cache worker model id for subsequent adversary/reviewer lineage filter.
				if (String(role ?? "").toLowerCase() === "worker") {
					_lastWorkerModelId = model.id;
				}
				reapplyThinkingLevel(pi, autoModeStartThinkingLevel);
				// ADR-005: Adjust active tool set for the selected model's provider capabilities.
				// Hard-filter incompatible tools, then let extensions override via adjust_tool_set hook.
				const activeToolNames = pi.getActiveTools();
				const { toolNames: compatibleTools, removedTools } = adjustToolSet(
					activeToolNames,
					model.api,
				);
				let finalToolNames = compatibleTools;
				// Fire adjust_tool_set hook — extensions can override the filtered tool set
				if (routingConfig.hooks !== false) {
					const hookResult = await pi.emitAdjustToolSet({
						selectedModelApi: model.api,
						selectedModelProvider: model.provider,
						selectedModelId: model.id,
						activeToolNames,
						filteredTools: removedTools,
					});
					if (hookResult?.toolNames) {
						finalToolNames = hookResult.toolNames;
					}
				}
				// Apply the filtered tool set if any tools were removed
				if (
					removedTools.length > 0 ||
					finalToolNames.length !== activeToolNames.length
				) {
					pi.setActiveTools(finalToolNames);
				}
				{
					const fallbackNote =
						modelId === effectiveModelConfig.primary
							? ""
							: ` (fallback from ${effectiveModelConfig.primary})`;
					const phase = unitPhaseLabel(unitType);
					ctx.ui.notify(
						`Model [${phase}]${routingTierLabel}: ${model.provider}/${model.id}${fallbackNote}`,
						"info",
					);
				}
				if (verbose) {
					// ADR-005: Report tools filtered due to provider incompatibility
					if (removedTools.length > 0) {
						ctx.ui.notify(
							`Tool compatibility: ${removedTools.length} tools filtered for ${model.api} — ${removedTools.join(", ")}`,
							"info",
						);
					}
				}
				break;
			} else {
				const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1];
				if (nextModel) {
					if (verbose)
						ctx.ui.notify(
							`Failed to set model ${modelId}, trying ${nextModel}...`,
							"info",
						);
				} else {
					ctx.ui.notify(
						`All preferred models unavailable for ${unitType}. Using default.`,
						"warning",
					);
				}
			}
		}
		// ── enabledModels exhaustion error ───────────────────────────────────
		// When every resolved candidate was filtered out by the enabledModels
		// allowlist (and no model was applied), surface a clear operator-
		// actionable error rather than silently falling back to the session default.
		if (
			appliedModel === null &&
			enabledModels !== undefined &&
			enabledModelsResolvedCount > 0 &&
			enabledModelsFilteredCount === enabledModelsResolvedCount
		) {
			throw new Error(
				`All fallback candidates filtered by enabledModels allowlist. ` +
					`Either add a provider/model to enabledModels in ~/.sf/agent/settings.json ` +
					`or unset enabledModels. Set SF_BYPASS_ENABLED_MODELS=1 to disable the check.`,
			);
		}
		if (
			uokFlags.modelPolicy &&
			policyAllowedModelKeys &&
			!attemptedPolicyEligible
		) {
			throw new ModelPolicyDispatchBlockedError(
				unitType,
				unitId,
				policyDenyReasons,
			);
		}
		// ── Advisor-check fallback to session model ─────────────────────────────────
		// When all configured models were filtered by the advisor check and no
		// autoModeStartModel was provided, fall back to ctx.model (the active session
		// model) so the subagent can still run on an allowed provider.
		// Only fires when the advisor check was active (advisor_allowed_providers
		// is set) and no model was successfully applied.
		if (
			appliedModel === null &&
			(unitType === "subagent" || unitType.startsWith("subagent/")) &&
			prefs &&
			ctx.model &&
			isProviderAllowedForAdvisor(ctx.model.provider, prefs)
		) {
			const sessionModel = ctx.model;
			const ok = await pi.setModel(sessionModel, {
				persist: persistModelChanges,
			});
			if (ok) {
				appliedModel = sessionModel;
				reapplyThinkingLevel(pi, autoModeStartThinkingLevel);
			}
		}
	} else if (autoModeStartModel) {
		// No model preference for this unit type — re-apply the model captured
		// at autonomous mode start to prevent bleed from shared global settings.json (#650).
		const startEnabledModels = readEnabledModels();
		const availableModels = filterModelsByProviderModelAllow(
			ctx.modelRegistry
				.getAvailable()
				.filter((m) =>
					isProviderAllowedByLists(
						m.provider,
						prefs?.allowed_providers,
						prefs?.blocked_providers,
					),
				)
				.filter((m) =>
					isModelInEnabledList(m.provider, m.id, startEnabledModels),
				),
			prefs?.provider_model_allow,
			prefs?.provider_model_block,
		);
		const startModel = availableModels.find(
			(m) =>
				m.provider === autoModeStartModel.provider &&
				m.id === autoModeStartModel.id,
		);
		if (startModel) {
			const ok = await pi.setModel(startModel, {
				persist: persistModelChanges,
			});
			if (!ok) {
				const byId = availableModels.find(
					(m) => m.id === autoModeStartModel.id,
				);
				if (byId) {
					const fallbackOk = await pi.setModel(byId, {
						persist: persistModelChanges,
					});
					if (fallbackOk) {
						appliedModel = byId;
						reapplyThinkingLevel(pi, autoModeStartThinkingLevel);
					}
				}
			} else {
				appliedModel = startModel;
				reapplyThinkingLevel(pi, autoModeStartThinkingLevel);
			}
		}
	}
	return { routing, appliedModel };
}
/**
 * Resolve a model ID string to a model object from the available models list.
 * Handles formats: "provider/model", "bare-id", "org/model-name" (OpenRouter).
 */
export function resolveModelId(modelId, availableModels, currentProvider) {
	const slashIdx = modelId.indexOf("/");
	if (slashIdx !== -1) {
		const maybeProvider = modelId.substring(0, slashIdx);
		const id = modelId.substring(slashIdx + 1);
		const knownProviders = new Set(
			availableModels.map((m) => m.provider.toLowerCase()),
		);
		if (knownProviders.has(maybeProvider.toLowerCase())) {
			const match = availableModels.find(
				(m) =>
					m.provider.toLowerCase() === maybeProvider.toLowerCase() &&
					m.id.toLowerCase() === id.toLowerCase(),
			);
			if (match) return match;
		}
		// Try matching the full string as a model ID (OpenRouter-style)
		const lower = modelId.toLowerCase();
		return availableModels.find(
			(m) =>
				m.id.toLowerCase() === lower ||
				`${m.provider}/${m.id}`.toLowerCase() === lower,
		);
	}
	// Bare ID — resolve with provider precedence to avoid silent misrouting.
	// Extension providers (e.g. claude-code) expose the same model IDs as their
	// upstream API providers but route through a subprocess with different
	// context, tool visibility, and cost characteristics (#2905).  Bare IDs in
	// preferences.yaml must resolve to the canonical API provider, not to an
	// extension wrapper that happens to be the current session provider.
	const candidates = availableModels.filter((m) =>
		matchesBareModelId(m.id, modelId),
	);
	if (candidates.length === 0) return undefined;
	if (candidates.length === 1) return candidates[0];
	const lowerModelId = modelId.toLowerCase();
	const isGeminiFamily =
		lowerModelId.startsWith("gemini-") || lowerModelId.startsWith("gemma-");
	// When the user's current provider is claude-code (set by startup migration
	// or explicit selection), honour it for bare IDs.  Routing back to anthropic
	// would undo the migration and hit the third-party subscription block (#3772).
	if (currentProvider === "claude-code") {
		const ccMatch = candidates.find((m) => m.provider === "claude-code");
		if (ccMatch) return ccMatch;
	}
	// Google Gemini routing should converge on the operational Google default
	// backend for bare IDs. Keep Vertex explicit, but prefer the CLI-core
	// backend over the direct API backend when both expose the same Gemini
	// family model. The direct API path remains available as an explicit or
	// fallback route, but is not the default operational surface.
	if (isGeminiFamily && currentProvider === "google-vertex") {
		const vertexMatch = candidates.find((m) => m.provider === "google-vertex");
		if (vertexMatch) return vertexMatch;
	}
	if (isGeminiFamily) {
		const googleCliMatch = candidates.find(
			(m) => m.provider === "google-gemini-cli",
		);
		if (googleCliMatch) return googleCliMatch;
		const googleApiMatch = candidates.find((m) => m.provider === "google");
		if (googleApiMatch) return googleApiMatch;
	}
	const familyPreferred = resolveFamilyPreferredBareModel(modelId, candidates);
	if (familyPreferred) return familyPreferred;
	// Extension / CLI-wrapper providers that should not win bare-ID resolution
	// when a first-class API provider also offers the same model AND the user
	// has not explicitly chosen the extension provider.
	const EXTENSION_PROVIDERS = new Set(["claude-code"]);
	// Prefer currentProvider only when it is a first-class API provider
	if (currentProvider && !EXTENSION_PROVIDERS.has(currentProvider)) {
		const providerMatch = candidates.find(
			(m) => m.provider === currentProvider,
		);
		if (providerMatch) return providerMatch;
	}
	// Prefer "anthropic" as the canonical provider for Anthropic models
	const anthropicMatch = candidates.find((m) => m.provider === "anthropic");
	if (anthropicMatch) return anthropicMatch;
	// Fall back to first non-extension candidate, or any candidate
	return (
		candidates.find((m) => !EXTENSION_PROVIDERS.has(m.provider)) ??
		candidates[0]
	);
}
/**
 * Flat-rate providers charge the same per request regardless of model.
 * Dynamic routing provides no cost benefit — it only degrades quality (#3453).
 * Uses case-insensitive matching with alias support to prevent fail-open on
 * provider naming variations.
 */
const BUILTIN_FLAT_RATE = new Set(["claude-code"]);
/**
 * Check if a provider has flat-rate pricing where model selection provides no cost benefit.
 * Consults built-in list, auth mode, and user preference list.
 */
export function isFlatRateProvider(provider, opts) {
	const p = provider.toLowerCase();
	if (BUILTIN_FLAT_RATE.has(p)) return true;
	if (opts?.userFlatRate?.some((id) => id.toLowerCase() === p)) return true;
	if (opts?.authMode === "externalCli") return true;
	return false;
}
/**
 * Build a FlatRateContext for a given provider from live runtime state.
 * Safe to call when ctx or prefs are undefined — missing pieces are
 * treated as "no signal".
 */
/**
 * Build a FlatRateContext for a provider from live runtime state (registry auth mode and preferences).
 * Safe to call with undefined ctx or prefs — missing pieces are treated as "no signal".
 */
export function buildFlatRateContext(provider, ctx, prefs) {
	let authMode;
	const getAuthMode = ctx?.modelRegistry?.getProviderAuthMode?.bind(
		ctx.modelRegistry,
	);
	if (typeof getAuthMode === "function") {
		try {
			const mode = getAuthMode(provider);
			if (
				mode === "apiKey" ||
				mode === "oauth" ||
				mode === "externalCli" ||
				mode === "none"
			) {
				authMode = mode;
			}
		} catch (err) {
			// Registry lookup failure must never break flat-rate detection —
			// fall through with authMode undefined and surface the cause.
			logWarning(
				"dispatch",
				`flat-rate auth-mode lookup failed for ${provider}: ${getErrorMessage(err)}`,
			);
		}
	}
	return {
		authMode,
		userFlatRate: prefs?.flat_rate_providers,
	};
}
