Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,131 @@
## [0.22.2] - 2026-10-16

Patch release, no protocol change. Three unrelated bugs closed in one
shipping window: a status field that structurally could not report
the failure it existed to report (`backend_reachable` initialised to
`None` and never written), a packaging extra that the SDK's own
`ImportError` told users to install but that `pyproject.toml` never
declared, and a pre-flight that priced at the input rate and so
silently under-reserved every real LLM call. None is a wire
change — all three are SDK-side. The status and packaging fixes are
behaviourally invisible to a healthy configuration; the reserve fix
is visible to anyone whose `max_output_tokens` is currently being
ignored.

### Migration

Three things differ from 0.22.1. None is silent on a healthy
configuration, but the third is a new opt-in signal that the wire
contract was always supposed to carry:

1. **`NullRunStatus` reflects a backend that has actually been
attempted.** A backend unreachable since `init()` used to read
`backend_reachable=None` and so the DEGRADED headline stayed
`STATE_OK` — a monitoring surface that structurally could not
report the failure it exists to report. The writer is now
called from every site that learns whether the backend answered
(both `/gate` paths in `check_workflow_budget`, both
`/auth/verify` outcomes); "answered" means transport-level, not
HTTP-2xx — a 402/403/429 is the backend replying and stays
reachable, only DNS / refused / TLS / timeout sets it False.
2. **`pip install nullrun[websocket]` actually installs `websockets`.**
The error message named an extra `pyproject.toml` never declared
(only the opentelemetry pair and `dev` were listed). The failure
mode was worse than a missing feature: a user told precisely how
to install it typed it, and got a *second* error. `websockets`
was in `uv.lock` only as a transitive of `langsmith`, so the
WS wire test's coverage depended on a dependency edge nobody
here controls. Now declared with floor `14.0` (the `additional_headers`
spelling replaced `extra_headers` in the asyncio client in
14.0; an older install would `TypeError` at connect time,
later and in production traffic, instead of at the
`ImportError` the user was told to fix).
3. **`set_call_context(max_output_tokens=…)` reaches `/gate`.**
The pre-flight has always sent `estimated_tokens=1`, which
prices at the INPUT rate and covers no output at all. Output
bills at the completion rate (5× input on the live catalog),
so a real 20k-token Opus response costs 50¢ against a 1¢
reserve: `/track` then rejects the row with
`CONSUME_OVERBUDGET` and the period counter under-counts the
spend. Now the caller's declared cap is sent when set, the key
is ABSENT (not present-and-null) when not, and a cap of zero
or less is treated as "not declared" rather than silently
under-reserving. The via-edge box's `token_split()` falls back
to the declared cap before the one-token estimate, never the
other way.

### Fixed

- **DEF-STATUS-WRITER-01** — `NullRunStatus.backend_reachable` was
initialised to `None` in `__init__` and read in `status()` — and
never assigned anywhere in the SDK. The comment above the
initialisation claimed the pair was "set in `_authenticate` and
updated on every successful / failed backend call thereafter";
no such writer had ever existed. Two silent consequences: the
DEGRADED headline was gated on `is False` so it never fired
(pinned to `None`), and the MISCONFIGURED arm was gated on
`_last_backend_attempt_at is not None` so it was dead for the
same reason. Fix: one writer `_note_backend_attempt(ok)` called
from the four sites that actually learn whether the backend
answered. "Ok" means "the backend answered", NOT "the call
succeeded": a 402/403/429 and a 401 are the backend replying,
and only a transport-level failure sets it False. Collapsing
those would brand a healthy server that correctly refused a
call as unreachable and drive DEGRADED off a credential
problem. The pair is read under a new lock because gate calls
arrive on the tool thread while `_authenticate` runs on the
caller's thread. (`b076643`, +100/-8; test files
`test_status_backend_reachable_has_a_writer.py`,
`test_status_ws_connected_reads_real_attribute.py`)

- **DEF-SDK-16 (packaging: declare the `websocket` extra)** —
`WebSocketConnection.connect` raises
`websockets library not available. Install with: pip install
nullrun[websocket]`, but `nullrun` had no `websocket` extra.
`[project.optional-dependencies]` declared exactly two — the
opentelemetry pair and `dev` — and `websockets` appeared
nowhere in `pyproject.toml`. Two declarations, not one:
`websocket = ["websockets>=14.0,<17.0"]` and `dev` also gains
`websockets` (so `tests/test_ws_push.py` resolves through a
declared edge, not through `langchain-core -> langsmith ->
websockets` which nobody here controls). A new source pin
asserts the general property: every `nullrun[<extra>]` install
hint appearing anywhere under `src/` must name an extra
`pyproject.toml` actually declares. (`a8cb823`, +24/-1;
`tests/test_websocket_extra_is_declared.py`)

