The 2:25 slow run left exactly one red: kimi-for-coding answers 404
model_not_found because the channel removed it from the account group
between 09:44 (four green probes, correct model_reported) and 15:00. L8
was reading that as "the capability table drifted", which is a statement
about the model the channel no longer serves.
The 404/model_not_found rule already used by T10 now lives in one helper
and is applied on the L1-L9 side too, via the same unreachable fallback:
that one rejection skips and records an uncovered row, every other
RequestRejectedError still bubbles, since those are the real failures
this suite exists to catch.
L9's "unknown shape" sample was the openai profile, which 1.3.3 gave a real
shape (off/on_base/effort_key all set), so the guard had nothing to reject.
It now registers a shapeless provider of its own and tests the mechanism
rather than whichever profile happens to be blank that month.
L8 checks the reported model before judging the capability table: this channel
answers glm-5 / glm-5.1 / glm-5.2 with glm-5.3, which is a routing problem the
library already warns about, not drift. All three are guarded, including the
one that passed by luck.
T10 tells 404 model_not_found (the channel dropped the model) apart from 400
(the tier really is refused), reading the status code and the body's type field
rather than the whole message; only the latter still counts as a conclusion
about a tier. An all-skipped tier list now skips instead of going green.
TestMiniMaxM3 gained the unreachable fallback its own docstring promised: an
outage now skips and leaves an uncovered row, where before it failed ahead of
_record and left no trace of what happened.
The behaviour section described round trips that only ever existed inside the
branch. Verified every claim against `git show 6ec9ec7:<file>` plus a 300-combo
side-by-side run (4 old provider slots x 25 models x three ENABLE_THINKING
states); exactly two differences exist.
- glm-5.3 / glm-5.3-flash / gemini-3.1-pro are newly registered as unable to
stop reasoning. They were unregistered in 1.3.2, so ENABLE_THINKING=false
passed; now it fails at assembly. That is the one change that breaks an
existing config, so it leads the table.
- The openai slot moves from "shape unknown, refuse at assembly" to the standard
OpenAI shapes in both directions. Neither openai nor anthropic nor google ever
injected medium; minimax did and still does, byte for byte.
- kimi-k3 was never registered before, so it changes nothing for anyone. Moved
to new capabilities as a first registration.
- Both keyword and positional ThinkingCapability construction break, not just
positional; spell out the migration form.
.env.example: name all eight registry slots, and say that "on" injects nothing
on the three slots whose on_base is empty.
The transport half of the effort_fallback wiring got a test last round; the
assembly half did not. Mutating _guard_thinking's fallback=source.effort_fallback
to a hardcoded "error" leaves the whole suite green, yet a zhipu/glm-5.3 source
carrying REASONING_EFFORT=medium + EFFORT_FALLBACK=nearest goes from assembling
fine to being refused at assembly. Pin it down: from_env must return a client.
It records the asked-for one. CacheMW sits outside the transport in the
onion, so at lookup time the nearest-mapping has not happened yet and the
applied tier does not exist. Telemetry's success rows do record the mapped
tier, which is where the confusion came from — the warning conflated the
two and would have sent anyone debugging a cache miss the wrong way.
Also repairs the design doc: the 2026-09-05 rollback note had been spliced
into the equivalence table, orphaning its last row, and §3.1 still said
seven tiers after `auto` made it eight.
CHANGELOG gets all five breaking changes, not the one the draft had:
ThinkingCapability's constructor, two ports that grew a parameter with
no default, resolve_thinking's new return type, and ProviderProfile's
single wire field. Behaviour changes get their own section, including
the one that is easy to miss — the openai fallback segment no longer
refuses an unknown shape, so a downstream that parks a foreign model
there and asks for thinking used to fail at assembly and now sends
nothing at all. minimax is called out as the exception it is: the gateway
proved M3 does not think without a parameter, so that segment keeps its
medium and its downstreams see no change this release.
The capability table is reported as it stands — 17 of 24 rows measured,
7 still on documentation, with the reason each one went unmeasured, so
nobody reads "measured" into a row that is a guess. The auto limitation
and its deliberate MiniMax-M3 inconsistency are written down rather than
left for someone to trip over; issue #21 holds the real fix.
ARCHITECTURE had five claims that measurement showed had gone false:
the cache key formula, the field count, the reconcile predicate and its
throttle key, and two field lists. README's FIELD set was missing the
two new keys it calls exhaustive.
docs-convention still opened by announcing a 17-page site that has not
existed since August. It now says what is actually there — one placeholder
page pointing at .env.example, CHANGELOG and the source docstrings — and
says which four files carry the sync gate while the site is down.
The minimax wire lost its tier value on the assumption that these models
reason by default, so injecting nothing still reads as "on". T10 measured
the real gateway and the assumption does not hold: MiniMax-M3 with no
reasoning parameter did not reason in 5 of 5 rounds, while all six
strength values worked. Existing downstreams on ENABLE_THINKING=true
went from reasoning to silently not reasoning, and the capability table
cannot catch it because phase 5 lets auto through unconditionally.
Restore on_base to the old {"reasoning_effort": "medium"} verbatim. This
is a stopgap - it hands the tier choice back to the library, which this
work set out to remove. The real fix is to constrain auto by the
capability table, a public behaviour change tracked as issue #21.
The assertions that said "minimax injects no tier on the on-tier" go
back with it; each carries a note on why it moved twice.
`test_extra_body_overrides_the_profile_slot` guards that a source's
`extra_body` wins over the slot the profile injects. Since T5 made
`enable_thinking=True` mean `auto`, its minimax fixture injected an empty
fragment — swapping the two update calls changed nothing and the whole
suite stayed green, while a real source (zhipu, glm-5.3, tier `low`)
would have shipped `low` instead of the `high` the caller wrote. Move the
fixture onto that combination, where the two sides write the same key.
`emit_attempt`'s docstring promises `reasoning_applies` carries no
default so that a missed argument is a TypeError rather than a silent
`True`; nothing enforced it. Pin the signature the way `record_llm_call`
is already pinned.
`resolve_thinking` is exported, and its third argument turned from `bool`
into `Effort` this cycle — so the most natural downstream call now passes
the `"low"` it read out of JSON or config. Every gate inside compares by
identity, and `"none" is Effort.NONE` is always false: the tier was not
rejected, it was silently misjudged. Phase 2 read the on-form for a
request that wanted the off-form, Phase 4 was skipped entirely on a model
that cannot be disabled, and the only visible symptom arrived much later
as an AttributeError from `.value` — an exception that is neither
documented nor one of the four error classes.
Design 4.4 already lists this as the fourth entry a tier can come in
through; this makes the code agree with it.
Twenty-five columns and not one of them answered "which tier was this?",
so the question the whole issue exists to settle - does a higher tier buy
anything - had no way to group its data.
The three emit entry points deliberately disagree, the way sampling
already does. A successful attempt records what the transport actually
sent: with EFFORT_FALLBACK=nearest a request for medium goes out as low,
and recomputing here would file the row under a tier that never left the
process. A failed attempt has no response to read, so it falls back to
the requested tier - which is exactly right for the tier errors that are
rejected before any HTTP happens, because the rejected tier is the
signal. Cache hits and terminal failures have no chosen source at all,
so a source-level tier is not a thing they could report.
emit_attempt now demands to be told whether the path reasons at all.
Embedding and OCR share the emitter but never send reasoning parameters;
without the flag a source that mistakenly carries ENABLE_THINKING would
hang a tier on a call that could not possibly have run at one.
The value lands as a plain str. StrEnum is a str subclass and asyncpg
promises nothing about encoding subclasses, and a telemetry write that
fails is only a warning - Postgres would just quietly lose the column.
NULL means nobody declared a tier, which is not the same statement as
'none', and the two must never be folded together.
Adding applied_effort to LLMResponse put it through the cache round
trip, where JSON stores a StrEnum as its plain value. Rehydrated raw, a
hit would hand downstream a str while the annotation says Effort, and
every `is Effort.LOW` in the library would quietly answer False on the
hit path only -- the same trap thinking_observation already has a
coercion for.
A value outside this version's vocabulary degrades to None rather than
failing the entry: projects sharing one Redis would otherwise keep
invalidating each other's writes over an attribution field, and None is
the honest reading of a tier this version cannot name.
The transport now hands back the tier it actually sent, and that tier
rides TransportResult into LLMResponse. It is not the requested one:
under EFFORT_FALLBACK=nearest a medium request goes out as low, and
telemetry grouping by the requested tier would file the row under a tier
that never left the process.
Reconciliation judges the same tier instead of the old enable_thinking
bool, and the warning throttle keys on it. Keyed on the bool, every tier
of one model shared a single key, so the second contradiction was
silenced for the lifetime of the transport. The predicate is an identity
check against Effort.NONE on purpose -- the member's value is the
non-empty string "none", so any truthiness test would send every strength
tier down the "asked to disable" branch and invert the alarm.
The absent-tier side has had goldens since issue #4; the side that
actually carries a tier had none, so swapping str() for repr() in the
cache key changed the literal without turning the suite red. Pin both
literals. Note the fingerprint's json.dumps emits the same bytes for an
Effort member and its .value, so that pair is indistinguishable by any
test -- the golden pins the literal itself instead.
Both were wired but unwitnessed: hardcoding the transport's fallback, or
blanking the source tier the assembly guard reads, left the whole unit
suite green. Cover them where the value is visible -- the bytes on the
wire for nearest-vs-error, and the assembly-time refusal that must name
low as the executable alternative.
A provider that registered a disable form but no enable form was told
its shape was unknown and pointed at register_provider -- work it had
already done -- for a request that only ever needed the disable form.
The old bool code took the slot by direction; the tiered rewrite lost
that. Take the relevant field again, and keep "shape unknown" for the
case where both halves are missing, so the "cannot disable" wording
still owns the half-missing case.
Both public assembly paths took the tier on trust: a bare "none" from
JSON or a hand-built SourceConfig stayed a str, and `is Effort.NONE`
then read it as a contradiction and crashed on `.value` while wording
the error -- the caller got an AttributeError where a ValueError was
promised, and on the request side that unclassified exception walked
straight through the transport's ThinkingUnsupportedError catch and the
retry classifier.
Normalize at the two entrances instead, matching what the .env path has
always done, and let EFFORT_FALLBACK be spelled with the same freedom as
its neighbour.
The per-call reasoning tier never reached the cache key, and the model
fingerprint could not stand in for it: the fingerprint is computed once at
assembly time, so two calls on the same client asking for low and max looked
identical to it. Same messages, different tiers, one shared entry -- the
verbatim replay of issue #4's five seeds all hitting the same response.
Source-level tiers join the fingerprint under the same rule enable_thinking
already follows (appended only when the source takes a position), and the
filter that decides which sources enter the mark set is widened to match --
without that, a source configured with nothing but REASONING_EFFORT would
never reach _fingerprint_mark at all.
None (no opinion) and Effort.NONE (asked not to reason) stay distinct keys.
Sources that opine on neither keep byte-identical keys and fingerprints, so
nothing existing cold-starts.
The Transport port took the request apart into five arguments, so a tier
placed on ChatRequest could never reach _build_payload: the field was set,
read by nobody, and silently ignored - the exact shape of failure that sent
downstream to extra_body in the first place.
complete() now takes reasoning_effort with no default, matching the
TelemetryRecorder convention: a default would turn a missing hand-off into
a silent 'no opinion'. All four fakes move with it, since @runtime_checkable
checks method names and not signatures.
EmbeddingTransport and OcrTransport are deliberately left alone - they have
no reasoning semantics - and a test now holds that line.
_build_payload drops its inline sugar conversion for effective_effort(), so
the guard and the hot path share one judgement, and passes the source's
effort_fallback for the same reason.
The three-layer priority (call > source > enable_thinking sugar > silence)
now lives in one pure function, thinking.effective_effort(). The assembly
guard and the request hot path used to each carry their own inline copy of
the sugar conversion; two copies of the same judgement drift into the worst
shape there is - passes at assembly, raises at runtime.
The guard now also honours effort_fallback, so a source that opted into
nearest is no longer sentenced at assembly for a tier it could have mapped.
resolve_thinking now takes an Effort instead of a tri-state bool, and the
four gates become five. The new one sits ahead of the generic tier check
on purpose: asking for `none` on GLM-5.3 used to fall through to "none is
not supported, pick low/high/max", which loses both the fact that the
model cannot stop reasoning and the one tier the caller could switch to
right now. Without that alternative, downstream goes looking for
extra_body — which is how issue #20 happened in the first place.
The return type is a ThinkingResolution rather than the payload alone.
Under fallback="nearest" the tier that goes out is not the tier that was
asked for, and telemetry has to record the one that ran, or task 10 files
a call under a tier it never used. Ties in that mapping go to the weaker
side: a silent medium -> max is a multiple of the bill, and the library
does not raise a caller's price on its own.
Two readings the design left implicit, both settled the way its own
compatibility promise requires:
- `auto` is exempt from the tier list. It means "on, no tier named",
which in the body is the absence of the effort key, not a value of it.
Checking it against the list would break every existing source that
sets ENABLE_THINKING=true against deepseek-v4 or glm-5.3.
- `none` is never a mapping target. Turning "think less" into "do not
think" reverses the decision instead of cheapening it; a switch-only
model maps to `auto` and a model that only has `none` still errors.
Both call sites convert enable_thinking in place for now; task 5 folds
that into effective_effort along with the source- and call-level tiers.
The previous commit added the name to __all__ but never bound it, so
`from polygateway import ThinkingWire` and `import *` both raised while
the whole suite stayed green — the export test names symbols one by one,
and nobody thought to add the new one.
The guard is now the invariant rather than a longer list: every name in
__all__ must be an attribute of the package.
Eight segments now, and each one holds a ThinkingWire instead of two
fixed fragments: off, on_base, and the key a tier gets written to. The
two fragments could not say "on, at this depth", which is what every
current generation model wants.
Two deliberate behaviour changes fall out of it. The openai segment stops
reporting its shape as unknown — reasoning_effort is OpenAI's own field,
not a vendor dialect, so a compatible endpoint behind the gateway takes
it. And minimax's on-tier stops carrying a hardcoded medium: that was the
library picking a price for the caller, and medium is not even a tier GLM,
kimi or deepseek serve.
The issue #5 guards stay; their sample moves from "the openai segment" to
an explicitly registered unknown one, which is what they always meant to test.
The boolean could say a model reasons or does not. It could not say what
GLM-5.3 and Gemini 3 Pro actually do: refuse to stop reasoning while
still letting you ask for less. So capability becomes the list of tiers a
model serves, and `none`'s presence in it is what "can_disable" now reads.
Effort carries `auto` alongside the strength tiers. Nine of the models on
our gateway are pure switches with no tier to name, and without `auto`
they would have to borrow a strength tier to mean "on" — which is the
exact bug this work exists to remove.
Tiers land as documented guesses from four registries that agree; every
entry says so in its evidence, and task 10 replaces them with measurements.
Codex found the per-call tier never reaches the transport: the protocol
takes five unpacked arguments, not the request, so a field on ChatRequest
goes nowhere. That is now its own step, fakes included.
It also found the mapped tier would be lost — resolve_thinking returned
only the payload, so telemetry would file a mapped call under a tier it
never ran at, which is exactly the grouping task 10 depends on.
The entity pages carry the rejected alternatives and why, so the next
reader does not have to reconstruct the argument: minimal patch cannot
fill a tier that does not exist, cherry's wire DSL buys four endpoint
protocols we do not have, and default_effort lost its only consumer the
moment enable_thinking=True became Effort.AUTO.
Ordered so the two type changes land first and everything else consumes
them: capability and wire in parallel, then the five gates, then the two
entry points, then cache key and telemetry, then the transport.
Task 10 exists because the human settled that the capability table is
measured through new-api, not read off a vendor page. Task 1 lands the
documented guess; task 10 replaces it with what the gateway does.
Codex argued `effort_fallback` had no verifiable consumer and could wait.
The human overruled that: swapping models is routine, and between "it
stops booting" and "it silently costs more" the caller deserves a middle
option it selects on purpose. So the mapping ships with this round.