Skip to content

Commit cb9dd29

Browse files
authored
AI: add agent.assert_tokens() for accumulated token limits (#200)
* feat(ai): add agent.assert_tokens() for accumulated token limits agent.assert_tokens(lambda x: x.where('input', '<=', 5000).where('output', '<=', 5000)) Tokens accumulate across every prompt()/stream() in a session (summing each ai message's token uses from the transcript, with a usage fallback), and the fluent TokenQuery evaluates where-clauses over input/output/cache/total. Works on both live-record and replay; to_response() now carries the transcript so replayed turns expose their per-message uses. * style(ai): ruff-format assert_tokens docstring * style(ai): ruff-format runner.py and test_record_cassette_format.py CI runs 'ruff format --check .' repo-wide; these two files (carried in from #199) were unformatted and failed the check. * test(orm): fix polymorphic relation tests to use the record relationship The Like model's MorphTo relationship is named 'record'; the tests referenced a nonexistent 'like.log' attribute, which returned None and failed to await / assert. Aligns with the eager-load case that already uses with_('record').
1 parent f111e8e commit cb9dd29

7 files changed

Lines changed: 207 additions & 13 deletions

File tree

example/agents/tests/units/agents/test_router_agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ def assert_tool_calls(tool: AssertToolCall):
2020
await agent.prompt("suggest python developer jobs")
2121
agent.assert_tool_called("job_search_tool", assert_tool_calls)
2222

23+
# Tokens accumulate across both turns of the session.
24+
agent.assert_tokens(lambda x: x.where("input", "<=", 5000).where("output", "<=", 5000))
25+
2326
async def test_the_router_with_initial_messages(self):
2427
with ChatAgent.record(
2528
"record_stream.json",

fastapi_startkit/src/fastapi_startkit/ai/recording.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,27 @@ def to_response(transcript: list[dict]) -> AgentResponse:
132132
content = entry.get("content", "")
133133
runtime += (entry.get("response_time") or 0) / 1000
134134

135-
return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, tool_events=tool_events, runtime=runtime)
135+
return AgentResponse(
136+
content=content,
137+
tool_calls=tool_calls,
138+
usage=usage,
139+
tool_events=tool_events,
140+
runtime=runtime,
141+
transcript=list(transcript),
142+
)
143+
144+
145+
def accumulate_uses(transcript: list[dict], totals: dict) -> None:
146+
"""Add every ``ai`` entry's token ``uses`` in ``transcript`` into ``totals``
147+
(keys: input, output, cache, total)."""
148+
for entry in transcript or []:
149+
if entry.get("type") != "ai":
150+
continue
151+
uses = entry.get("uses") or {}
152+
totals["input"] += uses.get("input_token", 0)
153+
totals["output"] += uses.get("output_token", 0)
154+
totals["cache"] += uses.get("cache_token", 0)
155+
totals["total"] += uses.get("total_token", 0)
136156

137157

138158
def chunks_from_transcript(transcript: list[dict]) -> list[str]:

fastapi_startkit/src/fastapi_startkit/ai/runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ def _record_ai_message(
5353
tool_calls = list(getattr(message, "tool_calls", None) or [])
5454
uses = recording.uses_from_usage_metadata(getattr(message, "usage_metadata", None))
5555
self._transcript.append(
56-
recording.ai(content=content, tool_calls=tool_calls, uses=uses, response_time=response_time_ms, chunks=chunks)
56+
recording.ai(
57+
content=content, tool_calls=tool_calls, uses=uses, response_time=response_time_ms, chunks=chunks
58+
)
5759
)
5860
if tool_calls:
5961
self._requested_tool_calls = tool_calls

fastapi_startkit/src/fastapi_startkit/ai/testing.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import hashlib
55
import inspect
66
import json
7+
import operator
78
import sys
89
import time
910
from collections.abc import AsyncIterator
@@ -21,6 +22,48 @@ def _joined(value: Any) -> str:
2122
return "".join(value) if isinstance(value, list) else value
2223

2324

25+
def _new_token_totals() -> dict:
26+
return {"input": 0, "output": 0, "cache": 0, "total": 0}
27+
28+
29+
class TokenQuery:
30+
"""Fluent predicate over accumulated token totals, e.g.::
31+
32+
agent.assert_tokens(lambda x: x.where("input", "<=", 5000).where("output", "<=", 5000))
33+
34+
Fields: ``input`` / ``output`` / ``cache`` / ``total``.
35+
"""
36+
37+
_OPS = {
38+
"<=": operator.le,
39+
">=": operator.ge,
40+
"<": operator.lt,
41+
">": operator.gt,
42+
"==": operator.eq,
43+
"=": operator.eq,
44+
"!=": operator.ne,
45+
}
46+
47+
def __init__(self, totals: dict) -> None:
48+
self._totals = totals
49+
self._checks: list[tuple[str, str, float]] = []
50+
51+
def where(self, field: str, op: str, value: float) -> "TokenQuery":
52+
self._checks.append((field, op, value))
53+
return self
54+
55+
def failures(self) -> list[str]:
56+
problems: list[str] = []
57+
for field, op, value in self._checks:
58+
compare = self._OPS.get(op)
59+
if compare is None:
60+
raise ValueError(f"Unsupported token operator {op!r}")
61+
actual = self._totals.get(field, 0)
62+
if not compare(actual, value):
63+
problems.append(f"{field}={actual} is not {op} {value}")
64+
return problems
65+
66+
2467
class AgentFake:
2568
def __init__(self, agent_cls: type[Agent], responses: list) -> None:
2669
self._agent_cls = agent_cls
@@ -30,6 +73,7 @@ def __init__(self, agent_cls: type[Agent], responses: list) -> None:
3073
self._records: list[dict] = []
3174
self._last_response: AgentResponse | None = None
3275
self.last_elapsed: float | None = None
76+
self._tokens: dict = _new_token_totals()
3377

3478
def _history(self) -> list:
3579
return self._records
@@ -73,9 +117,31 @@ async def stream(self, message: str, *, config: dict | None = None) -> AsyncIter
73117
self._remember(message, self._last_response)
74118

75119
def _remember(self, message: str, response: AgentResponse) -> None:
120+
self._accumulate_tokens(response)
76121
self._records.append({"role": "user", "content": message})
77122
self._records.append({"role": "assistant", "content": response.content})
78123

124+
def _accumulate_tokens(self, response: AgentResponse) -> None:
125+
"""Add a turn's token usage to the running totals. Prefer the per-message
126+
``uses`` on the transcript; fall back to the response's summary usage."""
127+
from . import recording # noqa: PLC0415
128+
129+
if response.transcript:
130+
recording.accumulate_uses(response.transcript, self._tokens)
131+
else:
132+
self._tokens["input"] += response.usage.get("input", 0)
133+
self._tokens["output"] += response.usage.get("output", 0)
134+
135+
def assert_tokens(self, predicate: Callable[[TokenQuery], Any]) -> None:
136+
"""Assert on the tokens accumulated across every prompt()/stream() so far.
137+
138+
agent.assert_tokens(lambda x: x.where("input", "<=", 5000).where("output", "<=", 5000))
139+
"""
140+
query = TokenQuery(dict(self._tokens))
141+
predicate(query)
142+
failures = query.failures()
143+
assert not failures, f"Token assertion failed: {'; '.join(failures)}. Accumulated tokens: {self._tokens}"
144+
79145
def assert_prompt(self, expected: str | Callable[[str], bool]) -> None:
80146
if callable(expected):
81147
assert any(expected(p) for p in self._prompts), (
@@ -107,6 +173,7 @@ def assert_not_prompted(self) -> None:
107173
def reset(self) -> "AgentFake":
108174
self._records.clear()
109175
self._last_response = None
176+
self._tokens = _new_token_totals()
110177
return self
111178

112179
def _require_response(self) -> AgentResponse:
@@ -194,6 +261,7 @@ def __init__(self, real: Agent, cassette: str | None = None, messages: list | No
194261
self._real.messages = self._history # type: ignore[method-assign]
195262
self._last_response: AgentResponse | None = None
196263
self.last_elapsed: float | None = None
264+
self._tokens: dict = _new_token_totals()
197265

198266
def _history(self) -> list:
199267
return self._seed_messages + self._records
@@ -263,6 +331,7 @@ def _response_from_cache(value: Any) -> AgentResponse:
263331
return AgentResponse(content=_joined(value))
264332

265333
def _remember_turn(self, message: str, response: AgentResponse) -> None:
334+
self._accumulate_tokens(response)
266335
self._records.append({"role": "user", "content": message})
267336
turn: dict[str, Any] = {"role": "assistant", "content": response.content}
268337
if response.tool_calls:
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Tests for agent.assert_tokens() — assert on accumulated token usage (task #1251).
2+
3+
agent.assert_tokens(lambda x: x.where("input", "<=", 5000).where("output", "<=", 5000))
4+
5+
Tokens accumulate across every recorded turn (and every ai message within a
6+
turn); the predicate builds where-clauses that must all hold.
7+
"""
8+
9+
import os
10+
import tempfile
11+
import unittest
12+
from unittest import mock
13+
14+
from fastapi_startkit.ai import recording
15+
from fastapi_startkit.ai.agent import Agent
16+
from fastapi_startkit.ai.response import AgentResponse
17+
18+
19+
class TokenAgent(Agent):
20+
pass
21+
22+
23+
def _ai(input_tokens: int, output_tokens: int) -> dict:
24+
return recording.ai(
25+
content="reply",
26+
uses={
27+
"input_token": input_tokens,
28+
"output_token": output_tokens,
29+
"cache_token": 0,
30+
"total_token": input_tokens + output_tokens,
31+
},
32+
response_time=1.0,
33+
)
34+
35+
36+
def _fake_prompt(responses: list):
37+
queue = list(responses)
38+
39+
async def prompt(agent_self, message, **kwargs):
40+
return queue.pop(0)
41+
42+
return mock.patch.object(TokenAgent, "prompt", prompt)
43+
44+
45+
class TestAssertTokens(unittest.IsolatedAsyncioTestCase):
46+
async def test_passes_when_accumulated_tokens_are_within_limits(self):
47+
responses = [
48+
AgentResponse(content="a", usage={"input": 100, "output": 20}, transcript=[_ai(100, 20)]),
49+
AgentResponse(content="b", usage={"input": 200, "output": 30}, transcript=[_ai(200, 30)]),
50+
]
51+
with tempfile.TemporaryDirectory() as tmp:
52+
with _fake_prompt(responses):
53+
with TokenAgent.record(os.path.join(tmp, "c.json")) as agent:
54+
await agent.prompt("a")
55+
await agent.prompt("b")
56+
# accumulated: input=300, output=50
57+
agent.assert_tokens(lambda x: x.where("input", "<=", 5000).where("output", "<=", 5000))
58+
59+
async def test_fails_when_a_limit_is_exceeded(self):
60+
responses = [AgentResponse(content="a", usage={"input": 400, "output": 20}, transcript=[_ai(400, 20)])]
61+
with tempfile.TemporaryDirectory() as tmp:
62+
with _fake_prompt(responses):
63+
with TokenAgent.record(os.path.join(tmp, "c.json")) as agent:
64+
await agent.prompt("a")
65+
with self.assertRaises(AssertionError):
66+
agent.assert_tokens(lambda x: x.where("input", "<=", 300))
67+
68+
async def test_supports_cache_and_total_fields(self):
69+
transcript = [
70+
recording.ai(
71+
content="a",
72+
uses={"input_token": 100, "output_token": 20, "cache_token": 40, "total_token": 160},
73+
response_time=1.0,
74+
)
75+
]
76+
with tempfile.TemporaryDirectory() as tmp:
77+
with _fake_prompt([AgentResponse(content="a", usage={"input": 100, "output": 20}, transcript=transcript)]):
78+
with TokenAgent.record(os.path.join(tmp, "c.json")) as agent:
79+
await agent.prompt("a")
80+
agent.assert_tokens(lambda x: x.where("cache", "<=", 40).where("total", ">=", 160))
81+
82+
async def test_accumulates_from_the_cassette_on_replay(self):
83+
responses = [AgentResponse(content="a", usage={"input": 100, "output": 20}, transcript=[_ai(100, 20)])]
84+
with tempfile.TemporaryDirectory() as tmp:
85+
cassette = os.path.join(tmp, "c.json")
86+
with _fake_prompt(responses):
87+
with TokenAgent.record(cassette) as agent:
88+
await agent.prompt("a")
89+
90+
# Replay: no live prompt available; tokens must come from the cassette.
91+
with TokenAgent.record(cassette) as agent:
92+
await agent.prompt("a")
93+
agent.assert_tokens(lambda x: x.where("input", "==", 100).where("output", "==", 20))

fastapi_startkit/tests/ai/test_record_cassette_format.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ class TestNewCassetteFormat(unittest.IsolatedAsyncioTestCase):
3030
async def test_recording_persists_an_ordered_transcript_keyed_by_input(self):
3131
response = AgentResponse(
3232
transcript=[
33-
{"type": "ai", "tool_calls": [{"name": "job_search_tool", "args": {"query": "python"}, "id": "c1"}],
34-
"uses": {"input_token": 117, "output_token": 22, "cache_token": 0, "total_token": 139},
35-
"response_time": 12.0},
36-
{"type": "tool_response", "content_type": "json", "content": "[{\"id\": 2}]", "response_time": 5.0},
33+
{
34+
"type": "ai",
35+
"tool_calls": [{"name": "job_search_tool", "args": {"query": "python"}, "id": "c1"}],
36+
"uses": {"input_token": 117, "output_token": 22, "cache_token": 0, "total_token": 139},
37+
"response_time": 12.0,
38+
},
39+
{"type": "tool_response", "content_type": "json", "content": '[{"id": 2}]', "response_time": 5.0},
3740
],
38-
content="[{\"id\": 2}]",
41+
content='[{"id": 2}]',
3942
tool_calls=[{"name": "job_search_tool", "args": {"query": "python"}, "id": "c1"}],
4043
)
4144
with tempfile.TemporaryDirectory() as tmp:
@@ -57,11 +60,15 @@ async def test_recording_persists_an_ordered_transcript_keyed_by_input(self):
5760
async def test_replaying_a_new_format_cassette_reconstructs_tool_calls(self):
5861
response = AgentResponse(
5962
transcript=[
60-
{"type": "ai", "tool_calls": [{"name": "job_search_tool", "args": {"query": "python"}, "id": "c1"}],
61-
"uses": {"input_token": 117, "output_token": 22}, "response_time": 12.0},
62-
{"type": "tool_response", "content_type": "json", "content": "[{\"id\": 2}]", "response_time": 5.0},
63+
{
64+
"type": "ai",
65+
"tool_calls": [{"name": "job_search_tool", "args": {"query": "python"}, "id": "c1"}],
66+
"uses": {"input_token": 117, "output_token": 22},
67+
"response_time": 12.0,
68+
},
69+
{"type": "tool_response", "content_type": "json", "content": '[{"id": 2}]', "response_time": 5.0},
6370
],
64-
content="[{\"id\": 2}]",
71+
content='[{"id": 2}]',
6572
tool_calls=[{"name": "job_search_tool", "args": {"query": "python"}, "id": "c1"}],
6673
)
6774
with tempfile.TemporaryDirectory() as tmp:

fastapi_startkit/tests/masoniteorm/sqlite/relationships/test_sqlite_polymorphic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ class TestRelationships(TestCase):
99
async def test_can_get_polymorphic_relation(self):
1010
likes = await Like.get()
1111
for like in likes:
12-
record = await like.log
12+
record = await like.record
1313
assert isinstance(record, (Articles, Product))
1414

1515
async def test_can_get_eager_load_polymorphic_relation(self):
1616
likes = await Like.with_("record").get()
1717
for like in likes:
18-
assert isinstance(like.log, (Articles, Product))
18+
assert isinstance(like.record, (Articles, Product))

0 commit comments

Comments
 (0)