From 8c15649127b3714b2b8f1903968163db70e6b90c Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Fri, 3 Apr 2026 11:46:36 -0400 Subject: [PATCH] fix(openai,groq,openrouter): use is-not-None checks in usage metadata token extraction (#36500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's `or` operator treats `0` as falsy, so `token_usage.get("total_tokens") or fallback` silently replaces a provider-reported `total_tokens=0` with the computed sum of input + output tokens. Providers can legitimately report zero tokens (e.g., cached responses, empty completions). The same pattern exists in the dual-key lookups for `input_tokens`/`output_tokens` in Groq and OpenRouter. While current APIs don't return both key formats simultaneously (making the `or`-chain functionally correct today), the semantics are still wrong; `0` should not fall through to a fallback. ## Changes - Replace `x.get(key) or fallback` with explicit `is not None` checks in `_create_usage_metadata` across `langchain-openai`, `langchain-groq`, and `langchain-openrouter` for `input_tokens`, `output_tokens`, and `total_tokens` - Fix a concrete bug in the `total_tokens` path: a provider-reported `0` was silently replaced by the computed sum - Harden dual-key lookups in Groq and OpenRouter to correctly preserve zero values from the preferred key, should both key formats ever coexist - Update OpenAI's single-key extraction for consistency — the old `or 0` pattern happened to produce correct results (`0 or 0 == 0`) but was semantically wrong --- .../groq/langchain_groq/chat_models.py | 15 +++---- .../groq/tests/unit_tests/test_chat_models.py | 41 +++++++++++++++++++ libs/partners/groq/uv.lock | 2 +- .../langchain_openai/chat_models/base.py | 9 ++-- .../tests/unit_tests/chat_models/test_base.py | 13 ++++++ libs/partners/openai/uv.lock | 18 ++++---- libs/partners/openrouter/Makefile | 2 +- .../langchain_openrouter/chat_models.py | 9 ++-- .../tests/unit_tests/test_chat_models.py | 35 ++++++++++++++++ libs/partners/openrouter/uv.lock | 2 +- 10 files changed, 121 insertions(+), 25 deletions(-) diff --git a/libs/partners/groq/langchain_groq/chat_models.py b/libs/partners/groq/langchain_groq/chat_models.py index d1fb4be8ab5..a4763883393 100644 --- a/libs/partners/groq/langchain_groq/chat_models.py +++ b/libs/partners/groq/langchain_groq/chat_models.py @@ -1576,17 +1576,18 @@ def _create_usage_metadata(groq_token_usage: dict) -> UsageMetadata: """ # Support both formats: new Responses API uses "input_tokens", # Chat Completions API uses "prompt_tokens" + _input = groq_token_usage.get("input_tokens") input_tokens = ( - groq_token_usage.get("input_tokens") - or groq_token_usage.get("prompt_tokens") - or 0 + _input if _input is not None else (groq_token_usage.get("prompt_tokens") or 0) ) + _output = groq_token_usage.get("output_tokens") output_tokens = ( - groq_token_usage.get("output_tokens") - or groq_token_usage.get("completion_tokens") - or 0 + _output + if _output is not None + else (groq_token_usage.get("completion_tokens") or 0) ) - total_tokens = groq_token_usage.get("total_tokens") or input_tokens + output_tokens + _total = groq_token_usage.get("total_tokens") + total_tokens = _total if _total is not None else input_tokens + output_tokens # Support both formats for token details: # Responses API uses "*_tokens_details", Chat Completions API might use diff --git a/libs/partners/groq/tests/unit_tests/test_chat_models.py b/libs/partners/groq/tests/unit_tests/test_chat_models.py index 1848f5f7ff3..33f4448bd5e 100644 --- a/libs/partners/groq/tests/unit_tests/test_chat_models.py +++ b/libs/partners/groq/tests/unit_tests/test_chat_models.py @@ -460,6 +460,47 @@ def test_create_usage_metadata_missing_total_tokens() -> None: assert result["total_tokens"] == 150 +def test_create_usage_metadata_zero_total_tokens() -> None: + """Test that explicit total_tokens=0 is preserved, not replaced by sum.""" + token_usage = { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 0, + } + + result = _create_usage_metadata(token_usage) + + assert result["total_tokens"] == 0 + + +def test_create_usage_metadata_zero_input_tokens_preferred_key() -> None: + """Test that input_tokens=0 is not overridden by prompt_tokens fallback.""" + token_usage = { + "input_tokens": 0, + "prompt_tokens": 50, + "completion_tokens": 5, + "total_tokens": 55, + } + + result = _create_usage_metadata(token_usage) + + assert result["input_tokens"] == 0 + + +def test_create_usage_metadata_zero_output_tokens_preferred_key() -> None: + """Test that output_tokens=0 is not overridden by completion_tokens fallback.""" + token_usage = { + "input_tokens": 10, + "output_tokens": 0, + "completion_tokens": 50, + "total_tokens": 60, + } + + result = _create_usage_metadata(token_usage) + + assert result["output_tokens"] == 0 + + def test_create_usage_metadata_empty_details() -> None: """Test that empty detail dicts don't create token detail objects.""" token_usage = { diff --git a/libs/partners/groq/uv.lock b/libs/partners/groq/uv.lock index 72f7416727a..c0ea778bd36 100644 --- a/libs/partners/groq/uv.lock +++ b/libs/partners/groq/uv.lock @@ -317,7 +317,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.23" +version = "1.2.25" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 022365da8da..cd39e366797 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -3837,9 +3837,12 @@ class OpenAIRefusalError(Exception): def _create_usage_metadata( oai_token_usage: dict, service_tier: str | None = None ) -> UsageMetadata: - input_tokens = oai_token_usage.get("prompt_tokens") or 0 - output_tokens = oai_token_usage.get("completion_tokens") or 0 - total_tokens = oai_token_usage.get("total_tokens") or input_tokens + output_tokens + _input = oai_token_usage.get("prompt_tokens") + input_tokens = _input if _input is not None else 0 + _output = oai_token_usage.get("completion_tokens") + output_tokens = _output if _output is not None else 0 + _total = oai_token_usage.get("total_tokens") + total_tokens = _total if _total is not None else input_tokens + output_tokens if service_tier not in {"priority", "flex"}: service_tier = None service_tier_prefix = f"{service_tier}_" if service_tier else "" diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index adbceddde5f..7b0253e93f1 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -1087,6 +1087,19 @@ def test__create_usage_metadata() -> None: ) +def test__create_usage_metadata_zero_total_tokens() -> None: + """Test that explicit total_tokens=0 is preserved, not replaced by sum.""" + usage_metadata = { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 0, + "prompt_tokens_details": None, + "completion_tokens_details": None, + } + result = _create_usage_metadata(usage_metadata) + assert result["total_tokens"] == 0 + + def test__create_usage_metadata_responses() -> None: response_usage_metadata = { "input_tokens": 100, diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index 587b4e76036..b61c087e5ef 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -550,7 +550,7 @@ wheels = [ [[package]] name = "langchain" -version = "1.2.13" +version = "1.2.15" source = { editable = "../../langchain_v1" } dependencies = [ { name = "langchain-core" }, @@ -578,7 +578,7 @@ requires-dist = [ { name = "langchain-perplexity", marker = "extra == 'perplexity'" }, { name = "langchain-together", marker = "extra == 'together'" }, { name = "langchain-xai", marker = "extra == 'xai'" }, - { name = "langgraph", specifier = ">=1.1.1,<1.2.0" }, + { name = "langgraph", specifier = ">=1.1.5,<1.2.0" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ] provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity"] @@ -614,7 +614,7 @@ typing = [ [[package]] name = "langchain-core" -version = "1.2.23" +version = "1.2.25" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, @@ -806,7 +806,7 @@ typing = [ [[package]] name = "langgraph" -version = "1.1.2" +version = "1.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -816,9 +816,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/a8/8494057db9149eb850258e5d4ae961a8dbda9a283e56e1b957393d9df0cd/langgraph-1.1.2.tar.gz", hash = "sha256:c4385ce349823a590891b3f6b1c46b54f51d0134164056866e95034985f047c9", size = 544288, upload-time = "2026-03-12T17:11:17.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/8a/47b983e33d3afc8c2c2385d2d8f3731ddfb5cb08e88f307f75105252a94c/langgraph-1.1.5.tar.gz", hash = "sha256:24b85d2d40cd002766d489e76f18027f947e4151366ac7ed97bab030ce50e494", size = 548492, upload-time = "2026-04-03T14:12:33.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/38/3117cd90325635893a76132cdae74f5b1f53c93c33b3dc6124521cec9825/langgraph-1.1.2-py3-none-any.whl", hash = "sha256:5fd43c839ec2b5af564e9ae2d2d4f22ce0a006a0b58e800cc4e8de4dd9cbb643", size = 167543, upload-time = "2026-03-12T17:11:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6a/542bb56c8270d3df858285be138aec5e292b4e43dadb6b0b6fe051f535c1/langgraph-1.1.5-py3-none-any.whl", hash = "sha256:cb25c20d135167837951906c0feeb26c91c733bd5001a920c4cb1ffb92a1097c", size = 169354, upload-time = "2026-04-03T14:12:31.879Z" }, ] [[package]] @@ -836,15 +836,15 @@ wheels = [ [[package]] name = "langgraph-prebuilt" -version = "1.0.8" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph-checkpoint" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/06/dd61a5c2dce009d1b03b1d56f2a85b3127659fdddf5b3be5d8f1d60820fb/langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69", size = 164442, upload-time = "2026-02-19T18:14:39.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/4c/06dac899f4945bedb0c3a1583c19484c2cc894114ea30d9a538dd270086e/langgraph_prebuilt-1.0.9.tar.gz", hash = "sha256:93de7512e9caade4b77ead92428f6215c521fdb71b8ffda8cd55f0ad814e64de", size = 165850, upload-time = "2026-04-03T14:06:37.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/41/ec966424ad3f2ed3996d24079d3342c8cd6c0bd0653c12b2a917a685ec6c/langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0", size = 35648, upload-time = "2026-02-19T18:14:37.611Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/8368ac187b75e7f9d938ca075d34f116683f5cfc48d924029ee79aea147b/langgraph_prebuilt-1.0.9-py3-none-any.whl", hash = "sha256:776c8e3154a5aef5ad0e5bf3f263f2dcaab3983786cc20014b7f955d99d2d1b2", size = 35958, upload-time = "2026-04-03T14:06:36.58Z" }, ] [[package]] diff --git a/libs/partners/openrouter/Makefile b/libs/partners/openrouter/Makefile index b8f8f350a08..eff6f358884 100644 --- a/libs/partners/openrouter/Makefile +++ b/libs/partners/openrouter/Makefile @@ -21,7 +21,7 @@ test_watch: # integration tests are run without the --disable-socket flag to allow network calls integration_test integration_tests: - uv run --group test --group test_integration pytest --timeout=30 $(TEST_FILE) + uv run --group test --group test_integration pytest --timeout=120 $(TEST_FILE) ###################### # LINTING AND FORMATTING diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 4add332fddc..06bc6c9311e 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -1389,13 +1389,16 @@ def _create_usage_metadata(token_usage: dict[str, Any]) -> UsageMetadata: Returns: Usage metadata with input/output token details. """ + _input = token_usage.get("prompt_tokens") input_tokens = int( - token_usage.get("prompt_tokens") or token_usage.get("input_tokens") or 0 + _input if _input is not None else (token_usage.get("input_tokens") or 0) ) + _output = token_usage.get("completion_tokens") output_tokens = int( - token_usage.get("completion_tokens") or token_usage.get("output_tokens") or 0 + _output if _output is not None else (token_usage.get("output_tokens") or 0) ) - total_tokens = int(token_usage.get("total_tokens") or input_tokens + output_tokens) + _total = token_usage.get("total_tokens") + total_tokens = int(_total if _total is not None else input_tokens + output_tokens) input_details_dict = ( token_usage.get("prompt_tokens_details") diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index c2926a8f191..0a52a1a198e 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -1575,6 +1575,41 @@ class TestCreateChatResult: assert isinstance(usage["output_token_details"]["reasoning"], int) +class TestCreateUsageMetadataZeroTotal: + """Test that explicit total_tokens=0 is preserved, not replaced by sum.""" + + def test_zero_total_tokens_preserved(self) -> None: + token_usage = { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 0, + } + result = _create_usage_metadata(token_usage) + assert result["total_tokens"] == 0 + + def test_zero_input_tokens_preferred_key(self) -> None: + """prompt_tokens=0 must not fall through to input_tokens.""" + token_usage = { + "prompt_tokens": 0, + "input_tokens": 50, + "completion_tokens": 5, + "total_tokens": 55, + } + result = _create_usage_metadata(token_usage) + assert result["input_tokens"] == 0 + + def test_zero_output_tokens_preferred_key(self) -> None: + """completion_tokens=0 must not fall through to output_tokens.""" + token_usage = { + "prompt_tokens": 10, + "completion_tokens": 0, + "output_tokens": 50, + "total_tokens": 60, + } + result = _create_usage_metadata(token_usage) + assert result["output_tokens"] == 0 + + # =========================================================================== # Streaming chunk tests # =========================================================================== diff --git a/libs/partners/openrouter/uv.lock b/libs/partners/openrouter/uv.lock index 7594693bdf4..ada52d6ad6e 100644 --- a/libs/partners/openrouter/uv.lock +++ b/libs/partners/openrouter/uv.lock @@ -337,7 +337,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.23" +version = "1.2.25" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" },