Skip to content

fix(sse): probe one tier down when a reasoning_effort 4xx names no enum - #14895

Merged
diegosouzapw merged 6 commits into
diegosouzapw:release/v3.8.51from
Moseyuh333:fix/reasoning-effort-opaque-400
Sep 29, 2026
Merged

diegosouzapw merged 6 commits into
diegosouzapw:release/v3.8.51from
Moseyuh333:fix/reasoning-effort-opaque-400

Conversation

@Moseyuh333

@Moseyuh333 Moseyuh333 commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • fix(sse): recover from a reasoning_effort 4xx that names no accepted set, instead of 400ing on every attempt.

The reactive reasoning_effort recovery in base.ts only fires when the 4xx body advertises the accepted values (expected one of \low`, `medium`, `high`), which parseReasoningEffortEnumreads. Many gateways refuse an out-of-range value without naming any. Measured live againsthttps://opencode.ai/zen/go/v1/chat/completions` on 2026-09-23, mimo-v2.6-flash and mimo-v2.5-pro answer xhigh/max with an opaque:

{"error":{"param":"","type":"server_error",
 "message":"Streaming response failed: [400] Invalid request parameters"}}

recordLearnedReasoningEffort is explicitly built not to learn from text it cannot map, and supportsXHighEffort() is opt-out, so a model whose registry entry has not caught up is stuck: every request pays a 400 it can never learn its way out of.

When no enum is available, this PR steps down exactly one tier and retries. If that probe is answered, the ceiling is now proven rather than advertised, so the accepted prefix is recorded and every later request clamps up front. A probe that is refused changes nothing — an upstream refusing every tier still surfaces its own error, and no capability is pinned on a guess.

Related Issues

Validation

  • Change type: routing (executor retry path)
  • Focused tests and category gates from the golden path
  • npm run lint — not run: eslint in this checkout's node_modules crashes on import (pre-existing, unrelated to this diff). CI runs it.
  • Reconciled with the current active release base (release/v3.8.51 @ 7a921299c); focused checks rerun afterward
  • Production-code changes include a new or updated automated test in this PR
  • SonarQube is temporarily opt-in while the private project has no quota; it is not a PR gate.

Isolation proof — the new test file dropped onto the unpatched tip (three source files reverted, test file kept) fails; restored, it passes:

source result
unpatched 7a921299c ✖ file-level fail (helpers absent)
patched ✔ 12/12

The first test in the file asserts parseReasoningEffortEnum still returns null for the opaque body, so the suite cannot silently pass against the enum path once a future upstream starts naming its set.

Test runs (DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test …):

suite result
reasoning-effort-opaque-400-probe.test.ts (new) 12/12
reasoning-effort-clamp-and-retry, learned-reasoning-effort-caps, reasoning-effort-learned-capability, reasoning-effort-clamp-direction-consistency, base-executor-sanitize-effort, base-executor-sanitize-effort-max-mapping, base-reasoning-effort-split, effort-tiers-loop-catalog-e2e 130/130
adaptive-effort, adaptive-effort-wiring, adaptive-effort-model-default-13448, provider-field-strips 36/36

tsc --noEmit -p open-sse/tsconfig.json reports 20 errors, all in services/compression/omniglyph* and vendor/codex-chatgpt-web (missing tiktoken / omniglyph type declarations in this checkout's shared node_modules). None are in a file this PR touches, and both packages are already declared in package.json.

Writing the tests caught a real design bug before it left the branch: nextProbeReasoningEffort originally stepped minimal → none, which recordLearnedProbeReasoningEffort cannot record, so a model answering on minimal would have learned nothing. The ladder now stops at low — see below.

Tests Added Or Updated

  • tests/unit/reasoning-effort-opaque-400-probe.test.ts (new, 12 tests) — unit coverage for nextProbeReasoningEffort / recordLearnedProbeReasoningEffort, plus end-to-end coverage of the retry path through BaseExecutor.execute with a mocked fetch: probe-then-learn, proactive clamp on the next request, failed probe learning nothing, no probe at the floor or without an effort, and the reasoning.effort carrier.

Coverage Notes

Touches open-sse/executors/base.ts, open-sse/executors/base/reasoningEffort.ts, open-sse/services/learnedReasoningEffortCaps.ts. The new suite drives the changed branch in base.ts through the real execute() path (same shape as the existing reasoning-effort-clamp-and-retry.test.ts) rather than only unit-testing the helpers, so the if/else wiring and the "probe failed → surface upstream" path are both executed.

Reviewer Notes

  • Cost: one extra upstream round-trip, once per provider+model per process. A request that 400s costs one extra fetch before it succeeds. After that the learned set applies up front. Providers that already advertise their enum never reach this path.
  • The learned set is process-local, like the existing learnedCaps Map — it resets on restart, so the first request after a restart may pay the probe again. This deliberately matches recordLearnedReasoningEffort rather than introducing a second store.
  • The ladder stops at low on purpose. minimal and none are not merely "less thinking" — they are the carriers that switch thinking off. Landing a request there would silently change its intent rather than repair it, so those tiers are not probed and a request already at low is left alone.
  • Learning only happens on a successful probe. A refused probe is not evidence about the model, so nothing is recorded and the probe's own response is surfaced (this is why the "failed probe" test asserts the surfaced status rather than the original one).
  • The probe rewrites only the effort. It reuses the sanitizer's existing carrier logic (readEffortCarriers / writeEffortValue via two new thin exports), so it does not invent a carrier the request did not use and does not reshape anything else. The thinking_budget and context_management 400-retry chains are untouched.
  • No migration, no new env knob, no config surface.

Maintainer rework (merge-batch 2026-09-28 (release drain))

The reactive reasoning_effort recovery in base.ts only fires when the 4xx body
advertises the accepted set ("expected one of `low`, `medium`, `high`"), which
parseReasoningEffortEnum reads. Plenty of gateways refuse an out-of-range value
without naming any: opencode.ai/zen/go answers mimo-v2.6-flash and mimo-v2.5-pro
with an opaque `Streaming response failed: [400] Invalid request parameters`
wrapped in the SSE error frame, and recordLearnedReasoningEffort is explicitly
built not to learn from text it cannot map. So the request 400s on every attempt,
forever, for a model whose registry entry simply has not caught up.

When no enum is available, step down exactly one tier and retry. If that probe is
ANSWERED, the ceiling is now proven rather than advertised, so record the whole
accepted prefix and let every later request clamp up front. A probe that is
refused changes nothing: an upstream refusing every tier still surfaces its own
error, and no capability is pinned on a guess.

The ladder stops at `low` deliberately. `minimal` and `none` are not merely
"less thinking" — they are the carriers that switch thinking off, so landing a
request there would silently change its intent instead of repairing it.

The probe rewrites only the effort, across all three carriers the sanitizer
already knows (top-level reasoning_effort, reasoning.effort, output_config.effort),
and never invents a carrier the request did not use.
Copilot AI lite review requested due to automatic review settings September 26, 2026 15:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical correctness issues and retry/accounting concerns remain unresolved.

Review effort: Lite
Findings: 2 High severity

Open (2)
What changed in this PR

Adds opaque reasoning_effort 4xx recovery by probing a lower tier and caching successful capability results.

Changes:

  • Adds carrier-aware probing and learned-capability helpers.
  • Integrates probe retries into BaseExecutor.
  • Adds focused tests and changelog documentation.
  • Review findings remain around overly broad retries, fingerprint serialization, request accounting, and sparse-tier learning.
File Description
tests/​unit/​reasoning-effort-opaque-400-probe.test.ts Tests probing, learning, failure handling, and carrier preservation.
open-sse/​services/​learnedReasoningEffortCaps.ts Implements probe selection and capability learning.
open-sse/​executors/​base/​reasoningEffort.ts Adds effort carrier read/write helpers.
open-sse/​executors/​base.ts Implements opaque-error probing and retry logic.
changelog.d/​fixes/​14895-reasoning-effort-opaque-400-probe.md Documents the fix.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread open-sse/executors/base.ts Outdated
Comment on lines +1613 to +1617
} else {
// No enum in the 4xx body — the upstream told us nothing we can
// record. Step down one tier and retry; if that retry is accepted we
// have PROOF of the ceiling (unlike the enum path, which trusts the
// upstream's own list) and can learn it for every later request.
Comment on lines +240 to +244
return recordLearnedReasoningEffort(
provider,
model,
REASONING_EFFORT_ORDER.slice(start, idx + 1)
);
Moseyuh333 and others added 4 commits September 28, 2026 00:33
…he probed tier

Two review points, both correct.

An opaque 4xx names no accepted set, so nothing in the body says the *effort*
was the cause — it is indistinguishable from an unrelated validation failure
(context too long, a malformed tool schema, a bad image). Running the probe for
every such 400 re-POSTs the real failure with a different body, and if the
upstream happens to answer 2xx, that masks the original error and records a
reasoning cap nothing was ever proven against, permanently lowering quality for
that provider/model in this process. The probe is now opt-in per provider via
OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS (comma-separated ids, or *), default
off. Upstreams that name their accepted set keep the unflagged enum path.

A successful probe proves one value and says nothing about the tiers below it: a
model can answer `high` and still refuse `low`, and the learned-cap tests already
cover sparse accepted sets such as {high,max}. Recording the whole
low..probed slice let later low/medium requests bypass clamping and go out
unproven. Only the probed tier is recorded now, which is the conservative
direction — the clamp maps anything higher onto a value the upstream has
acknowledged and leaves anything lower alone.
# Conflicts:
#	.env.example
#	open-sse/executors/base.ts
…ningEffortRecovery

diegosouzapw#14629 moved the clamp-and-retry chain out of BaseExecutor.execute() so
executors that never call super.execute() (commandCode, cliproxyapi, glm)
reach it. The probe now lives in that shared helper instead of the removed
inline block, so every caller gets it. Still opt-in per provider via
OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS (off by default), now documented
in ENVIRONMENT.md. Adds helper-level tests for a direct caller.

Co-authored-by: Moseyuh333 <Moseyuh333@users.noreply.github.com>
@diegosouzapw
diegosouzapw merged commit e9993e5 into diegosouzapw:release/v3.8.51 Sep 29, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants