revenium-python-sdk 0.5.0__tar.gz → 0.6.0__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 (147) hide show
  1. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/PKG-INFO +53 -1
  2. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/README.md +52 -0
  3. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/pyproject.toml +2 -1
  4. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/__init__.py +17 -0
  5. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/config.py +7 -0
  6. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/fields.py +42 -0
  7. revenium_python_sdk-0.6.0/revenium_middleware/_core/load_diagnostics.py +68 -0
  8. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering.py +17 -9
  9. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering_buffer.py +42 -2
  10. revenium_python_sdk-0.6.0/revenium_middleware/_core/metering_status.py +172 -0
  11. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering_submission.py +20 -1
  12. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/decorator.py +15 -4
  13. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/ai.py +422 -2
  14. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_audio_params.py +54 -0
  15. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_completion_params.py +52 -1
  16. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_image_params.py +56 -0
  17. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_video_params.py +54 -0
  18. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/event_create_params.py +2 -0
  19. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/__init__.py +3 -2
  20. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/__init__.py +3 -2
  21. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/_metering.py +13 -4
  22. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/__init__.py +4 -2
  23. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/utils.py +10 -0
  24. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/google_ai/__init__.py +3 -2
  25. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/google_ai/middleware.py +1 -1
  26. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/vertex_ai/__init__.py +3 -2
  27. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/__init__.py +3 -2
  28. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/__init__.py +3 -2
  29. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/proxy/__init__.py +3 -2
  30. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/proxy/middleware.py +1 -1
  31. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/ollama/__init__.py +3 -2
  32. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/__init__.py +3 -2
  33. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/middleware.py +7 -2
  34. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/__init__.py +14 -3
  35. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/PKG-INFO +53 -1
  36. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/SOURCES.txt +2 -0
  37. revenium_python_sdk-0.6.0/tests/test_metering.py +411 -0
  38. revenium_python_sdk-0.5.0/tests/test_metering.py +0 -106
  39. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/LICENSE +0 -0
  40. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/__init__.py +0 -0
  41. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/context.py +0 -0
  42. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/decorators.py +0 -0
  43. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/enforcement.py +0 -0
  44. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/exceptions.py +0 -0
  45. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/log_sanitize.py +0 -0
  46. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/outcomes.py +0 -0
  47. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/patch_registry.py +0 -0
  48. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/prompt_extraction.py +0 -0
  49. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/subscriber.py +0 -0
  50. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/trace_fields.py +0 -0
  51. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/LICENSE +0 -0
  52. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/__init__.py +0 -0
  53. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_base_client.py +0 -0
  54. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_client.py +0 -0
  55. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_compat.py +0 -0
  56. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_constants.py +0 -0
  57. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_exceptions.py +0 -0
  58. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_files.py +0 -0
  59. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_models.py +0 -0
  60. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_qs.py +0 -0
  61. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_resource.py +0 -0
  62. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_response.py +0 -0
  63. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_streaming.py +0 -0
  64. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_types.py +0 -0
  65. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/__init__.py +0 -0
  66. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_logs.py +0 -0
  67. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_proxy.py +0 -0
  68. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_reflection.py +0 -0
  69. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_resources_proxy.py +0 -0
  70. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_streams.py +0 -0
  71. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_sync.py +0 -0
  72. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_transform.py +0 -0
  73. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_typing.py +0 -0
  74. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_utils.py +0 -0
  75. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_version.py +0 -0
  76. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/context.py +0 -0
  77. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/py.typed +0 -0
  78. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/__init__.py +0 -0
  79. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/apis.py +0 -0
  80. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/events.py +0 -0
  81. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/__init__.py +0 -0
  82. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/api_meter_request_params.py +0 -0
  83. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/api_meter_response_params.py +0 -0
  84. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/metering_response_resource.py +0 -0
  85. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/agentic_outcomes.py +0 -0
  86. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/bedrock_adapter.py +0 -0
  87. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/bedrock_transport.py +0 -0
  88. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/config.py +0 -0
  89. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/middleware.py +0 -0
  90. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/prompt_extractor.py +0 -0
  91. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/provider.py +0 -0
  92. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/stream_create.py +0 -0
  93. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/trace_fields.py +0 -0
  94. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/config.py +0 -0
  95. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/middleware.py +0 -0
  96. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/trace_fields.py +0 -0
  97. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/__init__.py +0 -0
  98. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/exceptions.py +0 -0
  99. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/protocols.py +0 -0
  100. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/trace_fields.py +0 -0
  101. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/types.py +0 -0
  102. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/config.py +0 -0
  103. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/google_ai/provider.py +0 -0
  104. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/prompt_extractor.py +0 -0
  105. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/vertex_ai/middleware.py +0 -0
  106. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/vertex_ai/provider.py +0 -0
  107. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/__init__.py +0 -0
  108. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/_metadata.py +0 -0
  109. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/anthropic_driver.py +0 -0
  110. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/litellm_driver.py +0 -0
  111. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/ollama_driver.py +0 -0
  112. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/openai_driver.py +0 -0
  113. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/openai_embedding_driver.py +0 -0
  114. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/universal_driver.py +0 -0
  115. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/job_context.py +0 -0
  116. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/job_history.py +0 -0
  117. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/config.py +0 -0
  118. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/context.py +0 -0
  119. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/decorators.py +0 -0
  120. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/hooks.py +0 -0
  121. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/integrations/__init__.py +0 -0
  122. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/integrations/crewai.py +0 -0
  123. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/middleware.py +0 -0
  124. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/trace_fields.py +0 -0
  125. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/validation.py +0 -0
  126. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/ollama/middleware.py +0 -0
  127. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/ollama/trace_fields.py +0 -0
  128. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/azure_config.py +0 -0
  129. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/azure_model_resolver.py +0 -0
  130. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/config.py +0 -0
  131. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/exceptions.py +0 -0
  132. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/langchain/__init__.py +0 -0
  133. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/langchain/_utils.py +0 -0
  134. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/langchain/unified_handler.py +0 -0
  135. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/prompt_extractor.py +0 -0
  136. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/provider.py +0 -0
  137. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/trace_fields.py +0 -0
  138. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/middleware.py +0 -0
  139. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/perplexity_sdk.py +0 -0
  140. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/provider.py +0 -0
  141. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/trace_fields.py +0 -0
  142. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/webhooks/__init__.py +0 -0
  143. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/webhooks/_verify.py +0 -0
  144. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/dependency_links.txt +0 -0
  145. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/requires.txt +0 -0
  146. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/top_level.txt +0 -0
  147. {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revenium-python-sdk
3
- Version: 0.5.0
3
+ Version: 0.6.0
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
@@ -214,6 +214,52 @@ print(response.choices[0].message.content)
214
214
 
215
215
  ---
216
216
 
217
+ ## Metering Error Visibility
218
+
219
+ Metering runs in background threads and never raises into your code path — a
220
+ metering failure will never break your AI calls. To make failures observable
221
+ anyway, the SDK provides two mechanisms:
222
+
223
+ **Subscribe to failures** with a callback:
224
+
225
+ ```python
226
+ import revenium_middleware
227
+
228
+ @revenium_middleware.on_metering_error
229
+ def alert_on_metering_failure(event):
230
+ # event.error -- the exception (e.g. an HTTP 401/500 from Revenium)
231
+ # event.operation -- "completion", "image", "tool", ... (may be None)
232
+ # event.timestamp -- UTC datetime of the failure
233
+ my_monitoring.notify(f"Revenium metering failed: {event.error}")
234
+ ```
235
+
236
+ Callbacks run on the background metering thread; exceptions they raise are
237
+ suppressed and logged, so they can never disrupt your application.
238
+
239
+ Authentication headers (`x-api-key`, `authorization`) on any HTTP
240
+ request/response attached to the exception are redacted before the error is
241
+ exposed to callbacks or `last_error`.
242
+
243
+ **Poll the status counters:**
244
+
245
+ ```python
246
+ status = revenium_middleware.get_metering_status()
247
+ print(status.success_count) # events delivered successfully
248
+ print(status.error_count) # delivery failures
249
+ print(status.last_error) # most recent exception, or None
250
+ print(status.last_error_at) # UTC datetime of the most recent failure
251
+ ```
252
+
253
+ `reset_metering_status()` zeroes the counters and clears registered
254
+ callbacks; `remove_metering_error_callback(cb)` unsubscribes a single one.
255
+
256
+ Failures are also logged at **ERROR** level on the `revenium_middleware`
257
+ logger, including HTTP 4xx/5xx responses, a missing
258
+ `REVENIUM_METERING_API_KEY`, and a provider middleware that fails to import
259
+ when the provider's SDK is installed.
260
+
261
+ ---
262
+
217
263
  ## Agentic Outcomes (Outcome-Based Metering)
218
264
 
219
265
  Emit per-agent terminal outcomes (`CONVERTED`, `DEFLECTED`, `ESCALATED`) alongside completion and tool-event records, so dashboards show business value next to AI cost.
@@ -1075,6 +1121,12 @@ Enhanced observability fields for tracking AI operations across environments, re
1075
1121
  | `transaction_name` | `REVENIUM_TRANSACTION_NAME` | Human-friendly operation name | Label operations (e.g., `"Generate Response"`, `"Analyze Sentiment"`) |
1076
1122
  | `retry_number` | `REVENIUM_RETRY_NUMBER` | Retry attempt number (0 = first attempt) | Track retry attempts for failed operations |
1077
1123
  | `ticket_id` | `REVENIUM_TICKET_ID` | External ticket or issue ID (e.g., Jira, Linear) (max 256 chars) | Attribute AI costs to individual tickets or issues |
1124
+ | `skill_name` | `REVENIUM_SKILL_NAME` | Name of the agent skill that produced the call (max 256 chars) | Attribute AI costs to the skill that generated them |
1125
+ | `skill_source` | `REVENIUM_SKILL_SOURCE` | Where the skill was loaded from — accepted values: `bundled`, `projectSettings`, `userSettings`, `plugin` (case-sensitive) | Classify skill origin in the shared skill catalog |
1126
+ | `skill_kind` | `REVENIUM_SKILL_KIND` | Kind of skill invoked — accepted value: `workflow` (omit otherwise) | Distinguish workflow skills in reporting |
1127
+ | `skill_plugin_name` | `REVENIUM_SKILL_PLUGIN_NAME` | Plugin providing the skill, when `skill_source` is `plugin` (max 256 chars) | Attribute costs to a specific plugin |
1128
+ | `skill_marketplace_name` | `REVENIUM_SKILL_MARKETPLACE_NAME` | Marketplace the skill or plugin was installed from (max 256 chars) | Track marketplace-sourced skill usage |
1129
+ | `skill_invocation_trigger` | `REVENIUM_SKILL_INVOCATION_TRIGGER` | What triggered the skill (max 32 chars; common values: `user-slash`, `claude-proactive`, `nested-skill`) | Separate user-invoked from proactive skill usage |
1078
1130
 
1079
1131
  **Note:** `operation_type` (e.g., `CHAT`, `EMBED`, `TOOL_CALL`) and `operation_subtype` (e.g., `function_call`, `streaming`) are automatically detected by the middleware and cannot be overridden.
1080
1132
 
@@ -117,6 +117,52 @@ print(response.choices[0].message.content)
117
117
 
118
118
  ---
119
119
 
120
+ ## Metering Error Visibility
121
+
122
+ Metering runs in background threads and never raises into your code path — a
123
+ metering failure will never break your AI calls. To make failures observable
124
+ anyway, the SDK provides two mechanisms:
125
+
126
+ **Subscribe to failures** with a callback:
127
+
128
+ ```python
129
+ import revenium_middleware
130
+
131
+ @revenium_middleware.on_metering_error
132
+ def alert_on_metering_failure(event):
133
+ # event.error -- the exception (e.g. an HTTP 401/500 from Revenium)
134
+ # event.operation -- "completion", "image", "tool", ... (may be None)
135
+ # event.timestamp -- UTC datetime of the failure
136
+ my_monitoring.notify(f"Revenium metering failed: {event.error}")
137
+ ```
138
+
139
+ Callbacks run on the background metering thread; exceptions they raise are
140
+ suppressed and logged, so they can never disrupt your application.
141
+
142
+ Authentication headers (`x-api-key`, `authorization`) on any HTTP
143
+ request/response attached to the exception are redacted before the error is
144
+ exposed to callbacks or `last_error`.
145
+
146
+ **Poll the status counters:**
147
+
148
+ ```python
149
+ status = revenium_middleware.get_metering_status()
150
+ print(status.success_count) # events delivered successfully
151
+ print(status.error_count) # delivery failures
152
+ print(status.last_error) # most recent exception, or None
153
+ print(status.last_error_at) # UTC datetime of the most recent failure
154
+ ```
155
+
156
+ `reset_metering_status()` zeroes the counters and clears registered
157
+ callbacks; `remove_metering_error_callback(cb)` unsubscribes a single one.
158
+
159
+ Failures are also logged at **ERROR** level on the `revenium_middleware`
160
+ logger, including HTTP 4xx/5xx responses, a missing
161
+ `REVENIUM_METERING_API_KEY`, and a provider middleware that fails to import
162
+ when the provider's SDK is installed.
163
+
164
+ ---
165
+
120
166
  ## Agentic Outcomes (Outcome-Based Metering)
121
167
 
122
168
  Emit per-agent terminal outcomes (`CONVERTED`, `DEFLECTED`, `ESCALATED`) alongside completion and tool-event records, so dashboards show business value next to AI cost.
@@ -978,6 +1024,12 @@ Enhanced observability fields for tracking AI operations across environments, re
978
1024
  | `transaction_name` | `REVENIUM_TRANSACTION_NAME` | Human-friendly operation name | Label operations (e.g., `"Generate Response"`, `"Analyze Sentiment"`) |
979
1025
  | `retry_number` | `REVENIUM_RETRY_NUMBER` | Retry attempt number (0 = first attempt) | Track retry attempts for failed operations |
980
1026
  | `ticket_id` | `REVENIUM_TICKET_ID` | External ticket or issue ID (e.g., Jira, Linear) (max 256 chars) | Attribute AI costs to individual tickets or issues |
1027
+ | `skill_name` | `REVENIUM_SKILL_NAME` | Name of the agent skill that produced the call (max 256 chars) | Attribute AI costs to the skill that generated them |
1028
+ | `skill_source` | `REVENIUM_SKILL_SOURCE` | Where the skill was loaded from — accepted values: `bundled`, `projectSettings`, `userSettings`, `plugin` (case-sensitive) | Classify skill origin in the shared skill catalog |
1029
+ | `skill_kind` | `REVENIUM_SKILL_KIND` | Kind of skill invoked — accepted value: `workflow` (omit otherwise) | Distinguish workflow skills in reporting |
1030
+ | `skill_plugin_name` | `REVENIUM_SKILL_PLUGIN_NAME` | Plugin providing the skill, when `skill_source` is `plugin` (max 256 chars) | Attribute costs to a specific plugin |
1031
+ | `skill_marketplace_name` | `REVENIUM_SKILL_MARKETPLACE_NAME` | Marketplace the skill or plugin was installed from (max 256 chars) | Track marketplace-sourced skill usage |
1032
+ | `skill_invocation_trigger` | `REVENIUM_SKILL_INVOCATION_TRIGGER` | What triggered the skill (max 32 chars; common values: `user-slash`, `claude-proactive`, `nested-skill`) | Separate user-invoked from proactive skill usage |
981
1033
 
982
1034
  **Note:** `operation_type` (e.g., `CHAT`, `EMBED`, `TOOL_CALL`) and `operation_subtype` (e.g., `function_call`, `streaming`) are automatically detected by the middleware and cannot be overridden.
983
1035
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "revenium-python-sdk"
7
- version = "0.5.0"
7
+ version = "0.6.0"
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"
@@ -124,6 +124,7 @@ dev = [
124
124
  ]
125
125
 
126
126
  [tool.pytest.ini_options]
127
+ testpaths = ["tests"]
127
128
  markers = [
128
129
  "unit: Unit tests (fast, no external dependencies)",
129
130
  "e2e: end-to-end tests requiring live services",
@@ -160,6 +160,16 @@ from ._core import ( # noqa: E402
160
160
  is_selective_metering_enabled,
161
161
  )
162
162
 
163
+ # Metering error visibility (BACK-778): status counters + failure callbacks
164
+ from ._core.metering_status import ( # noqa: E402
165
+ MeteringErrorEvent,
166
+ MeteringStatus,
167
+ get_metering_status,
168
+ on_metering_error,
169
+ remove_metering_error_callback,
170
+ reset_metering_status,
171
+ )
172
+
163
173
  # Re-export tool metering utilities from the in-package metering module (BACK-2151)
164
174
  from ._metering import meter_tool, report_tool_call, configure # noqa: E402
165
175
 
@@ -202,6 +212,13 @@ __all__ = [
202
212
  "set_idempotency_key",
203
213
  # Config exports
204
214
  "is_selective_metering_enabled",
215
+ # Metering error visibility exports (BACK-778)
216
+ "MeteringErrorEvent",
217
+ "MeteringStatus",
218
+ "get_metering_status",
219
+ "on_metering_error",
220
+ "remove_metering_error_callback",
221
+ "reset_metering_status",
205
222
  # Tool metering exports
206
223
  "meter_tool",
207
224
  "report_tool_call",
@@ -63,6 +63,13 @@ class Config:
63
63
  ENV_REVENIUM_AGENTIC_JOB_NAME: str = "REVENIUM_AGENTIC_JOB_NAME"
64
64
  ENV_REVENIUM_AGENTIC_JOB_TYPE: str = "REVENIUM_AGENTIC_JOB_TYPE"
65
65
  ENV_REVENIUM_AGENTIC_JOB_VERSION: str = "REVENIUM_AGENTIC_JOB_VERSION"
66
+ # Skill attribution environment variables
67
+ ENV_REVENIUM_SKILL_INVOCATION_TRIGGER: str = "REVENIUM_SKILL_INVOCATION_TRIGGER"
68
+ ENV_REVENIUM_SKILL_KIND: str = "REVENIUM_SKILL_KIND"
69
+ ENV_REVENIUM_SKILL_MARKETPLACE_NAME: str = "REVENIUM_SKILL_MARKETPLACE_NAME"
70
+ ENV_REVENIUM_SKILL_NAME: str = "REVENIUM_SKILL_NAME"
71
+ ENV_REVENIUM_SKILL_PLUGIN_NAME: str = "REVENIUM_SKILL_PLUGIN_NAME"
72
+ ENV_REVENIUM_SKILL_SOURCE: str = "REVENIUM_SKILL_SOURCE"
66
73
  ENV_REVENIUM_OUTCOME_API_KEY: str = "REVENIUM_OUTCOME_API_KEY"
67
74
  ENV_REVENIUM_PROFITSTREAM_BASE_URL: str = "REVENIUM_PROFITSTREAM_BASE_URL"
68
75
 
@@ -30,6 +30,26 @@ _AGENTIC_JOB_ENV_MAP = {
30
30
  "agenticJobVersion": Config.ENV_REVENIUM_AGENTIC_JOB_VERSION,
31
31
  }
32
32
 
33
+ # Keys are the typed snake_case params on create_completion; values are the
34
+ # accepted usage_metadata aliases in precedence order.
35
+ SKILL_FIELD_MAP = {
36
+ "skill_invocation_trigger": ("skill_invocation_trigger", "skillInvocationTrigger"),
37
+ "skill_kind": ("skill_kind", "skillKind"),
38
+ "skill_marketplace_name": ("skill_marketplace_name", "skillMarketplaceName"),
39
+ "skill_name": ("skill_name", "skillName"),
40
+ "skill_plugin_name": ("skill_plugin_name", "skillPluginName"),
41
+ "skill_source": ("skill_source", "skillSource"),
42
+ }
43
+
44
+ _SKILL_ENV_MAP = {
45
+ "skill_invocation_trigger": Config.ENV_REVENIUM_SKILL_INVOCATION_TRIGGER,
46
+ "skill_kind": Config.ENV_REVENIUM_SKILL_KIND,
47
+ "skill_marketplace_name": Config.ENV_REVENIUM_SKILL_MARKETPLACE_NAME,
48
+ "skill_name": Config.ENV_REVENIUM_SKILL_NAME,
49
+ "skill_plugin_name": Config.ENV_REVENIUM_SKILL_PLUGIN_NAME,
50
+ "skill_source": Config.ENV_REVENIUM_SKILL_SOURCE,
51
+ }
52
+
33
53
 
34
54
  def extract_field_with_fallback(
35
55
  source: Mapping[str, Any],
@@ -129,6 +149,28 @@ def extract_agentic_job_fields(source: Mapping[str, Any]) -> Dict[str, Any]:
129
149
  return result
130
150
 
131
151
 
152
+ def extract_skill_fields(source: Mapping[str, Any]) -> Dict[str, Any]:
153
+ """Resolve skill attribution fields with per-field precedence:
154
+
155
+ explicit source metadata (snake then camel alias) >
156
+ ``REVENIUM_SKILL_*`` env var. Each field resolves independently and
157
+ absent fields are omitted entirely (never emitted as None). Returns
158
+ snake_case keys matching the typed create_completion parameters.
159
+ """
160
+ result = {}
161
+ for param_name, aliases in SKILL_FIELD_MAP.items():
162
+ value = None
163
+ for alias in aliases:
164
+ value = source.get(alias)
165
+ if value is not None:
166
+ break
167
+ if value is None:
168
+ value = os.getenv(_SKILL_ENV_MAP[param_name]) or None
169
+ if value is not None:
170
+ result[param_name] = value
171
+ return result
172
+
173
+
132
174
  def merge_extra_body(existing: Optional[Dict[str, Any]], agentic_fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
133
175
  if not agentic_fields:
134
176
  return existing if existing else None
@@ -0,0 +1,68 @@
1
+ """Log-level policy for provider middleware load failures.
2
+
3
+ Every provider package guards its middleware import with ``except
4
+ ImportError`` so that optional providers can be absent. That guard used to
5
+ log at DEBUG unconditionally, which hid real breakage: a customer with the
6
+ provider SDK installed whose middleware failed to import (missing ``wrapt``,
7
+ a broken internal import, a version conflict) got zero metering with no
8
+ visible signal.
9
+
10
+ The policy here distinguishes the two cases:
11
+
12
+ - Provider SDK **installed** but middleware import failed → ERROR, including
13
+ the underlying exception. Metering was expected to work and silently won't.
14
+ - Provider SDK **absent** → DEBUG. The provider simply isn't in use (e.g.
15
+ ``revenium_middleware.griptape`` imports several provider middlewares
16
+ eagerly and most installs only have one provider SDK).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import importlib.util
21
+ import logging
22
+ from typing import Tuple
23
+
24
+ logger = logging.getLogger("revenium_middleware")
25
+
26
+
27
+ def _package_installed(package: str) -> bool:
28
+ try:
29
+ return importlib.util.find_spec(package) is not None
30
+ except ModuleNotFoundError:
31
+ # find_spec("google.genai") raises when the parent package is absent.
32
+ return False
33
+ except Exception:
34
+ # Any other spec-lookup failure means the package exists but is
35
+ # broken — fail toward visibility.
36
+ return True
37
+
38
+
39
+ def log_middleware_load_failure(
40
+ provider_label: str,
41
+ exc: BaseException,
42
+ required_packages: Tuple[str, ...],
43
+ ) -> None:
44
+ """Log a provider middleware import failure at the appropriate level.
45
+
46
+ Args:
47
+ provider_label: Human-readable provider name for the log message.
48
+ exc: The ImportError (or subclass) that aborted the middleware load.
49
+ required_packages: Import names of the provider's own SDK package(s).
50
+ If any of them is installed, the failure is unexpected and logged
51
+ at ERROR; if none are, the provider is not in use and the failure
52
+ is logged at DEBUG.
53
+ """
54
+ if any(_package_installed(pkg) for pkg in required_packages):
55
+ logger.error(
56
+ "Revenium %s middleware could not load: %s. "
57
+ "Metering will NOT be active for %s calls.",
58
+ provider_label,
59
+ exc,
60
+ provider_label,
61
+ )
62
+ else:
63
+ logger.debug(
64
+ "%s SDK not installed (%s); Revenium %s middleware not loaded",
65
+ provider_label,
66
+ exc,
67
+ provider_label,
68
+ )
@@ -22,7 +22,8 @@ def _build_metering_client(
22
22
  ) -> Optional[ReveniumMetering]:
23
23
  """Construct a ReveniumMetering client from raw env-var inputs.
24
24
 
25
- Returns ``None`` when the API key is missing/empty (logs a warning).
25
+ Returns ``None`` when the API key is missing/empty (logs an error --
26
+ metering silently disabled is the failure mode this SDK must surface).
26
27
  Raises ``ValueError`` when the API key is present but malformed.
27
28
  Falls back to the library default when ``base_url_raw`` is set but not
28
29
  a valid http(s) URL.
@@ -40,7 +41,10 @@ def _build_metering_client(
40
41
  )
41
42
 
42
43
  if not api_key:
43
- logger.warning("Metering API key not set -- metering is disabled")
44
+ logger.error(
45
+ "REVENIUM_METERING_API_KEY environment variable is not set. "
46
+ "Metering is disabled and no usage data will be sent to Revenium."
47
+ )
44
48
  return None
45
49
 
46
50
  validate_api_key(api_key)
@@ -86,10 +90,11 @@ def initialize_metering(api_key: Optional[str] = None, base_url: Optional[str] =
86
90
  settings hooks) or to reconfigure at runtime. Explicit arguments take
87
91
  precedence over ``REVENIUM_METERING_API_KEY`` / ``REVENIUM_METERING_BASE_URL``.
88
92
 
89
- Returns True when metering is enabled after the call. Raises ``ValueError``
90
- for a malformed API key -- explicit configuration fails loudly, unlike the
91
- lazy ``get_client()`` path, which logs a warning and leaves metering
92
- disabled (best-effort by design).
93
+ Returns True when metering is enabled after the call; a missing/empty key
94
+ logs an ERROR and leaves metering disabled. Raises ``ValueError`` for a
95
+ malformed API key -- explicit configuration fails loudly, unlike the lazy
96
+ ``get_client()`` path, which logs a warning once for a malformed env key
97
+ and leaves metering disabled.
93
98
  """
94
99
  global client
95
100
  key = api_key if api_key is not None else os.environ.get("REVENIUM_METERING_API_KEY")
@@ -107,8 +112,9 @@ def get_client() -> Optional[ReveniumMetering]:
107
112
  ``REVENIUM_METERING_API_KEY`` appears, the next metering event builds the
108
113
  client instead of silently no-opping forever.
109
114
 
110
- Best-effort by design: a malformed env key is logged once and metering
111
- stays disabled, whereas the explicit ``initialize_metering()`` raises
115
+ A missing/unset key is logged at ERROR level by the build path; a
116
+ malformed env key is logged once as a warning and metering stays
117
+ disabled, whereas the explicit ``initialize_metering()`` raises
112
118
  ``ValueError`` so programmatic misconfiguration fails loudly.
113
119
  """
114
120
  global _last_failed_key
@@ -240,8 +246,10 @@ class MeteringThread(threading.Thread):
240
246
  # Log errors unless it's during shutdown
241
247
  if not shutdown_event.is_set():
242
248
  self.error = e
249
+ from revenium_middleware._core.metering_status import record_metering_error
250
+ record_metering_error(e)
243
251
  # Use exc_info=True to include traceback in the log
244
- logger.warning(f"Error in metering thread {self.name}: {str(e)}", exc_info=True)
252
+ logger.error(f"Error in metering thread {self.name}: {str(e)}", exc_info=True)
245
253
  else:
246
254
  logger.debug(f"Exception ignored in metering thread {self.name} during shutdown: {str(e)}")
247
255
  finally:
@@ -13,10 +13,15 @@ import os
13
13
  import threading
14
14
  import time
15
15
  from collections import deque
16
- from typing import Any, Callable, Deque, Dict, Optional
16
+ from typing import Any, Callable, Deque, Dict, List, Optional, Tuple
17
17
 
18
18
  import httpx
19
19
 
20
+ from revenium_middleware._core.metering_status import (
21
+ record_metering_error,
22
+ record_metering_success,
23
+ )
24
+
20
25
  logger = logging.getLogger("revenium_middleware")
21
26
 
22
27
  DEFAULT_MAX_SIZE = 1000
@@ -196,12 +201,18 @@ class MeteringBuffer:
196
201
  """
197
202
  sent = expired = discarded = 0
198
203
  started = time.monotonic()
204
+ # Status recording is deferred until _flush_lock is released:
205
+ # record_metering_error() runs subscriber callbacks synchronously, and
206
+ # a callback that calls back into this buffer would self-deadlock on
207
+ # the non-reentrant lock.
208
+ deferred_outcomes: List[Optional[Tuple[BaseException, str]]] = []
199
209
 
200
210
  with self._flush_lock:
201
211
  while True:
202
212
  if deadline_seconds is not None and time.monotonic() - started >= deadline_seconds:
203
213
  break
204
214
 
215
+ expired_kind: Optional[str] = None
205
216
  with self._lock:
206
217
  if not self._events:
207
218
  break
@@ -210,7 +221,19 @@ class MeteringBuffer:
210
221
  self._events.popleft()
211
222
  self._total_expired += 1
212
223
  expired += 1
213
- continue
224
+ expired_kind = event.kind
225
+ if expired_kind is not None:
226
+ # Expiry is a terminal failure with no delivery exception
227
+ # in hand; synthesize one so status counters and
228
+ # on_metering_error subscribers still get the signal.
229
+ deferred_outcomes.append((
230
+ TimeoutError(
231
+ f"buffered {expired_kind} metering event expired after "
232
+ f"{self._max_age_seconds:.0f}s without successful replay"
233
+ ),
234
+ expired_kind,
235
+ ))
236
+ continue
214
237
 
215
238
  # Cap each replay call so a single slow network call cannot
216
239
  # blow through the flush deadline (e.g. the shutdown budget).
@@ -232,16 +255,23 @@ class MeteringBuffer:
232
255
  # Only count the discard if the event is still at the
233
256
  # front; a concurrent push at capacity may have evicted
234
257
  # (and counted) it already.
258
+ discarded_here = False
235
259
  with self._lock:
236
260
  if self._events and self._events[0] is event:
237
261
  self._events.popleft()
238
262
  self._total_discarded += 1
239
263
  discarded += 1
264
+ discarded_here = True
265
+ if discarded_here:
266
+ # Terminal failure: the event is gone for good, so
267
+ # surface it to status counters and subscribers.
268
+ deferred_outcomes.append((exc, event.kind))
240
269
  logger.debug("Discarded buffered event after permanent failure: %s", exc)
241
270
  continue
242
271
 
243
272
  # Same identity guard: only count the replay if we actually
244
273
  # popped this event (not concurrently evicted-and-counted).
274
+ replayed_here = False
245
275
  with self._lock:
246
276
  if self._events and self._events[0] is event:
247
277
  self._events.popleft()
@@ -249,6 +279,16 @@ class MeteringBuffer:
249
279
  if len(self._events) < self._max_size:
250
280
  self._was_full = False
251
281
  sent += 1
282
+ replayed_here = True
283
+ if replayed_here:
284
+ deferred_outcomes.append(None)
285
+
286
+ for outcome in deferred_outcomes:
287
+ if outcome is None:
288
+ record_metering_success()
289
+ else:
290
+ error, kind = outcome
291
+ record_metering_error(error, operation=kind)
252
292
 
253
293
  remaining = self.stats()["size"]
254
294
  if sent or expired or discarded:
@@ -0,0 +1,172 @@
1
+ """Metering error visibility: status counters and subscriber callbacks.
2
+
3
+ Metering runs in background threads and must never raise into the customer's
4
+ code path, so failures were historically visible only as log lines. This
5
+ module gives customers two ways to learn about metering failures
6
+ programmatically:
7
+
8
+ - ``on_metering_error(callback)`` — subscribe to failures as they happen.
9
+ - ``get_metering_status()`` — poll a snapshot of success/error counters and
10
+ the most recent error.
11
+
12
+ Both are re-exported from the top-level ``revenium_middleware`` package.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import threading
18
+ from dataclasses import dataclass
19
+ from datetime import datetime, timezone
20
+ from typing import Callable, List, Optional
21
+
22
+ logger = logging.getLogger("revenium_middleware")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class MeteringErrorEvent:
27
+ """A single metering delivery failure, passed to error callbacks."""
28
+
29
+ error: BaseException
30
+ operation: Optional[str]
31
+ timestamp: datetime
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class MeteringStatus:
36
+ """Point-in-time snapshot of metering delivery health."""
37
+
38
+ error_count: int
39
+ success_count: int
40
+ last_error: Optional[BaseException]
41
+ last_error_at: Optional[datetime]
42
+
43
+
44
+ _lock = threading.Lock()
45
+ _error_count = 0
46
+ _success_count = 0
47
+ _last_error: Optional[BaseException] = None
48
+ _last_error_at: Optional[datetime] = None
49
+ _error_callbacks: List[Callable[[MeteringErrorEvent], None]] = []
50
+
51
+
52
+ def get_metering_status() -> MeteringStatus:
53
+ """Return a snapshot of metering success/error counters."""
54
+ with _lock:
55
+ return MeteringStatus(
56
+ error_count=_error_count,
57
+ success_count=_success_count,
58
+ last_error=_last_error,
59
+ last_error_at=_last_error_at,
60
+ )
61
+
62
+
63
+ def on_metering_error(
64
+ callback: Callable[[MeteringErrorEvent], None],
65
+ ) -> Callable[[MeteringErrorEvent], None]:
66
+ """Subscribe to metering failures.
67
+
68
+ The callback receives a :class:`MeteringErrorEvent` for every recorded
69
+ failure. Callbacks run on the (background) thread that detected the
70
+ failure; exceptions they raise are logged and suppressed so they can
71
+ never disrupt the customer's AI calls. Returns the callback, so it can
72
+ be used as a decorator.
73
+ """
74
+ with _lock:
75
+ _error_callbacks.append(callback)
76
+ return callback
77
+
78
+
79
+ def remove_metering_error_callback(
80
+ callback: Callable[[MeteringErrorEvent], None],
81
+ ) -> None:
82
+ """Unsubscribe a callback previously registered via ``on_metering_error``."""
83
+ with _lock:
84
+ try:
85
+ _error_callbacks.remove(callback)
86
+ except ValueError:
87
+ pass
88
+
89
+
90
+ def reset_metering_status() -> None:
91
+ """Reset counters, last error, and registered callbacks."""
92
+ global _error_count, _success_count, _last_error, _last_error_at
93
+ with _lock:
94
+ _error_count = 0
95
+ _success_count = 0
96
+ _last_error = None
97
+ _last_error_at = None
98
+ _error_callbacks.clear()
99
+
100
+
101
+ def record_metering_success() -> None:
102
+ """Record one successfully delivered metering event."""
103
+ global _success_count
104
+ with _lock:
105
+ _success_count += 1
106
+
107
+
108
+ _SENSITIVE_HEADERS = ("x-api-key", "authorization")
109
+
110
+
111
+ def _redact_sensitive_headers(error: BaseException) -> None:
112
+ """Redact auth headers on any HTTP request/response attached to ``error``.
113
+
114
+ HTTP client exceptions (e.g. ``APIStatusError``) expose the live
115
+ ``httpx.Request``/``httpx.Response`` as public attributes, and httpx does
116
+ not redact the ``x-api-key`` header the SDK authenticates with. Overwrite
117
+ ``x-api-key`` and ``authorization`` (case-insensitive, only if already
118
+ present) in place before the error reaches callbacks or ``last_error``.
119
+ Never raises, and each target is attempted independently: httpx's
120
+ ``Response.request`` property raises ``RuntimeError`` (not
121
+ ``AttributeError``) when no request was attached, and a failure on one
122
+ lookup must not defeat redaction of the objects that are reachable.
123
+ """
124
+ targets = []
125
+ for owner, attr in ((error, "request"), (error, "response")):
126
+ try:
127
+ targets.append(getattr(owner, attr, None))
128
+ except Exception:
129
+ targets.append(None)
130
+ try:
131
+ targets.append(getattr(targets[1], "request", None))
132
+ except Exception:
133
+ pass
134
+ for obj in targets:
135
+ try:
136
+ headers = getattr(obj, "headers", None)
137
+ if headers is None:
138
+ continue
139
+ for name in _SENSITIVE_HEADERS:
140
+ if name in headers:
141
+ headers[name] = "[REDACTED]"
142
+ except Exception:
143
+ continue
144
+
145
+
146
+ def record_metering_error(
147
+ error: BaseException, operation: Optional[str] = None
148
+ ) -> None:
149
+ """Record a metering delivery failure and notify subscribers.
150
+
151
+ Auth headers on any HTTP request/response carried by ``error`` are
152
+ redacted in place before the error is stored or fanned out. Never
153
+ raises: subscriber exceptions are logged and suppressed so error
154
+ reporting cannot disrupt the customer's code path.
155
+ """
156
+ global _error_count, _last_error, _last_error_at
157
+ _redact_sensitive_headers(error)
158
+ event = MeteringErrorEvent(
159
+ error=error, operation=operation, timestamp=datetime.now(timezone.utc)
160
+ )
161
+ with _lock:
162
+ _error_count += 1
163
+ _last_error = error
164
+ _last_error_at = event.timestamp
165
+ callbacks = list(_error_callbacks)
166
+ for callback in callbacks:
167
+ try:
168
+ callback(event)
169
+ except Exception:
170
+ logger.warning(
171
+ "Metering error callback %r raised; ignoring", callback, exc_info=True
172
+ )