Skip to content

opencode: the thinking-effort control offers a fixed low/medium/high set, but opencode variants are per model and an unsupported value is silently ignored #30

Open

Description

@andromarces

Summary

OPENCODE_THINKING_LEVELS declares low, medium, high for every opencode model. opencode computes variants per model. On the test host, 15 of 64 models declare no variants at all and 12 more declare variants without medium. opencode accepts an unsupported or unknown --variant value, ignores it, and completes the turn with exit 0, so the control silently does nothing and the operator gets no signal.

Variants that models do declare, including minimal, xhigh, max, none and thinking, are unreachable from the control.

Environment

  • codor 0.10.14-alpha.2 at 03481a3
  • opencode 0.0.0-dev-202609102034, installed through pnpm global
  • macOS (Darwin 27.0.0), Node 26.8.1
  • 64 models on the test host, from providers opencode, opencode-go, and github-copilot
  • Measurements and model probes are from this one macOS build. The affected source paths carry no platform branch, so the defect is expected to be platform-independent, but that is an inference from the source rather than a measurement.

packages/adapters/opencode/NOTES.md records opencode 1.17.14. The measurements below come from the dev build named above.

Reproduction

  1. Run opencode models --verbose. Each model carries a variants object.
  2. In codor, select an opencode model whose variants object lacks medium, or is empty.
  3. Set the Thinking effort slider to medium and send a turn.

Observed

The turn runs normally. Exit code 0, no warning, no change in behavior.

Direct check against a model that declares variants: {}:

opencode run --format json --model opencode/big-pickle --variant zzzbogus "Reply PONG only."
# -> step_start, text "PONG", step_finish, exit 0

An unknown variant name is accepted and dropped in silence.

The flag does work where the model declares the variant. The two runs below use a second model, opencode/muse-spark-1.3-contributor-free, which declares minimal, low, medium, high and xhigh, and hold one prompt constant across both:

--variant reasoning tokens
minimal 15
xhigh 409

Variant sets on the test host

Across 64 models, from the variants object in opencode models --verbose:

Variant Models offering it
high 45
low 44
medium 37
xhigh 25
max 23
none 10
minimal 6
thinking 1
  • 15 models declare variants: {}. All three offered positions are inert on those models.
  • 12 more declare variants without medium: every opencode-go/deepseek-*, glm-5.2, glm-5.3, glm-5.3-flash, hy3, hy4-preview, kimi-k3, minimax-m3, and github-copilot/kimi-k3.
  • opencode-go/deepseek-v4-pro declares only high and max. Two of the three offered levels are invalid, and max is unreachable.

Upstream packages/opencode/src/provider/transform.ts derives these per model. variants(model) returns {} when the model has no reasoning capability, and otherwise branches on provider, model id and SDK. Examples from that file: anthropicAdaptiveEfforts returns ["low","medium","high","xhigh","max"] or ["low","medium","high","max"] depending on the model; googleThinkingLevelEfforts returns ["low","high"], ["minimal","high"], ["high"], ["minimal","low","medium","high"] or ["low","medium","high"] depending on the model id; a minimax-m3 branch returns {none, thinking}.

The models documentation additionally documents operator-defined custom variants with arbitrary names, so no fixed list can be right for every install.

A variant is not a thinking level

This matters for the shape of the fix. variants(model) in transform.ts returns Record<string, Record<string, any>> — a named bundle of model parameters, not a point on an effort scale. The reasoning bundles happen to be named low, medium, high, xhigh, max, minimal, none, which is why they map onto a slider at all. The documented custom-variant example sets reasoningEffort together with textVerbosity, and disables a variant with fast: { disabled: true }. An operator-defined variant can carry anything and can be named anything.

So "expose every variant the model declares on the Thinking effort slider" is the wrong target. An arbitrary preset does not belong on an effort control.

Expected

Matching variant names against a list of recognized effort names is not a safe contract. The documentation states that an operator "can override existing variants or add your own", so a config can redefine low to carry any option bundle. A name match does not prove reasoning semantics.

That leaves two contracts that hold:

  • Separate control. Thinking effort is not offered for opencode. A distinct Model variant control exposes the selected model's declared variant names verbatim, with no claim about what each one does.
  • Classified payloads. codor inspects each variant's option bundle — opencode models --verbose returns it in full, for example {"low": {"reasoningEffort": "low"}} — and admits to the effort control only those it can classify as reasoning controls. This keeps the slider but requires codor to own that classification and to keep it current as opencode adds provider branches.

Either way, the control must not offer a value the selected model does not declare.

Cause

packages/adapters/opencode/src/adapter.ts:22-27

export const OPENCODE_THINKING_LEVELS = [
  'low',
  'medium',
  'high',
] as const satisfies readonly ThinkingLevel[];

That constant feeds capabilities.thinking_levels, which the UI reads through thinkingLevelsFor (packages/web-next/src/room/agent-spec.ts:92-96) to build the slider stops. openCodeArgs (adapter.ts:55-59) passes the selected value to --variant unchanged, and assertThinkingLevel rejects anything outside the constant.

