revenium-python-sdk 0.1.7__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {revenium_python_sdk-0.1.7/revenium_python_sdk.egg-info → revenium_python_sdk-0.1.8}/PKG-INFO +1 -1
  2. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/pyproject.toml +1 -1
  3. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/anthropic/bedrock_adapter.py +142 -47
  4. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/anthropic/middleware.py +9 -6
  5. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/langchain/unified_handler.py +99 -20
  6. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/middleware.py +107 -29
  7. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8/revenium_python_sdk.egg-info}/PKG-INFO +1 -1
  8. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/LICENSE +0 -0
  9. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/README.md +0 -0
  10. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/__init__.py +0 -0
  11. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/__init__.py +0 -0
  12. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/config.py +0 -0
  13. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/context.py +0 -0
  14. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/decorators.py +0 -0
  15. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/enforcement.py +0 -0
  16. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/exceptions.py +0 -0
  17. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/fields.py +0 -0
  18. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/metering.py +0 -0
  19. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/metering_submission.py +0 -0
  20. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/patch_registry.py +0 -0
  21. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/prompt_extraction.py +0 -0
  22. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/subscriber.py +0 -0
  23. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/_core/trace_fields.py +0 -0
  24. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/agentic_outcomes.py +0 -0
  25. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/anthropic/__init__.py +0 -0
  26. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/anthropic/config.py +0 -0
  27. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/anthropic/prompt_extractor.py +0 -0
  28. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/anthropic/provider.py +0 -0
  29. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/anthropic/trace_fields.py +0 -0
  30. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/fal/__init__.py +0 -0
  31. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/fal/_metering.py +0 -0
  32. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/fal/config.py +0 -0
  33. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/fal/middleware.py +0 -0
  34. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/fal/trace_fields.py +0 -0
  35. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/__init__.py +0 -0
  36. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/common/__init__.py +0 -0
  37. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/common/exceptions.py +0 -0
  38. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/common/protocols.py +0 -0
  39. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/common/trace_fields.py +0 -0
  40. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/common/types.py +0 -0
  41. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/common/utils.py +0 -0
  42. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/config.py +0 -0
  43. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/google_ai/__init__.py +0 -0
  44. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/google_ai/middleware.py +0 -0
  45. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/google_ai/provider.py +0 -0
  46. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/prompt_extractor.py +0 -0
  47. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/vertex_ai/__init__.py +0 -0
  48. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/vertex_ai/middleware.py +0 -0
  49. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/google/vertex_ai/provider.py +0 -0
  50. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/__init__.py +0 -0
  51. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/__init__.py +0 -0
  52. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/config.py +0 -0
  53. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/context.py +0 -0
  54. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/decorators.py +0 -0
  55. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/hooks.py +0 -0
  56. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/integrations/__init__.py +0 -0
  57. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/integrations/crewai.py +0 -0
  58. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/middleware.py +0 -0
  59. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/trace_fields.py +0 -0
  60. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/client/validation.py +0 -0
  61. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/proxy/__init__.py +0 -0
  62. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/litellm/proxy/middleware.py +0 -0
  63. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/ollama/__init__.py +0 -0
  64. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/ollama/middleware.py +0 -0
  65. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/ollama/trace_fields.py +0 -0
  66. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/__init__.py +0 -0
  67. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/azure_config.py +0 -0
  68. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/azure_model_resolver.py +0 -0
  69. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/config.py +0 -0
  70. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/exceptions.py +0 -0
  71. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/langchain/__init__.py +0 -0
  72. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/langchain/_utils.py +0 -0
  73. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/prompt_extractor.py +0 -0
  74. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/provider.py +0 -0
  75. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/openai/trace_fields.py +0 -0
  76. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/perplexity/__init__.py +0 -0
  77. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/perplexity/middleware.py +0 -0
  78. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/perplexity/perplexity_sdk.py +0 -0
  79. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/perplexity/provider.py +0 -0
  80. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/perplexity/trace_fields.py +0 -0
  81. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/webhooks/__init__.py +0 -0
  82. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_middleware/webhooks/_verify.py +0 -0
  83. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_python_sdk.egg-info/SOURCES.txt +0 -0
  84. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_python_sdk.egg-info/dependency_links.txt +0 -0
  85. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_python_sdk.egg-info/requires.txt +0 -0
  86. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/revenium_python_sdk.egg-info/top_level.txt +0 -0
  87. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/setup.cfg +0 -0
  88. {revenium_python_sdk-0.1.7 → revenium_python_sdk-0.1.8}/tests/test_metering.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revenium-python-sdk
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: The official Revenium Python SDK — unified AI metering middleware for OpenAI, Anthropic, Google, Ollama, LiteLLM, Perplexity, and fal.ai.
5
5
  Author-email: Revenium <support@revenium.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "revenium-python-sdk"
7
- version = "0.1.7"
7
+ version = "0.1.8"
8
8
  description = "The official Revenium Python SDK — unified AI metering middleware for OpenAI, Anthropic, Google, Ollama, LiteLLM, Perplexity, and fal.ai."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -169,7 +169,74 @@ def _model_id(model_name: str) -> str:
169
169
  return _MODEL_MAP.get(model_name, f"anthropic.{model_name}")
170
170
 
171
171
 
