Skip to content

Commit ffb77cf

Browse files
Aditya SinghAditya Singh
authored andcommitted
feat(usage): add agent_name and model_name to RequestUsage (#2100)
- Added optional `agent_name: str | None = None` and `model_name: str | None = None` fields to the `RequestUsage` dataclass (backward-compatible) - Modified `Usage.add()` to accept keyword-only `agent_name` and `model_name` parameters and annotate the resulting `RequestUsage` entry with them - Added `_get_model_name()` helper in run_loop.py to safely extract the model name string from any Model implementation via duck-typing - Updated both `run_single_turn_streamed` and `get_new_response` call-sites in run_loop.py to pass agent.name and the resolved model name when adding usage - When merging pre-existing `request_usage_entries`, agent/model names are applied to entries that don't already have them set (existing names preserved) - Updated `serialize_usage` / `deserialize_usage` in usage.py to round-trip the new fields through JSON - Added 9 new tests covering: default None values (backward compat), explicit field population, Usage.add() propagation, entry merge semantics, single-agent runner integration, model_name detection, and multi-agent per-agent attribution scenario - Full test suite passes (2198 tests); lint and pyright clean Closes #2100
1 parent 5c9fb2c commit ffb77cf

File tree

3 files changed

+297
-4
lines changed

3 files changed

+297
-4
lines changed

src/agents/run_internal/run_loop.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,19 @@
230230
]
231231

232232

233+
def _get_model_name(model: Any) -> str | None:
234+
"""Extract the string model name from a Model instance, if available.
235+
236+
Most built-in model implementations (``OpenAIResponsesModel``,
237+
``OpenAIChatCompletionsModel``) expose a ``model`` attribute that contains
238+
the underlying model name string. This helper retrieves it in a
239+
forward-compatible, duck-typed way so that third-party model implementations
240+
that may not have this attribute are handled gracefully.
241+
"""
242+
model_name = getattr(model, "model", None)
243+
if isinstance(model_name, str):
244+
return model_name
245+
return None
233246
async def _should_persist_stream_items(
234247
*,
235248
session: Session | None,
@@ -1401,7 +1414,11 @@ async def rewind_model_request() -> None:
14011414
)
14021415

