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.
- {revenium_python_sdk-0.1.6/revenium_python_sdk.egg-info → revenium_python_sdk-0.1.7}/PKG-INFO +93 -1
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/README.md +92 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/pyproject.toml +1 -1
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/__init__.py +6 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/__init__.py +8 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/context.py +66 -1
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/fields.py +21 -25
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/metering.py +13 -4
- revenium_python_sdk-0.1.7/revenium_middleware/_core/metering_submission.py +68 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/agentic_outcomes.py +3 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/bedrock_adapter.py +35 -34
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/middleware.py +184 -183
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/_metering.py +42 -37
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/utils.py +4 -3
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/middleware.py +2 -1
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/proxy/middleware.py +3 -2
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/ollama/middleware.py +3 -2
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/middleware.py +2 -1
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/middleware.py +2 -1
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/perplexity_sdk.py +2 -1
- revenium_python_sdk-0.1.7/revenium_middleware/webhooks/__init__.py +4 -0
- revenium_python_sdk-0.1.7/revenium_middleware/webhooks/_verify.py +74 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7/revenium_python_sdk.egg-info}/PKG-INFO +93 -1
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/SOURCES.txt +3 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/LICENSE +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/config.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/decorators.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/enforcement.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/exceptions.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/patch_registry.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/prompt_extraction.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/subscriber.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/config.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/prompt_extractor.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/provider.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/anthropic/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/config.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/middleware.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/fal/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/exceptions.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/protocols.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/common/types.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/config.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/google_ai/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/google_ai/middleware.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/google_ai/provider.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/prompt_extractor.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/vertex_ai/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/vertex_ai/middleware.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/google/vertex_ai/provider.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/config.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/context.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/decorators.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/hooks.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/integrations/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/integrations/crewai.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/client/validation.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/litellm/proxy/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/ollama/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/ollama/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/azure_config.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/azure_model_resolver.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/config.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/exceptions.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/langchain/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/langchain/_utils.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/langchain/unified_handler.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/prompt_extractor.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/provider.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/openai/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/__init__.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/provider.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/perplexity/trace_fields.py +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/dependency_links.txt +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/requires.txt +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_python_sdk.egg-info/top_level.txt +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/setup.cfg +0 -0
- {revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/tests/test_metering.py +0 -0
{revenium_python_sdk-0.1.6/revenium_python_sdk.egg-info → revenium_python_sdk-0.1.7}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: revenium-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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)
|
{revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/__init__.py
RENAMED
|
@@ -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
|
{revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/context.py
RENAMED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
{revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/_core/metering.py
RENAMED
|
@@ -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.
|
|
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)
|
{revenium_python_sdk-0.1.6 → revenium_python_sdk-0.1.7}/revenium_middleware/agentic_outcomes.py
RENAMED
|
@@ -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 =
|
|
709
|
-
cache_creation_token_count
|
|
710
|
-
cache_read_token_count
|
|
711
|
-
input_token_cost
|
|
712
|
-
output_token_cost
|
|
713
|
-
total_cost
|
|
714
|
-
output_token_count
|
|
715
|
-
cost_type
|
|
716
|
-
model
|
|
717
|
-
input_token_count
|
|
718
|
-
provider
|
|
719
|
-
model_source
|
|
720
|
-
reasoning_token_count
|
|
721
|
-
request_time
|
|
722
|
-
response_time
|
|
723
|
-
completion_start_time
|
|
724
|
-
request_duration
|
|
725
|
-
time_to_first_token
|
|
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
|
|
728
|
-
total_token_count
|
|
729
|
-
transaction_id
|
|
730
|
-
trace_id
|
|
731
|
-
task_type
|
|
732
|
-
subscriber
|
|
733
|
-
organization_name
|
|
734
|
-
subscription_id
|
|
735
|
-
product_name
|
|
736
|
-
agent
|
|
737
|
-
is_streamed
|
|
738
|
-
operation_type
|
|
739
|
-
response_quality_score
|
|
740
|
-
middleware_source
|
|
741
|
-
extra_body
|
|
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():
|