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
37 changes: 27 additions & 10 deletions custom_components/jev/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions custom_components/jev/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 10 additions & 8 deletions site-docs/cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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"
Expand Down
52 changes: 42 additions & 10 deletions site-docs/measurements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 28 additions & 0 deletions tests/test_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
38 changes: 33 additions & 5 deletions tests/test_services.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand Down
Loading