ordainer 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ordainer-0.1.0/CHANGELOG.md +8 -0
- ordainer-0.1.0/LICENSE +21 -0
- ordainer-0.1.0/MANIFEST.in +10 -0
- ordainer-0.1.0/PKG-INFO +178 -0
- ordainer-0.1.0/README.md +150 -0
- ordainer-0.1.0/pyproject.toml +44 -0
- ordainer-0.1.0/setup.cfg +4 -0
- ordainer-0.1.0/src/ordainer/__init__.py +170 -0
- ordainer-0.1.0/src/ordainer/async_client.py +243 -0
- ordainer-0.1.0/src/ordainer/client.py +207 -0
- ordainer-0.1.0/src/ordainer/decision.py +108 -0
- ordainer-0.1.0/src/ordainer/deny.py +301 -0
- ordainer-0.1.0/src/ordainer/engine.py +630 -0
- ordainer-0.1.0/src/ordainer/errors.py +51 -0
- ordainer-0.1.0/src/ordainer/py.typed +0 -0
- ordainer-0.1.0/src/ordainer/transport.py +107 -0
- ordainer-0.1.0/src/ordainer/version.py +2 -0
- ordainer-0.1.0/src/ordainer.egg-info/SOURCES.txt +15 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
- First version: `Client.decide()`, `Decision`, typed errors, retries, `fail_mode`, sticky deny, and optional `AsyncClient` (`pip install ordainer[async]`).
|
|
6
|
+
- Caller-facing block messages and the shadow log line match the Node SDK and the Ruby gem word for word. Match on `error.code`, not the message.
|
|
7
|
+
- Idempotency keys are measured in UTF-16 code units. A key with an unpaired surrogate is hashed from its UTF-16LE bytes, the same as the Node SDK. `decide` maps a key the HTTP client cannot send (a newline, a carriage return, or a character outside Latin-1) to `network_error`, and `fail_mode` applies. That failure is not retried, and the key is not copied into the error message or the warning.
|
|
8
|
+
- A host lookup failure reported by httpx as `ConnectError` still gets the "host not found; check ORDAINER_BASE_URL" hint when its cause is a `socket.gaierror`.
|
ordainer-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ordainer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ordainer-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ordainer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ask Ordainer before your tool runs: one decide() call checks the caller's plan and counts the use.
|
|
5
|
+
Author: Ordainer
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://docs.ordainer.dev
|
|
8
|
+
Project-URL: Changelog, https://github.com/Tyler-CEO/ordainer/blob/main/packages/ordainer-python/CHANGELOG.md
|
|
9
|
+
Keywords: ordainer,ai-agents,plan-limits,rate-limit,usage,quota
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Provides-Extra: async
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == "async"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
26
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# ordainer (Python)
|
|
30
|
+
|
|
31
|
+
Ask Ordainer before your tool runs: one `decide` call checks the caller's plan and counts the use.
|
|
32
|
+
Python 3.9+, standard library only (`http.client`, `json`), no required dependencies. Optional async client
|
|
33
|
+
via `pip install ordainer[async]` (httpx). Mirrors the Ruby gem (`packages/ordainer-ruby`, 0.1.1) and the
|
|
34
|
+
Node SDK (`packages/ordainer-node`).
|
|
35
|
+
|
|
36
|
+
Install from [PyPI](https://pypi.org/project/ordainer/):
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install ordainer
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Async client:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install "ordainer[async]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import os
|
|
50
|
+
from ordainer import Client, deny_response
|
|
51
|
+
|
|
52
|
+
client = Client(api_key=os.environ["ORDAINER_API_KEY"]) # ord_test_... or ord_live_...
|
|
53
|
+
decision = client.decide(tool="research.run")
|
|
54
|
+
|
|
55
|
+
if decision.allowed:
|
|
56
|
+
print(decision.decision, decision.remaining)
|
|
57
|
+
else:
|
|
58
|
+
blocked = deny_response(decision) # {"status", "headers", "body": {"error": {"code", "message", ...}}}
|
|
59
|
+
print(blocked["status"], blocked["body"]["error"]["code"])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`decide_or_raise` is the Python name for the Ruby client's `decide!`. It returns the `Decision` when the call
|
|
63
|
+
may run, and raises `DeniedError`, `RejectedError`, `RateLimitedError` or `UnavailableError` otherwise
|
|
64
|
+
(all `BlockedError`, with `.status`, `.headers`, `.body`, `.decision`).
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import os
|
|
68
|
+
from ordainer import BlockedError, Client
|
|
69
|
+
|
|
70
|
+
client = Client(api_key=os.environ["ORDAINER_API_KEY"])
|
|
71
|
+
try:
|
|
72
|
+
decision = client.decide_or_raise(tool="research.run")
|
|
73
|
+
print(decision.decision)
|
|
74
|
+
except BlockedError as error:
|
|
75
|
+
blocked = {"status": error.status, "headers": error.headers, "body": error.body}
|
|
76
|
+
print(blocked["status"], blocked["body"]["error"]["message"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
There is no Django, Flask or FastAPI middleware in 0.1.0.
|
|
80
|
+
|
|
81
|
+
## Options
|
|
82
|
+
|
|
83
|
+
| Option | Default | Meaning |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `api_key` | `ORDAINER_API_KEY`, then `ORDAINER_KEY` | Agent key. Override per call: `decide(tool=..., api_key=...)`. |
|
|
86
|
+
| `base_url` | `ORDAINER_BASE_URL` or `https://api.ordainer.dev` | |
|
|
87
|
+
| `timeout` | `0.3` | Seconds per attempt. |
|
|
88
|
+
| `fail_mode` | `"open"` | `"open"` lets the call run when Ordainer can't answer; `"closed"` blocks it. |
|
|
89
|
+
| `retries` | `1` | Extra attempts (0 to 5) after a timeout, network error or 503, with jittered backoff, same idempotency key. |
|
|
90
|
+
| `retry_budget` | `0.65` | Seconds for all attempts of one `decide`. |
|
|
91
|
+
| `mode` | `None` | `None` follows the plan's mode in the console; `"enforce"` or `"shadow"`. |
|
|
92
|
+
| `billing_lapse` | `"open"` | What to do on 403 `subscription_inactive`. |
|
|
93
|
+
| `deny_status` | `429` | Status for quota denies: 429 or 402. |
|
|
94
|
+
| `sticky_deny` | `60` | Seconds to keep denying would-be fail-opens for an (agent key, tool) after a quota deny. 0 = off. |
|
|
95
|
+
| `logger` | warnings to stderr | An object with `warning` (or `warn`) and optional `error`, or `False`. |
|
|
96
|
+
| `on_decision` | `None` | Called with every `Decision`. |
|
|
97
|
+
|
|
98
|
+
Durations are seconds, the same lengths as the Node SDK's millisecond settings (`timeoutMs` 300, `retryBudgetMs` 650, `stickyDenyMs` 60000).
|
|
99
|
+
|
|
100
|
+
## Rules (same as the Node SDK and the Ruby gem)
|
|
101
|
+
|
|
102
|
+
- `decide` never raises for an Ordainer answer or an outage. A deny is a normal result. Bad options raise
|
|
103
|
+
`ConfigurationError`.
|
|
104
|
+
- Only a deny from Ordainer blocks in enforce mode. Shadow never blocks (`would_deny`).
|
|
105
|
+
- `fail_mode` covers only "Ordainer couldn't answer": timeout, network error, 5xx, or a 200 that isn't a decision.
|
|
106
|
+
- A 4xx (bad or missing key, 400, 409, 429 rate limit, ...) is Ordainer answering "no": blocked in any
|
|
107
|
+
`fail_mode` (`rejected`). 403 `subscription_inactive` follows `billing_lapse` instead. 429
|
|
108
|
+
`safety_limit_reached` lets the call run (`safety_limited`).
|
|
109
|
+
- Only timeouts, network errors and 503 are retried, never other 5xx or 4xx. A retry sends the same idempotency key, so it is replayed, never counted twice. Length is counted in UTF-16 code units, the same way as the Node SDK and the server. A key over 255 units, or a key that contains a control character or an unpaired surrogate, is hashed. An unpaired surrogate is hashed from its UTF-16LE bytes. Anything else is hashed as UTF-8.
|
|
110
|
+
- What the caller reads (`deny_response`, same text as the Node SDK and the Ruby gem): a block a retry can't fix answers
|
|
111
|
+
`500 plan_check_rejected` with plain words for the cause and the API's code last, e.g. "This call wasn't run. This is a
|
|
112
|
+
setup issue on the service's side: one of its keys isn't recognized. It isn't something you did, and retrying won't help
|
|
113
|
+
until the service fixes the key. (For the service's team: unknown_key.)" A `429 rate_limited` answers "The service is
|
|
114
|
+
getting too many requests on this key right now, so this call was held back. Please wait a moment and try again.
|
|
115
|
+
(rate_limited)" with `Retry-After`; a `429 concurrency_limited` answers "The service is handling too many calls on this key at once, so this call was held back. Please wait a moment and try again. (concurrency_limited)" (the `code` field
|
|
116
|
+
stays `rate_limited`). Billing blocks (403) keep the Legal-approved text.
|
|
117
|
+
- The same text as the Node SDK for the other blocks too (PM final wording, Oct 18). Error messages can be reworded;
|
|
118
|
+
codes, statuses and fields stay the same, so match on `code`, not the message. An outage under `fail_mode="closed"`
|
|
119
|
+
answers `503 ordainer_unavailable` with `Retry-After: 5`: "This call wasn't run. The service couldn't finish a safety check it does before each call, so it stopped instead of going ahead without it. Nothing is wrong on your side. Please try again in a minute. (ordainer_unavailable)".
|
|
120
|
+
A used-up test allowance answers `429 test_limit_reached`: "This call wasn't run. This service's test calls for the
|
|
121
|
+
month are used up. They reset on November 1 (UTC). Nothing is wrong on your side, and trying again won't help until
|
|
122
|
+
they reset. If you need it sooner, contact the service's team. (test_limit_reached)" (the day is `resets_at` in UTC as
|
|
123
|
+
"<Month D>"; no "They reset on" sentence unless `resets_at` is a valid ISO-8601 date-time). A `tool_not_in_plan` deny
|
|
124
|
+
answers `403 plan_denied`: "This call wasn't run because your plan doesn't include "research.run". If you need it, ask
|
|
125
|
+
whoever manages your plan. (tool_not_in_plan)", naming the tool you passed to `decide` in quotes
|
|
126
|
+
only when it matches `^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$` and has no `//` and no leading `scheme:` (`^[A-Za-z][A-Za-z0-9+.-]*:`, so nothing URL-like is echoed), otherwise "this action". Any other deny answers "This
|
|
127
|
+
call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team.
|
|
128
|
+
(reason)", with the reason in brackets only when it looks like an API code (lowercase letters and `_`, up to 40
|
|
129
|
+
characters); the reason is also its own `reason` field.
|
|
130
|
+
- A shadow deny logs exactly: `[ordainer] shadow: would deny tool=research.run reason=quota_exceeded remaining=0/5 request_id=req_s (call allowed)` (`remaining` is `no cap` when `limit` is null). The agent key is never logged.
|
|
131
|
+
- After a `quota_exceeded` deny, a later call for the same agent key and tool that would fail open is denied instead (`sticky_deny`) until `sticky_deny` seconds pass, the plan resets, or an allow arrives. Shadow still runs the call.
|
|
132
|
+
|
|
133
|
+
## Decision
|
|
134
|
+
|
|
135
|
+
`allowed`, `decision` (`"allow"` / `"deny"` / `None`), `reason`, `remaining`, `limit`, `resets_at`,
|
|
136
|
+
`request_id`, `mode`, `would_deny`, `fail_open`, `fail_closed`, `rejected`, `rate_limited`,
|
|
137
|
+
`retry_after_sec`, `billing_inactive`, `billing_lapse`, `test_limit_reached`, `safety_limited`, `sticky_deny`, `replayed`,
|
|
138
|
+
`attempts`, `latency_ms`, `error` (`code`, `message`, `status`, `limit_scope`, `request_id`), `to_dict()`.
|
|
139
|
+
|
|
140
|
+
## Async
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import asyncio
|
|
144
|
+
import os
|
|
145
|
+
from ordainer import AsyncClient
|
|
146
|
+
|
|
147
|
+
async def main():
|
|
148
|
+
async with AsyncClient(api_key=os.environ["ORDAINER_API_KEY"]) as client:
|
|
149
|
+
decision = await client.decide(tool="research.run")
|
|
150
|
+
print(decision.allowed, decision.decision)
|
|
151
|
+
|
|
152
|
+
asyncio.run(main())
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Same options, same rules, same messages.
|
|
156
|
+
|
|
157
|
+
## Shared client
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import os
|
|
161
|
+
import ordainer
|
|
162
|
+
|
|
163
|
+
def configure(cfg):
|
|
164
|
+
cfg.api_key = os.environ["ORDAINER_API_KEY"]
|
|
165
|
+
cfg.fail_mode = "open"
|
|
166
|
+
|
|
167
|
+
ordainer.configure(configure)
|
|
168
|
+
decision = ordainer.client().decide(tool="research.run")
|
|
169
|
+
print(decision.allowed, decision.decision)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Tests
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
cd packages/ordainer-python && python -m pip install -e ".[async,dev]" && python -m pytest
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Set `ORDAINER_LIVE_TEST_KEY` (a test key) to also run one live call against `ORDAINER_BASE_URL`.
|
ordainer-0.1.0/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# ordainer (Python)
|
|
2
|
+
|
|
3
|
+
Ask Ordainer before your tool runs: one `decide` call checks the caller's plan and counts the use.
|
|
4
|
+
Python 3.9+, standard library only (`http.client`, `json`), no required dependencies. Optional async client
|
|
5
|
+
via `pip install ordainer[async]` (httpx). Mirrors the Ruby gem (`packages/ordainer-ruby`, 0.1.1) and the
|
|
6
|
+
Node SDK (`packages/ordainer-node`).
|
|
7
|
+
|
|
8
|
+
Install from [PyPI](https://pypi.org/project/ordainer/):
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install ordainer
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Async client:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install "ordainer[async]"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from ordainer import Client, deny_response
|
|
23
|
+
|
|
24
|
+
client = Client(api_key=os.environ["ORDAINER_API_KEY"]) # ord_test_... or ord_live_...
|
|
25
|
+
decision = client.decide(tool="research.run")
|
|
26
|
+
|
|
27
|
+
if decision.allowed:
|
|
28
|
+
print(decision.decision, decision.remaining)
|
|
29
|
+
else:
|
|
30
|
+
blocked = deny_response(decision) # {"status", "headers", "body": {"error": {"code", "message", ...}}}
|
|
31
|
+
print(blocked["status"], blocked["body"]["error"]["code"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`decide_or_raise` is the Python name for the Ruby client's `decide!`. It returns the `Decision` when the call
|
|
35
|
+
may run, and raises `DeniedError`, `RejectedError`, `RateLimitedError` or `UnavailableError` otherwise
|
|
36
|
+
(all `BlockedError`, with `.status`, `.headers`, `.body`, `.decision`).
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
from ordainer import BlockedError, Client
|
|
41
|
+
|
|
42
|
+
client = Client(api_key=os.environ["ORDAINER_API_KEY"])
|
|
43
|
+
try:
|
|
44
|
+
decision = client.decide_or_raise(tool="research.run")
|
|
45
|
+
print(decision.decision)
|
|
46
|
+
except BlockedError as error:
|
|
47
|
+
blocked = {"status": error.status, "headers": error.headers, "body": error.body}
|
|
48
|
+
print(blocked["status"], blocked["body"]["error"]["message"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
There is no Django, Flask or FastAPI middleware in 0.1.0.
|
|
52
|
+
|
|
53
|
+
## Options
|
|
54
|
+
|
|
55
|
+
| Option | Default | Meaning |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `api_key` | `ORDAINER_API_KEY`, then `ORDAINER_KEY` | Agent key. Override per call: `decide(tool=..., api_key=...)`. |
|
|
58
|
+
| `base_url` | `ORDAINER_BASE_URL` or `https://api.ordainer.dev` | |
|
|
59
|
+
| `timeout` | `0.3` | Seconds per attempt. |
|
|
60
|
+
| `fail_mode` | `"open"` | `"open"` lets the call run when Ordainer can't answer; `"closed"` blocks it. |
|
|
61
|
+
| `retries` | `1` | Extra attempts (0 to 5) after a timeout, network error or 503, with jittered backoff, same idempotency key. |
|
|
62
|
+
| `retry_budget` | `0.65` | Seconds for all attempts of one `decide`. |
|
|
63
|
+
| `mode` | `None` | `None` follows the plan's mode in the console; `"enforce"` or `"shadow"`. |
|
|
64
|
+
| `billing_lapse` | `"open"` | What to do on 403 `subscription_inactive`. |
|
|
65
|
+
| `deny_status` | `429` | Status for quota denies: 429 or 402. |
|
|
66
|
+
| `sticky_deny` | `60` | Seconds to keep denying would-be fail-opens for an (agent key, tool) after a quota deny. 0 = off. |
|
|
67
|
+
| `logger` | warnings to stderr | An object with `warning` (or `warn`) and optional `error`, or `False`. |
|
|
68
|
+
| `on_decision` | `None` | Called with every `Decision`. |
|
|
69
|
+
|
|
70
|
+
Durations are seconds, the same lengths as the Node SDK's millisecond settings (`timeoutMs` 300, `retryBudgetMs` 650, `stickyDenyMs` 60000).
|
|
71
|
+
|
|
72
|
+
## Rules (same as the Node SDK and the Ruby gem)
|
|
73
|
+
|
|
74
|
+
- `decide` never raises for an Ordainer answer or an outage. A deny is a normal result. Bad options raise
|
|
75
|
+
`ConfigurationError`.
|
|
76
|
+
- Only a deny from Ordainer blocks in enforce mode. Shadow never blocks (`would_deny`).
|
|
77
|
+
- `fail_mode` covers only "Ordainer couldn't answer": timeout, network error, 5xx, or a 200 that isn't a decision.
|
|
78
|
+
- A 4xx (bad or missing key, 400, 409, 429 rate limit, ...) is Ordainer answering "no": blocked in any
|
|
79
|
+
`fail_mode` (`rejected`). 403 `subscription_inactive` follows `billing_lapse` instead. 429
|
|
80
|
+
`safety_limit_reached` lets the call run (`safety_limited`).
|
|
81
|
+
- Only timeouts, network errors and 503 are retried, never other 5xx or 4xx. A retry sends the same idempotency key, so it is replayed, never counted twice. Length is counted in UTF-16 code units, the same way as the Node SDK and the server. A key over 255 units, or a key that contains a control character or an unpaired surrogate, is hashed. An unpaired surrogate is hashed from its UTF-16LE bytes. Anything else is hashed as UTF-8.
|
|
82
|
+
- What the caller reads (`deny_response`, same text as the Node SDK and the Ruby gem): a block a retry can't fix answers
|
|
83
|
+
`500 plan_check_rejected` with plain words for the cause and the API's code last, e.g. "This call wasn't run. This is a
|
|
84
|
+
setup issue on the service's side: one of its keys isn't recognized. It isn't something you did, and retrying won't help
|
|
85
|
+
until the service fixes the key. (For the service's team: unknown_key.)" A `429 rate_limited` answers "The service is
|
|
86
|
+
getting too many requests on this key right now, so this call was held back. Please wait a moment and try again.
|
|
87
|
+
(rate_limited)" with `Retry-After`; a `429 concurrency_limited` answers "The service is handling too many calls on this key at once, so this call was held back. Please wait a moment and try again. (concurrency_limited)" (the `code` field
|
|
88
|
+
stays `rate_limited`). Billing blocks (403) keep the Legal-approved text.
|
|
89
|
+
- The same text as the Node SDK for the other blocks too (PM final wording, Oct 18). Error messages can be reworded;
|
|
90
|
+
codes, statuses and fields stay the same, so match on `code`, not the message. An outage under `fail_mode="closed"`
|
|
91
|
+
answers `503 ordainer_unavailable` with `Retry-After: 5`: "This call wasn't run. The service couldn't finish a safety check it does before each call, so it stopped instead of going ahead without it. Nothing is wrong on your side. Please try again in a minute. (ordainer_unavailable)".
|
|
92
|
+
A used-up test allowance answers `429 test_limit_reached`: "This call wasn't run. This service's test calls for the
|
|
93
|
+
month are used up. They reset on November 1 (UTC). Nothing is wrong on your side, and trying again won't help until
|
|
94
|
+
they reset. If you need it sooner, contact the service's team. (test_limit_reached)" (the day is `resets_at` in UTC as
|
|
95
|
+
"<Month D>"; no "They reset on" sentence unless `resets_at` is a valid ISO-8601 date-time). A `tool_not_in_plan` deny
|
|
96
|
+
answers `403 plan_denied`: "This call wasn't run because your plan doesn't include "research.run". If you need it, ask
|
|
97
|
+
whoever manages your plan. (tool_not_in_plan)", naming the tool you passed to `decide` in quotes
|
|
98
|
+
only when it matches `^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$` and has no `//` and no leading `scheme:` (`^[A-Za-z][A-Za-z0-9+.-]*:`, so nothing URL-like is echoed), otherwise "this action". Any other deny answers "This
|
|
99
|
+
call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team.
|
|
100
|
+
(reason)", with the reason in brackets only when it looks like an API code (lowercase letters and `_`, up to 40
|
|
101
|
+
characters); the reason is also its own `reason` field.
|
|
102
|
+
- A shadow deny logs exactly: `[ordainer] shadow: would deny tool=research.run reason=quota_exceeded remaining=0/5 request_id=req_s (call allowed)` (`remaining` is `no cap` when `limit` is null). The agent key is never logged.
|
|
103
|
+
- After a `quota_exceeded` deny, a later call for the same agent key and tool that would fail open is denied instead (`sticky_deny`) until `sticky_deny` seconds pass, the plan resets, or an allow arrives. Shadow still runs the call.
|
|
104
|
+
|
|
105
|
+
## Decision
|
|
106
|
+
|
|
107
|
+
`allowed`, `decision` (`"allow"` / `"deny"` / `None`), `reason`, `remaining`, `limit`, `resets_at`,
|
|
108
|
+
`request_id`, `mode`, `would_deny`, `fail_open`, `fail_closed`, `rejected`, `rate_limited`,
|
|
109
|
+
`retry_after_sec`, `billing_inactive`, `billing_lapse`, `test_limit_reached`, `safety_limited`, `sticky_deny`, `replayed`,
|
|
110
|
+
`attempts`, `latency_ms`, `error` (`code`, `message`, `status`, `limit_scope`, `request_id`), `to_dict()`.
|
|
111
|
+
|
|
112
|
+
## Async
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
import asyncio
|
|
116
|
+
import os
|
|
117
|
+
from ordainer import AsyncClient
|
|
118
|
+
|
|
119
|
+
async def main():
|
|
120
|
+
async with AsyncClient(api_key=os.environ["ORDAINER_API_KEY"]) as client:
|
|
121
|
+
decision = await client.decide(tool="research.run")
|
|
122
|
+
print(decision.allowed, decision.decision)
|
|
123
|
+
|
|
124
|
+
asyncio.run(main())
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Same options, same rules, same messages.
|
|
128
|
+
|
|
129
|
+
## Shared client
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import os
|
|
133
|
+
import ordainer
|
|
134
|
+
|
|
135
|
+
def configure(cfg):
|
|
136
|
+
cfg.api_key = os.environ["ORDAINER_API_KEY"]
|
|
137
|
+
cfg.fail_mode = "open"
|
|
138
|
+
|
|
139
|
+
ordainer.configure(configure)
|
|
140
|
+
decision = ordainer.client().decide(tool="research.run")
|
|
141
|
+
print(decision.allowed, decision.decision)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Tests
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
cd packages/ordainer-python && python -m pip install -e ".[async,dev]" && python -m pytest
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Set `ORDAINER_LIVE_TEST_KEY` (a test key) to also run one live call against `ORDAINER_BASE_URL`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ordainer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Ask Ordainer before your tool runs: one decide() call checks the caller's plan and counts the use."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Ordainer" }]
|
|
14
|
+
keywords = ["ordainer", "ai-agents", "plan-limits", "rate-limit", "usage", "quota"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
async = ["httpx>=0.27"]
|
|
30
|
+
dev = ["pytest>=7", "httpx>=0.27"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://docs.ordainer.dev"
|
|
34
|
+
Changelog = "https://github.com/Tyler-CEO/ordainer/blob/main/packages/ordainer-python/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
ordainer = ["py.typed"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
pythonpath = ["src", "tests"]
|
ordainer-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Ask Ordainer before your tool runs.
|
|
2
|
+
|
|
3
|
+
One decide() call checks the caller's plan and counts the use. Python 3.9+,
|
|
4
|
+
standard library only. Mirrors the Ruby gem (packages/ordainer-ruby) and the
|
|
5
|
+
Node SDK (packages/ordainer-node).
|
|
6
|
+
|
|
7
|
+
from ordainer import Client, deny_response
|
|
8
|
+
|
|
9
|
+
client = Client(api_key=os.environ["ORDAINER_API_KEY"])
|
|
10
|
+
decision = client.decide(tool="research.run")
|
|
11
|
+
if not decision.allowed:
|
|
12
|
+
blocked = deny_response(decision)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
from .client import Client
|
|
22
|
+
from .decision import Decision, ErrorInfo
|
|
23
|
+
from .deny import (
|
|
24
|
+
BILLING_BLOCKED_MESSAGE,
|
|
25
|
+
CONCURRENCY_LIMITED_MESSAGE,
|
|
26
|
+
RATE_LIMITED_MESSAGE,
|
|
27
|
+
UNAVAILABLE_MESSAGE,
|
|
28
|
+
deny_response,
|
|
29
|
+
plan_denied_message,
|
|
30
|
+
rejected_message,
|
|
31
|
+
reset_day_utc,
|
|
32
|
+
test_limit_message,
|
|
33
|
+
)
|
|
34
|
+
from .engine import idempotency_key_for
|
|
35
|
+
from .errors import (
|
|
36
|
+
BlockedError,
|
|
37
|
+
ConfigurationError,
|
|
38
|
+
DeniedError,
|
|
39
|
+
Error,
|
|
40
|
+
NetworkError,
|
|
41
|
+
RateLimitedError,
|
|
42
|
+
RejectedError,
|
|
43
|
+
TimeoutError,
|
|
44
|
+
UnavailableError,
|
|
45
|
+
)
|
|
46
|
+
from .version import __version__
|
|
47
|
+
|
|
48
|
+
VERSION = __version__
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"VERSION",
|
|
52
|
+
"AsyncClient",
|
|
53
|
+
"BILLING_BLOCKED_MESSAGE",
|
|
54
|
+
"BlockedError",
|
|
55
|
+
"CONCURRENCY_LIMITED_MESSAGE",
|
|
56
|
+
"Client",
|
|
57
|
+
"Configuration",
|
|
58
|
+
"ConfigurationError",
|
|
59
|
+
"Decision",
|
|
60
|
+
"DeniedError",
|
|
61
|
+
"Error",
|
|
62
|
+
"ErrorInfo",
|
|
63
|
+
"NetworkError",
|
|
64
|
+
"RATE_LIMITED_MESSAGE",
|
|
65
|
+
"RateLimitedError",
|
|
66
|
+
"RejectedError",
|
|
67
|
+
"TimeoutError",
|
|
68
|
+
"UNAVAILABLE_MESSAGE",
|
|
69
|
+
"UnavailableError",
|
|
70
|
+
"__version__",
|
|
71
|
+
"client",
|
|
72
|
+
"configure",
|
|
73
|
+
"configuration",
|
|
74
|
+
"deny_response",
|
|
75
|
+
"idempotency_key_for",
|
|
76
|
+
"plan_denied_message",
|
|
77
|
+
"rejected_message",
|
|
78
|
+
"reset",
|
|
79
|
+
"reset_day_utc",
|
|
80
|
+
"test_limit_message",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Configuration:
|
|
85
|
+
"""Global settings for ordainer.client(), same fields as Client."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self.api_key: Optional[str] = os.environ.get("ORDAINER_API_KEY") or os.environ.get("ORDAINER_KEY")
|
|
89
|
+
self.base_url: Optional[str] = None
|
|
90
|
+
self.timeout: Any = None
|
|
91
|
+
self.fail_mode: Any = None
|
|
92
|
+
self.retries: Any = None
|
|
93
|
+
self.retry_budget: Any = None
|
|
94
|
+
self.mode: Any = None
|
|
95
|
+
self.billing_lapse: Any = None
|
|
96
|
+
self.deny_status: Any = None
|
|
97
|
+
self.sticky_deny: Any = None
|
|
98
|
+
self.logger: Any = None
|
|
99
|
+
self.on_decision: Optional[Callable[..., Any]] = None
|
|
100
|
+
|
|
101
|
+
def to_client_options(self) -> dict[str, Any]:
|
|
102
|
+
options = {
|
|
103
|
+
"api_key": self.api_key,
|
|
104
|
+
"base_url": self.base_url,
|
|
105
|
+
"timeout": self.timeout,
|
|
106
|
+
"fail_mode": self.fail_mode,
|
|
107
|
+
"retries": self.retries,
|
|
108
|
+
"retry_budget": self.retry_budget,
|
|
109
|
+
"mode": self.mode,
|
|
110
|
+
"billing_lapse": self.billing_lapse,
|
|
111
|
+
"deny_status": self.deny_status,
|
|
112
|
+
"sticky_deny": self.sticky_deny,
|
|
113
|
+
"logger": self.logger,
|
|
114
|
+
"on_decision": self.on_decision,
|
|
115
|
+
}
|
|
116
|
+
return {key: value for key, value in options.items() if value is not None}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_configuration: Optional[Configuration] = None
|
|
120
|
+
_client: Optional[Client] = None
|
|
121
|
+
_lock = threading.RLock()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def configuration() -> Configuration:
|
|
125
|
+
global _configuration
|
|
126
|
+
with _lock:
|
|
127
|
+
if _configuration is None:
|
|
128
|
+
_configuration = Configuration()
|
|
129
|
+
return _configuration
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def configure(callback: Callable[[Configuration], Any]) -> Configuration:
|
|
133
|
+
"""Apply callback to the global configuration and drop the cached client."""
|
|
134
|
+
global _client
|
|
135
|
+
cfg = configuration()
|
|
136
|
+
callback(cfg)
|
|
137
|
+
with _lock:
|
|
138
|
+
old = _client
|
|
139
|
+
_client = None
|
|
140
|
+
if old is not None:
|
|
141
|
+
old.close()
|
|
142
|
+
return cfg
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def client() -> Client:
|
|
146
|
+
"""A shared client built from the configuration."""
|
|
147
|
+
global _client
|
|
148
|
+
with _lock:
|
|
149
|
+
if _client is None:
|
|
150
|
+
_client = Client(**configuration().to_client_options())
|
|
151
|
+
return _client
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reset() -> None:
|
|
155
|
+
"""Drop the global configuration and the shared client."""
|
|
156
|
+
global _configuration, _client
|
|
157
|
+
with _lock:
|
|
158
|
+
old = _client
|
|
159
|
+
_configuration = None
|
|
160
|
+
_client = None
|
|
161
|
+
if old is not None:
|
|
162
|
+
old.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def __getattr__(name: str) -> Any:
|
|
166
|
+
if name == "AsyncClient":
|
|
167
|
+
from .async_client import AsyncClient
|
|
168
|
+
|
|
169
|
+
return AsyncClient
|
|
170
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|