172
- def bedrock_invoke(model: str, payload: dict, region: Optional[str] = None) -> Tuple[str, int, int]:
172
+ def _as_dict(value: Any) -> dict:
173
+ """Return value when it is a dict, otherwise an empty dict."""
174
+ return value if isinstance(value, dict) else {}
175
+
176
+
177
+ def _token_count(value: Any) -> Optional[int]:
178
+ """Return a non-negative integer token count, or None for invalid values."""
179
+ if isinstance(value, bool):
180
+ return None
181
+ if isinstance(value, int):
182
+ return value if value >= 0 else None
183
+ if isinstance(value, str) and value.isdigit():
184
+ return int(value)
185
+ return None
186
+
187
+
188
+ def _first_token_count(source: Any, keys: Tuple[str, ...]) -> Optional[int]:
189
+ """Return the first valid token count for any of the supplied keys."""
190
+ source = _as_dict(source)
191
+ for key in keys:
192
+ if key in source:
193
+ value = _token_count(source.get(key))
194
+ if value is not None:
195
+ return value
196
+ return None
197
+
198
+
199
+ def _extract_cache_tokens(usage: Any) -> Tuple[Optional[int], Optional[int]]:
200
+ """Return (cache_creation_tokens, cache_read_tokens) from a Bedrock usage dict."""
201
+ cache_creation = _first_token_count(
202
+ usage,
203
+ (
204
+ "cacheWriteInputTokensCount",
205
+ "cacheWriteInputTokens",
206
+ "cache_write_input_tokens_count",
207
+ "cache_write_input_tokens",
208
+ "cache_creation_input_tokens_count",
209
+ "cache_creation_input_tokens",
210
+ )
211
+ )
212
+ cache_read = _first_token_count(
213
+ usage,
214
+ (
215
+ "cacheReadInputTokensCount",
216
+ "cacheReadInputTokens",
217
+ "cache_read_input_tokens_count",
218
+ "cache_read_input_tokens",
219
+ )
220
+ )
221
+ return cache_creation, cache_read
222
+
223
+
224
+ def _extract_token_counts(usage: Any, metrics: Optional[Any] = None) -> Tuple[Optional[int], Optional[int]]:
225
+ """Return (input_tokens, output_tokens) from Bedrock usage and metrics blocks."""
226
+ usage = _as_dict(usage)
227
+ metrics = _as_dict(metrics)
228
+ input_tokens = _first_token_count(usage, ("inputTokens", "input_tokens", "prompt_tokens"))
229
+ if input_tokens is None:
230
+ input_tokens = _first_token_count(metrics, ("inputTokenCount",))
231
+
232
+ output_tokens = _first_token_count(usage, ("outputTokens", "output_tokens", "completion_tokens"))
233
+ if output_tokens is None:
234
+ output_tokens = _first_token_count(metrics, ("outputTokenCount",))
235
+
236
+ return input_tokens, output_tokens
237
+
238
+
239
+ def bedrock_invoke(model: str, payload: dict, region: Optional[str] = None) -> Tuple[str, int, int, int, int]:
173
240
  """
174
241
  Invoke Bedrock model with Anthropic-compatible parameters.
175
242
 
@@ -179,7 +246,7 @@ def bedrock_invoke(model: str, payload: dict, region: Optional[str] = None) -> T
179
246
  region: AWS region (defaults to AWS_REGION env var or us-east-1)
180
247
 
181
248
  Returns:
182
- Tuple of (text_content, input_tokens, output_tokens)
249
+ Tuple of (text_content, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens)
183
250
 
184
251
  Raises:
185
252
  BedrockValidationError: For invalid input parameters
@@ -225,22 +292,15 @@ def bedrock_invoke(model: str, payload: dict, region: Optional[str] = None) -> T
225
292
  if c.get("type") == "text"
226
293
  )
227
294
 
228
- # Try multiple token field formats to find the right one
229
- input_tokens = usage.get("inputTokens", 0) # camelCase (AWS standard)
230
- output_tokens = usage.get("outputTokens", 0)
231
-
232
- # Try snake_case if camelCase returns 0
233
- if input_tokens == 0 and output_tokens == 0:
234
- input_tokens = usage.get("input_tokens", 0)
235
- output_tokens = usage.get("output_tokens", 0)
236
-
237
- # Try other possible field names
238
- if input_tokens == 0 and output_tokens == 0:
239
- input_tokens = usage.get("prompt_tokens", 0)
240
- output_tokens = usage.get("completion_tokens", 0)
295
+ input_tokens, output_tokens = _extract_token_counts(usage)
296
+ cache_creation_tokens, cache_read_tokens = _extract_cache_tokens(usage)
297
+ input_tokens = input_tokens if input_tokens is not None else 0
298
+ output_tokens = output_tokens if output_tokens is not None else 0
299
+ cache_creation_tokens = cache_creation_tokens if cache_creation_tokens is not None else 0
300
+ cache_read_tokens = cache_read_tokens if cache_read_tokens is not None else 0
241
301
 
242
302
  logger.debug(f"Bedrock invoke successful: {input_tokens} input tokens, {output_tokens} output tokens")
243
- return text, input_tokens, output_tokens
303
+ return text, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens
244
304
 
245
305
  except BedrockValidationError:
246
306
  # Re-raise validation errors as-is
@@ -267,6 +327,8 @@ class BedrockStreamIterator:
267
327
  self.accumulated_text = ""
268
328
  self.input_tokens = 0
269
329
  self.output_tokens = 0
330
+ self.cache_creation_tokens = 0
331
+ self.cache_read_tokens = 0
270
332
  self._stream = None
271
333
  self._started = False
272
334
 
@@ -314,7 +376,17 @@ class BedrockStreamIterator:
314
376
  def _process_stream(self, stream):
315
377
  """Process the Bedrock stream and yield text chunks."""
316
378
  for event in stream:
317
- chunk = event.get("chunk")
379
+ if not hasattr(event, "get"):
380
+ continue
381
+
382
+ metadata = _as_dict(event.get("metadata"))
383
+ if metadata:
384
+ self._update_usage_from_blocks(
385
+ metadata.get("usage") or {},
386
+ metadata.get("metrics") or {}
387
+ )
388
+
389
+ chunk = _as_dict(event.get("chunk"))
318
390
  if not chunk:
319
391
  continue
320
392
 
@@ -324,13 +396,25 @@ class BedrockStreamIterator:
324
396
 
325
397
  try:
326
398
  chunk_data = json.loads(chunk_bytes.decode("utf-8"))
399
+ if not isinstance(chunk_data, dict):
400
+ continue
401
+
327
402
  logger.debug(f"Bedrock stream chunk: {chunk_data}")
328
403
 
329
404
  chunk_type = chunk_data.get("type")
405
+ usage = _as_dict(chunk_data.get("usage"))
406
+ metrics = _as_dict(chunk_data.get("amazon-bedrock-invocationMetrics"))
407
+ if usage or metrics:
408
+ self._update_usage_from_blocks(usage, metrics)
409
+
410
+ if chunk_type == "message_start":
411
+ message_usage = _as_dict(_as_dict(chunk_data.get("message")).get("usage"))
412
+ if message_usage:
413
+ self._update_usage_from_blocks(message_usage)
330
414
 
331
415
  # Handle content block delta (text chunk)
332
416
  if chunk_type == "content_block_delta":
333
- delta = chunk_data.get("delta", {})
417
+ delta = _as_dict(chunk_data.get("delta"))
334
418
  text = delta.get("text", "")
335
419
  if text:
336
420
  self.accumulated_text += text
@@ -338,27 +422,14 @@ class BedrockStreamIterator:
338
422
 
339
423
  # Handle message stop (final message with usage)
340
424
  elif chunk_type == "message_stop":
341
- # Check for usage in the chunk data
342
- usage = chunk_data.get("usage", {})
343
- # Also check for Amazon Bedrock invocation metrics
344
- metrics = chunk_data.get("amazon-bedrock-invocationMetrics", {})
345
-
346
- # Try multiple token field formats
347
- self.input_tokens = (
348
- usage.get("inputTokens", 0) or
349
- usage.get("input_tokens", 0) or
350
- metrics.get("inputTokenCount", 0)
351
- )
352
- self.output_tokens = (
353
- usage.get("outputTokens", 0) or
354
- usage.get("output_tokens", 0) or
355
- metrics.get("outputTokenCount", 0)
356
- )
357
-
358
425
  logger.debug(f"Bedrock streaming completed. Input tokens: {self.input_tokens}, Output tokens: {self.output_tokens}")
359
426
 
427
+ # Handle message delta usage-only updates.
428
+ elif chunk_type == "message_delta":
429
+ logger.debug("Bedrock stream: message_delta")
430
+
360
431
  # Handle other chunk types (content_block_start, etc.)
361
- elif chunk_type in ["content_block_start", "message_start"]:
432
+ elif chunk_type == "content_block_start":
362
433
  logger.debug(f"Bedrock stream: {chunk_type}")
363
434
 
364
435
  except json.JSONDecodeError as e:
@@ -367,6 +438,22 @@ class BedrockStreamIterator:
367
438
 
368
439
  logger.debug(f"Bedrock streaming finished. Total text length: {len(self.accumulated_text)}")
369
440
 
441
+ def _update_usage_from_blocks(self, usage: Any, metrics: Optional[Any] = None):
442
+ """Update token counters from a stream usage block without resetting missing fields."""
443
+ usage = _as_dict(usage)
444
+ metrics = _as_dict(metrics)
445
+ input_tokens, output_tokens = _extract_token_counts(usage, metrics)
446
+ if input_tokens is not None:
447
+ self.input_tokens = input_tokens
448
+ if output_tokens is not None:
449
+ self.output_tokens = output_tokens
450
+
451
+ cache_creation_tokens, cache_read_tokens = _extract_cache_tokens(usage)
452
+ if cache_creation_tokens is not None:
453
+ self.cache_creation_tokens = cache_creation_tokens
454
+ if cache_read_tokens is not None:
455
+ self.cache_read_tokens = cache_read_tokens
456
+
370
457
 
371
458
  def bedrock_invoke_stream(model: str, payload: dict, region: Optional[str] = None) -> BedrockStreamIterator:
372
459
  """
