omina-client 1.0.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.
- omina_client-1.0.0/.gitignore +14 -0
- omina_client-1.0.0/PKG-INFO +90 -0
- omina_client-1.0.0/README.md +73 -0
- omina_client-1.0.0/pyproject.toml +36 -0
- omina_client-1.0.0/src/omina_client/__init__.py +50 -0
- omina_client-1.0.0/src/omina_client/client.py +280 -0
- omina_client-1.0.0/src/omina_client/exceptions.py +61 -0
- omina_client-1.0.0/src/omina_client/models.py +396 -0
- omina_client-1.0.0/src/omina_client/retry.py +47 -0
- omina_client-1.0.0/tests/test_client.py +183 -0
- omina_client-1.0.0/tests/test_live_server.py +122 -0
- omina_client-1.0.0/tests/test_models.py +179 -0
- omina_client-1.0.0/tests/test_retry.py +31 -0
- omina_client-1.0.0/uv.lock +339 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/target
|
|
2
|
+
/keys/
|
|
3
|
+
.venv/
|
|
4
|
+
**/.venv/
|
|
5
|
+
**/__pycache__/
|
|
6
|
+
**/*.py[cod]
|
|
7
|
+
**/.pytest_cache/
|
|
8
|
+
**/dist/
|
|
9
|
+
/training_runs/
|
|
10
|
+
/data/telemetry_50k/
|
|
11
|
+
/models/minilm-l6-h384/model.safetensors
|
|
12
|
+
/bundles/compiled_from_cli.omina
|
|
13
|
+
/bundles/drone_flight.omina
|
|
14
|
+
/reports/**/predictions.jsonl
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: omina-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client SDK for the Omina edge intelligence daemon
|
|
5
|
+
Author: Omina Project Maintainers
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Requires-Dist: httpx>=0.27.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Omina Python Client SDK (`omina-client`)
|
|
19
|
+
|
|
20
|
+
The official Python client SDK for the **Omina** intelligence daemon.
|
|
21
|
+
|
|
22
|
+
Designed for downstream systems engineers, telemetry monitoring pipelines, robotics scripts, and automated control loops.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Type-Safe Primitive Extraction**: Direct extraction of continuous `Gauge`, binary `Gate`, categorical `Route`, semantic `Noul`, discrete `Choice`, and ordinal `Score` heads.
|
|
27
|
+
- **Transparent Backpressure Resilience**: Automatic exponential backoff with full jitter on HTTP `429 OVERLOADED` and `503 DEADLINE_EXCEEDED` respecting `Retry-After` headers.
|
|
28
|
+
- **Hardware Fallback Awareness**: Immediate detection of neural graph fallbacks (`response.is_fallback`, `fallback_triggered`).
|
|
29
|
+
- **Synchronous & Asynchronous**: High-performance sync (`OminaClient`) and async (`AsyncOminaClient`) clients built on top of `httpx`.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install omina-client
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from omina_client import OminaClient
|
|
41
|
+
from omina_client.exceptions import OminaOverloadError
|
|
42
|
+
|
|
43
|
+
client = OminaClient(
|
|
44
|
+
base_url="http://localhost:8080",
|
|
45
|
+
max_retries=3,
|
|
46
|
+
base_backoff_ms=50
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
response = client.evaluate(
|
|
51
|
+
domain_id="drone_flight",
|
|
52
|
+
context_log="Winds at 40 knots. Vision sensor degraded. GPS altitude spiking."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 1. Dictionary-like or attribute access
|
|
56
|
+
altitude = response.gauges["altitude"].predicted_value
|
|
57
|
+
sigma = response.gauges["altitude"].uncertainty_sigma
|
|
58
|
+
is_breached = response.gates["geofence_breach"].decision
|
|
59
|
+
maneuver = response.routes["maneuver"].selected
|
|
60
|
+
|
|
61
|
+
# 2. Direct helper access
|
|
62
|
+
alt = response.gauge("altitude").predicted_value
|
|
63
|
+
breached = response.gate("geofence_breach") # returns bool
|
|
64
|
+
man = response.route("maneuver") # returns str
|
|
65
|
+
|
|
66
|
+
# 3. Fallback inspection
|
|
67
|
+
if response.gauges["altitude"].fallback_triggered:
|
|
68
|
+
print("Warning: Altitude gauge executed safe fallback envelope!")
|
|
69
|
+
if response.is_fallback:
|
|
70
|
+
print("Degraded heads:", response.fallback_heads)
|
|
71
|
+
|
|
72
|
+
print(f"Maneuver: {maneuver}, Altitude: {altitude}m (±{sigma})")
|
|
73
|
+
|
|
74
|
+
except OminaOverloadError as e:
|
|
75
|
+
print(f"Edge daemon saturated after {e.retries} retries: {e}")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Async Usage
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
from omina_client import AsyncOminaClient
|
|
83
|
+
|
|
84
|
+
async def main():
|
|
85
|
+
async with AsyncOminaClient("http://localhost:8080") as client:
|
|
86
|
+
response = await client.evaluate("drone_flight", "Clear skies, nominal telemetry")
|
|
87
|
+
print("Altitude:", response.gauge("altitude").predicted_value)
|
|
88
|
+
|
|
89
|
+
asyncio.run(main())
|
|
90
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Omina Python Client SDK (`omina-client`)
|
|
2
|
+
|
|
3
|
+
The official Python client SDK for the **Omina** intelligence daemon.
|
|
4
|
+
|
|
5
|
+
Designed for downstream systems engineers, telemetry monitoring pipelines, robotics scripts, and automated control loops.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Type-Safe Primitive Extraction**: Direct extraction of continuous `Gauge`, binary `Gate`, categorical `Route`, semantic `Noul`, discrete `Choice`, and ordinal `Score` heads.
|
|
10
|
+
- **Transparent Backpressure Resilience**: Automatic exponential backoff with full jitter on HTTP `429 OVERLOADED` and `503 DEADLINE_EXCEEDED` respecting `Retry-After` headers.
|
|
11
|
+
- **Hardware Fallback Awareness**: Immediate detection of neural graph fallbacks (`response.is_fallback`, `fallback_triggered`).
|
|
12
|
+
- **Synchronous & Asynchronous**: High-performance sync (`OminaClient`) and async (`AsyncOminaClient`) clients built on top of `httpx`.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install omina-client
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from omina_client import OminaClient
|
|
24
|
+
from omina_client.exceptions import OminaOverloadError
|
|
25
|
+
|
|
26
|
+
client = OminaClient(
|
|
27
|
+
base_url="http://localhost:8080",
|
|
28
|
+
max_retries=3,
|
|
29
|
+
base_backoff_ms=50
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
response = client.evaluate(
|
|
34
|
+
domain_id="drone_flight",
|
|
35
|
+
context_log="Winds at 40 knots. Vision sensor degraded. GPS altitude spiking."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# 1. Dictionary-like or attribute access
|
|
39
|
+
altitude = response.gauges["altitude"].predicted_value
|
|
40
|
+
sigma = response.gauges["altitude"].uncertainty_sigma
|
|
41
|
+
is_breached = response.gates["geofence_breach"].decision
|
|
42
|
+
maneuver = response.routes["maneuver"].selected
|
|
43
|
+
|
|
44
|
+
# 2. Direct helper access
|
|
45
|
+
alt = response.gauge("altitude").predicted_value
|
|
46
|
+
breached = response.gate("geofence_breach") # returns bool
|
|
47
|
+
man = response.route("maneuver") # returns str
|
|
48
|
+
|
|
49
|
+
# 3. Fallback inspection
|
|
50
|
+
if response.gauges["altitude"].fallback_triggered:
|
|
51
|
+
print("Warning: Altitude gauge executed safe fallback envelope!")
|
|
52
|
+
if response.is_fallback:
|
|
53
|
+
print("Degraded heads:", response.fallback_heads)
|
|
54
|
+
|
|
55
|
+
print(f"Maneuver: {maneuver}, Altitude: {altitude}m (±{sigma})")
|
|
56
|
+
|
|
57
|
+
except OminaOverloadError as e:
|
|
58
|
+
print(f"Edge daemon saturated after {e.retries} retries: {e}")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Async Usage
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
from omina_client import AsyncOminaClient
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
async with AsyncOminaClient("http://localhost:8080") as client:
|
|
69
|
+
response = await client.evaluate("drone_flight", "Clear skies, nominal telemetry")
|
|
70
|
+
print("Altitude:", response.gauge("altitude").predicted_value)
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "omina-client"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python client SDK for the Omina edge intelligence daemon"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Omina Project Maintainers" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"httpx>=0.27.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8.0.0",
|
|
28
|
+
"pytest-asyncio>=0.23.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/omina_client"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Omina Official Python Client SDK."""
|
|
2
|
+
|
|
3
|
+
from omina_client.client import AsyncOminaClient, OminaClient
|
|
4
|
+
from omina_client.exceptions import (
|
|
5
|
+
OminaBadRequestError,
|
|
6
|
+
OminaDeadlineError,
|
|
7
|
+
OminaError,
|
|
8
|
+
OminaHeadNotFoundError,
|
|
9
|
+
OminaInternalServerError,
|
|
10
|
+
OminaNotFoundError,
|
|
11
|
+
OminaOverloadError,
|
|
12
|
+
OminaSchemaError,
|
|
13
|
+
OminaUnprocessableEntityError,
|
|
14
|
+
)
|
|
15
|
+
from omina_client.models import (
|
|
16
|
+
ChoiceResult,
|
|
17
|
+
EvaluationResponse,
|
|
18
|
+
GateResult,
|
|
19
|
+
GaugeResult,
|
|
20
|
+
MultiDomainResponse,
|
|
21
|
+
NoulResult,
|
|
22
|
+
RouteResult,
|
|
23
|
+
ScoreResult,
|
|
24
|
+
SystemHealthResponse,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "1.0.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"OminaClient",
|
|
31
|
+
"AsyncOminaClient",
|
|
32
|
+
"EvaluationResponse",
|
|
33
|
+
"MultiDomainResponse",
|
|
34
|
+
"SystemHealthResponse",
|
|
35
|
+
"GaugeResult",
|
|
36
|
+
"GateResult",
|
|
37
|
+
"RouteResult",
|
|
38
|
+
"NoulResult",
|
|
39
|
+
"ChoiceResult",
|
|
40
|
+
"ScoreResult",
|
|
41
|
+
"OminaError",
|
|
42
|
+
"OminaOverloadError",
|
|
43
|
+
"OminaDeadlineError",
|
|
44
|
+
"OminaNotFoundError",
|
|
45
|
+
"OminaBadRequestError",
|
|
46
|
+
"OminaUnprocessableEntityError",
|
|
47
|
+
"OminaInternalServerError",
|
|
48
|
+
"OminaHeadNotFoundError",
|
|
49
|
+
"OminaSchemaError",
|
|
50
|
+
]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Synchronous and Asynchronous HTTP clients for the Omina Edge Serving Daemon."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from omina_client.exceptions import (
|
|
9
|
+
OminaBadRequestError,
|
|
10
|
+
OminaDeadlineError,
|
|
11
|
+
OminaError,
|
|
12
|
+
OminaInternalServerError,
|
|
13
|
+
OminaNotFoundError,
|
|
14
|
+
OminaOverloadError,
|
|
15
|
+
OminaUnprocessableEntityError,
|
|
16
|
+
)
|
|
17
|
+
from omina_client.models import (
|
|
18
|
+
EvaluationResponse,
|
|
19
|
+
MultiDomainResponse,
|
|
20
|
+
SystemHealthResponse,
|
|
21
|
+
)
|
|
22
|
+
from omina_client.retry import compute_backoff, is_retryable_status, parse_retry_after
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _extract_error_message(resp: httpx.Response) -> str:
|
|
26
|
+
try:
|
|
27
|
+
data = resp.json()
|
|
28
|
+
if isinstance(data, dict):
|
|
29
|
+
err = data.get("error")
|
|
30
|
+
details = data.get("details")
|
|
31
|
+
if err and details:
|
|
32
|
+
return f"{err}: {details}"
|
|
33
|
+
if err:
|
|
34
|
+
return str(err)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
text = resp.text.strip()
|
|
38
|
+
return text if text else f"HTTP {resp.status_code}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OminaClient:
|
|
42
|
+
"""Synchronous client for the Omina edge intelligence daemon with built-in backpressure handling."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
base_url: str,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
base_backoff_ms: float = 50.0,
|
|
49
|
+
max_backoff_ms: float = 2000.0,
|
|
50
|
+
timeout: float = 10.0,
|
|
51
|
+
bearer_token: Optional[str] = None,
|
|
52
|
+
):
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.max_retries = max_retries
|
|
55
|
+
self.base_backoff_ms = base_backoff_ms
|
|
56
|
+
self.max_backoff_ms = max_backoff_ms
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
self.bearer_token = bearer_token
|
|
59
|
+
|
|
60
|
+
headers = {"Content-Type": "application/json"}
|
|
61
|
+
if self.bearer_token:
|
|
62
|
+
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
|
63
|
+
|
|
64
|
+
self._client = httpx.Client(base_url=self.base_url, headers=headers, timeout=self.timeout)
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._client.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> "OminaClient":
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
def evaluate(self, domain_id: str, context_log: str) -> EvaluationResponse:
|
|
76
|
+
"""Evaluates a context text log against a registered domain head."""
|
|
77
|
+
payload = {"domain_id": domain_id, "context_log": context_log}
|
|
78
|
+
data = self._dispatch_post("/v1/evaluate", payload)
|
|
79
|
+
return EvaluationResponse.from_dict(data)
|
|
80
|
+
|
|
81
|
+
def evaluate_frame(
|
|
82
|
+
self, context_log: str, domains: Optional[List[str]] = None
|
|
83
|
+
) -> MultiDomainResponse:
|
|
84
|
+
"""Evaluates a context frame across multiple or all registered domains."""
|
|
85
|
+
payload: Dict[str, Any] = {"context_log": context_log}
|
|
86
|
+
if domains is not None:
|
|
87
|
+
payload["domains"] = domains
|
|
88
|
+
data = self._dispatch_post("/v1/evaluate/frame", payload)
|
|
89
|
+
return MultiDomainResponse.from_dict(data)
|
|
90
|
+
|
|
91
|
+
def health(self) -> SystemHealthResponse:
|
|
92
|
+
"""Checks daemon health and resident encoder state via GET /system/health."""
|
|
93
|
+
data = self._dispatch_get("/system/health")
|
|
94
|
+
return SystemHealthResponse.from_dict(data)
|
|
95
|
+
|
|
96
|
+
def health_check(self) -> SystemHealthResponse:
|
|
97
|
+
"""Alias for health()."""
|
|
98
|
+
return self.health()
|
|
99
|
+
|
|
100
|
+
def _dispatch_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
101
|
+
attempt = 0
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
resp = self._client.post(path, json=payload)
|
|
105
|
+
if resp.is_success:
|
|
106
|
+
return resp.json()
|
|
107
|
+
|
|
108
|
+
status = resp.status_code
|
|
109
|
+
if is_retryable_status(status):
|
|
110
|
+
retry_after = parse_retry_after(resp.headers)
|
|
111
|
+
err_msg = _extract_error_message(resp)
|
|
112
|
+
|
|
113
|
+
if attempt >= self.max_retries:
|
|
114
|
+
if status == 429:
|
|
115
|
+
raise OminaOverloadError(err_msg, retries=attempt, retry_after=retry_after)
|
|
116
|
+
else:
|
|
117
|
+
raise OminaDeadlineError(err_msg, retries=attempt)
|
|
118
|
+
|
|
119
|
+
delay = compute_backoff(
|
|
120
|
+
attempt, self.base_backoff_ms, self.max_backoff_ms, retry_after
|
|
121
|
+
)
|
|
122
|
+
time.sleep(delay)
|
|
123
|
+
attempt += 1
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Non-retryable error
|
|
127
|
+
err_msg = _extract_error_message(resp)
|
|
128
|
+
self._raise_for_status(status, err_msg)
|
|
129
|
+
|
|
130
|
+
except httpx.TransportError as e:
|
|
131
|
+
if attempt < self.max_retries:
|
|
132
|
+
delay = compute_backoff(attempt, self.base_backoff_ms, self.max_backoff_ms)
|
|
133
|
+
time.sleep(delay)
|
|
134
|
+
attempt += 1
|
|
135
|
+
continue
|
|
136
|
+
raise OminaError(f"HTTP transport failure: {e}") from e
|
|
137
|
+
|
|
138
|
+
def _dispatch_get(self, path: str) -> Dict[str, Any]:
|
|
139
|
+
try:
|
|
140
|
+
resp = self._client.get(path)
|
|
141
|
+
if resp.is_success:
|
|
142
|
+
return resp.json()
|
|
143
|
+
err_msg = _extract_error_message(resp)
|
|
144
|
+
self._raise_for_status(resp.status_code, err_msg)
|
|
145
|
+
return resp.json()
|
|
146
|
+
except httpx.TransportError as e:
|
|
147
|
+
raise OminaError(f"HTTP transport failure: {e}") from e
|
|
148
|
+
|
|
149
|
+
def _raise_for_status(self, status: int, err_msg: str) -> None:
|
|
150
|
+
if status == 404:
|
|
151
|
+
raise OminaNotFoundError(err_msg)
|
|
152
|
+
elif status == 400:
|
|
153
|
+
raise OminaBadRequestError(err_msg)
|
|
154
|
+
elif status == 422:
|
|
155
|
+
raise OminaUnprocessableEntityError(err_msg)
|
|
156
|
+
elif status == 500:
|
|
157
|
+
raise OminaInternalServerError(err_msg)
|
|
158
|
+
else:
|
|
159
|
+
raise OminaError(f"HTTP {status}: {err_msg}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AsyncOminaClient:
|
|
163
|
+
"""Asynchronous client for the Omina edge intelligence daemon with built-in backpressure handling."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
base_url: str,
|
|
168
|
+
max_retries: int = 3,
|
|
169
|
+
base_backoff_ms: float = 50.0,
|
|
170
|
+
max_backoff_ms: float = 2000.0,
|
|
171
|
+
timeout: float = 10.0,
|
|
172
|
+
bearer_token: Optional[str] = None,
|
|
173
|
+
):
|
|
174
|
+
self.base_url = base_url.rstrip("/")
|
|
175
|
+
self.max_retries = max_retries
|
|
176
|
+
self.base_backoff_ms = base_backoff_ms
|
|
177
|
+
self.max_backoff_ms = max_backoff_ms
|
|
178
|
+
self.timeout = timeout
|
|
179
|
+
self.bearer_token = bearer_token
|
|
180
|
+
|
|
181
|
+
headers = {"Content-Type": "application/json"}
|
|
182
|
+
if self.bearer_token:
|
|
183
|
+
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
|
184
|
+
|
|
185
|
+
self._client = httpx.AsyncClient(base_url=self.base_url, headers=headers, timeout=self.timeout)
|
|
186
|
+
|
|
187
|
+
async def aclose(self) -> None:
|
|
188
|
+
await self._client.aclose()
|
|
189
|
+
|
|
190
|
+
async def __aenter__(self) -> "AsyncOminaClient":
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
194
|
+
await self.aclose()
|
|
195
|
+
|
|
196
|
+
async def evaluate(self, domain_id: str, context_log: str) -> EvaluationResponse:
|
|
197
|
+
"""Evaluates a context text log against a registered domain head."""
|
|
198
|
+
payload = {"domain_id": domain_id, "context_log": context_log}
|
|
199
|
+
data = await self._dispatch_post("/v1/evaluate", payload)
|
|
200
|
+
return EvaluationResponse.from_dict(data)
|
|
201
|
+
|
|
202
|
+
async def evaluate_frame(
|
|
203
|
+
self, context_log: str, domains: Optional[List[str]] = None
|
|
204
|
+
) -> MultiDomainResponse:
|
|
205
|
+
"""Evaluates a context frame across multiple or all registered domains."""
|
|
206
|
+
payload: Dict[str, Any] = {"context_log": context_log}
|
|
207
|
+
if domains is not None:
|
|
208
|
+
payload["domains"] = domains
|
|
209
|
+
data = await self._dispatch_post("/v1/evaluate/frame", payload)
|
|
210
|
+
return MultiDomainResponse.from_dict(data)
|
|
211
|
+
|
|
212
|
+
async def health(self) -> SystemHealthResponse:
|
|
213
|
+
"""Checks daemon health and resident encoder state via GET /system/health."""
|
|
214
|
+
data = await self._dispatch_get("/system/health")
|
|
215
|
+
return SystemHealthResponse.from_dict(data)
|
|
216
|
+
|
|
217
|
+
async def health_check(self) -> SystemHealthResponse:
|
|
218
|
+
"""Alias for health()."""
|
|
219
|
+
return await self.health()
|
|
220
|
+
|
|
221
|
+
async def _dispatch_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
222
|
+
attempt = 0
|
|
223
|
+
while True:
|
|
224
|
+
try:
|
|
225
|
+
resp = await self._client.post(path, json=payload)
|
|
226
|
+
if resp.is_success:
|
|
227
|
+
return resp.json()
|
|
228
|
+
|
|
229
|
+
status = resp.status_code
|
|
230
|
+
if is_retryable_status(status):
|
|
231
|
+
retry_after = parse_retry_after(resp.headers)
|
|
232
|
+
err_msg = _extract_error_message(resp)
|
|
233
|
+
|
|
234
|
+
if attempt >= self.max_retries:
|
|
235
|
+
if status == 429:
|
|
236
|
+
raise OminaOverloadError(err_msg, retries=attempt, retry_after=retry_after)
|
|
237
|
+
else:
|
|
238
|
+
raise OminaDeadlineError(err_msg, retries=attempt)
|
|
239
|
+
|
|
240
|
+
delay = compute_backoff(
|
|
241
|
+
attempt, self.base_backoff_ms, self.max_backoff_ms, retry_after
|
|
242
|
+
)
|
|
243
|
+
await asyncio.sleep(delay)
|
|
244
|
+
attempt += 1
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
# Non-retryable error
|
|
248
|
+
err_msg = _extract_error_message(resp)
|
|
249
|
+
self._raise_for_status(status, err_msg)
|
|
250
|
+
|
|
251
|
+
except httpx.TransportError as e:
|
|
252
|
+
if attempt < self.max_retries:
|
|
253
|
+
delay = compute_backoff(attempt, self.base_backoff_ms, self.max_backoff_ms)
|
|
254
|
+
await asyncio.sleep(delay)
|
|
255
|
+
attempt += 1
|
|
256
|
+
continue
|
|
257
|
+
raise OminaError(f"HTTP transport failure: {e}") from e
|
|
258
|
+
|
|
259
|
+
async def _dispatch_get(self, path: str) -> Dict[str, Any]:
|
|
260
|
+
try:
|
|
261
|
+
resp = await self._client.get(path)
|
|
262
|
+
if resp.is_success:
|
|
263
|
+
return resp.json()
|
|
264
|
+
err_msg = _extract_error_message(resp)
|
|
265
|
+
self._raise_for_status(resp.status_code, err_msg)
|
|
266
|
+
return resp.json()
|
|
267
|
+
except httpx.TransportError as e:
|
|
268
|
+
raise OminaError(f"HTTP transport failure: {e}") from e
|
|
269
|
+
|
|
270
|
+
def _raise_for_status(self, status: int, err_msg: str) -> None:
|
|
271
|
+
if status == 404:
|
|
272
|
+
raise OminaNotFoundError(err_msg)
|
|
273
|
+
elif status == 400:
|
|
274
|
+
raise OminaBadRequestError(err_msg)
|
|
275
|
+
elif status == 422:
|
|
276
|
+
raise OminaUnprocessableEntityError(err_msg)
|
|
277
|
+
elif status == 500:
|
|
278
|
+
raise OminaInternalServerError(err_msg)
|
|
279
|
+
else:
|
|
280
|
+
raise OminaError(f"HTTP {status}: {err_msg}")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Exceptions emitted by the Omina Client SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OminaError(Exception):
|
|
7
|
+
"""Base exception for all Omina client errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OminaOverloadError(OminaError):
|
|
12
|
+
"""Raised when the edge daemon's inference queue is saturated (HTTP 429)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, retries: int = 0, retry_after: Optional[float] = None):
|
|
15
|
+
super().__init__(f"{message} (exhausted {retries} retries)")
|
|
16
|
+
self.message = message
|
|
17
|
+
self.retries = retries
|
|
18
|
+
self.retry_after = retry_after
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OminaDeadlineError(OminaError):
|
|
22
|
+
"""Raised when an inference request exceeds the server-side deadline (HTTP 503/504)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, retries: int = 0):
|
|
25
|
+
super().__init__(f"{message} (exhausted {retries} retries)")
|
|
26
|
+
self.message = message
|
|
27
|
+
self.retries = retries
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OminaNotFoundError(OminaError):
|
|
31
|
+
"""Raised when the requested domain or endpoint does not exist on the daemon (HTTP 404)."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OminaBadRequestError(OminaError):
|
|
36
|
+
"""Raised when the client request payload is malformed (HTTP 400)."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OminaUnprocessableEntityError(OminaError):
|
|
41
|
+
"""Raised when the request payload cannot be processed (HTTP 422)."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OminaInternalServerError(OminaError):
|
|
46
|
+
"""Raised when the daemon encounters an unhandled internal error (HTTP 500)."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OminaHeadNotFoundError(OminaError):
|
|
51
|
+
"""Raised when attempting to retrieve an unknown head ID from an evaluation response."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, head_id: str, domain_id: str):
|
|
54
|
+
super().__init__(f"Head '{head_id}' not found in domain '{domain_id}' response")
|
|
55
|
+
self.head_id = head_id
|
|
56
|
+
self.domain_id = domain_id
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OminaSchemaError(OminaError):
|
|
60
|
+
"""Raised when a response fails schema validation or JSON decoding."""
|
|
61
|
+
pass
|