Metadata-Version: 2.4
Name: ordainer
Version: 0.1.0
Summary: Ask Ordainer before your tool runs: one decide() call checks the caller's plan and counts the use.
Author: Ordainer
License-Expression: MIT
Project-URL: Homepage, https://docs.ordainer.dev
Project-URL: Changelog, https://github.com/Tyler-CEO/ordainer/blob/main/packages/ordainer-python/CHANGELOG.md
Keywords: ordainer,ai-agents,plan-limits,rate-limit,usage,quota
Classifier: Development Status :: 4 - Beta
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: async
Requires-Dist: httpx>=0.27; extra == "async"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Dynamic: license-file

# ordainer (Python)

Ask Ordainer before your tool runs: one `decide` call checks the caller's plan and counts the use.
Python 3.9+, standard library only (`http.client`, `json`), no required dependencies. Optional async client
via `pip install ordainer[async]` (httpx). Mirrors the Ruby gem (`packages/ordainer-ruby`, 0.1.1) and the
Node SDK (`packages/ordainer-node`).

Install from [PyPI](https://pypi.org/project/ordainer/):

```sh
pip install ordainer
```

Async client:

```sh
pip install "ordainer[async]"
```

```python
import os
from ordainer import Client, deny_response

client = Client(api_key=os.environ["ORDAINER_API_KEY"])  # ord_test_... or ord_live_...
decision = client.decide(tool="research.run")

if decision.allowed:
    print(decision.decision, decision.remaining)
else:
    blocked = deny_response(decision)  # {"status", "headers", "body": {"error": {"code", "message", ...}}}
    print(blocked["status"], blocked["body"]["error"]["code"])
```

`decide_or_raise` is the Python name for the Ruby client's `decide!`. It returns the `Decision` when the call
may run, and raises `DeniedError`, `RejectedError`, `RateLimitedError` or `UnavailableError` otherwise
(all `BlockedError`, with `.status`, `.headers`, `.body`, `.decision`).

```python
import os
from ordainer import BlockedError, Client

client = Client(api_key=os.environ["ORDAINER_API_KEY"])
try:
    decision = client.decide_or_raise(tool="research.run")
    print(decision.decision)
except BlockedError as error:
    blocked = {"status": error.status, "headers": error.headers, "body": error.body}
    print(blocked["status"], blocked["body"]["error"]["message"])
```

There is no Django, Flask or FastAPI middleware in 0.1.0.

## Options

| Option | Default | Meaning |
| --- | --- | --- |
| `api_key` | `ORDAINER_API_KEY`, then `ORDAINER_KEY` | Agent key. Override per call: `decide(tool=..., api_key=...)`. |
| `base_url` | `ORDAINER_BASE_URL` or `https://api.ordainer.dev` | |
| `timeout` | `0.3` | Seconds per attempt. |
| `fail_mode` | `"open"` | `"open"` lets the call run when Ordainer can't answer; `"closed"` blocks it. |
| `retries` | `1` | Extra attempts (0 to 5) after a timeout, network error or 503, with jittered backoff, same idempotency key. |
| `retry_budget` | `0.65` | Seconds for all attempts of one `decide`. |
| `mode` | `None` | `None` follows the plan's mode in the console; `"enforce"` or `"shadow"`. |
| `billing_lapse` | `"open"` | What to do on 403 `subscription_inactive`. |
| `deny_status` | `429` | Status for quota denies: 429 or 402. |
| `sticky_deny` | `60` | Seconds to keep denying would-be fail-opens for an (agent key, tool) after a quota deny. 0 = off. |
| `logger` | warnings to stderr | An object with `warning` (or `warn`) and optional `error`, or `False`. |
| `on_decision` | `None` | Called with every `Decision`. |

Durations are seconds, the same lengths as the Node SDK's millisecond settings (`timeoutMs` 300, `retryBudgetMs` 650, `stickyDenyMs` 60000).

## Rules (same as the Node SDK and the Ruby gem)

- `decide` never raises for an Ordainer answer or an outage. A deny is a normal result. Bad options raise
  `ConfigurationError`.
- Only a deny from Ordainer blocks in enforce mode. Shadow never blocks (`would_deny`).
- `fail_mode` covers only "Ordainer couldn't answer": timeout, network error, 5xx, or a 200 that isn't a decision.
- A 4xx (bad or missing key, 400, 409, 429 rate limit, ...) is Ordainer answering "no": blocked in any
  `fail_mode` (`rejected`). 403 `subscription_inactive` follows `billing_lapse` instead. 429
  `safety_limit_reached` lets the call run (`safety_limited`).
- 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.
- What the caller reads (`deny_response`, same text as the Node SDK and the Ruby gem): a block a retry can't fix answers
  `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
  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
  until the service fixes the key. (For the service's team: unknown_key.)" A `429 rate_limited` answers "The service is
  getting too many requests on this key right now, so this call was held back. Please wait a moment and try again.
  (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
  stays `rate_limited`). Billing blocks (403) keep the Legal-approved text.
- The same text as the Node SDK for the other blocks too (PM final wording, Oct 18). Error messages can be reworded;
  codes, statuses and fields stay the same, so match on `code`, not the message. An outage under `fail_mode="closed"`
  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)".
  A used-up test allowance answers `429 test_limit_reached`: "This call wasn't run. This service's test calls for the
  month are used up. They reset on November 1 (UTC). Nothing is wrong on your side, and trying again won't help until
  they reset. If you need it sooner, contact the service's team. (test_limit_reached)" (the day is `resets_at` in UTC as
  "<Month D>"; no "They reset on" sentence unless `resets_at` is a valid ISO-8601 date-time). A `tool_not_in_plan` deny
  answers `403 plan_denied`: "This call wasn't run because your plan doesn't include "research.run". If you need it, ask
  whoever manages your plan. (tool_not_in_plan)", naming the tool you passed to `decide` in quotes
  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
  call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team.
  (reason)", with the reason in brackets only when it looks like an API code (lowercase letters and `_`, up to 40
  characters); the reason is also its own `reason` field.
- 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.
- 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.

## Decision

`allowed`, `decision` (`"allow"` / `"deny"` / `None`), `reason`, `remaining`, `limit`, `resets_at`,
`request_id`, `mode`, `would_deny`, `fail_open`, `fail_closed`, `rejected`, `rate_limited`,
`retry_after_sec`, `billing_inactive`, `billing_lapse`, `test_limit_reached`, `safety_limited`, `sticky_deny`, `replayed`,
`attempts`, `latency_ms`, `error` (`code`, `message`, `status`, `limit_scope`, `request_id`), `to_dict()`.

## Async

```python
import asyncio
import os
from ordainer import AsyncClient

async def main():
    async with AsyncClient(api_key=os.environ["ORDAINER_API_KEY"]) as client:
        decision = await client.decide(tool="research.run")
        print(decision.allowed, decision.decision)

asyncio.run(main())
```

Same options, same rules, same messages.

## Shared client

```python
import os
import ordainer

def configure(cfg):
    cfg.api_key = os.environ["ORDAINER_API_KEY"]
    cfg.fail_mode = "open"

ordainer.configure(configure)
decision = ordainer.client().decide(tool="research.run")
print(decision.allowed, decision.decision)
```

## Tests

```sh
cd packages/ordainer-python && python -m pip install -e ".[async,dev]" && python -m pytest
```

Set `ORDAINER_LIVE_TEST_KEY` (a test key) to also run one live call against `ORDAINER_BASE_URL`.
