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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <category>: <target>` 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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>?): 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>?): String {
if (values.isNullOrEmpty()) return effort
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>?): 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)
}
}
Original file line number Diff line number Diff line change
@@ -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")))
}
}
Loading