Commit Graph

434 Commits

Author SHA1 Message Date
iomgaa 9f7d407120 fix: skip hedge dispatch when primary finishes during admission
Address branch review findings 1-4 on feature/1.3.7-hedged-requests:

- src/polygateway/middleware/retry.py: recheck primary.done() after
  hedge admission in _attempt_hedged; release the hedge permit via
  settle_and_release(permit, 0) (release_probe for probe entries) and
  adjudicate the primary directly instead of firing a billable HTTP
  request that would be cancelled immediately
- src/polygateway/config.py: check_hedge_assembly raises a hedge-located
  ValueError for empty sources instead of a bare min() error
- tests/unit/test_hedge.py: pin that an injected FakeClock jump of 10^6
  seconds does not trigger hedging (design section 8); pin pick(exclude)
  counting no gate_rejections and leaving reasons untouched; pin silent
  hedge abandonment when the candidate circuit is open; deterministic
  regression for the admission-window race (BlockingLimiter harness)
- tests/unit/test_config.py: assert the empty-sources guard message
  locates the hedge key

Red-to-green evidence in tests/outputs/137/review-fixes/
2026-09-10 14:47:40 -04:00
iomgaa 0572611af7 docs: document hedged requests and bare generation time 2026-09-10 14:12:30 -04:00
iomgaa fe616cf91d docs: add approved hedged requests design and implementation plan 2026-09-10 14:04:31 -04:00
iomgaa adc069447a feat: add opt-in cross-source hedged requests for chat 2026-09-10 14:03:25 -04:00
iomgaa 463eca380d feat: expose bare generation time and hedge flags in CallStats
CallStats gains hedges/generation_ms/hedge_won (all defaulted, appended
after total_latency_ms); _CallContext counts them via record_generation
(overwrite for chat/OCR, accumulate for embedding batches) and
register_hedge, and snapshot carries them out. All three _attempt
implementations time only the transport call itself on the same injected
clock as total_latency_ms; the chat sink is recorded by the orchestrator
so hedge winner attribution stays with T3. Hedge counters stay 0/False
until the T3 orchestration lands.

Red-green evidence: tests/outputs/137/t2/ (10 new tests AttributeError
red, then green; full unit+contracts 1621 passed).
2026-09-10 13:28:51 -04:00
iomgaa 0a6d6225db feat: add a required first-token event to the transport port
Transport.complete gains the keyword-only first_token_event (no default,
per the port convention): streaming sets it on the first delta, the
non-streaming path accepts it but never sets it, None means the caller
does not observe the first token. All fake/wrapping transports and the
three direct call sites follow the signature; the e2e wrapper forwards.

Red-green evidence: tests/outputs/137/t1/ (batch A TypeError red, then
147 file tests + 1550 unit tests green).
2026-09-10 13:16:38 -04:00
iomgaa 166b2865d0 docs: record 1.3.6 release gate results and matrix exemption v1.3.6 2026-09-10 11:31:27 -04:00
iomgaa b0ab39edbf docs: scope the slow release gate by diff intersection 2026-09-10 11:30:40 -04:00
iomgaa a07a6b096b merge: release 1.3.6 call deadline and cancellation settlement 2026-09-10 05:22:35 -04:00
iomgaa 53d2f089e1 chore: release 1.3.6 2026-09-10 05:22:24 -04:00
iomgaa 5e2c35a812 test: pin the expiry window and close settlement review gaps 2026-09-10 05:20:56 -04:00
iomgaa b1bc06e2b1 docs: document the optional call deadline and cancellation settlement
The deadline governs waiting, not the moment a call returns: cleanup
still runs in finally, so the return time is the deadline plus the
cleanup cost (5-7x the deadline in the measured fixture). An expiry
therefore does not mean nothing was produced or nothing was billed.
Leaving the key unset keeps 1.3.5 semantics verbatim, which also keeps
its two long waits: a pure 429 sequence can still wait for a long time,
and a large finite Retry-After is still slept in full because the
library deliberately does not clamp the hint with backoff_max_s.

- CHANGELOG unreleased section states those three sentences, records the
  cancellation settlement change (an attempt cancelled after the port
  started but before the settlement is known keeps its reservation at
  the source estimate: over-charge rather than refund something the
  upstream may already have billed; known settlements and unclassified
  escapes are untouched) and warns that except GatewayUnavailableError
  does not catch CallDeadlineExceeded
