onceonly-sdk 2.0.2__py3-none-any.whl → 3.0.1__py3-none-any.whl
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.
- onceonly/__init__.py +26 -1
- onceonly/_http.py +26 -4
- onceonly/_util.py +3 -1
- onceonly/ai.py +378 -31
- onceonly/ai_models.py +27 -0
- onceonly/client.py +77 -4
- onceonly/decorators.py +87 -1
- onceonly/governance.py +471 -0
- onceonly/models.py +58 -7
- onceonly/version.py +1 -1
- onceonly_sdk-3.0.1.dist-info/METADATA +1031 -0
- onceonly_sdk-3.0.1.dist-info/RECORD +18 -0
- {onceonly_sdk-2.0.2.dist-info → onceonly_sdk-3.0.1.dist-info}/WHEEL +1 -1
- onceonly_sdk-2.0.2.dist-info/METADATA +0 -216
- onceonly_sdk-2.0.2.dist-info/RECORD +0 -17
- {onceonly_sdk-2.0.2.dist-info → onceonly_sdk-3.0.1.dist-info}/licenses/LICENSE +0 -0
- {onceonly_sdk-2.0.2.dist-info → onceonly_sdk-3.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1031 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: onceonly-sdk
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: Python SDK for OnceOnly idempotency API
|
|
5
|
+
Author-email: OnceOnly <support@onceonly.tech>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://onceonly.tech/
|
|
8
|
+
Project-URL: Documentation, https://docs.onceonly.tech
|
|
9
|
+
Project-URL: Repository, https://github.com/mykolademyanov/onceonly-python
|
|
10
|
+
Keywords: idempotency,automation,zapier,make,ai-agents
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: httpx>=0.25
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
21
|
+
Requires-Dist: anyio>=4.0; extra == "test"
|
|
22
|
+
Provides-Extra: langchain
|
|
23
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# OnceOnly Python SDK
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
**AI Agent Execution & Governance Layer**
|
|
31
|
+
|
|
32
|
+
Exactly-once execution + runtime safety + agent control plane.
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/onceonly-sdk/)
|
|
35
|
+
[](https://www.python.org/downloads/)
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
|
|
38
|
+
[Website](https://onceonly.tech/) • [Docs](https://docs.onceonly.tech) • [API Reference](https://docs.onceonly.tech/reference/idempotency/) • [Examples](./examples/)
|
|
39
|
+
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 🎯 What is OnceOnly?
|
|
45
|
+
|
|
46
|
+
**The problem:** AI agents are non-deterministic. They retry failed calls, re-run tools, crash mid-execution, and replay events. This causes duplicate payments, repeated emails, and inconsistent state.
|
|
47
|
+
|
|
48
|
+
**The solution:** OnceOnly sits between your AI and the real world, guaranteeing:
|
|
49
|
+
|
|
50
|
+
✅ **Exactly-once execution** - Same input = same result, always
|
|
51
|
+
✅ **Crash safety** - Worker dies? Pick up where you left off
|
|
52
|
+
✅ **Retry safety** - Agent retries? We deduplicate automatically
|
|
53
|
+
✅ **Budget control** - Cap spending per agent/hour/day
|
|
54
|
+
✅ **Permission enforcement** - Whitelist/blacklist tools
|
|
55
|
+
✅ **Kill switch** - Disable rogue agents instantly
|
|
56
|
+
✅ **Forensic audit** - Complete action history
|
|
57
|
+
|
|
58
|
+
This isn't just idempotency. **This is an AI Agent Control Plane.**
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## ⚡ Quick Start (30 seconds)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install onceonly-sdk
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from onceonly import OnceOnly
|
|
70
|
+
|
|
71
|
+
client = OnceOnly(api_key="once_live_...")
|
|
72
|
+
|
|
73
|
+
# Prevent duplicate webhook processing
|
|
74
|
+
result = client.check_lock(key="webhook:stripe:evt_123", ttl=3600)
|
|
75
|
+
|
|
76
|
+
if result.duplicate:
|
|
77
|
+
return {"status": "already_processed"}
|
|
78
|
+
|
|
79
|
+
# Process webhook (runs exactly once)
|
|
80
|
+
process_payment(webhook_data)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**That's it.** You just made your webhook idempotent.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 🚀 5-Minute Tutorial
|
|
88
|
+
|
|
89
|
+
### 1️⃣ Basic Deduplication (Webhooks, Cron Jobs, Workers)
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from onceonly import OnceOnly
|
|
93
|
+
|
|
94
|
+
client = OnceOnly(api_key="once_live_...")
|
|
95
|
+
|
|
96
|
+
# Stripe webhook
|
|
97
|
+
@app.post("/webhooks/stripe")
|
|
98
|
+
def stripe_webhook(event_id: str):
|
|
99
|
+
result = client.check_lock(
|
|
100
|
+
key=f"stripe:{event_id}",
|
|
101
|
+
ttl=7200 # 2 hours
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if result.duplicate:
|
|
105
|
+
return {"status": "ok"} # Already processed
|
|
106
|
+
|
|
107
|
+
# Process event (guaranteed exactly-once)
|
|
108
|
+
handle_payment_succeeded(event_id)
|
|
109
|
+
return {"status": "processed"}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 2️⃣ AI Agent with Budget & Permissions
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from onceonly import OnceOnly
|
|
116
|
+
|
|
117
|
+
client = OnceOnly(api_key="once_live_...")
|
|
118
|
+
|
|
119
|
+
# Set policy (one-time setup)
|
|
120
|
+
client.gov.upsert_policy({
|
|
121
|
+
"agent_id": "billing-agent",
|
|
122
|
+
"max_actions_per_hour": 200,
|
|
123
|
+
"max_spend_usd_per_day": 50.0,
|
|
124
|
+
"allowed_tools": ["stripe.charge", "send_email"],
|
|
125
|
+
"blocked_tools": ["delete_user"]
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
# Execute tool with enforcement
|
|
129
|
+
result = client.ai.run_tool(
|
|
130
|
+
agent_id="billing-agent",
|
|
131
|
+
tool="stripe.charge",
|
|
132
|
+
args={"amount": 9999, "currency": "usd"},
|
|
133
|
+
spend_usd=0.5, # Track API cost
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if result.allowed:
|
|
137
|
+
print(f"Charged: {result.result}")
|
|
138
|
+
elif result.decision == "blocked":
|
|
139
|
+
print(f"Agent blocked: {result.policy_reason}")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 3️⃣ Exactly-Once Function Execution
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from onceonly import OnceOnly, idempotent_ai
|
|
146
|
+
|
|
147
|
+
client = OnceOnly(api_key="once_live_...")
|
|
148
|
+
|
|
149
|
+
@idempotent_ai(
|
|
150
|
+
client,
|
|
151
|
+
key_fn=lambda user_id: f"welcome:email:{user_id}",
|
|
152
|
+
ttl=86400 # 24 hours
|
|
153
|
+
)
|
|
154
|
+
def send_welcome_email(user_id: str):
|
|
155
|
+
# This runs exactly ONCE per user_id
|
|
156
|
+
# Even if called 1000 times concurrently
|
|
157
|
+
email_service.send(
|
|
158
|
+
to=get_user_email(user_id),
|
|
159
|
+
template="welcome"
|
|
160
|
+
)
|
|
161
|
+
return {"sent": True}
|
|
162
|
+
|
|
163
|
+
# All these calls get the same result
|
|
164
|
+
send_welcome_email("user_123") # Sends email
|
|
165
|
+
send_welcome_email("user_123") # Returns cached result
|
|
166
|
+
send_welcome_email("user_123") # Returns cached result
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## ✅ Cheat-Sheet (Pick The Right Call)
|
|
172
|
+
|
|
173
|
+
**I want…**
|
|
174
|
+
- **Idempotent webhook/cron/job:** `check_lock(key, ttl, meta)`
|
|
175
|
+
- **Long-running server job:** `ai.run_and_wait(key, ttl, metadata)`
|
|
176
|
+
- **Governed tool call (agent + tool):** `ai.run_tool(agent_id, tool, args, spend_usd)`
|
|
177
|
+
- **Local side-effect exactly once:** `ai.run_fn(key, fn, ttl)`
|
|
178
|
+
- **Decorator version:** `@idempotent` or `@idempotent_ai`
|
|
179
|
+
|
|
180
|
+
**Async equivalents**
|
|
181
|
+
- `check_lock_async`
|
|
182
|
+
- `ai.run_and_wait_async`
|
|
183
|
+
- `ai.run_tool_async`
|
|
184
|
+
- `ai.run_fn_async`
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 🤖 Full LLM Agent Flow (No OnceOnly vs OnceOnly)
|
|
189
|
+
|
|
190
|
+
These two examples show why OnceOnly matters in production.
|
|
191
|
+
|
|
192
|
+
### Without OnceOnly (duplicates + money loss)
|
|
193
|
+
```python
|
|
194
|
+
# examples/ai/agent_full_flow_no_onceonly.py
|
|
195
|
+
decision = llm_decide()
|
|
196
|
+
payload = {"tool": decision["tool"], "args": decision["args"]}
|
|
197
|
+
|
|
198
|
+
# A retry or crash can re-run this call
|
|
199
|
+
call_tool(payload)
|
|
200
|
+
call_tool(payload) # duplicate charge
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### With OnceOnly (deduped + governed)
|
|
204
|
+
```python
|
|
205
|
+
# examples/ai/agent_full_flow_onceonly.py
|
|
206
|
+
res = client.ai.run_tool(
|
|
207
|
+
agent_id="billing-agent",
|
|
208
|
+
tool="stripe.charge",
|
|
209
|
+
args={"amount": 9999, "currency": "usd", "user_id": "u_42"},
|
|
210
|
+
spend_usd=0.5
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if res.allowed:
|
|
214
|
+
print(res.result)
|
|
215
|
+
else:
|
|
216
|
+
print("Blocked:", res.policy_reason)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Why this matters**
|
|
220
|
+
- Prevents **duplicate charges** on retries
|
|
221
|
+
- Enforces **budgets** and **permissions**
|
|
222
|
+
- Gives **audit trails** for every tool call
|
|
223
|
+
|
|
224
|
+
**Cost impact (simple example)**
|
|
225
|
+
- Without OnceOnly: 1 retry on a $99 charge = **$198**
|
|
226
|
+
- With OnceOnly: 1 retry on a $99 charge = **$99**
|
|
227
|
+
|
|
228
|
+
**Flow diagram (simplified)**
|
|
229
|
+
```
|
|
230
|
+
LLM -> Tool Call -> External System
|
|
231
|
+
| | |
|
|
232
|
+
| |__ retry ____| (duplicate charge)
|
|
233
|
+
|
|
|
234
|
+
OnceOnly in between
|
|
235
|
+
|
|
|
236
|
+
LLM -> OnceOnly -> Tool Call -> External System
|
|
237
|
+
|
|
|
238
|
+
|__ duplicate detected -> blocked
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## 📚 Complete Feature Matrix
|
|
244
|
+
|
|
245
|
+
| Feature | Description | Use Case |
|
|
246
|
+
|---------|-------------|----------|
|
|
247
|
+
| **check_lock()** | Fast idempotency primitive | Webhooks, cron jobs, workers |
|
|
248
|
+
| **ai.run_and_wait()** | Long-running AI jobs | Image gen, video processing, reports |
|
|
249
|
+
| **ai.run_tool()** | Governance tool runner | Tool calls with budgets/permissions |
|
|
250
|
+
| **ai.run_fn()** | Local exactly-once execution | Payments, emails, database writes |
|
|
251
|
+
| **@idempotent_ai** | Decorator for functions | Simple exactly-once guarantee |
|
|
252
|
+
| **gov.upsert_policy()** | Set agent limits | Budget caps, tool permissions |
|
|
253
|
+
| **gov.disable_agent()** | Kill switch | Emergency stop |
|
|
254
|
+
| **gov.agent_logs()** | Audit trail | Forensics, compliance |
|
|
255
|
+
| **gov.agent_metrics()** | Usage stats | Monitoring, alerting |
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## 🧠 Architecture Layers
|
|
260
|
+
|
|
261
|
+
OnceOnly provides **5 layers** of safety:
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
┌─────────────────────────────────────────────────────────┐
|
|
265
|
+
│ L5: Agent Governance (policies, kill switch, audit) │
|
|
266
|
+
├─────────────────────────────────────────────────────────┤
|
|
267
|
+
│ L4: Decorator Runtime (@idempotent_ai) │
|
|
268
|
+
├─────────────────────────────────────────────────────────┤
|
|
269
|
+
│ L3: Local Side-Effects (ai.run_fn) │
|
|
270
|
+
├─────────────────────────────────────────────────────────┤
|
|
271
|
+
│ L2: AI Job Orchestration (ai.run_and_wait) │
|
|
272
|
+
├─────────────────────────────────────────────────────────┤
|
|
273
|
+
│ L1: Idempotency Primitive (check_lock) │
|
|
274
|
+
└─────────────────────────────────────────────────────────┘
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Pick the layer that fits your use case. They compose cleanly.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 💎 Golden Example (Payment with Full Safety)
|
|
282
|
+
|
|
283
|
+
This example shows **complete** runtime + governance safety:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
from onceonly import OnceOnly, idempotent_ai
|
|
287
|
+
import stripe
|
|
288
|
+
|
|
289
|
+
client = OnceOnly(api_key="once_live_...")
|
|
290
|
+
|
|
291
|
+
# 1. Set governance policy (one-time)
|
|
292
|
+
client.gov.upsert_policy({
|
|
293
|
+
"agent_id": "billing-agent",
|
|
294
|
+
"max_actions_per_hour": 100,
|
|
295
|
+
"max_spend_usd_per_day": 25.0,
|
|
296
|
+
"allowed_tools": ["stripe.charge", "send_receipt"],
|
|
297
|
+
"blocked_tools": ["delete_user", "refund_all"]
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
# 2. Define exactly-once payment function
|
|
301
|
+
@idempotent_ai(
|
|
302
|
+
client,
|
|
303
|
+
key_fn=lambda user_id, amount: f"charge:{user_id}:{amount}",
|
|
304
|
+
ttl=300, # 5 minutes
|
|
305
|
+
metadata_fn=lambda u, a: {
|
|
306
|
+
"user_id": u,
|
|
307
|
+
"amount_cents": a,
|
|
308
|
+
"agent": "billing-agent"
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
def charge_user(user_id: str, amount_cents: int):
|
|
312
|
+
"""Charge user - guaranteed exactly once"""
|
|
313
|
+
return stripe.Charge.create(
|
|
314
|
+
amount=amount_cents,
|
|
315
|
+
currency="usd",
|
|
316
|
+
customer=get_stripe_customer_id(user_id)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# 3. Execute with full safety
|
|
320
|
+
result = charge_user("user_42", 9999)
|
|
321
|
+
|
|
322
|
+
if result.status == "completed":
|
|
323
|
+
charge_id = result.result["data"]["id"]
|
|
324
|
+
print(f"✅ Charged: {charge_id}")
|
|
325
|
+
else:
|
|
326
|
+
print(f"❌ Failed: {result.error_code}")
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Guarantees:**
|
|
330
|
+
- ✅ Charged **exactly once** (even if retried 1000x)
|
|
331
|
+
- ✅ Budget enforced (won't exceed $25/day)
|
|
332
|
+
- ✅ Tool allowed (stripe.charge in whitelist)
|
|
333
|
+
- ✅ Crash safe (worker dies? resumes automatically)
|
|
334
|
+
- ✅ Audit logged (forensic trail for compliance)
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## 🛡️ Governance & Safety
|
|
339
|
+
|
|
340
|
+
### Agent Policies
|
|
341
|
+
|
|
342
|
+
Control what agents can do:
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
# Strict policy (whitelist only)
|
|
346
|
+
client.gov.upsert_policy({
|
|
347
|
+
"agent_id": "readonly-agent",
|
|
348
|
+
"max_actions_per_hour": 500,
|
|
349
|
+
"allowed_tools": ["get_user", "search", "list_items"],
|
|
350
|
+
"blocked_tools": [] # Everything else blocked
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
# Moderate policy (blacklist dangerous tools)
|
|
354
|
+
client.gov.upsert_policy({
|
|
355
|
+
"agent_id": "support-agent",
|
|
356
|
+
"max_actions_per_hour": 200,
|
|
357
|
+
"max_spend_usd_per_day": 50.0,
|
|
358
|
+
"blocked_tools": ["delete_user", "stripe.charge"]
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
# Per-tool limits
|
|
362
|
+
client.gov.upsert_policy({
|
|
363
|
+
"agent_id": "billing-agent",
|
|
364
|
+
"max_calls_per_tool": {
|
|
365
|
+
"stripe.refund": 5, # Max 5 refunds/day
|
|
366
|
+
"send_email": 100 # Max 100 emails/day
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Policy Templates
|
|
372
|
+
|
|
373
|
+
Use pre-configured templates:
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
# Quick setup with sensible defaults
|
|
377
|
+
policy = client.gov.policy_from_template(
|
|
378
|
+
agent_id="new-agent",
|
|
379
|
+
template="moderate", # strict|moderate|permissive|read_only|support_bot
|
|
380
|
+
overrides={
|
|
381
|
+
"max_actions_per_hour": 300,
|
|
382
|
+
"blocked_tools": ["delete_user"]
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**Available templates (server defaults):**
|
|
388
|
+
- `strict`
|
|
389
|
+
- `moderate`
|
|
390
|
+
- `permissive`
|
|
391
|
+
- `read_only`
|
|
392
|
+
- `support_bot`
|
|
393
|
+
|
|
394
|
+
### Kill Switch
|
|
395
|
+
|
|
396
|
+
Instantly disable rogue agents:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
# Emergency stop
|
|
400
|
+
client.gov.disable_agent(
|
|
401
|
+
"rogue-agent",
|
|
402
|
+
reason="Suspicious behavior detected"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Re-enable after investigation
|
|
406
|
+
client.gov.enable_agent("rogue-agent")
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Audit & Forensics
|
|
410
|
+
|
|
411
|
+
Complete action history:
|
|
412
|
+
|
|
413
|
+
```python
|
|
414
|
+
# Get recent actions
|
|
415
|
+
logs = client.gov.agent_logs("billing-agent", limit=100)
|
|
416
|
+
|
|
417
|
+
for log in logs:
|
|
418
|
+
print(f"{log.ts}: {log.tool} - {log.decision}")
|
|
419
|
+
print(f" Reason: {log.policy_reason or log.reason}")
|
|
420
|
+
print(f" Risk: {log.risk_level}")
|
|
421
|
+
print(f" Cost: ${log.spend_usd}")
|
|
422
|
+
|
|
423
|
+
# Get metrics
|
|
424
|
+
metrics = client.gov.agent_metrics("billing-agent", period="day")
|
|
425
|
+
print(f"Actions: {metrics.total_actions}")
|
|
426
|
+
print(f"Blocked: {metrics.blocked_actions}")
|
|
427
|
+
print(f"Spend: ${metrics.total_spend_usd}")
|
|
428
|
+
print(f"Top tools: {metrics.top_tools}")
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## 🔌 Framework Integrations
|
|
434
|
+
|
|
435
|
+
### LangChain
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
from langchain_core.tools import tool
|
|
439
|
+
from onceonly import OnceOnly
|
|
440
|
+
from onceonly.integrations.langchain import make_idempotent_tool
|
|
441
|
+
|
|
442
|
+
client = OnceOnly(api_key="once_live_...")
|
|
443
|
+
|
|
444
|
+
@tool
|
|
445
|
+
def send_email(to: str, subject: str, body: str) -> str:
|
|
446
|
+
"""Send email to user"""
|
|
447
|
+
email_service.send(to=to, subject=subject, body=body)
|
|
448
|
+
return f"Email sent to {to}"
|
|
449
|
+
|
|
450
|
+
# Wrap with idempotency
|
|
451
|
+
idempotent_send_email = make_idempotent_tool(
|
|
452
|
+
send_email,
|
|
453
|
+
client=client,
|
|
454
|
+
key_prefix="agent:email",
|
|
455
|
+
ttl=3600
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Use in agent
|
|
459
|
+
from langchain.agents import AgentExecutor, create_react_agent
|
|
460
|
+
|
|
461
|
+
agent = create_react_agent(llm, tools=[idempotent_send_email], prompt)
|
|
462
|
+
executor = AgentExecutor(agent=agent, tools=[idempotent_send_email])
|
|
463
|
+
|
|
464
|
+
# Agent can retry - we guarantee exactly-once execution
|
|
465
|
+
result = executor.invoke({"input": "Send welcome email to new@user.com"})
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### FastAPI
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
from fastapi import FastAPI, Depends, HTTPException
|
|
472
|
+
from onceonly import OnceOnly
|
|
473
|
+
import os
|
|
474
|
+
|
|
475
|
+
app = FastAPI()
|
|
476
|
+
|
|
477
|
+
def get_onceonly() -> OnceOnly:
|
|
478
|
+
return OnceOnly(api_key=os.environ["ONCEONLY_API_KEY"])
|
|
479
|
+
|
|
480
|
+
@app.post("/webhooks/stripe")
|
|
481
|
+
async def stripe_webhook(
|
|
482
|
+
event: dict,
|
|
483
|
+
client: OnceOnly = Depends(get_onceonly)
|
|
484
|
+
):
|
|
485
|
+
result = await client.check_lock_async(
|
|
486
|
+
key=f"stripe:{event['id']}",
|
|
487
|
+
ttl=7200,
|
|
488
|
+
meta={"type": event["type"]}
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if result.duplicate:
|
|
492
|
+
return {"status": "duplicate"}
|
|
493
|
+
|
|
494
|
+
await process_stripe_event(event)
|
|
495
|
+
return {"status": "processed"}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## 🧰 Tools Registry (User-Owned Tools)
|
|
501
|
+
|
|
502
|
+
Register your own tools (URLs) and enforce permissions per agent.
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
# Register a tool (requires Pro or Agency)
|
|
506
|
+
tool = client.gov.create_tool({
|
|
507
|
+
"name": "send_email",
|
|
508
|
+
"url": "https://example.com/tools/send_email",
|
|
509
|
+
"scope_id": "global",
|
|
510
|
+
"auth": {"type": "hmac_sha256", "secret": "your_shared_secret"},
|
|
511
|
+
"timeout_ms": 15000,
|
|
512
|
+
"max_retries": 2,
|
|
513
|
+
"enabled": True,
|
|
514
|
+
"description": "Send email to user"
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
# Toggle a tool
|
|
518
|
+
client.gov.toggle_tool("send_email", enabled=False)
|
|
519
|
+
|
|
520
|
+
# List tools
|
|
521
|
+
tools = client.gov.list_tools(scope_id="global")
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Tools registry limits by plan**
|
|
525
|
+
- Pro: 10 tools
|
|
526
|
+
- Agency: 500 tools
|
|
527
|
+
|
|
528
|
+
Note: Tools registry is **not available** on Free/Starter.
|
|
529
|
+
|
|
530
|
+
**Rules & expectations (important)**
|
|
531
|
+
- `name` must be unique per `scope_id` and match `^[a-zA-Z0-9_.:-]+$`
|
|
532
|
+
- `scope_id` lets you namespace tools (e.g. `global` or `agent:billing-agent`)
|
|
533
|
+
- `auth.type` currently supports `hmac_sha256` (use a shared secret)
|
|
534
|
+
- Your tool endpoint should verify HMAC and be idempotent on its side
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## 📖 API Reference
|
|
539
|
+
|
|
540
|
+
### Core Client
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
from onceonly import OnceOnly
|
|
544
|
+
|
|
545
|
+
client = OnceOnly(
|
|
546
|
+
api_key="once_live_...",
|
|
547
|
+
base_url="https://api.onceonly.tech/v1", # optional
|
|
548
|
+
timeout=5.0, # HTTP timeout
|
|
549
|
+
fail_open=True, # graceful degradation
|
|
550
|
+
max_retries_429=3, # auto-retry on rate limit
|
|
551
|
+
retry_backoff=0.5, # initial backoff (seconds)
|
|
552
|
+
retry_max_backoff=10.0 # max backoff (seconds)
|
|
553
|
+
)
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### API Endpoints Map (Public)
|
|
557
|
+
|
|
558
|
+
Use this map to find the correct endpoint category quickly:
|
|
559
|
+
|
|
560
|
+
- **Core**: `GET /v1/me`, `GET /v1/usage`, `GET /v1/usage/all`, `GET /v1/events`, `GET /v1/metrics`
|
|
561
|
+
- **Idempotency**: `POST /v1/check-lock`
|
|
562
|
+
- **AI Jobs**: `POST /v1/ai/run`, `GET /v1/ai/status`, `GET /v1/ai/result`
|
|
563
|
+
- **AI Lease (local side-effects)**: `POST /v1/ai/lease`, `POST /v1/ai/extend`, `POST /v1/ai/complete`, `POST /v1/ai/fail`, `POST /v1/ai/cancel`
|
|
564
|
+
- **Governance (policies)**: `POST /v1/policies/{agent_id}`, `POST /v1/policies/{agent_id}/from-template`, `GET /v1/policies`, `GET /v1/policies/{agent_id}`
|
|
565
|
+
- **Governance (agents)**: `POST /v1/agents/{agent_id}/disable`, `POST /v1/agents/{agent_id}/enable`, `GET /v1/agents/{agent_id}/logs`, `GET /v1/agents/{agent_id}/metrics`
|
|
566
|
+
- **Tools Registry**: `POST /v1/tools`, `GET /v1/tools`, `GET /v1/tools/{tool}`, `POST /v1/tools/{tool}/toggle`, `DELETE /v1/tools/{tool}`
|
|
567
|
+
|
|
568
|
+
### Idempotency
|
|
569
|
+
|
|
570
|
+
```python
|
|
571
|
+
# Sync
|
|
572
|
+
result = client.check_lock(
|
|
573
|
+
key="order:12345",
|
|
574
|
+
ttl=3600, # Lock duration (seconds)
|
|
575
|
+
meta={"user_id": 42} # Optional metadata
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Async
|
|
579
|
+
result = await client.check_lock_async(key="order:12345", ttl=3600)
|
|
580
|
+
|
|
581
|
+
# Check result
|
|
582
|
+
if result.duplicate:
|
|
583
|
+
print(f"Duplicate! First seen: {result.first_seen_at}")
|
|
584
|
+
else:
|
|
585
|
+
print("First time - proceed with action")
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### AI Execution
|
|
589
|
+
|
|
590
|
+
```python
|
|
591
|
+
# Long-running job (server-side)
|
|
592
|
+
result = client.ai.run_and_wait(
|
|
593
|
+
key="report:monthly:2024-01",
|
|
594
|
+
ttl=1800, # Job timeout (seconds)
|
|
595
|
+
timeout=120.0, # Polling timeout
|
|
596
|
+
poll_min=1.0, # Min poll interval
|
|
597
|
+
poll_max=10.0, # Max poll interval
|
|
598
|
+
metadata={"month": "2024-01"}
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Governance tool runner (agent + tool)
|
|
602
|
+
tool_res = client.ai.run_tool(
|
|
603
|
+
agent_id="billing-agent",
|
|
604
|
+
tool="stripe.charge",
|
|
605
|
+
args={"amount": 9999, "currency": "usd"},
|
|
606
|
+
spend_usd=0.5
|
|
607
|
+
)
|
|
608
|
+
if tool_res.allowed:
|
|
609
|
+
print(tool_res.result)
|
|
610
|
+
else:
|
|
611
|
+
print(f"Blocked: {tool_res.policy_reason}")
|
|
612
|
+
|
|
613
|
+
# Async tool runner
|
|
614
|
+
tool_res = await client.ai.run_tool_async(
|
|
615
|
+
agent_id="billing-agent",
|
|
616
|
+
tool="stripe.charge",
|
|
617
|
+
args={"amount": 9999, "currency": "usd"},
|
|
618
|
+
spend_usd=0.5
|
|
619
|
+
)
|
|
620
|
+
if tool_res.allowed:
|
|
621
|
+
print(tool_res.result)
|
|
622
|
+
else:
|
|
623
|
+
print(f"Blocked: {tool_res.policy_reason}")
|
|
624
|
+
|
|
625
|
+
# Local function execution
|
|
626
|
+
result = client.ai.run_fn(
|
|
627
|
+
key="email:welcome:user123",
|
|
628
|
+
fn=lambda: send_email(...),
|
|
629
|
+
ttl=300,
|
|
630
|
+
wait_on_conflict=True, # Wait if another process executing
|
|
631
|
+
timeout=60.0,
|
|
632
|
+
error_code="email_failed"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# Check status only (no polling)
|
|
636
|
+
status = client.ai.status("report:monthly:2024-01")
|
|
637
|
+
print(f"Status: {status.status}, TTL: {status.ttl_left}s")
|
|
638
|
+
|
|
639
|
+
# Get result
|
|
640
|
+
result = client.ai.result("report:monthly:2024-01")
|
|
641
|
+
if result.status == "completed":
|
|
642
|
+
print(result.result)
|
|
643
|
+
|
|
644
|
+
### AI Modes (Choose One)
|
|
645
|
+
|
|
646
|
+
| Mode | Use When | Call | Result Type |
|
|
647
|
+
|------|----------|------|-------------|
|
|
648
|
+
| **Job (server-side)** | Long-running tasks | `ai.run_and_wait(key=...)` | `AiResult` |
|
|
649
|
+
| **Tool (governed)** | Agent tool execution | `ai.run_tool(agent_id=..., tool=...)` | `AiToolResult` |
|
|
650
|
+
| **Local side-effects** | Your code does the work | `ai.run_fn(key=..., fn=...)` | `AiResult` |
|
|
651
|
+
|
|
652
|
+
### AI Result Shapes (AI-friendly)
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
# Tool result (governance)
|
|
656
|
+
AiToolResult = {
|
|
657
|
+
"ok": bool,
|
|
658
|
+
"allowed": bool,
|
|
659
|
+
"decision": str, # "executed" | "blocked" | "dedup"
|
|
660
|
+
"policy_reason": str | None,
|
|
661
|
+
"risk_level": str | None,
|
|
662
|
+
"result": dict | None,
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
# Job result (run_and_wait / result)
|
|
666
|
+
AiResult = {
|
|
667
|
+
"ok": bool,
|
|
668
|
+
"status": str, # "completed" | "failed" | "in_progress"
|
|
669
|
+
"key": str,
|
|
670
|
+
"result": dict | None,
|
|
671
|
+
"error_code": str | None,
|
|
672
|
+
"done_at": str | None,
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Tool: Happy vs Blocked
|
|
677
|
+
|
|
678
|
+
```python
|
|
679
|
+
res = client.ai.run_tool(
|
|
680
|
+
agent_id="billing-agent",
|
|
681
|
+
tool="stripe.refund",
|
|
682
|
+
args={"charge_id": "ch_123", "amount": 500},
|
|
683
|
+
spend_usd=0.2
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
if res.allowed:
|
|
687
|
+
print("OK", res.result)
|
|
688
|
+
else:
|
|
689
|
+
print("BLOCKED", res.policy_reason)
|
|
690
|
+
```
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### Decorators
|
|
694
|
+
|
|
695
|
+
```python
|
|
696
|
+
from onceonly import idempotent, idempotent_ai
|
|
697
|
+
|
|
698
|
+
# Basic idempotency
|
|
699
|
+
@idempotent(client, key_prefix="payment", ttl=3600)
|
|
700
|
+
def process_payment(order_id: str):
|
|
701
|
+
# Runs once per order_id
|
|
702
|
+
stripe.charge(...)
|
|
703
|
+
|
|
704
|
+
# AI lease execution
|
|
705
|
+
@idempotent_ai(
|
|
706
|
+
client,
|
|
707
|
+
key_fn=lambda user_id: f"onboard:{user_id}",
|
|
708
|
+
ttl=600,
|
|
709
|
+
metadata_fn=lambda uid: {"user": uid}
|
|
710
|
+
)
|
|
711
|
+
def onboard_user(user_id: str):
|
|
712
|
+
# Exactly-once, even across multiple workers
|
|
713
|
+
create_account(user_id)
|
|
714
|
+
send_welcome_email(user_id)
|
|
715
|
+
return {"onboarded": True}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
### Governance
|
|
719
|
+
|
|
720
|
+
```python
|
|
721
|
+
# Set policy
|
|
722
|
+
policy = client.gov.upsert_policy({
|
|
723
|
+
"agent_id": "my-agent",
|
|
724
|
+
"max_actions_per_hour": 200,
|
|
725
|
+
"max_spend_usd_per_day": 50.0,
|
|
726
|
+
"allowed_tools": ["tool_a", "tool_b"],
|
|
727
|
+
"blocked_tools": ["dangerous_tool"],
|
|
728
|
+
"max_calls_per_tool": {"tool_a": 10}
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
# From template
|
|
732
|
+
policy = client.gov.policy_from_template(
|
|
733
|
+
agent_id="my-agent",
|
|
734
|
+
template="moderate",
|
|
735
|
+
overrides={"max_actions_per_hour": 300}
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
# Kill switch
|
|
739
|
+
status = client.gov.disable_agent("my-agent", reason="Testing")
|
|
740
|
+
status = client.gov.enable_agent("my-agent")
|
|
741
|
+
|
|
742
|
+
# Audit
|
|
743
|
+
logs = client.gov.agent_logs("my-agent", limit=100)
|
|
744
|
+
metrics = client.gov.agent_metrics("my-agent", period="day")
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
---
|
|
748
|
+
|
|
749
|
+
## ⚙️ Configuration
|
|
750
|
+
|
|
751
|
+
### Environment Variables
|
|
752
|
+
|
|
753
|
+
```bash
|
|
754
|
+
export ONCEONLY_API_KEY="once_live_..."
|
|
755
|
+
export ONCEONLY_BASE_URL="https://api.onceonly.tech/v1" # optional
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
### Fail-Open Behavior
|
|
759
|
+
|
|
760
|
+
Network/server failures don't break your app (graceful degradation):
|
|
761
|
+
|
|
762
|
+
```python
|
|
763
|
+
client = OnceOnly(
|
|
764
|
+
api_key="...",
|
|
765
|
+
fail_open=True # default: allows execution on timeout/5xx
|
|
766
|
+
)
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
**Fail-open NEVER applies to:**
|
|
770
|
+
- 401/403 (auth errors) → Always blocks
|
|
771
|
+
- 402 (usage limit) → Always blocks
|
|
772
|
+
- 422 (validation) → Always blocks
|
|
773
|
+
- 429 (rate limit) → Retries with backoff
|
|
774
|
+
|
|
775
|
+
### Connection Pooling
|
|
776
|
+
|
|
777
|
+
```python
|
|
778
|
+
import httpx
|
|
779
|
+
from onceonly import OnceOnly
|
|
780
|
+
|
|
781
|
+
# Reuse HTTP connections
|
|
782
|
+
sync_client = httpx.Client(
|
|
783
|
+
timeout=10.0,
|
|
784
|
+
limits=httpx.Limits(max_keepalive_connections=20)
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
client = OnceOnly(
|
|
788
|
+
api_key="...",
|
|
789
|
+
sync_client=sync_client
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# Close when done
|
|
793
|
+
client.close()
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
### Context Managers
|
|
797
|
+
|
|
798
|
+
```python
|
|
799
|
+
# Auto-cleanup
|
|
800
|
+
with OnceOnly(api_key="...") as client:
|
|
801
|
+
result = client.check_lock(key="task", ttl=300)
|
|
802
|
+
|
|
803
|
+
# Async
|
|
804
|
+
async with OnceOnly(api_key="...") as client:
|
|
805
|
+
result = await client.check_lock_async(key="task", ttl=300)
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
---
|
|
809
|
+
|
|
810
|
+
## 🚨 Common Patterns & Best Practices
|
|
811
|
+
|
|
812
|
+
### ✅ DO
|
|
813
|
+
|
|
814
|
+
```python
|
|
815
|
+
# ✅ Use specific, deterministic keys
|
|
816
|
+
key = f"payment:{order_id}:{user_id}"
|
|
817
|
+
|
|
818
|
+
# ✅ Set appropriate TTLs
|
|
819
|
+
ttl = 3600 # 1 hour for webhooks
|
|
820
|
+
ttl = 86400 # 24 hours for daily jobs
|
|
821
|
+
|
|
822
|
+
# ✅ Add metadata for debugging
|
|
823
|
+
meta = {"user_id": 123, "amount": 9999, "source": "web"}
|
|
824
|
+
|
|
825
|
+
# ✅ Handle duplicates gracefully
|
|
826
|
+
if result.duplicate:
|
|
827
|
+
logger.info(f"Duplicate detected: {result.key}")
|
|
828
|
+
return cached_response
|
|
829
|
+
|
|
830
|
+
# ✅ Use decorators for simplicity
|
|
831
|
+
@idempotent_ai(client, key_fn=lambda x: f"task:{x}")
|
|
832
|
+
def my_task(x): ...
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
### ❌ DON'T
|
|
836
|
+
|
|
837
|
+
```python
|
|
838
|
+
# ❌ Don't use random/timestamp in keys
|
|
839
|
+
key = f"payment:{uuid.uuid4()}" # Every call is "unique"
|
|
840
|
+
key = f"task:{time.time()}" # Never deduplicates
|
|
841
|
+
|
|
842
|
+
# ❌ Don't set TTL too short
|
|
843
|
+
ttl = 1 # Retries will leak through
|
|
844
|
+
|
|
845
|
+
# ❌ Don't ignore duplicate status
|
|
846
|
+
result = client.check_lock(...)
|
|
847
|
+
process_payment() # Always runs!
|
|
848
|
+
|
|
849
|
+
# ❌ Don't catch and swallow errors silently
|
|
850
|
+
try:
|
|
851
|
+
client.check_lock(...)
|
|
852
|
+
except: pass # Lose safety guarantees
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
---
|
|
856
|
+
|
|
857
|
+
## 🐛 Troubleshooting
|
|
858
|
+
|
|
859
|
+
### "Unauthorized" (401/403)
|
|
860
|
+
|
|
861
|
+
**Cause:** Invalid API key
|
|
862
|
+
|
|
863
|
+
```python
|
|
864
|
+
# ❌ Wrong
|
|
865
|
+
client = OnceOnly(api_key="sk_test_...")
|
|
866
|
+
|
|
867
|
+
# ✅ Correct
|
|
868
|
+
client = OnceOnly(api_key="once_live_...")
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
### "Usage limit reached" (402)
|
|
872
|
+
|
|
873
|
+
**Cause:** Exceeded monthly quota for your plan
|
|
874
|
+
|
|
875
|
+
**Solution:** Upgrade at https://onceonly.tech/pricing
|
|
876
|
+
|
|
877
|
+
```python
|
|
878
|
+
# Check current usage
|
|
879
|
+
usage = client.usage(kind="make")
|
|
880
|
+
print(f"Used: {usage['usage']} / {usage['limit']}")
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
### "Rate limit exceeded" (429)
|
|
884
|
+
|
|
885
|
+
**Cause:** Too many requests per second
|
|
886
|
+
|
|
887
|
+
**Solution:** Enable auto-retry:
|
|
888
|
+
|
|
889
|
+
```python
|
|
890
|
+
client = OnceOnly(
|
|
891
|
+
api_key="...",
|
|
892
|
+
max_retries_429=3, # Auto-retry up to 3 times
|
|
893
|
+
retry_backoff=0.5, # Start with 0.5s delay
|
|
894
|
+
retry_max_backoff=10.0 # Cap at 10s
|
|
895
|
+
)
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
### Duplicates not being detected
|
|
899
|
+
|
|
900
|
+
**Cause:** Key is not deterministic
|
|
901
|
+
|
|
902
|
+
```python
|
|
903
|
+
# ❌ Wrong: random UUID
|
|
904
|
+
key = f"order:{uuid.uuid4()}"
|
|
905
|
+
|
|
906
|
+
# ✅ Correct: stable identifier
|
|
907
|
+
key = f"order:{order_id}"
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
### Agent blocked by policy
|
|
911
|
+
|
|
912
|
+
**Cause:** Policy restrictions
|
|
913
|
+
|
|
914
|
+
```python
|
|
915
|
+
# Check what happened
|
|
916
|
+
logs = client.gov.agent_logs("my-agent", limit=10)
|
|
917
|
+
for log in logs:
|
|
918
|
+
if log.decision == "blocked":
|
|
919
|
+
print(f"Blocked: {log.tool} - {log.policy_reason or log.reason}")
|
|
920
|
+
|
|
921
|
+
# Adjust policy
|
|
922
|
+
client.gov.upsert_policy({
|
|
923
|
+
"agent_id": "my-agent",
|
|
924
|
+
"allowed_tools": ["tool_a", "tool_b", "tool_c"], # Add tool_c
|
|
925
|
+
})
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
## 📊 Feature Availability
|
|
929
|
+
|
|
930
|
+
| Feature | Free | Starter | Pro | Agency |
|
|
931
|
+
|---------|------|---------|-----|--------|
|
|
932
|
+
| **Core Idempotency** |||||
|
|
933
|
+
| `check_lock()` | 1K/mo | 20K/mo | 200K/mo | 2M/mo |
|
|
934
|
+
| `ai.run_and_wait()` | 3K/mo | 100K/mo | 1M/mo | 10M/mo |
|
|
935
|
+
| **Agent Governance** |||||
|
|
936
|
+
| `gov.upsert_policy()` | ❌ | ❌ | ✅ Limited | ✅ Full |
|
|
937
|
+
| `gov.agent_logs()` | ❌ | ❌ | ✅ | ✅ |
|
|
938
|
+
| `gov.agent_metrics()` | ❌ | ❌ | ✅ | ✅ |
|
|
939
|
+
| `gov.disable_agent()` (Kill switch) | ❌ | ❌ | ❌ | ✅ |
|
|
940
|
+
| `gov.enable_agent()` | ❌ | ❌ | ❌ | ✅ |
|
|
941
|
+
| **Policy Features** |||||
|
|
942
|
+
| Budget limits (`max_spend_usd_per_day`) | ❌ | ❌ | ✅ | ✅ |
|
|
943
|
+
| Tool blocklist (`blocked_tools`) | ❌ | ❌ | ✅ | ✅ |
|
|
944
|
+
| Tool whitelist (`allowed_tools`) | ❌ | ❌ | ❌ | ✅ |
|
|
945
|
+
| Per-tool limits (`max_calls_per_tool`) | ❌ | ❌ | ✅ | ✅ |
|
|
946
|
+
|
|
947
|
+
> **Pro Plan**: Limited governance (no `allowed_tools` whitelist, no kill switch)
|
|
948
|
+
> **Agency Plan**: Full governance (whitelist, kill switch, anomaly detection)
|
|
949
|
+
|
|
950
|
+
## 📈 Plan Limits (Defaults)
|
|
951
|
+
|
|
952
|
+
These are the default limits enforced by the API (may be configured by the server):
|
|
953
|
+
|
|
954
|
+
| Plan | `check_lock` (make) | `ai` (runs) | Default TTL | Max TTL | Tools Registry Limit |
|
|
955
|
+
|------|---------------------|------------|-------------|---------|----------------------|
|
|
956
|
+
| Free | 1K / month | 3K / month | 60s | 1h | Not available |
|
|
957
|
+
| Starter | 20K / month | 100K / month | 1h | 24h | Not available |
|
|
958
|
+
| Pro | 200K / month | 1M / month | 6h | 7d | 10 tools |
|
|
959
|
+
| Agency | 2M / month | 10M / month | 24h | 30d | 500 tools |
|
|
960
|
+
|
|
961
|
+
**Pro vs Agency differences (important):**
|
|
962
|
+
- **Pro**: Governance is limited (no `allowed_tools` whitelist, no kill switch).
|
|
963
|
+
- **Agency**: Full governance, including tool whitelist + kill switch.
|
|
964
|
+
|
|
965
|
+
---
|
|
966
|
+
|
|
967
|
+
## 📊 Production Checklist
|
|
968
|
+
|
|
969
|
+
Before going live:
|
|
970
|
+
|
|
971
|
+
- [ ] **Use production API key** (`once_live_...`)
|
|
972
|
+
- [ ] **Set appropriate TTLs** (not too short, not too long)
|
|
973
|
+
- [ ] **Enable auto-retry** (`max_retries_429=3`)
|
|
974
|
+
- [ ] **Add metadata** for debugging (`meta={"user": ...}`)
|
|
975
|
+
- [ ] **Monitor usage** (check `client.usage()` regularly)
|
|
976
|
+
- [ ] **Set up governance** for AI agents
|
|
977
|
+
- [ ] **Test fail-open** behavior (simulate API downtime)
|
|
978
|
+
- [ ] **Review audit logs** periodically
|
|
979
|
+
- [ ] **Set up alerts** for blocked actions
|
|
980
|
+
|
|
981
|
+
---
|
|
982
|
+
|
|
983
|
+
## 🔗 Links
|
|
984
|
+
|
|
985
|
+
- **Website:** https://onceonly.tech
|
|
986
|
+
- **Documentation:** https://docs.onceonly.tech
|
|
987
|
+
- **API Reference:** https://docs.onceonly.tech/reference/idempotency/
|
|
988
|
+
- **Python SDK Docs:** https://docs.onceonly.tech/sdk/python
|
|
989
|
+
- **GitHub:** https://github.com/onceonly-tech/onceonly-python
|
|
990
|
+
- **PyPI:** https://pypi.org/project/onceonly-sdk/
|
|
991
|
+
- **Support:** support@onceonly.tech
|
|
992
|
+
|
|
993
|
+
---
|
|
994
|
+
|
|
995
|
+
## 📄 License
|
|
996
|
+
|
|
997
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
998
|
+
|
|
999
|
+
---
|
|
1000
|
+
|
|
1001
|
+
## 🤝 Contributing
|
|
1002
|
+
|
|
1003
|
+
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
1004
|
+
|
|
1005
|
+
## ✅ Tests
|
|
1006
|
+
|
|
1007
|
+
```bash
|
|
1008
|
+
pytest -q
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
Integration smoke tests (live API):
|
|
1012
|
+
|
|
1013
|
+
```bash
|
|
1014
|
+
export TEST_API_KEY="once_live_..."
|
|
1015
|
+
export TEST_BASE_URL="https://api.onceonly.tech"
|
|
1016
|
+
pytest -q -m integration
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
---
|
|
1020
|
+
|
|
1021
|
+
## ⭐ Support
|
|
1022
|
+
|
|
1023
|
+
If OnceOnly helps your project, give us a star on [GitHub](https://github.com/onceonly-tech/onceonly-python)!
|
|
1024
|
+
|
|
1025
|
+
Questions? Open an issue or email support@onceonly.tech
|
|
1026
|
+
|
|
1027
|
+
---
|
|
1028
|
+
|
|
1029
|
+
<div align="center">
|
|
1030
|
+
<sub>Built with ❤️ by the OnceOnly team</sub>
|
|
1031
|
+
</div>
|