14031416
if final_response is not None:
1404-
context_wrapper.usage.add(final_response.usage)
1417+
context_wrapper.usage.add(
1418+
final_response.usage,
1419+
agent_name=agent.name,
1420+
model_name=_get_model_name(model),
1421+
)
14051422
await asyncio.gather(
14061423
(
14071424
agent.hooks.on_llm_end(context_wrapper, agent, final_response)
@@ -1656,7 +1673,11 @@ async def rewind_model_request() -> None:
16561673
# new deltas.
16571674
server_conversation_tracker.mark_input_as_sent(filtered.input)
16581675

1659-
context_wrapper.usage.add(new_response.usage)
1676+
context_wrapper.usage.add(
1677+
new_response.usage,
1678+
agent_name=agent.name,
1679+
model_name=_get_model_name(model),
1680+
)
16601681

16611682
await asyncio.gather(
16621683
(

src/agents/usage.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def deserialize_usage(usage_data: Mapping[str, Any]) -> Usage:
4343
entry.get("output_tokens_details") or {"reasoning_tokens": 0},
4444
OutputTokensDetails(reasoning_tokens=0),
4545
),
46+
agent_name=entry.get("agent_name", None),
47+
model_name=entry.get("model_name", None),
4648
)
4749
)
4850

@@ -76,6 +78,20 @@ class RequestUsage:
7678
output_tokens_details: OutputTokensDetails
7779
"""Details about the output tokens for this individual request."""
7880

81+
agent_name: str | None = None
82+
"""Name of the agent that made this request, if available.
83+
84+
Populated automatically when an agent makes a model call so that callers can attribute
85+
token usage and costs to specific agents in multi-agent workflows.
86+
"""
87+
88+
model_name: str | None = None
89+
"""Name of the model used for this request, if available.
90+
91+
Populated automatically when an agent makes a model call so that callers can attribute
92+
token usage and costs to specific models.
93+
"""
94+
7995

8096
def _normalize_input_tokens_details(
8197
v: InputTokensDetails | PromptTokensDetails | None,
@@ -154,13 +170,23 @@ def __post_init__(self) -> None:
154170
if output_details_none or output_reasoning_none:
155171
self.output_tokens_details = OutputTokensDetails(reasoning_tokens=0)
156172

157-
def add(self, other: Usage) -> None:
173+
def add(
174+
self,
175+
other: Usage,
176+
*,
177+
agent_name: str | None = None,
178+
model_name: str | None = None,
179+
) -> None:
158180
"""Add another Usage object to this one, aggregating all fields.
159181
160182
This method automatically preserves request_usage_entries.
161183
162184
Args:
163185
other: The Usage object to add to this one.
186+
agent_name: Optional name of the agent making this request, used to annotate the
187+
resulting ``RequestUsage`` entry for per-agent cost attribution.
188+
model_name: Optional name of the model used for this request, used to annotate the
189+
resulting ``RequestUsage`` entry for per-model cost attribution.
164190
"""
165191
self.requests += other.requests if other.requests else 0
166192
self.input_tokens += other.input_tokens if other.input_tokens else 0
@@ -206,10 +232,18 @@ def add(self, other: Usage) -> None:
206232
total_tokens=other.total_tokens,
207233
input_tokens_details=input_details,
208234
output_tokens_details=output_details,
235+
agent_name=agent_name,
236+
model_name=model_name,
209237
)
210238
self.request_usage_entries.append(request_usage)
211239
elif other.request_usage_entries:
212240
# If the other Usage already has individual request breakdowns, merge them.
241+
# Apply agent_name/model_name to entries that don't already have them set.
242+
for entry in other.request_usage_entries:
243+
if agent_name is not None and entry.agent_name is None:
244+
entry.agent_name = agent_name
245+
if model_name is not None and entry.model_name is None:
246+
entry.model_name = model_name
213247
self.request_usage_entries.extend(other.request_usage_entries)
214248

215249

@@ -228,7 +262,7 @@ def serialize_usage(usage: Usage) -> dict[str, Any]:
228262
output_details = _serialize_usage_details(usage.output_tokens_details, {"reasoning_tokens": 0})
229263

230264
def _serialize_request_entry(entry: RequestUsage) -> dict[str, Any]:
231-
return {
265+
result: dict[str, Any] = {
232266
"input_tokens": entry.input_tokens,
233267
"output_tokens": entry.output_tokens,
234268
"total_tokens": entry.total_tokens,
@@ -239,6 +273,11 @@ def _serialize_request_entry(entry: RequestUsage) -> dict[str, Any]:
239273
entry.output_tokens_details, {"reasoning_tokens": 0}
240274
),
241275
}
276+
if entry.agent_name is not None:
277+
result["agent_name"] = entry.agent_name
278+
if entry.model_name is not None:
279+
result["model_name"] = entry.model_name
280+
return result
242281

243282
return {
244283
"requests": usage.requests,

tests/test_usage.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,236 @@ def test_usage_normalizes_chat_completions_types():
377377

378378
assert isinstance(usage.output_tokens_details, OutputTokensDetails)
379379
assert usage.output_tokens_details.reasoning_tokens == 100
380+
381+
382+
# ============================================================================
383+
# Tests for agent_name and model_name on RequestUsage (issue #2100)
384+
# ============================================================================
385+
386+
387+
def test_request_usage_default_agent_model_names_are_none():
388+
"""Backward-compat: RequestUsage without agent_name/model_name defaults to None."""
389+
entry = RequestUsage(
390+
input_tokens=10,
391+
output_tokens=5,
392+
total_tokens=15,
393+
input_tokens_details=InputTokensDetails(cached_tokens=0),
394+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
395+
)
396+
assert entry.agent_name is None
397+
assert entry.model_name is None
398+
399+
400+
def test_request_usage_with_agent_and_model_names():
401+
"""RequestUsage can be created with explicit agent_name and model_name."""
402+
entry = RequestUsage(
403+
input_tokens=10,
404+
output_tokens=5,
405+
total_tokens=15,
406+
input_tokens_details=InputTokensDetails(cached_tokens=0),
407+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
408+
agent_name="Math Tutor",
409+
model_name="gpt-4o",
410+
)
411+
assert entry.agent_name == "Math Tutor"
412+
assert entry.model_name == "gpt-4o"
413+
414+
415+
def test_usage_add_propagates_agent_and_model_names():
416+
"""Usage.add() with agent_name/model_name annotates the RequestUsage entry."""
417+
parent = Usage()
418+
child = Usage(
419+
requests=1,
420+
input_tokens=65,
421+
output_tokens=13,
422+
total_tokens=78,
423+
input_tokens_details=InputTokensDetails(cached_tokens=0),
424+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
425+
)
426+
parent.add(child, agent_name="Code Reviewer", model_name="gpt-4o-mini")
427+
428+
assert len(parent.request_usage_entries) == 1
429+
entry = parent.request_usage_entries[0]
430+
assert entry.agent_name == "Code Reviewer"
431+
assert entry.model_name == "gpt-4o-mini"
432+
assert entry.input_tokens == 65
433+
assert entry.output_tokens == 13
434+
435+
436+
def test_usage_add_without_agent_model_names_stays_none():
437+
"""Usage.add() without agent/model names leaves them as None (backward compat)."""
438+
parent = Usage()
439+
child = Usage(
440+
requests=1,
441+
input_tokens=20,
442+
output_tokens=10,
443+
total_tokens=30,
444+
input_tokens_details=InputTokensDetails(cached_tokens=0),
445+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
446+
)
447+
parent.add(child)
448+
449+
assert len(parent.request_usage_entries) == 1
450+
entry = parent.request_usage_entries[0]
451+
assert entry.agent_name is None
452+
assert entry.model_name is None
453+
454+
455+
def test_usage_add_merge_existing_entries_applies_agent_model_names():
456+
"""When merging existing request_usage_entries, agent/model names are applied to unset ones."""
457+
# An existing entry without names
458+
existing_entry = RequestUsage(
459+
input_tokens=100,
460+
output_tokens=50,
461+
total_tokens=150,
462+
input_tokens_details=InputTokensDetails(cached_tokens=0),
463+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
464+
)
465+
parent = Usage()
466+
child = Usage(
467+
requests=2, # not 1, so it won't auto-create a new entry
468+
input_tokens=100,
469+
output_tokens=50,
470+
total_tokens=150,
471+
input_tokens_details=InputTokensDetails(cached_tokens=0),
472+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
473+
request_usage_entries=[existing_entry],
474+
)
475+
parent.add(child, agent_name="Triage Agent", model_name="gpt-4o")
476+
477+
assert len(parent.request_usage_entries) == 1
478+
assert parent.request_usage_entries[0].agent_name == "Triage Agent"
479+
assert parent.request_usage_entries[0].model_name == "gpt-4o"
480+
481+
482+
def test_usage_add_merge_existing_entries_does_not_overwrite_names():
483+
"""Existing agent/model names on entries are not overwritten during merge."""
484+
existing_entry = RequestUsage(
485+
input_tokens=100,
486+
output_tokens=50,
487+
total_tokens=150,
488+
input_tokens_details=InputTokensDetails(cached_tokens=0),
489+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
490+
agent_name="Already Named Agent",
491+
model_name="already-named-model",
492+
)
493+
parent = Usage()
494+
child = Usage(
495+
requests=2,
496+
input_tokens=100,
497+
output_tokens=50,
498+
total_tokens=150,
499+
input_tokens_details=InputTokensDetails(cached_tokens=0),
500+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
501+
request_usage_entries=[existing_entry],
502+
)
503+
parent.add(child, agent_name="New Agent Name", model_name="new-model")
504+
505+
# The existing names should NOT be overwritten
506+
assert parent.request_usage_entries[0].agent_name == "Already Named Agent"
507+
assert parent.request_usage_entries[0].model_name == "already-named-model"
508+
509+
510+
@pytest.mark.asyncio
511+
async def test_runner_run_populates_agent_name_in_request_usage():
512+
"""Integration: Running an agent populates agent_name in RequestUsage entries."""
513+
from agents.usage import Usage as AgentUsage
514+
515+
model_usage = AgentUsage(
516+
requests=1,
517+
input_tokens=42,
518+
output_tokens=8,
519+
total_tokens=50,
520+
input_tokens_details=InputTokensDetails(cached_tokens=0),
521+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
522+
)
523+
fake = FakeModel(initial_output=[get_text_message("hello")])
524+
fake.set_hardcoded_usage(model_usage)
525+
agent = Agent(name="My Assistant", model=fake)
526+
527+
result = await Runner.run(agent, input="hi")
528+
529+
entries = result.context_wrapper.usage.request_usage_entries
530+
assert len(entries) == 1
531+
assert entries[0].agent_name == "My Assistant"
532+
533+
534+
@pytest.mark.asyncio
535+
async def test_runner_run_populates_model_name_in_request_usage():
536+
"""Integration: Running an agent populates model_name in RequestUsage entries."""
537+
from agents.usage import Usage as AgentUsage
538+
539+
model_usage = AgentUsage(
540+
requests=1,
541+
input_tokens=30,
542+
output_tokens=10,
543+
total_tokens=40,
544+
input_tokens_details=InputTokensDetails(cached_tokens=0),
545+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
546+
)
547+
# FakeModel doesn't expose a `.model` attribute by default → model_name should be None
548+
# We give it one to test that model_name is picked up.
549+
fake = FakeModel(initial_output=[get_text_message("ok")])
550+
fake.model = "test-model-name" # type: ignore[attr-defined]
551+
fake.set_hardcoded_usage(model_usage)
552+
agent = Agent(name="Model-Aware Agent", model=fake)
553+
554+
result = await Runner.run(agent, input="ping")
555+
556+
entries = result.context_wrapper.usage.request_usage_entries
557+
assert len(entries) == 1
558+
assert entries[0].model_name == "test-model-name"
559+
560+
561+
@pytest.mark.asyncio
562+
async def test_multi_agent_run_attributes_usage_to_correct_agents():
563+
"""Multi-agent scenario: each RequestUsage entry has the right agent_name."""
564+
565+
from agents.usage import Usage as AgentUsage
566+
from tests.test_responses import get_handoff_tool_call
567+
568+
# Two separate models so we can track which agent's usage is which
569+
triage_usage = AgentUsage(
570+
requests=1,
571+
input_tokens=100,
572+
output_tokens=10,
573+
total_tokens=110,
574+
input_tokens_details=InputTokensDetails(cached_tokens=0),
575+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
576+
)
577+
specialist_usage = AgentUsage(
578+
requests=1,
579+
input_tokens=200,
580+
output_tokens=20,
581+
total_tokens=220,
582+
input_tokens_details=InputTokensDetails(cached_tokens=0),
583+
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
584+
)
585+
586+
specialist_model = FakeModel(initial_output=[get_text_message("specialist done")])
587+
specialist_model.set_hardcoded_usage(specialist_usage)
588+
specialist_agent = Agent(name="Specialist Agent", model=specialist_model)
589+
590+
triage_model = FakeModel()
591+
triage_model.add_multiple_turn_outputs(
592+
[
593+
[get_handoff_tool_call(specialist_agent)],
594+
]
595+
)
596+
triage_model.set_hardcoded_usage(triage_usage)
597+
triage_agent = Agent(name="Triage Agent", model=triage_model, handoffs=[specialist_agent])
598+
599+
result = await Runner.run(triage_agent, input="route me")
600+
601+
all_entries = result.context_wrapper.usage.request_usage_entries
602+
assert len(all_entries) == 2, f"Expected 2 request entries, got {len(all_entries)}"
603+
604+
agent_names = [e.agent_name for e in all_entries]
605+
assert "Triage Agent" in agent_names, f"Expected 'Triage Agent' in {agent_names}"
606+
assert "Specialist Agent" in agent_names, f"Expected 'Specialist Agent' in {agent_names}"
607+
608+
triage_entry = next(e for e in all_entries if e.agent_name == "Triage Agent")
609+
assert triage_entry.input_tokens == 100
610+
611+
specialist_entry = next(e for e in all_entries if e.agent_name == "Specialist Agent")
612+
assert specialist_entry.input_tokens == 200

0 commit comments

Comments
 (0)