diff --git a/PulseLoop/Coach/Config/CoachSettings.swift b/PulseLoop/Coach/Config/CoachSettings.swift index 64f3df5d..178c83b2 100644 --- a/PulseLoop/Coach/Config/CoachSettings.swift +++ b/PulseLoop/Coach/Config/CoachSettings.swift @@ -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" @@ -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" @@ -99,11 +122,13 @@ 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 } @@ -111,11 +136,22 @@ enum GeminiModel: String, CaseIterable, Identifiable { 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 @@ -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 } @@ -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] } } @@ -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`. @@ -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 @@ -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() {} diff --git a/PulseLoop/Coach/Config/CoachSettingsSection.swift b/PulseLoop/Coach/Config/CoachSettingsSection.swift index d93e9c2e..6415371e 100644 --- a/PulseLoop/Coach/Config/CoachSettingsSection.swift +++ b/PulseLoop/Coach/Config/CoachSettingsSection.swift @@ -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 @@ -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") { @@ -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) @@ -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: @@ -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 } } ) @@ -563,7 +567,9 @@ struct CoachSettingsSection: View { /// `reasoningEffort` is optional; the picker uses "" for the "Default" (nil) tag. private var reasoningEffortBinding: Binding { 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 } ) } diff --git a/PulseLoop/Coach/Gemini/GeminiClient.swift b/PulseLoop/Coach/Gemini/GeminiClient.swift index 781bfa95..32e0ce14 100644 --- a/PulseLoop/Coach/Gemini/GeminiClient.swift +++ b/PulseLoop/Coach/Gemini/GeminiClient.swift @@ -35,7 +35,7 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { // message so the schema turn can fill the response `sources` array. private var pendingSourcesNote: String? - init(apiKey: String, model: String = "gemini-2.5-flash", session: URLSession = .shared) { + init(apiKey: String, model: String = GeminiModel.default.rawValue, session: URLSession = .shared) { self.apiKey = apiKey self.model = model self.session = session @@ -58,6 +58,7 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { let tools = req["tools"] as? [[String: Any]] ?? [] let textFormat = (req["text"] as? [String: Any])?["format"] as? [String: Any] let previousResponseId = req["previous_response_id"] as? String + let thinkingLevel = ((req["reasoning"] as? [String: Any])?["effort"] as? String)?.uppercased() if previousResponseId == nil { setupConversation(from: input) @@ -65,7 +66,7 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { appendContinuation(previousId: previousResponseId!, input: input) } - let geminiBody = buildGeminiBody(tools: convertTools(tools), textFormat: textFormat) + let geminiBody = buildGeminiBody(tools: convertTools(tools), textFormat: textFormat, thinkingLevel: thinkingLevel) let geminiData = try JSONSerialization.data(withJSONObject: geminiBody) let urlStr = "\(baseURL)/\(model):generateContent?key=\(apiKey)" @@ -266,7 +267,7 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { // MARK: - Build Gemini request body - private func buildGeminiBody(tools: [[String: Any]], textFormat: [String: Any]?) -> [String: Any] { + private func buildGeminiBody(tools: [[String: Any]], textFormat: [String: Any]?, thinkingLevel: String?) -> [String: Any] { var body: [String: Any] = ["contents": contents] if !systemText.isEmpty { @@ -307,6 +308,10 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { } } } + // Gemini 3+ thinking depth. Only sent when the user picked a level — + // `CoachSettings.resolvedReasoningEffort` never passes one to a 2.5 model, + // which rejects `thinkingLevel`. + if let thinkingLevel { genConfig["thinkingConfig"] = ["thinkingLevel": thinkingLevel] } if !genConfig.isEmpty { body["generationConfig"] = genConfig } return body @@ -314,6 +319,14 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { // MARK: - Parse Gemini response → OpenAIResponse + /// A model part with its `thoughtSignature` (when Gemini sent one) re-attached. + private static func signed(_ part: [String: Any], _ signature: String?) -> [String: Any] { + guard let signature else { return part } + var part = part + part["thoughtSignature"] = signature + return part + } + private func parseGeminiResponse(_ data: Data) throws -> OpenAIResponse { guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ResponsesError.decoding("GeminiClient: response was not a JSON object") @@ -332,9 +345,13 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { var modelParts: [[String: Any]] = [] for part in parts { + // Gemini 3+ attaches a `thoughtSignature` to function-call (and some + // text) parts and rejects the next turn with HTTP 400 unless it's echoed + // back exactly, so stored parts keep it alongside the content. + let signature = part["thoughtSignature"] as? String if let text = part["text"] as? String { outputItems.append(.message(text: text)) - modelParts.append(["text": text]) + modelParts.append(Self.signed(["text": text], signature)) } else if let fc = part["functionCall"] as? [String: Any], let name = fc["name"] as? String { let args = fc["args"] as? [String: Any] ?? [:] @@ -343,7 +360,7 @@ final class GeminiClient: ResponsesClient, @unchecked Sendable { let callId = "gemini_call_\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))" callIdToName[callId] = name outputItems.append(.functionCall(ResponseFunctionCall(name: name, callID: callId, arguments: argsStr))) - modelParts.append(["functionCall": ["name": name, "args": args]]) + modelParts.append(Self.signed(["functionCall": ["name": name, "args": args]], signature)) } } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift index 8190c477..9afb3d59 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift @@ -23,7 +23,7 @@ enum CoachNotificationGenerator { let body = try OpenAIRequestBuilder.data( model: flags.model, input: input, tools: [], textFormat: CoachNotificationSchema.textFormat, - previousResponseId: nil, reasoningEffort: flags.settings.reasoningEffort + previousResponseId: nil, reasoningEffort: flags.settings.resolvedReasoningEffort ) let response = try await client.send(requestBody: body) return CoachNotification.decode(fromJSON: response.outputText) ?? scripted(slot: slot, packet: packet) @@ -50,7 +50,7 @@ enum CoachNotificationGenerator { let body = try OpenAIRequestBuilder.data( model: flags.model, input: input, tools: [], textFormat: CoachNotificationSchema.textFormat, - previousResponseId: nil, reasoningEffort: flags.settings.reasoningEffort + previousResponseId: nil, reasoningEffort: flags.settings.resolvedReasoningEffort ) let response = try await client.send(requestBody: body) // An anomaly alert always sends — ignore a stray skip from the model. diff --git a/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift b/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift index 1374cbf8..76b34a41 100644 --- a/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift +++ b/PulseLoop/Coach/OpenRouter/OpenRouterClient.swift @@ -2,7 +2,7 @@ import Foundation /// Adapts the app's `ResponsesClient` protocol to OpenRouter's OpenAI-compatible /// **Chat Completions** API (`POST /api/v1/chat/completions`). OpenRouter is an -/// aggregator: `model` is a `vendor/model` slug (e.g. `anthropic/claude-sonnet-4.6`) +/// aggregator: `model` is a `vendor/model` slug (e.g. `anthropic/claude-sonnet-5.5`) /// that it routes to the underlying provider. Translates the app's Responses-API /// request bodies into Chat Completions requests and maps responses back, so no /// other component needs to know which provider is active. @@ -57,7 +57,7 @@ final class OpenRouterClient: ResponsesClient, @unchecked Sendable { let tools = req["tools"] as? [[String: Any]] ?? [] let previousResponseId = req["previous_response_id"] as? String // OpenRouter accepts the same unified `reasoning` object the app already - // builds (`{ "effort": "low|medium|high" }`); it's ignored for models that + // builds (`{ "effort": "low|medium|high|…" }`); it's ignored for models that // don't reason, so forward it as-is when present. let reasoning = req["reasoning"] // The Responses-API `text.format` carries the strict coach_response JSON diff --git a/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift b/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift index f251b874..0fec738b 100644 --- a/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift +++ b/PulseLoop/Coach/Orchestration/CoachOrchestrator.swift @@ -225,7 +225,7 @@ struct CoachOrchestrator { ) async throws -> OpenAIResponse { let body = try OpenAIRequestBuilder.data( model: flags.model, input: input, tools: tools, textFormat: textFormat, - previousResponseId: previousResponseId, reasoningEffort: flags.settings.reasoningEffort) + previousResponseId: previousResponseId, reasoningEffort: flags.settings.resolvedReasoningEffort) let response = try await client.send(requestBody: body) tally.add(response.usage) return response diff --git a/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift b/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift index 4cf6e415..60070023 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryGenerator.swift @@ -24,7 +24,7 @@ enum CoachSummaryGenerator { let body = try OpenAIRequestBuilder.data( model: flags.model, input: input, tools: [], textFormat: CoachSummarySchema.textFormat, - previousResponseId: nil, reasoningEffort: flags.settings.reasoningEffort + previousResponseId: nil, reasoningEffort: flags.settings.resolvedReasoningEffort ) let response = try await client.send(requestBody: body) return CoachSummaryContent.decode(fromJSON: response.outputText) ?? fallback diff --git a/PulseLoop/Coach/Usage/CoachModelPricing.swift b/PulseLoop/Coach/Usage/CoachModelPricing.swift index 14be1cee..389a53e4 100644 --- a/PulseLoop/Coach/Usage/CoachModelPricing.swift +++ b/PulseLoop/Coach/Usage/CoachModelPricing.swift @@ -20,21 +20,39 @@ struct CoachModelPrice: Equatable { /// `nil` so the UI can show "cost unavailable" rather than a wrong number. enum CoachPricingCatalog { /// Normalized-key → published list price, standard (non-batch) tier, as of - /// July 2026. Sources: platform OpenAI/Google/DeepSeek/MiniMax pricing pages, + /// October 2026. Sources: platform OpenAI/Google/DeepSeek/MiniMax pricing pages, /// platform.claude.com pricing docs, and openrouter.ai model pages. Estimates - /// only — OpenRouter's API-reported exact cost always takes precedence. + /// only — OpenRouter's API-reported exact cost always takes precedence. Models + /// that dropped out of the pickers keep their entry so a stored or Custom + /// choice still prices. private static let table: [String: CoachModelPrice] = [ - // OpenAI (native) — CoachModel enum + // OpenAI (native) — CoachModel enum. GPT-6 bills the whole request at 2x + // input / 1.5x output above 272k input tokens. + "gpt-6-luna": CoachModelPrice(inputPer1M: 0.10, cachedInputPer1M: 0.01, outputPer1M: 0.50), + "gpt-6.1-sol": CoachModelPrice(inputPer1M: 2.00, cachedInputPer1M: 0.10, outputPer1M: 10.00), + "gpt-6-astra": CoachModelPrice(inputPer1M: 10.00, cachedInputPer1M: 1.00, outputPer1M: 50.00), "gpt-5.4-mini": CoachModelPrice(inputPer1M: 0.75, cachedInputPer1M: 0.075, outputPer1M: 4.50), "gpt-5.4": CoachModelPrice(inputPer1M: 2.50, cachedInputPer1M: 0.25, outputPer1M: 15.00), "gpt-5.5": CoachModelPrice(inputPer1M: 5.00, cachedInputPer1M: 0.50, outputPer1M: 30.00), // Gemini (native) — GeminiModel enum. gemini-2.0-flash was shut down // June 1 2026 and has no published price; it intentionally has no entry - // so the UI reports "cost unavailable". + // so the UI reports "cost unavailable". 4 Argon's rates double Dec 1 2026 + // and 3.8 Flash's on Jan 1 2027. + "gemini-3.8-flash": CoachModelPrice(inputPer1M: 0.75, cachedInputPer1M: 0.075, outputPer1M: 3.75), + "gemini-3.5-flash-lite": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 2.50), + "gemini-4-argon": CoachModelPrice(inputPer1M: 2.00, cachedInputPer1M: 0.10, outputPer1M: 10.00), "gemini-2.5-flash": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 2.50), "gemini-2.5-pro": CoachModelPrice(inputPer1M: 1.25, cachedInputPer1M: 0.125, outputPer1M: 10.00), // ≤200k-token tier; >200k bills 2x // OpenRouter — OpenRouterModel enum (vendor/model slugs). List prices // match the vendors'; cached rates use the vendor cache-read price. + "anthropic/claude-sonnet-5.5": CoachModelPrice(inputPer1M: 2.00, cachedInputPer1M: 0.10, outputPer1M: 10.00), + "anthropic/claude-opus-5.5": CoachModelPrice(inputPer1M: 4.00, cachedInputPer1M: 0.20, outputPer1M: 20.00), + "anthropic/claude-haiku-5.5": CoachModelPrice(inputPer1M: 0.10, cachedInputPer1M: 0.01, outputPer1M: 0.50), // ≤100k-token tier + "openai/gpt-6.1-sol": CoachModelPrice(inputPer1M: 2.00, cachedInputPer1M: 0.10, outputPer1M: 10.00), + "openai/gpt-6-luna": CoachModelPrice(inputPer1M: 0.10, cachedInputPer1M: 0.01, outputPer1M: 0.50), + "google/gemini-4-argon": CoachModelPrice(inputPer1M: 2.00, cachedInputPer1M: 0.10, outputPer1M: 10.00), + "google/gemini-3.8-flash": CoachModelPrice(inputPer1M: 0.75, cachedInputPer1M: 0.075, outputPer1M: 3.75), + "deepseek/deepseek-v4.1-flash": CoachModelPrice(inputPer1M: 0.016, cachedInputPer1M: 0.005, outputPer1M: 0.891), // cheapest OpenRouter endpoint "anthropic/claude-sonnet-4.6": CoachModelPrice(inputPer1M: 3.00, cachedInputPer1M: 0.30, outputPer1M: 15.00), "anthropic/claude-opus-4.7": CoachModelPrice(inputPer1M: 5.00, cachedInputPer1M: 0.50, outputPer1M: 25.00), "openai/gpt-5.5": CoachModelPrice(inputPer1M: 5.00, cachedInputPer1M: 0.50, outputPer1M: 30.00), @@ -42,16 +60,16 @@ enum CoachPricingCatalog { "google/gemini-2.5-flash": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 2.50), "google/gemini-2.5-pro": CoachModelPrice(inputPer1M: 1.25, cachedInputPer1M: 0.125, outputPer1M: 10.00), "deepseek/deepseek-v4-flash": CoachModelPrice(inputPer1M: 0.09, cachedInputPer1M: nil, outputPer1M: 0.18), // OpenRouter routes below DeepSeek's direct $0.14/$0.28 - // MiniMax — MiniMaxModel enum. No published cache-read rate; -highspeed - // variants bill 2x the standard rate. - "minimax-m3": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), // ≤512k-token tier; >512k bills 2x - "minimax-m2.7": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), - "minimax-m2.7-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: nil, outputPer1M: 2.40), - "minimax-m2.5": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), - "minimax-m2.5-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: nil, outputPer1M: 2.40), - "minimax-m2.1": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), - "minimax-m2.1-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: nil, outputPer1M: 2.40), - "minimax-m2": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: nil, outputPer1M: 1.20), + // MiniMax — MiniMaxModel enum. -highspeed variants bill 2x the standard + // input/output rate; M2.5/M2.7 retire Nov 18 2026. + "minimax-m3": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.06, outputPer1M: 1.20), // ≤512k-token tier; >512k bills 2x + "minimax-m2.7": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.06, outputPer1M: 1.20), + "minimax-m2.7-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: 0.06, outputPer1M: 2.40), + "minimax-m2.5": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 1.20), + "minimax-m2.5-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: 0.03, outputPer1M: 2.40), + "minimax-m2.1": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 1.20), + "minimax-m2.1-highspeed": CoachModelPrice(inputPer1M: 0.60, cachedInputPer1M: 0.03, outputPer1M: 2.40), + "minimax-m2": CoachModelPrice(inputPer1M: 0.30, cachedInputPer1M: 0.03, outputPer1M: 1.20), ] /// The `effectiveModel` strings that always cost $0 (local / scripted). diff --git a/PulseLoop/DesignSystem/SettingsFormComponents.swift b/PulseLoop/DesignSystem/SettingsFormComponents.swift index 5ac0a92d..3b699e1e 100644 --- a/PulseLoop/DesignSystem/SettingsFormComponents.swift +++ b/PulseLoop/DesignSystem/SettingsFormComponents.swift @@ -40,7 +40,7 @@ struct SettingsToggleRow: View { /// Settings row with a menu selector that owns a compact, single-line value label. /// SwiftUI's `.menu` Picker renders its selected label with its own layout and ignores /// ancestor font/lineLimit, so long option names (e.g. "On-device (Apple)", -/// "gemini-2.5-flash") wrap into the chevron and the variable height makes the row jump. +/// "gemini-3.5-flash-lite") wrap into the chevron and the variable height makes the row jump. /// This renders the current `value` itself and truncates it, keeping every row one line. /// `menuContent` supplies the options (typically a tagged `Picker`, shown inline with a /// checkmark inside the menu). diff --git a/PulseLoopTests/CoachTests.swift b/PulseLoopTests/CoachTests.swift index c0be364a..92fd3720 100644 --- a/PulseLoopTests/CoachTests.swift +++ b/PulseLoopTests/CoachTests.swift @@ -514,6 +514,102 @@ final class GeminiClientTests: XCTestCase { XCTAssertTrue(sent.contains("example.com") && sent.contains("Source A") && sent.contains("Source B"), "grounding sources must be carried into the follow-up turn") } + + /// Gemini 3+ rejects a tool-result turn (HTTP 400) unless the function-call + /// part's `thoughtSignature` is echoed back exactly. + func testEchoesThoughtSignatureOnContinuation() async throws { + StubURLProtocol.statusCode = 200 + StubURLProtocol.responseBody = Data(""" + {"candidates":[{"content":{"parts":[ + {"functionCall":{"name":"get_daily_summary","args":{}},"thoughtSignature":"sig-abc"} + ]}}]} + """.utf8) + + let client = GeminiClient(apiKey: "AIza-test", session: session()) + let first = try await client.send(requestBody: OpenAIRequestBuilder.data( + model: "gemini-3.8-flash", input: [], tools: [], textFormat: nil, + previousResponseId: nil, reasoningEffort: nil)) + let callID = try XCTUnwrap(first.functionCalls.first?.callID) + + StubURLProtocol.responseBody = Data(#"{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}"#.utf8) + _ = try await client.send(requestBody: OpenAIRequestBuilder.data( + model: "gemini-3.8-flash", input: [OpenAIRequestBuilder.functionCallOutput(callID: callID, output: "{}")], + tools: [], textFormat: nil, previousResponseId: first.id, reasoningEffort: nil)) + + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: XCTUnwrap(StubURLProtocol.lastRequestBody)) as? [String: Any]) + let contents = try XCTUnwrap(json["contents"] as? [[String: Any]]) + let modelTurn = try XCTUnwrap(contents.first { $0["role"] as? String == "model" }) + let part = try XCTUnwrap((modelTurn["parts"] as? [[String: Any]])?.first) + XCTAssertEqual(part["thoughtSignature"] as? String, "sig-abc") + XCTAssertNotNil(part["functionCall"]) + } + + /// The reasoning effort maps to Gemini's `thinkingConfig.thinkingLevel`, and is + /// omitted entirely when unset. + func testReasoningEffortSendsThinkingLevel() async throws { + StubURLProtocol.statusCode = 200 + StubURLProtocol.responseBody = Data(#"{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}"#.utf8) + + let client = GeminiClient(apiKey: "AIza-test", session: session()) + _ = try await client.send(requestBody: OpenAIRequestBuilder.data( + model: "gemini-4-argon", input: [], tools: [], textFormat: nil, + previousResponseId: nil, reasoningEffort: "high")) + var json = try XCTUnwrap(try JSONSerialization.jsonObject(with: XCTUnwrap(StubURLProtocol.lastRequestBody)) as? [String: Any]) + let thinking = (json["generationConfig"] as? [String: Any])?["thinkingConfig"] as? [String: Any] + XCTAssertEqual(thinking?["thinkingLevel"] as? String, "HIGH") + + _ = try await client.send(requestBody: OpenAIRequestBuilder.data( + model: "gemini-4-argon", input: [], tools: [], textFormat: nil, + previousResponseId: nil, reasoningEffort: nil)) + json = try XCTUnwrap(try JSONSerialization.jsonObject(with: XCTUnwrap(StubURLProtocol.lastRequestBody)) as? [String: Any]) + XCTAssertNil(json["generationConfig"]) + } +} + +// MARK: - Reasoning effort per model + +final class ReasoningEffortTests: XCTestCase { + private func settings(_ provider: CoachProviderMode, _ model: String, _ effort: String?) -> CoachSettings { + var s = CoachSettings() + s.providerMode = provider + s.model = model + s.reasoningEffort = effort + return s + } + + /// GPT-6.1 Sol rejects `none` with HTTP 400, so a level left over from Luna is + /// dropped back to the model default rather than sent. + func testUnsupportedEffortIsDropped() { + XCTAssertEqual(settings(.userOpenAIKey, CoachModel.luna.rawValue, "none").resolvedReasoningEffort, "none") + XCTAssertNil(settings(.userOpenAIKey, CoachModel.sol.rawValue, "none").resolvedReasoningEffort) + XCTAssertEqual(settings(.userOpenAIKey, CoachModel.sol.rawValue, "max").resolvedReasoningEffort, "max") + XCTAssertNil(settings(.userOpenRouterKey, OpenRouterModel.deepseek.rawValue, "medium").resolvedReasoningEffort) + } + + /// A Custom OpenRouter slug isn't known, so the hint passes through and the + /// picker offers only the common levels; a non-preset Gemini model (a 2.5 + /// model rejects `thinkingLevel`) gets no hint at all. + func testUnknownModels() { + let custom = settings(.userOpenRouterKey, "acme/some-model", "xhigh") + XCTAssertEqual(custom.resolvedReasoningEffort, "xhigh") + XCTAssertEqual(custom.reasoningEffortOptions, ReasoningEffort.common) + + let legacyGemini = settings(.userGeminiKey, "gemini-2.5-flash", "high") + XCTAssertNil(legacyGemini.resolvedReasoningEffort) + XCTAssertTrue(legacyGemini.reasoningEffortOptions.isEmpty) + + XCTAssertTrue(settings(.userMiniMaxKey, MiniMaxModel.m3.rawValue, nil).reasoningEffortOptions.isEmpty) + } + + /// Every curated preset has a price, so the usage view never shows + /// "cost unavailable" for a model the picker offers. + func testEveryPresetIsPriced() { + let presets = CoachModel.allCases.map(\.rawValue) + GeminiModel.allCases.map(\.rawValue) + + OpenRouterModel.allCases.map(\.rawValue) + MiniMaxModel.allCases.map(\.rawValue) + for model in presets { + XCTAssertNotNil(CoachPricingCatalog.price(for: model), "\(model) has no price") + } + } } // MARK: - OpenRouterClient