@@ -451,7 +538,8 @@ def create_bedrock_payload(messages: Union[list, List[Dict[str, Any]]], **kwargs
451
538
 
452
539
 
453
540
  def create_anthropic_response(text: str, input_tokens: int, output_tokens: int,
454
- model: str, request_id: Optional[str] = None):
541
+ model: str, request_id: Optional[str] = None,
542
+ cache_creation_tokens: int = 0, cache_read_tokens: int = 0):
455
543
  """
456
544
  Create an Anthropic-compatible response object.
457
545
 
@@ -461,6 +549,8 @@ def create_anthropic_response(text: str, input_tokens: int, output_tokens: int,
461
549
  output_tokens: Number of output tokens
462
550
  model: Model name
463
551
  request_id: Optional request ID
552
+ cache_creation_tokens: Number of cache creation input tokens (default 0)
553
+ cache_read_tokens: Number of cache read input tokens (default 0)
464
554
 
465
555
  Returns:
466
556
  Object that mimics Anthropic's Message response structure with both
@@ -510,26 +600,25 @@ def create_anthropic_response(text: str, input_tokens: int, output_tokens: int,
510
600
  self.text = text
511
601
 
512
602
  class Usage(HybridAccessMixin):
513
- def __init__(self, input_tokens, output_tokens):
603
+ def __init__(self, input_tokens, output_tokens, cache_creation_tokens=0, cache_read_tokens=0):
514
604
  self.input_tokens = input_tokens
515
605
  self.output_tokens = output_tokens
516
606
  self.total_tokens = input_tokens + output_tokens
517
- # Add cache token attributes for compatibility
518
- self.cache_creation_input_tokens = 0
519
- self.cache_read_input_tokens = 0
607
+ self.cache_creation_input_tokens = cache_creation_tokens
608
+ self.cache_read_input_tokens = cache_read_tokens
520
609
 
521
610
  class Message(HybridAccessMixin):
522
- def __init__(self, text, input_tokens, output_tokens, model, request_id):
611
+ def __init__(self, text, input_tokens, output_tokens, model, request_id, cache_creation_tokens=0, cache_read_tokens=0):
523
612
  self.id = request_id or _generate_safe_id("msg_bedrock", text)
524
613
  self.type = "message"
525
614
  self.role = "assistant"
526
615
  self.model = model
527
616
  self.content = [TextBlock(text)]
528
- self.usage = Usage(input_tokens, output_tokens)
617
+ self.usage = Usage(input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens)
529
618
  self.stop_reason = "end_turn"
530
619
  self.stop_sequence = None
531
620
 
532
- return Message(text, input_tokens, output_tokens, model, request_id)
621
+ return Message(text, input_tokens, output_tokens, model, request_id, cache_creation_tokens, cache_read_tokens)
533
622
 
534
623
 
535
624
  class BedrockStreamWrapper:
@@ -558,6 +647,8 @@ class BedrockStreamWrapper:
558
647
  self.first_token_time = None
559
648
  self.request_start_time = time.time() * 1000 # Convert to milliseconds
560
649
  self.accumulated_text = ""
650
+ self.cache_creation_tokens = 0
651
+ self.cache_read_tokens = 0
561
652
 
562
653
  def __enter__(self):
563
654
  """Enter the context manager and initialize the stream."""
@@ -635,6 +726,8 @@ class BedrockStreamWrapper:
635
726
  # Get token counts from the stream iterator
636
727
  input_tokens = getattr(self.stream_iterator, 'input_tokens', 0)
637
728
  output_tokens = getattr(self.stream_iterator, 'output_tokens', 0)
729
+ self.cache_creation_tokens = getattr(self.stream_iterator, 'cache_creation_tokens', 0)
730
+ self.cache_read_tokens = getattr(self.stream_iterator, 'cache_read_tokens', 0)
638
731
 
639
732
  # Generate a response ID
640
733
  self.response_id = _generate_safe_id("msg_bedrock_stream", self.accumulated_text)
@@ -645,7 +738,9 @@ class BedrockStreamWrapper:
645
738
  input_tokens=input_tokens,
646
739
  output_tokens=output_tokens,
647
740
  model=self.model,
648
- request_id=self.response_id
741
+ request_id=self.response_id,
742
+ cache_creation_tokens=self.cache_creation_tokens,
743
+ cache_read_tokens=self.cache_read_tokens
649
744
  )
650
745
 
651
746
  def _send_metering_data(self, request_duration: float):
@@ -707,8 +802,8 @@ class BedrockStreamWrapper:
707
802
  meta = extract_common_metadata(self.usage_metadata)
708
803
 
709
804
  result = submit_ai_event("completion", {
710
- "cache_creation_token_count": 0,
711
- "cache_read_token_count": 0,
805
+ "cache_creation_token_count": self.cache_creation_tokens,
806
+ "cache_read_token_count": self.cache_read_tokens,
712
807
  "input_token_cost": None,
713
808
  "output_token_cost": None,
714
809
  "total_cost": None,
@@ -165,14 +165,16 @@ def _handle_bedrock_request(args, kwargs, usage_metadata, request_time_dt, reque
165
165
  payload = create_bedrock_payload(messages, **bedrock_kwargs)
166
166
 
167
167
  # Invoke Bedrock
168
- text, input_tokens, output_tokens = bedrock_invoke(model, payload)
168
+ text, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens = bedrock_invoke(model, payload)
169
169
 
170
170
  # Create Anthropic-compatible response
171
171
  response = create_anthropic_response(
172
172
  text=text,
173
173
  input_tokens=input_tokens,
174
174
  output_tokens=output_tokens,
175
- model=model
175
+ model=model,
176
+ cache_creation_tokens=cache_creation_tokens,
177
+ cache_read_tokens=cache_read_tokens
176
178
  )
177
179
 
178
180
  # Calculate timing
@@ -182,7 +184,8 @@ def _handle_bedrock_request(args, kwargs, usage_metadata, request_time_dt, reque
182
184
 
183
185
  # Create metering call for Bedrock (pass kwargs for vision detection)
184
186
  _create_bedrock_metering_call(
185
- response, usage_metadata, request_time, response_time, request_duration, kwargs
187
+ response, usage_metadata, request_time, response_time, request_duration, kwargs,
188
+ cache_creation_tokens=cache_creation_tokens, cache_read_tokens=cache_read_tokens
186
189
  )
187
190
 
188
191
  return response
@@ -288,7 +291,7 @@ def _extract_trace_fields(usage_metadata, request_body=None):
288
291
  }
289
292
 
290
293
 
291
- def _create_bedrock_metering_call(response, usage_metadata, request_time, response_time, request_duration, request_kwargs=None):
294
+ def _create_bedrock_metering_call(response, usage_metadata, request_time, response_time, request_duration, request_kwargs=None, cache_creation_tokens=0, cache_read_tokens=0):
292
295
  """Create a metering call for Bedrock usage."""
293
296
 
294
297
  # Get provider metadata
@@ -336,8 +339,8 @@ def _create_bedrock_metering_call(response, usage_metadata, request_time, respon
336
339
  meta = extract_common_metadata(usage_metadata)
337
340
 
338
341
  result = submit_ai_event("completion", {
339
- "cache_creation_token_count": 0,
340
- "cache_read_token_count": 0,
342
+ "cache_creation_token_count": cache_creation_tokens,
343
+ "cache_read_token_count": cache_read_tokens,
341
344
  "input_token_cost": None,
342
345
  "output_token_cost": None,
343
346
  "total_cost": None,
@@ -217,18 +217,15 @@ class UnifiedReveniumCallbackHandler(AsyncCallbackHandler):
217
217
  logger.debug(f"Found usage_metadata: {usage_data}")
218
218
 
219
219
  # Check for response_metadata with token_usage (LangChain v0.1+)
220
- elif hasattr(response, 'response_metadata') and response.response_metadata:
221
- if 'token_usage' in response.response_metadata:
222
- usage_data = response.response_metadata['token_usage']
223
- if self.enable_debug_logging:
224
- logger.debug(f"Found token_usage in response_metadata: {usage_data}")
225
- elif 'usage' in response.response_metadata:
226
- usage_data = response.response_metadata['usage']
227
- if self.enable_debug_logging:
228
- logger.debug(f"Found usage in response_metadata: {usage_data}")
220
+ if not usage_data and hasattr(response, 'response_metadata') and response.response_metadata:
221
+ usage_data = self._extract_usage_from_response_metadata(
222
+ response.response_metadata
223
+ )
224
+ if usage_data and self.enable_debug_logging:
225
+ logger.debug(f"Found usage in response_metadata: {usage_data}")
229
226
 
230
227
  # Check for llm_output (older LangChain versions)
231
- elif hasattr(response, 'llm_output') and response.llm_output:
228
+ if not usage_data and hasattr(response, 'llm_output') and response.llm_output:
232
229
  if 'token_usage' in response.llm_output:
233
230
  usage_data = response.llm_output['token_usage']
234
231
  if self.enable_debug_logging:
@@ -239,7 +236,7 @@ class UnifiedReveniumCallbackHandler(AsyncCallbackHandler):
239
236
  logger.debug(f"Found usage in llm_output: {usage_data}")
240
237
 
241
238
  # For streaming responses, check if there's accumulated usage data
242
- elif hasattr(response, 'content') and hasattr(response, 'additional_kwargs'):
239
+ if not usage_data and hasattr(response, 'content') and hasattr(response, 'additional_kwargs'):
243
240
  # This might be a streaming chunk with accumulated data
244
241
  if 'usage' in response.additional_kwargs:
245
242
  usage_data = response.additional_kwargs['usage']
@@ -247,11 +244,17 @@ class UnifiedReveniumCallbackHandler(AsyncCallbackHandler):
247
244
  logger.debug(f"Found usage in additional_kwargs: {usage_data}")
248
245
 
249
246
  # Last resort: check for any 'usage' attribute directly
250
- elif hasattr(response, 'usage'):
247
+ if not usage_data and hasattr(response, 'usage'):
251
248
  usage_data = response.usage
252
249
  if self.enable_debug_logging:
253
250
  logger.debug(f"Found direct usage attribute: {usage_data}")
254
251
 
252
+ # LLMResult often stores AIMessage usage on generations[0][0].message.
253
+ if not usage_data:
254
+ usage_data = self._extract_usage_from_generations(response)
255
+ if usage_data and self.enable_debug_logging:
256
+ logger.debug(f"Found usage in generation message: {usage_data}")
257
+
255
258
  if not usage_data and self.enable_debug_logging:
256
259
  logger.debug("No usage data found in response")
257
260
  # Log response structure for debugging
@@ -260,6 +263,44 @@ class UnifiedReveniumCallbackHandler(AsyncCallbackHandler):
260
263
 
261
264
  return usage_data
262
265
 
266
+ def _extract_usage_from_response_metadata(
267
+ self, response_metadata: Dict[str, Any]
268
+ ) -> Optional[Dict[str, Any]]:
269
+ if not isinstance(response_metadata, dict):
270
+ return None
271
+ if 'token_usage' in response_metadata:
272
+ return response_metadata['token_usage']
273
+ if 'usage' in response_metadata:
274
+ return response_metadata['usage']
275
+ return None
276
+
277
+ def _extract_usage_from_generations(self, response: Any) -> Optional[Dict[str, Any]]:
278
+ generations = getattr(response, 'generations', None)
279
+ if not generations:
280
+ return None
281
+
282
+ for generation_group in generations:
283
+ if isinstance(generation_group, (list, tuple)):
284
+ candidates = generation_group
285
+ else:
286
+ candidates = [generation_group]
287
+
288
+ for generation in candidates:
289
+ message = getattr(generation, 'message', None) or generation
290
+
291
+ usage_metadata = getattr(message, 'usage_metadata', None)
292
+ if usage_metadata:
293
+ return usage_metadata
294
+
295
+ response_metadata = getattr(message, 'response_metadata', None)
296
+ usage_data = self._extract_usage_from_response_metadata(
297
+ response_metadata
298
+ )
299
+ if usage_data:
300
+ return usage_data
301
+
302
+ return None
303
+
263
304
  def _process_metering_call(self, usage_data: Dict[str, Any], run_info: Dict[str, Any]) -> None:
264
305
  """
