maritime 0.2.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.
- maritime-0.2.0/.gitignore +6 -0
- maritime-0.2.0/PKG-INFO +158 -0
- maritime-0.2.0/README.md +134 -0
- maritime-0.2.0/maritime/__init__.py +81 -0
- maritime-0.2.0/maritime/_http.py +136 -0
- maritime-0.2.0/maritime/errors.py +61 -0
- maritime-0.2.0/maritime/resources.py +192 -0
- maritime-0.2.0/pyproject.toml +36 -0
- maritime-0.2.0/tests/test_client.py +187 -0
maritime-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maritime
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK for Maritime — provision and drive AI agents on Maritime's serverless infrastructure from your own backend.
|
|
5
|
+
Project-URL: Homepage, https://maritime.sh
|
|
6
|
+
Project-URL: Documentation, https://maritime.sh/docs/build
|
|
7
|
+
Project-URL: Repository, https://github.com/mariagorskikh/maritime-sdk
|
|
8
|
+
Author: Maritime
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,ai,maritime,openclaw,sdk,serverless
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# maritime (Python SDK)
|
|
26
|
+
|
|
27
|
+
Official Python SDK for [Maritime](https://maritime.sh) — provision and drive AI agents on Maritime's serverless infrastructure, straight from your own backend.
|
|
28
|
+
|
|
29
|
+
Build an app where every one of **your** users gets **their own** agent: one call on sign-up spins up an isolated agent on Maritime's fleet; another sends it a message. You never touch a container.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install maritime
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.9+. Zero dependencies (pure standard library).
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from maritime import Maritime
|
|
41
|
+
|
|
42
|
+
client = Maritime() # reads MARITIME_API_KEY
|
|
43
|
+
|
|
44
|
+
# When YOUR user signs up, give them their own agent. provision() is idempotent
|
|
45
|
+
# on external_id — safe to call on every login.
|
|
46
|
+
agent = client.agents.provision(
|
|
47
|
+
external_id=f"customer_{user.id}", # your id for this agent
|
|
48
|
+
name=f"assistant-{user.id}",
|
|
49
|
+
template="openclaw",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Talk to it (sleeping agents auto-wake):
|
|
53
|
+
reply = client.agents.chat(agent["id"], "Summarize my unread email.")["response"]
|
|
54
|
+
print(reply)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Authentication
|
|
58
|
+
|
|
59
|
+
Mint an API key (`mk_...`) from the dashboard (**Settings → API keys**) or the CLI (`maritime keys create`), then set `MARITIME_API_KEY`, or pass it explicitly:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
client = Maritime(api_key="mk_...")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Keys carry **scopes** — hand a narrower key to a subsystem that only needs part of the surface:
|
|
66
|
+
|
|
67
|
+
| Scope | Grants |
|
|
68
|
+
|-------|--------|
|
|
69
|
+
| `provision` | create agents |
|
|
70
|
+
| `deploy` | start/stop/restart/sleep/chat |
|
|
71
|
+
| `secrets` | read/write env vars |
|
|
72
|
+
| `manage` | everything, incl. delete + key management (wildcard) |
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
worker = client.keys.create("chat-worker", scopes=["deploy"])
|
|
76
|
+
# worker["raw_key"] is shown once — store it now.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Agents
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# Create (kicks off deploy). Prefer template — a bare framework yields a broken image.
|
|
83
|
+
agent = client.agents.create(
|
|
84
|
+
"support-bot",
|
|
85
|
+
template="openclaw",
|
|
86
|
+
external_id="customer_42",
|
|
87
|
+
instructions="You are a friendly support agent for Acme Inc.",
|
|
88
|
+
env=[{"key": "ACME_API_KEY", "value": "...", "secret": True}],
|
|
89
|
+
tier="smart", # "smart" | "extended" | "always_on"
|
|
90
|
+
idle_ttl_seconds=3600, # 0 = always-on
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Idempotent get-or-create by external_id (recommended for per-user provisioning)
|
|
94
|
+
agent = client.agents.provision(external_id="customer_42", name="support-bot")
|
|
95
|
+
|
|
96
|
+
client.agents.get(agent["id"])
|
|
97
|
+
client.agents.list(external_id="customer_42") # filter by your id
|
|
98
|
+
client.agents.list() # all of your agents
|
|
99
|
+
|
|
100
|
+
# Chat (synchronous; auto-wakes a sleeping agent)
|
|
101
|
+
result = client.agents.chat(agent["id"], "Hello")
|
|
102
|
+
print(result["response"]) # None + result["error"] if delivery failed
|
|
103
|
+
|
|
104
|
+
# Lifecycle
|
|
105
|
+
client.agents.start(agent["id"])
|
|
106
|
+
client.agents.sleep(agent["id"]) # cheapest resting state (serverless snapshot)
|
|
107
|
+
client.agents.restart(agent["id"])
|
|
108
|
+
client.agents.delete(agent["id"]) # tears down container + volume + network
|
|
109
|
+
|
|
110
|
+
# Env vars (secrets encrypted at rest; reach a running container after reload_env)
|
|
111
|
+
client.agents.set_env(agent["id"], "STRIPE_KEY", "sk_live_...", secret=True)
|
|
112
|
+
client.agents.list_env(agent["id"])
|
|
113
|
+
client.agents.reload_env(agent["id"])
|
|
114
|
+
|
|
115
|
+
# Logs
|
|
116
|
+
client.agents.logs(agent["id"], limit=100, level="error")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Errors
|
|
120
|
+
|
|
121
|
+
Every failure is a subclass of `MaritimeError` — catch the base to catch them all, or narrow by type:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from maritime import (
|
|
125
|
+
MaritimeAuthError, # 401 / 403 — bad or under-scoped key
|
|
126
|
+
MaritimePaymentRequiredError, # 402 — wallet needs funding
|
|
127
|
+
MaritimeNotFoundError, # 404 — no such agent (or not yours)
|
|
128
|
+
MaritimeConflictError, # 409 — name already taken
|
|
129
|
+
MaritimeRateLimitError, # 429
|
|
130
|
+
MaritimeAPIError, # any other non-2xx (has .status, .detail)
|
|
131
|
+
MaritimeConnectionError, # never reached Maritime (network/timeout)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
client.agents.create("dupe", template="openclaw")
|
|
136
|
+
except MaritimeConflictError:
|
|
137
|
+
... # an agent with that name already exists
|
|
138
|
+
except MaritimeAPIError as err:
|
|
139
|
+
print(err.status, err.detail, err.request_id)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Configuration
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
Maritime(
|
|
146
|
+
api_key="mk_...", # or MARITIME_API_KEY
|
|
147
|
+
base_url="https://api.maritime.sh", # or MARITIME_API_URL
|
|
148
|
+
timeout=60.0, # per-request seconds
|
|
149
|
+
max_retries=2, # network + 5xx/429 (GET/DELETE and 429/503 only)
|
|
150
|
+
default_headers={"x-team": "acme"},
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Retries are safe by construction: `GET`/`DELETE` retry on any transient failure; `POST`/`PUT` retry only on network errors and `429`/`503` (never a `5xx` that might have applied a write).
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
maritime-0.2.0/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# maritime (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [Maritime](https://maritime.sh) — provision and drive AI agents on Maritime's serverless infrastructure, straight from your own backend.
|
|
4
|
+
|
|
5
|
+
Build an app where every one of **your** users gets **their own** agent: one call on sign-up spins up an isolated agent on Maritime's fleet; another sends it a message. You never touch a container.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install maritime
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.9+. Zero dependencies (pure standard library).
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from maritime import Maritime
|
|
17
|
+
|
|
18
|
+
client = Maritime() # reads MARITIME_API_KEY
|
|
19
|
+
|
|
20
|
+
# When YOUR user signs up, give them their own agent. provision() is idempotent
|
|
21
|
+
# on external_id — safe to call on every login.
|
|
22
|
+
agent = client.agents.provision(
|
|
23
|
+
external_id=f"customer_{user.id}", # your id for this agent
|
|
24
|
+
name=f"assistant-{user.id}",
|
|
25
|
+
template="openclaw",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Talk to it (sleeping agents auto-wake):
|
|
29
|
+
reply = client.agents.chat(agent["id"], "Summarize my unread email.")["response"]
|
|
30
|
+
print(reply)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Authentication
|
|
34
|
+
|
|
35
|
+
Mint an API key (`mk_...`) from the dashboard (**Settings → API keys**) or the CLI (`maritime keys create`), then set `MARITIME_API_KEY`, or pass it explicitly:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
client = Maritime(api_key="mk_...")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Keys carry **scopes** — hand a narrower key to a subsystem that only needs part of the surface:
|
|
42
|
+
|
|
43
|
+
| Scope | Grants |
|
|
44
|
+
|-------|--------|
|
|
45
|
+
| `provision` | create agents |
|
|
46
|
+
| `deploy` | start/stop/restart/sleep/chat |
|
|
47
|
+
| `secrets` | read/write env vars |
|
|
48
|
+
| `manage` | everything, incl. delete + key management (wildcard) |
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
worker = client.keys.create("chat-worker", scopes=["deploy"])
|
|
52
|
+
# worker["raw_key"] is shown once — store it now.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Agents
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
# Create (kicks off deploy). Prefer template — a bare framework yields a broken image.
|
|
59
|
+
agent = client.agents.create(
|
|
60
|
+
"support-bot",
|
|
61
|
+
template="openclaw",
|
|
62
|
+
external_id="customer_42",
|
|
63
|
+
instructions="You are a friendly support agent for Acme Inc.",
|
|
64
|
+
env=[{"key": "ACME_API_KEY", "value": "...", "secret": True}],
|
|
65
|
+
tier="smart", # "smart" | "extended" | "always_on"
|
|
66
|
+
idle_ttl_seconds=3600, # 0 = always-on
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Idempotent get-or-create by external_id (recommended for per-user provisioning)
|
|
70
|
+
agent = client.agents.provision(external_id="customer_42", name="support-bot")
|
|
71
|
+
|
|
72
|
+
client.agents.get(agent["id"])
|
|
73
|
+
client.agents.list(external_id="customer_42") # filter by your id
|
|
74
|
+
client.agents.list() # all of your agents
|
|
75
|
+
|
|
76
|
+
# Chat (synchronous; auto-wakes a sleeping agent)
|
|
77
|
+
result = client.agents.chat(agent["id"], "Hello")
|
|
78
|
+
print(result["response"]) # None + result["error"] if delivery failed
|
|
79
|
+
|
|
80
|
+
# Lifecycle
|
|
81
|
+
client.agents.start(agent["id"])
|
|
82
|
+
client.agents.sleep(agent["id"]) # cheapest resting state (serverless snapshot)
|
|
83
|
+
client.agents.restart(agent["id"])
|
|
84
|
+
client.agents.delete(agent["id"]) # tears down container + volume + network
|
|
85
|
+
|
|
86
|
+
# Env vars (secrets encrypted at rest; reach a running container after reload_env)
|
|
87
|
+
client.agents.set_env(agent["id"], "STRIPE_KEY", "sk_live_...", secret=True)
|
|
88
|
+
client.agents.list_env(agent["id"])
|
|
89
|
+
client.agents.reload_env(agent["id"])
|
|
90
|
+
|
|
91
|
+
# Logs
|
|
92
|
+
client.agents.logs(agent["id"], limit=100, level="error")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
Every failure is a subclass of `MaritimeError` — catch the base to catch them all, or narrow by type:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from maritime import (
|
|
101
|
+
MaritimeAuthError, # 401 / 403 — bad or under-scoped key
|
|
102
|
+
MaritimePaymentRequiredError, # 402 — wallet needs funding
|
|
103
|
+
MaritimeNotFoundError, # 404 — no such agent (or not yours)
|
|
104
|
+
MaritimeConflictError, # 409 — name already taken
|
|
105
|
+
MaritimeRateLimitError, # 429
|
|
106
|
+
MaritimeAPIError, # any other non-2xx (has .status, .detail)
|
|
107
|
+
MaritimeConnectionError, # never reached Maritime (network/timeout)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
client.agents.create("dupe", template="openclaw")
|
|
112
|
+
except MaritimeConflictError:
|
|
113
|
+
... # an agent with that name already exists
|
|
114
|
+
except MaritimeAPIError as err:
|
|
115
|
+
print(err.status, err.detail, err.request_id)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Configuration
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
Maritime(
|
|
122
|
+
api_key="mk_...", # or MARITIME_API_KEY
|
|
123
|
+
base_url="https://api.maritime.sh", # or MARITIME_API_URL
|
|
124
|
+
timeout=60.0, # per-request seconds
|
|
125
|
+
max_retries=2, # network + 5xx/429 (GET/DELETE and 429/503 only)
|
|
126
|
+
default_headers={"x-team": "acme"},
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Retries are safe by construction: `GET`/`DELETE` retry on any transient failure; `POST`/`PUT` retry only on network errors and `429`/`503` (never a `5xx` that might have applied a write).
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Official Python SDK for Maritime.
|
|
2
|
+
|
|
3
|
+
Provision and drive AI agents on Maritime's serverless infrastructure from your
|
|
4
|
+
own backend.
|
|
5
|
+
|
|
6
|
+
from maritime import Maritime
|
|
7
|
+
|
|
8
|
+
client = Maritime() # reads MARITIME_API_KEY
|
|
9
|
+
|
|
10
|
+
# When YOUR user signs up, give them their own agent (idempotent):
|
|
11
|
+
agent = client.agents.provision(
|
|
12
|
+
external_id=f"customer_{user_id}",
|
|
13
|
+
name=f"assistant-{user_id}",
|
|
14
|
+
template="openclaw",
|
|
15
|
+
)
|
|
16
|
+
reply = client.agents.chat(agent["id"], "Hello!")["response"]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
from ._http import HttpClient
|
|
24
|
+
from .errors import (
|
|
25
|
+
MaritimeAPIError,
|
|
26
|
+
MaritimeAuthError,
|
|
27
|
+
MaritimeConflictError,
|
|
28
|
+
MaritimeConnectionError,
|
|
29
|
+
MaritimeError,
|
|
30
|
+
MaritimeNotFoundError,
|
|
31
|
+
MaritimePaymentRequiredError,
|
|
32
|
+
MaritimeRateLimitError,
|
|
33
|
+
)
|
|
34
|
+
from .resources import Agents, Keys, Webhooks
|
|
35
|
+
|
|
36
|
+
__version__ = "0.2.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Maritime",
|
|
40
|
+
"MaritimeError",
|
|
41
|
+
"MaritimeConnectionError",
|
|
42
|
+
"MaritimeAPIError",
|
|
43
|
+
"MaritimeAuthError",
|
|
44
|
+
"MaritimePaymentRequiredError",
|
|
45
|
+
"MaritimeNotFoundError",
|
|
46
|
+
"MaritimeConflictError",
|
|
47
|
+
"MaritimeRateLimitError",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Maritime:
|
|
52
|
+
"""The Maritime client.
|
|
53
|
+
|
|
54
|
+
:param api_key: ``mk_...`` key. Defaults to the ``MARITIME_API_KEY`` env var.
|
|
55
|
+
:param base_url: API base. Defaults to ``MARITIME_API_URL`` or api.maritime.sh.
|
|
56
|
+
:param timeout: per-request timeout (seconds).
|
|
57
|
+
:param max_retries: retries on network errors and 5xx/429.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_key: Optional[str] = None,
|
|
63
|
+
base_url: Optional[str] = None,
|
|
64
|
+
timeout: float = 60.0,
|
|
65
|
+
max_retries: int = 2,
|
|
66
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.http = HttpClient(
|
|
69
|
+
api_key=api_key,
|
|
70
|
+
base_url=base_url,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
max_retries=max_retries,
|
|
73
|
+
default_headers=default_headers,
|
|
74
|
+
)
|
|
75
|
+
self.agents = Agents(self.http)
|
|
76
|
+
self.keys = Keys(self.http)
|
|
77
|
+
self.webhooks = Webhooks(self.http)
|
|
78
|
+
|
|
79
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
80
|
+
"""Escape hatch for endpoints not yet wrapped."""
|
|
81
|
+
return self.http.request(method, path, **kwargs)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Zero-dependency HTTP transport (stdlib urllib) with typed errors + retry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .errors import MaritimeConnectionError, MaritimeError, api_error_from_status
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.maritime.sh"
|
|
16
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpClient:
|
|
20
|
+
"""Thin transport: auth header, JSON encode/decode, typed errors, bounded
|
|
21
|
+
retry with backoff. No third-party dependencies."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_key: Optional[str] = None,
|
|
26
|
+
base_url: Optional[str] = None,
|
|
27
|
+
timeout: float = 60.0,
|
|
28
|
+
max_retries: int = 2,
|
|
29
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
key = api_key or os.environ.get("MARITIME_API_KEY")
|
|
32
|
+
if not key:
|
|
33
|
+
raise MaritimeError(
|
|
34
|
+
"Missing Maritime API key. Pass api_key=... or set MARITIME_API_KEY."
|
|
35
|
+
)
|
|
36
|
+
self._api_key = key
|
|
37
|
+
self._base_url = (base_url or os.environ.get("MARITIME_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._max_retries = max_retries
|
|
40
|
+
self._default_headers = default_headers or {}
|
|
41
|
+
|
|
42
|
+
def _url(self, path: str, query: Optional[Dict[str, Any]]) -> str:
|
|
43
|
+
url = self._base_url + path
|
|
44
|
+
if query:
|
|
45
|
+
clean = {k: v for k, v in query.items() if v is not None}
|
|
46
|
+
if clean:
|
|
47
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
48
|
+
return url
|
|
49
|
+
|
|
50
|
+
def request(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
path: str,
|
|
54
|
+
query: Optional[Dict[str, Any]] = None,
|
|
55
|
+
body: Optional[Any] = None,
|
|
56
|
+
idempotent: Optional[bool] = None,
|
|
57
|
+
) -> Any:
|
|
58
|
+
url = self._url(path, query)
|
|
59
|
+
data = None
|
|
60
|
+
headers = {
|
|
61
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
62
|
+
"Accept": "application/json",
|
|
63
|
+
"User-Agent": "maritime-python-sdk",
|
|
64
|
+
}
|
|
65
|
+
headers.update(self._default_headers)
|
|
66
|
+
if body is not None:
|
|
67
|
+
data = json.dumps(body).encode("utf-8")
|
|
68
|
+
headers["Content-Type"] = "application/json"
|
|
69
|
+
|
|
70
|
+
method = method.upper()
|
|
71
|
+
retry_safe = idempotent if idempotent is not None else method in ("GET", "DELETE")
|
|
72
|
+
|
|
73
|
+
last_err: Optional[Exception] = None
|
|
74
|
+
for attempt in range(self._max_retries + 1):
|
|
75
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
78
|
+
return self._parse(resp.read(), resp.status)
|
|
79
|
+
except urllib.error.HTTPError as e:
|
|
80
|
+
status = e.code
|
|
81
|
+
detail = self._detail(e)
|
|
82
|
+
request_id = e.headers.get("x-request-id") if e.headers else None
|
|
83
|
+
retryable = (
|
|
84
|
+
attempt < self._max_retries
|
|
85
|
+
and status in _RETRYABLE_STATUS
|
|
86
|
+
and (retry_safe or status in (429, 503))
|
|
87
|
+
)
|
|
88
|
+
if retryable:
|
|
89
|
+
last_err = api_error_from_status(status, detail, request_id)
|
|
90
|
+
time.sleep(self._backoff(attempt, e))
|
|
91
|
+
continue
|
|
92
|
+
raise api_error_from_status(status, detail, request_id)
|
|
93
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
94
|
+
last_err = e
|
|
95
|
+
if attempt < self._max_retries:
|
|
96
|
+
time.sleep(self._backoff(attempt))
|
|
97
|
+
continue
|
|
98
|
+
reason = getattr(e, "reason", e)
|
|
99
|
+
raise MaritimeConnectionError(
|
|
100
|
+
f"Failed to reach Maritime at {self._base_url}: {reason}"
|
|
101
|
+
) from e
|
|
102
|
+
|
|
103
|
+
if isinstance(last_err, MaritimeError):
|
|
104
|
+
raise last_err
|
|
105
|
+
raise MaritimeConnectionError(f"Request to {url} failed after retries")
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _backoff(attempt: int, err: Optional[urllib.error.HTTPError] = None) -> float:
|
|
109
|
+
if err is not None and err.headers:
|
|
110
|
+
retry_after = err.headers.get("retry-after")
|
|
111
|
+
if retry_after:
|
|
112
|
+
try:
|
|
113
|
+
return min(float(retry_after), 20.0)
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
return min(0.5 * (2 ** attempt), 8.0)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _parse(raw: bytes, status: int) -> Any:
|
|
120
|
+
if status == 204 or not raw:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
return json.loads(raw.decode("utf-8"))
|
|
124
|
+
except (ValueError, UnicodeDecodeError):
|
|
125
|
+
return raw.decode("utf-8", "replace")
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _detail(err: urllib.error.HTTPError) -> str:
|
|
129
|
+
try:
|
|
130
|
+
payload = json.loads(err.read().decode("utf-8"))
|
|
131
|
+
if isinstance(payload, dict) and "detail" in payload:
|
|
132
|
+
d = payload["detail"]
|
|
133
|
+
return d if isinstance(d, str) else json.dumps(d)
|
|
134
|
+
except Exception: # noqa: BLE001
|
|
135
|
+
pass
|
|
136
|
+
return f"Request failed with status {err.code}"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Exception hierarchy for the Maritime SDK.
|
|
2
|
+
|
|
3
|
+
Catch :class:`MaritimeError` to catch them all, or narrow by subclass.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MaritimeError(Exception):
|
|
12
|
+
"""Base class for every error the SDK raises."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MaritimeConnectionError(MaritimeError):
|
|
16
|
+
"""The request never reached Maritime (DNS, connection, timeout)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MaritimeAPIError(MaritimeError):
|
|
20
|
+
"""Maritime returned a non-2xx response."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, status: int, detail: str, request_id: Optional[str] = None) -> None:
|
|
23
|
+
super().__init__(f"Maritime API error {status}: {detail}")
|
|
24
|
+
self.status = status
|
|
25
|
+
self.detail = detail
|
|
26
|
+
self.request_id = request_id
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MaritimeAuthError(MaritimeAPIError):
|
|
30
|
+
"""401 / 403 — bad or insufficiently-scoped API key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MaritimePaymentRequiredError(MaritimeAPIError):
|
|
34
|
+
"""402 — the account wallet needs funding before this action can proceed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MaritimeNotFoundError(MaritimeAPIError):
|
|
38
|
+
"""404 — the agent (or other resource) does not exist or is not yours."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MaritimeConflictError(MaritimeAPIError):
|
|
42
|
+
"""409 — a uniqueness conflict (e.g. an agent name already exists)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MaritimeRateLimitError(MaritimeAPIError):
|
|
46
|
+
"""429 — rate limited."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def api_error_from_status(status: int, detail: str, request_id: Optional[str] = None) -> MaritimeAPIError:
|
|
50
|
+
"""Build the most specific error subclass for an HTTP status."""
|
|
51
|
+
if status in (401, 403):
|
|
52
|
+
return MaritimeAuthError(status, detail, request_id)
|
|
53
|
+
if status == 402:
|
|
54
|
+
return MaritimePaymentRequiredError(status, detail, request_id)
|
|
55
|
+
if status == 404:
|
|
56
|
+
return MaritimeNotFoundError(status, detail, request_id)
|
|
57
|
+
if status == 409:
|
|
58
|
+
return MaritimeConflictError(status, detail, request_id)
|
|
59
|
+
if status == 429:
|
|
60
|
+
return MaritimeRateLimitError(status, detail, request_id)
|
|
61
|
+
return MaritimeAPIError(status, detail, request_id)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Resource namespaces: ``client.agents`` and ``client.keys``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ._http import HttpClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Agents:
|
|
11
|
+
"""Operations on Maritime agents. Access via ``client.agents``."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, http: HttpClient) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def create(
|
|
17
|
+
self,
|
|
18
|
+
name: str,
|
|
19
|
+
*,
|
|
20
|
+
template: Optional[str] = None,
|
|
21
|
+
external_id: Optional[str] = None,
|
|
22
|
+
description: Optional[str] = None,
|
|
23
|
+
instructions: Optional[str] = None,
|
|
24
|
+
tier: Optional[str] = None,
|
|
25
|
+
env: Optional[List[Dict[str, Any]]] = None,
|
|
26
|
+
mem_mb: Optional[int] = None,
|
|
27
|
+
vcpus: Optional[float] = None,
|
|
28
|
+
idle_ttl_seconds: Optional[int] = None,
|
|
29
|
+
disk_gb: Optional[float] = None,
|
|
30
|
+
github_repo: Optional[str] = None,
|
|
31
|
+
image_name: Optional[str] = None,
|
|
32
|
+
) -> Dict[str, Any]:
|
|
33
|
+
"""Create an agent and kick off its deploy.
|
|
34
|
+
|
|
35
|
+
``env`` items look like ``{"key": ..., "value": ..., "secret": True}``.
|
|
36
|
+
"""
|
|
37
|
+
body: Dict[str, Any] = {
|
|
38
|
+
"name": name,
|
|
39
|
+
"templateId": template,
|
|
40
|
+
"externalId": external_id,
|
|
41
|
+
"description": description,
|
|
42
|
+
"instructions": instructions,
|
|
43
|
+
"tier": tier,
|
|
44
|
+
"memMb": mem_mb,
|
|
45
|
+
"vcpus": vcpus,
|
|
46
|
+
"idleTtlSeconds": idle_ttl_seconds,
|
|
47
|
+
"diskGb": disk_gb,
|
|
48
|
+
"githubRepo": github_repo,
|
|
49
|
+
"imageName": image_name,
|
|
50
|
+
}
|
|
51
|
+
if env:
|
|
52
|
+
body["initialEnvVars"] = [
|
|
53
|
+
{"key": e["key"], "value": e["value"], "isSecret": e.get("secret", True)}
|
|
54
|
+
for e in env
|
|
55
|
+
]
|
|
56
|
+
body = {k: v for k, v in body.items() if v is not None}
|
|
57
|
+
return self._http.request("POST", "/api/agents", body=body)
|
|
58
|
+
|
|
59
|
+
def provision(self, external_id: str, name: str, *, template: str = "openclaw", **kwargs: Any) -> Dict[str, Any]:
|
|
60
|
+
"""Get-or-create an agent by ``external_id`` (idempotent — safe to call
|
|
61
|
+
on every sign-in). Returns the existing agent if one exists."""
|
|
62
|
+
existing = self.list(external_id=external_id)
|
|
63
|
+
if existing:
|
|
64
|
+
return existing[0]
|
|
65
|
+
try:
|
|
66
|
+
return self.create(name, template=template, external_id=external_id, **kwargs)
|
|
67
|
+
except Exception:
|
|
68
|
+
# Lost a race with a concurrent provisioner — re-read.
|
|
69
|
+
raced = self.list(external_id=external_id)
|
|
70
|
+
if raced:
|
|
71
|
+
return raced[0]
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
def get(self, agent_id: str) -> Dict[str, Any]:
|
|
75
|
+
return self._http.request("GET", f"/api/agents/{_enc(agent_id)}")
|
|
76
|
+
|
|
77
|
+
def list(self, *, external_id: Optional[str] = None, name: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
78
|
+
return self._http.request(
|
|
79
|
+
"GET", "/api/agents", query={"externalId": external_id, "name": name}
|
|
80
|
+
) or []
|
|
81
|
+
|
|
82
|
+
def chat(self, agent_id: str, message: str, *, conversation_id: Optional[str] = None) -> Dict[str, Any]:
|
|
83
|
+
"""Send a message and wait for the reply. Sleeping agents auto-wake.
|
|
84
|
+
Returns ``{"response": str | None, "error"?: str}``."""
|
|
85
|
+
return self._http.request(
|
|
86
|
+
"POST",
|
|
87
|
+
f"/api/agents/{_enc(agent_id)}/chat",
|
|
88
|
+
body={"message": message, "conversation_id": conversation_id},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def start(self, agent_id: str) -> Dict[str, Any]:
|
|
92
|
+
return self._lifecycle(agent_id, "start")
|
|
93
|
+
|
|
94
|
+
def stop(self, agent_id: str) -> Dict[str, Any]:
|
|
95
|
+
return self._lifecycle(agent_id, "stop")
|
|
96
|
+
|
|
97
|
+
def sleep(self, agent_id: str) -> Dict[str, Any]:
|
|
98
|
+
return self._lifecycle(agent_id, "sleep")
|
|
99
|
+
|
|
100
|
+
def restart(self, agent_id: str) -> Dict[str, Any]:
|
|
101
|
+
return self._lifecycle(agent_id, "restart")
|
|
102
|
+
|
|
103
|
+
def delete(self, agent_id: str) -> None:
|
|
104
|
+
self._http.request("DELETE", f"/api/agents/{_enc(agent_id)}")
|
|
105
|
+
|
|
106
|
+
def list_env(self, agent_id: str) -> List[Dict[str, Any]]:
|
|
107
|
+
return self._http.request("GET", f"/api/agents/{_enc(agent_id)}/env") or []
|
|
108
|
+
|
|
109
|
+
def set_env(self, agent_id: str, key: str, value: str, *, secret: bool = True) -> Dict[str, Any]:
|
|
110
|
+
"""Set (upsert) an env var. Secrets are encrypted at rest; changes reach
|
|
111
|
+
a running container after :meth:`reload_env` or a restart."""
|
|
112
|
+
return self._http.request(
|
|
113
|
+
"POST",
|
|
114
|
+
f"/api/agents/{_enc(agent_id)}/env",
|
|
115
|
+
body={"key": key, "value": value, "isSecret": secret},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def delete_env(self, agent_id: str, key: str) -> None:
|
|
119
|
+
self._http.request("DELETE", f"/api/agents/{_enc(agent_id)}/env/{_enc(key)}")
|
|
120
|
+
|
|
121
|
+
def reload_env(self, agent_id: str) -> Dict[str, Any]:
|
|
122
|
+
return self._lifecycle(agent_id, "reload-env")
|
|
123
|
+
|
|
124
|
+
def logs(self, agent_id: str, *, limit: Optional[int] = None, level: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
125
|
+
return self._http.request(
|
|
126
|
+
"GET", f"/api/agents/{_enc(agent_id)}/logs", query={"limit": limit, "level": level}
|
|
127
|
+
) or []
|
|
128
|
+
|
|
129
|
+
def _lifecycle(self, agent_id: str, action: str) -> Dict[str, Any]:
|
|
130
|
+
return self._http.request(
|
|
131
|
+
"POST", f"/api/agents/{_enc(agent_id)}/{action}", idempotent=True
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Keys:
|
|
136
|
+
"""Manage API keys programmatically. Access via ``client.keys``."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, http: HttpClient) -> None:
|
|
139
|
+
self._http = http
|
|
140
|
+
|
|
141
|
+
def create(self, name: str, *, scopes: Optional[List[str]] = None, expires_in_days: Optional[int] = None) -> Dict[str, Any]:
|
|
142
|
+
"""Mint a new key. The raw key is in ``["raw_key"]`` — shown once."""
|
|
143
|
+
return self._http.request(
|
|
144
|
+
"POST",
|
|
145
|
+
"/api/v1/keys",
|
|
146
|
+
body={
|
|
147
|
+
"name": name,
|
|
148
|
+
"scopes": scopes or ["provision", "deploy", "secrets", "manage"],
|
|
149
|
+
"expires_in_days": expires_in_days,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
154
|
+
return self._http.request("GET", "/api/v1/keys") or []
|
|
155
|
+
|
|
156
|
+
def revoke(self, key_id: str) -> None:
|
|
157
|
+
self._http.request("DELETE", f"/api/v1/keys/{_enc(key_id)}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Webhooks:
|
|
161
|
+
"""Manage outbound webhook subscriptions. Access via ``client.webhooks``.
|
|
162
|
+
|
|
163
|
+
Subscribe a URL to receive signed agent lifecycle events (agent.deployed,
|
|
164
|
+
agent.error, agent.sleeping, agent.woken, agent.restarted, agent.stopped)
|
|
165
|
+
instead of polling. Each delivery carries ``X-Maritime-Signature:
|
|
166
|
+
sha256=<hmac>`` — verify it with the subscription's secret.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, http: HttpClient) -> None:
|
|
170
|
+
self._http = http
|
|
171
|
+
|
|
172
|
+
def create(self, url: str, *, events: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
173
|
+
"""Create a subscription. The signing ``secret`` is in the result — once."""
|
|
174
|
+
return self._http.request(
|
|
175
|
+
"POST", "/api/v1/webhooks", body={"url": url, "events": events or []}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
179
|
+
return self._http.request("GET", "/api/v1/webhooks") or []
|
|
180
|
+
|
|
181
|
+
def delete(self, subscription_id: str) -> None:
|
|
182
|
+
self._http.request("DELETE", f"/api/v1/webhooks/{_enc(subscription_id)}")
|
|
183
|
+
|
|
184
|
+
def test(self, subscription_id: str) -> Dict[str, Any]:
|
|
185
|
+
"""Deliver a synthetic ``ping`` and return the result."""
|
|
186
|
+
return self._http.request("POST", f"/api/v1/webhooks/{_enc(subscription_id)}/test")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _enc(value: str) -> str:
|
|
190
|
+
from urllib.parse import quote
|
|
191
|
+
|
|
192
|
+
return quote(str(value), safe="")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "maritime"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Official Python SDK for Maritime — provision and drive AI agents on Maritime's serverless infrastructure from your own backend."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Maritime" }]
|
|
13
|
+
keywords = ["maritime", "ai", "agents", "sdk", "openclaw", "serverless"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://maritime.sh"
|
|
29
|
+
Documentation = "https://maritime.sh/docs/build"
|
|
30
|
+
Repository = "https://github.com/mariagorskikh/maritime-sdk"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=7"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["maritime"]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import urllib.error
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from maritime import (
|
|
9
|
+
Maritime,
|
|
10
|
+
MaritimeAuthError,
|
|
11
|
+
MaritimeConflictError,
|
|
12
|
+
MaritimeError,
|
|
13
|
+
MaritimeNotFoundError,
|
|
14
|
+
MaritimePaymentRequiredError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Resp:
|
|
19
|
+
"""Minimal stand-in for the object returned by urlopen()."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status: int, body: Any):
|
|
22
|
+
self.status = status
|
|
23
|
+
self._body = b"" if body is None else json.dumps(body).encode()
|
|
24
|
+
|
|
25
|
+
def read(self):
|
|
26
|
+
return self._body
|
|
27
|
+
|
|
28
|
+
def __enter__(self):
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, *a):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MockTransport:
|
|
36
|
+
"""Patches urllib.request.urlopen to return queued responses + record calls."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, responses: List[Dict[str, Any]]):
|
|
39
|
+
self.responses = responses
|
|
40
|
+
self.calls: List[Dict[str, Any]] = []
|
|
41
|
+
self._i = 0
|
|
42
|
+
|
|
43
|
+
def __call__(self, req, timeout=None):
|
|
44
|
+
self.calls.append({
|
|
45
|
+
"url": req.full_url,
|
|
46
|
+
"method": req.get_method(),
|
|
47
|
+
"body": json.loads(req.data) if req.data else None,
|
|
48
|
+
"headers": {k.lower(): v for k, v in req.headers.items()},
|
|
49
|
+
})
|
|
50
|
+
r = self.responses[min(self._i, len(self.responses) - 1)]
|
|
51
|
+
self._i += 1
|
|
52
|
+
status = r["status"]
|
|
53
|
+
if status >= 400:
|
|
54
|
+
raise urllib.error.HTTPError(
|
|
55
|
+
req.full_url, status, r.get("detail", "err"),
|
|
56
|
+
{"content-type": "application/json"},
|
|
57
|
+
io.BytesIO(json.dumps({"detail": r.get("detail", "err")}).encode()),
|
|
58
|
+
)
|
|
59
|
+
return _Resp(status, r.get("body"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def make_client(monkeypatch):
|
|
64
|
+
def _make(responses, **kw):
|
|
65
|
+
transport = MockTransport(responses)
|
|
66
|
+
monkeypatch.setattr("urllib.request.urlopen", transport)
|
|
67
|
+
client = Maritime(api_key="mk_test", base_url="https://api.example.test", max_retries=kw.get("max_retries", 2))
|
|
68
|
+
return client, transport
|
|
69
|
+
return _make
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_missing_api_key_raises(monkeypatch):
|
|
73
|
+
monkeypatch.delenv("MARITIME_API_KEY", raising=False)
|
|
74
|
+
with pytest.raises(MaritimeError):
|
|
75
|
+
Maritime()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_bearer_and_json(make_client):
|
|
79
|
+
client, t = make_client([{"status": 200, "body": [{"id": "a1"}]}])
|
|
80
|
+
agents = client.agents.list()
|
|
81
|
+
assert agents == [{"id": "a1"}]
|
|
82
|
+
assert t.calls[0]["headers"]["authorization"] == "Bearer mk_test"
|
|
83
|
+
assert t.calls[0]["url"] == "https://api.example.test/api/agents"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_create_body_camelcase(make_client):
|
|
87
|
+
client, t = make_client([{"status": 201, "body": {"id": "a1"}}])
|
|
88
|
+
client.agents.create("x", template="openclaw", external_id="c1", env=[{"key": "F", "value": "b"}])
|
|
89
|
+
body = t.calls[0]["body"]
|
|
90
|
+
assert body["templateId"] == "openclaw"
|
|
91
|
+
assert body["externalId"] == "c1"
|
|
92
|
+
assert body["initialEnvVars"] == [{"key": "F", "value": "b", "isSecret": True}]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_chat_conversation_id(make_client):
|
|
96
|
+
client, t = make_client([{"status": 200, "body": {"response": "hi"}}])
|
|
97
|
+
r = client.agents.chat("a1", "hello", conversation_id="c1")
|
|
98
|
+
assert r["response"] == "hi"
|
|
99
|
+
assert t.calls[0]["body"] == {"message": "hello", "conversation_id": "c1"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_list_external_id_query(make_client):
|
|
103
|
+
client, t = make_client([{"status": 200, "body": []}])
|
|
104
|
+
client.agents.list(external_id="cust_9")
|
|
105
|
+
assert "externalId=cust_9" in t.calls[0]["url"]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_delete_204(make_client):
|
|
109
|
+
client, t = make_client([{"status": 204}])
|
|
110
|
+
assert client.agents.delete("a1") is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.mark.parametrize("status,klass", [
|
|
114
|
+
(401, MaritimeAuthError),
|
|
115
|
+
(403, MaritimeAuthError),
|
|
116
|
+
(402, MaritimePaymentRequiredError),
|
|
117
|
+
(404, MaritimeNotFoundError),
|
|
118
|
+
(409, MaritimeConflictError),
|
|
119
|
+
])
|
|
120
|
+
def test_typed_errors(make_client, status, klass):
|
|
121
|
+
client, _ = make_client([{"status": status, "detail": "boom"}])
|
|
122
|
+
with pytest.raises(klass):
|
|
123
|
+
client.agents.get("a1")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_error_exposes_status_detail(make_client):
|
|
127
|
+
client, _ = make_client([{"status": 404, "detail": "no such agent"}])
|
|
128
|
+
with pytest.raises(MaritimeNotFoundError) as exc:
|
|
129
|
+
client.agents.get("a1")
|
|
130
|
+
assert exc.value.status == 404
|
|
131
|
+
assert exc.value.detail == "no such agent"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_retry_503_then_ok(make_client):
|
|
135
|
+
client, t = make_client([{"status": 503, "detail": "down"}, {"status": 200, "body": [{"id": "ok"}]}])
|
|
136
|
+
assert client.agents.list() == [{"id": "ok"}]
|
|
137
|
+
assert len(t.calls) == 2
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_no_retry_post_500(make_client):
|
|
141
|
+
client, t = make_client([{"status": 500, "detail": "err"}, {"status": 201, "body": {"id": "nope"}}], max_retries=3)
|
|
142
|
+
with pytest.raises(MaritimeError) as exc:
|
|
143
|
+
client.agents.create("x", template="openclaw")
|
|
144
|
+
assert exc.value.status == 500
|
|
145
|
+
assert len(t.calls) == 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_webhooks_create_list_delete_test(make_client):
|
|
149
|
+
client, t = make_client([
|
|
150
|
+
{"status": 201, "body": {"id": "wh1", "url": "https://x.test/h", "events": ["agent.error"], "secret": "whsec_x"}},
|
|
151
|
+
{"status": 200, "body": []},
|
|
152
|
+
{"status": 204},
|
|
153
|
+
{"status": 200, "body": {"delivered": True, "status_code": 200}},
|
|
154
|
+
])
|
|
155
|
+
created = client.webhooks.create("https://x.test/h", events=["agent.error"])
|
|
156
|
+
assert created["secret"] == "whsec_x"
|
|
157
|
+
assert t.calls[0]["url"].endswith("/api/v1/webhooks")
|
|
158
|
+
assert t.calls[0]["body"] == {"url": "https://x.test/h", "events": ["agent.error"]}
|
|
159
|
+
|
|
160
|
+
client.webhooks.list()
|
|
161
|
+
assert t.calls[1]["method"] == "GET"
|
|
162
|
+
|
|
163
|
+
client.webhooks.delete("wh1")
|
|
164
|
+
assert t.calls[2]["method"] == "DELETE"
|
|
165
|
+
assert t.calls[2]["url"].endswith("/api/v1/webhooks/wh1")
|
|
166
|
+
|
|
167
|
+
r = client.webhooks.test("wh1")
|
|
168
|
+
assert r["delivered"] is True
|
|
169
|
+
assert t.calls[3]["url"].endswith("/api/v1/webhooks/wh1/test")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_provision_returns_existing(make_client):
|
|
173
|
+
client, t = make_client([{"status": 200, "body": [{"id": "existing", "externalId": "c1"}]}])
|
|
174
|
+
a = client.agents.provision(external_id="c1", name="x")
|
|
175
|
+
assert a["id"] == "existing"
|
|
176
|
+
assert len(t.calls) == 1 # only the list, no create
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_provision_creates_when_absent(make_client):
|
|
180
|
+
client, t = make_client([
|
|
181
|
+
{"status": 200, "body": []},
|
|
182
|
+
{"status": 201, "body": {"id": "new", "externalId": "c2"}},
|
|
183
|
+
])
|
|
184
|
+
a = client.agents.provision(external_id="c2", name="x")
|
|
185
|
+
assert a["id"] == "new"
|
|
186
|
+
assert len(t.calls) == 2
|
|
187
|
+
assert t.calls[1]["body"]["templateId"] == "openclaw"
|