Files
nucleic/Sources/NucleicApp/ModelCatalog.swift
T
abkslmandnucleic 980e9f734b Add Model Selector Options
Nucleic-Session: 6359C9B9-DA05-42C5-8E0D-7904A70E7ED7
Co-authored-by: Nucleic <[email protected]>
2026-07-09 13:07:08 -07:00

419 lines
20 KiB
Swift

import Foundation
import NucleicCore
/// Selectable model SKUs and effort levels, plus the persistence keys for the per-app
/// defaults applied to new chats. The chosen SKU also selects the backend
/// (`BackendID.forModel`), and each model exposes only the effort levels it supports.
enum ModelCatalog {
/// Full model SKUs the picker offers (not friendly aliases). The chosen SKU also selects
/// the backend (`BackendID.forModel`): the Claude SKUs run on Claude Code, the `gpt-*` SKUs
/// on Codex (`codex app-server`), and the `grok-*` SKUs on Grok over ACP (`grok agent stdio`).
static let models: [String] = [
"claude-fable-5",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.6-sol",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"grok-build",
]
/// All effort levels, ordered lowest → highest. A given model supports a *prefix* of
/// these (see `efforts(for:)`); the picker should only ever offer the supported ones.
static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]
/// Grok's reasoning levels. Grok Build exposes a single **"Auto"** reasoning mode (xAI picks
/// the thinking depth itself), so the only API-level choice is `auto` — presented under the
/// "Reasoning" noun (see `effortNoun`) and rendered "Auto" (`effortDisplayName`). The backend
/// never passes a reasoning flag for Grok; the selection is cosmetic. (Orchestra is still
/// offered below it in the menu — it's an orchestration mode, not a Grok reasoning level.)
static let grokEfforts: [String] = ["auto"]
/// The effort levels `sku` actually supports. Grok exposes only its single "Auto" reasoning
/// (`grokEfforts`); Codex models top out at "xhigh" (confirmed from `codex model/list`:
/// gpt-5.x supports low/medium/high/xhigh); Claude models support the full range incl. "max".
static func efforts(for sku: String) -> [String] {
if BackendID.forModel(sku) == .grok { return grokEfforts }
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? {
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 (e.g. "max" carried onto a Codex model → "xhigh").
/// Orchestra is an orchestration mode, not an API level — valid for every model (it
/// resolves to a supported effort, `xhigh`, on the host), so it's always preserved.
static func clampedEffort(_ effort: String, for sku: String) -> String {
if isOrchestra(effort) { return effort }
let supported = efforts(for: sku)
return supported.contains(effort) ? effort : (supported.last ?? fallbackEffort)
}
/// The reasoning control's noun in the composer: Codex and Grok call it "Reasoning" (both
/// expose a `reasoning_effort`-style thinking level), Claude "Effort".
static func effortNoun(for sku: String) -> String {
switch BackendID.forModel(sku) {
case .codex, .grok: return "Reasoning"
default: return "Effort"
}
}
/// The picker's SKUs split into per-provider runs, preserving `models` order, so the
/// composer's model menu can set each provider apart with a divider (Claude, then GPT,
/// then Grok). Providers are contiguous in `models`, so a new group starts whenever the
/// inferred backend changes (`BackendID.forModel`).
static var modelGroups: [[String]] {
var groups: [[String]] = []
var current: [String] = []
var currentBackend: BackendID?
for sku in models {
let backend = BackendID.forModel(sku) ?? .claudeCode
if backend != currentBackend, !current.isEmpty {
groups.append(current)
current = []
}
currentBackend = backend
current.append(sku)
}
if !current.isEmpty { groups.append(current) }
return groups
}
/// The SKUs that run on `backend` — for the in-session model menu, where the backend is
/// fixed at creation and can't change. (The home composer offers all SKUs, since the model
/// chooses the backend.)
static func models(for backend: BackendID) -> [String] {
models.filter { sku in
let modelBackend = BackendID.forModel(sku) ?? .claudeCode
switch backend {
case .claudeCode: return modelBackend == .claudeCode
case .codex, .codexExec: return modelBackend == .codex
case .grok: return modelBackend == .grok
}
}
}
/// The "orchestra" orchestration mode (`OrchestrationMode`). It rides in the effort menu
/// like a level, but it isn't an API effort — it pairs `xhigh` with standing consent to
/// fan work out to parallel subagents. Kept out of `efforts` (the canonical API levels) so
/// it can be presented separately, below a divider, with its own purple treatment.
static let orchestraEffort = OrchestrationMode.effortSentinel
/// Whether `effort` selects orchestra.
static func isOrchestra(_ effort: String?) -> Bool { OrchestrationMode.isOrchestra(effort) }
/// A pretty, human label for an effort level — orchestra reads "Orchestra"; Grok's single
/// reasoning mode reads "Auto" (a named mode, like Orchestra, not a generic level); the plain
/// API levels keep their lowercase word (matching how they're shown in the menu today).
static func effortDisplayName(_ effort: String) -> String {
if isOrchestra(effort) { return "Orchestra" }
if effort == "auto" { return "Auto" }
return effort
}
/// One-line description of what orchestra does, for the menu item's help tooltip.
static let orchestraBlurb =
"Orchestra: maximum-effort orchestration. Runs at xhigh and lets the agent fan work "
+ "out to parallel subagents on its own for thorough, verified results (uses more tokens)."
/// Tooltip shown once Orchestra is on for a chat: it's a one-way latch and can't be turned off,
/// so the other effort levels are disabled (the host enforces this in `SessionController`).
static let orchestraLockedHelp =
"Orchestra is on for this chat and can't be turned off — start a new chat to use a "
+ "different effort level."
/// Why Orchestra may be unavailable: it's a Nucleic Control capability (the hardened,
/// sandboxed path), so it's only offered for projects under Nucleic Control. Shown as the
/// grayed annotation on the disabled menu item and as its tooltip.
static let orchestraRequiresControlNote = "Requires Nucleic Control"
/// Full explanation for the requirement tooltip.
static let orchestraRequiresControlHelp =
"Orchestra is a Nucleic Control capability — clone or move this project under Nucleic "
+ "Control to enable it."
/// A pretty, human label for a model SKU (e.g. "claude-sonnet-4-6" → "Sonnet
/// 4.6"). The two Opus 4.8 SKUs share the name "Opus 4.8"; their context window
/// is conveyed by the picker badge (see `contextBadge`). Falls back to a
/// best-effort prettifier for unknown SKUs.
static func displayName(_ sku: String) -> String {
switch sku {
case "claude-fable-5": return "Fable 5"
case "claude-opus-4-8[1m]": return "Opus 4.8"
case "claude-opus-4-8": return "Opus 4.8"
case "claude-sonnet-5": return "Sonnet 5"
case "claude-sonnet-4-6": return "Sonnet 4.6"
case "claude-haiku-4-5": return "Haiku 4.5"
case "gpt-5.5": return "GPT-5.5"
case "gpt-5.4": return "GPT-5.4"
case "gpt-5.4-mini": return "GPT-5.4 Mini"
case "grok-build": return "Grok Build"
default: return prettify(sku)
}
}
private static func prettify(_ sku: String) -> String {
var stem = sku
if stem.hasPrefix("claude-") { stem.removeFirst("claude-".count) }
var suffix = ""
if let open = stem.firstIndex(of: "["), let close = stem.firstIndex(of: "]") {
suffix = " (\(stem[stem.index(after: open)..<close].uppercased()))" // [1m] → (1M)
stem = String(stem[..<open])
}
let parts = stem.split(separator: "-")
guard let family = parts.first else { return sku }
let familyName = family.prefix(1).uppercased() + family.dropFirst()
let version = parts.dropFirst().joined(separator: ".")
return version.isEmpty ? "\(familyName)\(suffix)" : "\(familyName) \(version)\(suffix)"
}
static let fallbackModel = "claude-fable-5"
static let fallbackEffort = "high"
static let fallbackOrchestraWorkerModel = "claude-sonnet-5"
/// Per-provider default model + effort, **highest priority first**, used to pick a new-chat
/// default the user can actually run. The first provider the user has connected wins: Claude
/// → Fable 5 at high effort, then Codex → GPT-5.5 at medium, then Grok → Grok Build at
/// "auto" (its only reasoning mode). Consulted by `resolvedDefault` when the stored default's
/// backend isn't connected — so the default is never a model on a provider the user lacks.
static let providerDefaults: [(backend: BackendID, model: String, effort: String)] = [
(.claudeCode, "claude-fable-5", "high"),
(.codex, "gpt-5.5", "medium"),
(.grok, "grok-build", "auto"),
]
/// A comparable model on the *other* provider, for status-driven failover: each SKU pairs
/// with its nearest-tier counterpart across the Claude ↔ GPT line, so when one provider
/// reports an incident the app can offer a like-for-like default on the other (Opus ↔
/// GPT-5.5, Sonnet ↔ GPT-5.4, Haiku ↔ GPT-5.4 Mini). Nil for an unrecognized SKU.
static func crossProviderAlternative(for sku: String) -> String? {
switch sku {
case "claude-fable-5": return "gpt-5.5"
case "claude-opus-4-8[1m]", "claude-opus-4-8": return "gpt-5.5"
case "claude-sonnet-5", "claude-sonnet-4-6": return "gpt-5.4"
case "claude-haiku-4-5": return "gpt-5.4-mini"
case "gpt-5.5": return "claude-opus-4-8[1m]"
case "gpt-5.4": return "claude-sonnet-5"
case "gpt-5.4-mini": return "claude-haiku-4-5"
default: return nil
}
}
/// Approximate input context-window size (tokens) for a model SKU, used by the
/// composer's session context-usage indicator. The `[1m]` variant gets the 1M
/// window; standard Opus 4.8 gets 256K; every other Claude model uses 200K.
static func contextWindow(for sku: String) -> Int {
if sku.contains("[1m]") { return 1_000_000 }
if sku.hasPrefix("claude-fable") { return 1_000_000 } // Fable 5: 1M (max and default)
if sku.hasPrefix("claude-sonnet-5") { return 1_000_000 } // Sonnet 5: 1M
if sku.hasPrefix("claude-opus-4-8") { return 256_000 }
if sku.hasPrefix("gpt-5") { return 350_000 } // codex gpt-5.x window (~353K observed)
if sku.hasPrefix("grok") { return 256_000 } // grok-build window (inferred)
return 200_000
}
/// Short, grayed annotation shown beside a SKU in the model picker to call out
/// its context window — "1M" for the `[1m]` Opus 4.8 and "256K" for the
/// standard one — so the two same-named Opus entries are distinguishable. Nil
/// for other models.
static func contextBadge(for sku: String) -> String? {
switch sku {
case "claude-opus-4-8[1m]": return "1M"
case "claude-opus-4-8": return "256K"
default: return nil
}
}
static let defaultModelKey = "nucleic.defaultModel"
static let defaultEffortKey = "nucleic.defaultEffort"
static let defaultOrchestraWorkerModelKey = "nucleic.defaultOrchestraWorkerModel"
static let defaultAutoKey = "nucleic.defaultAuto"
static let defaultAutoShipKey = "nucleic.defaultAutoShip"
static var storedDefaultModel: String {
UserDefaults.standard.string(forKey: defaultModelKey) ?? fallbackModel
}
static var storedDefaultEffort: String {
UserDefaults.standard.string(forKey: defaultEffortKey) ?? fallbackEffort
}
static var storedDefaultOrchestraWorkerModel: String {
UserDefaults.standard.string(forKey: defaultOrchestraWorkerModelKey) ?? fallbackOrchestraWorkerModel
}
static var storedDefaultAuto: Bool {
UserDefaults.standard.bool(forKey: defaultAutoKey)
}
static var storedDefaultAutoShip: Bool {
UserDefaults.standard.bool(forKey: defaultAutoShipKey)
}
/// The default `(model, effort)` a new chat should use given which provider backends are
/// **connected** (installed *and* authenticated — see `ProviderAvailability`). Honors the
/// stored default when its backend is connected, so an explicit choice still wins; otherwise
/// falls through `providerDefaults` (Claude → Codex → Grok) to the highest-priority connected
/// provider, so the default is never a model whose backend the user can't reach. With nothing
/// connected, keeps the stored/`fallback` default so the picker always has *a* selection.
static func resolvedDefault(connected: Set<BackendID>) -> (model: String, effort: String) {
let storedModel = storedDefaultModel
if let backend = BackendID.forModel(storedModel), connected.contains(backend) {
return (storedModel, clampedEffort(storedDefaultEffort, for: storedModel))
}
if let pick = providerDefaults.first(where: { connected.contains($0.backend) }) {
return (pick.model, pick.effort)
}
return (storedModel, storedDefaultEffort)
}
/// The wire projection sent to paired phones (SYNC §5.2) so their composer/header pickers
/// mirror this catalog — same per-backend effort caps, context windows, and grouping —
/// without re-deriving any backend rules on-device. Built from this single source of truth.
static var wireCatalog: WireModelCatalog {
let groups: [[WireModelCatalog.Model]] = modelGroups.map { group in
group.map { sku in
WireModelCatalog.Model(
sku: sku,
displayName: displayName(sku),
backend: BackendID.forModel(sku) ?? .claudeCode,
contextBadge: contextBadge(for: sku),
contextWindow: contextWindow(for: sku),
efforts: efforts(for: sku),
effortNoun: effortNoun(for: sku))
}
}
// Pretty labels only for the non-identity effort levels (e.g. "auto" → "Auto") plus the
// orchestra sentinel; the phone shows the raw level for everything else.
var effortNames: [String: String] = [:]
for sku in models {
for level in efforts(for: sku) where effortDisplayName(level) != level {
effortNames[level] = effortDisplayName(level)
}
}
effortNames[orchestraEffort] = effortDisplayName(orchestraEffort) // "Orchestra"
return WireModelCatalog(
groups: groups,
effortDisplayNames: effortNames,
orchestraSentinel: orchestraEffort,
orchestraRequiresControlNote: orchestraRequiresControlNote,
fallbackModel: fallbackModel,
fallbackEffort: fallbackEffort)
}
}
/// How long a *completed* chat may sit untouched before Nucleic auto-archives it.
/// Persisted as the raw seconds value; `.never` (0) turns auto-archiving off. The
/// `AppStore` only ever sees the resolved `interval`, keeping the core unaware of the
/// UI's specific choices.
enum AutoArchivePolicy: Int, CaseIterable, Identifiable {
case never = 0
case after30Minutes = 1800
case afterHour = 3600
case after3Hours = 10800
case afterDay = 86_400
case afterWeek = 604_800
var id: Int { rawValue }
var label: String {
switch self {
case .never: "Never"
case .after30Minutes: "After 30 minutes"
case .afterHour: "After 1 hour"
case .after3Hours: "After 3 hours"
case .afterDay: "After 1 day"
case .afterWeek: "After 1 week"
}
}
/// The bare duration phrase used in explanatory copy ("30 minutes", "a week").
var durationPhrase: String {
switch self {
case .never: ""
case .after30Minutes: "30 minutes"
case .afterHour: "an hour"
case .after3Hours: "3 hours"
case .afterDay: "a day"
case .afterWeek: "a week"
}
}
/// Idle threshold in seconds the `AppStore` sweeps against, or nil to disable.
var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }
static let storageKey = "nucleic.autoArchivePolicy"
/// Out of the box, completed chats archive after a day; the user can shorten it,
/// lengthen it, or pick "Never" in Settings.
static let fallback = AutoArchivePolicy.afterDay
static var stored: AutoArchivePolicy {
// No stored key yet → the default behavior; `.never` is a real stored 0.
guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
return AutoArchivePolicy(rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
}
}
/// How long a chat may stay *archived* before Nucleic reclaims its worktree checkout to free
/// disk. The branch — and any uncommitted work, committed as a WIP commit first — is kept, so
/// unarchiving the chat re-creates the worktree exactly. Persisted as the raw seconds value;
/// `.never` (0) turns cleanup off. Like `AutoArchivePolicy`, the `AppStore` only ever sees the
/// resolved `interval`, keeping the core unaware of the UI's specific choices.
enum ArchivedWorktreeCleanupPolicy: Int, CaseIterable, Identifiable {
case never = 0
case afterHour = 3600
case afterDay = 86_400
case after3Days = 259_200
case afterWeek = 604_800
case afterMonth = 2_592_000
var id: Int { rawValue }
var label: String {
switch self {
case .never: "Never"
case .afterHour: "After 1 hour"
case .afterDay: "After 1 day"
case .after3Days: "After 3 days"
case .afterWeek: "After 1 week"
case .afterMonth: "After 1 month"
}
}
/// The bare duration phrase used in explanatory copy ("an hour", "a month").
var durationPhrase: String {
switch self {
case .never: ""
case .afterHour: "an hour"
case .afterDay: "a day"
case .after3Days: "3 days"
case .afterWeek: "a week"
case .afterMonth: "a month"
}
}
/// Threshold in seconds the `AppStore` sweeps against, or nil to disable.
var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }
static let storageKey = "nucleic.archivedWorktreeCleanupPolicy"
/// Out of the box, an archived chat's worktree is reclaimed after a day; the user can
/// shorten it, lengthen it, or pick "Never" in Settings.
static let fallback = ArchivedWorktreeCleanupPolicy.afterDay
static var stored: ArchivedWorktreeCleanupPolicy {
// No stored key yet → the default behavior; `.never` is a real stored 0.
guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
return ArchivedWorktreeCleanupPolicy(
rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
}
}