265
306
  Create a Revenium metering call with the usage data.
@@ -316,20 +357,37 @@ class UnifiedReveniumCallbackHandler(AsyncCallbackHandler):
316
357
  """
317
358
  class MockResponse:
318
359
  def __init__(self, usage_data, model_name):
360
+ def has_usage_key(key):
361
+ if isinstance(usage_data, dict):
362
+ return key in usage_data
363
+ return hasattr(usage_data, key)
364
+
365
+ def get_usage_value(key, default=0):
366
+ if isinstance(usage_data, dict):
367
+ return usage_data.get(key, default)
368
+ return getattr(usage_data, key, default)
369
+
370
+ def detail_object(details):
371
+ if not details:
372
+ return None
373
+ if isinstance(details, dict):
374
+ return type('TokenDetails', (), details)()
375
+ return details
376
+
319
377
  # Set usage information in the format expected by middleware
320
- if 'input_tokens' in usage_data:
378
+ if has_usage_key('input_tokens'):
321
379
  # LangChain v0.2+ format
322
380
  self.usage = type('Usage', (), {
323
- 'prompt_tokens': usage_data.get('input_tokens', 0),
324
- 'completion_tokens': usage_data.get('output_tokens', 0),
325
- 'total_tokens': usage_data.get('total_tokens', 0)
381
+ 'prompt_tokens': get_usage_value('input_tokens', 0),
382
+ 'completion_tokens': get_usage_value('output_tokens', 0),
383
+ 'total_tokens': get_usage_value('total_tokens', 0)
326
384
  })()
327
- elif 'prompt_tokens' in usage_data:
385
+ elif has_usage_key('prompt_tokens'):
328
386
  # OpenAI format
329
387
  self.usage = type('Usage', (), {
330
- 'prompt_tokens': usage_data.get('prompt_tokens', 0),
331
- 'completion_tokens': usage_data.get('completion_tokens', 0),
332
- 'total_tokens': usage_data.get('total_tokens', 0)
388
+ 'prompt_tokens': get_usage_value('prompt_tokens', 0),
389
+ 'completion_tokens': get_usage_value('completion_tokens', 0),
390
+ 'total_tokens': get_usage_value('total_tokens', 0)
333
391
  })()
334
392
  else:
335
393
  # Fallback - create minimal usage
@@ -339,6 +397,27 @@ class UnifiedReveniumCallbackHandler(AsyncCallbackHandler):
339
397
  'total_tokens': 0
340
398
  })()
341
399
 
400
+ # Preserve provider-specific cache detail shapes for middleware.
401
+ for detail_name in (
402
+ 'prompt_tokens_details',
403
+ 'input_tokens_details',
404
+ 'input_token_details',
405
+ ):
406
+ details = get_usage_value(detail_name, None)
407
+ details = detail_object(details)
408
+ if details is not None:
409
+ setattr(self.usage, detail_name, details)
410
+
411
+ # Preserve Anthropic's native flat cache keys. A live
412
+ # ChatAnthropic LLMResult reports cache tokens here rather than
413
+ # under an `input_token_details` sub-object.
414
+ for flat_key in (
415
+ 'cache_creation_input_tokens',
416
+ 'cache_read_input_tokens',
417
+ ):
418
+ if has_usage_key(flat_key):
419
+ setattr(self.usage, flat_key, get_usage_value(flat_key, 0))
420
+
342
421
  # Set model name
343
422
  self.model = model_name
344
423
 
@@ -2,6 +2,7 @@ import datetime
2
2
  import logging
3
3
  import os
4
4
  import uuid
5
+ from numbers import Number
5
6
  from typing import Dict, Any, Optional, Tuple
6
7
  from enum import Enum
7
8
 
@@ -43,6 +44,73 @@ logger = logging.getLogger("revenium_middleware.extension")
43
44
  SENSITIVE_FIELDS = SecurityConfig.SENSITIVE_FIELDS
44
45
 
45
46
 
47
+ def _get_value(source: Any, key: str, default: Any = None) -> Any:
48
+ """Read a value from either a dict-like or object-like SDK payload."""
49
+ if source is None:
50
+ return default
51
+ if isinstance(source, dict):
52
+ return source.get(key, default)
53
+ return getattr(source, key, default)
54
+
55
+
56
+ def _coerce_int(value: Any, default: int = 0) -> int:
57
+ if value is None:
58
+ return default
59
+ if isinstance(value, bool):
60
+ return default
61
+ if isinstance(value, Number):
62
+ return int(value)
63
+ if isinstance(value, str):
64
+ try:
65
+ return int(value)
66
+ except ValueError:
67
+ return default
68
+ return default
69
+
70
+
71
+ def _extract_cache_token_counts(usage: Any) -> Tuple[int, int]:
72
+ """
73
+ Return (cache_creation_token_count, cache_read_token_count).
74
+
75
+ OpenAI reports cached prompt/input tokens as cache reads. Anthropic-shaped
76
+ LangChain metadata reports read and creation counts separately.
77
+ """
78
+ input_token_details = _get_value(usage, "input_token_details")
79
+ cache_creation_token_count = _coerce_int(
80
+ _get_value(input_token_details, "cache_creation", 0)
81
+ )
82
+ cache_read_token_count = _coerce_int(
83
+ _get_value(input_token_details, "cache_read", 0)
84
+ )
85
+ if cache_creation_token_count or cache_read_token_count:
86
+ return cache_creation_token_count, cache_read_token_count
87
+
88
+ # Anthropic native usage shape: a live ChatAnthropic LLMResult exposes the
89
+ # raw `response_metadata.usage` block with these flat keys rather than the
90
+ # normalized `input_token_details` shape.
91
+ cache_creation_token_count = _coerce_int(
92
+ _get_value(usage, "cache_creation_input_tokens", 0)
93
+ )
94
+ cache_read_token_count = _coerce_int(
95
+ _get_value(usage, "cache_read_input_tokens", 0)
96
+ )
97
+ if cache_creation_token_count or cache_read_token_count:
98
+ return cache_creation_token_count, cache_read_token_count
99
+
100
+ input_tokens_details = _get_value(usage, "input_tokens_details")
101
+ cache_read_token_count = _coerce_int(
102
+ _get_value(input_tokens_details, "cached_tokens", 0)
103
+ )
104
+ if cache_read_token_count:
105
+ return 0, cache_read_token_count
106
+
107
+ prompt_details = _get_value(usage, "prompt_tokens_details")
108
+ cache_read_token_count = _coerce_int(
109
+ _get_value(prompt_details, "cached_tokens", 0)
110
+ )
111
+ return 0, cache_read_token_count
112
+
113
+
46
114
  def extract_prompt_data_if_enabled(
47
115
  request_body: Optional[Dict[str, Any]],
48
116
  response: Any = None,
@@ -303,10 +371,10 @@ def extract_usage_data(response, operation_type: OperationType, request_time: st
303
371
  # Responses API doesn't have choices, use default
304
372
  stop_reason = "END"
305
373
 
306
- # Extract cached tokens (only available for chat completions)
307
- cached_tokens = 0
308
- if operation_type == OperationType.CHAT and hasattr(response.usage, 'prompt_tokens_details'):
309
- cached_tokens = getattr(response.usage.prompt_tokens_details, 'cached_tokens', 0)
374
+ # Extract provider-specific cache token details.
375
+ cache_creation_token_count, cache_read_token_count = _extract_cache_token_counts(
376
+ response.usage
377
+ )
310
378
 
311
379
  # Build unified usage data structure
312
380
  usage_data = {
@@ -321,8 +389,8 @@ def extract_usage_data(response, operation_type: OperationType, request_time: st
321
389
  "model_source": provider_metadata["model_source"],
322
390
  "is_streamed": False, # Will be overridden for streaming
323
391
  "time_to_first_token": 0, # Will be set by caller if applicable
324
- "cache_creation_token_count": cached_tokens,
325
- "cache_read_token_count": 0,
392
+ "cache_creation_token_count": cache_creation_token_count,
393
+ "cache_read_token_count": cache_read_token_count,
326
394
  "reasoning_token_count": 0,
327
395
  "request_time": request_time,
328
396
  "response_time": response_time,
@@ -379,6 +447,8 @@ async def log_token_usage(
379
447
  input_messages: Optional[str] = None,
380
448
  output_response: Optional[str] = None,
381
449
  prompts_truncated: Optional[bool] = None,
450
+ cache_creation_token_count: Optional[int] = None,
451
+ cache_read_token_count: Optional[int] = None,
382
452
  ) -> None:
383
453
  """Log token usage to Revenium."""
384
454
  if client is None:
@@ -403,9 +473,16 @@ async def log_token_usage(
403
473
 
404
474
  # Prepare arguments for create_completion
405
475
  # Build completion args, only including non-None values for optional fields
476
+ if cache_creation_token_count is None:
477
+ cache_creation_tokens = 0
478
+ else:
479
+ cache_creation_tokens = cache_creation_token_count
480
+ if cache_read_token_count is None:
481
+ cache_read_token_count = _coerce_int(cached_tokens)
482
+
406
483
  completion_args = {
407
- "cache_creation_token_count": cached_tokens,
408
- "cache_read_token_count": 0,
484
+ "cache_creation_token_count": cache_creation_tokens,
485
+ "cache_read_token_count": cache_read_token_count,
409
486
  "output_token_count": completion_tokens,
410
487
  "cost_type": "AI",
411
488
  "model": model,
@@ -634,6 +711,8 @@ def create_metering_call(
634
711
  completion_tokens=usage_data["output_token_count"],
635
712
  total_tokens=usage_data["total_token_count"],
636
713
  cached_tokens=usage_data["cache_creation_token_count"],
714
+ cache_creation_token_count=usage_data["cache_creation_token_count"],
715
+ cache_read_token_count=usage_data["cache_read_token_count"],
637
716
  stop_reason=usage_data["stop_reason"],
638
717
  request_time=usage_data["request_time"],
639
718
  response_time=usage_data["response_time"],
@@ -1044,17 +1123,17 @@ def handle_streaming_response(
1044
1123
  prompt_tokens = 0
1045
1124
  completion_tokens = 0
1046
1125
  total_tokens = 0
1047
- cached_tokens = 0
1126
+ cache_creation_token_count = 0
1127
+ cache_read_token_count = 0
1048
1128
 
1049
1129
  # First check if we have the final usage data from the special chunk
1050
1130
  if self.final_usage:
1051
1131
  prompt_tokens = self.final_usage.prompt_tokens
1052
1132
  completion_tokens = self.final_usage.completion_tokens
1053
1133
  total_tokens = self.final_usage.total_tokens
1054
- # Check if we have cached tokens info
1055
- if hasattr(self.final_usage, 'prompt_tokens_details') and hasattr(
1056
- self.final_usage.prompt_tokens_details, 'cached_tokens'):
1057
- cached_tokens = self.final_usage.prompt_tokens_details.cached_tokens
1134
+ cache_creation_token_count, cache_read_token_count = _extract_cache_token_counts(
1135
+ self.final_usage
1136
+ )
1058
1137
  logger.debug(
1059
1138
  f"Using token usage from final chunk: prompt={prompt_tokens}, completion={completion_tokens}, total={total_tokens}")
1060
1139
  else:
@@ -1168,7 +1247,9 @@ def handle_streaming_response(
1168
1247
  prompt_tokens=prompt_tokens,
1169
1248
  completion_tokens=completion_tokens,
1170
1249
  total_tokens=total_tokens,
1171
- cached_tokens=cached_tokens,
1250
+ cached_tokens=0,
1251
+ cache_creation_token_count=cache_creation_token_count,
1252
+ cache_read_token_count=cache_read_token_count,
1172
1253
  stop_reason=stop_reason,
1173
1254
  request_time=self.request_time_dt.strftime(
1174
1255
  "%Y-%m-%dT%H:%M:%SZ"
@@ -1359,12 +1440,17 @@ def handle_streaming_responses(stream, request_time_dt, usage_metadata,
1359
1440
  input_tokens = 0
1360
1441
  output_tokens = 0
1361
1442
  total_tokens = 0
1443
+ cache_creation_token_count = 0
1444
+ cache_read_token_count = 0
1362
1445
 
1363
1446
  # Get usage data from the final chunk or last chunk
1364
1447
  if self.final_usage:
1365
1448
  input_tokens = self.final_usage.input_tokens
1366
1449
  output_tokens = self.final_usage.output_tokens
1367
1450
  total_tokens = self.final_usage.total_tokens
1451
+ cache_creation_token_count, cache_read_token_count = _extract_cache_token_counts(
1452
+ self.final_usage
1453
+ )
1368
1454
  logger.debug(
1369
1455
  f"Using token usage from Responses API stream final chunk: input={input_tokens}, "
1370
1456
  f"output={output_tokens}, total={total_tokens}")
@@ -1373,6 +1459,9 @@ def handle_streaming_responses(stream, request_time_dt, usage_metadata,
1373
1459
  input_tokens = self.last_chunk.usage.input_tokens
1374
1460
  output_tokens = self.last_chunk.usage.output_tokens
1375
1461
  total_tokens = self.last_chunk.usage.total_tokens
1462
+ cache_creation_token_count, cache_read_token_count = _extract_cache_token_counts(
1463
+ self.last_chunk.usage
1464
+ )
1376
1465
  logger.debug(
1377
1466
  f"Using token usage from Responses API last chunk: input={input_tokens}, "
1378
1467
  f"output={output_tokens}, total={total_tokens}")
@@ -1463,6 +1552,8 @@ def handle_streaming_responses(stream, request_time_dt, usage_metadata,
1463
1552
  completion_tokens=output_tokens,
1464
1553
  total_tokens=total_tokens,
1465
1554
  cached_tokens=0,
1555
+ cache_creation_token_count=cache_creation_token_count,
1556
+ cache_read_token_count=cache_read_token_count,
1466
1557
  stop_reason="END",
1467
1558
  request_time=self.request_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
1468
1559
  response_time=response_time,
@@ -1541,24 +1632,11 @@ def _wrap_async_stream(stream, request_time_dt, usage_metadata, client_instance=
1541
1632
  if not self.chunks and not self.final_usage:
1542
1633
  return
1543
1634
 
1544
- prompt_tokens = 0
1545
- completion_tokens = 0
1546
- cached_tokens = 0
1547
-
1548
- if self.final_usage:
1549
- prompt_tokens = getattr(self.final_usage, 'prompt_tokens', 0) or 0
1550
- completion_tokens = getattr(self.final_usage, 'completion_tokens', 0) or 0
1551
- if hasattr(self.final_usage, 'prompt_tokens_details') and self.final_usage.prompt_tokens_details:
1552
- cached_tokens = getattr(self.final_usage.prompt_tokens_details, 'cached_tokens', 0) or 0
1635
+ if self.final_usage is None:
1636
+ return
1553
1637
 
1554
- total_tokens = prompt_tokens + completion_tokens
1555
1638
  time_to_first_token = int((self.first_token_time - request_time_dt).total_seconds() * 1000) if self.first_token_time else 0
1556
1639
 
1557
- response_time_dt = datetime.datetime.now(datetime.timezone.utc)
1558
- request_time = request_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
1559
- response_time = response_time_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
1560
- request_duration = (response_time_dt - request_time_dt).total_seconds() * 1000
1561
-
1562
1640
  try:
1563
1641
  create_metering_call(
1564
1642
  type('Response', (), {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revenium-python-sdk
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary: The official Revenium Python SDK — unified AI metering middleware for OpenAI, Anthropic, Google, Ollama, LiteLLM, Perplexity, and fal.ai.
5
5
  Author-email: Revenium <support@revenium.io>
6
6
  License: MIT