Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 122 additions & 39 deletions PulseLoop/Coach/Config/CoachSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,43 @@ enum LocalStructuredOutput: String, Codable, CaseIterable, Identifiable, Sendabl
}
}

/// A `reasoning.effort` level. Which levels a model accepts varies — GPT-6 Sol/Astra
/// reject `none`, DeepSeek has no `medium`, Gemini stops at `high` — and an
/// unsupported level is an HTTP 400 on OpenAI, so each preset lists its own. On
/// Gemini the level is sent as `thinkingConfig.thinkingLevel`.
enum ReasoningEffort: String, CaseIterable, Identifiable {
case none, minimal, low, medium, high, xhigh, max

var id: String { rawValue }

var label: String {
switch self {
case .none: return "None"
case .minimal: return "Minimal"
case .low: return "Low"
case .medium: return "Medium"
case .high: return "High"
case .xhigh: return "Extra high"
case .max: return "Max"
}
}

/// Levels every reasoning-capable model accepts — offered for a Custom slug.
static let common: [ReasoningEffort] = [.low, .medium, .high]
/// GPT-6 Sol/Astra and Claude 5.5.
static let lowToMax: [ReasoningEffort] = [.low, .medium, .high, .xhigh, .max]
}

/// Preset MiniMax model choices surfaced in Settings. MiniMax's catalog is small
/// and fixed (unlike OpenRouter), so these are exact API model names rather than a
/// free-text field. The stored `CoachSettings.model` stays a string, so a new
/// model name still works if typed/served; these are just the curated picks.
///
/// The M2.5 and M2.7 families are omitted: MiniMax closes them to new users on
/// Oct 19 2026 and retires them on Nov 18 2026. `MiniMax-M3.1-Flash-Preview` is
/// plan-only (not pay-as-you-go), so it isn't offered to API-key users either.
enum MiniMaxModel: String, CaseIterable, Identifiable {
case m3 = "MiniMax-M3"
case m27 = "MiniMax-M2.7"
case m27highspeed = "MiniMax-M2.7-highspeed"
case m25 = "MiniMax-M2.5"
case m25highspeed = "MiniMax-M2.5-highspeed"
case m21 = "MiniMax-M2.1"
case m21highspeed = "MiniMax-M2.1-highspeed"
case m2 = "MiniMax-M2"
Expand All @@ -84,10 +111,6 @@ enum MiniMaxModel: String, CaseIterable, Identifiable {
var blurb: String {
switch self {
case .m3: return "Latest — agentic reasoning, tool use, 1M context (default)"
case .m27: return "Recursive self-improvement (~60 tps)"
case .m27highspeed: return "M2.7 — same performance, faster (~100 tps)"
case .m25: return "Peak performance & value (~60 tps)"
case .m25highspeed: return "M2.5 — same performance, faster (~100 tps)"
case .m21: return "Strong multi-language programming (~60 tps)"
case .m21highspeed: return "M2.1 — faster (~100 tps)"
case .m2: return "Agentic capabilities, advanced reasoning"
Expand All @@ -99,23 +122,36 @@ enum MiniMaxModel: String, CaseIterable, Identifiable {
static let `default` = MiniMaxModel.m3
}

/// Preset Gemini model choices surfaced in Settings.
/// Preset Gemini model choices surfaced in Settings. `gemini-2.0-flash` was shut
/// down June 1 2026, and since Sep 18 2026 the 2.5 models only serve keys that
/// already used them, so the picks are the current Gemini 3/4 models.
enum GeminiModel: String, CaseIterable, Identifiable {
case flash25 = "gemini-2.5-flash"
case flash20 = "gemini-2.0-flash"
case pro25 = "gemini-2.5-pro"
case flash38 = "gemini-3.8-flash"
case flashLite = "gemini-3.5-flash-lite"
case argon = "gemini-4-argon"

var id: String { rawValue }

var label: String { rawValue }

var blurb: String {
switch self {
case .flash25: return "Fast & capable (default)"
case .flash20: return "Previous generation"
case .pro25: return "Best reasoning"
case .flash38: return "Fast & capable (default)"
case .flashLite: return "Lowest cost & latency"
case .argon: return "Best reasoning"
}
}

/// Accepted `thinkingConfig.thinkingLevel` values — 3.8 Flash and 4 Argon
/// reject `minimal`.
var reasoningEfforts: [ReasoningEffort] {
switch self {
case .flashLite: return [.minimal, .low, .medium, .high]
case .flash38, .argon: return [.low, .medium, .high]
}
}

static let `default` = GeminiModel.flash38
}

/// Preset OpenRouter model slugs surfaced in Settings. OpenRouter routes a
Expand All @@ -124,13 +160,14 @@ enum GeminiModel: String, CaseIterable, Identifiable {
/// Settings also exposes a "Custom…" text field where the user can type any
/// current slug from openrouter.ai/models. These are just the curated picks.
enum OpenRouterModel: String, CaseIterable, Identifiable {
case claudeSonnet = "anthropic/claude-sonnet-4.6"
case claudeOpus = "anthropic/claude-opus-4.7"
case gpt55 = "openai/gpt-5.5"
case gpt54mini = "openai/gpt-5.4-mini"
case geminiFlash = "google/gemini-2.5-flash"
case geminiPro = "google/gemini-2.5-pro"
case deepseekV4 = "deepseek/deepseek-v4-flash"
case claudeSonnet = "anthropic/claude-sonnet-5.5"
case claudeOpus = "anthropic/claude-opus-5.5"
case claudeHaiku = "anthropic/claude-haiku-5.5"
case gptSol = "openai/gpt-6.1-sol"
case gptLuna = "openai/gpt-6-luna"
case geminiPro = "google/gemini-4-argon"
case geminiFlash = "google/gemini-3.8-flash"
case deepseek = "deepseek/deepseek-v4.1-flash"

var id: String { rawValue }

Expand All @@ -140,11 +177,22 @@ enum OpenRouterModel: String, CaseIterable, Identifiable {
switch self {
case .claudeSonnet: return "Balanced, great for coaching (default)"
case .claudeOpus: return "Most capable Claude"
case .gpt55: return "OpenAI flagship"
case .gpt54mini: return "Lower cost & latency"
case .geminiFlash: return "Fast & capable"
case .claudeHaiku: return "Fastest, lowest-cost Claude"
case .gptSol: return "OpenAI balanced flagship"
case .gptLuna: return "Lower cost & latency"
case .geminiPro: return "Deep reasoning"
case .deepseekV4: return "Strong open reasoning, low cost"
case .geminiFlash: return "Fast & capable"
case .deepseek: return "Strong open reasoning, low cost"
}
}

/// Effort levels the model lists in OpenRouter's `reasoning.supported_efforts`.
var reasoningEfforts: [ReasoningEffort] {
switch self {
case .claudeSonnet, .claudeOpus, .claudeHaiku, .gptSol: return ReasoningEffort.lowToMax
case .gptLuna: return [.none] + ReasoningEffort.lowToMax
case .geminiPro, .geminiFlash: return [.low, .medium, .high]
case .deepseek: return [.low, .high, .max]
}
}

Expand All @@ -157,27 +205,31 @@ enum OpenRouterModel: String, CaseIterable, Identifiable {
/// string (so a new model can be typed/served without a code change); these are
/// just the curated picks surfaced in Settings.
enum CoachModel: String, CaseIterable, Identifiable {
case gpt54mini = "gpt-5.4-mini"
case gpt54 = "gpt-5.4"
case gpt55 = "gpt-5.5"
case luna = "gpt-6-luna"
case sol = "gpt-6.1-sol"
case astra = "gpt-6-astra"

var id: String { rawValue }

var label: String {
var label: String { rawValue }

var blurb: String {
switch self {
case .gpt54mini: return "gpt-5.4-mini"
case .gpt54: return "gpt-5.4"
case .gpt55: return "gpt-5.5"
case .luna: return "Lower cost & latency"
case .sol: return "Balanced (default)"
case .astra: return "Best reasoning"
}
}

var blurb: String {
/// Accepted `reasoning.effort` levels — Sol and Astra reject `none` (HTTP 400).
var reasoningEfforts: [ReasoningEffort] {
switch self {
case .gpt54mini: return "Lower cost & latency"
case .gpt54: return "Balanced (default)"
case .gpt55: return "Best reasoning"
case .luna: return [.none] + ReasoningEffort.lowToMax
case .sol, .astra: return ReasoningEffort.lowToMax
}
}

static let `default` = CoachModel.sol
}

/// User-tunable coach configuration, persisted as JSON in `UserDefaults`.
Expand All @@ -192,8 +244,9 @@ struct CoachSettings: Codable, Equatable {
var providerMode: CoachProviderMode = .appleOnDevice
/// Fallback model string used when the user switches to a keyed provider; user-configurable
/// (never hard-coded in the client). Unused while on the Apple on-device model.
var model: String = CoachModel.gpt54.rawValue
/// Optional reasoning effort hint ("low"/"medium"/"high") when the model supports it.
var model: String = CoachModel.default.rawValue
/// Optional reasoning effort hint (a `ReasoningEffort` raw value). Sent via
/// `resolvedReasoningEffort`, which drops a level the current model doesn't accept.
var reasoningEffort: String? = nil
var enableWebSearch: Bool = false
/// OpenRouter-only: when true, route only through providers that don't log or
Expand Down Expand Up @@ -268,6 +321,36 @@ struct CoachSettings: Codable, Equatable {
return trimmed.isEmpty ? MiniMaxModel.default.rawValue : trimmed
}

/// The effort levels the selected preset accepts; `nil` when unknown (a Custom
/// slug, a non-preset stored model) or when the provider takes no effort hint.
var knownReasoningEfforts: [ReasoningEffort]? {
switch providerMode {
case .userOpenAIKey: return CoachModel(rawValue: model)?.reasoningEfforts
case .userOpenRouterKey: return OpenRouterModel(rawValue: model)?.reasoningEfforts
// A non-preset Gemini model may be a 2.5 model, which rejects `thinkingLevel`.
case .userGeminiKey: return GeminiModel(rawValue: model)?.reasoningEfforts ?? []
default: return nil
}
}

/// The levels the Reasoning picker offers: the preset's own list, or the common
/// subset for a Custom/legacy OpenAI or OpenRouter model. Empty hides the picker.
var reasoningEffortOptions: [ReasoningEffort] {
if let known = knownReasoningEfforts { return known }
switch providerMode {
case .userOpenAIKey, .userOpenRouterKey: return ReasoningEffort.common
default: return []
}
}

/// The effort to send: the stored hint, dropped back to the model's default when
/// the selected preset doesn't accept it (e.g. `none` left over after switching
/// from Luna to Sol). Unknown models pass the hint through unchanged.
var resolvedReasoningEffort: String? {
guard let effort = reasoningEffort, let supported = knownReasoningEfforts else { return reasoningEffort }
return supported.contains { $0.rawValue == effort } ? effort : nil
}

static let `default` = CoachSettings()

init() {}
Expand Down
42 changes: 24 additions & 18 deletions PulseLoop/Coach/Config/CoachSettingsSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,22 @@ struct CoachSettingsSection: View {
if store.settings.providerMode == .userOpenRouterKey, isCustomOpenRouterModel {
customModelField
}

// Reasoning effort / Gemini thinking level. The levels differ per model
// (GPT-6 Sol rejects None, DeepSeek has no Medium), so the picker lists
// only what the selected model accepts.
if !store.settings.reasoningEffortOptions.isEmpty {
FormValueRow(title: "Reasoning") {
Picker("Reasoning", selection: reasoningEffortBinding) {
Text("Default").tag("")
ForEach(store.settings.reasoningEffortOptions) { effort in
Text(effort.label).tag(effort.rawValue)
}
}
.pickerStyle(.menu)
.tint(PulseColors.accent)
}
}
}

// The key field tracks the *effective* provider — the active cloud
Expand Down Expand Up @@ -254,22 +270,10 @@ struct CoachSettingsSection: View {
)
}

// OpenRouter-only routing controls. OpenRouter exposes a unified
// reasoning-effort hint plus provider-level privacy and sort options
// the native OpenAI/Gemini clients don't, so they only appear here.
// OpenRouter-only routing controls: provider-level privacy and sort
// options the native OpenAI/Gemini clients don't have.
if store.settings.providerMode == .userOpenRouterKey {
SettingsGroup(header: "Routing") {
FormValueRow(title: "Reasoning") {
Picker("Reasoning", selection: reasoningEffortBinding) {
Text("Default").tag("")
Text("Low").tag("low")
Text("Medium").tag("medium")
Text("High").tag("high")
}
.pickerStyle(.menu)
.tint(PulseColors.accent)
}

FormToggleRow(title: "Privacy routing", isOn: privacyRoutingBinding)

FormValueRow(title: "Provider sort") {
Expand Down Expand Up @@ -465,7 +469,7 @@ struct CoachSettingsSection: View {
.padding(.horizontal, 14).padding(.vertical, 10)
.pulseGlass(Capsule())

Text("Any model slug from openrouter.ai/models — e.g. anthropic/claude-sonnet-4.6.")
Text("Any model slug from openrouter.ai/models — e.g. anthropic/claude-sonnet-5.5.")
.font(.caption).foregroundStyle(PulseColors.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down Expand Up @@ -510,7 +514,7 @@ struct CoachSettingsSection: View {
// Reset model to the default for the selected provider.
switch newProvider {
case .userGeminiKey:
store.settings.model = GeminiModel.flash25.rawValue
store.settings.model = GeminiModel.default.rawValue
case .userOpenRouterKey:
store.settings.model = OpenRouterModel.default.rawValue
case .userMiniMaxKey:
Expand All @@ -519,7 +523,7 @@ struct CoachSettingsSection: View {
// (now hidden) toggle can't linger enabled.
store.settings.enableWebSearch = false
default:
store.settings.model = CoachModel.gpt54.rawValue
store.settings.model = CoachModel.default.rawValue
}
}
)
Expand Down Expand Up @@ -563,7 +567,9 @@ struct CoachSettingsSection: View {
/// `reasoningEffort` is optional; the picker uses "" for the "Default" (nil) tag.
private var reasoningEffortBinding: Binding<String> {
Binding(
get: { store.settings.reasoningEffort ?? "" },
// Shows "Default" when the stored level isn't one this model accepts —
// matching `resolvedReasoningEffort`, which won't send it.
get: { store.settings.resolvedReasoningEffort ?? "" },
set: { store.settings.reasoningEffort = $0.isEmpty ? nil : $0 }
)
}
Expand Down
Loading
Loading