mowa 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.
- mowa/__init__.py +3 -0
- mowa/client.py +318 -0
- mowa/py.typed +0 -0
- mowa-0.1.0.dist-info/METADATA +88 -0
- mowa-0.1.0.dist-info/RECORD +6 -0
- mowa-0.1.0.dist-info/WHEEL +4 -0
mowa/__init__.py
ADDED
mowa/client.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Union
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://mowa.dev"
|
|
17
|
+
|
|
18
|
+
Transport = Callable[[str, Mapping[str, str], float], Tuple[int, Mapping[str, str], bytes]]
|
|
19
|
+
VersionSelector = Union[str, int]
|
|
20
|
+
CacheKey = Tuple[str, str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MowaError(Exception):
|
|
24
|
+
def __init__(self, message: str, status: Optional[int] = None, code: Optional[str] = None) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Agent:
|
|
33
|
+
slug: str
|
|
34
|
+
version: str
|
|
35
|
+
system_prompt: str
|
|
36
|
+
model: str
|
|
37
|
+
provider: Optional[str] = None
|
|
38
|
+
name: Optional[str] = None
|
|
39
|
+
settings: Dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
tools: List[Any] = field(default_factory=list)
|
|
41
|
+
inputs: List[Any] = field(default_factory=list)
|
|
42
|
+
outputs: List[Any] = field(default_factory=list)
|
|
43
|
+
source: str = "network"
|
|
44
|
+
|
|
45
|
+
def trace_attributes(self) -> Dict[str, str]:
|
|
46
|
+
return {"mowa.agent": self.slug, "mowa.version": self.version}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class _CacheEntry:
|
|
51
|
+
agent: Agent
|
|
52
|
+
etag: Optional[str]
|
|
53
|
+
fetched_at: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def urllib_transport(url: str, headers: Mapping[str, str], timeout: float) -> Tuple[int, Mapping[str, str], bytes]:
|
|
57
|
+
request = urllib.request.Request(url, headers=dict(headers), method="GET")
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
60
|
+
return response.status, dict(response.headers.items()), response.read()
|
|
61
|
+
except urllib.error.HTTPError as error:
|
|
62
|
+
try:
|
|
63
|
+
body = error.read()
|
|
64
|
+
except Exception:
|
|
65
|
+
body = b""
|
|
66
|
+
response_headers = dict(error.headers.items()) if error.headers is not None else {}
|
|
67
|
+
return error.code, response_headers, body or b""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HttpxTransport:
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
import httpx
|
|
73
|
+
|
|
74
|
+
self._client = httpx.Client(follow_redirects=True)
|
|
75
|
+
|
|
76
|
+
def __call__(self, url: str, headers: Mapping[str, str], timeout: float) -> Tuple[int, Mapping[str, str], bytes]:
|
|
77
|
+
response = self._client.get(url, headers=dict(headers), timeout=timeout)
|
|
78
|
+
return response.status_code, dict(response.headers.items()), response.content
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_transport() -> Transport:
|
|
82
|
+
try:
|
|
83
|
+
return HttpxTransport()
|
|
84
|
+
except ImportError:
|
|
85
|
+
return urllib_transport
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def version_key(version: VersionSelector) -> str:
|
|
89
|
+
if isinstance(version, bool):
|
|
90
|
+
raise ValueError("version must be \"live\", \"draft\" or an int like 3.")
|
|
91
|
+
if isinstance(version, int):
|
|
92
|
+
if version < 1:
|
|
93
|
+
raise ValueError("version must be 1 or higher.")
|
|
94
|
+
return f"v{version}"
|
|
95
|
+
if isinstance(version, str):
|
|
96
|
+
cleaned = version.strip().lower()
|
|
97
|
+
if cleaned in ("live", "draft"):
|
|
98
|
+
return cleaned
|
|
99
|
+
if cleaned.startswith("v") and cleaned[1:].isdigit():
|
|
100
|
+
return cleaned
|
|
101
|
+
if cleaned.isdigit():
|
|
102
|
+
return f"v{cleaned}"
|
|
103
|
+
raise ValueError("version must be \"live\", \"draft\" or an int like 3.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def provider_from_model(model: str) -> Optional[str]:
|
|
107
|
+
if "/" in model:
|
|
108
|
+
provider = model.split("/", 1)[0].strip()
|
|
109
|
+
return provider or None
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _lowercase_headers(headers: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
114
|
+
return {str(name).lower(): str(value) for name, value in (headers or {}).items()}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _error_from_body(slug: str, status: int, body: bytes) -> MowaError:
|
|
118
|
+
message = f"HTTP {status}"
|
|
119
|
+
code: Optional[str] = None
|
|
120
|
+
try:
|
|
121
|
+
payload = json.loads(body.decode("utf-8")) if body else None
|
|
122
|
+
except (ValueError, UnicodeDecodeError):
|
|
123
|
+
payload = None
|
|
124
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
125
|
+
error = payload["error"]
|
|
126
|
+
if error.get("message"):
|
|
127
|
+
message = f"{error['message']} (HTTP {status})"
|
|
128
|
+
if error.get("code"):
|
|
129
|
+
code = str(error["code"])
|
|
130
|
+
return MowaError(f"Could not load agent \"{slug}\": {message}", status=status, code=code)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _agent_from_payload(slug: str, requested_version: str, payload: Any) -> Agent:
|
|
134
|
+
if not isinstance(payload, dict):
|
|
135
|
+
raise MowaError(f"Could not load agent \"{slug}\": response was not a JSON object.")
|
|
136
|
+
model = str(payload.get("model") or "")
|
|
137
|
+
return Agent(
|
|
138
|
+
slug=str(payload.get("slug") or slug),
|
|
139
|
+
version=str(payload.get("version") or requested_version),
|
|
140
|
+
system_prompt=str(payload.get("systemPrompt") or ""),
|
|
141
|
+
model=model,
|
|
142
|
+
provider=payload.get("provider") or provider_from_model(model),
|
|
143
|
+
name=payload.get("name"),
|
|
144
|
+
settings=dict(payload.get("settings") or {}),
|
|
145
|
+
tools=list(payload.get("tools") or []),
|
|
146
|
+
inputs=list(payload.get("inputs") or []),
|
|
147
|
+
outputs=list(payload.get("outputs") or []),
|
|
148
|
+
source="network",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def agent_from_fallback(slug: str, fallback: Mapping[str, Any]) -> Agent:
|
|
153
|
+
model = str(fallback.get("model") or "")
|
|
154
|
+
return Agent(
|
|
155
|
+
slug=slug,
|
|
156
|
+
version="fallback",
|
|
157
|
+
system_prompt=str(fallback.get("system_prompt") or ""),
|
|
158
|
+
model=model,
|
|
159
|
+
provider=fallback.get("provider") or provider_from_model(model),
|
|
160
|
+
settings=dict(fallback.get("settings") or {}),
|
|
161
|
+
source="fallback",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Mowa:
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
api_key: Optional[str] = None,
|
|
169
|
+
base_url: Optional[str] = None,
|
|
170
|
+
cache_ttl: float = 60.0,
|
|
171
|
+
timeout: float = 2.0,
|
|
172
|
+
transport: Optional[Transport] = None,
|
|
173
|
+
clock: Optional[Callable[[], float]] = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
resolved_key = api_key if api_key is not None else os.environ.get("MOWA_API_KEY")
|
|
176
|
+
self.api_key = resolved_key.strip() if resolved_key else None
|
|
177
|
+
resolved_base = base_url or os.environ.get("MOWA_BASE_URL") or DEFAULT_BASE_URL
|
|
178
|
+
self.base_url = resolved_base.strip().rstrip("/")
|
|
179
|
+
self.cache_ttl = float(cache_ttl)
|
|
180
|
+
self.timeout = float(timeout)
|
|
181
|
+
self._transport = transport
|
|
182
|
+
self._clock = clock or time.monotonic
|
|
183
|
+
self._lock = threading.Lock()
|
|
184
|
+
self._cache: Dict[CacheKey, _CacheEntry] = {}
|
|
185
|
+
self._refreshing: Set[CacheKey] = set()
|
|
186
|
+
self._refresh_threads: List[threading.Thread] = []
|
|
187
|
+
|
|
188
|
+
def get_agent(
|
|
189
|
+
self,
|
|
190
|
+
slug: str,
|
|
191
|
+
version: VersionSelector = "live",
|
|
192
|
+
fallback: Optional[Mapping[str, Any]] = None,
|
|
193
|
+
) -> Agent:
|
|
194
|
+
key = (slug, version_key(version))
|
|
195
|
+
with self._lock:
|
|
196
|
+
entry = self._cache.get(key)
|
|
197
|
+
if entry is not None:
|
|
198
|
+
if self._clock() - entry.fetched_at < self.cache_ttl:
|
|
199
|
+
return replace(entry.agent, source="cache")
|
|
200
|
+
stale_agent = replace(entry.agent, source="stale")
|
|
201
|
+
else:
|
|
202
|
+
stale_agent = None
|
|
203
|
+
if stale_agent is not None:
|
|
204
|
+
self._refresh_in_background(key)
|
|
205
|
+
return stale_agent
|
|
206
|
+
try:
|
|
207
|
+
return self._fetch_and_store(key, etag=None)
|
|
208
|
+
except Exception as error:
|
|
209
|
+
if fallback is not None:
|
|
210
|
+
return agent_from_fallback(slug, fallback)
|
|
211
|
+
if isinstance(error, MowaError):
|
|
212
|
+
raise
|
|
213
|
+
reason = str(error) or type(error).__name__
|
|
214
|
+
raise MowaError(f"Could not load agent \"{slug}\": {reason}") from error
|
|
215
|
+
|
|
216
|
+
def prefetch(self, slugs: Iterable[str], version: VersionSelector = "live") -> None:
|
|
217
|
+
unique_slugs = list(dict.fromkeys(slugs))
|
|
218
|
+
if not unique_slugs:
|
|
219
|
+
return
|
|
220
|
+
with ThreadPoolExecutor(max_workers=min(8, len(unique_slugs))) as pool:
|
|
221
|
+
for future in [pool.submit(self._prefetch_one, slug, version) for slug in unique_slugs]:
|
|
222
|
+
future.result()
|
|
223
|
+
|
|
224
|
+
def clear_cache(self) -> None:
|
|
225
|
+
with self._lock:
|
|
226
|
+
self._cache.clear()
|
|
227
|
+
|
|
228
|
+
def wait_for_refresh(self, timeout: Optional[float] = 5.0) -> bool:
|
|
229
|
+
with self._lock:
|
|
230
|
+
threads = list(self._refresh_threads)
|
|
231
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
232
|
+
for thread in threads:
|
|
233
|
+
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
234
|
+
thread.join(remaining)
|
|
235
|
+
with self._lock:
|
|
236
|
+
self._refresh_threads = [thread for thread in self._refresh_threads if thread.is_alive()]
|
|
237
|
+
return not self._refresh_threads
|
|
238
|
+
|
|
239
|
+
def _prefetch_one(self, slug: str, version: VersionSelector) -> None:
|
|
240
|
+
try:
|
|
241
|
+
self.get_agent(slug, version=version)
|
|
242
|
+
except MowaError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def _refresh_in_background(self, key: CacheKey) -> None:
|
|
246
|
+
with self._lock:
|
|
247
|
+
if key in self._refreshing:
|
|
248
|
+
return
|
|
249
|
+
self._refreshing.add(key)
|
|
250
|
+
thread = threading.Thread(
|
|
251
|
+
target=self._refresh,
|
|
252
|
+
args=(key,),
|
|
253
|
+
name=f"mowa-refresh-{key[0]}-{key[1]}",
|
|
254
|
+
daemon=True,
|
|
255
|
+
)
|
|
256
|
+
self._refresh_threads = [alive for alive in self._refresh_threads if alive.is_alive()]
|
|
257
|
+
self._refresh_threads.append(thread)
|
|
258
|
+
thread.start()
|
|
259
|
+
|
|
260
|
+
def _refresh(self, key: CacheKey) -> None:
|
|
261
|
+
try:
|
|
262
|
+
with self._lock:
|
|
263
|
+
entry = self._cache.get(key)
|
|
264
|
+
etag = entry.etag if entry is not None else None
|
|
265
|
+
self._fetch_and_store(key, etag=etag)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
finally:
|
|
269
|
+
with self._lock:
|
|
270
|
+
self._refreshing.discard(key)
|
|
271
|
+
|
|
272
|
+
def _url_for(self, key: CacheKey) -> str:
|
|
273
|
+
slug, selected = key
|
|
274
|
+
url = f"{self.base_url}/api/v1/agents/{quote(slug, safe='')}/live"
|
|
275
|
+
if selected != "live":
|
|
276
|
+
url += f"?version={selected}"
|
|
277
|
+
return url
|
|
278
|
+
|
|
279
|
+
def _headers_for(self, etag: Optional[str]) -> Dict[str, str]:
|
|
280
|
+
headers = {
|
|
281
|
+
"accept": "application/json",
|
|
282
|
+
"x-api-key": self.api_key or "",
|
|
283
|
+
"x-mowa-sdk": f"py/{__version__}",
|
|
284
|
+
"user-agent": f"mowa-python/{__version__}",
|
|
285
|
+
}
|
|
286
|
+
if etag:
|
|
287
|
+
headers["if-none-match"] = etag
|
|
288
|
+
return headers
|
|
289
|
+
|
|
290
|
+
def _send(self, url: str, headers: Mapping[str, str]) -> Tuple[int, Mapping[str, str], bytes]:
|
|
291
|
+
if self._transport is None:
|
|
292
|
+
self._transport = default_transport()
|
|
293
|
+
return self._transport(url, headers, self.timeout)
|
|
294
|
+
|
|
295
|
+
def _fetch_and_store(self, key: CacheKey, etag: Optional[str]) -> Agent:
|
|
296
|
+
slug, selected = key
|
|
297
|
+
if not self.api_key:
|
|
298
|
+
raise MowaError("Set MOWA_API_KEY or pass api_key.")
|
|
299
|
+
status, raw_headers, body = self._send(self._url_for(key), self._headers_for(etag))
|
|
300
|
+
response_headers = _lowercase_headers(raw_headers)
|
|
301
|
+
if status == 304:
|
|
302
|
+
with self._lock:
|
|
303
|
+
entry = self._cache.get(key)
|
|
304
|
+
if entry is None:
|
|
305
|
+
raise MowaError(f"Could not load agent \"{slug}\": got 304 with no cached copy.", status=304)
|
|
306
|
+
entry.fetched_at = self._clock()
|
|
307
|
+
return entry.agent
|
|
308
|
+
if not 200 <= status < 300:
|
|
309
|
+
raise _error_from_body(slug, status, body)
|
|
310
|
+
try:
|
|
311
|
+
payload = json.loads(body.decode("utf-8"))
|
|
312
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
313
|
+
raise MowaError(f"Could not load agent \"{slug}\": response was not valid JSON.") from error
|
|
314
|
+
agent = _agent_from_payload(slug, selected, payload)
|
|
315
|
+
new_etag = response_headers.get("etag") or (payload.get("etag") if isinstance(payload, dict) else None)
|
|
316
|
+
with self._lock:
|
|
317
|
+
self._cache[key] = _CacheEntry(agent=agent, etag=new_etag, fetched_at=self._clock())
|
|
318
|
+
return agent
|
mowa/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mowa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Load your Mowa agents at runtime.
|
|
5
|
+
Project-URL: Homepage, https://mowa.dev
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Provides-Extra: httpx
|
|
12
|
+
Requires-Dist: httpx>=0.24; extra == 'httpx'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# mowa
|
|
16
|
+
|
|
17
|
+
Load your Mowa agents at runtime. Edit the prompt in Mowa, ship it without a deploy.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install mowa
|
|
23
|
+
pip install "mowa[httpx]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No required deps. Uses `httpx` if it's installed, else the standard library.
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from openai import OpenAI
|
|
32
|
+
from mowa import Mowa
|
|
33
|
+
|
|
34
|
+
mowa = Mowa()
|
|
35
|
+
openai = OpenAI()
|
|
36
|
+
|
|
37
|
+
agent = mowa.get_agent(
|
|
38
|
+
"support",
|
|
39
|
+
fallback={"system_prompt": "You are a helpful support agent.", "model": "gpt-4o-mini"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
reply = openai.chat.completions.create(
|
|
43
|
+
model=agent.model,
|
|
44
|
+
messages=[
|
|
45
|
+
{"role": "system", "content": agent.system_prompt},
|
|
46
|
+
{"role": "user", "content": "Where is my order?"},
|
|
47
|
+
],
|
|
48
|
+
temperature=agent.settings.get("temperature"),
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`Mowa()` reads `MOWA_API_KEY` and `MOWA_BASE_URL` from the environment. You can also pass `api_key`, `base_url`, `cache_ttl` (default 60s) and `timeout` (default 2s).
|
|
53
|
+
|
|
54
|
+
## Versions
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
mowa.get_agent("support")
|
|
58
|
+
mowa.get_agent("support", version="draft")
|
|
59
|
+
mowa.get_agent("support", version=3)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Caching and fallback
|
|
63
|
+
|
|
64
|
+
- First call fetches the agent and keeps it in memory.
|
|
65
|
+
- For the next `cache_ttl` seconds you get the cached copy. No network.
|
|
66
|
+
- After that you still get the cached copy right away, and it refreshes in the background.
|
|
67
|
+
- If the first fetch fails and you passed a `fallback`, you get the fallback. It never raises.
|
|
68
|
+
- With no fallback, it raises `MowaError`.
|
|
69
|
+
|
|
70
|
+
`agent.source` tells you where it came from: `network`, `cache`, `stale` or `fallback`.
|
|
71
|
+
|
|
72
|
+
Warm the cache at startup with `mowa.prefetch(["support", "billing"])`. Reset it with `mowa.clear_cache()`.
|
|
73
|
+
|
|
74
|
+
## Traces
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
span.set_attributes(agent.trace_attributes())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Gives `{"mowa.agent": "support", "mowa.version": "v3"}`, so traces link back to the version that ran.
|
|
81
|
+
|
|
82
|
+
## Raw HTTP
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
curl https://mowa.dev/api/v1/agents/support/live -H "x-api-key: $MOWA_API_KEY"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Add `?version=draft` or `?version=v3`. Send `If-None-Match` with the `ETag` to get a `304` when nothing changed.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
mowa/__init__.py,sha256=zMh_QIRsScpg5manL4LEOGAufPNxPHgDfhQ92pnWNlc,137
|
|
2
|
+
mowa/client.py,sha256=kxMXat_HsiwcZ3V4jPGqzU7iIMEYnWjsZNyNLNONVrM,12007
|
|
3
|
+
mowa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
mowa-0.1.0.dist-info/METADATA,sha256=qiii4VXDyJfgl_AGu89hvtt2-XP9P6TkpsnOXJpWbZs,2413
|
|
5
|
+
mowa-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
mowa-0.1.0.dist-info/RECORD,,
|