Files
nucleic/Sources/NucleicCore/IntelligenceRouting.swift
T

428 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import NucleicProtocol
/// The per-model effort capability rules, extracted from the app-layer `ModelCatalog` so the
/// Intelligence router (and its tests) can live in NucleicCore. `ModelCatalog` keeps thin
/// delegating wrappers, so every existing call site — and the wire catalog projection — is
/// unchanged. First slice of the catalog-by-BackendID refactor flagged in
/// docs/GROK_ADAPTER.md §"Known design debt".
public enum EffortLadder {
/// Ordinary effort levels, ordered lowest → highest. A given model supports a *prefix*
/// of these (see `efforts(for:codexPro:)`).
public static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]
/// Codex's wire value for the Pro-only GPT-5.6 Sol mode (displayed "Pro").
public static let proEffort = "ultra"
/// The single "Auto" reasoning mode used by the ACP wrapper agents: the agent picks its
/// own thinking depth, so the only API-level choice is `auto` (cosmetic — never sent).
public static let autoEfforts: [String] = ["auto"]
/// Effort when nothing else is stored or supported (mirrored by
/// `ModelCatalog.fallbackEffort`).
public static let fallbackEffort = "high"
/// The backends whose agents expose only the single "Auto" reasoning — the ACP wrapper
/// agents, which don't take a `reasoning_effort`-style flag.
public static let autoReasoningBackends: Set<BackendID> = [
.grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp,
]
/// The effort levels `sku` actually supports for this account. ACP wrappers expose only
/// "Auto"; GPT-5.6 models support "max"; a ChatGPT Pro account additionally gets Sol's
/// `ultra` wire mode; older Codex models top out at "xhigh".
public static func efforts(for sku: String, codexPro: Bool = false) -> [String] {
if let backend = BackendID.forModel(sku), autoReasoningBackends.contains(backend) {
return autoEfforts
}
if sku == "gpt-5.6-sol", codexPro {
return efforts + [proEffort]
}
guard let cap = effortCap(for: sku), let idx = efforts.firstIndex(of: cap) else {
return efforts
}
return Array(efforts[...idx])
}
/// Highest effort `sku` supports, or `nil` for the full range.
private static func effortCap(for sku: String) -> String? {
if sku.hasPrefix("gpt-5.6-") { return "max" }
return BackendID.forModel(sku) == .codex ? "xhigh" : nil
}
/// Clamp `effort` to what `sku` supports, so switching to a model with a lower cap can't
/// leave an unsupported level selected. Orchestra is an orchestration mode, not an API
/// level — valid for every model (it resolves to `xhigh` on the host), so it's preserved.
public static func clampedEffort(_ effort: String, for sku: String, codexPro: Bool = false) -> String {
if OrchestrationMode.isOrchestra(effort) { return effort }
let supported = efforts(for: sku, codexPro: codexPro)
return supported.contains(effort) ? effort : (supported.last ?? fallbackEffort)
}
}
/// Intelligence-routing provenance carried onto a `Session` at creation (and per-turn via
/// `SessionController.setRoutingNote`) — pure transparency for the "why this model" UI.
public struct RoutingNote: Sendable, Equatable {
public var purpose: PromptPurpose
public var level: IntelligenceLevel
public var reason: String
public init(purpose: PromptPurpose, level: IntelligenceLevel, reason: String) {
self.purpose = purpose
self.level = level
self.reason = reason
}
public init(_ resolution: IntelligenceRouter.Resolution) {
self.init(
purpose: resolution.purpose, level: resolution.level, reason: resolution.reason)
}
}
/// Purpose × level → concrete (model, effort). The routing knowledge is a hand-encoded
/// matrix grounded in July-2026 model research (see the plan's research table and the
/// per-cell comments): Opus 5 peaks at *medium* effort on routine coding and never exceeds
/// `high`; GPT-5.6 Sol owns the backend lane at high+; Fable 5 appears only at Deep/Max on
/// the lanes that genuinely reward long-horizon reasoning; Haiku/Luna only where a light
/// model can't hurt. Every cell carries a Claude-lane and a GPT-lane candidate so a
/// single-provider user always resolves; `IntelligenceRoutingTests` enforces the
/// invariants (tier monotonicity, per-model caps, valid efforts for every connected set).
public enum IntelligenceRouter {
/// One ranked routing option: a SKU, its effort, and whether a ChatGPT Pro account
/// upgrades the effort to Sol's `ultra` mode at Max level.
public struct Candidate: Sendable, Equatable {
public var sku: String
public var effort: String
public var ultraEligible: Bool
public init(_ sku: String, _ effort: String, ultraEligible: Bool = false) {
self.sku = sku
self.effort = effort
self.ultraEligible = ultraEligible
}
}
/// Temporary quota exclusions applied on top of the purpose ranking. Provider-wide
/// windows remove a whole lane; Claude model-family windows remove only that family so
/// another Claude model can still be the nearest usable match.
public struct Limits: Sendable, Equatable {
public var providers: Set<BackendID>
public var models: Set<String>
public init(providers: Set<BackendID> = [], models: Set<String> = []) {
self.providers = providers
self.models = models
}
public init(
claudeUsage: SubscriptionUsage?, codexUsage: CodexUsage?,
claudeRateLimit: RateLimit?, now: Date
) {
var providers = Set<BackendID>()
var models = Set<String>()
let claudeWindows = [
claudeUsage?.fiveHour, claudeUsage?.sevenDay,
claudeUsage?.sevenDayOpus, claudeUsage?.sevenDaySonnet,
]
let hasPreciseClaudeUsage = claudeWindows.contains { $0 != nil }
func reached(_ window: UsageWindow?) -> Bool {
window.map { $0.utilization(at: now) >= 100 } ?? false
}
if reached(claudeUsage?.fiveHour) || reached(claudeUsage?.sevenDay) {
providers.insert(.claudeCode)
} else if !hasPreciseClaudeUsage,
claudeRateLimit?.status == "rejected",
claudeRateLimit?.resetsAt.map({ now < $0 }) ?? true {
// The stream event is a coarse Claude-only fallback when the richer OAuth
// snapshot is unavailable.
providers.insert(.claudeCode)
}
if reached(claudeUsage?.sevenDayOpus) {
models.formUnion(IntelligenceRouter.routedModels.filter { $0.hasPrefix("claude-opus") })
}
if reached(claudeUsage?.sevenDaySonnet) {
models.formUnion(IntelligenceRouter.routedModels.filter { $0.hasPrefix("claude-sonnet") })
}
if codexUsage?.weekly?.utilization(at: now) ?? 0 >= 100 {
providers.insert(.codex)
}
self.init(providers: providers, models: models)
}
public func contains(model sku: String) -> Bool {
if models.contains(sku) { return true }
guard let backend = BackendID.forModel(sku) else { return false }
let normalized = backend == .codexExec ? BackendID.codex : backend
return providers.contains(normalized)
}
}
/// What the router decided and why — the composer passes `model`/`effort` straight into
/// the existing start path (concrete strings; downstream plumbing unchanged), and the
/// resolved chip + session routing note surface `reason`.
public struct Resolution: Sendable, Equatable {
public var model: String
public var effort: String
public var purpose: PromptPurpose
public var level: IntelligenceLevel
public var reason: String
public var isAvailable: Bool
public init(
model: String, effort: String, purpose: PromptPurpose,
level: IntelligenceLevel, reason: String, isAvailable: Bool = true
) {
self.model = model
self.effort = effort
self.purpose = purpose
self.level = level
self.reason = reason
self.isAvailable = isAvailable
}
}
/// Cost tiers for the "a misroute can never cross more than one tier" test invariant:
/// T0 haiku/luna · T1 terra/sonnet · T2 opus-5/sol · T3 fable. Unknown SKUs rank T2 so
/// a future addition fails toward caution in the tests, not silently into the cheap bin.
public static func costTier(of sku: String) -> Int {
if sku.hasPrefix("claude-haiku") || sku == "gpt-5.6-luna" { return 0 }
if sku.hasPrefix("claude-sonnet") || sku == "gpt-5.6-terra" { return 1 }
if sku.hasPrefix("claude-fable") { return 3 }
return 2
}
/// The routing matrix. Per cell: ranked candidates, preferred lane first. Claude lane
/// uses sonnet-5/opus-5/fable-5/haiku-4-5; GPT lane uses luna/terra/sol. Research notes
/// inline where a cell is deliberately *not* the obvious escalation.
static let matrix: [PromptPurpose: [IntelligenceLevel: [Candidate]]] = [
.planning: [
.quick: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
.light: [.init("claude-opus-5", "medium"), .init("gpt-5.6-terra", "high")],
.balanced: [.init("claude-opus-5", "high"), .init("gpt-5.6-sol", "high")],
.deep: [.init("claude-fable-5", "high"), .init("gpt-5.6-sol", "xhigh")],
// Fable is the long-horizon planning specialist; max effort is reserved for it.
.max: [.init("claude-fable-5", "max"), .init("gpt-5.6-sol", "xhigh", ultraEligible: true)],
],
.backendImpl: [
.quick: [.init("claude-sonnet-5", "low"), .init("gpt-5.6-luna", "medium")],
.light: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
// Sol leads the backend lane from Balanced up — it rewards high+ effort on
// implementation where Opus 5 would already be past its medium-effort peak.
.balanced: [.init("gpt-5.6-sol", "high"), .init("claude-opus-5", "medium")],
.deep: [.init("gpt-5.6-sol", "xhigh"), .init("claude-fable-5", "high")],
.max: [.init("gpt-5.6-sol", "xhigh", ultraEligible: true), .init("claude-fable-5", "high")],
],
.frontendImpl: [
.quick: [.init("claude-sonnet-5", "low"), .init("gpt-5.6-luna", "medium")],
.light: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
// Opus 5 is the consensus UI pick — at medium, its measured peak for routine work.
.balanced: [.init("claude-opus-5", "medium"), .init("gpt-5.6-terra", "high")],
.deep: [.init("claude-opus-5", "high"), .init("gpt-5.6-sol", "high")],
.max: [.init("claude-fable-5", "high"), .init("gpt-5.6-sol", "xhigh")],
],
.quickFix: [
.quick: [.init("gpt-5.6-luna", "low"), .init("claude-sonnet-5", "low")],
.light: [.init("claude-sonnet-5", "low"), .init("gpt-5.6-luna", "medium")],
.balanced: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
// Deep tops out at Opus·medium (its measured peak) — a "deep" quick fix wants
// care, not scope; and it keeps this row within one cost tier of the heavy rows
// a borderline classification could have landed on (the drift invariant).
.deep: [.init("claude-opus-5", "medium"), .init("gpt-5.6-terra", "high")],
// Even "Max" on a quick fix stops at Opus·medium — more model would only invite
// out-of-scope refactors (the measured Opus-past-medium failure mode).
.max: [.init("claude-opus-5", "medium"), .init("gpt-5.6-sol", "high")],
],
.refactor: [
.quick: [.init("claude-sonnet-5", "low"), .init("gpt-5.6-luna", "medium")],
.light: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
.balanced: [.init("claude-sonnet-5", "high"), .init("gpt-5.6-terra", "high")],
.deep: [.init("claude-opus-5", "medium"), .init("gpt-5.6-sol", "high")],
.max: [.init("claude-fable-5", "high"), .init("gpt-5.6-sol", "xhigh")],
],
.debugging: [
.quick: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
.light: [.init("claude-sonnet-5", "high"), .init("gpt-5.6-terra", "high")],
.balanced: [.init("claude-opus-5", "high"), .init("gpt-5.6-sol", "high")],
.deep: [.init("claude-fable-5", "high"), .init("gpt-5.6-sol", "xhigh")],
// Root-causing gnarly failures is the other lane that genuinely rewards Fable max.
.max: [.init("claude-fable-5", "max"), .init("gpt-5.6-sol", "xhigh", ultraEligible: true)],
],
.review: [
.quick: [.init("claude-haiku-4-5", "low"), .init("gpt-5.6-luna", "low")],
.light: [.init("claude-sonnet-5", "low"), .init("gpt-5.6-terra", "medium")],
.balanced: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "high")],
.deep: [.init("claude-opus-5", "high"), .init("gpt-5.6-sol", "high")],
.max: [.init("claude-opus-5", "high"), .init("gpt-5.6-sol", "xhigh")],
],
.writing: [
.quick: [.init("claude-haiku-4-5", "low"), .init("gpt-5.6-luna", "low")],
.light: [.init("claude-sonnet-5", "low"), .init("gpt-5.6-luna", "medium")],
// Sonnet/Terra (not the bargain tier) from Balanced up: prose prompts routinely
// border on backend/docs mixes, so the row stays within one cost tier of the
// implementation rows a borderline classification could have meant (the drift
// invariant) — and serious writing genuinely reads better off the mid tier.
.balanced: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
.deep: [.init("claude-opus-5", "medium"), .init("gpt-5.6-terra", "high")],
.max: [.init("claude-opus-5", "medium"), .init("gpt-5.6-terra", "high")],
],
// The conservative row every failed classification lands on: mid-tier at every stop,
// so "we couldn't tell" can never mean Fable-at-max or Haiku-on-a-hard-task.
.general: [
.quick: [.init("claude-sonnet-5", "low"), .init("gpt-5.6-luna", "medium")],
.light: [.init("claude-sonnet-5", "medium"), .init("gpt-5.6-terra", "medium")],
.balanced: [.init("claude-opus-5", "medium"), .init("gpt-5.6-terra", "high")],
.deep: [.init("claude-opus-5", "high"), .init("gpt-5.6-sol", "high")],
.max: [.init("claude-fable-5", "high"), .init("gpt-5.6-sol", "xhigh")],
],
]
/// Every SKU the matrix can produce. Kept derived from the matrix so quota-family
/// exclusions automatically cover new cells without a second catalog to maintain.
static var routedModels: Set<String> {
Set(matrix.values.flatMap { $0.values }.flatMap { $0 }.map(\.sku))
}
/// The requested cell is the primary ranking. If quota removes all of its usable
/// candidates, walk lower levels of the same purpose from nearest to cheapest before
/// considering higher levels. This preserves the user budget whenever possible while
/// still providing a same-purpose match for a single-provider account.
private static func rankedCandidates(
purpose: PromptPurpose, level: IntelligenceLevel
) -> [Candidate] {
let row = matrix[purpose] ?? matrix[.general] ?? [:]
let lower = IntelligenceLevel.allCases
.filter { $0 <= level }
.sorted { $0.rawValue > $1.rawValue }
let higher = IntelligenceLevel.allCases
.filter { $0 > level }
.sorted { $0.rawValue < $1.rawValue }
var seen = Set<String>()
return (lower + higher).flatMap { row[$0] ?? [] }.filter { seen.insert($0.sku).inserted }
}
/// One-line research rationale per model family, composed into `Resolution.reason` so
/// the chip tooltip explains *why* this model, not just which.
static func rationale(for sku: String, effort: String) -> String {
if sku.hasPrefix("claude-fable") {
return "long-horizon specialist — worth its cost on genuinely complex work"
}
if sku.hasPrefix("claude-opus-5") {
return effort == "medium"
? "consensus quality pick at its measured peak effort"
: "near-flagship quality at half the flagship price"
}
if sku == "gpt-5.6-sol" { return "strongest implementation lane at high effort" }
if sku == "gpt-5.6-terra" { return "balanced mid-tier for scoped work" }
if sku == "gpt-5.6-luna" { return "fast lane for clear, contained tasks" }
if sku.hasPrefix("claude-sonnet") { return "balanced daily driver" }
if sku.hasPrefix("claude-haiku") { return "light model — plenty for prose and lookups" }
return "closest available match"
}
/// Pick the (model, effort) for `purpose` at `level`.
///
/// - `connected`: backends the user can actually run (`ProviderAvailability`).
/// - `pinned`: the preferred Settings provider. It remains strict while usable, but
/// is temporarily bypassed when quota blocks every candidate on that provider.
/// - `backendLock`: in-session routing. The fixed backend cannot be bypassed, but a
/// model-family limit may choose the nearest model on that same backend.
/// - `degraded`: active provider incidents are demoted, never eliminated.
/// - `limits`: reached provider or model-family quota windows are hard exclusions.
/// - `fallback`: the resolved app default used when routing has no catalog candidate.
public static func route(
purpose: PromptPurpose,
level: IntelligenceLevel,
connected: Set<BackendID>,
pinned: BackendID? = nil,
backendLock: BackendID? = nil,
degraded: Set<BackendID> = [],
limits: Limits = Limits(),
codexPro: Bool = false,
fallback: (model: String, effort: String)
) -> Resolution {
let ranked = rankedCandidates(purpose: purpose, level: level)
func lane(_ backend: BackendID) -> BackendID {
backend == .codexExec ? .codex : backend
}
func candidateLane(_ candidate: Candidate) -> BackendID? {
BackendID.forModel(candidate.sku).map(lane)
}
var reachable = Set(connected.map(lane))
if let lock = backendLock { reachable.insert(lane(lock)) }
let lockedLane = backendLock.map(lane)
func structurallyEligible(_ candidate: Candidate) -> Bool {
guard let backend = candidateLane(candidate), reachable.contains(backend) else { return false }
return lockedLane == nil || backend == lockedLane
}
let quotaEligible = ranked.filter {
structurallyEligible($0) && !limits.contains(model: $0.sku)
}
var pinBypassedForLimit = false
var eligible: [Candidate]
if let pinnedLane = pinned.map(lane) {
let onPin = quotaEligible.filter { candidateLane($0) == pinnedLane }
let limitedOnPin = ranked.contains {
structurallyEligible($0) && candidateLane($0) == pinnedLane
&& limits.contains(model: $0.sku)
}
if !onPin.isEmpty {
eligible = onPin
} else if limitedOnPin, lockedLane == nil {
// A pin expresses preference, not a request to launch against a provider
// known to be rejecting work. Restore it automatically after the reset.
eligible = quotaEligible
pinBypassedForLimit = true
} else {
eligible = []
}
} else {
eligible = quotaEligible
}
// Demote (never drop) degraded lanes after quota filtering. A healthy alternative
// wins, but if all remaining lanes have incidents the purpose ranking stands.
var demoted = false
let healthy = eligible.filter { candidate in
candidateLane(candidate).map { !degraded.map(lane).contains($0) } ?? false
}
if !healthy.isEmpty, healthy.count < eligible.count {
demoted = true
eligible = healthy + eligible.filter { !healthy.contains($0) }
}
guard let pick = eligible.first else {
let clamped = EffortLadder.clampedEffort(
fallback.effort, for: fallback.model, codexPro: codexPro)
let quotaBlockedRoute = limits.contains(model: fallback.model) || ranked.contains {
structurallyEligible($0) && limits.contains(model: $0.sku)
}
let reason = quotaBlockedRoute
? "\(purpose.displayName) · \(level.displayName) — every compatible model is at its usage limit"
: "\(purpose.displayName) · \(level.displayName) — no routed candidate on a connected provider, using your default"
return Resolution(
model: fallback.model, effort: clamped, purpose: purpose, level: level,
reason: reason, isAvailable: !quotaBlockedRoute)
}
var effort = pick.effort
if pick.ultraEligible, codexPro, level == .max {
effort = EffortLadder.proEffort
}
effort = EffortLadder.clampedEffort(effort, for: pick.sku, codexPro: codexPro)
let selectedIndex = ranked.firstIndex(of: pick) ?? 0
let preferredWasLimited = ranked[..<selectedIndex].contains {
structurallyEligible($0) && limits.contains(model: $0.sku)
}
var reason = "\(purpose.displayName) · \(level.displayName)\(rationale(for: pick.sku, effort: effort))"
if pinBypassedForLimit {
reason += " (pinned provider is at its usage limit; using the nearest available provider)"
} else if preferredWasLimited {
reason += " (preferred route is at its usage limit; using the nearest available match)"
}
if demoted { reason += " (preferred lane has a provider incident)" }
return Resolution(
model: pick.sku, effort: effort, purpose: purpose, level: level, reason: reason)
}
}