|
| 1 | +## [0.22.2] - 2026-10-16 |
| 2 | + |
| 3 | +Patch release, no protocol change. Three unrelated bugs closed in one |
| 4 | +shipping window: a status field that structurally could not report |
| 5 | +the failure it existed to report (`backend_reachable` initialised to |
| 6 | +`None` and never written), a packaging extra that the SDK's own |
| 7 | +`ImportError` told users to install but that `pyproject.toml` never |
| 8 | +declared, and a pre-flight that priced at the input rate and so |
| 9 | +silently under-reserved every real LLM call. None is a wire |
| 10 | +change — all three are SDK-side. The status and packaging fixes are |
| 11 | +behaviourally invisible to a healthy configuration; the reserve fix |
| 12 | +is visible to anyone whose `max_output_tokens` is currently being |
| 13 | +ignored. |
| 14 | + |
| 15 | +### Migration |
| 16 | + |
| 17 | +Three things differ from 0.22.1. None is silent on a healthy |
| 18 | +configuration, but the third is a new opt-in signal that the wire |
| 19 | +contract was always supposed to carry: |
| 20 | + |
| 21 | +1. **`NullRunStatus` reflects a backend that has actually been |
| 22 | + attempted.** A backend unreachable since `init()` used to read |
| 23 | + `backend_reachable=None` and so the DEGRADED headline stayed |
| 24 | + `STATE_OK` — a monitoring surface that structurally could not |
| 25 | + report the failure it exists to report. The writer is now |
| 26 | + called from every site that learns whether the backend answered |
| 27 | + (both `/gate` paths in `check_workflow_budget`, both |
| 28 | + `/auth/verify` outcomes); "answered" means transport-level, not |
| 29 | + HTTP-2xx — a 402/403/429 is the backend replying and stays |
| 30 | + reachable, only DNS / refused / TLS / timeout sets it False. |
| 31 | +2. **`pip install nullrun[websocket]` actually installs `websockets`.** |
| 32 | + The error message named an extra `pyproject.toml` never declared |
| 33 | + (only the opentelemetry pair and `dev` were listed). The failure |
| 34 | + mode was worse than a missing feature: a user told precisely how |
| 35 | + to install it typed it, and got a *second* error. `websockets` |
| 36 | + was in `uv.lock` only as a transitive of `langsmith`, so the |
| 37 | + WS wire test's coverage depended on a dependency edge nobody |
| 38 | + here controls. Now declared with floor `14.0` (the `additional_headers` |
| 39 | + spelling replaced `extra_headers` in the asyncio client in |
| 40 | + 14.0; an older install would `TypeError` at connect time, |
| 41 | + later and in production traffic, instead of at the |
| 42 | + `ImportError` the user was told to fix). |
| 43 | +3. **`set_call_context(max_output_tokens=…)` reaches `/gate`.** |
| 44 | + The pre-flight has always sent `estimated_tokens=1`, which |
| 45 | + prices at the INPUT rate and covers no output at all. Output |
| 46 | + bills at the completion rate (5× input on the live catalog), |
| 47 | + so a real 20k-token Opus response costs 50¢ against a 1¢ |
| 48 | + reserve: `/track` then rejects the row with |
| 49 | + `CONSUME_OVERBUDGET` and the period counter under-counts the |
| 50 | + spend. Now the caller's declared cap is sent when set, the key |
| 51 | + is ABSENT (not present-and-null) when not, and a cap of zero |
| 52 | + or less is treated as "not declared" rather than silently |
| 53 | + under-reserving. The via-edge box's `token_split()` falls back |
| 54 | + to the declared cap before the one-token estimate, never the |
| 55 | + other way. |
| 56 | + |
| 57 | +### Fixed |
| 58 | + |
| 59 | +- **DEF-STATUS-WRITER-01** — `NullRunStatus.backend_reachable` was |
| 60 | + initialised to `None` in `__init__` and read in `status()` — and |
| 61 | + never assigned anywhere in the SDK. The comment above the |
| 62 | + initialisation claimed the pair was "set in `_authenticate` and |
| 63 | + updated on every successful / failed backend call thereafter"; |
| 64 | + no such writer had ever existed. Two silent consequences: the |
| 65 | + DEGRADED headline was gated on `is False` so it never fired |
| 66 | + (pinned to `None`), and the MISCONFIGURED arm was gated on |
| 67 | + `_last_backend_attempt_at is not None` so it was dead for the |
| 68 | + same reason. Fix: one writer `_note_backend_attempt(ok)` called |
| 69 | + from the four sites that actually learn whether the backend |
| 70 | + answered. "Ok" means "the backend answered", NOT "the call |
| 71 | + succeeded": a 402/403/429 and a 401 are the backend replying, |
| 72 | + and only a transport-level failure sets it False. Collapsing |
| 73 | + those would brand a healthy server that correctly refused a |
| 74 | + call as unreachable and drive DEGRADED off a credential |
| 75 | + problem. The pair is read under a new lock because gate calls |
| 76 | + arrive on the tool thread while `_authenticate` runs on the |
| 77 | + caller's thread. (`b076643`, +100/-8; test files |
| 78 | + `test_status_backend_reachable_has_a_writer.py`, |
| 79 | + `test_status_ws_connected_reads_real_attribute.py`) |
| 80 | + |
| 81 | +- **DEF-SDK-16 (packaging: declare the `websocket` extra)** — |
| 82 | + `WebSocketConnection.connect` raises |
| 83 | + `websockets library not available. Install with: pip install |
| 84 | + nullrun[websocket]`, but `nullrun` had no `websocket` extra. |
| 85 | + `[project.optional-dependencies]` declared exactly two — the |
| 86 | + opentelemetry pair and `dev` — and `websockets` appeared |
| 87 | + nowhere in `pyproject.toml`. Two declarations, not one: |
| 88 | + `websocket = ["websockets>=14.0,<17.0"]` and `dev` also gains |
| 89 | + `websockets` (so `tests/test_ws_push.py` resolves through a |
| 90 | + declared edge, not through `langchain-core -> langsmith -> |
| 91 | + websockets` which nobody here controls). A new source pin |
| 92 | + asserts the general property: every `nullrun[<extra>]` install |
| 93 | + hint appearing anywhere under `src/` must name an extra |
| 94 | + `pyproject.toml` actually declares. (`a8cb823`, +24/-1; |
| 95 | + `tests/test_websocket_extra_is_declared.py`) |
| 96 | + |
| 97 | +### Added |
| 98 | + |
| 99 | +- **DEF-MOT-01 (reserve: forward caller-declared |
| 100 | + `max_output_tokens` to `/gate`)** — per ADR-016 §2.1 the |
| 101 | + backend prices an output reserve from TWO independent signals |
| 102 | + (the caller's declared cap and its own per-model catalog cap) |
| 103 | + and refuses to reserve from either one alone. The SDK's half |
| 104 | + of that contract is now narrow and specific: when the caller |
| 105 | + set a cap it is sent, when the caller did not the key is |
| 106 | + ABSENT (not present-and-null — a null is a value the backend |
| 107 | + has to defend against, not an honest "I don't know"), and a |
| 108 | + cap of zero or less is treated as "not declared" rather than |
| 109 | + silently under-reserving. New ContextVar |
| 110 | + `_call_max_output_tokens_var`, reader |
| 111 | + `get_call_max_output_tokens()`, and a `max_output_tokens=` |
| 112 | + parameter on `set_call_context(...)`. The via-edge box's |
| 113 | + `token_split()` falls back to the declared cap before the |
| 114 | + one-token estimate; every step down errs toward charging |
| 115 | + MORE, never less. (`f095c46`, +251/-9; test file |
| 116 | + `tests/test_max_output_tokens_reserve.py`) |
| 117 | + |
| 118 | +### Verification |
| 119 | + |
| 120 | +| Check | Result | |
| 121 | +|---|---| |
| 122 | +| `ruff check src tests` | All checks passed | |
| 123 | +| `mypy src/nullrun` | Success: no issues found in 39 source files | |
| 124 | +| `pytest -q` | **1913 passed, 12 skipped** in 156.34s (vs baseline 1849 / 12 — **+64 new tests** from `test_status_backend_reachable_has_a_writer`, `test_status_ws_connected_reads_real_attribute`, `test_websocket_extra_is_declared`, `test_max_output_tokens_reserve`) | |
| 125 | +| Scratch diff | clean (`dist_local/`, `*.defect*` absent) | |
| 126 | +| `nullrun.__version__` | `0.22.2` | |
| 127 | +| Wire-format compatibility | unchanged from 0.22.1 (no protocol change; the `max_output_tokens` key is new-but-OPTIONAL, the backend ignores its absence as before) | |
| 128 | + |
1 | 129 | ## [0.22.1] - 2026-10-15 |
2 | 130 |
|
3 | 131 | Patch release, no protocol change. The SDK's public exception surface |
|
0 commit comments