ralio 0.1.0__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.
- ralio/__init__.py +59 -0
- ralio/_crypto.py +161 -0
- ralio/_store.py +105 -0
- ralio/auth.py +111 -0
- ralio/client.py +131 -0
- ralio/errors.py +106 -0
- ralio/py.typed +0 -0
- ralio/registration.py +225 -0
- ralio/resources/__init__.py +11 -0
- ralio/resources/agents.py +21 -0
- ralio/resources/chat.py +81 -0
- ralio/resources/payment_intents.py +34 -0
- ralio/resources/transactions.py +31 -0
- ralio/transport.py +151 -0
- ralio/types.py +247 -0
- ralio-0.1.0.dist-info/METADATA +210 -0
- ralio-0.1.0.dist-info/RECORD +19 -0
- ralio-0.1.0.dist-info/WHEEL +4 -0
- ralio-0.1.0.dist-info/licenses/LICENSE +21 -0
ralio/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Official Python SDK for the Ralio agentic payment API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from .client import RalioClient
|
|
8
|
+
from .errors import (
|
|
9
|
+
RalioAPIError,
|
|
10
|
+
RalioAuthError,
|
|
11
|
+
RalioConfigError,
|
|
12
|
+
RalioError,
|
|
13
|
+
RalioNotFoundError,
|
|
14
|
+
RalioPermissionError,
|
|
15
|
+
RalioRateLimitError,
|
|
16
|
+
RalioRegistrationError,
|
|
17
|
+
RalioValidationError,
|
|
18
|
+
)
|
|
19
|
+
from .registration import register
|
|
20
|
+
from .types import (
|
|
21
|
+
Agent,
|
|
22
|
+
ChatReply,
|
|
23
|
+
ChatStreamEvent,
|
|
24
|
+
CredentialBinding,
|
|
25
|
+
Message,
|
|
26
|
+
Page,
|
|
27
|
+
PaymentInstruction,
|
|
28
|
+
PaymentIntent,
|
|
29
|
+
Transaction,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Single source of truth is the installed package metadata (pyproject.toml).
|
|
33
|
+
try:
|
|
34
|
+
__version__ = version("ralio")
|
|
35
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
36
|
+
__version__ = "0.0.0+unknown"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"RalioClient",
|
|
40
|
+
"register",
|
|
41
|
+
"Agent",
|
|
42
|
+
"ChatReply",
|
|
43
|
+
"ChatStreamEvent",
|
|
44
|
+
"CredentialBinding",
|
|
45
|
+
"Message",
|
|
46
|
+
"Page",
|
|
47
|
+
"PaymentIntent",
|
|
48
|
+
"PaymentInstruction",
|
|
49
|
+
"Transaction",
|
|
50
|
+
"RalioError",
|
|
51
|
+
"RalioConfigError",
|
|
52
|
+
"RalioRegistrationError",
|
|
53
|
+
"RalioAPIError",
|
|
54
|
+
"RalioAuthError",
|
|
55
|
+
"RalioPermissionError",
|
|
56
|
+
"RalioNotFoundError",
|
|
57
|
+
"RalioValidationError",
|
|
58
|
+
"RalioRateLimitError",
|
|
59
|
+
]
|
ralio/_crypto.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""ES256 / DPoP crypto primitives for the Ralio machine-auth path.
|
|
2
|
+
|
|
3
|
+
Everything here mirrors what the Ralio API expects byte-for-byte:
|
|
4
|
+
|
|
5
|
+
- P-256 (ES256) is the only curve the token endpoint accepts.
|
|
6
|
+
- The public JWK is the canonical RFC 7638 form (``crv``/``kty``/``x``/``y``
|
|
7
|
+
only, sorted), so the thumbprint computed here matches the ``cnf.jkt`` the
|
|
8
|
+
server stamps on the access token and the fingerprint the owner confirms.
|
|
9
|
+
- Client assertions follow RFC 7521/7523; DPoP proofs follow RFC 9449.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import contextlib
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import jwt as pyjwt
|
|
26
|
+
from cryptography.hazmat.primitives import serialization
|
|
27
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
29
|
+
|
|
30
|
+
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
31
|
+
|
|
32
|
+
# The server rejects assertions older than 300s; stay well under to absorb skew.
|
|
33
|
+
_CLIENT_ASSERTION_TTL_SECONDS = 60
|
|
34
|
+
_SECRET_FILE_MODE = 0o600
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def b64url(raw: bytes) -> str:
|
|
38
|
+
"""Unpadded base64url — the only form RFC 7515/7517/7638 accept."""
|
|
39
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_keypair() -> tuple[EllipticCurvePrivateKey, dict[str, str]]:
|
|
43
|
+
"""Mint a P-256 keypair and return ``(private_key, canonical_public_jwk)``."""
|
|
44
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
|
45
|
+
return private_key, public_jwk(private_key)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def public_jwk(private_key: EllipticCurvePrivateKey) -> dict[str, str]:
|
|
49
|
+
"""Return the canonical RFC 7638 public JWK for *private_key*."""
|
|
50
|
+
numbers = private_key.public_key().public_numbers()
|
|
51
|
+
return {
|
|
52
|
+
"crv": "P-256",
|
|
53
|
+
"kty": "EC",
|
|
54
|
+
"x": b64url(numbers.x.to_bytes(32, "big")),
|
|
55
|
+
"y": b64url(numbers.y.to_bytes(32, "big")),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def jwk_thumbprint(canonical_jwk: dict[str, str]) -> str:
|
|
60
|
+
"""RFC 7638 thumbprint of a canonical JWK — used as ``kid`` and key id."""
|
|
61
|
+
canonical_json = json.dumps(canonical_jwk, separators=(",", ":"), sort_keys=True)
|
|
62
|
+
return b64url(hashlib.sha256(canonical_json.encode("ascii")).digest())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_private_key(path: str | Path, private_key: EllipticCurvePrivateKey) -> Path:
|
|
66
|
+
"""Write *private_key* as PKCS8 PEM at *path*, mode 0600, atomically."""
|
|
67
|
+
dest = Path(path)
|
|
68
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
pem = private_key.private_bytes(
|
|
70
|
+
encoding=serialization.Encoding.PEM,
|
|
71
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
72
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
73
|
+
)
|
|
74
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=dest.parent, prefix=".tmp-")
|
|
75
|
+
try:
|
|
76
|
+
with contextlib.suppress(OSError, AttributeError):
|
|
77
|
+
# fchmod unavailable on Windows; set perms before any bytes land.
|
|
78
|
+
os.fchmod(tmp_fd, _SECRET_FILE_MODE)
|
|
79
|
+
with os.fdopen(tmp_fd, "wb") as f:
|
|
80
|
+
tmp_fd = -1
|
|
81
|
+
f.write(pem)
|
|
82
|
+
os.replace(tmp_path, dest)
|
|
83
|
+
except BaseException:
|
|
84
|
+
if tmp_fd >= 0:
|
|
85
|
+
os.close(tmp_fd)
|
|
86
|
+
with contextlib.suppress(OSError):
|
|
87
|
+
os.unlink(tmp_path)
|
|
88
|
+
raise
|
|
89
|
+
return dest
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_private_key(path: str | Path) -> EllipticCurvePrivateKey:
|
|
93
|
+
"""Load a PKCS8 PEM P-256 private key from *path*."""
|
|
94
|
+
key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None)
|
|
95
|
+
if not isinstance(key, EllipticCurvePrivateKey):
|
|
96
|
+
raise ValueError("Ralio credentials require a P-256 (EC) private key")
|
|
97
|
+
return key
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sign_client_assertion(
|
|
101
|
+
private_key: EllipticCurvePrivateKey,
|
|
102
|
+
*,
|
|
103
|
+
client_id: str,
|
|
104
|
+
audience: str,
|
|
105
|
+
kid: str,
|
|
106
|
+
ttl_seconds: int = _CLIENT_ASSERTION_TTL_SECONDS,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Sign an RFC 7523 JWT bearer client assertion.
|
|
109
|
+
|
|
110
|
+
``iss`` and ``sub`` both equal *client_id*; ``aud`` is the absolute token
|
|
111
|
+
endpoint URL. ``kid`` is the JWK thumbprint so the server can locate the
|
|
112
|
+
binding directly.
|
|
113
|
+
"""
|
|
114
|
+
iat = int(time.time())
|
|
115
|
+
payload = {
|
|
116
|
+
"iss": client_id,
|
|
117
|
+
"sub": client_id,
|
|
118
|
+
"aud": audience,
|
|
119
|
+
"iat": iat,
|
|
120
|
+
"exp": iat + ttl_seconds,
|
|
121
|
+
"jti": secrets.token_urlsafe(16),
|
|
122
|
+
}
|
|
123
|
+
return pyjwt.encode(payload, _pem(private_key), algorithm="ES256", headers={"kid": kid})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def sign_dpop_proof(
|
|
127
|
+
private_key: EllipticCurvePrivateKey,
|
|
128
|
+
*,
|
|
129
|
+
method: str,
|
|
130
|
+
url: str,
|
|
131
|
+
access_token: str,
|
|
132
|
+
jwk: dict[str, str],
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Sign a single-use DPoP proof (RFC 9449) for one method + URL + token.
|
|
135
|
+
|
|
136
|
+
*url* must already have its query and fragment stripped (``htu`` per
|
|
137
|
+
RFC 9449 §4.2). The embedded ``jwk`` must be the canonical public JWK so
|
|
138
|
+
its thumbprint matches the access token's ``cnf.jkt``.
|
|
139
|
+
"""
|
|
140
|
+
iat = int(time.time())
|
|
141
|
+
payload: dict[str, Any] = {
|
|
142
|
+
"htm": method.upper(),
|
|
143
|
+
"htu": url,
|
|
144
|
+
"iat": iat,
|
|
145
|
+
"jti": secrets.token_urlsafe(16),
|
|
146
|
+
"ath": b64url(hashlib.sha256(access_token.encode("ascii")).digest()),
|
|
147
|
+
}
|
|
148
|
+
return pyjwt.encode(
|
|
149
|
+
payload,
|
|
150
|
+
_pem(private_key),
|
|
151
|
+
algorithm="ES256",
|
|
152
|
+
headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": dict(jwk)},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _pem(private_key: EllipticCurvePrivateKey) -> bytes:
|
|
157
|
+
return private_key.private_bytes(
|
|
158
|
+
encoding=serialization.Encoding.PEM,
|
|
159
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
160
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
161
|
+
)
|
ralio/_store.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""On-disk credential store, shared with the Ralio CLI.
|
|
2
|
+
|
|
3
|
+
The layout mirrors the CLI byte-for-byte so :func:`ralio.register` and
|
|
4
|
+
``ralio auth agent`` are interchangeable — either one can write the
|
|
5
|
+
credentials and the other (or :class:`ralio.RalioClient`) can consume them:
|
|
6
|
+
|
|
7
|
+
- ``~/.ralio/credentials.json`` (0600) — ``client_id``, ``key_jkt``, tokens.
|
|
8
|
+
- ``~/.ralio/keys/<jkt>.pem`` (0600) — the P-256 private key, PKCS8 PEM,
|
|
9
|
+
named by its RFC 7638 thumbprint.
|
|
10
|
+
|
|
11
|
+
``RALIO_CONFIG_DIR`` overrides ``~/.ralio`` (tests, multi-tenant hosts).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.ralio.co"
|
|
24
|
+
|
|
25
|
+
_DIR_MODE = 0o700
|
|
26
|
+
_SECRET_FILE_MODE = 0o600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_base_url(explicit: str | None = None) -> str:
|
|
30
|
+
"""Explicit value, else ``RALIO_API_URL``, else production. No trailing slash."""
|
|
31
|
+
return (explicit or _env("RALIO_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def config_dir() -> Path:
|
|
35
|
+
return Path(_env("RALIO_CONFIG_DIR") or Path.home() / ".ralio")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def credentials_path() -> Path:
|
|
39
|
+
return config_dir() / "credentials.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def key_path_for(jkt: str) -> Path:
|
|
43
|
+
"""Default on-disk location for the key whose RFC 7638 thumbprint is *jkt*."""
|
|
44
|
+
return config_dir() / "keys" / f"{jkt}.pem"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_credentials(creds: dict[str, Any]) -> None:
|
|
48
|
+
"""Persist *creds* at the CLI-compatible credentials path, mode 0600, atomically."""
|
|
49
|
+
_ensure_secret_dir(config_dir())
|
|
50
|
+
_write_secret_file(credentials_path(), json.dumps(creds, indent=2) + "\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_credentials() -> dict[str, Any] | None:
|
|
54
|
+
"""Load persisted credentials, or ``None`` when absent or unreadable."""
|
|
55
|
+
try:
|
|
56
|
+
raw = credentials_path().read_text()
|
|
57
|
+
except OSError:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
parsed = json.loads(raw)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
return parsed if isinstance(parsed, dict) else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_keys_dir() -> None:
|
|
67
|
+
"""Create the keys directory (and config dir) with owner-only permissions."""
|
|
68
|
+
_ensure_secret_dir(config_dir())
|
|
69
|
+
_ensure_secret_dir(config_dir() / "keys")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def delete_private_key(path: str | Path) -> None:
|
|
73
|
+
"""Remove a private key file, if present. Idempotent."""
|
|
74
|
+
Path(path).unlink(missing_ok=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _env(name: str) -> str | None:
|
|
78
|
+
"""Env var value, with the empty string treated as unset."""
|
|
79
|
+
return os.environ.get(name) or None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ensure_secret_dir(directory: Path) -> None:
|
|
83
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
# mkdir's mode is masked by the umask and skipped for pre-existing dirs.
|
|
85
|
+
with contextlib.suppress(OSError):
|
|
86
|
+
directory.chmod(_DIR_MODE)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _write_secret_file(path: Path, data: str) -> None:
|
|
90
|
+
"""Atomic secret write: temp file chmodded 0600 before any bytes land."""
|
|
91
|
+
tmp_fd, tmp_path = tempfile.mkstemp(dir=path.parent, prefix=".tmp-")
|
|
92
|
+
try:
|
|
93
|
+
with contextlib.suppress(OSError, AttributeError):
|
|
94
|
+
# fchmod unavailable on Windows.
|
|
95
|
+
os.fchmod(tmp_fd, _SECRET_FILE_MODE)
|
|
96
|
+
with os.fdopen(tmp_fd, "w") as f:
|
|
97
|
+
tmp_fd = -1
|
|
98
|
+
f.write(data)
|
|
99
|
+
os.replace(tmp_path, path)
|
|
100
|
+
except BaseException:
|
|
101
|
+
if tmp_fd >= 0:
|
|
102
|
+
os.close(tmp_fd)
|
|
103
|
+
with contextlib.suppress(OSError):
|
|
104
|
+
os.unlink(tmp_path)
|
|
105
|
+
raise
|
ralio/auth.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Access-token lifecycle for the machine path.
|
|
2
|
+
|
|
3
|
+
A :class:`TokenManager` mints tokens via the ``private_key_jwt`` client
|
|
4
|
+
assertion, caches the access token until shortly before expiry, and rotates
|
|
5
|
+
the refresh token. All token mutations are serialised behind a lock:
|
|
6
|
+
|
|
7
|
+
> Presenting a previously-rotated refresh token is treated as a replay attack
|
|
8
|
+
> and revokes the whole chain.
|
|
9
|
+
|
|
10
|
+
so two threads must never race a refresh.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
20
|
+
|
|
21
|
+
from . import _crypto
|
|
22
|
+
from .errors import raise_for_response
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TokenManager:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
client_id: str,
|
|
30
|
+
private_key: EllipticCurvePrivateKey,
|
|
31
|
+
kid: str,
|
|
32
|
+
token_url: str,
|
|
33
|
+
http: httpx.Client,
|
|
34
|
+
scopes: tuple[str, ...] | None = None,
|
|
35
|
+
refresh_leeway_seconds: float = 300.0,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._client_id = client_id
|
|
38
|
+
self._private_key = private_key
|
|
39
|
+
self._kid = kid
|
|
40
|
+
self._token_url = token_url
|
|
41
|
+
self._http = http
|
|
42
|
+
self._scopes = scopes
|
|
43
|
+
self._leeway = refresh_leeway_seconds
|
|
44
|
+
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
self._access_token: str | None = None
|
|
47
|
+
self._refresh_token: str | None = None
|
|
48
|
+
self._expires_at = 0.0
|
|
49
|
+
|
|
50
|
+
def access_token(self) -> str:
|
|
51
|
+
"""Return a valid access token, minting or refreshing as needed."""
|
|
52
|
+
with self._lock:
|
|
53
|
+
if self._access_token and time.time() < self._expires_at - self._leeway:
|
|
54
|
+
return self._access_token
|
|
55
|
+
return self._obtain_locked()
|
|
56
|
+
|
|
57
|
+
def force_refresh(self) -> str:
|
|
58
|
+
"""Discard the cached token and obtain a fresh one. Used after a 401."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._access_token = None
|
|
61
|
+
return self._obtain_locked()
|
|
62
|
+
|
|
63
|
+
def _obtain_locked(self) -> str:
|
|
64
|
+
if self._refresh_token:
|
|
65
|
+
try:
|
|
66
|
+
return self._refresh_locked()
|
|
67
|
+
except Exception:
|
|
68
|
+
# Refresh chains can be revoked or expired; fall back to a
|
|
69
|
+
# fresh client-assertion mint, which always works while the
|
|
70
|
+
# binding is active.
|
|
71
|
+
self._refresh_token = None
|
|
72
|
+
return self._mint_locked()
|
|
73
|
+
|
|
74
|
+
def _mint_locked(self) -> str:
|
|
75
|
+
assertion = _crypto.sign_client_assertion(
|
|
76
|
+
self._private_key,
|
|
77
|
+
client_id=self._client_id,
|
|
78
|
+
audience=self._token_url,
|
|
79
|
+
kid=self._kid,
|
|
80
|
+
)
|
|
81
|
+
data = {
|
|
82
|
+
"grant_type": "client_credentials",
|
|
83
|
+
"client_assertion_type": _crypto.CLIENT_ASSERTION_TYPE,
|
|
84
|
+
"client_assertion": assertion,
|
|
85
|
+
}
|
|
86
|
+
if self._scopes:
|
|
87
|
+
data["scope"] = " ".join(self._scopes)
|
|
88
|
+
return self._exchange_locked(data)
|
|
89
|
+
|
|
90
|
+
def _refresh_locked(self) -> str:
|
|
91
|
+
assert self._refresh_token is not None
|
|
92
|
+
return self._exchange_locked(
|
|
93
|
+
{
|
|
94
|
+
"grant_type": "refresh_token",
|
|
95
|
+
"refresh_token": self._refresh_token,
|
|
96
|
+
"client_id": self._client_id,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _exchange_locked(self, data: dict[str, str]) -> str:
|
|
101
|
+
response = self._http.post(
|
|
102
|
+
self._token_url,
|
|
103
|
+
data=data,
|
|
104
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
105
|
+
)
|
|
106
|
+
raise_for_response(response)
|
|
107
|
+
body = response.json()
|
|
108
|
+
self._access_token = body["access_token"]
|
|
109
|
+
self._refresh_token = body.get("refresh_token") or self._refresh_token
|
|
110
|
+
self._expires_at = time.time() + float(body.get("expires_in", 1800))
|
|
111
|
+
return self._access_token
|
ralio/client.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The top-level :class:`RalioClient`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
10
|
+
|
|
11
|
+
from . import _crypto, _store
|
|
12
|
+
from .auth import TokenManager
|
|
13
|
+
from .errors import RalioConfigError
|
|
14
|
+
from .resources import (
|
|
15
|
+
AgentsResource,
|
|
16
|
+
ChatResource,
|
|
17
|
+
PaymentIntentsResource,
|
|
18
|
+
TransactionsResource,
|
|
19
|
+
)
|
|
20
|
+
from .transport import Transport
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RalioClient:
|
|
24
|
+
"""Synchronous client for the Ralio API, authenticated via a credential
|
|
25
|
+
binding (OAuth 2.1 ``client_credentials`` + ``private_key_jwt`` + DPoP).
|
|
26
|
+
|
|
27
|
+
After a one-time :func:`ralio.register` on this host, no configuration is
|
|
28
|
+
needed::
|
|
29
|
+
|
|
30
|
+
client = ralio.RalioClient() # reads the persisted credentials
|
|
31
|
+
reply = client.chat.send(message="What's my balance?")
|
|
32
|
+
|
|
33
|
+
To manage credentials yourself, pass ``client_id`` and
|
|
34
|
+
``private_key_path`` together.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
client_id: str | None = None,
|
|
41
|
+
private_key_path: str | Path | None = None,
|
|
42
|
+
base_url: str | None = None,
|
|
43
|
+
scopes: list[str] | None = None,
|
|
44
|
+
timeout: float = 30.0,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._base_url = _store.resolve_base_url(base_url)
|
|
47
|
+
client_id, key_path = _resolve_credentials(client_id, private_key_path)
|
|
48
|
+
private_key = _load_private_key(key_path)
|
|
49
|
+
public_jwk = _crypto.public_jwk(private_key)
|
|
50
|
+
kid = _crypto.jwk_thumbprint(public_jwk)
|
|
51
|
+
|
|
52
|
+
# SSE streams need no read timeout; keep connect/write/pool bounded.
|
|
53
|
+
self._http = httpx.Client(
|
|
54
|
+
timeout=httpx.Timeout(timeout, read=None),
|
|
55
|
+
)
|
|
56
|
+
tokens = TokenManager(
|
|
57
|
+
client_id=client_id,
|
|
58
|
+
private_key=private_key,
|
|
59
|
+
kid=kid,
|
|
60
|
+
token_url=f"{self._base_url}/oauth/token",
|
|
61
|
+
http=self._http,
|
|
62
|
+
scopes=tuple(scopes) if scopes else None,
|
|
63
|
+
)
|
|
64
|
+
transport = Transport(
|
|
65
|
+
base_url=self._base_url,
|
|
66
|
+
http=self._http,
|
|
67
|
+
tokens=tokens,
|
|
68
|
+
private_key=private_key,
|
|
69
|
+
public_jwk=public_jwk,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.agents = AgentsResource(transport)
|
|
73
|
+
self.chat = ChatResource(transport, self.agents)
|
|
74
|
+
self.transactions = TransactionsResource(transport)
|
|
75
|
+
self.payment_intents = PaymentIntentsResource(transport)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self._http.close()
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> RalioClient:
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(
|
|
84
|
+
self,
|
|
85
|
+
exc_type: type[BaseException] | None,
|
|
86
|
+
exc: BaseException | None,
|
|
87
|
+
tb: TracebackType | None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_credentials(
|
|
93
|
+
client_id: str | None,
|
|
94
|
+
private_key_path: str | Path | None,
|
|
95
|
+
) -> tuple[str, Path]:
|
|
96
|
+
"""Resolve the binding handle and key path, falling back to the store."""
|
|
97
|
+
if client_id and private_key_path:
|
|
98
|
+
return client_id, Path(private_key_path)
|
|
99
|
+
if client_id or private_key_path:
|
|
100
|
+
raise RalioConfigError(
|
|
101
|
+
"client_id and private_key_path must be passed together; omit both "
|
|
102
|
+
"to use the credentials persisted by ralio.register()."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
stored = _store.load_credentials() or {}
|
|
106
|
+
stored_client_id = stored.get("client_id")
|
|
107
|
+
stored_key_path = stored.get("key_path")
|
|
108
|
+
stored_jkt = stored.get("key_jkt")
|
|
109
|
+
key_path: Path | None = None
|
|
110
|
+
if isinstance(stored_key_path, str) and stored_key_path:
|
|
111
|
+
key_path = Path(stored_key_path)
|
|
112
|
+
elif isinstance(stored_jkt, str) and stored_jkt:
|
|
113
|
+
key_path = _store.key_path_for(stored_jkt)
|
|
114
|
+
if not isinstance(stored_client_id, str) or not stored_client_id or key_path is None:
|
|
115
|
+
raise RalioConfigError(
|
|
116
|
+
f"No Ralio credentials found at {_store.credentials_path()}. Run "
|
|
117
|
+
"ralio.register() (or `ralio auth agent`) on this host first, or "
|
|
118
|
+
"pass client_id and private_key_path explicitly."
|
|
119
|
+
)
|
|
120
|
+
return stored_client_id, key_path
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_private_key(key_path: Path) -> EllipticCurvePrivateKey:
|
|
124
|
+
try:
|
|
125
|
+
return _crypto.load_private_key(key_path)
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
raise RalioConfigError(
|
|
128
|
+
f"Private key missing at {key_path} — the binding may have been "
|
|
129
|
+
"revoked and the key removed. Re-run ralio.register() with a "
|
|
130
|
+
"fresh ticket."
|
|
131
|
+
) from None
|
ralio/errors.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Exception hierarchy for the Ralio SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RalioError(Exception):
|
|
11
|
+
"""Base class for every error raised by the SDK."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RalioConfigError(RalioError):
|
|
15
|
+
"""Local configuration problem — missing key file, bad arguments."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RalioRegistrationError(RalioError):
|
|
19
|
+
"""A credential-binding registration failed — the ticket was invalid,
|
|
20
|
+
expired, or already consumed, the public key was unusable, or the
|
|
21
|
+
server's response didn't match the local key."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RalioAPIError(RalioError):
|
|
25
|
+
"""An error response from the Ralio API.
|
|
26
|
+
|
|
27
|
+
Carries the HTTP ``status_code``, the server-supplied ``detail`` string,
|
|
28
|
+
and the ``WWW-Authenticate`` challenge when present (DPoP/OAuth failures
|
|
29
|
+
put the specific reason there).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
message: str,
|
|
35
|
+
*,
|
|
36
|
+
status_code: int,
|
|
37
|
+
detail: str | None = None,
|
|
38
|
+
www_authenticate: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.detail = detail
|
|
43
|
+
self.www_authenticate = www_authenticate
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RalioAuthError(RalioAPIError):
|
|
47
|
+
"""401 — missing/invalid token, failed client assertion, or rejected proof."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RalioPermissionError(RalioAPIError):
|
|
51
|
+
"""403 — token lacks the required scope, or the resource isn't owned."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RalioNotFoundError(RalioAPIError):
|
|
55
|
+
"""404 — resource does not exist."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RalioValidationError(RalioAPIError):
|
|
59
|
+
"""422 — invalid field values or a business-rule violation."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RalioRateLimitError(RalioAPIError):
|
|
63
|
+
"""429 — rate limited. Back off and retry."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_STATUS_MAP: dict[int, type[RalioAPIError]] = {
|
|
67
|
+
401: RalioAuthError,
|
|
68
|
+
403: RalioPermissionError,
|
|
69
|
+
404: RalioNotFoundError,
|
|
70
|
+
422: RalioValidationError,
|
|
71
|
+
429: RalioRateLimitError,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def raise_for_response(response: httpx.Response) -> None:
|
|
76
|
+
"""Raise the appropriate :class:`RalioAPIError` if *response* is an error."""
|
|
77
|
+
if response.is_success:
|
|
78
|
+
return
|
|
79
|
+
detail = _extract_detail(response)
|
|
80
|
+
cls = _STATUS_MAP.get(response.status_code, RalioAPIError)
|
|
81
|
+
message = detail or f"HTTP {response.status_code}"
|
|
82
|
+
raise cls(
|
|
83
|
+
message,
|
|
84
|
+
status_code=response.status_code,
|
|
85
|
+
detail=detail,
|
|
86
|
+
www_authenticate=response.headers.get("www-authenticate"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_detail(response: httpx.Response) -> str | None:
|
|
91
|
+
"""Return the FastAPI ``detail`` field, or the raw body as a fallback."""
|
|
92
|
+
try:
|
|
93
|
+
payload = response.json()
|
|
94
|
+
except (ValueError, json.JSONDecodeError):
|
|
95
|
+
return response.text or None
|
|
96
|
+
if isinstance(payload, dict):
|
|
97
|
+
detail = payload.get("detail")
|
|
98
|
+
if isinstance(detail, str):
|
|
99
|
+
return detail
|
|
100
|
+
if detail is not None:
|
|
101
|
+
return json.dumps(detail)
|
|
102
|
+
# OAuth-style error bodies use error/error_description instead.
|
|
103
|
+
oauth = payload.get("error_description") or payload.get("error")
|
|
104
|
+
if isinstance(oauth, str):
|
|
105
|
+
return oauth
|
|
106
|
+
return response.text or None
|
ralio/py.typed
ADDED
|
File without changes
|