From 5e6bf6fac29eaeeb3db199887fc7997df84d081e Mon Sep 17 00:00:00 2001 From: AboveColin Date: Thu, 24 Sep 2026 08:05:51 +0200 Subject: [PATCH] The budget estimate counts the part of the bill every request pays A 138 byte action was estimated at 70 tokens and billed 278. The estimate was bytes over a ratio, with no fixed part, and the ratio came from the last call alone. After that small call, a 6 KB request was estimated at 14,327 tokens, billed 3,277 one call earlier, and refused on every try. The estimate is now 250 tokens plus the body over the ratio, and only a call whose body was billed at least 250 tokens updates the ratio. --- custom_components/jev/const.py | 37 ++++++++++++++------ custom_components/jev/coordinator.py | 17 +++++---- site-docs/cost.md | 18 +++++----- site-docs/measurements.md | 52 ++++++++++++++++++++++------ tests/test_init.py | 2 +- tests/test_payload.py | 28 +++++++++++++++ tests/test_services.py | 38 +++++++++++++++++--- 7 files changed, 152 insertions(+), 40 deletions(-) diff --git a/custom_components/jev/const.py b/custom_components/jev/const.py index 126ac69..e0adf7a 100644 --- a/custom_components/jev/const.py +++ b/custom_components/jev/const.py @@ -44,17 +44,34 @@ DEFAULT_SCAN_INTERVAL_SECONDS: Final = 300 TRIGGER_DEBOUNCE_SECONDS: Final = 5.0 -# What the pre-flight budget check divides payload bytes by before any call of its -# own has measured the real ratio. This one is derived rather than measured end to -# end: the five-entity conversation payload in site-docs/measurements.md is 3,142 -# bytes and measured 1,329 to 1,371 input tokens live, which is 2.29 to 2.36 bytes -# per token. The low end is the one that over-estimates the cost. +# What every request is billed before its body counts, whatever its size. Measured +# live on 2026-09-24 (site-docs/measurements.md): jev.noul with a 138 byte body was +# billed 278 input tokens and one with 6,136 bytes 3,277, and jev.ask with 1 and 8 +# questions, 137 and 613 bytes, was billed 279 and 377. A straight line through +# each pair crosses zero bytes at 209 and 251 tokens. 250 is near the higher one. # -# Every answered call replaces it with the ratio that endpoint actually reported, -# so an endpoint with another tokeniser calibrates this in one request. A hardcoded -# divisor would be a landmine the day someone points the entry at OpenRouter or at -# a gateway of their own. -COLD_START_BYTES_PER_TOKEN: Final = 2.29 +# Without it, the estimate was bytes over a ratio and nothing else. A 138 byte +# action was estimated at 70 tokens and billed 278. +REQUEST_OVERHEAD_TOKENS: Final = 250 + +# What the pre-flight budget check divides the body bytes by before any call of its +# own has measured the real ratio. The five-entity conversation payload in +# site-docs/measurements.md is 3,142 bytes and measured 1,329 to 1,371 input tokens +# live. Less the fixed part, that is 2.80 to 2.91 bytes per token. The low end is the +# one that over-estimates the cost. +# +# An answered call with a body worth measuring replaces it with the ratio that +# endpoint actually reported, so an endpoint with another tokeniser calibrates this +# in one request. A hardcoded divisor would be a landmine the day someone points the +# entry at OpenRouter or at a gateway of their own. +COLD_START_BYTES_PER_TOKEN: Final = 2.8 + +# A call is worth measuring when its body was billed at least as much as the fixed +# part. Below that, a few tokens of rounding in the fixed part swing the ratio. +# Measured: with the ratio taken from the 278 token action, the next 6,136 byte +# request was estimated at 14,327 tokens and billed 3,277, and it was refused again +# on every try, because a refused call measures nothing. +MIN_MEASURED_BODY_TOKENS: Final = REQUEST_OVERHEAD_TOKENS # The estimate is a tripwire, not an accounting figure. Sixteen live commands on one # payload shape varied by 3% (site-docs/measurements.md), so 20% sits well past any diff --git a/custom_components/jev/coordinator.py b/custom_components/jev/coordinator.py index 3fb2d3d..08dc498 100644 --- a/custom_components/jev/coordinator.py +++ b/custom_components/jev/coordinator.py @@ -44,7 +44,9 @@ DOMAIN, ISSUE_BUDGET_EXCEEDED, ISSUE_BUDGET_SPENT, + MIN_MEASURED_BODY_TOKENS, MIN_UPDATE_INTERVAL_SECONDS, + REQUEST_OVERHEAD_TOKENS, STORE_SAVE_DELAY_SECONDS, TRIGGER_DEBOUNCE_SECONDS, ) @@ -73,9 +75,10 @@ class UsageAccount: budget: int = 0 price_per_million: float = USD_PER_MILLION_INPUT_TOKENS budget_exceeded: bool = False - # Measured from the last answered call rather than assumed, and deliberately - # not persisted: it describes the endpoint, not the day, and the first call - # after a restart measures it again. + # The bytes of a request body per billed token, not counting the fixed part. + # Measured from the last answered call with a body worth measuring rather than + # assumed, and deliberately not persisted: it describes the endpoint, not the + # day, and the first large call after a restart measures it again. bytes_per_token: float = COLD_START_BYTES_PER_TOKEN # Estimates of requests that are in flight. Two contexts refreshing together # each saw the same total before either answer came back, so both fitted and @@ -170,8 +173,9 @@ def roll_over(self, today: date) -> None: def record(self, input_tokens: int, payload_bytes: int | None = None) -> None: self.calls += 1 self.input_tokens += input_tokens - if payload_bytes and input_tokens > 0: - self.bytes_per_token = payload_bytes / input_tokens + body_tokens = input_tokens - REQUEST_OVERHEAD_TOKENS + if payload_bytes and body_tokens >= MIN_MEASURED_BODY_TOKENS: + self.bytes_per_token = payload_bytes / body_tokens self._save() async def async_flush(self) -> None: @@ -199,7 +203,8 @@ def would_exceed(self) -> bool: def estimate_tokens(self, request_bytes: int) -> int: """What a request of this size will be billed, over-estimated on purpose.""" - return ceil(request_bytes / self.bytes_per_token * BUDGET_ESTIMATE_MARGIN) + body_tokens = request_bytes / self.bytes_per_token + return ceil((REQUEST_OVERHEAD_TOKENS + body_tokens) * BUDGET_ESTIMATE_MARGIN) def would_exceed_with(self, estimate: int) -> bool: """Whether a request costing `estimate` would end the day over budget. diff --git a/site-docs/cost.md b/site-docs/cost.md index a8c7d0c..a1babbf 100644 --- a/site-docs/cost.md +++ b/site-docs/cost.md @@ -51,8 +51,9 @@ into a token estimate, and a call that would not fit in what is left of the budg never sent. A budget is a limit on what gets spent, and one that only notices after the spending is a report. -The estimate divides the request size by the bytes per token of the last answered -call. A question, an action and an AI Task all update that ratio. +The estimate is a fixed 250 tokens that every request pays, plus the body size +divided by a bytes-per-token ratio. A question, an action and an AI Task all update +that ratio. When it trips: @@ -96,12 +97,13 @@ one request over the line rather than a runaway. ### How the estimate is worked out -The estimate is `bytes / bytes-per-token`, with a 1.2 margin. The ratio is not -hardcoded: the first call after a restart uses 2.29 bytes per token, measured against -the live API, and every answered call after that replaces it with the payload size -divided by the input tokens the endpoint actually reported. An endpoint that counts -tokens differently, OpenRouter or a gateway of your own, is measured rather than -assumed within one call. See the +The estimate is `(250 + bytes / bytes-per-token) * 1.2`. The 250 is what every +request is billed before its body counts. The ratio is not hardcoded: the first call +after a restart uses 2.80 bytes per token, measured against the live API. After that, +every answered call with a body of at least 250 tokens replaces it with what the +endpoint actually billed. A small call leaves it alone, because its bill is nearly all +fixed part. An endpoint that counts tokens differently, OpenRouter or a gateway of +your own, is measured rather than assumed. See the [receipt](measurements.md#bytes-per-input-token). !!! tip "Size it past anything real" diff --git a/site-docs/measurements.md b/site-docs/measurements.md index 09a053d..539e936 100644 --- a/site-docs/measurements.md +++ b/site-docs/measurements.md @@ -154,27 +154,59 @@ the entities. The daily budget has to refuse a call before it is sent, and the only token count there is comes back with the reply. So the size of the request is measured locally and -divided by a bytes-per-token ratio. +turned into tokens: a fixed part that every request pays, plus the body bytes divided +by a bytes-per-token ratio. + +### The fixed part + +Four requests against the live API on 2026-09-24, body bytes measured locally for the +same request: + +| Request | Body bytes | Input tokens billed | +|---|---|---| +| `jev.noul`, one short state line | 138 | 278 | +| `jev.noul`, 6 KB of state | 6,136 | 3,277 | +| `jev.ask`, 1 question | 137 | 279 | +| `jev.ask`, 8 questions | 613 | 377 | + +A straight line through each pair crosses zero bytes at 209 tokens (the two `noul` +rows) and at 251 tokens (the two `ask` rows). The fixed part is 250. It is paid per +request, not per question: seven more questions added 98 tokens, not seven times 250. + +Before this, the estimate was the bytes over a ratio and nothing else. The 138 byte +action was estimated at 70 tokens and billed 278. + +### The ratio The cold-start ratio comes from the two readings above. The conversation payload with 5 exposed entities and 7 questions is 3,142 bytes, and the same shape against the live -API reported 1,329 to 1,371 input tokens per command: +API reported 1,329 to 1,371 input tokens per command. Less the fixed part: | | | |---|---| -| 3,142 / 1,371 | 2.29 bytes per token | -| 3,142 / 1,329 | 2.36 bytes per token | +| 3,142 / (1,371 - 250) | 2.80 bytes per token | +| 3,142 / (1,329 - 250) | 2.91 bytes per token | -2.29 is the seed, because the lower ratio is the larger token estimate and an estimate +2.80 is the seed, because the lower ratio is the larger token estimate and an estimate that refuses slightly early beats one that lets a call through. This is a derivation across two runs rather than one payload counted both ways: the byte figures were measured locally with no API call, and the token figures came from a -different set of sixteen live commands on the same fixtures. That is exactly why the -ratio is a seed and not a constant. Every answered call replaces it with its own -payload bytes divided by the input tokens the endpoint reported, so a different -tokenizer, a gateway, or OpenRouter is measured rather than assumed, and the assumption -lasts one call. +different set of sixteen live commands on the same fixtures. That is why the ratio is +a seed and not a constant. An answered call replaces it with its own body bytes +divided by the tokens it was billed past the fixed part, so a different tokenizer, a +gateway, or OpenRouter is measured rather than assumed. + +Only a call whose body was billed at least 250 tokens replaces it. The 278 token +action above has 28 tokens of body, and a ratio taken from all of it said 0.5 bytes +per token. With that ratio, the 6 KB request was estimated at 14,327 tokens after being billed +3,277 one call earlier, and it was refused on every try, because a refused call +measures nothing. + +The ratio depends on what the bytes are. The 6 KB state was a repeated two-byte word +and came to 2.0 bytes per token. The questions of the `ask` rows came to 4.9. The first +6 KB request after a restart was estimated below what it was billed. The next one was +not. The estimate carries a 1.2 margin. Input tokens across those sixteen commands varied by 3.2%, 1,329 to 1,371 for the same seven questions, so 20% sits well past anything diff --git a/tests/test_init.py b/tests/test_init.py index d426d6a..be5a39c 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -147,7 +147,7 @@ async def test_the_budget_stops_the_next_call_and_keeps_what_it_has(hass, mock_c mock_client.ask.return_value = build_response( laundry_laundry_forgotten=NoulAnswer(noul=0.81) ) - await setup_with_context(hass, _budget_entry(400)) + await setup_with_context(hass, _budget_entry(700)) assert hass.states.get("sensor.jev_input_tokens_today").state == PROBED_AND_ASKED assert hass.states.get("sensor.jev_laundry_forgotten").state == "0.81" diff --git a/tests/test_payload.py b/tests/test_payload.py index 442828e..1e434cf 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -7,12 +7,15 @@ """ import json +from datetime import date from typing import Any, ClassVar import pytest from aiohttp.payload import JsonPayload from jevclient import Choice, JevClient, Noul, Score +from custom_components.jev.const import BUDGET_ESTIMATE_MARGIN +from custom_components.jev.coordinator import UsageAccount from custom_components.jev.payload import payload_bytes REPLY = { @@ -92,3 +95,28 @@ async def test_the_model_is_part_of_what_is_measured(): short = payload_bytes("x", QUESTIONS, "a") long = payload_bytes("x", QUESTIONS, "a" * 40) assert long - short == 39 + + +# Measured live on 2026-09-24: body bytes, then the input tokens the API billed. +_BILLED = [ + (138, 278), # jev.noul, one short state line + (613, 377), # jev.ask, eight questions + (3142, 1371), # the five-entity conversation payload, the dearest of sixteen +] + + +@pytest.mark.parametrize(("request_bytes", "billed"), _BILLED) +def test_the_cold_start_estimate_is_above_what_was_billed(request_bytes, billed): + """Before any call has measured the ratio, the estimate must not be low. + + Without the fixed part, the 138 byte action was estimated at 70 tokens. + """ + usage = UsageAccount(day=date(2026, 9, 24)) + assert billed <= usage.estimate_tokens(request_bytes) <= billed * 1.5 + + +def test_one_measured_call_sets_the_estimate_for_the_next(): + """6,136 bytes of dense state was billed 3,277 tokens. The same again fits.""" + usage = UsageAccount(day=date(2026, 9, 24)) + usage.record(3277, 6136) + assert 3277 <= usage.estimate_tokens(6136) <= 3277 * BUDGET_ESTIMATE_MARGIN + 1 diff --git a/tests/test_services.py b/tests/test_services.py index df47b4c..8d463fa 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,14 +1,16 @@ """The four actions, including what they refuse.""" +from dataclasses import replace + import pytest import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_API_KEY from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from jevclient import ChoiceAnswer, NoulAnswer, ScoreAnswer +from jevclient import ChoiceAnswer, NoulAnswer, ScoreAnswer, Usage from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.jev.const import DOMAIN +from custom_components.jev.const import DOMAIN, REQUEST_OVERHEAD_TOKENS from custom_components.jev.payload import payload_bytes from .conftest import build_response @@ -43,8 +45,16 @@ async def test_noul_returns_the_probability_and_the_threshold( async def test_an_action_teaches_the_budget_how_big_a_token_is( hass, loaded_entry, mock_client ): - """The budget estimate divides request bytes by this ratio, so every call counts.""" - mock_client.ask.return_value = build_response(answer=NoulAnswer(noul=0.81)) + """The budget estimate divides body bytes by this ratio, so every call counts. + + The fixed part of the bill is taken off first, because it is paid whatever the + body holds. + """ + billed = REQUEST_OVERHEAD_TOKENS + 900 + mock_client.ask.return_value = replace( + build_response(answer=NoulAnswer(noul=0.81)), + usage=Usage(input_tokens=billed, output_tokens=20), + ) await call( hass, "noul", @@ -53,7 +63,25 @@ async def test_an_action_teaches_the_budget_how_big_a_token_is( state, questions = mock_client.ask.call_args.args runtime = loaded_entry.runtime_data sent = payload_bytes(state, questions, runtime.model) - assert runtime.usage.bytes_per_token == sent / 321 + assert runtime.usage.bytes_per_token == sent / 900 + + +async def test_a_small_call_leaves_the_ratio_alone(hass, loaded_entry, mock_client): + """278 tokens for 138 bytes is nearly all fixed part, and says little about bytes. + + Measured live: with the ratio taken from that call, the next 6,136 byte request + was estimated at 14,327 tokens and billed 3,277. It was refused on every try, + because a refused call measures nothing. + """ + runtime = loaded_entry.runtime_data + before = runtime.usage.bytes_per_token + mock_client.ask.return_value = build_response(answer=NoulAnswer(noul=0.81)) + await call( + hass, + "noul", + {"state": "The machine has drawn 1.2 W.", "instructions": "Is it done?"}, + ) + assert runtime.usage.bytes_per_token == before async def test_the_threshold_is_the_callers_and_nothing_else(