From 8507ebcc52f749fd831128eb6ed92fc85e5207d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:39:41 +0000 Subject: [PATCH] fix(openai): clamp the thinking-off tier onto what the model accepts on the Responses path Official OpenAI's off tier is "none", chosen by endpoint and never checked against the model. A model declaring only low..max (gpt-6-astra) rejects it with HTTP 400, so every compaction died on the parameter rather than its size. Port ThinkingRuleResolver.clampOffEffort from OpenMinis 1.14: the lowest declared tier, never a higher one. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3 --- CHANGELOG.md | 1 + .../openai/OpenAIProviderResponses.kt | 9 ++- .../provider/thinking/ThinkingRuleResolver.kt | 29 +++++++ .../provider/ResponsesOffEffortWireTest.kt | 60 +++++++++++++++ .../provider/thinking/OffEffortClampTest.kt | 77 +++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/android/app/src/test/java/com/openminis/app/provider/ResponsesOffEffortWireTest.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/provider/thinking/OffEffortClampTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb7ff09..1d03d4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This changelog tracks **Minis for Android** as an independently maintained Andro - Device protection (Settings > Permissions, on by default): privileged commands from the agent, `android-root-cli` and the Android tools can no longer write partitions or the system partitions, remove `/data/system`, `/data/misc`, `/metadata` or `/data/adb`, uninstall or disable core system apps, factory-reset or remove user 0. Nothing is asked: the agent gets `DEVICE_PROTECTED: : ` and tries another way. Everything else stays open. Refusals are listed under the switch. When `/data` is nearly full the app freezes its own terminals and refuses new bulk writes until space is freed. `android-root-cli partition read` exports a partition read-only. - Root only: Shizuku (and AXManager / Sui) support is removed; Root is the app's one privileged path. The code before this change is kept on the branch `archive/before-root-only`. - `android-shizuku-cli` is now `android-root-cli`: the same subcommands and JSON, run as root through `su`. Its Agent permission switch carries over. +- Fixed `400 reasoning.effort: 'none' is not supported` on the Responses path with thinking off (for example context compaction on GPT-6 Astra): the "off" value is now clamped to the lowest effort tier the model declares. Ported from OpenMinis 1.14. - The virtual screen runs in a root service (libsu) instead of a Shizuku UserService. Run its compatibility check once more to switch it back on. - Accessibility repair, clearing restricted settings and one-tap authorize use Root; Settings > System has a Root page in place of "Root & Shizuku", and the readiness checklist no longer lists Shizuku. diff --git a/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt b/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt index 2b0a098e..0b2b9ac0 100644 --- a/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt +++ b/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt @@ -11,6 +11,7 @@ import com.openminis.app.data.model.hasImageInput import com.openminis.app.provider.HostedWebSearchPolicy import com.openminis.app.provider.LLMProvider import com.openminis.app.provider.safeOptString +import com.openminis.app.provider.thinking.ThinkingRuleResolver import com.openminis.app.util.Sha256 import java.io.IOException import org.json.JSONArray @@ -175,7 +176,13 @@ internal fun OpenAIProvider.buildResponsesAPIBody( !thinkingLevel.isEnabled && model.supportsReasoning == true && !model.id.lowercase().let { it.contains("mimo") || it.contains("agnes") } -> { explicitOffEffort()?.let { offEffort -> - body.put("reasoning", JSONObject().put("effort", offEffort)) + // [OpenMinis#377] Clamp the off tier onto what this model accepts. + // explicitOffEffort() answers "which vendor is this" (official OpenAI → + // "none"), never "what does this model take": gpt-6-astra declares + // [low…max] and rejects "none", so every compaction (and its halving + // retries) died with HTTP 400 on the parameter rather than the size. + val safe = ThinkingRuleResolver.clampOffEffort(offEffort, model.reasoningEffortValues) + body.put("reasoning", JSONObject().put("effort", safe)) } } } diff --git a/src/android/app/src/main/java/com/openminis/app/provider/thinking/ThinkingRuleResolver.kt b/src/android/app/src/main/java/com/openminis/app/provider/thinking/ThinkingRuleResolver.kt index e55c3685..20885256 100644 --- a/src/android/app/src/main/java/com/openminis/app/provider/thinking/ThinkingRuleResolver.kt +++ b/src/android/app/src/main/java/com/openminis/app/provider/thinking/ThinkingRuleResolver.kt @@ -691,6 +691,35 @@ object ThinkingRuleResolver { fun clampEffortForModel(effort: String, lid: String): String = if (effort == "xhigh" && (lid.contains("mimo") || lid.contains("agnes"))) "high" else effort + /** + * [OpenMinis#377] Snap an OFF-tier value onto what the model will actually accept. + * + * Distinct from [clampEffort], and the difference is the whole point. clampEffort + * walks DOWN then UP, so a `["high","max"]` model turns an OFF request into "high" — + * inverting the user's intent. The right answer for OFF is always the LOWEST tier the + * model declares: the closest thing to "don't think" that the model will accept, and + * never more than a higher explicit level would have sent. + * + * Returns [effort] unchanged when the model declares nothing (or declares the requested + * value), so models without catalog effort data behave exactly as before. + * + * Why it exists: gpt-6-astra declares `[low, medium, high, xhigh, max]` and rejects + * `reasoning.effort:"none"` with HTTP 400. Compaction sends OFF, the official-OpenAI off + * tier is "none", and every compact died on the parameter rather than on its size. + * Ported from OpenMinis 1.14. + */ + fun clampOffEffort(effort: String, values: List?): String { + if (values.isNullOrEmpty()) return effort + if (values.contains(effort)) return effort + val ladder = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max") + // Lowest DECLARED tier by ladder order; unknown spellings are ignored rather than + // guessed at, and if none are recognisable the original value stands. + return values.mapNotNull { v -> ladder.indexOf(v).takeIf { it >= 0 }?.to(v) } + .minByOrNull { it.first } + ?.second + ?: effort + } + /** Snap a requested tier onto the model's declared set, walking DOWN then up. */ fun clampEffort(effort: String, values: List?): String { if (values.isNullOrEmpty()) return effort diff --git a/src/android/app/src/test/java/com/openminis/app/provider/ResponsesOffEffortWireTest.kt b/src/android/app/src/test/java/com/openminis/app/provider/ResponsesOffEffortWireTest.kt new file mode 100644 index 00000000..d62a708e --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/provider/ResponsesOffEffortWireTest.kt @@ -0,0 +1,60 @@ +package com.openminis.app.provider + +import com.openminis.app.data.model.LLMMessage +import com.openminis.app.data.model.LLMModel +import com.openminis.app.data.model.ThinkingLevel +import com.openminis.app.provider.openai.OpenAIProvider +import com.openminis.app.provider.openai.buildResponsesAPIBody +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * [OpenMinis#377] On the Responses path with thinking OFF against official OpenAI, the off tier + * is "none" — which a model declaring only low…max (gpt-6-astra) rejects with HTTP 400. The + * request must carry the lowest tier the model declares instead. + */ +class ResponsesOffEffortWireTest { + + private fun body(declared: List?): JSONObject { + val model = LLMModel( + id = "gpt-6-astra", + displayName = "GPT-6 Astra", + provider = "OpenAI", + supportsReasoning = true, + reasoningEffortValues = declared, + ) + val provider = OpenAIProvider( + apiKey = "k", + model = model, + basePath = "https://api.openai.com/v1", + useResponsesAPI = true, + ) + return provider.buildResponsesAPIBody( + messages = listOf(LLMMessage(LLMMessage.Role.USER, "hi")), + systemPrompt = null, + maxTokens = 1024, + stream = false, + thinkingLevel = ThinkingLevel.OFF, + ) + } + + @Test + fun `a model declaring only low to max gets low instead of none`() { + val effort = body(listOf("low", "medium", "high", "xhigh", "max")) + .getJSONObject("reasoning").getString("effort") + assertEquals("low", effort) + } + + @Test + fun `a model that declares none keeps none`() { + val effort = body(listOf("none", "low", "high")).getJSONObject("reasoning").getString("effort") + assertEquals("none", effort) + } + + @Test + fun `a model with no declared tiers keeps the vendor off tier`() { + val effort = body(null).getJSONObject("reasoning").getString("effort") + assertEquals("none", effort) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/provider/thinking/OffEffortClampTest.kt b/src/android/app/src/test/java/com/openminis/app/provider/thinking/OffEffortClampTest.kt new file mode 100644 index 00000000..621e70b9 --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/provider/thinking/OffEffortClampTest.kt @@ -0,0 +1,77 @@ +package com.openminis.app.provider.thinking + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * [OpenMinis#377] Clamping the OFF tier onto what a model actually accepts. + * + * Reported on Android: Responses API + gpt-6-astra, reasoning effort set to + * `low`, and every compaction died with + * `[400] reasoning.effort: 'none' is not supported` (server listing + * low/medium/high/xhigh/max as the supported set). The off tier is chosen by + * ENDPOINT — official OpenAI → "none" — and was never checked against the + * MODEL, so a model that mandates reasoning received an illegal value. + */ +class OffEffortClampTest { + + private val gpt6Astra = listOf("low", "medium", "high", "xhigh", "max") + + @Test + fun `the reported case snaps none to the lowest supported tier`() { + assertEquals("low", ThinkingRuleResolver.clampOffEffort("none", gpt6Astra)) + } + + @Test + fun `a model that supports none keeps none`() { + assertEquals( + "none", + ThinkingRuleResolver.clampOffEffort("none", listOf("none", "low", "high")), + ) + } + + @Test + fun `no declared values is left exactly as-is`() { + // Zero-regression guarantee: models without catalog effort data must + // behave precisely as they did before this change. + assertEquals("none", ThinkingRuleResolver.clampOffEffort("none", null)) + assertEquals("none", ThinkingRuleResolver.clampOffEffort("none", emptyList())) + assertEquals("minimal", ThinkingRuleResolver.clampOffEffort("minimal", null)) + } + + @Test + fun `OFF never escalates to a high tier`() { + // The hazard that makes clampEffort unusable here: it walks DOWN then + // UP, so a ["high","max"] model turns an OFF request into "high" — + // inverting the user's intent. clampOffEffort picks the LOWEST declared + // tier instead, which is the closest legal value to "do not think". + assertEquals("high", ThinkingRuleResolver.clampEffort("none", listOf("high", "max"))) + assertEquals("high", ThinkingRuleResolver.clampOffEffort("none", listOf("high", "max"))) + // …and where the two differ, clampOffEffort is never the higher one. + assertEquals("low", ThinkingRuleResolver.clampOffEffort("none", listOf("max", "low"))) + } + + @Test + fun `declaration order does not matter`() { + assertEquals("low", ThinkingRuleResolver.clampOffEffort("none", listOf("max", "high", "low"))) + assertEquals("low", ThinkingRuleResolver.clampOffEffort("none", gpt6Astra.reversed())) + } + + @Test + fun `the Ark minimal off-tier clamps too`() { + // Volcano Ark's off tier is "minimal"; a model there declaring only + // low/medium/high must receive "low", not a rejected "minimal". + assertEquals( + "low", + ThinkingRuleResolver.clampOffEffort("minimal", listOf("low", "medium", "high")), + ) + } + + @Test + fun `unrecognisable declarations are not guessed at`() { + // A catalog entry with spellings outside the ladder tells us nothing + // reliable, so the vendor's off tier stands rather than being replaced + // by an arbitrary pick. + assertEquals("none", ThinkingRuleResolver.clampOffEffort("none", listOf("turbo", "ultra-max"))) + } +}