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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {revenium_python_sdk-0.1.6/revenium_python_sdk.egg-info → revenium_python_sdk-0.1.7}/PKG-INFO +93 -1
  2. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/README.md +92 -0
  3. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/pyproject.toml +1 -1
  4. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/__init__.py +6 -0
  5. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/__init__.py +8 -0
  6. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/context.py +66 -1
  7. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/fields.py +21 -25
  8. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/metering.py +13 -4
  9. revenium_python_sdk-0.1.7/revenium_middleware/_core/metering_submission.py +68 -0
  10. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/agentic_outcomes.py +3 -0
  11. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/bedrock_adapter.py +35 -34
  12. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/middleware.py +184 -183
  13. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/_metering.py +42 -37
  14. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/utils.py +4 -3
  15. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/middleware.py +2 -1
  16. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/proxy/middleware.py +3 -2
  17. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/ollama/middleware.py +3 -2
  18. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/middleware.py +2 -1
  19. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/middleware.py +2 -1
  20. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/perplexity_sdk.py +2 -1
  21. revenium_python_sdk-0.1.7/revenium_middleware/webhooks/__init__.py +4 -0
  22. revenium_python_sdk-0.1.7/revenium_middleware/webhooks/_verify.py +74 -0
  23. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7/revenium_python_sdk.egg-info}/PKG-INFO +93 -1
  24. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/SOURCES.txt +3 -0
  25. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/LICENSE +0 -0
  26. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/config.py +0 -0
  27. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/decorators.py +0 -0
  28. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/enforcement.py +0 -0
  29. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/exceptions.py +0 -0
  30. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/patch_registry.py +0 -0
  31. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/prompt_extraction.py +0 -0
  32. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/subscriber.py +0 -0
  33. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/trace_fields.py +0 -0
  34. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/__init__.py +0 -0
  35. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/config.py +0 -0
  36. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/prompt_extractor.py +0 -0
  37. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/provider.py +0 -0
  38. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/trace_fields.py +0 -0
  39. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/__init__.py +0 -0
  40. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/config.py +0 -0
  41. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/middleware.py +0 -0
  42. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/trace_fields.py +0 -0
  43. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/__init__.py +0 -0
  44. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/__init__.py +0 -0
  45. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/exceptions.py +0 -0
  46. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/protocols.py +0 -0
  47. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/trace_fields.py +0 -0
  48. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/types.py +0 -0
  49. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/config.py +0 -0
  50. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/google_ai/__init__.py +0 -0
  51. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/google_ai/middleware.py +0 -0
  52. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/google_ai/provider.py +0 -0
  53. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/prompt_extractor.py +0 -0
  54. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/vertex_ai/__init__.py +0 -0
  55. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/vertex_ai/middleware.py +0 -0
  56. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/vertex_ai/provider.py +0 -0
  57. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/__init__.py +0 -0
  58. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/__init__.py +0 -0
  59. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/config.py +0 -0
  60. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/context.py +0 -0
  61. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/decorators.py +0 -0
  62. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/hooks.py +0 -0
  63. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/integrations/__init__.py +0 -0
  64. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/integrations/crewai.py +0 -0
  65. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/trace_fields.py +0 -0
  66. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/validation.py +0 -0
  67. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/proxy/__init__.py +0 -0
  68. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/ollama/__init__.py +0 -0
  69. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/ollama/trace_fields.py +0 -0
  70. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/__init__.py +0 -0
  71. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/azure_config.py +0 -0
  72. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/azure_model_resolver.py +0 -0
  73. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/config.py +0 -0
  74. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/exceptions.py +0 -0
  75. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/langchain/__init__.py +0 -0
  76. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/langchain/_utils.py +0 -0
  77. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/langchain/unified_handler.py +0 -0
  78. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/prompt_extractor.py +0 -0
  79. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/provider.py +0 -0
  80. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/trace_fields.py +0 -0
  81. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/__init__.py +0 -0
  82. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/provider.py +0 -0
  83. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/trace_fields.py +0 -0
  84. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/dependency_links.txt +0 -0
  85. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/requires.txt +0 -0
  86. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/top_level.txt +0 -0
  87. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/setup.cfg +0 -0
  88. {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/tests/test_metering.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: revenium-python-sdk
3
- Version: 0.1.6
3
+ Version: 0.1.7
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
@@ -227,6 +227,98 @@ See [`examples/agentic_outcomes/`](examples/agentic_outcomes/) for runnable demo
227
227
 
228
228
  ---
229
229
 
230
+ ## Idempotency
231
+
232
+ Every metering POST from the provider middleware automatically includes an `Idempotency-Key` header. If the Revenium API receives the same key with the same body within 24 hours, it returns the cached response instead of double-billing — making metering submissions safe to retry.
233
+
234
+ ### Default
235
+
236
+ A fresh UUID v4 is generated automatically for every metering call. No action required.
237
+
238
+ ### Override
239
+
240
+ Use the `idempotency_key` context manager to tie metering to a business-level identifier so the same logical operation never double-meters across retries:
241
+
242
+ ```python
243
+ from revenium_middleware import idempotency_key
244
+
245
+ with idempotency_key(f"order-{order_id}"):
246
+ response = openai.chat.completions.create(...)
247
+ ```
248
+
249
+ The context manager is backed by `contextvars`, so it scopes correctly across threads and asyncio tasks.
250
+
251
+ ### Backend behavior
252
+
253
+ | Scenario | Backend response |
254
+ | -- | -- |
255
+ | First call with key K and body B | Executes, caches for 24h |
256
+ | Retry with same K and same B | Returns cached response (no double-bill) |
257
+ | Same K with different B | `409 idempotency_key_mismatch` |
258
+ | Concurrent in-flight with same K | `409 idempotency_key_in_progress` + `Retry-After: 1` |
259
+ | Malformed key | `400 invalid_idempotency_key` |
260
+
261
+ See [docs.revenium.io/integrations/idempotency](https://docs.revenium.io/integrations/idempotency) for full backend semantics.
262
+
263
+ ### Key format
264
+
265
+ `Idempotency-Key` must be 1–255 printable ASCII characters. UUID v4 is the recommended format and what the SDK generates by default.
266
+
267
+ ---
268
+
269
+ ## Webhook Signature Verification
270
+
271
+ Revenium signs every outbound webhook with HMAC-SHA256 when a signing secret is configured. The SDK ships a verification helper so your handler can validate signatures without writing crypto.
272
+
273
+ Two headers arrive on every signed delivery:
274
+
275
+ | Header | Value |
276
+ | -- | -- |
277
+ | `X-Revenium-Signature-256` | `sha256=<hex>`. During a 24h rotation overlap: `sha256=A, sha256=B`. |
278
+ | `X-Revenium-Webhook-Timestamp` | Unix seconds at signing time. |
279
+
280
+ ### FastAPI example
281
+
282
+ ```python
283
+ import os
284
+
285
+ from fastapi import FastAPI, Header, HTTPException, Request
286
+
287
+ from revenium_middleware.webhooks import verify_signature
288
+
289
+ app = FastAPI()
290
+ SIGNING_SECRETS = [os.environ["REVENIUM_WEBHOOK_SECRET"]]
291
+
292
+
293
+ @app.post("/webhooks/revenium")
294
+ async def receive(
295
+ request: Request,
296
+ x_revenium_signature_256: str = Header(...),
297
+ x_revenium_webhook_timestamp: str = Header(...),
298
+ ):
299
+ body = await request.body()
300
+ if not verify_signature(
301
+ payload=body,
302
+ signature_header=x_revenium_signature_256,
303
+ timestamp_header=x_revenium_webhook_timestamp,
304
+ secrets=SIGNING_SECRETS,
305
+ ):
306
+ raise HTTPException(status_code=401, detail="Invalid signature")
307
+
308
+ # ... process the event
309
+ return {"ok": True}
310
+ ```
311
+
312
+ ### Secret rotation
313
+
314
+ When you rotate a signing secret in the Revenium dashboard with the default 24-hour overlap, both the old and new secrets are active simultaneously and every webhook is signed with both. Supply both values in `SIGNING_SECRETS` during the overlap window; remove the old one once it expires.
315
+
316
+ ### Webhooks without a signing secret
317
+
318
+ Webhook deliveries without a configured signing secret arrive without HMAC headers. If your endpoint receives both signed and unsigned traffic, branch on header presence: treat missing headers as legacy unsigned mode and missing-signature-on-signed-only endpoints as an authentication failure.
319
+
320
+ ---
321
+
230
322
  ## Provider Usage Guides
231
323
 
232
324
  ### OpenAI
@@ -139,6 +139,98 @@ See [`examples/agentic_outcomes/`](examples/agentic_outcomes/) for runnable demo
139
139
 
140
140
  ---
141
141
 
142
+ ## Idempotency
143
+
144
+ Every metering POST from the provider middleware automatically includes an `Idempotency-Key` header. If the Revenium API receives the same key with the same body within 24 hours, it returns the cached response instead of double-billing — making metering submissions safe to retry.
145
+
146
+ ### Default
147
+
148
+ A fresh UUID v4 is generated automatically for every metering call. No action required.
149
+
150
+ ### Override
151
+
152
+ Use the `idempotency_key` context manager to tie metering to a business-level identifier so the same logical operation never double-meters across retries:
153
+
154
+ ```python
155
+ from revenium_middleware import idempotency_key
156
+
157
+ with idempotency_key(f"order-{order_id}"):
158
+ response = openai.chat.completions.create(...)
159
+ ```
160
+
161
+ The context manager is backed by `contextvars`, so it scopes correctly across threads and asyncio tasks.
162
+
163
+ ### Backend behavior
164
+
165
+ | Scenario | Backend response |
166
+ | -- | -- |
167
+ | First call with key K and body B | Executes, caches for 24h |
168
+ | Retry with same K and same B | Returns cached response (no double-bill) |
169
+ | Same K with different B | `409 idempotency_key_mismatch` |
170
+ | Concurrent in-flight with same K | `409 idempotency_key_in_progress` + `Retry-After: 1` |
171
+ | Malformed key | `400 invalid_idempotency_key` |
172
+
173
+ See [docs.revenium.io/integrations/idempotency](https://docs.revenium.io/integrations/idempotency) for full backend semantics.
174
+
175
+ ### Key format
176
+
177
+ `Idempotency-Key` must be 1–255 printable ASCII characters. UUID v4 is the recommended format and what the SDK generates by default.
178
+
179
+ ---
180
+
181
+ ## Webhook Signature Verification
182
+
183
+ Revenium signs every outbound webhook with HMAC-SHA256 when a signing secret is configured. The SDK ships a verification helper so your handler can validate signatures without writing crypto.
184
+
185
+ Two headers arrive on every signed delivery:
186
+
187
+ | Header | Value |
188
+ | -- | -- |
189
+ | `X-Revenium-Signature-256` | `sha256=<hex>`. During a 24h rotation overlap: `sha256=A, sha256=B`. |
190
+ | `X-Revenium-Webhook-Timestamp` | Unix seconds at signing time. |
191
+
192
+ ### FastAPI example
193
+
194
+ ```python
195
+ import os
196
+
197
+ from fastapi import FastAPI, Header, HTTPException, Request
198
+
199
+ from revenium_middleware.webhooks import verify_signature
200
+
201
+ app = FastAPI()
202
+ SIGNING_SECRETS = [os.environ["REVENIUM_WEBHOOK_SECRET"]]
203
+
204
+
205
+ @app.post("/webhooks/revenium")
206
+ async def receive(
207
+ request: Request,
208
+ x_revenium_signature_256: str = Header(...),
209
+ x_revenium_webhook_timestamp: str = Header(...),
210
+ ):
211
+ body = await request.body()
212
+ if not verify_signature(
213
+ payload=body,
214
+ signature_header=x_revenium_signature_256,
215
+ timestamp_header=x_revenium_webhook_timestamp,
216
+ secrets=SIGNING_SECRETS,
217
+ ):
218
+ raise HTTPException(status_code=401, detail="Invalid signature")
219
+
220
+ # ... process the event
221
+ return {"ok": True}
222
+ ```
223
+
224
+ ### Secret rotation
225
+
226
+ When you rotate a signing secret in the Revenium dashboard with the default 24-hour overlap, both the old and new secrets are active simultaneously and every webhook is signed with both. Supply both values in `SIGNING_SECRETS` during the overlap window; remove the old one once it expires.
227
+
228
+ ### Webhooks without a signing secret
229
+
230
+ Webhook deliveries without a configured signing secret arrive without HMAC headers. If your endpoint receives both signed and unsigned traffic, branch on header presence: treat missing headers as legacy unsigned mode and missing-signature-on-signed-only endpoints as an authentication failure.
231
+
232
+ ---
233
+
142
234
  ## Provider Usage Guides
143
235
 
144
236
  ### OpenAI
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "revenium-python-sdk"
7
- version = "0.1.6"
7
+ version = "0.1.7"
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"
@@ -151,6 +151,9 @@ from ._core import ( # noqa: E402
151
151
  set_injected_metadata,
152
152
  clear_injected_metadata,
153
153
  merge_metadata,
154
+ idempotency_key,
155
+ get_idempotency_key,
156
+ set_idempotency_key,
154
157
  is_selective_metering_enabled,
155
158
  )
156
159
 
@@ -178,6 +181,9 @@ __all__ = [
178
181
  "set_injected_metadata",
179
182
  "clear_injected_metadata",
180
183
  "merge_metadata",
184
+ "idempotency_key",
185
+ "get_idempotency_key",
186
+ "set_idempotency_key",
181
187
  # Config exports
182
188
  "is_selective_metering_enabled",
183
189
  # Tool metering exports (from revenium_metering)
@@ -6,6 +6,7 @@ provider-specific middleware implementations.
6
6
  """
7
7
 
8
8
  from .metering import run_async_in_thread, shutdown_event, client
9
+ from .metering_submission import submit_ai_event
9
10
  from .exceptions import BudgetExceededError
10
11
  from .enforcement import check_enforcement, is_circuit_breaker_enabled, stop_polling
11
12
  from .context import (
@@ -17,6 +18,9 @@ from .context import (
17
18
  set_injected_metadata,
18
19
  clear_injected_metadata,
19
20
  merge_metadata,
21
+ idempotency_key,
22
+ get_idempotency_key,
23
+ set_idempotency_key,
20
24
  )
21
25
  from .decorators import revenium_meter, revenium_metadata, track_usage
22
26
  from .config import is_selective_metering_enabled
@@ -48,6 +52,7 @@ __all__ = [
48
52
  "client",
49
53
  "run_async_in_thread",
50
54
  "shutdown_event",
55
+ "submit_ai_event",
51
56
  # Enforcement / circuit breaker
52
57
  "BudgetExceededError",
53
58
  "check_enforcement",
@@ -66,6 +71,9 @@ __all__ = [
66
71
  "set_injected_metadata",
67
72
  "clear_injected_metadata",
68
73
  "merge_metadata",
74
+ "idempotency_key",
75
+ "get_idempotency_key",
76
+ "set_idempotency_key",
69
77
  # Config
70
78
  "is_selective_metering_enabled",
71
79
  # Field extraction
@@ -6,8 +6,9 @@ whether code is currently executing inside a decorated function that should be m
6
6
  and to store metadata that should be injected into API calls.
7
7
  """
8
8
 
9
+ import contextlib
9
10
  import contextvars
10
- from typing import Optional, Dict, Any
11
+ from typing import Optional, Dict, Any, Iterator
11
12
 
12
13
  # Context variable to track if we're inside a decorated function
13
14
  _decorated_function_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
@@ -24,6 +25,11 @@ _injected_metadata_context: contextvars.ContextVar[Optional[Dict[str, Any]]] = c
24
25
  'revenium_injected_metadata', default=None
25
26
  )
26
27
 
28
+ # Context variable to store an Idempotency-Key override for the current scope
29
+ _idempotency_key_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
30
+ 'revenium_idempotency_key', default=None
31
+ )
32
+
27
33
 
28
34
  def is_inside_decorated_function() -> bool:
29
35
  """
@@ -107,3 +113,62 @@ def merge_metadata(api_metadata: Optional[Dict[str, Any]] = None) -> Dict[str, A
107
113
  merged = {**injected, **api}
108
114
  return merged
109
115
 
116
+
117
+ def get_idempotency_key() -> Optional[str]:
118
+ """Return the Idempotency-Key override set on the current context, or None."""
119
+ return _idempotency_key_context.get()
120
+
121
+
122
+ def set_idempotency_key(key: Optional[str]) -> contextvars.Token:
123
+ """Set the Idempotency-Key override on the current context. Returns a Token for reset().
124
+
125
+ Note: passing ``None`` is indistinguishable from "no override set" to consumers — both
126
+ cause the wrapper to fall through to UUID v4 generation. Use this function with a real
127
+ string for explicit overrides; rely on the ``idempotency_key()`` context manager for
128
+ scoped overrides that auto-reset on exit.
129
+
130
+ Raises:
131
+ ValueError: if ``key`` is the empty string. Mirrors the guard on the
132
+ ``idempotency_key()`` CM so empty strings can never reach the wrapper
133
+ via any entry point.
134
+ """
135
+ if key == "":
136
+ raise ValueError(
137
+ "idempotency_key must be a non-empty string; "
138
+ "the backend rejects empty keys"
139
+ )
140
+ return _idempotency_key_context.set(key)
141
+
142
+
143
+ @contextlib.contextmanager
144
+ def idempotency_key(key: str) -> Iterator[None]:
145
+ """Override the auto-generated Idempotency-Key for AI metering calls within the block.
146
+
147
+ Args:
148
+ key: The explicit key to use. Must be a non-empty string. Forwarded
149
+ verbatim to the backend, which enforces the 1-255 printable ASCII
150
+ constraint.
151
+
152
+ Raises:
153
+ ValueError: if ``key`` is the empty string. The backend would reject
154
+ empty keys with ``400 invalid_idempotency_key``, which the
155
+ provider middleware then swallows — failing fast at the CM
156
+ surfaces the bug at its source instead.
157
+
158
+ Example:
159
+ from revenium_middleware import idempotency_key
160
+
161
+ with idempotency_key(f"order-{order_id}"):
162
+ response = openai.chat.completions.create(...)
163
+ """
164
+ if key == "":
165
+ raise ValueError(
166
+ "idempotency_key must be a non-empty string; "
167
+ "the backend rejects empty keys and the SDK's middleware swallows the error"
168
+ )
169
+ token = _idempotency_key_context.set(key)
170
+ try:
171
+ yield
172
+ finally:
173
+ _idempotency_key_context.reset(token)
174
+
@@ -1,9 +1,17 @@
1
1
  import logging
2
+ import threading
2
3
  import warnings
3
4
  from typing import Any, Dict, Mapping, Optional, Set, Tuple
4
5
 
5
6
  logger = logging.getLogger(__name__)
6
7
 
8
+ # Dedup set for deprecated-alias logger.warning so high-volume callers don't get a flood
9
+ # per call. Python's warnings module already dedupes warnings.warn by call site, but
10
+ # logger.warning has no built-in dedup. The lock makes the check-then-add atomic so
11
+ # concurrent threads can't both observe "not seen" and emit duplicate startup warnings.
12
+ _WARNED_DEPRECATED_FIELDS: Set[Tuple[str, str]] = set()
13
+ _WARNED_DEPRECATED_LOCK = threading.Lock()
14
+
7
15
  AGENTIC_JOB_FIELD_MAP = {
8
16
  "agenticJobId": ("agentic_job_id", "agenticJobId"),
9
17
  "agenticJobName": ("agentic_job_name", "agenticJobName"),
@@ -11,21 +19,6 @@ AGENTIC_JOB_FIELD_MAP = {
11
19
  "agenticJobVersion": ("agentic_job_version", "agenticJobVersion"),
12
20
  }
13
21
 
14
- # Tracks (old_field, new_field) pairs already warned about in this process,
15
- # so high-volume callers don't get a deprecation log flood per call.
16
- _WARNED_DEPRECATED_FIELDS: Set[Tuple[str, str]] = set()
17
-
18
-
19
- def _reset_deprecation_warning_cache() -> None:
20
- """Clear the once-per-key deprecation-warning gate.
21
-
22
- Intended for test isolation — production callers should not need this.
23
- Test suites with autouse fixtures call this before each test so that tests
24
- which assert a warning fires on a specific call do not bleed across each
25
- other.
26
- """
27
- _WARNED_DEPRECATED_FIELDS.clear()
28
-
29
22
 
30
23
  def extract_field_with_fallback(
31
24
  source: Mapping[str, Any],
@@ -45,17 +38,20 @@ def extract_field_with_fallback(
45
38
 
46
39
  if source.get(old_snake) or source.get(old_camel):
47
40
  if not (source.get(new_snake) or source.get(new_camel)):
48
- key = (old_camel, new_camel)
49
- if key not in _WARNED_DEPRECATED_FIELDS:
50
- _WARNED_DEPRECATED_FIELDS.add(key)
51
- msg = (
52
- "Fields '%s' and '%s' are deprecated and are no longer "
53
- "accepted by the Revenium backend. The SDK is translating to "
54
- "'%s' for this call. Use '%s' or '%s' instead. The "
55
- "input-layer aliases will be removed in the next major release."
56
- )
41
+ msg = (
42
+ "Fields '%s' and '%s' are deprecated and are no longer "
43
+ "accepted by the Revenium backend. The SDK is translating to "
44
+ "'%s' for this call. Use '%s' or '%s' instead. The "
45
+ "input-layer aliases will be removed in the next major release."
46
+ )
47
+ pair = (old_snake, new_snake)
48
+ with _WARNED_DEPRECATED_LOCK:
49
+ should_log = pair not in _WARNED_DEPRECATED_FIELDS
50
+ if should_log:
51
+ _WARNED_DEPRECATED_FIELDS.add(pair)
52
+ if should_log:
57
53
  logger.warning(msg, old_camel, old_snake, new_camel, new_camel, new_snake)
58
- warnings.warn(msg % (old_camel, old_snake, new_camel, new_camel, new_snake), DeprecationWarning, stacklevel=3)
54
+ warnings.warn(msg % (old_camel, old_snake, new_camel, new_camel, new_snake), DeprecationWarning, stacklevel=3)
59
55
 
60
56
  return value
61
57
 
@@ -3,6 +3,7 @@ import time
3
3
  import logging
4
4
  import asyncio
5
5
  import threading
6
+ import contextvars
6
7
  import atexit
7
8
  import signal
8
9
  from typing import Literal, Awaitable, Any, Optional, Callable
@@ -116,11 +117,12 @@ else:
116
117
 
117
118
 
118
119
  class MeteringThread(threading.Thread):
119
- def __init__(self, coro, *args, **kwargs):
120
+ def __init__(self, coro, *args, ctx: Optional[contextvars.Context] = None, **kwargs):
120
121
  # Default to non-daemon threads so atexit handlers wait for them
121
122
  daemon = kwargs.pop('daemon', False)
122
123
  super().__init__(*args, **kwargs)
123
124
  self.coro = coro
125
+ self.ctx = ctx
124
126
  self.daemon = daemon # Store daemon status
125
127
  self.error = None
126
128
  self.loop = None
@@ -144,8 +146,11 @@ class MeteringThread(threading.Thread):
144
146
  asyncio.set_event_loop(self.loop)
145
147
  logger.debug(f"Metering thread {self.name} started with loop {id(self.loop)}")
146
148
  try:
147
- # Run the coroutine until it completes
148
- self.loop.run_until_complete(self.coro)
149
+ # Run the coroutine until it completes, inside the captured context if available
150
+ if self.ctx is not None:
151
+ self.ctx.run(self.loop.run_until_complete, self.coro)
152
+ else:
153
+ self.loop.run_until_complete(self.coro)
149
154
  finally:
150
155
  # Ensure async generators are properly shut down
151
156
  logger.debug(f"Shutting down async generators for loop {id(self.loop)} in thread {self.name}")
@@ -210,9 +215,13 @@ def run_async_in_thread(coroutine_or_func):
210
215
  return None
211
216
 
212
217
 
218
+ # Capture the current context so contextvars (e.g. idempotency_key) propagate
219
+ # into the worker thread, which otherwise starts with an empty context.
220
+ ctx = contextvars.copy_context()
221
+
213
222
  # Create and start the thread
214
223
  # Pass daemon=False explicitly if that's the desired default
215
- thread = MeteringThread(coro, daemon=False)
224
+ thread = MeteringThread(coro, ctx=ctx, daemon=False)
216
225
  with _threads_lock:
217
226
  active_threads.append(thread)
218
227
  logger.debug(f"Starting and adding thread {thread.name} to active list.")
@@ -0,0 +1,68 @@
1
+ """Centralized submission of AI metering events with Idempotency-Key injection."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from typing import Any, Dict, Optional
6
+
7
+ from revenium_middleware._core.context import get_idempotency_key
8
+ from revenium_middleware._core.metering import client
9
+
10
+
11
+ _AI_OPERATIONS = frozenset({"completion", "image", "video", "audio"})
12
+
13
+
14
+ def submit_ai_event(
15
+ operation: str,
16
+ args: Dict[str, Any],
17
+ idempotency_key: Optional[str] = None,
18
+ ) -> Any:
19
+ """Submit an AI metering event with an Idempotency-Key header.
20
+
21
+ Args:
22
+ operation: One of "completion", "image", "video", "audio".
23
+ args: Keyword arguments forwarded to client.ai.create_<operation>.
24
+ idempotency_key: Optional explicit override. If None, the function
25
+ checks the current contextvar (set via the idempotency_key()
26
+ context manager or set_idempotency_key());
27
+ if that is also None, a UUID v4 is generated.
28
+
29
+ Returns:
30
+ Whatever client.ai.create_<operation> returns. Returns None when
31
+ the metering client is not configured (no API key).
32
+
33
+ Raises:
34
+ ValueError: if operation is not one of the recognized AI operations.
35
+ """
36
+ if client is None:
37
+ return None
38
+
39
+ if operation not in _AI_OPERATIONS:
40
+ raise ValueError(
41
+ f"Unknown AI metering operation {operation!r}; "
42
+ f"expected one of {sorted(_AI_OPERATIONS)}"
43
+ )
44
+
45
+ if idempotency_key is not None:
46
+ if idempotency_key == "":
47
+ raise ValueError(
48
+ "idempotency_key must be a non-empty string; "
49
+ "the backend rejects empty keys"
50
+ )
51
+ key = idempotency_key
52
+ else:
53
+ ctx_key = get_idempotency_key()
54
+ key = ctx_key if ctx_key is not None else str(uuid.uuid4())
55
+
56
+ existing_headers = args.get("extra_headers") or {}
57
+ if any(k.lower() == "idempotency-key" for k in existing_headers):
58
+ raise ValueError(
59
+ "Pass Idempotency-Key via the idempotency_key parameter, "
60
+ "not via extra_headers; the wrapper owns this header"
61
+ )
62
+ merged_args = {
63
+ **args,
64
+ "extra_headers": {**existing_headers, "Idempotency-Key": key},
65
+ }
66
+
67
+ method = getattr(client.ai, f"create_{operation}")
68
+ return method(**merged_args)
@@ -16,6 +16,7 @@ import logging
16
16
  import threading
17
17
  import time
18
18
  import urllib.parse
19
+ import uuid
19
20
  from dataclasses import dataclass
20
21
  from typing import Any, Dict, Optional
21
22
 
@@ -175,6 +176,8 @@ class AgenticOutcomeClient:
175
176
  prompts_truncated=payload.get("promptsTruncated"),
176
177
  error_reason=payload.get("errorReason"),
177
178
  extra_body=extra_body or None,
179
+ # Per-instance client; idempotency key generated inline (see FRONT-1208 spec exception).
180
+ extra_headers={"Idempotency-Key": str(uuid.uuid4())},
178
181
  )
179
182
  return payload
180
183
 
@@ -653,6 +653,7 @@ class BedrockStreamWrapper:
653
653
  try:
654
654
  # Import here to avoid circular imports
655
655
  from revenium_middleware import shutdown_event
656
+ from revenium_middleware._core import submit_ai_event
656
657
  from .provider import Provider, get_provider_metadata
657
658
  from .middleware import _get_thread_safe_client, _safe_run_async_in_thread
658
659
  from .trace_fields import detect_vision_content
@@ -705,41 +706,41 @@ class BedrockStreamWrapper:
705
706
  organization_name, product_name = extract_org_and_product(self.usage_metadata)
706
707
  meta = extract_common_metadata(self.usage_metadata)
707
708
 
708
- result = client.ai.create_completion(
709
- cache_creation_token_count=0,
710
- cache_read_token_count=0,
711
- input_token_cost=None,
712
- output_token_cost=None,
713
- total_cost=None,
714
- output_token_count=completion_tokens,
715
- cost_type="AI",
716
- model=self.final_message.model,
717
- input_token_count=prompt_tokens,
718
- provider=provider_metadata["provider"],
719
- model_source=provider_metadata["model_source"],
720
- reasoning_token_count=0,
721
- request_time=self.request_time,
722
- response_time=self.response_time,
723
- completion_start_time=self.response_time,
724
- request_duration=int(request_duration),
725
- time_to_first_token=int(
709
+ result = submit_ai_event("completion", {
710
+ "cache_creation_token_count": 0,
711
+ "cache_read_token_count": 0,
712
+ "input_token_cost": None,
713
+ "output_token_cost": None,
714
+ "total_cost": None,
715
+ "output_token_count": completion_tokens,
716
+ "cost_type": "AI",
717
+ "model": self.final_message.model,
718
+ "input_token_count": prompt_tokens,
719
+ "provider": provider_metadata["provider"],
720
+ "model_source": provider_metadata["model_source"],
721
+ "reasoning_token_count": 0,
722
+ "request_time": self.request_time,
723
+ "response_time": self.response_time,
724
+ "completion_start_time": self.response_time,
725
+ "request_duration": int(request_duration),
726
+ "time_to_first_token": int(
726
727
  self.first_token_time - self.request_start_time) if self.first_token_time else 0,
727
- stop_reason="END",
728
- total_token_count=prompt_tokens + completion_tokens,
729
- transaction_id=self.response_id,
730
- trace_id=meta["trace_id"],
731
- task_type=meta["task_type"],
732
- subscriber=subscriber if subscriber else None,
733
- organization_name=organization_name,
734
- subscription_id=meta["subscription_id"],
735
- product_name=product_name,
736
- agent=meta["agent"],
737
- is_streamed=True,
738
- operation_type="CHAT",
739
- response_quality_score=meta["response_quality_score"],
740
- middleware_source="PYTHON",
741
- extra_body=extra_body if extra_body else None,
742
- )
728
+ "stop_reason": "END",
729
+ "total_token_count": prompt_tokens + completion_tokens,
730
+ "transaction_id": self.response_id,
731
+ "trace_id": meta["trace_id"],
732
+ "task_type": meta["task_type"],
733
+ "subscriber": subscriber if subscriber else None,
734
+ "organization_name": organization_name,
735
+ "subscription_id": meta["subscription_id"],
736
+ "product_name": product_name,
737
+ "agent": meta["agent"],
738
+ "is_streamed": True,
739
+ "operation_type": "CHAT",
740
+ "response_quality_score": meta["response_quality_score"],
741
+ "middleware_source": "PYTHON",
742
+ "extra_body": extra_body if extra_body else None,
743
+ })
743
744
  logger.debug("Metering call result for Bedrock stream: %s", result)
744
745
  except Exception as e:
745
746
  if not shutdown_event.is_set():