From 52c25e9526417e0fd94cec2346c4e9784f46da68 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 14 Oct 2026 17:31:56 -0700 Subject: [PATCH 1/5] feat(coach): live model lists per provider + iOS #140 model catalog and per-model reasoning levels Settings now reads each cloud provider's own model list once the provider is selected with a saved key (OpenAI /v1/models, Gemini v1beta/models, OpenRouter /api/v1/models, MiniMax /v1/models), shows it under the curated picks, and flags a stored model the provider no longer lists or is retiring, instead of every coach turn failing on a stale preset. The stored model stays a free string; the presets remain the fallback when the list can't be fetched. Ports iOS #140 (8833ee0): - presets: GPT-6 Luna / Sol (default) / Astra; OpenRouter Claude 5.5, GPT-6, Gemini 4 Argon / 3.8 Flash, DeepSeek V4.1; MiniMax drops M2.5/M2.7 (retiring Nov 18 2026). Gemini keeps the rolling -latest aliases (issue #22), which iOS doesn't use. - ReasoningEffort none..max with each model's accepted levels; the stored effort is dropped when the selected model rejects it (an HTTP 400 on every turn otherwise). OpenRouter models outside the presets use the live `reasoning.supported_efforts`. - Gemini sends the level as thinkingConfig.thinkingLevel (3+ only; 2.x rejects it). - Prices for the new models, checked against OpenRouter's live listing. --- .../coach/config/CoachClientResolver.kt | 15 +- .../coach/config/CoachModelCatalog.kt | 219 ++++++++++++++++++ .../coach/config/CoachModelPresets.kt | 73 ++++-- .../coach/config/ReasoningEfforts.kt | 61 +++++ .../pulseloop/coach/gemini/GeminiClient.kt | 12 +- .../coach/summaries/CoachSummaryService.kt | 4 +- .../com/pulseloop/coach/tools/CoachTool.kt | 2 +- .../coach/usage/CoachModelPricing.kt | 45 ++-- .../java/com/pulseloop/ui/PulseLoopApp.kt | 9 +- .../pulseloop/ui/screens/CoachModelPicker.kt | 154 ++++++++++++ .../pulseloop/ui/screens/MealAnalysisSheet.kt | 4 +- .../ui/screens/SettingsSubScreens.kt | 93 +++++--- .../com/pulseloop/coach/CoachActionTest.kt | 2 +- .../coach/CoachClientResolverTest.kt | 14 +- .../pulseloop/coach/CoachModelCatalogTest.kt | 138 +++++++++++ .../pulseloop/coach/CoachModelPresetsTest.kt | 27 ++- .../com/pulseloop/coach/GeminiClientTest.kt | 17 ++ .../pulseloop/coach/OpenRouterClientTest.kt | 5 +- 18 files changed, 808 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt create mode 100644 app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt create mode 100644 app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt create mode 100644 app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt diff --git a/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt b/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt index 5d036d8c..90ee7c1e 100644 --- a/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt +++ b/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt @@ -113,10 +113,19 @@ object CoachClientResolver { * The per-request settings for the active provider, shared by the chat * flags provider and the summary service. `reasoningEffort` keeps the * store's null=omit contract: null (or no settings) sends no `reasoning` - * field, which models like gpt-4o require. + * field, which models like gpt-4o require. It is also dropped when [model] + * doesn't accept the stored level (iOS #140 `resolvedReasoningEffort`): an + * unsupported level is an HTTP 400 on every turn. */ - fun coachSettings(settings: CoachProviderSettings?): CoachSettings = - CoachSettings(reasoningEffort = settings?.reasoningEffort) + fun coachSettings(settings: CoachProviderSettings?, model: String): CoachSettings = + CoachSettings(reasoningEffort = settings?.let { resolvedReasoningEffort(it, model) }) + + /** The stored effort, or null when [model] doesn't accept it — see [ReasoningEfforts.resolve]. */ + fun resolvedReasoningEffort(settings: CoachProviderSettings, model: String): String? = + ReasoningEfforts.resolve( + settings.providerMode, model, settings.reasoningEffort, + CoachModelCatalog.cachedEntry(settings.providerMode, model), + ) /** * The model slug the orchestrator/services should put in the request body diff --git a/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt b/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt new file mode 100644 index 00000000..6dabb290 --- /dev/null +++ b/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt @@ -0,0 +1,219 @@ +package com.pulseloop.coach.config + +import com.pulseloop.coach.openai.ResponsesError +import com.pulseloop.coach.openai.ResponsesHttp +import kotlinx.serialization.json.* + +/** + * The models a cloud provider serves right now, read from its own model-list endpoint once the + * provider is selected and its key saved — so Settings stops depending on preset slugs that go + * stale (the presets in [CoachModelPresets] stay as the curated picks shown first, and as the + * fallback when the list can't be fetched). + * + * Advisory, like [com.pulseloop.coach.local.LocalModelCatalog]: the stored model stays a free + * string, so a model the list omits still works if the provider serves it. + * + * | Provider | Endpoint | What it adds | + * |------------|--------------------------------------------|--------------------------------------| + * | OpenAI | `GET api.openai.com/v1/models` | ids only — filtered to chat models | + * | Gemini | `GET …/v1beta/models` (paged) | display name, `generateContent` only | + * | OpenRouter | `GET openrouter.ai/api/v1/models` | name, reasoning levels, expiry date | + * | MiniMax | `GET api.minimax.io/v1/models` | ids only | + * + * OpenRouter is the only one that says which reasoning levels a model accepts + * (`reasoning.supported_efforts`) or when it is being retired (`expiration_date`); for the others + * those come from the presets, or [ReasoningEfforts] falls back to the common levels. + */ +object CoachModelCatalog { + + /** + * One model the provider lists. [reasoningEfforts] null = the listing doesn't say; + * [expiresOn] is an ISO date (`2026-11-18`) when the provider has announced a retirement. + */ + data class Entry( + val id: String, + val name: String? = null, + val reasoningEfforts: List? = null, + val expiresOn: String? = null, + ) + + sealed class Result { + data class Success(val entries: List, val fetchedAtMillis: Long) : Result() { + fun find(id: String): Entry? = entries.firstOrNull { it.id == id.trim() } + } + /** [message] is already user-facing. */ + data class Failure(val message: String) : Result() + } + + /** + * True when a fetched listing doesn't include [model] — it has most likely been retired, or + * (OpenRouter) no longer supports tools. Two exceptions that the listing never shows by design: + * Gemini's rolling `-latest` aliases, and OpenAI dated snapshots, which [parseOpenAI] drops. + */ + fun isUnlisted(provider: CoachProviderMode, listing: Result.Success, model: String): Boolean { + val slug = model.trim() + if (slug.isEmpty() || listing.find(slug) != null) return false + if (provider == CoachProviderMode.USER_GEMINI_KEY && slug.endsWith("-latest")) return false + if (provider == CoachProviderMode.USER_OPENAI_KEY && DATED_SNAPSHOT.containsMatchIn(slug)) return false + return true + } + + /** The providers with a model-list endpoint. Local has its own ([com.pulseloop.coach.local.LocalModelCatalog]). */ + fun supports(provider: CoachProviderMode): Boolean = provider in ENDPOINTS + + /** The last successful listing for [provider], when it was fetched with the same key and is fresh. */ + fun cached(provider: CoachProviderMode, apiKey: String, nowMillis: Long = System.currentTimeMillis()): Result.Success? = + cache[provider]?.takeIf { it.keyHash == apiKey.hashCode() && nowMillis - it.result.fetchedAtMillis < TTL_MILLIS }?.result + + /** The listed entry for [model] under the active provider, from the cache only (no network). */ + fun cachedEntry(provider: CoachProviderMode, model: String): Entry? = + cache[provider]?.result?.find(model) + + /** + * Fetches the listing, or returns the cached one when [force] is false and it is still fresh. + * Never throws: a failure is returned as data for Settings to show inline. + */ + suspend fun fetch(provider: CoachProviderMode, apiKey: String, force: Boolean = false): Result { + if (!force) cached(provider, apiKey)?.let { return it } + val url = ENDPOINTS[provider] ?: return Result.Failure("This provider has no model list.") + if (apiKey.isBlank()) return Result.Failure("Save an API key to load the model list.") + return try { + val entries = when (provider) { + CoachProviderMode.USER_GEMINI_KEY -> fetchGemini(url, apiKey) + CoachProviderMode.USER_OPENAI_KEY -> parseOpenAI(get(url, mapOf("Authorization" to "Bearer $apiKey"))) + CoachProviderMode.USER_OPENROUTER_KEY -> parseOpenRouter(get(url, mapOf("Authorization" to "Bearer $apiKey"))) + CoachProviderMode.USER_MINIMAX_KEY -> parseIds(get(url, mapOf("Authorization" to "Bearer $apiKey"))) + else -> emptyList() + } + if (entries.isEmpty()) return Result.Failure("The provider listed no usable models.") + Result.Success(entries, System.currentTimeMillis()).also { + cache[provider] = Cached(apiKey.hashCode(), it) + } + } catch (e: ResponsesError.Http) { + Result.Failure( + if (e.status == 401 || e.status == 403) "The provider rejected the key (HTTP ${e.status})." + else "The provider answered HTTP ${e.status} for its model list." + ) + } catch (e: ResponsesError.Transport) { + val why = e.underlying.message?.takeIf { it.isNotBlank() } ?: e.underlying::class.java.simpleName + Result.Failure("Couldn't load the model list — $why.") + } catch (e: Exception) { + Result.Failure(e.message ?: "Couldn't read the model list.") + } + } + + // ── Parsing (internal for unit tests) ──────────────────────────────── + + /** + * OpenAI lists every model the key can use — embeddings, speech, image, moderation and + * realtime ones included — with nothing but an id. Keep the text models the coach can drive + * through the Responses API, drop dated snapshots (each is also listed under its alias) and the + * slugs the picker retired (issue #77). + */ + internal fun parseOpenAI(body: String): List = + dataArray(body).mapNotNull { (it as? JsonObject)?.string("id") } + .filter { id -> + (id.startsWith("gpt-") || O_SERIES.matches(id)) && + NON_CHAT.none { it in id } && + !DATED_SNAPSHOT.containsMatchIn(id) && + id !in OpenAIModel.RETIRED_SLUGS + } + .distinct().sorted().map { Entry(it) } + + /** One page of `models.list`; ids come as `models/gemini-…` and are stored without the prefix. */ + internal fun parseGeminiPage(body: String): Pair, String?> { + val root = JSON.parseToJsonElement(body) as? JsonObject + ?: throw ResponsesError.Decoding("The Gemini model list was not a JSON object.") + val entries = (root["models"] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject }.mapNotNull { m -> + val id = m.string("name")?.removePrefix("models/") ?: return@mapNotNull null + val methods = (m["supportedGenerationMethods"] as? JsonArray).orEmpty() + .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + if (!id.startsWith("gemini") || "generateContent" !in methods || GEMINI_NON_CHAT.any { it in id }) null + else Entry(id, name = m.string("displayName")) + } + return entries to root.string("nextPageToken")?.takeIf { it.isNotBlank() } + } + + /** + * OpenRouter's listing is the richest: a name, `supported_parameters`, the reasoning levels the + * model accepts and an expiry date. The coach drives a tool loop, so a model without `tools` + * is left out; so are the `:batch` variants, which aren't for interactive use. + */ + internal fun parseOpenRouter(body: String): List = + dataArray(body).mapNotNull { it as? JsonObject }.mapNotNull { m -> + val id = m.string("id") ?: return@mapNotNull null + val params = (m["supported_parameters"] as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + if ("tools" !in params || id.endsWith(":batch")) return@mapNotNull null + val efforts = ((m["reasoning"] as? JsonObject)?.get("supported_efforts") as? JsonArray) + ?.mapNotNull { ReasoningEffort.fromRaw((it as? JsonPrimitive)?.contentOrNull) } + ?.sortedBy { it.ordinal } + Entry( + id = id, + name = m.string("name"), + // Listed but empty: the model takes no effort hint. + reasoningEfforts = efforts ?: if ("reasoning" in params) null else emptyList(), + expiresOn = m.string("expiration_date"), + ) + }.distinctBy { it.id }.sortedBy { it.id } + + /** The bare OpenAI-style `{"data":[{"id":…}]}` envelope (MiniMax). */ + internal fun parseIds(body: String): List = + dataArray(body).mapNotNull { (it as? JsonObject)?.string("id") }.distinct().sorted().map { Entry(it) } + + // ── Internals ──────────────────────────────────────────────────────── + + private suspend fun fetchGemini(url: String, apiKey: String): List { + val all = mutableListOf() + var token: String? = null + // The listing is a few dozen models; the page cap is only a guard against a looping token. + repeat(MAX_GEMINI_PAGES) { + val page = get("$url?pageSize=1000" + (token?.let { "&pageToken=$it" } ?: ""), mapOf("x-goog-api-key" to apiKey)) + val (entries, next) = parseGeminiPage(page) + all += entries + token = next ?: return all.distinctBy { it.id }.sortedBy { it.id } + } + return all.distinctBy { it.id }.sortedBy { it.id } + } + + private suspend fun get(url: String, headers: Map): String = + ResponsesHttp.get(url, headers, REFRESH_TIMEOUT_SECONDS) + + private fun dataArray(body: String): JsonArray = + when (val root = JSON.parseToJsonElement(body)) { + is JsonObject -> root["data"] as? JsonArray + is JsonArray -> root + else -> null + } ?: throw ResponsesError.Decoding("No `data` array in the model list.") + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + + private fun JsonArray?.orEmpty(): List = this ?: emptyList() + + private data class Cached(val keyHash: Int, val result: Result.Success) + + private val cache = java.util.concurrent.ConcurrentHashMap() + + private val ENDPOINTS = mapOf( + CoachProviderMode.USER_OPENAI_KEY to "https://api.openai.com/v1/models", + CoachProviderMode.USER_GEMINI_KEY to "https://generativelanguage.googleapis.com/v1beta/models", + CoachProviderMode.USER_OPENROUTER_KEY to "https://openrouter.ai/api/v1/models", + // Global host, as MiniMaxClient uses. + CoachProviderMode.USER_MINIMAX_KEY to "https://api.minimax.io/v1/models", + ) + + private val JSON = Json { ignoreUnknownKeys = true } + private val O_SERIES = Regex("^o\\d+(-.*)?$") + private val DATED_SNAPSHOT = Regex("-\\d{4}-\\d{2}-\\d{2}$") + private val NON_CHAT = listOf( + "embedding", "tts", "whisper", "transcribe", "audio", "realtime", "image", "dall-e", + "moderation", "search", "instruct", "computer-use", "sora", + ) + private val GEMINI_NON_CHAT = listOf("embedding", "tts", "image", "native-audio", "live", "aqa") + + /** A refresh sits behind a provider switch, so it should fail fast rather than hang the picker. */ + private const val REFRESH_TIMEOUT_SECONDS = 15 + private const val MAX_GEMINI_PAGES = 5 + /** Long enough that reopening Settings doesn't refetch; short enough to notice a new model the same day. */ + private const val TTL_MILLIS = 6 * 60 * 60 * 1000L +} diff --git a/app/src/main/java/com/pulseloop/coach/config/CoachModelPresets.kt b/app/src/main/java/com/pulseloop/coach/config/CoachModelPresets.kt index 0d3ab5d4..9140761e 100644 --- a/app/src/main/java/com/pulseloop/coach/config/CoachModelPresets.kt +++ b/app/src/main/java/com/pulseloop/coach/config/CoachModelPresets.kt @@ -1,16 +1,44 @@ package com.pulseloop.coach.config +/** + * Ported from [ReasoningEffort] in CoachSettings.swift (iOS #140). + * 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 class ReasoningEffort(val rawValue: String, val label: String) { + NONE("none", "None"), + MINIMAL("minimal", "Minimal"), + LOW("low", "Low"), + MEDIUM("medium", "Medium"), + HIGH("high", "High"), + XHIGH("xhigh", "Extra high"), + MAX("max", "Max"); + + companion object { + /** Levels every reasoning-capable model accepts — offered for a model we know nothing about. */ + val COMMON = listOf(LOW, MEDIUM, HIGH) + /** GPT-6 Sol/Astra and Claude 5.5. */ + val LOW_TO_MAX = listOf(LOW, MEDIUM, HIGH, XHIGH, MAX) + + fun fromRaw(raw: String?): ReasoningEffort? = entries.firstOrNull { it.rawValue == raw } + } +} + /** * Ported from [GeminiModel] in CoachSettings.swift. * Preset Gemini model choices surfaced in Settings. */ -enum class GeminiModel(val slug: String, val blurb: String) { +enum class GeminiModel(val slug: String, val blurb: String, val reasoningEfforts: List) { // Rolling `-latest` aliases (docs: ai.google.dev/gemini-api/docs/models) point at the most // recent release for each variation and are updated with a two-week notice — so the presets // never go stale again (issue #22). Current generation behind them: 3.5 Flash / 3.1 Flash-Lite. - FLASH_LATEST("gemini-flash-latest", "Fast & capable (default)"), - FLASH_LITE_LATEST("gemini-flash-lite-latest", "Fastest & lowest cost"), - PRO_LATEST("gemini-pro-latest", "Best reasoning"); + // Since iOS #140 the targets are 3.8 Flash, 3.5 Flash-Lite and 4 Argon; only Flash-Lite + // accepts `minimal` as a `thinkingLevel`. + FLASH_LATEST("gemini-flash-latest", "Fast & capable (default)", ReasoningEffort.COMMON), + FLASH_LITE_LATEST("gemini-flash-lite-latest", "Fastest & lowest cost", listOf(ReasoningEffort.MINIMAL) + ReasoningEffort.COMMON), + PRO_LATEST("gemini-pro-latest", "Best reasoning", ReasoningEffort.COMMON); val label: String get() = slug @@ -40,14 +68,16 @@ enum class GeminiModel(val slug: String, val blurb: String) { * exposes a "Custom…" text field where the user can type any current slug from * openrouter.ai/models. These are just the curated picks. */ -enum class OpenRouterModel(val slug: String, val blurb: String) { - CLAUDE_SONNET("anthropic/claude-sonnet-4.6", "Balanced, great for coaching (default)"), - CLAUDE_OPUS("anthropic/claude-opus-4.7", "Most capable Claude"), - GPT_55("openai/gpt-5.5", "OpenAI flagship"), - GPT_54_MINI("openai/gpt-5.4-mini", "Lower cost & latency"), - GEMINI_FLASH("google/gemini-2.5-flash", "Fast & capable"), - GEMINI_PRO("google/gemini-2.5-pro", "Deep reasoning"), - DEEPSEEK_V4("deepseek/deepseek-v4-flash", "Strong open reasoning, low cost"); +enum class OpenRouterModel(val slug: String, val blurb: String, val reasoningEfforts: List) { + // Levels are each model's `reasoning.supported_efforts` in OpenRouter's /api/v1/models. + CLAUDE_SONNET("anthropic/claude-sonnet-5.5", "Balanced, great for coaching (default)", ReasoningEffort.LOW_TO_MAX), + CLAUDE_OPUS("anthropic/claude-opus-5.5", "Most capable Claude", ReasoningEffort.LOW_TO_MAX), + CLAUDE_HAIKU("anthropic/claude-haiku-5.5", "Fastest, lowest-cost Claude", ReasoningEffort.LOW_TO_MAX), + GPT_SOL("openai/gpt-6.1-sol", "OpenAI balanced flagship", ReasoningEffort.LOW_TO_MAX), + GPT_LUNA("openai/gpt-6-luna", "Lower cost & latency", listOf(ReasoningEffort.NONE) + ReasoningEffort.LOW_TO_MAX), + GEMINI_PRO("google/gemini-4-argon", "Deep reasoning", ReasoningEffort.COMMON), + GEMINI_FLASH("google/gemini-3.8-flash", "Fast & capable", ReasoningEffort.COMMON), + DEEPSEEK("deepseek/deepseek-v4.1-flash", "Strong open reasoning, low cost", listOf(ReasoningEffort.LOW, ReasoningEffort.HIGH, ReasoningEffort.MAX)); val label: String get() = slug @@ -62,13 +92,13 @@ enum class OpenRouterModel(val slug: String, val blurb: String) { * Ported from [MiniMaxModel] in CoachSettings.swift (iOS #54). * Preset MiniMax model slugs surfaced in Settings. MiniMax's OpenAI-compatible * endpoint takes the model name verbatim; these are the curated picks. + * + * The M2.5 and M2.7 families are omitted (iOS #140): MiniMax closes them to new users on Oct 19 2026 + * and retires them on Nov 18 2026. A stored M2.5/M2.7 choice keeps working until then, and the + * live model list flags it once MiniMax stops listing it. */ enum class MiniMaxModel(val slug: String, val blurb: String) { M3("MiniMax-M3", "Latest — agentic reasoning, tool use, 1M context (default)"), - M27("MiniMax-M2.7", "Recursive self-improvement (~60 tps)"), - M27_HIGHSPEED("MiniMax-M2.7-highspeed", "M2.7 — same performance, faster (~100 tps)"), - M25("MiniMax-M2.5", "Peak performance & value (~60 tps)"), - M25_HIGHSPEED("MiniMax-M2.5-highspeed", "M2.5 — same performance, faster (~100 tps)"), M21("MiniMax-M2.1", "Strong multi-language programming (~60 tps)"), M21_HIGHSPEED("MiniMax-M2.1-highspeed", "M2.1 — faster (~100 tps)"), M2("MiniMax-M2", "Agentic capabilities, advanced reasoning"); @@ -88,15 +118,16 @@ enum class MiniMaxModel(val slug: String, val blurb: String) { * string (so a new model can be typed/served without a code change); these are * just the curated picks surfaced in Settings. */ -enum class OpenAIModel(val slug: String, val blurb: String) { - GPT_54_MINI("gpt-5.4-mini", "Lower cost & latency"), - GPT_54("gpt-5.4", "Balanced (default)"), - GPT_55("gpt-5.5", "Best reasoning"); +enum class OpenAIModel(val slug: String, val blurb: String, val reasoningEfforts: List) { + // Sol and Astra reject `none` (HTTP 400). + LUNA("gpt-6-luna", "Lower cost & latency", listOf(ReasoningEffort.NONE) + ReasoningEffort.LOW_TO_MAX), + SOL("gpt-6.1-sol", "Balanced (default)", ReasoningEffort.LOW_TO_MAX), + ASTRA("gpt-6-astra", "Best reasoning", ReasoningEffort.LOW_TO_MAX); val label: String get() = slug companion object { - val DEFAULT = GPT_54 + val DEFAULT = SOL /** Slugs the Settings picker used to offer and no longer does (issue #77). A stored * selection of one would keep failing with no visible way out, so it reads as [DEFAULT]. */ diff --git a/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt b/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt new file mode 100644 index 00000000..98d07831 --- /dev/null +++ b/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt @@ -0,0 +1,61 @@ +package com.pulseloop.coach.config + +/** + * Ported from `CoachSettings.knownReasoningEfforts` / `reasoningEffortOptions` / + * `resolvedReasoningEffort` in CoachSettings.swift (iOS #140), extended with the live + * [CoachModelCatalog] listing so a model outside the presets still gets its real levels. + * + * The stored effort is one global setting, but which levels are valid depends on the model: an + * OpenAI request with a level the model rejects is an HTTP 400 on every turn (`none` left over after + * switching from Luna to Sol), and Gemini 2.5 rejects `thinkingLevel` altogether. So the stored hint + * is checked against the selected model before it is sent, and dropped back to the model's default + * when it isn't one the model accepts. + */ +object ReasoningEfforts { + + /** + * The levels [model] is known to accept, or null when nothing says (a Custom OpenAI or + * OpenRouter slug the listing hasn't covered). Empty means the model takes no effort hint. + */ + fun known(provider: CoachProviderMode, model: String, live: CoachModelCatalog.Entry? = null): List? { + val slug = model.trim() + return when (provider) { + CoachProviderMode.USER_OPENAI_KEY -> + OpenAIModel.entries.firstOrNull { it.slug == slug }?.reasoningEfforts + CoachProviderMode.USER_OPENROUTER_KEY -> + OpenRouterModel.entries.firstOrNull { it.slug == slug }?.reasoningEfforts ?: live?.reasoningEfforts + CoachProviderMode.USER_GEMINI_KEY -> + GeminiModel.entries.firstOrNull { it.slug == slug }?.reasoningEfforts ?: geminiLevels(slug) + // Neither client sends a `reasoning` field (MiniMax reasons inline; see the local + // client's header), so there is nothing to choose. + else -> emptyList() + } + } + + /** The levels the Reasoning picker offers; empty hides it. */ + fun options(provider: CoachProviderMode, model: String, live: CoachModelCatalog.Entry? = null): List = + known(provider, model, live) ?: ReasoningEffort.COMMON + + /** + * The effort to send: the stored [stored] hint when [model] accepts it, else null (the model's + * own default). An unknown model gets the hint unchanged — it may well accept it, and blocking + * it would make a new model's effort unsettable. + */ + fun resolve(provider: CoachProviderMode, model: String, stored: String?, live: CoachModelCatalog.Entry? = null): String? { + val effort = stored?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val supported = known(provider, model, live) ?: return effort + return effort.takeIf { e -> supported.any { it.rawValue == e } } + } + + /** + * Gemini's `thinkingLevel` arrived with Gemini 3; a 2.x model rejects it, and a `-latest` alias + * we don't preset might point anywhere. So only a versioned 3+ slug gets levels. + */ + internal fun geminiLevels(slug: String): List { + val major = GEMINI_VERSION.find(slug)?.groupValues?.get(1)?.toIntOrNull() ?: return emptyList() + if (major < 3) return emptyList() + return if ("flash-lite" in slug) listOf(ReasoningEffort.MINIMAL) + ReasoningEffort.COMMON else ReasoningEffort.COMMON + } + + private val GEMINI_VERSION = Regex("^gemini-(\\d+)") +} diff --git a/app/src/main/java/com/pulseloop/coach/gemini/GeminiClient.kt b/app/src/main/java/com/pulseloop/coach/gemini/GeminiClient.kt index 9c69115d..255ad767 100644 --- a/app/src/main/java/com/pulseloop/coach/gemini/GeminiClient.kt +++ b/app/src/main/java/com/pulseloop/coach/gemini/GeminiClient.kt @@ -126,6 +126,10 @@ class GeminiClient( val tools = (req["tools"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: emptyList() val textFormat = ((req["text"] as? JsonObject)?.get("format")) as? JsonObject val previousResponseId = (req["previous_response_id"] as? JsonPrimitive)?.contentOrNull + // The app's `reasoning.effort` (iOS #140). `CoachClientResolver.resolvedReasoningEffort` + // never passes one to a 2.x model, which rejects `thinkingLevel`. + val thinkingLevel = ((req["reasoning"] as? JsonObject)?.get("effort") as? JsonPrimitive) + ?.contentOrNull?.takeIf { it.isNotBlank() }?.uppercase() if (previousResponseId == null) { setupConversation(input) @@ -133,7 +137,7 @@ class GeminiClient( appendContinuation(previousResponseId, input) } - return buildGeminiBody(convertTools(tools), textFormat) + return buildGeminiBody(convertTools(tools), textFormat, thinkingLevel) } // ── Conversation setup ─────────────────────────────────────────────── @@ -333,7 +337,7 @@ class GeminiClient( // ── Build Gemini request body ──────────────────────────────────────── - private fun buildGeminiBody(tools: List, textFormat: JsonObject?): JsonObject { + private fun buildGeminiBody(tools: List, textFormat: JsonObject?, thinkingLevel: String?): JsonObject { val body = mutableMapOf("contents" to JsonArray(contents)) if (systemText.isNotEmpty()) { @@ -380,6 +384,10 @@ class GeminiClient( } } } + // Gemini 3+ thinking depth, only when the user picked a level. + thinkingLevel?.let { + genConfig["thinkingConfig"] = JsonObject(mapOf("thinkingLevel" to JsonPrimitive(it))) + } if (genConfig.isNotEmpty()) body["generationConfig"] = JsonObject(genConfig) return JsonObject(body) diff --git a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt index b587fefc..5fb171ef 100644 --- a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt +++ b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt @@ -84,12 +84,12 @@ class CoachSummaryService( val apiKey = if (resolution != null) resolution.key ?: "" else apiKeyStore.apiKey val model = snapshot ?.let { CoachClientResolver.activeModel(it, apiKeyStore.model) } - ?: apiKeyStore.model.ifEmpty { "gpt-5.4" } + ?: apiKeyStore.model.ifEmpty { com.pulseloop.coach.config.OpenAIModel.DEFAULT.slug } val flags = CoachFeatureFlags( coachEnabled = apiKeyStore.coachEnabled && apiKey.isNotEmpty(), webSearchEnabled = apiKeyStore.webSearchEnabled, model = model, - settings = CoachClientResolver.coachSettings(snapshot), + settings = CoachClientResolver.coachSettings(snapshot, model), ) val angle = CoachVarietyHints.angle(built.scopeKey + kind.rawValue) val recentTexts = recentSummaryTexts(kind, excludeId = existing?.id) diff --git a/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt b/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt index 287fb2ce..826bfc4b 100644 --- a/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt +++ b/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt @@ -49,7 +49,7 @@ data class CoachFeatureFlags( val webSearchEnabled: Boolean = false, val writeToolsEnabled: Boolean = false, val liveMeasurementsEnabled: Boolean = false, - val model: String = "gpt-5.4", + val model: String = com.pulseloop.coach.config.OpenAIModel.DEFAULT.slug, val maxRounds: Int = 15, val maxToolCalls: Int = 30, val settings: CoachSettings = CoachSettings(), diff --git a/app/src/main/java/com/pulseloop/coach/usage/CoachModelPricing.kt b/app/src/main/java/com/pulseloop/coach/usage/CoachModelPricing.kt index 4b033f11..aad10c25 100644 --- a/app/src/main/java/com/pulseloop/coach/usage/CoachModelPricing.kt +++ b/app/src/main/java/com/pulseloop/coach/usage/CoachModelPricing.kt @@ -25,21 +25,40 @@ data class CoachModelPrice( */ object CoachPricingCatalog { /** Normalized-key → published list price, standard (non-batch) tier, as of - * July 2026. Sources: platform OpenAI/Google/DeepSeek/MiniMax pricing pages, + * October 2026 (iOS #140; checked against openrouter.ai/api/v1/models on + * 2026-10-14). 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 val table: Map = mapOf( - // OpenAI (native) + // OpenAI (native). GPT-6 bills the whole request at 2x input / 1.5x output + // above 272k input tokens. + "gpt-6-luna" to CoachModelPrice(0.10, 0.01, 0.50), + "gpt-6.1-sol" to CoachModelPrice(2.00, 0.10, 10.00), + "gpt-6-astra" to CoachModelPrice(10.00, 1.00, 50.00), "gpt-5.4-mini" to CoachModelPrice(0.75, 0.075, 4.50), "gpt-5.4" to CoachModelPrice(2.50, 0.25, 15.00), "gpt-5.5" to CoachModelPrice(5.00, 0.50, 30.00), // Gemini (native). 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". + // 4 Argon's rates double Dec 1 2026 and 3.8 Flash's on Jan 1 2027. + "gemini-3.8-flash" to CoachModelPrice(0.75, 0.075, 3.75), + "gemini-3.5-flash-lite" to CoachModelPrice(0.30, 0.03, 2.50), + "gemini-4-argon" to CoachModelPrice(2.00, 0.10, 10.00), "gemini-2.5-flash" to CoachModelPrice(0.30, 0.03, 2.50), "gemini-2.5-pro" to CoachModelPrice(1.25, 0.125, 10.00), // <=200k-token tier; >200k bills 2x // OpenRouter (vendor/model slugs). List prices match the vendors'; // cached rates use the vendor cache-read price. + "anthropic/claude-sonnet-5.5" to CoachModelPrice(2.00, 0.10, 10.00), + "anthropic/claude-opus-5.5" to CoachModelPrice(4.00, 0.20, 20.00), + "anthropic/claude-haiku-5.5" to CoachModelPrice(0.10, 0.01, 0.50), // <=100k-token tier + "openai/gpt-6.1-sol" to CoachModelPrice(2.00, 0.10, 10.00), + "openai/gpt-6-luna" to CoachModelPrice(0.10, 0.01, 0.50), + "google/gemini-4-argon" to CoachModelPrice(2.00, 0.10, 10.00), + "google/gemini-3.8-flash" to CoachModelPrice(0.75, 0.075, 3.75), + "deepseek/deepseek-v4.1-flash" to CoachModelPrice(0.016, 0.005, 0.891), // cheapest OpenRouter endpoint "anthropic/claude-sonnet-4.6" to CoachModelPrice(3.00, 0.30, 15.00), "anthropic/claude-opus-4.7" to CoachModelPrice(5.00, 0.50, 25.00), "openai/gpt-5.5" to CoachModelPrice(5.00, 0.50, 30.00), @@ -47,16 +66,16 @@ object CoachPricingCatalog { "google/gemini-2.5-flash" to CoachModelPrice(0.30, 0.03, 2.50), "google/gemini-2.5-pro" to CoachModelPrice(1.25, 0.125, 10.00), "deepseek/deepseek-v4-flash" to CoachModelPrice(0.09, null, 0.18), // OpenRouter routes below DeepSeek's direct $0.14/$0.28 - // MiniMax. No published cache-read rate; -highspeed variants bill 2x - // the standard rate. - "minimax-m3" to CoachModelPrice(0.30, null, 1.20), // <=512k-token tier; >512k bills 2x - "minimax-m2.7" to CoachModelPrice(0.30, null, 1.20), - "minimax-m2.7-highspeed" to CoachModelPrice(0.60, null, 2.40), - "minimax-m2.5" to CoachModelPrice(0.30, null, 1.20), - "minimax-m2.5-highspeed" to CoachModelPrice(0.60, null, 2.40), - "minimax-m2.1" to CoachModelPrice(0.30, null, 1.20), - "minimax-m2.1-highspeed" to CoachModelPrice(0.60, null, 2.40), - "minimax-m2" to CoachModelPrice(0.30, null, 1.20), + // MiniMax. -highspeed variants bill 2x the standard input/output rate; + // M2.5/M2.7 retire Nov 18 2026. + "minimax-m3" to CoachModelPrice(0.30, 0.06, 1.20), // <=512k-token tier; >512k bills 2x + "minimax-m2.7" to CoachModelPrice(0.30, 0.06, 1.20), + "minimax-m2.7-highspeed" to CoachModelPrice(0.60, 0.06, 2.40), + "minimax-m2.5" to CoachModelPrice(0.30, 0.03, 1.20), + "minimax-m2.5-highspeed" to CoachModelPrice(0.60, 0.03, 2.40), + "minimax-m2.1" to CoachModelPrice(0.30, 0.03, 1.20), + "minimax-m2.1-highspeed" to CoachModelPrice(0.60, 0.03, 2.40), + "minimax-m2" to CoachModelPrice(0.30, 0.03, 1.20), ) /** The model strings that always cost $0 (local / scripted). */ diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 9951f931..6bb2c181 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -101,15 +101,16 @@ fun PulseLoopApp() { flagsProvider = { val resolution = com.pulseloop.coach.config.CoachClientResolver.resolve(providerStore, apiKeyStore) val providerSettings = providerStore.snapshot() + val activeModel = com.pulseloop.coach.config.CoachClientResolver.activeModel( + providerSettings, apiKeyStore.model, + ) CoachFeatureFlags( coachEnabled = apiKeyStore.coachEnabled && resolution.key != null, webSearchEnabled = apiKeyStore.webSearchEnabled, writeToolsEnabled = false, // safe default liveMeasurementsEnabled = true, - model = com.pulseloop.coach.config.CoachClientResolver.activeModel( - providerSettings, apiKeyStore.model, - ), - settings = com.pulseloop.coach.config.CoachClientResolver.coachSettings(providerSettings), + model = activeModel, + settings = com.pulseloop.coach.config.CoachClientResolver.coachSettings(providerSettings, activeModel), providerMode = providerSettings.providerMode, ) }, diff --git a/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt b/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt new file mode 100644 index 00000000..2aa1c140 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt @@ -0,0 +1,154 @@ +package com.pulseloop.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pulseloop.coach.config.CoachModelCatalog +import com.pulseloop.coach.config.CoachProviderMode +import com.pulseloop.ui.theme.PulseColors +import kotlinx.coroutines.launch + +/** + * The cloud-provider model picker: the curated [presets] first, then every model the provider + * itself lists ([CoachModelCatalog]), fetched as soon as the provider is selected with a saved key. + * The listing is advisory — [selected] stays whatever string the user picked — but it is what tells + * the user a stored model has been retired, instead of every coach turn failing with a 404. + * + * [onListing] reports each fetched listing (null while there is none) so the Reasoning picker can + * offer the selected model's real levels. + */ +@Composable +fun CoachModelPicker( + provider: CoachProviderMode, + providerName: String, + selected: String, + presets: List>, + savedKey: String, + onPick: (String) -> Unit, + onListing: (CoachModelCatalog.Result.Success?) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + var listing by remember(provider) { mutableStateOf(null) } + var loading by remember(provider) { mutableStateOf(false) } + + suspend fun load(force: Boolean) { + if (savedKey.isBlank()) { listing = null; onListing(null); return } + loading = true + val result = CoachModelCatalog.fetch(provider, savedKey, force) + listing = result + onListing(result as? CoachModelCatalog.Result.Success) + loading = false + } + LaunchedEffect(provider, savedKey) { load(force = false) } + + val success = listing as? CoachModelCatalog.Result.Success + val presetSlugs = presets.map { it.first }.toSet() + val others = success?.entries?.filter { it.id !in presetSlugs }.orEmpty() + + Text("Model", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + var expanded by remember { mutableStateOf(false) } + var filter by remember(provider) { mutableStateOf("") } + Box { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Text(selected, Modifier.weight(1f)) + Icon(Icons.Filled.ArrowDropDown, null) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + @Composable + fun Item(slug: String, detail: String) = DropdownMenuItem( + text = { + Column { + Text(slug) + if (detail.isNotEmpty()) Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + onClick = { onPick(slug); expanded = false }, + ) + + if (presets.isNotEmpty()) MenuHeader("Recommended") + presets.forEach { (slug, blurb) -> Item(slug, blurb) } + + if (others.isNotEmpty()) { + HorizontalDivider() + MenuHeader("All models from $providerName (${others.size})") + // OpenRouter lists hundreds; a filter beats scrolling, and the cap keeps the + // (non-lazy) menu from composing every row. + if (others.size > FILTER_THRESHOLD) { + OutlinedTextField( + value = filter, + onValueChange = { filter = it }, + placeholder = { Text("Filter, e.g. claude") }, + singleLine = true, + modifier = Modifier.padding(horizontal = 12.dp).fillMaxWidth(), + ) + } + val matches = others.filter { e -> + filter.isBlank() || e.id.contains(filter.trim(), ignoreCase = true) || + e.name?.contains(filter.trim(), ignoreCase = true) == true + } + matches.take(MAX_ROWS).forEach { e -> + val detail = listOfNotNull( + e.name?.takeIf { it != e.id }, + e.expiresOn?.let { "retiring $it" }, + ).joinToString(" · ") + Item(e.id, detail) + } + if (matches.size > MAX_ROWS) { + MenuHeader("${matches.size - MAX_ROWS} more — type to narrow") + } + } + } + } + + // Status of the live listing, and what it says about the selected model. + val expiry = success?.find(selected)?.expiresOn + val unlisted = success != null && CoachModelCatalog.isUnlisted(provider, success, selected) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + val (status, color) = when { + savedKey.isBlank() -> "Save your key to see every model $providerName offers." to MaterialTheme.colorScheme.onSurfaceVariant + loading -> "Loading models from $providerName…" to MaterialTheme.colorScheme.onSurfaceVariant + listing is CoachModelCatalog.Result.Failure -> (listing as CoachModelCatalog.Result.Failure).message to PulseColors.danger + success != null -> "${success.entries.size} models available from $providerName" to MaterialTheme.colorScheme.onSurfaceVariant + else -> "" to MaterialTheme.colorScheme.onSurfaceVariant + } + Text(status, style = MaterialTheme.typography.bodySmall, color = color, modifier = Modifier.weight(1f)) + if (savedKey.isNotBlank() && !loading) { + TextButton(onClick = { scope.launch { load(force = true) } }) { + Text(if (listing is CoachModelCatalog.Result.Failure) "Retry" else "Refresh") + } + } + } + if (unlisted) { + Text( + "$providerName doesn't list “$selected” any more — it may have been retired. Pick another model.", + style = MaterialTheme.typography.bodySmall, + color = PulseColors.danger, + ) + } else if (expiry != null) { + Text( + "$providerName is retiring this model on $expiry.", + style = MaterialTheme.typography.bodySmall, + color = PulseColors.warning, + ) + } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun MenuHeader(text: String) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) +} + +private const val FILTER_THRESHOLD = 15 +private const val MAX_ROWS = 60 diff --git a/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt b/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt index 54782540..b316fffa 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt @@ -716,14 +716,14 @@ object MealEstimator { OpenAIRequestBuilder.message("system", MealAnalysisLogic.systemPrompt), OpenAIRequestBuilder.message("user", prompt, images), )) - // iOS passes flags.settings.reasoningEffort through OpenAIRequestBuilder.data + // iOS passes flags.settings.resolvedReasoningEffort through OpenAIRequestBuilder.data // (MealAnalysisSheet.swift:401); the model-aware overload also drops `reasoning` // for the legacy chat families that reject it outright. val requestBody = JsonObject(mapOf( "model" to JsonPrimitive(model), "input" to input, "text" to JsonObject(mapOf("format" to MealAnalysisLogic.textFormat())), - ) + OpenAIRequestBuilder.reasoningParams(providerStore.snapshot().reasoningEffort, model)) + ) + OpenAIRequestBuilder.reasoningParams(CoachClientResolver.resolvedReasoningEffort(providerStore.snapshot(), model), model)) try { val response = resolution.client.send(requestBody.toString().toByteArray()) val estimate = MealAnalysisLogic.decode(response.outputText) diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index fca6f7d4..a710a4c0 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -275,6 +275,11 @@ fun CoachSettingsScreen(onBack: () -> Unit) { var orPrivacy by remember { mutableStateOf(providerStore.orPrivacyRouting) } var orSort by remember { mutableStateOf(providerStore.orProviderSort) } var reasoningEffort by remember { mutableStateOf(providerStore.reasoningEffort) } + // Bumped on every key save/remove so the model picker refetches the provider's + // list with the new key; the key fields' own state changes per keystroke. + var keySaves by remember { mutableIntStateOf(0) } + // The provider's live model list (CoachModelCatalog), for the Reasoning picker. + var liveListing by remember { mutableStateOf(null) } var imageInput by remember { mutableStateOf(providerStore.imageInputEnabled) } // Local / self-hosted (docs/local-llm-coach.md). var localBaseUrl by remember { mutableStateOf(providerStore.localBaseUrl) } @@ -396,21 +401,29 @@ fun CoachSettingsScreen(onBack: () -> Unit) { when (providerMode) { CoachProviderMode.USER_GEMINI_KEY -> { - ModelDropdown("Model", geminiModel, GeminiModel.entries.map { it.slug to it.blurb }) { - geminiModel = it; providerStore.geminiModel = it - } + CoachModelPicker( + provider = providerMode, providerName = "Gemini", selected = geminiModel, + presets = GeminiModel.entries.map { it.slug to it.blurb }, + savedKey = keySaves.let { providerStore.geminiApiKey }, + onPick = { geminiModel = it; providerStore.geminiModel = it }, + onListing = { liveListing = it }, + ) KeyField( label = "Gemini API Key", value = geminiKey, visible = geminiKeyVisible, saved = providerStore.hasGeminiKey, onValue = { geminiKey = it }, onVisibility = { geminiKeyVisible = !geminiKeyVisible }, - onSave = { providerStore.geminiApiKey = geminiKey }, - onRemove = { geminiKey = ""; providerStore.geminiApiKey = "" }, + onSave = { providerStore.geminiApiKey = geminiKey; keySaves++ }, + onRemove = { geminiKey = ""; providerStore.geminiApiKey = ""; keySaves++ }, ) } CoachProviderMode.USER_OPENROUTER_KEY -> { - ModelDropdown("Model", orModel, OpenRouterModel.entries.map { it.slug to it.blurb }) { - orModel = it; providerStore.openRouterModel = it - } + CoachModelPicker( + provider = providerMode, providerName = "OpenRouter", selected = orModel, + presets = OpenRouterModel.entries.map { it.slug to it.blurb }, + savedKey = keySaves.let { providerStore.openRouterApiKey }, + onPick = { orModel = it; providerStore.openRouterModel = it }, + onListing = { liveListing = it }, + ) // The OpenRouter catalog is huge and shifts often — any current // vendor/model slug from openrouter.ai/models works here. OutlinedTextField( @@ -425,8 +438,8 @@ fun CoachSettingsScreen(onBack: () -> Unit) { label = "OpenRouter API Key", value = orKey, visible = orKeyVisible, saved = providerStore.hasOpenRouterKey, onValue = { orKey = it }, onVisibility = { orKeyVisible = !orKeyVisible }, - onSave = { providerStore.openRouterApiKey = orKey }, - onRemove = { orKey = ""; providerStore.openRouterApiKey = "" }, + onSave = { providerStore.openRouterApiKey = orKey; keySaves++ }, + onRemove = { orKey = ""; providerStore.openRouterApiKey = ""; keySaves++ }, ) Spacer(Modifier.height(8.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { @@ -442,15 +455,19 @@ fun CoachSettingsScreen(onBack: () -> Unit) { ) { providerStore.orProviderSort = it.takeIf { s -> s != "auto" }; orSort = providerStore.orProviderSort } } CoachProviderMode.USER_MINIMAX_KEY -> { - ModelDropdown("Model", minimaxModel, MiniMaxModel.entries.map { it.slug to it.blurb }) { - minimaxModel = it; providerStore.minimaxModel = it - } + CoachModelPicker( + provider = providerMode, providerName = "MiniMax", selected = minimaxModel, + presets = MiniMaxModel.entries.map { it.slug to it.blurb }, + savedKey = keySaves.let { providerStore.minimaxApiKey }, + onPick = { minimaxModel = it; providerStore.minimaxModel = it }, + onListing = { liveListing = it }, + ) KeyField( label = "MiniMax API Key", value = minimaxKey, visible = minimaxKeyVisible, saved = providerStore.hasMinimaxKey, onValue = { minimaxKey = it }, onVisibility = { minimaxKeyVisible = !minimaxKeyVisible }, - onSave = { providerStore.minimaxApiKey = minimaxKey }, - onRemove = { minimaxKey = ""; providerStore.minimaxApiKey = "" }, + onSave = { providerStore.minimaxApiKey = minimaxKey; keySaves++ }, + onRemove = { minimaxKey = ""; providerStore.minimaxApiKey = ""; keySaves++ }, ) } CoachProviderMode.LOCAL_OPENAI_COMPAT -> { @@ -676,16 +693,20 @@ fun CoachSettingsScreen(onBack: () -> Unit) { ) } else -> { - // OpenAI (and legacy modes): the original model picker + key field. - ModelDropdown("Model", selectedModel, models) { - selectedModel = it; keyStore.model = it - } + // OpenAI (and legacy modes): the model picker + key field. + CoachModelPicker( + provider = CoachProviderMode.USER_OPENAI_KEY, providerName = "OpenAI", selected = selectedModel, + presets = models, + savedKey = keySaves.let { keyStore.apiKey }, + onPick = { selectedModel = it; keyStore.model = it }, + onListing = { liveListing = it }, + ) KeyField( label = "OpenAI API Key", value = apiKey, visible = apiKeyVisible, saved = keyStore.apiKey.isNotBlank(), onValue = { apiKey = it }, onVisibility = { apiKeyVisible = !apiKeyVisible }, - onSave = { keyStore.apiKey = apiKey }, - onRemove = { apiKey = ""; keyStore.apiKey = "" }, + onSave = { keyStore.apiKey = apiKey; keySaves++ }, + onRemove = { apiKey = ""; keyStore.apiKey = ""; keySaves++ }, ) } } @@ -696,11 +717,31 @@ fun CoachSettingsScreen(onBack: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(8.dp)) - ModelDropdown( - "Reasoning effort", reasoningEffort ?: "default", - listOf("default" to "Model decides (default)", "low" to "Fastest", "medium" to "Balanced", "high" to "Deepest — more tokens"), - ) { providerStore.reasoningEffort = it.takeIf { s -> s != "default" }; reasoningEffort = providerStore.reasoningEffort } + // Reasoning effort / Gemini thinking level (iOS #140). The levels differ per model + // (GPT-6 Sol rejects None, DeepSeek has no Medium), so the picker lists only what + // the selected model accepts, and shows "default" for a stored level it doesn't — + // matching CoachClientResolver.resolvedReasoningEffort, which won't send it. + val effortProvider = if (providerMode in setOf( + CoachProviderMode.USER_GEMINI_KEY, CoachProviderMode.USER_OPENROUTER_KEY, + CoachProviderMode.USER_MINIMAX_KEY, CoachProviderMode.LOCAL_OPENAI_COMPAT, + )) providerMode else CoachProviderMode.USER_OPENAI_KEY + val effortModel = when (effortProvider) { + CoachProviderMode.USER_GEMINI_KEY -> geminiModel + CoachProviderMode.USER_OPENROUTER_KEY -> orModel + CoachProviderMode.USER_OPENAI_KEY -> selectedModel + else -> "" + } + val liveEntry = liveListing?.find(effortModel) + val effortOptions = com.pulseloop.coach.config.ReasoningEfforts.options(effortProvider, effortModel, liveEntry) + if (effortOptions.isNotEmpty()) { + val shownEffort = com.pulseloop.coach.config.ReasoningEfforts.resolve(effortProvider, effortModel, reasoningEffort, liveEntry) + Spacer(Modifier.height(8.dp)) + ModelDropdown( + "Reasoning effort", + com.pulseloop.coach.config.ReasoningEffort.fromRaw(shownEffort)?.label ?: "Default", + listOf("default" to "Model decides (default)") + effortOptions.map { it.rawValue to it.label }, + ) { providerStore.reasoningEffort = it.takeIf { s -> s != "default" }; reasoningEffort = providerStore.reasoningEffort } + } // Image attachments (iOS #31): gates the attach button in the coach chat. Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/test/java/com/pulseloop/coach/CoachActionTest.kt b/app/src/test/java/com/pulseloop/coach/CoachActionTest.kt index 673b2ed7..51b08791 100644 --- a/app/src/test/java/com/pulseloop/coach/CoachActionTest.kt +++ b/app/src/test/java/com/pulseloop/coach/CoachActionTest.kt @@ -180,7 +180,7 @@ class CoachActionTest { assertTrue(flags.coachEnabled) assertFalse(flags.webSearchEnabled) assertFalse(flags.writeToolsEnabled) - assertEquals("gpt-5.4", flags.model) + assertEquals(com.pulseloop.coach.config.OpenAIModel.DEFAULT.slug, flags.model) assertEquals(15, flags.maxRounds) assertEquals(30, flags.maxToolCalls) } diff --git a/app/src/test/java/com/pulseloop/coach/CoachClientResolverTest.kt b/app/src/test/java/com/pulseloop/coach/CoachClientResolverTest.kt index 9572a57e..f0587a68 100644 --- a/app/src/test/java/com/pulseloop/coach/CoachClientResolverTest.kt +++ b/app/src/test/java/com/pulseloop/coach/CoachClientResolverTest.kt @@ -180,10 +180,18 @@ class CoachClientResolverTest { @Test fun testCoachSettingsKeepNullEqualsOmitContract() { // null store/effort must stay null so no `reasoning` field is sent. - assertNull(CoachClientResolver.coachSettings(null).reasoningEffort) - assertNull(CoachClientResolver.coachSettings(CoachProviderSettings()).reasoningEffort) + assertNull(CoachClientResolver.coachSettings(null, "gpt-6.1-sol").reasoningEffort) + assertNull(CoachClientResolver.coachSettings(CoachProviderSettings(), "gpt-6.1-sol").reasoningEffort) assertEquals("high", CoachClientResolver.coachSettings( - CoachProviderSettings(reasoningEffort = "high")).reasoningEffort) + CoachProviderSettings(reasoningEffort = "high"), "gpt-6.1-sol").reasoningEffort) + } + + /** iOS #140: `none` left over after switching from Luna to Sol is an HTTP 400 on every turn. */ + @Test + fun testCoachSettingsDropAnEffortTheModelRejects() { + val s = CoachProviderSettings(reasoningEffort = "none") + assertEquals("none", CoachClientResolver.coachSettings(s, "gpt-6-luna").reasoningEffort) + assertNull(CoachClientResolver.coachSettings(s, "gpt-6.1-sol").reasoningEffort) } @Test diff --git a/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt b/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt new file mode 100644 index 00000000..df678577 --- /dev/null +++ b/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt @@ -0,0 +1,138 @@ +package com.pulseloop.coach + +import com.pulseloop.coach.config.CoachModelCatalog +import com.pulseloop.coach.config.CoachProviderMode +import com.pulseloop.coach.config.ReasoningEffort +import com.pulseloop.coach.config.ReasoningEfforts +import org.junit.Assert.* +import org.junit.Test + +/** The live model listings (OpenAI / Gemini / OpenRouter / MiniMax) and the per-model reasoning levels. */ +class CoachModelCatalogTest { + + // ── OpenAI ────────────────────────────────────────────────────────── + + @Test + fun `openai keeps chat models and drops everything else`() { + val body = """{"object":"list","data":[ + {"id":"gpt-6.1-sol"},{"id":"gpt-6-luna"},{"id":"o5"},{"id":"o5-mini"}, + {"id":"gpt-6.1-sol-2026-09-30"},{"id":"text-embedding-4-large"},{"id":"gpt-6-realtime"}, + {"id":"gpt-image-2"},{"id":"gpt-6-mini-tts"},{"id":"whisper-1"},{"id":"omni-moderation-latest"}, + {"id":"gpt-4o"},{"id":"dall-e-3"},{"id":"gpt-6-luna"} + ]}""" + assertEquals( + listOf("gpt-6-luna", "gpt-6.1-sol", "o5", "o5-mini"), + CoachModelCatalog.parseOpenAI(body).map { it.id }, + ) + } + + // ── Gemini ────────────────────────────────────────────────────────── + + @Test + fun `gemini keeps generateContent models and strips the models prefix`() { + val body = """{"models":[ + {"name":"models/gemini-3.8-flash","displayName":"Gemini 3.8 Flash","supportedGenerationMethods":["generateContent","countTokens"]}, + {"name":"models/gemini-flash-latest","displayName":"Gemini Flash Latest","supportedGenerationMethods":["generateContent"]}, + {"name":"models/gemini-embedding-002","supportedGenerationMethods":["embedContent"]}, + {"name":"models/gemini-3.8-flash-preview-tts","supportedGenerationMethods":["generateContent"]}, + {"name":"models/imagen-5","supportedGenerationMethods":["predict"]} + ],"nextPageToken":"abc"}""" + val (entries, next) = CoachModelCatalog.parseGeminiPage(body) + assertEquals(listOf("gemini-3.8-flash", "gemini-flash-latest"), entries.map { it.id }) + assertEquals("Gemini 3.8 Flash", entries.first().name) + assertEquals("abc", next) + assertNull(CoachModelCatalog.parseGeminiPage("""{"models":[]}""").second) + } + + // ── OpenRouter ────────────────────────────────────────────────────── + + /** Trimmed from openrouter.ai/api/v1/models on 2026-10-14. */ + private val openRouterBody = """{"data":[ + {"id":"anthropic/claude-haiku-5.5","name":"Anthropic: Claude Haiku 5.5", + "supported_parameters":["reasoning","reasoning_effort","tools","structured_outputs"], + "reasoning":{"mandatory":false,"supported_efforts":["max","xhigh","high","medium","low"],"default_effort":"medium"}, + "expiration_date":null}, + {"id":"anthropic/claude-haiku-5.5:batch","name":"batch","supported_parameters":["tools"]}, + {"id":"deepseek/deepseek-v4.1-flash","name":"DeepSeek V4.1 Flash","supported_parameters":["reasoning","tools"], + "reasoning":{"supported_efforts":["max","high","low"]}}, + {"id":"xiaomi/mimo-v2.5","name":"MiMo V2.5","supported_parameters":["tools"],"expiration_date":"2026-10-21"}, + {"id":"some/vision-only","name":"No tools","supported_parameters":["max_tokens"]} + ]}""" + + @Test + fun `openrouter keeps tool-capable models with their levels and expiry`() { + val entries = CoachModelCatalog.parseOpenRouter(openRouterBody).associateBy { it.id } + assertEquals(setOf("anthropic/claude-haiku-5.5", "deepseek/deepseek-v4.1-flash", "xiaomi/mimo-v2.5"), entries.keys) + assertEquals(ReasoningEffort.LOW_TO_MAX, entries.getValue("anthropic/claude-haiku-5.5").reasoningEfforts) + assertEquals( + listOf(ReasoningEffort.LOW, ReasoningEffort.HIGH, ReasoningEffort.MAX), + entries.getValue("deepseek/deepseek-v4.1-flash").reasoningEfforts, + ) + // No `reasoning` parameter at all: the model takes no effort hint. + assertEquals(emptyList(), entries.getValue("xiaomi/mimo-v2.5").reasoningEfforts) + assertEquals("2026-10-21", entries.getValue("xiaomi/mimo-v2.5").expiresOn) + assertNull(entries.getValue("anthropic/claude-haiku-5.5").expiresOn) + } + + // ── Unlisted (retired) models ─────────────────────────────────────── + + @Test + fun `a stored model the listing omits is flagged, except aliases and snapshots`() { + val openAI = CoachModelCatalog.Result.Success(CoachModelCatalog.parseOpenAI("""{"data":[{"id":"gpt-6.1-sol"}]}"""), 0) + assertTrue(CoachModelCatalog.isUnlisted(CoachProviderMode.USER_OPENAI_KEY, openAI, "gpt-5.4")) + assertFalse(CoachModelCatalog.isUnlisted(CoachProviderMode.USER_OPENAI_KEY, openAI, "gpt-6.1-sol")) + assertFalse(CoachModelCatalog.isUnlisted(CoachProviderMode.USER_OPENAI_KEY, openAI, "gpt-6.1-sol-2026-09-30")) + val gemini = CoachModelCatalog.Result.Success(listOf(CoachModelCatalog.Entry("gemini-3.8-flash")), 0) + assertFalse(CoachModelCatalog.isUnlisted(CoachProviderMode.USER_GEMINI_KEY, gemini, "gemini-flash-latest")) + assertTrue(CoachModelCatalog.isUnlisted(CoachProviderMode.USER_GEMINI_KEY, gemini, "gemini-2.0-flash")) + } + + @Test + fun `minimax listing reads the openai envelope`() { + assertEquals( + listOf("MiniMax-M2.1", "MiniMax-M3"), + CoachModelCatalog.parseIds("""{"data":[{"id":"MiniMax-M3"},{"id":"MiniMax-M2.1"}]}""").map { it.id }, + ) + } + + // ── Reasoning levels ──────────────────────────────────────────────── + + @Test + fun `presets decide the levels, and an unsupported stored level is dropped`() { + val openAI = CoachProviderMode.USER_OPENAI_KEY + assertEquals("none", ReasoningEfforts.resolve(openAI, "gpt-6-luna", "none")) + assertNull(ReasoningEfforts.resolve(openAI, "gpt-6.1-sol", "none")) + assertEquals("xhigh", ReasoningEfforts.resolve(openAI, "gpt-6.1-sol", "xhigh")) + // Unknown model: pass the hint through, offer the common levels. + assertEquals("xhigh", ReasoningEfforts.resolve(openAI, "gpt-7-preview", "xhigh")) + assertEquals(ReasoningEffort.COMMON, ReasoningEfforts.options(openAI, "gpt-7-preview")) + assertNull(ReasoningEfforts.resolve(openAI, "gpt-6.1-sol", null)) + } + + @Test + fun `openrouter uses the live levels for a model outside the presets`() { + val live = CoachModelCatalog.parseOpenRouter(openRouterBody).first { it.id == "xiaomi/mimo-v2.5" } + val or = CoachProviderMode.USER_OPENROUTER_KEY + assertTrue(ReasoningEfforts.options(or, live.id, live).isEmpty()) + assertNull(ReasoningEfforts.resolve(or, live.id, "high", live)) + // DeepSeek has no `medium`. + assertNull(ReasoningEfforts.resolve(or, "deepseek/deepseek-v4.1-flash", "medium")) + } + + @Test + fun `gemini levels only for version 3 and up`() { + val gemini = CoachProviderMode.USER_GEMINI_KEY + assertNull(ReasoningEfforts.resolve(gemini, "gemini-2.5-flash", "high")) + assertTrue(ReasoningEfforts.options(gemini, "gemini-2.5-pro").isEmpty()) + assertEquals("high", ReasoningEfforts.resolve(gemini, "gemini-4-argon", "high")) + assertEquals("minimal", ReasoningEfforts.resolve(gemini, "gemini-flash-lite-latest", "minimal")) + assertNull(ReasoningEfforts.resolve(gemini, "gemini-flash-latest", "minimal")) + assertNull(ReasoningEfforts.resolve(gemini, "gemini-flash-latest", "xhigh")) + } + + @Test + fun `providers that send no reasoning field hide the picker`() { + assertTrue(ReasoningEfforts.options(CoachProviderMode.USER_MINIMAX_KEY, "MiniMax-M3").isEmpty()) + assertTrue(ReasoningEfforts.options(CoachProviderMode.LOCAL_OPENAI_COMPAT, "qwen3:8b").isEmpty()) + } +} diff --git a/app/src/test/java/com/pulseloop/coach/CoachModelPresetsTest.kt b/app/src/test/java/com/pulseloop/coach/CoachModelPresetsTest.kt index 92f04744..658ab767 100644 --- a/app/src/test/java/com/pulseloop/coach/CoachModelPresetsTest.kt +++ b/app/src/test/java/com/pulseloop/coach/CoachModelPresetsTest.kt @@ -10,13 +10,28 @@ import org.junit.Test class CoachModelPresetsTest { @Test - fun testDeepSeekPresetUsesV4FlashSlug() { - assertEquals("deepseek/deepseek-v4-flash", OpenRouterModel.DEEPSEEK_V4.slug) + fun testDeepSeekPresetUsesV41FlashSlug() { + assertEquals("deepseek/deepseek-v4.1-flash", OpenRouterModel.DEEPSEEK.slug) } @Test fun testOpenRouterDefaultIsClaudeSonnet() { - assertEquals("anthropic/claude-sonnet-4.6", OpenRouterModel.DEFAULT.slug) + assertEquals("anthropic/claude-sonnet-5.5", OpenRouterModel.DEFAULT.slug) + } + + /** iOS #140: MiniMax closes M2.5/M2.7 to new users Oct 19 2026 and retires them Nov 18. */ + @Test + fun testRetiringMiniMaxFamiliesAreNotOffered() { + MiniMaxModel.entries.forEach { + assertFalse(it.slug, it.slug.startsWith("MiniMax-M2.5") || it.slug.startsWith("MiniMax-M2.7")) + } + } + + @Test + fun testEveryReasoningPresetListsAtLeastOneLevel() { + OpenAIModel.entries.forEach { assertTrue(it.slug, it.reasoningEfforts.isNotEmpty()) } + OpenRouterModel.entries.forEach { assertTrue(it.slug, it.reasoningEfforts.isNotEmpty()) } + GeminiModel.entries.forEach { assertTrue(it.slug, it.reasoningEfforts.isNotEmpty()) } } @Test @@ -48,9 +63,9 @@ class CoachModelPresetsTest { } @Test - fun testOpenAIDefaultMatchesLegacyStoreDefault() { - // ApiKeyStore.model defaults to "gpt-5.4"; the preset default must agree. - assertEquals("gpt-5.4", OpenAIModel.DEFAULT.slug) + fun testOpenAIDefaultIsSol() { + // iOS #140. ApiKeyStore.model reads a blank store as this default. + assertEquals("gpt-6.1-sol", OpenAIModel.DEFAULT.slug) } /** Issue #77: a stored retired slug must not strand the user on a model the picker no longer offers. */ diff --git a/app/src/test/java/com/pulseloop/coach/GeminiClientTest.kt b/app/src/test/java/com/pulseloop/coach/GeminiClientTest.kt index 0fa36de0..388a2e89 100644 --- a/app/src/test/java/com/pulseloop/coach/GeminiClientTest.kt +++ b/app/src/test/java/com/pulseloop/coach/GeminiClientTest.kt @@ -22,8 +22,10 @@ class GeminiClientTest { tools: List = emptyList(), textFormat: JsonObject? = null, previousResponseId: String? = null, + effort: String? = null, ) = JsonObject(buildMap { put("model", JsonPrimitive("gemini-2.5-flash")) + effort?.let { put("reasoning", JsonObject(mapOf("effort" to JsonPrimitive(it)))) } put("input", JsonArray(input)) put("tools", JsonArray(tools)) textFormat?.let { put("text", JsonObject(mapOf("format" to it))) } @@ -48,6 +50,21 @@ class GeminiClientTest { )))), )) + // ── Thinking level (iOS #140) ─────────────────────────────────────── + + @Test + fun testReasoningEffortIsSentAsThinkingLevel() { + val body = GeminiClient("key").buildRequestBody(request(listOf(msg("user", "hi")), effort = "high")) + val level = body["generationConfig"]!!.jsonObject["thinkingConfig"]!!.jsonObject["thinkingLevel"]!!.jsonPrimitive.content + assertEquals("HIGH", level) + } + + @Test + fun testNoEffortSendsNoThinkingConfig() { + val body = GeminiClient("key").buildRequestBody(request(listOf(msg("user", "hi")))) + assertNull(body["generationConfig"]?.jsonObject?.get("thinkingConfig")) + } + // ── First-turn conversion ─────────────────────────────────────────── @Test diff --git a/app/src/test/java/com/pulseloop/coach/OpenRouterClientTest.kt b/app/src/test/java/com/pulseloop/coach/OpenRouterClientTest.kt index c01588d9..f9d8b41f 100644 --- a/app/src/test/java/com/pulseloop/coach/OpenRouterClientTest.kt +++ b/app/src/test/java/com/pulseloop/coach/OpenRouterClientTest.kt @@ -103,7 +103,8 @@ class OpenRouterClientTest { val body = client.buildRequestBody(request( listOf(msg("user", "hi")), tools = listOf(webSearchTool, functionTool), )) - assertEquals("anthropic/claude-sonnet-4.6:online", body["model"]!!.jsonPrimitive.content) + // The client sends its own configured model (the default preset here), not the body's. + assertEquals("${com.pulseloop.coach.config.OpenRouterModel.DEFAULT.slug}:online", body["model"]!!.jsonPrimitive.content) // The hosted web_search spec itself is dropped from the tool list. assertEquals(1, body["tools"]!!.jsonArray.size) } @@ -111,7 +112,7 @@ class OpenRouterClientTest { @Test fun testNoOnlineSuffixWithoutWebSearch() { val body = OpenRouterClient("key").buildRequestBody(request(listOf(msg("user", "hi")))) - assertEquals("anthropic/claude-sonnet-4.6", body["model"]!!.jsonPrimitive.content) + assertEquals(com.pulseloop.coach.config.OpenRouterModel.DEFAULT.slug, body["model"]!!.jsonPrimitive.content) } @Test From 696a98dd8aa1956dcc55ebfcd6c3f6ac4e303939 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 14 Oct 2026 17:32:13 -0700 Subject: [PATCH 2/5] docs(ios-sync): triage iOS #140 (8833ee0), ported in 52c25e9 --- docs/ios-sync.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index ac7dc25e..798679ba 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -29,10 +29,10 @@ the work list, and assembling one from all three is how items get missed. |---|---| | **Canonical iOS repo** | `github.com/saksham2001/PulseLoopiOS` (always `main`) | | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | -| **Last triaged iOS commit** | `124877f` — Merge PR #101 (simulator launch-arg hardening), 2026-10-02 | -| **Last triage date** | 2026-10-07 — see [2026-10-07 triage](#2026-10-07-triage-since-439ca81--124877f-5-first-parent) | -| **Last port date** | 2026-08-23 — PR #96 nutrition **complete**: barcode scanner + AI meal analysis (`e80c76c`) on top of the OFF client (`a13238d`) and five coach tools (`05d8833`); plus the self-hosted-provider schema fix they exposed (`d3d1371`). 2026-08-22 — #130 RWfit JieLi history (`c9be848`), Workout pause intervals (`71f251e`), PR #94 `CoachNotificationDataTrigger` (`9d43227`), PR #93 hardening (`c95b6e8`) | -| **Range covered** | **2026-10-07:** 5 first-parent items since `439ca81`: PRs #133, #138, #101 + 2 direct commits (`89024c8` version, `21ff323` docs) → 3 PRs leave Android work, none ported yet (see the Outstanding list). **Previously:** 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **all 12 ported** (#130 was backed out as fabricated, then rebuilt from the vendor decompile — see its row below). Verified against a live `git fetch` on 2026-08-23: `origin/main` is `439ca81` and local `main` is 0 commits behind, so upstream is fully triaged and ported | +| **Last triaged iOS commit** | `8833ee0` — Merge PR #140 (coach model catalog, Oct 2026) | +| **Last triage date** | 2026-10-14 — see [2026-10-14 triage](#2026-10-14-triage-since-124877f--8833ee0-1-first-parent) | +| **Last port date** | 2026-10-14 — PR #140 coach model catalog + per-model reasoning levels, plus live per-provider model lists (`52c25e9`). 2026-08-23 — PR #96 nutrition **complete**: barcode scanner + AI meal analysis (`e80c76c`) on top of the OFF client (`a13238d`) and five coach tools (`05d8833`); plus the self-hosted-provider schema fix they exposed (`d3d1371`). 2026-08-22 — #130 RWfit JieLi history (`c9be848`), Workout pause intervals (`71f251e`), PR #94 `CoachNotificationDataTrigger` (`9d43227`), PR #93 hardening (`c95b6e8`) | +| **Range covered** | **2026-10-14:** 1 first-parent item since `124877f`: PR #140 → ported. **2026-10-07:** 5 first-parent items since `439ca81`: PRs #133, #138, #101 + 2 direct commits (`89024c8` version, `21ff323` docs) → 3 PRs leave Android work, none ported yet (see the Outstanding list). **Previously:** 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **all 12 ported** (#130 was backed out as fabricated, then rebuilt from the vendor decompile — see its row below). Verified against a live `git fetch` on 2026-08-23: `origin/main` is `439ca81` and local `main` is 0 commits behind, so upstream is fully triaged and ported | --- @@ -174,6 +174,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☐ | [#133](https://github.com/saksham2001/PulseLoopiOS/pull/133) `83850e1` | 08-31 | Local / self-hosted LLM coach provider — the iOS port *of Android's own* PR #51 (`c6167b8`), so the provider is ALREADY-HAVE. Its follow-up fix `6d006ec` carries three general coach bugs Android still has. | **PARTIAL** (`6d006ec` only) | S×3 | Outstanding row 1 | | ☐ | [#138](https://github.com/saksham2001/PulseLoopiOS/pull/138) `e16c05c` | 09-06 | RWfit initialization, paged history sync, manual readings (32 files, +2.4k) | **ADAPT** (vendor bytes win; see note) | L | Outstanding row 3 | | ☐ | [#101](https://github.com/saksham2001/PulseLoopiOS/pull/101) `124877f` | 10-02 | Simulator launch-arg hardening + usable missing-workout fallback screen | **PARTIAL** (fallback screen only) | S | Outstanding row 2 | +| ☑ | [#140](https://github.com/saksham2001/PulseLoopiOS/pull/140) `8833ee0` | 10-14 | Coach model catalog for GPT-6, Claude 5.5, Gemini 4 + per-model reasoning levels | **ADAPT** (Gemini keeps the `-latest` aliases; Android adds live model lists) | M | `52c25e9` | ## Port priority — open items (as of 2026-08-08) @@ -499,6 +500,12 @@ their own M-sized item and drop to Tier 2/3; only #61d/#61e are Tier-1-sized. - **#79 Activity Year-trends** (S) — blocked: no Activity-trends screen on Android yet (not created by #57's redesign either). - ~~**#74 Measurement-Frequency relocation**~~ ✅ **DONE** `368a3f2` (2026-07-19) — see the session note below. +### 2026-10-14 triage (since `124877f` → `8833ee0`, 1 first-parent) + +| Item | Verdict | Outcome | +|------|---------|---------| +| PR #140 `8833ee0` coach model catalog | ADAPT | Ported in `52c25e9`. Presets (GPT-6 Luna/Sol/Astra; OpenRouter Claude 5.5, GPT-6, Gemini 4 Argon / 3.8 Flash, DeepSeek V4.1; MiniMax without M2.5/M2.7), `ReasoningEffort` with per-model levels and the drop-unsupported rule (`ReasoningEfforts.resolve`, applied in `CoachClientResolver.coachSettings` and meal analysis), Gemini `thinkingConfig.thinkingLevel`, prices. **Differences:** Gemini keeps Android's rolling `-latest` presets (issue #22) instead of iOS's versioned slugs; Gemini `thoughtSignature` echo was already on Android. **Android-only:** `CoachModelCatalog` reads each provider's own model list (OpenAI, Gemini, OpenRouter, MiniMax), flags a stored model the provider no longer lists or is retiring (OpenRouter `expiration_date`), and takes OpenRouter's live `reasoning.supported_efforts` for non-preset models. iOS could adopt the same. | + ### 2026-10-07 triage (since `439ca81` → `124877f`, 5 first-parent) iOS `main` fast-forwarded from `439ca81`; local iOS `main` still carries its one non-upstream From 189fecfe1241803083a437264fe868b89dea47d4 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 14 Oct 2026 17:35:43 -0700 Subject: [PATCH 3/5] fix(coach): review follow-ups on the live model list - A cancelled or superseded model-list fetch could land after a newer one (CancellationException was swallowed into a Failure, and Refresh wasn't cancelled on a key change). One load job at a time now, and cancellation propagates. - Offline / backend-proxy modes send OpenAI requests; they resolve reasoning effort by the OpenAI rules again instead of always dropping it. - Gemini page tokens are URL-encoded. - MiniMax presets are never flagged as unlisted (whether /v1/models lists the -highspeed variants is unverified); a retired M2.5/M2.7 choice still is. --- .../coach/config/CoachClientResolver.kt | 7 +++++- .../coach/config/CoachModelCatalog.kt | 7 +++++- .../coach/config/ReasoningEfforts.kt | 5 +++- .../pulseloop/ui/screens/CoachModelPicker.kt | 25 +++++++++++++------ .../pulseloop/coach/CoachModelCatalogTest.kt | 14 +++++++++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt b/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt index 90ee7c1e..a387093d 100644 --- a/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt +++ b/app/src/main/java/com/pulseloop/coach/config/CoachClientResolver.kt @@ -120,7 +120,12 @@ object CoachClientResolver { fun coachSettings(settings: CoachProviderSettings?, model: String): CoachSettings = CoachSettings(reasoningEffort = settings?.let { resolvedReasoningEffort(it, model) }) - /** The stored effort, or null when [model] doesn't accept it — see [ReasoningEfforts.resolve]. */ + /** + * The stored effort, or null when [model] doesn't accept it — see [ReasoningEfforts.resolve]. + * Best effort for an OpenRouter model outside the presets: its levels come from the in-memory + * model list, so until Settings has fetched it this session, the stored effort is sent as is + * (the pre-#140 behaviour) rather than guessed at. + */ fun resolvedReasoningEffort(settings: CoachProviderSettings, model: String): String? = ReasoningEfforts.resolve( settings.providerMode, model, settings.reasoningEffort, diff --git a/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt b/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt index 6dabb290..1de3d9ee 100644 --- a/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt +++ b/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt @@ -55,6 +55,9 @@ object CoachModelCatalog { if (slug.isEmpty() || listing.find(slug) != null) return false if (provider == CoachProviderMode.USER_GEMINI_KEY && slug.endsWith("-latest")) return false if (provider == CoachProviderMode.USER_OPENAI_KEY && DATED_SNAPSHOT.containsMatchIn(slug)) return false + // Unverified whether MiniMax lists every variant (the -highspeed ones), so only a slug we + // no longer preset — M2.5/M2.7 after their retirement — is flagged there. + if (provider == CoachProviderMode.USER_MINIMAX_KEY && MiniMaxModel.entries.any { it.slug == slug }) return false return true } @@ -89,6 +92,8 @@ object CoachModelCatalog { Result.Success(entries, System.currentTimeMillis()).also { cache[provider] = Cached(apiKey.hashCode(), it) } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: ResponsesError.Http) { Result.Failure( if (e.status == 401 || e.status == 403) "The provider rejected the key (HTTP ${e.status})." @@ -167,7 +172,7 @@ object CoachModelCatalog { var token: String? = null // The listing is a few dozen models; the page cap is only a guard against a looping token. repeat(MAX_GEMINI_PAGES) { - val page = get("$url?pageSize=1000" + (token?.let { "&pageToken=$it" } ?: ""), mapOf("x-goog-api-key" to apiKey)) + val page = get("$url?pageSize=1000" + (token?.let { "&pageToken=" + java.net.URLEncoder.encode(it, "UTF-8") } ?: ""), mapOf("x-goog-api-key" to apiKey)) val (entries, next) = parseGeminiPage(page) all += entries token = next ?: return all.distinctBy { it.id }.sortedBy { it.id } diff --git a/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt b/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt index 98d07831..3a884e78 100644 --- a/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt +++ b/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt @@ -28,7 +28,10 @@ object ReasoningEfforts { GeminiModel.entries.firstOrNull { it.slug == slug }?.reasoningEfforts ?: geminiLevels(slug) // Neither client sends a `reasoning` field (MiniMax reasons inline; see the local // client's header), so there is nothing to choose. - else -> emptyList() + CoachProviderMode.USER_MINIMAX_KEY, CoachProviderMode.LOCAL_OPENAI_COMPAT -> emptyList() + // Offline / backend proxy send OpenAI requests (CoachClientResolver's fallback branch), + // and Settings shows the OpenAI picker for them. + else -> known(CoachProviderMode.USER_OPENAI_KEY, slug, live) } } diff --git a/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt b/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt index 2aa1c140..ffd3596c 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt @@ -36,13 +36,22 @@ fun CoachModelPicker( var listing by remember(provider) { mutableStateOf(null) } var loading by remember(provider) { mutableStateOf(false) } - suspend fun load(force: Boolean) { - if (savedKey.isBlank()) { listing = null; onListing(null); return } - loading = true - val result = CoachModelCatalog.fetch(provider, savedKey, force) - listing = result - onListing(result as? CoachModelCatalog.Result.Success) - loading = false + // One load at a time: a Refresh still in flight when the key changes (or another Refresh) is + // cancelled, so an older key's answer can't land on top of the current one. + var job by remember { mutableStateOf(null) } + fun load(force: Boolean) { + job?.cancel() + if (savedKey.isBlank()) { listing = null; loading = false; onListing(null); return } + job = scope.launch { + loading = true + try { + val result = CoachModelCatalog.fetch(provider, savedKey, force) + listing = result + onListing(result as? CoachModelCatalog.Result.Success) + } finally { + loading = false + } + } } LaunchedEffect(provider, savedKey) { load(force = false) } @@ -119,7 +128,7 @@ fun CoachModelPicker( } Text(status, style = MaterialTheme.typography.bodySmall, color = color, modifier = Modifier.weight(1f)) if (savedKey.isNotBlank() && !loading) { - TextButton(onClick = { scope.launch { load(force = true) } }) { + TextButton(onClick = { load(force = true) }) { Text(if (listing is CoachModelCatalog.Result.Failure) "Retry" else "Refresh") } } diff --git a/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt b/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt index df678577..2d49d967 100644 --- a/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt +++ b/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt @@ -130,6 +130,20 @@ class CoachModelCatalogTest { assertNull(ReasoningEfforts.resolve(gemini, "gemini-flash-latest", "xhigh")) } + /** Review: offline / proxy send OpenAI requests, so they keep the OpenAI rules. */ + @Test + fun `offline and proxy modes resolve like openai`() { + assertEquals("high", ReasoningEfforts.resolve(CoachProviderMode.OFFLINE_STUB, "gpt-6.1-sol", "high")) + assertNull(ReasoningEfforts.resolve(CoachProviderMode.BACKEND_PROXY, "gpt-6.1-sol", "none")) + } + + @Test + fun `minimax presets are never flagged, a retired family is`() { + val listing = CoachModelCatalog.Result.Success(listOf(CoachModelCatalog.Entry("MiniMax-M3")), 0) + assertFalse(CoachModelCatalog.isUnlisted(CoachProviderMode.USER_MINIMAX_KEY, listing, "MiniMax-M2.1-highspeed")) + assertTrue(CoachModelCatalog.isUnlisted(CoachProviderMode.USER_MINIMAX_KEY, listing, "MiniMax-M2.7")) + } + @Test fun `providers that send no reasoning field hide the picker`() { assertTrue(ReasoningEfforts.options(CoachProviderMode.USER_MINIMAX_KEY, "MiniMax-M3").isEmpty()) From 4621312a15c8eb857f9af104dc89275e84ec4539 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 14 Oct 2026 18:02:04 -0700 Subject: [PATCH 4/5] fix(ui): Today activity tile scales its values down instead of clipping them Reported on an unknown build and phone: the Activity tile cut off steps and distance and lost the calories row. The tile already sizes to its content (6be5d1c), but nothing guaranteed it when the grid row ends up shorter than the values column. The column now reports its full height as before, and if it still gets less, it is drawn scaled down to fit (anchored left, centred) rather than clipped. The rings already shrink to the height they get. --- .../com/pulseloop/ui/components/TodayTiles.kt | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pulseloop/ui/components/TodayTiles.kt b/app/src/main/java/com/pulseloop/ui/components/TodayTiles.kt index 48799f58..95ea08d0 100644 --- a/app/src/main/java/com/pulseloop/ui/components/TodayTiles.kt +++ b/app/src/main/java/com/pulseloop/ui/components/TodayTiles.kt @@ -21,7 +21,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle @@ -169,7 +172,13 @@ fun ActivityTile( // text further would only move the breaking point — [TodayTileMetrics.height] now grows // the tile with the font scale instead. Don't re-pin the height to a dp literal. val compact = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) - Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.weight(1f)) { + // Last line of defence against the clipping above, reported again in October 2026 on an + // unknown build and phone (steps and distance cut off, calories missing). The column + // still reports its full height, so the tile grows to fit whenever the grid lets it; + // if the tile ends up shorter anyway, the column is scaled down to fit instead of + // being cut off. The rings already shrink to the height they get. + ScaleDownToFit(Modifier.weight(1f)) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { values.forEach { value -> Column { Text( @@ -194,6 +203,30 @@ fun ActivityTile( } } } + } + } + } +} + +/** + * Lays out [content] at its natural height and, when the incoming height is smaller, draws it + * scaled down (anchored left, centred vertically) so nothing is clipped. Intrinsic measurements + * pass through unchanged, so a parent that asks how tall the content wants to be still gets the + * full answer — scaling only kicks in when that answer wasn't honoured. + */ +@Composable +internal fun ScaleDownToFit(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content, modifier) { measurables, constraints -> + val placeable = measurables.first().measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)) + val maxHeight = if (constraints.hasBoundedHeight) constraints.maxHeight else placeable.height + val height = placeable.height.coerceAtMost(maxHeight).coerceAtLeast(constraints.minHeight) + val scale = if (placeable.height > height && placeable.height > 0) height / placeable.height.toFloat() else 1f + layout(placeable.width, height) { + placeable.placeWithLayer(0, (height - placeable.height) / 2) { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0.5f) + } } } } From 845be65f9db27b3d536160dc450efecf1dee8323 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 14 Oct 2026 18:08:37 -0700 Subject: [PATCH 5/5] fix(coach): a cancelled model-list fetch no longer clears the next one's loading state The superseded job's finally could run after its replacement had set loading, so the picker showed the old status (and a Refresh button) while the new fetch was still in flight. --- .../main/java/com/pulseloop/ui/screens/CoachModelPicker.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt b/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt index ffd3596c..636519f4 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import com.pulseloop.coach.config.CoachModelCatalog import com.pulseloop.coach.config.CoachProviderMode import com.pulseloop.ui.theme.PulseColors +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** @@ -49,7 +50,9 @@ fun CoachModelPicker( listing = result onListing(result as? CoachModelCatalog.Result.Success) } finally { - loading = false + // A cancelled job's finally can run after its replacement set `loading`; only the + // live job clears it, or the replacement's fetch would look finished. + if (isActive) loading = false } } }