From 8892025ed715186c8b4b7542443633e89484c419 Mon Sep 17 00:00:00 2001 From: Giulio Lovisotto Date: Tue, 6 Oct 2026 23:45:49 +0200 Subject: [PATCH] fix(hermes): report native token usage Signed-off-by: Giulio Lovisotto --- responses_api_agents/hermes_agent/app.py | 24 +++++--- .../hermes_agent/tests/test_app.py | 55 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/responses_api_agents/hermes_agent/app.py b/responses_api_agents/hermes_agent/app.py index 45355923db7..990f6fd13b7 100644 --- a/responses_api_agents/hermes_agent/app.py +++ b/responses_api_agents/hermes_agent/app.py @@ -82,6 +82,22 @@ from responses_api_agents.hermes_agent.observability import HermesAgentObserver, normalize_hermes_messages +def _usage_from_result(result: dict[str, Any]) -> Optional[NeMoGymResponseUsage]: + # Hermes' prompt total already includes cache reads/writes, and its completion + # total includes reasoning. Early returns can omit these native aggregates. + prompt_tokens = result.get("prompt_tokens") + completion_tokens = result.get("completion_tokens") + if prompt_tokens is None or completion_tokens is None: + return None + return NeMoGymResponseUsage( + input_tokens=prompt_tokens, + input_tokens_details=NeMoGymResponseInputTokensDetails(cached_tokens=result.get("cache_read_tokens")), + output_tokens=completion_tokens, + output_tokens_details=NeMoGymResponseOutputTokensDetails(reasoning_tokens=result.get("reasoning_tokens")), + total_tokens=prompt_tokens + completion_tokens, + ) + + def _trajectory_to_output_items(messages, n_input): output_items = [] for item in messages[n_input:]: @@ -951,13 +967,7 @@ def _response_from_result( tool_choice=body.tool_choice, tools=body.tools, parallel_tool_calls=body.parallel_tool_calls, - usage=NeMoGymResponseUsage( - input_tokens=0, - input_tokens_details=NeMoGymResponseInputTokensDetails(cached_tokens=0), - output_tokens=0, - output_tokens_details=NeMoGymResponseOutputTokensDetails(reasoning_tokens=0), - total_tokens=0, - ), + usage=_usage_from_result(result), ) async def _create_response( diff --git a/responses_api_agents/hermes_agent/tests/test_app.py b/responses_api_agents/hermes_agent/tests/test_app.py index be41c6c607a..fcc9a5ebcb4 100644 --- a/responses_api_agents/hermes_agent/tests/test_app.py +++ b/responses_api_agents/hermes_agent/tests/test_app.py @@ -679,6 +679,61 @@ def run_conversation(self, *args, **kwargs) -> dict: assert hermes.interrupted_agents == set() +class TestResultUsage: + @pytest.mark.parametrize("partial", [False, True]) + def test_reports_cache_inclusive_native_totals(self, partial) -> None: + hermes = HermesAgent(config=_config(), server_client=MagicMock(spec=ServerClient)) + response = hermes._response_from_result( + body=NeMoGymResponseCreateParamsNonStreaming(input="hi"), + result={ + "messages": [{"role": "assistant", "content": "done"}], + "partial": partial, + "input_tokens": 100, + "cache_read_tokens": 200, + "cache_write_tokens": 300, + "prompt_tokens": 600, + "completion_tokens": 80, + "reasoning_tokens": 30, + }, + model_name="model", + ) + assert response.usage.input_tokens == 600 + assert response.usage.input_tokens_details.cached_tokens == 200 + assert response.usage.output_tokens == 80 + assert response.usage.output_tokens_details.reasoning_tokens == 30 + assert response.usage.total_tokens == 680 + + @pytest.mark.parametrize( + "usage", + [{}, {"prompt_tokens": 10}, {"completion_tokens": 20}, {"prompt_tokens": None, "completion_tokens": 20}], + ) + def test_missing_native_totals_remain_unknown(self, usage) -> None: + hermes = HermesAgent(config=_config(), server_client=MagicMock(spec=ServerClient)) + response = hermes._response_from_result( + body=NeMoGymResponseCreateParamsNonStreaming(input="hi"), + result={"messages": [{"role": "assistant", "content": "partial answer"}], **usage}, + model_name="model", + ) + assert response.usage is None + + def test_missing_details_remain_unknown(self) -> None: + hermes = HermesAgent(config=_config(), server_client=MagicMock(spec=ServerClient)) + response = hermes._response_from_result( + body=NeMoGymResponseCreateParamsNonStreaming(input="hi"), + result={ + "messages": [{"role": "assistant", "content": "done"}], + "prompt_tokens": 10, + "completion_tokens": 20, + }, + model_name="model", + ) + assert response.usage.input_tokens == 10 + assert response.usage.output_tokens == 20 + assert response.usage.total_tokens == 30 + assert response.usage.input_tokens_details.cached_tokens is None + assert response.usage.output_tokens_details.reasoning_tokens is None + + class TestResultClassification: @pytest.mark.parametrize( "error",