### Added

- **DEF-MOT-01 (reserve: forward caller-declared
`max_output_tokens` to `/gate`)** — per ADR-016 §2.1 the
backend prices an output reserve from TWO independent signals
(the caller's declared cap and its own per-model catalog cap)
and refuses to reserve from either one alone. The SDK's half
of that contract is now narrow and specific: when the caller
set a cap it is sent, when the caller did not the key is
ABSENT (not present-and-null — a null is a value the backend
has to defend against, not an honest "I don't know"), and a
cap of zero or less is treated as "not declared" rather than
silently under-reserving. New ContextVar
`_call_max_output_tokens_var`, reader
`get_call_max_output_tokens()`, and a `max_output_tokens=`
parameter on `set_call_context(...)`. The via-edge box's
`token_split()` falls back to the declared cap before the
one-token estimate; every step down errs toward charging
MORE, never less. (`f095c46`, +251/-9; test file
`tests/test_max_output_tokens_reserve.py`)

### Verification

| Check | Result |
|---|---|
| `ruff check src tests` | All checks passed |
| `mypy src/nullrun` | Success: no issues found in 39 source files |
| `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`) |
| Scratch diff | clean (`dist_local/`, `*.defect*` absent) |
| `nullrun.__version__` | `0.22.2` |
| 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) |

## [0.22.1] - 2026-10-15

