428 lines
22 KiB
Swift
428 lines
22 KiB
Swift
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)
|
||
}
|
||
}
|