- README in four places: the capability table, the exception handling
  example, the "which exceptions reach the caller" table and the error
  model section, which now spells out the remaining large-but-finite
  Retry-After wait
- .env.example documents LLM__CALL_DEADLINE_S as commented out
- new findings file indexes the red/green evidence, the commands and
  their exit codes, what was not run and who covers it, and repeats the
  three residual risks

No version bump, no tag, no release: those belong to the release
checklist.
2026-09-10 04:33:55 -04:00
iomgaa da77b123ec fix: ignore non-finite Retry-After hints
A gateway that answers 429 with Retry-After: inf (or 1e999, which float()
happily rounds to inf) used to reach backoff_delay as retry_after_s=inf.
max(delay, retry_after) then picked it, and since the library deliberately
does not clamp the hint with backoff_max_s, the attempt slept forever.

- _parse_retry_after now rejects non-finite values via math.isinf and
  returns None, so the call falls back to plain exponential backoff
- it emits exactly one warning carrying the source name and the verdict
  word retry_after_not_finite, never the raw header: under a 429 storm an
  echoed header drowns the real signal and does not help localisation
- source_name becomes a required keyword-only argument; the function is
  private, so no default is given and a missed call site fails loudly
  instead of silently dropping the source identity from the warning
- nan keeps flowing through the existing seconds > 0 semantics; no new
  branch, no reordering of the parse