The argv is correct and the slider default position sends no flag. The defect is the fixed set, and the fact that the capability model has no place to put a per-model one.

The adapter's own comment at adapter.ts:116-117 already makes the argument for the model catalog — "no fixed list can be right for every install — ask the CLI". Variants come from the same command and are subject to the same constraint. NOTES.md states "Variant availability is provider/model-dependent" and then declares a fixed set anyway.

Suggested fix

The correction is larger than one constant, so the scope is worth agreeing before a PR.

  • opencode models --verbose already returns the per-model variants object. On the test host it costs 1.20 s against 0.90 s for the plain form, and emits 80 KB for 64 models. maxBuffer is currently 1 MB (adapter.ts:124); a large install can exceed that.
  • ModelCatalog (packages/protocol/src/adapter.ts:156-159) carries models: string[] with no per-model metadata. Either contract needs per-model variant metadata to travel with the catalog, which is a new field rather than a change to thinking_levels.
  • thinking_levels (adapter.ts:122) is adapter-wide and stays that way under the separate-control design; the per-model set lives in the new model metadata, and the variant a member selects needs its own field rather than reusing thinking.
  • ThinkingLevelSchema (packages/protocol/src/adapter.ts:13-19) admits low, medium, high, xhigh, max, ultra, ultracode. The observed variants minimal, none and thinking are outside it. Under the separate-control design a variant name is free-form and cannot pass this enum at all. Under the classified-payload design the enum decides what the effort control may carry, and minimal and none would have to be added.

Acceptance the fix has to cover

Four cases that the current code does not handle, and that a model-aware catalog alone does not fix:

  1. Changing the model does not reconcile the level. Every model handler in AgentControls.tsx (lines 295, 320, 333, 346) calls set({ model }) and leaves thinking untouched. The configure path does the same server-side: settled(changes.thinking, member.thinking) at daemon.ts:2321. Moving from a model that declares medium to one that does not leaves medium selected.
  2. The Default model position has no model. set({ model: '' }) at AgentControls.tsx:320 sends no --model, so opencode runs its own configured default and codor cannot know which variants apply. The control needs defined behavior for that position.
  3. Server validation is adapter-wide. validateSpawnOptions (packages/switchboard/src/adapter-registry.ts:176-184) checks thinking against adapter.capabilities.thinking_levels and never against the selected model. It is called from six sites in daemon.ts (lines 1300, 1793, 2327, 2799, 7341, 7418), so a per-model rule has to land there, not only in the dialog. Validation needs the model and variant pair.
  4. Custom variant names, if codor exposes them at all, are free-form strings and cannot pass a fixed enum.

One smaller change is correct, though it removes a feature rather than fixing it: declare thinking: false for the opencode adapter, so no effort control is offered at all. That is honest — the current control cannot tell the operator whether the value it sends will be used — and it is reversible once the per-model work lands.

What is not correct is widening OPENCODE_THINKING_LEVELS to a larger fixed union. That would still offer levels the selected model rejects, would still exclude custom variants, and would need schema changes for minimal and none. It trades one wrong fixed list for a larger wrong fixed list.

Intended behavior change for harn:assume harness-declares-supported-thinking-levels: codor stops offering opencode a fixed effort set that the selected model may not declare. The exact replacement depends on which of the two contracts above the maintainers choose, so the assumption's new wording is theirs to fix, not stated here. Harn hashes not edited; described here per CONTRIBUTING.

Note for NOTES.md

packages/adapters/opencode/NOTES.md states that the docs and a no-spend help probe "did not establish unsupported-model behavior, so a native rejection is reported as an ordinary failed turn". That is an accurate record of an open question, not an error. The probe above answers it: there is no native rejection. An unsupported variant is accepted and ignored, so the failure is silent and codor cannot surface it as a failed turn.

Test gap

packages/adapters/opencode/src/adapter.spec.ts asserts the exact --variant argument for low, medium and high. It cannot catch a level the selected model does not accept, because the fake opencode models fixture carries no variant metadata. Nothing currently covers the four acceptance cases above either.

A model-aware fix needs a fixture in the --verbose shape, plus one check per acceptance case:

  1. A model declaring variants: {} offers no stops.
  2. Changing the selected model to one that does not declare the current value reconciles or clears it, rather than leaving it set.
  3. The Default model position has whatever behavior the fix defines, asserted explicitly.
  4. validateSpawnOptions rejects a model and variant pair the model does not declare, not merely a value outside the adapter-wide list.

Workaround

Check opencode models --verbose for the chosen model before trusting the control. Leave it at Default where the model declares no matching variant.

Activity

  1. andromarces commented on Sep 13, 2026

    @andromarces
    ContributorAuthor

    Related work in #32 (draft): OpenCode thinking is restored as fixed levels plus a bounded custom variant entry, with per-line argv and v2 default-model resolution. Per-model validation as requested here is explicitly out of scope — Codor does not check a value against the selected model (v1 ignores unknown variants silently, v2 fails the turn natively). That validation is still open after #32 lands. Refs #32.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions