Commit Graph

61 Commits

Author SHA1 Message Date
iomgaa 10fbc5441e docs: design how the gateway's refusal survives the transport layer
Issue #10: the 400 body dies in _status_to_error, and telemetry only
writes str(exc), so adding a field alone would not make the refusal
queryable after the fact. Design keeps the summary in both the message
and a new base-class body_text, across every non-2xx branch and both
transports.
2026-08-16 05:03:33 -04:00
iomgaa 2e028d38f2 fix: probe for the telemetry table before creating it
PostgreSQL checks the schema CREATE privilege before the IF NOT EXISTS
existence test, so an account with only table-level INSERT was denied on
CREATE TABLE IF NOT EXISTS even though the table was right there and
writable. The denial set _failed and the whole recorder went no-op for
the process lifetime, silently: 150+ calls downstream lost their latency,
token and cost rows with nothing but one warning to show for it.

The probe is the direct fix. The larger fix is the criterion: structural
degradation now means "provably cannot write" (pool creation failed, or
the table is absent and cannot be created), not "something threw during
init" -- a probe or acquire failure just skips the row and retries on the
next call.

SQLite stays as it is on purpose. Measured: it short-circuits the
statement at parse time, so it passes even under another connection's
EXCLUSIVE lock or on a read-only file. A probe there would buy nothing;
the docstring now says so to keep symmetry-minded future edits away.
2026-08-07 11:21:33 -04:00
iomgaa f3e06eac89 chore: register the issue #8 design and plan in the research wiki
Registration pages carry the chosen approach, why the split is by "which
budget the time consumes", the five rejected alternatives with reasons,
and the 3.6 correction found during independent verification.
2026-08-06 11:09:37 -04:00
iomgaa a0a5cf7ecc fix: return 429 attempt time to the stall budget
Independent verification found the first cut had swapped one bug for a
worse one. The budgets were split by "did we send a request", so a 429
attempt counted as productive — but 429 is exempt from the retry budget,
so its time burned neither budget. Against a queueing gateway that holds
the request for the full timeout before answering 429, a call could hang
for 301 attempts / 25.2 hours, measured, versus 301 seconds before the
change.

The split is now by which budget the time consumes: time that burns
max_attempts is excluded from stall, time that does not (429 attempts
included) belongs to stall. Measured again: back to one attempt / 301s.

Only the chat loop needs this — embedding and ocr count 429 against
max_attempts unconditionally, so the gap never existed there. The stall
verdict moved into _stalled(), which both call sites had duplicated, to
keep __call__ under the complexity gate.
2026-08-06 10:55:51 -04:00
iomgaa 573e505a4b docs: add the implementation plan for issue #8
Six tasks: StallClock plus the chat loop, then embedding, ocr, the config
comments, the full-suite regression with doc sync, and independent
verification. Codex review raised four points, all confirmed and folded in:
a stale line reference in the fidelity section, explicit cancellation
acceptance for T2/T3 (the new attempting() wrapper now wraps their existing
cancel paths), a telemetry-boundary test pinning the design's claim that
telemetry jitter must not feed the stall verdict, and concrete test
construction for the embedding/ocr regressions.
2026-08-06 09:09:31 -04:00
iomgaa ce2dda7d45 docs: sharpen the productive-time boundary after Codex review
Two internal-consistency fixes from the independent design review:
the 429 saturation argument wrongly claimed exponential backoff growth
(429 skips the retry budget, so max(fails, 1) pins the delay to the base
tier), and "productive" was defined as waiting on the response while the
StallClock actually wraps all of _attempt. The boundary is now stated as
_attempt itself, including per-attempt accounting and telemetry, with the
rationale that telemetry jitter must not participate in the stall verdict.
2026-08-06 08:16:07 -04:00
iomgaa bfe423ddf8 docs: bill only non-productive waiting against the stall budget
Issue #8: a single request that burns its full timeout_s also exhausts
stall_window_s, so the retry budget silently never applies. Root cause is
that both budgets charge the same wall-clock time. The design makes the two
budgets orthogonal — real attempts bill the retry budget, everything else
bills the stall budget — which drops the timeout_s / stall_window_s coupling
instead of guarding it with an assembly-time check.
2026-08-06 08:01:07 -04:00
iomgaa 5853c3f8ff fix: keep the accounting path degrading after the wrapper change
Letting SourceNotConfiguredError through the gate wrappers opened a hole
the recheck caught: _record_quietly only degrades GovernanceBackendError,
so an assembly defect raised from the accounting side would now escape and
destroy a response from a call that had already genuinely succeeded. That
inverts the exact invariant _record_quietly exists to hold.

Widening _record_quietly is the right fix rather than narrowing the
wrappers, because that layer degrades by what the path is (accounting, the
call is already done) rather than by which error type shows up. Narrowing
would have left 4 of 9 wrapper methods as exceptions to a rule nobody can
remember.

No backend raises it from an accounting method today, so this is a
guardrail for whoever adds source-name validation to a breaker backend.

The stub that first reported this green was wrong: its record_success
lacked count_attempt, so it raised TypeError and the wrapper relabeled it.
Fixed signature, then the test failed as it should have.

Also finishes the three-to-five leak path correction across the four
remaining spots, including the wiki summary card that indexes this design.
2026-08-06 06:39:52 -04:00
iomgaa a57a5cea72 fix: let assembly defects pierce the gate wrappers
Independent verification caught that the split shipped in the previous
commit did not actually hold on the only path production uses. The gate
wrappers re-raise GovernanceBackendError but nothing else, so
SourceNotConfiguredError fell into the following `except Exception` and
came back out as a governance_backend_down failure with retry_after_s=5.0.
A misconfigured source name would still retry forever and never surface.

The existing tests missed it because both of them call the private _cfg()
directly, one layer below the wrapper the governance loops actually go
through. The regression test goes through QuotaGate.

telemetry.py has to widen its terminal catch in the same commit: once the
wrapper stops relabeling the error, it is no longer a GovernanceBackendError,
and it is raised before any attempt exists, so the path would have recorded
no telemetry at all.

Also corrects the leak path count from three to five. QuotaGate.stats and
BreakerGate.retry_after_s are not wrapped by _record_quietly either.
2026-08-06 05:57:50 -04:00
iomgaa 1fa91cf73d docs: add the implementation plan for issue #7
Five tasks, with the ARCHITECTURE section 6.1 revision first so the code
never contradicts the single source of truth, and the reparenting kept
atomic because scope is a required keyword argument and any split would
leave an unrunnable tree.

The Codex review caught that the planned test evidence pointed at the
wrong stubs: the ones at test_backpressure.py:176-186 cover accounting-side
degradation, not the three gate paths that actually leak to callers, and
try_acquire and try_enter have no stub at all.
2026-08-06 04:11:17 -04:00
iomgaa 3a104fcce4 docs: record human approval of the issue #7 design
All three open decisions were settled as proposed: a dedicated
SourceNotConfiguredError so a misconfigured source reaches the dead
letter queue instead of retrying forever, a 5 second retry_after_s so a
backlog does not stampede a backend that is already down, and public
export so callers can alarm on assembly defects specifically.
2026-08-06 03:56:41 -04:00
iomgaa b1109e9fe9 docs: fold the Codex review into the issue #7 design and register it
Pins the ARCHITECTURE section 6.1 revision to land before or with the
implementation, since the new scope reason contradicts the current single
source of truth. Documents why SourceNotConfiguredError may sit outside
the four-way classification: that rule governs transport-translated call
failures, and the GatewayUnavailableError family already lives outside it.