2026-09-10 04:25:09 -04:00
iomgaa 9474c76ab0 feat: add an optional per-call wall-clock deadline
Give one logical call an optional hard wall-clock boundary (issue #22).
Leaving it unset keeps 1.3.5 behaviour verbatim: the timeout context is
never entered when deadline_s is None.

- new deadline.py: ensure_call_deadline() range check (None or a finite
  positive number; bool/0/nan/inf and out-of-range ints are rejected as
  ValueError so OverflowError never leaks) plus with_call_deadline(),
  which distinguishes an expiry from a TimeoutError raised by the body
  or its cleanup via a local-variable identity comparison rather than
  cm.expired() alone
- new CallDeadlineExceeded: deliberately outside the four categories and
  not a GatewayUnavailableError, and carries no retry_after_s
- new {SCOPE}__CALL_DEADLINE_S key, guarded on the env, direct
  construction and dataclasses.replace paths
- three clients take a call_deadline_s constructor argument and a
  keyword-only per-call override on chat/embed/recognize_text/
  parse_layout; None inherits the assembled value
- validation runs before the awaitable is created, so an illegal value
  cannot strand an un-awaited coroutine
- one embed call shares a single deadline across all of its batches
- import-linter gains a polygateway.deadline layer

- cover where the deadline lands: backoff sleep, admission polling,
  the structured re-ask ladder and embedding's batch loop, plus the
  empty-texts early return that stays outside it
- cover what an expiry costs: exactly one terminal_failure row carrying
  error_type=CallDeadlineExceeded, a cancelled attempt row sharing its
  logical_call_id, cleanup that outlives the deadline (lower bound only)
  and an already-billed success being discarded
- pin the injected clock as orthogonal: a 10^6 second jump never expires
  a call, yet total_latency_ms still reads that clock
2026-09-10 04:14:44 -04:00
iomgaa 1ff83bbfe0 fix: settle cancelled attempts against the source estimate
取消发生在"端口已开始、结算尚未确定"时,原先按 settle(0) 把入场预扣整笔
退还,等于把可能已被上游计费的用量退回闸里;启用调用期限后库自身会常规性
触发该路径,故先修记账再启用。

改动只落在取消路径的取值上(设计 §6.3 矩阵 S3/S5/S7):
- actual 初值保持 0,另设函数内局部阶段变量 settlement_known(不进任何签名);
- 成功路径算出用量后置位,真实 usage 恰为 0 同样算"已知",不被取消覆写;
- 已处理领域失败分支把结算决定前移到其第一个 await 之前(同级
  except CancelledError 接不住本块 await 上的取消,它直穿 finally),
  故 SourceDead 的既有 0 在取消下被保住,瞬时失败仍是 est,值与 1.3.5 逐字相同;
- 未被四分类接住的异常不经上述分支,仍按 0 退全款(S8,本版不扩大语义);
- OCR 的 0 token 是事实而非未知,settle(0) 不变,只补注释。

取消窗口一律用真实 asyncio.Event 钉死(不再 sleep 撞窗口),并加防越界回归:
RuntimeError 逃逸仍结 0、真实 usage 为 0 的成功仍结 0。
2026-09-10 00:48:07 -04:00
iomgaa 6c640fcca3 docs: record approved call deadline design and plan 2026-09-10 00:33:13 -04:00
iomgaa d2455e8fd4 docs: record 1.3.5 release completion and external verification
Merge-time gates, registry artifacts and anonymous page checks for 1.3.5
recorded as a separate finding; the validation finding gains a status
pointer so its historic 'not yet executed' section is not silently stale.
Released main/tag and uploaded artifacts are untouched.
2026-09-09 14:02:15 -04:00
iomgaa ab00aa4457 chore: merge release 1.3.5 call observability v1.3.5 2026-09-09 13:26:21 -04:00
iomgaa 433039be79 chore: prepare release 1.3.5
Version bump in pyproject and __init__, CHANGELOG dated 2026-09-09,
README install lower bound raised to >=1.3.5, and release-prep evidence
(remote check, gates, real gateway smoke and one bounded live probe)
recorded in the 1.3.5 validation finding.
2026-09-09 13:21:40 -04:00
iomgaa b4812e12c8 fix: degrade terminal telemetry failures instead of masking domain errors
终态出口 `emit_terminal_once` 此前只让 `_record` 内的 except 兜住落库,而
诊断字段的提取(`_error_fields` → `_structured_detail` → `format_bounded_errors`)
在降级 try **之外**求值。下游经公共端口(自实现 `StructuredOutputStrategy`
或 transport)构造出 `ResultInvalidError(validation_errors=(非 str,))` 时,提取期
抛的 `TypeError` 会顶替调用方本该收到的领域异常——错误四分类被击穿(下游
`except ResultInvalidError` 落空),且 `claim_terminal()` 已消耗故终态行照样丢,
同时违反"遥测写失败降级不冒泡"。

改法与 RetryMW 的 attempt 出口(`retry.py::_emit`)同款: 把快照冻结与 await
整段包进 try,`CancelledError` 原样上抛、其余落一条 warning。终态行按已批准的
best effort(兜底命中时该次逻辑调用 0 条终态行,不补写)。异常类型校验与
`ResultInvalidError` 的既有设计均未改动。

顺带同步审查报告的 Minor 项: SQLite recorder docstring 26 → 36 字段、
research-wiki 索引重建、ARCHITECTURE 的 `sampling` 段落终态调用点口径,
并删除 `TelemetryMW` 迁移后无读取点的 `self._now` 死字段(保留形参,
避免平白打断既有装配写法)。
2026-09-09 12:50:51 -04:00
iomgaa 067b15be48 docs: document logical call telemetry and migration impact
Field counts come from inspect, not memory: record_llm_call takes 36
parameters, COLUMNS has 36 entries, the physical table has 37.

- README: capability table says 36 fields and names the three row kinds;
  new section covers reading call_stats, the five SQL migration items,
  the attribution query and the storage-side upgrade
- README/.env.example/ARCHITECTURE: error_body follows the summarize_body
  limit and the structured-exhaustion error carries its own bounded
  explanation, so neither is inside PGW_TELEMETRY_TEXT_CAP coverage
- ARCHITECTURE 7.8: the ten columns with per-column semantics, the I3/I4
  invariants, operation versus exc.operation, and the assembly gate
- CHANGELOG: unreleased section listing the four public changes and what
  downstream must do, in particular counting failures by event_kind and
  the assembly-time error for custom recorders
- schemas/llm-calls: the ten columns plus a three-row-kind section
- metrics/call-telemetry-coverage: 1.3.5 coverage contract, real live
  baselines left unfilled rather than stating a fake percentage

Validation record records the T4 evidence: mechanical migration red then
green, the four PG acceptance cases, the seven-item mutation matrix with
all seven killed and the copy restored to an identical digest, plus the
PYTHONPATH pitfall that made the first mutation round silently test the
original source.

Version numbers and release steps are deliberately untouched.
2026-09-09 12:17:50 -04:00
iomgaa 7b2f6105f3 test: cover the ten call observability columns on real Postgres
Migrate the PG telemetry fixtures to the 36-field recorder and add the
storage compatibility acceptance the plan calls for.

Mechanical migration:
- _EXPECTED_COLUMNS 27 -> 37 physical columns
- _record_minimal gains the ten keys in the same shape as the unit suite
- _PRE_TENANT_COLUMNS now excludes 14 columns, derived from
  _CALL_OBSERVABILITY_COLUMNS instead of a second hand-written list, and
  the two manual-mode warnings assert a notice derived from COLUMNS order
  so a column that silently drops out of the warning turns the test red

New TestCallObservabilityColumnsAcceptance, all on a 27-column 1.3.4
shaped table built by the existing pg_sandbox factory:
- auto appends the ten columns in the same order as a fresh database and
  old rows keep NULL in every one of them (no backfill, no sentinel)
- manual sends no DDL, trims the INSERT, and still round-trips the other
  26 columns value by value
- an old-version writer using insert_sql with the 1.3.4 column set and a
  new-version writer share one table, and event_kind filtering counts
  neither the old rows as failures nor as successes

_minimal_fields is split out of _record_minimal so the simulated old
process reuses the same values rather than copying them.

Verified against the real lab Postgres: 30 passed.
2026-09-09 12:17:34 -04:00
iomgaa 393f2bf617 feat: record call observability columns and terminal failure rows
Grow the telemetry contract from 26 to 36 fields and give every logical
call a failure terminal row, so SQL can finally answer "how many calls
failed" and "why did the whole pool die".

Schema and port move together with the emitter writes in one commit:
splitting them would ship columns that nothing populates.

- schema: append 10 nullable columns (scope, operation, logical_call_id,
  event_kind, http_status_code, error_type, cause_type, error_body,
  attempts, total_latency_ms) to all five definition sites in one order
- ports: 10 keyword-only parameters without defaults; the protocol
  signature is now the single source the assembly gate derives from
- emitter: take domain exception objects instead of pre-flattened text
  and pin down the diagnostics in one helper; a relabelled 503 stays
  503 and success rows leave all five columns NULL
- emitter: reject recorders whose record_llm_call cannot accept the
  current field shape at assembly time, since _record would otherwise
  swallow the TypeError and drop every row while calls keep succeeding
- clients: write at most one terminal row per logical call through a
  single shared exit, deduplicated by the call context; TelemetryMW
  stops writing terminals so the two sites cannot double count
- clients: cancellation stays best effort and propagates, non-domain
  exceptions get no terminal row and keep their classification
- transports: give _status_to_error an explicit operation and fix the
  historically mislabelled embedding HTTP failures
- structured: promote the bounded error formatter so the reask feedback
  and the terminal explanation share one set of limits

Terminal rows carry no cost and no tokens, so cost aggregation is
unchanged; failure counts must now filter on event_kind.
2026-09-09 11:27:52 -04:00
iomgaa 87c261bf73 feat: track logical call statistics across governed calls 2026-09-09 10:04:39 -04:00
iomgaa 300ced5dbd docs: record approved call observability design and plan 2026-09-09 09:42:24 -04:00
iomgaa a81cc91124 docs: propose logical call statistics and failure diagnostics 2026-09-09 09:08:47 -04:00
iomgaa e71a623b04 docs: remove trailing blank line from release evidence 2026-09-09 07:28:02 -04:00
iomgaa 0c1165965f docs: record verified 1.3.4 publication and release checks 2026-09-09 07:27:19 -04:00
iomgaa af57f93adc chore: merge release 1.3.4 thinking contracts v1.3.4 2026-09-09 06:56:21 -04:00
iomgaa dae12f9a16 chore: prepare release 1.3.4 2026-09-09 06:52:27 -04:00
iomgaa b7e6943497 test: keep cancelled embedding probe rounds incomplete 2026-09-09 05:14:37 -04:00
iomgaa 7f6a824e79 test: complete embedding probe report identity and round counts 2026-09-09 05:06:39 -04:00
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