Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/AgentModelCatalog.swift
T
weishuandClaude Opus 5.5 1efcc3183d feat(ios): switch models for every agent in session settings
The settings sheet only had model pickers for Claude and Codex; other
agents could not change models after creation. Live sessions now load
/sessions/:id/<flavor>-models. Agy uses its machine catalog, since it
has no session route. As on web, grok and copilot skip
terminal-controlled sessions.

Rows follow web getModelOptionsForFlavor:
- Copilot, dsh, grok and kimi offer a Default or Auto row that clears
  the model.
- Cursor's Auto is the explicit 'auto' wire id.
- Opencode, agy and pi only switch between listed models.
- An unlisted current model stays visible.

Pi resolves provider/modelId from metadata.piSelectedModel and switches
with a {provider, modelId} reference.

Efforts:
- Grok and OpenCode options come from their *-reasoning-effort-options
  routes and reload after a model switch. OpenCode options that still
  describe the previous model stay hidden.
- Pi shows the current model's thinking levels.

The sheet's load-failure flag now covers every catalog, not just Codex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:00:00 +08:00

312 lines
14 KiB
Swift

import Foundation
/// Model and effort option builders for the dynamically discovered agents —
/// ports of the web builders (`NewSession/grokModels.ts`,
/// `AssistantChat/modelOptions.ts`, `piThinkingLevelOptions.ts`,
/// `cursorCliSku.ts`). Claude and Codex keep their own catalogs.
///
/// New-session rows use the `'auto'` / `'default'` sentinels of
/// ``NewSessionOption``; session rows use ``CatalogOption`` with `nil` for
/// "clear to the agent default".
public enum AgentModelCatalog {
public enum Scope: Equatable, Sendable {
/// One catalog per machine.
case machine
/// Probed in the target directory: needs an existing `cwd`.
case directory
}
/// Nil for flavors without a discovered catalog (claude, codex, gemini).
public static func scope(for flavor: AgentFlavor) -> Scope? {
switch flavor {
case .agy, .cursor, .dsh, .pi: return .machine
case .copilot, .grok, .kimi, .opencode: return .directory
default: return nil
}
}
// MARK: - Model rows
/// The value a picker stores for a row. Pi spawns as `provider/modelId`.
public static func modelValue(_ model: AgentModelSummary, flavor: AgentFlavor) -> String {
flavor == .pi ? "\(model.provider ?? "unknown")/\(model.modelId)" : model.modelId
}
public static func modelLabel(_ model: AgentModelSummary, flavor: AgentFlavor) -> String {
let name = model.name ?? model.modelId
switch flavor {
case .kimi, .pi:
guard let provider = model.provider, !provider.isEmpty else { return name }
return "\(provider) — \(name)"
default:
return name
}
}
/// Catalog rows a picker lists: cursor keeps ACP model ids only, copilot
/// drops its own `auto` row (the picker supplies one); duplicates collapse.
public static func listedModels(_ response: AgentModelsResponse, flavor: AgentFlavor) -> [AgentModelSummary] {
var seen = Set<String>()
return (response.availableModels ?? []).filter { model in
switch flavor {
case .cursor:
guard isCursorCatalogModelId(model.modelId) else { return false }
case .copilot:
guard model.modelId != "auto" else { return false }
default:
break
}
return seen.insert(modelValue(model, flavor: flavor)).inserted
}
}
/// The row a picker value refers to (`'auto'` → none).
public static func selectedModel(
_ response: AgentModelsResponse?,
flavor: AgentFlavor,
value: String?
) -> AgentModelSummary? {
guard let response, let value, value != "auto" else { return nil }
return listedModels(response, flavor: flavor).first { modelValue($0, flavor: flavor) == value }
}
// MARK: - New session
/// Model rows for the create form: a Default (`auto`) row, labelled
/// "Auto" for copilot and cursor, then the catalog. OpenCode marks the
/// agent's own default.
public static func newSessionModelOptions(
flavor: AgentFlavor,
response: AgentModelsResponse
) -> [NewSessionOption] {
let auto = NewSessionOption(
value: "auto",
label: flavor == .copilot || flavor == .cursor ? "Auto" : "Default"
)
return [auto] + listedModels(response, flavor: flavor).map { model in
var label = modelLabel(model, flavor: flavor)
if flavor == .opencode, model.modelId == response.currentModelId {
label += " (default)"
}
return NewSessionOption(value: modelValue(model, flavor: flavor), label: label)
}
}
/// Grok launch effort (`buildGrokEffortOptions`): the efforts of the
/// selected model (or the agent's default on `auto`); static levels
/// while unknown.
public static func grokEffortOptions(response: AgentModelsResponse?, model: String) -> [NewSessionOption] {
let effective = model == "auto" ? response?.currentModelId : model
let efforts = response?.availableModels?.first { $0.modelId == effective }?.reasoningEfforts ?? []
guard !efforts.isEmpty else {
return [
NewSessionOption(value: "auto", label: "Default"),
NewSessionOption(value: "low", label: "Low"),
NewSessionOption(value: "medium", label: "Medium"),
NewSessionOption(value: "high", label: "High"),
]
}
return [NewSessionOption(value: "auto", label: "Default")]
+ efforts.map { NewSessionOption(value: $0.value, label: $0.name ?? $0.value) }
}
/// Pi launch thinking level; nil hides it for a non-reasoning model.
public static func piEffortOptions(response: AgentModelsResponse?, model: String) -> [NewSessionOption]? {
let selected = selectedModel(response, flavor: .pi, value: model)
guard selected?.reasoning != false else { return nil }
return [NewSessionOption(value: "auto", label: "Default")]
+ piThinkingLevels(map: selected?.thinkingLevelMap).map {
NewSessionOption(value: $0, label: piThinkingLevelLabel($0))
}
}
/// OpenCode launch reasoning effort ("variant") of the selected model, or
/// of the agent's default on `auto`. Static levels while the variants
/// are unknown; nil hides the field when the model has none.
public static func opencodeReasoningEffortOptions(
variants: [String: [String]]?,
response: AgentModelsResponse?,
model: String
) -> [NewSessionOption]? {
let effective = model == "auto" ? response?.currentModelId : model
guard let effective, let list = variants?[effective] else {
return [
NewSessionOption(value: "default", label: "Default"),
NewSessionOption(value: "low", label: "Low"),
NewSessionOption(value: "medium", label: "Medium"),
NewSessionOption(value: "high", label: "High"),
NewSessionOption(value: "max", label: "Max"),
]
}
guard !list.isEmpty else { return nil }
return [NewSessionOption(value: "default", label: "Default")]
+ list.map { NewSessionOption(value: $0, label: $0) }
}
// MARK: - Session settings
/// The current model as a picker value. Pi resolves `provider/modelId`
/// (metadata first, then the bare id); cursor collapses its auto
/// aliases; flavors without a Default row show the running model.
public static func sessionCurrentModel(
flavor: AgentFlavor,
response: AgentModelsResponse,
sessionModel: String?,
piSelectedModel: PiModelReference?
) -> String? {
switch flavor {
case .pi:
if let reference = piSelectedModel {
return "\(reference.provider)/\(reference.modelId)"
}
guard let id = sessionModel ?? response.currentModelId else { return nil }
return listedModels(response, flavor: .pi)
.first { $0.modelId == id }
.map { modelValue($0, flavor: .pi) } ?? id
case .cursor:
let normalized = sessionModel?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
return ["", "auto", "default", "default[]"].contains(normalized) ? "auto" : sessionModel
case .opencode, .agy:
return sessionModel ?? response.currentModelId
default:
return sessionModel
}
}
/// In-session model rows (web `getModelOptionsForFlavor`). `nil` clears
/// to the agent default where the flavor offers that; opencode, agy and
/// pi only switch between listed models. Empty hides the picker.
public static func sessionModelOptions(
flavor: AgentFlavor,
response: AgentModelsResponse,
currentModel: String?
) -> [CatalogOption] {
let rows = listedModels(response, flavor: flavor).map { model in
CatalogOption(value: modelValue(model, flavor: flavor), label: modelLabel(model, flavor: flavor))
}
let options: [CatalogOption]
switch flavor {
case .opencode, .agy, .pi:
options = rows
case .dsh:
options = rows.isEmpty ? [] : [CatalogOption(value: nil, label: "Default")] + rows
case .copilot:
options = [CatalogOption(value: nil, label: "Auto")] + rows
case .cursor:
// Cursor's Auto is an explicit wire id.
options = rows.isEmpty ? [] : [CatalogOption(value: "auto", label: "Auto")] + rows
case .grok, .kimi:
options = [CatalogOption(value: nil, label: "Default")] + rows
default:
options = []
}
return withCurrentModelOption(options, current: currentModel)
}
/// `withCurrentModelOption`: an unlisted current model stays visible,
/// right after the Default/Auto row.
public static func withCurrentModelOption(_ options: [CatalogOption], current: String?) -> [CatalogOption] {
guard !options.isEmpty, let current, !current.isEmpty, current != "auto",
!options.contains(where: { $0.value == current }) else { return options }
let first = options[0].value
var next = options
next.insert(CatalogOption(value: current, label: current), at: first == nil || first == "auto" ? 1 : 0)
return next
}
/// Grok in-session efforts (`grok-reasoning-effort-options`).
public static func grokSessionEffortOptions(_ response: ReasoningEffortOptionsResponse) -> [CatalogOption] {
guard response.success, let options = response.options, !options.isEmpty else { return [] }
return [CatalogOption(value: nil, label: "Default")]
+ options.map { CatalogOption(value: $0.value, label: $0.name ?? $0.value) }
}
/// OpenCode in-session reasoning efforts. Empty (hidden) while the
/// options still describe another model than the session's.
public static func opencodeSessionEffortOptions(
_ response: ReasoningEffortOptionsResponse,
sessionModel: String?,
currentValue: String?
) -> [CatalogOption] {
guard response.success, let options = response.options, !options.isEmpty,
response.currentModelId == (sessionModel ?? response.targetModelId) else { return [] }
var result = [CatalogOption(value: nil, label: "Default")]
if let currentValue, !options.contains(where: { $0.value == currentValue }) {
result.append(CatalogOption(value: currentValue, label: ModelCatalog.capitalizedFirst(currentValue)))
}
return result + options.map {
CatalogOption(value: $0.value, label: $0.name ?? ModelCatalog.capitalizedFirst($0.value))
}
}
/// Pi in-session thinking levels of the current model (no Default row);
/// nil while the model is unresolved or has no thinking levels.
public static func piSessionEffortOptions(
response: AgentModelsResponse,
currentModel: String?
) -> [CatalogOption]? {
guard let model = selectedModel(response, flavor: .pi, value: currentModel),
model.reasoning != false else { return nil }
return piThinkingLevels(map: model.thinkingLevelMap).map {
CatalogOption(value: $0, label: piThinkingLevelLabel($0))
}
}
// MARK: - Pi thinking levels (`shared/src/piThinkingLevel.ts`)
public static let piThinkingLevelOrder = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]
/// `xhigh`/`max` need an explicit map entry; a level mapped to `null`
/// is unsupported; anything else absent from the map is supported.
public static func isPiThinkingLevelSupported(_ level: String, map: [String: String?]?) -> Bool {
let entry = map?[level]
if level == "xhigh" || level == "max" {
guard let entry else { return false }
return entry != nil
}
guard let entry else { return true }
return entry != nil
}
public static func piThinkingLevels(map: [String: String?]?) -> [String] {
piThinkingLevelOrder.filter { isPiThinkingLevelSupported($0, map: map) }
}
public static func piThinkingLevelLabel(_ level: String) -> String {
level == "xhigh" ? "XHigh" : ModelCatalog.capitalizedFirst(level)
}
// MARK: - Cursor (`shared/src/cursorCliSku.ts`)
private static let cursorCliSkuSuffixes = [
"-extra-high-fast", "-extra-high", "-xhigh-fast", "-xhigh", "-high-fast", "-high",
"-medium-fast", "-medium", "-low-fast", "-low", "-none-fast", "-none",
"-thinking-high-fast", "-thinking-high", "-thinking", "-fast",
]
/// `isCursorAcpCatalogModelId`: ACP wire ids (`base[params]`) and bare
/// bases; rejects `auto`/`default` and CLI effort/speed SKU slugs.
public static func isCursorCatalogModelId(_ modelId: String) -> Bool {
let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let lower = trimmed.lowercased()
if lower == "auto" || lower == "default" { return false }
if trimmed == "default[]" || trimmed.contains("[") { return true }
return cursorCliSkuBaseId(trimmed) == trimmed
}
private static func cursorCliSkuBaseId(_ slug: String) -> String {
var base = slug
var changed = true
while changed {
changed = false
for suffix in cursorCliSkuSuffixes where base.hasSuffix(suffix) {
base.removeLast(suffix.count)
changed = true
break
}
}
return base
}
}