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.
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/PKG-INFO +53 -1
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/README.md +52 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/pyproject.toml +2 -1
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/__init__.py +17 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/config.py +7 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/fields.py +42 -0
- revenium_python_sdk-0.6.0/revenium_middleware/_core/load_diagnostics.py +68 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering.py +17 -9
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering_buffer.py +42 -2
- revenium_python_sdk-0.6.0/revenium_middleware/_core/metering_status.py +172 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering_submission.py +20 -1
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/decorator.py +15 -4
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/ai.py +422 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_audio_params.py +54 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_completion_params.py +52 -1
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_image_params.py +56 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/ai_create_video_params.py +54 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/event_create_params.py +2 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/_metering.py +13 -4
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/__init__.py +4 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/utils.py +10 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/google_ai/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/google_ai/middleware.py +1 -1
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/vertex_ai/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/proxy/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/proxy/middleware.py +1 -1
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/ollama/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/__init__.py +3 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/middleware.py +7 -2
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/__init__.py +14 -3
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/PKG-INFO +53 -1
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/SOURCES.txt +2 -0
- revenium_python_sdk-0.6.0/tests/test_metering.py +411 -0
- revenium_python_sdk-0.5.0/tests/test_metering.py +0 -106
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/LICENSE +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/context.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/decorators.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/enforcement.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/exceptions.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/log_sanitize.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/outcomes.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/patch_registry.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/prompt_extraction.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/subscriber.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/LICENSE +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_base_client.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_client.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_compat.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_constants.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_exceptions.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_files.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_models.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_qs.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_resource.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_response.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_streaming.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_types.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_logs.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_proxy.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_reflection.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_resources_proxy.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_streams.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_sync.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_transform.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_typing.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_utils/_utils.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/_version.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/context.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/py.typed +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/apis.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/resources/events.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/api_meter_request_params.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/api_meter_response_params.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_metering/types/metering_response_resource.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/agentic_outcomes.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/bedrock_adapter.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/bedrock_transport.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/config.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/middleware.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/prompt_extractor.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/provider.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/stream_create.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/anthropic/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/config.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/middleware.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/fal/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/exceptions.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/protocols.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/common/types.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/config.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/google_ai/provider.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/prompt_extractor.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/vertex_ai/middleware.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/google/vertex_ai/provider.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/_metadata.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/anthropic_driver.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/litellm_driver.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/ollama_driver.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/openai_driver.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/openai_embedding_driver.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/griptape/universal_driver.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/job_context.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/job_history.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/config.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/context.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/decorators.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/hooks.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/integrations/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/integrations/crewai.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/middleware.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/litellm/client/validation.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/ollama/middleware.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/ollama/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/azure_config.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/azure_model_resolver.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/config.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/exceptions.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/langchain/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/langchain/_utils.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/langchain/unified_handler.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/prompt_extractor.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/provider.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/openai/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/middleware.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/perplexity_sdk.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/provider.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/perplexity/trace_fields.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/webhooks/__init__.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/webhooks/_verify.py +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/dependency_links.txt +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/requires.txt +0 -0
- {revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_python_sdk.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
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
|
+
)
|
{revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering.py
RENAMED
|
@@ -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
|
|
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.
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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.
|
|
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:
|
{revenium_python_sdk-0.5.0 → revenium_python_sdk-0.6.0}/revenium_middleware/_core/metering_buffer.py
RENAMED
|
@@ -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
|
-
|
|
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
|
+
)
|