Commit Graph

402 Commits

Author SHA1 Message Date
iomgaa 3eb22d2a55 test: fix structured reask evidence and live coverage conclusions 2026-09-09 03:34:48 -04:00
iomgaa d332287b28 docs: document reasoning ownership and explicit cache migration 2026-09-09 02:41:25 -04:00
iomgaa 73008ad7d5 test: apply evidence-based live checks without hiding regressions 2026-09-09 02:40:13 -04:00
iomgaa 16fa0ca474 docs: record deterministic reasoning contract validation 2026-09-09 01:39:13 -04:00
iomgaa c710c3a7ec fix: explain explicit auto migration and verify probe cleanup 2026-09-09 01:37:07 -04:00
iomgaa a0a33c0c01 test: guard custom reasoning roots at the transport boundary 2026-09-09 01:35:14 -04:00
iomgaa 47488ee4fd test: guard reasoning-free telemetry through real client paths 2026-09-09 01:34:36 -04:00
iomgaa d0078c1be5 test: pin explicit cache migration and reasoning row semantics 2026-09-09 01:34:33 -04:00
iomgaa 71f1bdf26b test: isolate factory checks from developer proxy settings 2026-09-09 01:27:25 -04:00
iomgaa 8e61a66342 fix: reject conflicting raw reasoning overrides before sending 2026-09-09 01:26:34 -04:00
iomgaa 1ee74c35a8 fix: validate ownership of managed reasoning parameters 2026-09-09 01:24:54 -04:00
iomgaa 4ed144c9e4 fix: enforce registered auto reasoning capabilities 2026-09-09 01:22:18 -04:00
iomgaa dda55567ae docs: register thinking contracts and record baseline checks 2026-09-09 00:48:57 -04:00
iomgaa 2553fc7f34 docs: record approved thinking contracts and implementation plan 2026-09-09 00:48:31 -04:00
iomgaa 6a090541be test: skip L8 when the channel drops the model instead of failing
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.
2026-09-05 18:51:22 -04:00
iomgaa 758a127f06 test: stop reading channel outages as library defects in live e2e
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.
2026-09-05 16:24:24 -04:00
iomgaa a716f12483 Merge branch 'feat/issue-20-reasoning-effort'
推理从「开 / 关」升级为档位(issue #20)。八档 Effort 词汇、源级与请求级两个入口、
能力表按档位登记并经真实网关实测校正、缓存 key 与遥测各加一维。

issue #20 抱怨的三道机制全部恢复: 能力表查得到、装配期报错触发且带可执行替代、
运行期对账成立。核心争议由 T10 的约 500 次真实调用了结——glm-5.3 确实关不掉推理,
issue 里那个 rt≈1.2 是短提示词下的采样噪声。
v1.3.3
2026-09-05 12:27:38 -04:00
iomgaa 9021425875 docs: measure the 1.3.3 notes against 1.3.2, not against this branch
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.
2026-09-05 12:25:24 -04:00
iomgaa bb9ef038c7 test: make the assembly guard fail when it forgets the source's fallback
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.
2026-09-05 12:20:31 -04:00
iomgaa 85892fb1b5 fix: stop telling people the cache key records the mapped tier
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.
2026-09-05 11:30:52 -04:00
iomgaa e9607b2f0c docs: cut 1.3.3 notes for the tier work
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.
2026-09-05 11:22:01 -04:00
iomgaa a194f4326e fix: put the tier back where "on" by itself is not on
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.
2026-09-05 10:48:08 -04:00
iomgaa f5e6fafe8d test: replace the guessed tier table with what the gateway actually does
T10 经 new-api 中转对 26 个可调用模型逐个实测(约 500 次真实调用),把
DEFAULT_CAPABILITIES 里的文档推定换成实测结论,并把实测本身固化成可复跑、
可只跑单模型的 slow 用例。

三处与文档推定不符:
- kimi-k3 实测可关(关闭档 completion 恒 9 且与 max 档锚点可分),T1 的保守
  登记被推翻,OpenRouter 的 mandatory:false 胜出;
- MiniMax-M3 的 auto 实测不推理(minimax 的"开"是不注入任何参数,而 M3 默认档
  本就不推理),六个强度值反而全部生效且彼此等价,故清单由 (none, auto) 换成
  none + 六个强度值;
- glm-5.3 关不掉推理坐实(请求 none 后 4/5 轮仍观测到推理),issue #20 的核心
  争议就此了结——当时短提示词下的 rt≈1.2 是采样噪声。

新增 kimi-for-coding(现有该型号自己的实测证据,不再走 Phase 3)。

用例带三条判据: 关闭方向要求每轮未观测到推理;短提示词的"关掉了"必须过长
上下文复核(glm-5.3-flash 正是短 5/5 未观测、长上下文 2/3 露馅);上游整片
不回传推理信号时另取无魔数锚点,不许把"没看见"写成"没发生"。另加一道模型
身份核对——该渠道把 glm-5/5.1/5.2 全部路由到 glm-5.3,那三组数据一律作废。

未覆盖 9 个型号(claude 三个 7 天限额用尽、gemini 两个上游报错、gpt-5.4 限流、
glm 三个被路由),evidence 逐条写明原因,不得被后续文档写成"已实测"。

单元测试里三条以 MiniMax-M3 当"纯开关型"样本的用例改用 glm-4.6v——实测后
M3 不再是那个形状,而 glm-4.6v 是全表证据最硬的 (none, auto)。
2026-09-05 10:35:30 -04:00
iomgaa f9b357b9d7 test: give two silent guards something to fail on again
`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.
2026-09-05 06:54:40 -04:00
iomgaa 4866e6b858 fix: normalise a bare tier string at the fourth door
`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.
2026-09-05 06:49:34 -04:00
iomgaa 32b92a8894 docs: count the breaking changes again, and say where the fourth door is 2026-09-05 06:37:57 -04:00
iomgaa e06cd8e8b7 feat: record which tier a call actually ran at
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.
2026-09-05 05:57:29 -04:00
iomgaa 9832dcee63 docs: name the three doors a tier can enter through, before a fourth appears 2026-09-05 05:12:26 -04:00
iomgaa bd9da4c911 fix: bring a cached tier back as a tier, not as a bare string
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.
2026-09-05 05:05:28 -04:00
iomgaa 848dc0aa7f feat: wire the tier through the transport and keep each tier's warning distinct
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.
2026-09-05 05:00:29 -04:00
iomgaa 5dfb15e6a2 docs: point the phase 2 row at the predicate it now delegates to 2026-09-05 04:38:10 -04:00
iomgaa d1b3563183 test: golden the key and fingerprint literals for a declared tier
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.
2026-09-05 04:27:39 -04:00
iomgaa 468af53f51 test: pin the tier fallback and the assembly guard to real behaviour
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.
2026-09-05 04:21:32 -04:00
iomgaa 81a901144e fix: judge the phase 2 shape by the tier that was asked for
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.
2026-09-05 04:14:29 -04:00
iomgaa 1a35d515d9 fix: read a tier the way every config path actually spells it
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.
2026-09-05 04:07:06 -04:00
iomgaa 701a8a6841 docs: fix the phase 2 predicate that would misdirect a custom provider 2026-09-05 03:49:21 -04:00
iomgaa 78a578bf44 docs: say why the cache key uses the asked-for tier, not the mapped one 2026-09-05 02:45:57 -04:00
iomgaa 33c8e8274b fix: keep a low-tier answer out of the cache slot a max-tier one filled
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.
2026-09-05 02:39:38 -04:00
iomgaa 80a8013642 feat: carry the per-call tier down to the transport that must send it
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.
2026-09-05 02:28:24 -04:00
iomgaa 1f13eb18ab feat: let one call ask for a different tier than its source defaults to
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.
2026-09-05 02:15:25 -04:00
iomgaa 603a835f60 feat: let a source name its reasoning tier, and say so when it contradicts itself 2026-09-05 01:56:24 -04:00
iomgaa ed563b9ca0 docs: let the tie rule stand over the worked example that contradicted it 2026-09-05 01:43:22 -04:00
iomgaa a1c4273a8b feat: refuse an impossible tier with the cheapest one that model does have
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.
2026-09-05 01:36:30 -04:00
iomgaa 84230673b9 fix: import the ThinkingWire that __all__ already promised
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.
2026-09-05 01:17:12 -04:00
iomgaa 3cb5331950 docs: correct the equivalence claim T2 disproved 2026-09-05 00:48:01 -04:00
iomgaa 7fabc792b2 feat: give zhipu, moonshot, anthropic and google a wire of their own
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.
2026-09-05 00:42:58 -04:00
iomgaa 2a50ddcf12 refactor: make capability a tier list, since "can it be off" is one entry
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.
2026-09-05 00:31:28 -04:00
iomgaa de261e485d docs: fix the three places the plan could not actually execute
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.
2026-09-05 00:21:29 -04:00
iomgaa e01420178f docs: register the design and plan in the wiki, with what got rejected
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.
2026-09-05 00:12:55 -04:00
iomgaa abeb09f588 docs: plan the tier work as ten steps that each stand on their own
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.
2026-09-05 00:06:25 -04:00