Also collapses the ten per-field response ternaries in emit_attempt into
an _AttemptUsage view. They all expressed the same decision and pushed the
method to cyclomatic complexity C, which blocked the commit gate.
2026-08-06 03:45:48 -04:00
iomgaa 2f5abb6a55 docs: design the governance backend error reclassification (issue #7)
Fail-closed governance backend failures are semantically scope-level
unavailability, yet GovernanceBackendError sits directly under
PolyGatewayError, so callers writing only `except GatewayUnavailableError`
drop them into the catch-all bucket and burn their failure budget on a
fault that a restart would clear.

The design reparents it under GatewayUnavailableError with a new
governance_backend_down reason, splits the two "unknown source" sites into
a separate assembly-defect error so a misconfiguration still reaches the
dead letter queue, and picks a non-zero retry_after_s to avoid a
zero-delay retry storm against a backend that is already down.
2026-08-06 02:33:06 -04:00
iomgaa a1a9212ba1 docs: state the real downstream impact of the M2.x refusal
The design claimed merging would immediately break dissect. It would
not: dissect keeps running whatever version it already has, and this
release does not touch it. What is true is narrower -- once dissect
moves to 1.0.6, the M2.7 scope will refuse to assemble.

Worth recording because the distinction is not academic here.
dissect/requirements.txt declares polygateway>=1.0.1,<1.1, a range
rather than a pin, so 1.0.6 satisfies it and any routine reinstall picks
it up without anyone deciding to upgrade. So it is not "breaks on
merge", it is "breaks on the next dependency install".

The paragraph now carries both corrections it went through, since a
claim about downstream impact that was wrong twice is worth leaving
visible rather than quietly rewriting.
2026-08-02 08:22:55 -04:00
iomgaa 5eb01a0096 chore: release 1.0.6 and keep the live matrix out of the CI gate
The thinking matrix had been running inside make ci all along, which is
not what the design claimed. It takes seven minutes, spends 137 real
calls, and its criteria are statistical, so a network hiccup fails the
build for reasons unrelated to the change under test -- one run died on
three consecutive network errors exhausting the source.

The project already has the mechanism for this: the slow marker, which
addopts excludes by default and the config comments describe as "CI runs
it on demand". Marking the matrix slow brings make ci back down from
seven minutes to ninety seconds while the matrix stays a merge
requirement via -m slow.

The design also claimed e2e does not run in CI. It does: make test runs
pytest over tests/, e2e included, and the existing smoke tests really
call the gateway whenever .env has credentials. Only slow-marked tests
are excluded. Both documents now say so.

Version sources are pyproject and __init__; a test enforces they agree,
and it caught the second one being missed.
2026-08-02 08:12:01 -04:00
iomgaa 48805cb9fb fix: address the independent verification findings (issue #5, #6)
The verifier caught that the disable-direction evidence only proved "no
regression", not "actually took effect": on M3 the disabled runs and the
no-opinion baseline are identically distributed, because that model does
not reason by default anyway. So the disable runs alone cannot rule out
the very failure mode issue #5 is about -- the parameter being silently
dropped upstream. The bogus-value experiment that does rule it out was
sitting in the findings document instead of the test suite; it is now
case L3b, and the L3 assertion that could never fail is gone.

Also from the review: the e2e helper caught bare Exception, which would
have disguised a library bug as an unavailable source, exactly the
silence the reporting discipline exists to prevent; the unregistered
model warning fired on every request instead of once per source; and the
transport caught ValueError broadly enough to mislabel unrelated errors,
now narrowed to a dedicated ThinkingUnsupportedError.

The design and plan still described the original judgement criteria,
which the measurements had already overturned. Both now match what the
tests actually do, and the design no longer claims the only new failure
surface is the openai one -- dissect configures MiniMax-M2.7 with
ENABLE_THINKING=false and will fail at assembly, which has to be
coordinated before this merges.
2026-08-02 07:40:06 -04:00
iomgaa 781579bf36 docs: record thinking-switch findings and capability design (issue #5, #6)
Findings: live-API measurements across MiniMax M3/M2.7/M2.5, qwen and
deepseek, plus a survey of how nine unified gateways model per-model
parameter divergence. Key facts: reasoning_effort is MiniMax's real
switch, M2.x reasoning is mandatory and cannot be disabled, and the
relay's local token-count fallback silently drops reasoning_tokens.

Design: keep the parameter shape at provider level, push capability
down to model level, split "unknown" / "unsupported" / "no opinion"
into three distinct values, and fail at assembly time when a model
cannot honour enable_thinking=False.
2026-08-02 05:42:05 -04:00
iomgaa b24e224beb docs: soften the non-chat extra_body gate to strip-and-warn
Stripping is load-bearing: without it telemetry would record a sampling
parameter that was never sent on the OCR and embedding paths.
2026-07-31 12:31:31 -04:00
iomgaa 09e77f11f8 docs: register the sampling design in the research wiki 2026-07-31 12:00:20 -04:00
iomgaa 0cc89fb03c docs: harden the sampling design against the reviewer findings
Pin the telemetry column semantics across all three emitter entry points,
add the cross-layer sampling snapshot, and reject extra_body on the
embedding and OCR paths instead of accepting it silently.
2026-07-31 11:57:39 -04:00
iomgaa 20fd899d93 docs: design sampling parameter passthrough (issue #4)
Two-layer entry: per-call overlay on chat() and per-source extra_body.
Covers the cache-key and telemetry interactions the issue omitted.
2026-07-31 11:40:14 -04:00
iomgaa cd1a9520ff docs: spell out that a reported zero is not a missing value 2026-07-31 07:53:42 -04:00
iomgaa 037e7a011e docs: fold the plan review findings into the plan 2026-07-31 07:09:52 -04:00
iomgaa 7ccb25e8f1 docs: record the human approval of the design decisions 2026-07-31 06:54:55 -04:00
iomgaa 42d16919fc docs: register the design in the research wiki 2026-07-31 04:37:52 -04:00
iomgaa 005a90ca19 docs: fold the independent review findings into the design 2026-07-31 04:35:51 -04:00
iomgaa 8a824e2000 docs: design the response observability fields for issue 3 2026-07-31 04:22:51 -04:00
iomgaa 61231f7f6e docs: fold plan review into the est_tokens plan
The reviewer confirmed the T1-T4 ordering holds -- it re-derived every
intermediate state and checked that no construction path can produce
est_tokens=0 with tpm>0 before T4 -- but found four gaps.

Two existing tests go red and the plan never said so: test_types.py:94
asserts the very constraint T4 deletes, and test_embedding.py:105 is a
transport-level case for the fallback T3 rewrites, easy to miss while
looking only at test_openai_compat.py.

The T4 acceptance line claimed all three settlement sides use the
derived value, but the cancel branch never assigns actual and leaves it
at the retry.py:329 initial zero -- an implementer would have "fixed"
a branch the design freezes. Corrected here and in the design section
5 sentence it came from.

USAGE_SOURCES would have landed with no consumer, so T1 now carries the
two value-domain assertions the design asks for, including the one that
pins the no-runtime-validation ruling.
2026-07-30 09:51:01 -04:00
iomgaa 9a8f5cea5a docs: register the est_tokens design in the research wiki
Records the approved option, the four rejected alternatives with their
reasons, the intentionally dropped CHS migration item, and the two
defects the independent review caught. Links the entry to m1-core-design
as a refinement, since that milestone is where est_tokens froze with
both jobs attached.
2026-07-30 05:35:49 -04:00
iomgaa 637ac51754 docs: clear review residue from the est_tokens design
The value-domain table still listed the OCR endpoint as a producer of
"unavailable" while section 3.3 had just decided to keep its "measured"
label -- an implementer following the normative table would have redone
the change that was explicitly dropped, and the guard test would fail.

Also corrects the derivation call-site count to five, qualifies the
retained conservative settlement to the non-dead transient branch only,
and pins the gap metric to "AND cache_hit = false" so cache hits, which
carry cost 0.0 by design, do not inflate it.
2026-07-30 05:13:57 -04:00
iomgaa ac7c86fdee docs: fold independent review into est_tokens design
The reviewer found two real defects. First, changing the usage fallback
to (0, 0) breaks the success-side settlement too, not just the failure
side: retry.py:338 and embedding.py:271 take actual from the same
return value, so a call whose gateway never sends a usage frame would
have its whole pre-deduction refunded -- systematic TPM undercounting.
Added as change item 9. Second, dropping the OCR item: types.py:51 and
ocr.py:9 both state OCR's zero token count is a fact, not an unknown,
so "measured" was already accurate, and relabelling it would pollute
the very metric used to justify the chosen option.

Also pins the cost short-circuit after the cache_hit branch, confines
value-domain enforcement to producers so no bare ValueError escapes
chat(), completes the authoritative-document list, and narrows the
p90 rejection to the read-port argument.
2026-07-30 05:06:00 -04:00
iomgaa fd7d9d330b docs: design est_tokens decoupling from usage fallback
Split the two jobs SourceConfig.est_tokens has been doing: TPM entry
pre-deduction, where conservative means safe, and the telemetry usage
fallback, where feeding a worst-case upper bound through the output
price inflates cost by ~26x.

Records the approved decisions: usage_source gains an "unavailable"
state whose cost is NULL, and an unset est_tokens derives from
tpm//60 so the in-flight ceiling stays scale-invariant. Also declares
the CHS "conservative accounting" migration item as intentionally
dropped, and the pre-existing global-TPM gap as knowingly unfixed.

Refs: gitea issue #2
2026-07-30 04:10:52 -04:00
iomgaa afd6101c08 test: cover the env-key messages left unguarded by mutation testing
Mutation testing showed the negative structured-retries and expected-dim checks
in the env parsing path could be deleted with every test still passing. Their
value is the env key name in the message, so they need tests that assert it.

Changelog now states the real scope of this release and warns that normalising
scope moves the Redis keys, the one change here that silently relocates runtime
state. Records the breaker threshold derivation as deliberately env-only so it
does not resurface as another round.
2026-07-30 02:31:51 -04:00
iomgaa 726f26d8bd fix: normalise scope and blank strings on the construction path too
The verifier found four more env-only behaviours of the same class the branch
was already fixing. The worst is scope: it goes straight into the Redis keys
(pgw:limit:{scope}, pgw:gate:{scope}), so one process using from_env("LLM")
and another constructing scope="LLM" by hand split the rate limit and breaker
state across two namespaces, each tracking its own quota, with no error.

Blank redis_url and pricing_path now collapse to None as from_env has always
done, so they fall into the required-field checks instead of reaching the redis
client as an unparseable URL. EmbeddingSettings gains the __post_init__ it never
had, moving its batch_size and expected_dim checks off the from_env-only path.

Also adds the cache backend whitelist test that mutation testing showed missing.
2026-07-30 02:15:32 -04:00
iomgaa a65b504a3d fix: consolidate remaining assembly validation into GatewaySettings
Round two of the from_env-only validation problem. Fifteen checks still lived
in the env parsing functions: six enum domains, the redis_url requirement for
redis-backed limiter/breaker/cache, cache namespace and TTL, telemetry path and
DSN, non-negative structured retries and non-blank scope. from_settings and
direct construction bypassed all of them.

The five asserts in client.py that claimed config had already validated
redis_url and the telemetry targets now hold on every path, so they revert to
what CLAUDE.md permits: internal invariant declarations that also narrow the
Optional for type checkers. Their comments now name the method that guarantees
them, since the previous wording is exactly what went stale.

Postgres DSNs built by hand now get the SQLAlchemy +driver suffix stripped the
way from_env has always stripped it, with a warning so the rewrite is not
silent. The env path strips earlier, so it stays quiet.
2026-07-30 00:58:33 -04:00
iomgaa 8c9e1179bc docs: design second round of settings validation consolidation
The independent verifier found 15 more checks still living only in from_env:
six enum domains, seven conditional-required pairs and two scalar ranges.
More severe than round one because client.py has five asserts that claim
config already validated the redis_url and telemetry paths, which is false on
the from_settings path.

Also records a normalisation gap the verifier missed: _load_pg_dsn strips the
SQLAlchemy +asyncpg suffix, so a hand-built DSN reaches asyncpg unstripped.
2026-07-30 00:46:44 -04:00
iomgaa b693d442f5 docs: record approval, verification evidence and a follow-up gap
Corrects the changelog claim that the old guard messages only named env keys:
they named fields too, it was the remediation advice that pointed at env keys.

Records the human approval of the design, the mutation-testing evidence from
the independent verifier, and the same-class gap it found: 14 checks (redis_url
presence, telemetry paths, enum validity) still live only in from_env, while
client.py asserts they were already validated. Deliberately out of scope here.
2026-07-30 00:36:59 -04:00
iomgaa 64d0fac879 docs: clarify where the invalid-settings exception is raised
Implementation confirmed that no invalid GatewaySettings instance can exist, so
the factory test's exception fires while evaluating the argument rather than
inside from_settings. Recorded so the test is not misread as the factory
carrying its own validation.
2026-07-30 00:09:47 -04:00
iomgaa f92065bc0b docs: design settings invariant guards on every construction path
Guards for the three cross-field invariants (source timeout vs lease TTL,
stall window vs max TTFT, probe TTL vs slowest timeout) only ran inside
from_env, so the from_settings path could build a GatewaySettings that
violates the class's own documented invariants. Design moves all of them
plus a non-empty sources check into __post_init__ as _validate_* methods,
matching the existing frozen dataclasses in types.py.

Covers two defects left by PR#1: probe_ttl_s was never moved, and an empty
sources tuple leaked a bare 'max() arg is an empty sequence'.
2026-07-29 23:55:47 -04:00
iomgaa ee1bc403ad docs: revise M4 design per independent review (3I/6M) and register wiki entity 2026-07-22 05:12:45 -04:00
iomgaa c95c70b69b docs: add M4 migration verification design 2026-07-22 04:57:54 -04:00
iomgaa dfc013e8ed docs: note 35-sample multipart evidence and archive path 2026-07-21 21:54:33 -04:00
iomgaa 40be3a22ef docs: address M3 OCR design review findings 2026-07-21 21:50:56 -04:00
iomgaa b8f7007d06 docs: add M3 OCR port family design 2026-07-21 21:39:49 -04:00
iomgaa a06761917e fix: address M2.5 verifier findings before merge
AIMD ceiling now respects per-source max_concurrency and the pacer is
assembled explicitly in the client; MIN_CALLS parses as strict int;
acceptance doc corrects source-5 attempt count to 549; design and
migration notes aligned with implemented 429/stall/suppression
semantics and AIMD constants documented.
2026-07-21 21:10:02 -04:00
iomgaa 121888a0eb feat: suppress consecutive-channel opening on evidently healthy sources
Round 8 forensics caught the healthy source circuit-opened by five
random empty completions (~20% ambient failure rate makes a 5-streak
land every ~3000 attempts), blacking out the only good source for 60s.
When the window holds min_calls samples below the failure-rate
threshold, a streak is noise and no longer opens the gate; cold-start
and low-traffic semantics are unchanged and sudden death of a warm
source is still caught by the rate channel.
2026-07-21 15:28:34 -04:00
iomgaa 69968f2e8b feat: exempt 429 pushback from retry budget with stall ceiling
Round 6 hit account-level rate throttling the concurrency AIMD cannot
absorb: at 26 req/min the gateway still returned 16% 429s and each one
burned a third of the retry budget. Retry-After-guided 429s now back
off without consuming attempts (gRPC pushback semantics); the retry
loop gains a per-call ceiling using the same dual-condition stall
verdict as quota-wait (local window exceeded AND no global progress).
2026-07-21 13:19:16 -04:00
iomgaa 6b98a89bb3 feat: default two structured re-asks and sharper AIMD cut
Round 5 left three residual failure classes; ladder exhaustion (3.2%
of structured calls with a single re-ask) and 429 leakage (5.8%, AIMD
oscillating above the sustainable point) are addressable: re-ask
default goes 1 to 2 (conservative vs instructor's 3) and the AIMD cut
factor drops to 0.5.
2026-07-21 12:52:32 -04:00
iomgaa 0e17f71482 feat: gate in-call demotion on credible alternative health
Round 3 showed unconditional yield-after-two-failures pushes the third
attempt onto known-bad sources in heterogeneous pools (83% vs 10%
expected success). OutcomeAwareSelector now exposes health(); a failed
source only yields when some untried candidate scores at least half its
health. Health-blind selectors keep the unconditional rule.
2026-07-21 11:24:52 -04:00
iomgaa 58061c7536 feat: add AIMD adaptive concurrency pacing per source
P6 round 2 showed routing convergence turns account-level 429s into
the binding constraint (62% throttle rate at full concurrency). Each
source now carries a local AIMD limit: multiplicative cut on 429,
additive growth on success. Over-limit picks queue via the existing
quota-wait poll instead of burning retry budget or tripping the
circuit-open verdict.
2026-07-21 10:38:48 -04:00