diff --git a/openhands-sdk/openhands/sdk/llm/utils/telemetry.py b/openhands-sdk/openhands/sdk/llm/utils/telemetry.py index b9a39059fe..d50a95adb1 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/telemetry.py +++ b/openhands-sdk/openhands/sdk/llm/utils/telemetry.py @@ -253,14 +253,24 @@ def _cache_buckets(usage: Usage | ResponseAPIUsage) -> tuple[int, int]: """ if isinstance(usage, Usage): details = usage.prompt_tokens_details - if details is None: - return 0, 0 + cache_read = int(details.cached_tokens or 0) if details is not None else 0 + if not cache_read: + priv = usage.__pydantic_private__ or {} + cache_read = int(priv.get("_cache_read_input_tokens", 0) or 0) + if not cache_read: + extra = usage.model_extra or {} + cache_read = int(extra.get("cached_tokens", 0) or 0) + cache_write = ( - details.cache_creation_tokens - if "cache_creation_tokens" in details.model_fields_set + int(details.cache_creation_tokens or 0) + if details is not None + and "cache_creation_tokens" in details.model_fields_set else 0 ) - return int(details.cached_tokens or 0), int(cache_write or 0) + if not cache_write: + priv = usage.__pydantic_private__ or {} + cache_write = int(priv.get("_cache_creation_input_tokens", 0) or 0) + return cache_read, cache_write details = usage.input_tokens_details cache_read = details.cached_tokens if details is not None else 0 diff --git a/tests/sdk/llm/test_llm_telemetry.py b/tests/sdk/llm/test_llm_telemetry.py index 84de822c20..00a2c4d3a8 100644 --- a/tests/sdk/llm/test_llm_telemetry.py +++ b/tests/sdk/llm/test_llm_telemetry.py @@ -195,6 +195,51 @@ def test_record_usage_with_cache_write(self, basic_telemetry): token_usage = basic_telemetry.metrics.token_usages[0] assert token_usage.cache_write_tokens == 30 + def test_record_usage_cache_read_kimi_cached_tokens(self, basic_telemetry): + """Cache read via ``usage.cached_tokens`` (Kimi-K2 / DeepSeek style). + + These providers put ``cached_tokens`` directly on the Usage object + instead of inside ``prompt_tokens_details``. + """ + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + assert usage.model_extra is not None + usage.model_extra["cached_tokens"] = 80 + + basic_telemetry._record_usage(usage, "test-id", 4096) + + token_usage = basic_telemetry.metrics.token_usages[0] + assert token_usage.cache_read_tokens == 80 + + def test_record_usage_cache_read_anthropic_private_attr(self, basic_telemetry): + """Cache read via ``_cache_read_input_tokens`` private attr. + + litellm sets this during streaming for Anthropic responses where + ``prompt_tokens_details`` may be absent. + """ + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + usage._cache_read_input_tokens = 80 + + basic_telemetry._record_usage(usage, "test-id", 4096) + + token_usage = basic_telemetry.metrics.token_usages[0] + assert token_usage.cache_read_tokens == 80 + + def test_record_usage_cache_write_anthropic_private_attr(self, basic_telemetry): + """Cache write via ``_cache_creation_input_tokens`` private attr. + + litellm sets this during streaming for Anthropic responses. + ``prompt_tokens_details.cache_creation_tokens`` is NOT in + ``model_fields_set`` in this case, so the private attr is the only + source of truth. + """ + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + usage._cache_creation_input_tokens = 200 + + basic_telemetry._record_usage(usage, "test-id", 4096) + + token_usage = basic_telemetry.metrics.token_usages[0] + assert token_usage.cache_write_tokens == 200 + def test_record_usage_missing_tokens(self, basic_telemetry): """Test token usage recording with missing token counts.""" usage = Usage() # Empty usage