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.
@@ -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.
@@ -0,0 +1,10 @@
1
+ include README.md
2
+ include CHANGELOG.md
3
+ include LICENSE
4
+ include pyproject.toml
5
+ recursive-include src *.py
6
+ recursive-include src py.typed
7
+ prune tests
8
+ prune src/*.egg-info
9
+ global-exclude __pycache__
10
+ global-exclude *.py[cod]
@@ -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`.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}")