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..a387093d 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,24 @@ 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]. + * 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, + 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..1de3d9ee --- /dev/null +++ b/app/src/main/java/com/pulseloop/coach/config/CoachModelCatalog.kt @@ -0,0 +1,224 @@ +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 + // 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 + } + + /** 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: 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})." + 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=" + 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 } + } + 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..3a884e78 --- /dev/null +++ b/app/src/main/java/com/pulseloop/coach/config/ReasoningEfforts.kt @@ -0,0 +1,64 @@ +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. + 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) + } + } + + /** 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/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) + } } } } 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..636519f4 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ui/screens/CoachModelPicker.kt @@ -0,0 +1,166 @@ +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.isActive +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) } + + // 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 { + // 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 + } + } + } + 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 = { 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..2d49d967 --- /dev/null +++ b/app/src/test/java/com/pulseloop/coach/CoachModelCatalogTest.kt @@ -0,0 +1,152 @@ +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")) + } + + /** 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()) + 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 diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 6337c174..cbe848bd 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 | --- @@ -172,6 +172,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 | `cedb1df` (follow-up `6d006ec`; provider already Android PR #51) | | ☐ | [#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 | `cedb1df` (fallback screen) | +| ☑ | [#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) @@ -497,6 +498,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