Patch release, no protocol change. The SDK's public exception surface
Expand Down
35 changes: 34 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "nullrun"
# Full release history lives in CHANGELOG.md; only the current version
# is pinned here.
version = "0.22.1"
version = "0.22.2"
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. The headline is the canonical §1 statement
# from positioning.md — "runtime decision layer for tool-using AI agents"
Expand Down Expand Up @@ -77,7 +77,31 @@ opentelemetry = [
"opentelemetry-api>=1.26.0,<2.0",
"opentelemetry-sdk>=1.26.0,<2.0",
]
# DEF-SDK-16 (RUN_ID 20261015T1809). ``transport_websocket.connect``
# raises ``ImportError("… Install with: pip install nullrun[websocket]")``
# when the ``websockets`` import fails — but no ``websocket`` extra was
# ever declared, so the command in the error message did not exist and
# pip rejected it. The extra below makes the message true.
#
# The floor is 14.0, not "any version": the transport calls
# ``websockets.connect(url, additional_headers=...)``. The
# ``additional_headers`` spelling belongs to the asyncio client that
# became the default ``websockets.connect`` in 14.0; earlier releases
# spelled the same argument ``extra_headers``, so a lower floor
# resolves and then fails at connect time with a TypeError instead of
# at import time.
websocket = [
"websockets>=14.0,<17.0",
]
dev = [
# ``tests/test_ws_push.py`` imports ``websockets`` at module scope
# and spins up a real local WS server. It used to pass only because
# ``langchain-core`` -> ``langsmith`` -> ``websockets`` happened to
# pull it in transitively; a langchain/langsmith release that drops
# that edge would turn the whole module into a collection error.
# Declared directly so the test suite's WS coverage depends on the
# websocket extra existing, not on someone else's dependency graph.
"websockets>=14.0,<17.0",
"pytest>=8.0",
"pytest-asyncio>=0.23",
# Sprint 0 (coverage): ``pytest-rerunfailures`` is used on a
Expand Down Expand Up @@ -135,6 +159,15 @@ dev = [
# bound as the `[fastapi]` extra so CI and end users agree on the
# minimum. `httpx` (already a core dep) covers `TestClient`.
"fastapi>=0.100,<1.0",
# `tests/test_websocket_extra_is_declared.py` parses
# `pyproject.toml` at collection time to pin the general
# "every `nullrun[<extra>]` install hint under `src/` names
# an extra `pyproject.toml` actually declares" property.
# `tomllib` is stdlib from 3.11; on 3.10 the test must fall
# back to `tomli`, and the fallback is only safe if the dep
# is declared. Conditional on `<3.11` so the upper-bound
# environments do not pull a redundant package.
"tomli>=2.0; python_version < '3.11'",
]

[project.scripts]
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
string and the SDK_MIN_VERSION constant.
"""

__version__ = "0.22.1"
__version__ = "0.22.2"
__platform_version__ = "1.0.0"
48 changes: 46 additions & 2 deletions src/nullrun/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@
# enforced on /track only).
_call_model_var: ContextVar[str | None] = ContextVar("call_model", default=None)
_call_tools_var: ContextVar[tuple[str, ...]] = ContextVar("call_tools", default=())
# The `max_output_tokens` the caller intends to request from the
# provider — Anthropic/OpenAI's `max_tokens` / `max_output_tokens`.
#
# The pre-flight has always sent `estimated_tokens=1`, which prices at
# the INPUT rate and covers no output at all. Output is billed at the
# completion rate (5× the input rate on the live catalog), so a real
# 20k-token Opus response costs 50¢ against a 1¢ reserve: /track then
# rejects the row with CONSUME_OVERBUDGET and the period counter
# under-counts the spend.
#
# `None` means "the caller didn't say", and the backend keeps the old
# arithmetic — a missing signal must not invent a reserve. The backend
# bounds whatever arrives here by its own per-model catalog cap, so a
# client can only ever LOWER the reserve, never inflate it.
_call_max_output_tokens_var: ContextVar[int | None] = ContextVar(
"call_max_output_tokens", default=None
)
# Per-call MCP tool class + annotations. Set via the
# ``set_mcp_tool_context`` helper when the SDK recognises an MCP
# server. The gate honors `tool_class` over its own
Expand Down Expand Up @@ -142,6 +159,18 @@ def get_call_tools() -> tuple[str, ...]:
return _call_tools_var.get()


def get_call_max_output_tokens() -> int | None:
"""The declared output cap for the next pre-flight, or ``None``.

``None`` is the honest default: the SDK cannot invent an output
size for a call it does not make. The backend treats the pair
(declared, catalog cap) as the only inputs it will price an output
reserve from — see ADR-016 §2.1 — and refuses to reserve from
either signal alone.
"""
return _call_max_output_tokens_var.get()


def get_call_mcp_class() -> str | None:
"""Canonical tool class for the next ``/check`` call.

Expand Down Expand Up @@ -802,9 +831,10 @@ def create_child_span_with_id(parent: SpanContext, span_id: str) -> SpanContext:
def set_call_context(
model: str | None = None,
tools: list[str] | tuple[str, ...] | None = None,
max_output_tokens: int | None = None,
) -> None:
"""Set per-call context (model name, tool list) for the next /gate
pre-flight check.
"""Set per-call context (model name, tool list, output cap) for the
next /gate pre-flight check.

Replaces the previous fake ``model="budget-precheck"`` and
``estimated_tokens=1`` always-default / always-empty pre-flight.
Expand All @@ -821,11 +851,25 @@ def set_call_context(
``blocked_tools`` aggregate and returns block on any
match. Pass ``None`` to leave the current value
unchanged, ``[]`` to clear.
max_output_tokens: The output cap you are about to send the
provider — Anthropic's ``max_tokens``, OpenAI's
``max_output_tokens``. Sent only when you pass a positive
integer; omitting it keeps the pre-flight exactly as it
was, which under-reserves output rather than guessing at
a number. The backend clamps this to its own per-model
catalog cap, so a value larger than the model can emit is
not a way to raise your own reserve.
"""
if model is not None:
_call_model_var.set(model)
if tools is not None:
_call_tools_var.set(tuple(tools))
if max_output_tokens is not None:
# A cap of zero or less is not a cap; forwarding it would let a
# caller declare "no output" and reserve nothing at all. Treat
# it as "not declared" instead of silently under-reserving.
if max_output_tokens > 0:
_call_max_output_tokens_var.set(max_output_tokens)


def set_mcp_tool_context(
Expand Down
21 changes: 15 additions & 6 deletions src/nullrun/edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,14 +386,23 @@ def edge_transport_from_env(env: dict[str, str] | None = None) -> EdgeTransport
def token_split(check_request: dict[str, Any]) -> tuple[int, int]:
"""The token counts this call is reported with.

Explicit `edge_input_tokens` / `edge_output_tokens` win. Absent
them, `estimated_tokens` is reported as OUTPUT tokens — output is
what the price is dominated by for most models, and reporting the
whole estimate as output errs toward charging more rather than
less. Under-reporting is the one direction this cannot take, since
the box cannot check the number and will simply believe it.
Precedence for output: explicit `edge_output_tokens`, then the
declared `max_output_tokens`, then `estimated_tokens`. Each step
down is a weaker answer to "how big will the output be" — a known
size, then the upper bound the caller is about to ask the provider
for, then a blob the runtime only ever sets to 1.

Every step errs toward charging MORE, never less. That is
deliberate and load-bearing: the box cannot check the number, it
will believe it, and output is what the price is dominated by for
most models (5× the input rate on the live catalog). Reporting the
declared cap when it is the only real signal costs a little
over-reserve on calls that finish short; reporting `1` instead
under-reserves every real call.
"""
out = check_request.get("edge_output_tokens")
if out is None:
out = check_request.get("max_output_tokens")
if out is None:
out = check_request.get("estimated_tokens") or 0
inp = check_request.get("edge_input_tokens") or 0
Expand Down
Loading
Loading