matrx-api 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.
- matrx_api/__init__.py +31 -0
- matrx_api/client.py +420 -0
- matrx_api/credentials.py +105 -0
- matrx_api/deployments.py +122 -0
- matrx_api/errors.py +88 -0
- matrx_api/generated/__init__.py +1 -0
- matrx_api/generated/account.py +1110 -0
- matrx_api/generated/agents.py +1000 -0
- matrx_api/generated/ai.py +181 -0
- matrx_api/generated/apps.py +651 -0
- matrx_api/generated/architecture.py +2071 -0
- matrx_api/generated/audio.py +371 -0
- matrx_api/generated/chat.py +900 -0
- matrx_api/generated/code.py +951 -0
- matrx_api/generated/commerce.py +30 -0
- matrx_api/generated/communications.py +460 -0
- matrx_api/generated/computer.py +541 -0
- matrx_api/generated/content.py +700 -0
- matrx_api/generated/crm.py +440 -0
- matrx_api/generated/data.py +630 -0
- matrx_api/generated/education.py +40 -0
- matrx_api/generated/files.py +2261 -0
- matrx_api/generated/human_resources.py +290 -0
- matrx_api/generated/integrations.py +8920 -0
- matrx_api/generated/intelligence.py +1000 -0
- matrx_api/generated/knowledge.py +1910 -0
- matrx_api/generated/legal.py +370 -0
- matrx_api/generated/marketing.py +2300 -0
- matrx_api/generated/masterwork.py +510 -0
- matrx_api/generated/media.py +1021 -0
- matrx_api/generated/platform.py +250 -0
- matrx_api/generated/publish.py +410 -0
- matrx_api/generated/web.py +1771 -0
- matrx_api/generated/workflows.py +1411 -0
- matrx_api/streams.py +109 -0
- matrx_api-0.1.0.dist-info/METADATA +56 -0
- matrx_api-0.1.0.dist-info/RECORD +38 -0
- matrx_api-0.1.0.dist-info/WHEEL +4 -0
matrx_api/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""matrx-api: the Python client of every AI Matrx server, generated from the server contract."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
ApiStream,
|
|
5
|
+
CallOptions,
|
|
6
|
+
JsonOrStream,
|
|
7
|
+
MatrxApi,
|
|
8
|
+
Multipart,
|
|
9
|
+
OperationDescriptor,
|
|
10
|
+
RequestInfo,
|
|
11
|
+
)
|
|
12
|
+
from .credentials import (
|
|
13
|
+
ApiKeyCredential,
|
|
14
|
+
GuestCredential,
|
|
15
|
+
MATRX_HEADERS,
|
|
16
|
+
ScopedCredential,
|
|
17
|
+
ServiceCredential,
|
|
18
|
+
UserCredential,
|
|
19
|
+
credential_to_headers,
|
|
20
|
+
)
|
|
21
|
+
from .deployments import CONTRACT_SHA256, DEPLOYMENTS, DOMAINS
|
|
22
|
+
from .errors import MatrxApiError, OrganizationContextError
|
|
23
|
+
from .streams import NdjsonFramer, SseFrame, SseFramer
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ApiKeyCredential", "ApiStream", "CONTRACT_SHA256", "CallOptions", "DEPLOYMENTS", "DOMAINS",
|
|
27
|
+
"GuestCredential", "JsonOrStream", "MATRX_HEADERS", "MatrxApi", "MatrxApiError", "Multipart",
|
|
28
|
+
"NdjsonFramer", "OperationDescriptor", "OrganizationContextError", "RequestInfo",
|
|
29
|
+
"ScopedCredential", "ServiceCredential", "SseFrame", "SseFramer", "UserCredential",
|
|
30
|
+
"credential_to_headers",
|
|
31
|
+
]
|
matrx_api/client.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""``MatrxApi`` - the ONE client of every AI Matrx server (Python sister of ``createApi``).
|
|
2
|
+
|
|
3
|
+
Every generated operation hands this client a small :class:`OperationDescriptor` straight from the
|
|
4
|
+
server contract (method, path template, deployment, auth tier, org rule, stream kind). The client owns the rest:
|
|
5
|
+
|
|
6
|
+
- base URL per deployment (defaults from api-deployments.json, overridable per deployment);
|
|
7
|
+
- the injected credentials (five kinds, ``credentials.py``), re-read on every call;
|
|
8
|
+
- the organization binding, honouring each operation's ``x-matrx-org``: ``required`` is refused BEFORE
|
|
9
|
+
the wire when absent, ``optional`` sends one when bound, ``exempt`` never sends one;
|
|
10
|
+
- ``X-Request-Id`` (unique per call);
|
|
11
|
+
- reads (GET/HEAD) retry with backoff on transport failure; writes NEVER retry;
|
|
12
|
+
- one error model (:class:`MatrxApiError`);
|
|
13
|
+
- NDJSON / SSE / bytes streams as async iterators.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Generic, Literal, TypeVar
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from .credentials import MATRX_HEADERS, CredentialsSetting, credential_to_headers, resolve_credential
|
|
28
|
+
from .deployments import DEPLOYMENTS
|
|
29
|
+
from .errors import MatrxApiError, OrganizationContextError
|
|
30
|
+
from .streams import NdjsonFramer, SseFramer, SseFrame
|
|
31
|
+
|
|
32
|
+
Environment = Literal["production", "localhost"]
|
|
33
|
+
StreamKind = Literal["none", "ndjson", "sse", "bytes", "ndjson_or_json", "sse_or_json", "bytes_or_json"]
|
|
34
|
+
|
|
35
|
+
DEFAULT_CONNECT_TIMEOUT = 15.0
|
|
36
|
+
E = TypeVar("E")
|
|
37
|
+
J = TypeVar("J")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class OperationDescriptor:
|
|
42
|
+
method: str
|
|
43
|
+
path: str
|
|
44
|
+
deployment: str
|
|
45
|
+
auth: str
|
|
46
|
+
org: str
|
|
47
|
+
stream: str # "none" or a StreamKind
|
|
48
|
+
name: str # "<domain>.<op>"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class CallOptions:
|
|
53
|
+
organization_id: str | None = None
|
|
54
|
+
headers: Mapping[str, str] | None = None
|
|
55
|
+
connect_timeout: float | None = None
|
|
56
|
+
total_timeout: float | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class Multipart:
|
|
61
|
+
"""A multipart/form-data body (``data`` fields + ``files`` as httpx accepts them)."""
|
|
62
|
+
|
|
63
|
+
data: Mapping[str, Any] | None = None
|
|
64
|
+
files: Any = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class RequestInfo:
|
|
69
|
+
operation: str
|
|
70
|
+
method: str
|
|
71
|
+
url: str
|
|
72
|
+
request_id: str
|
|
73
|
+
deployment: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _interpolate(template: str, params: Mapping[str, Any] | None) -> str:
|
|
77
|
+
import re
|
|
78
|
+
from urllib.parse import quote
|
|
79
|
+
|
|
80
|
+
def sub(m: "re.Match[str]") -> str:
|
|
81
|
+
name = m.group(1)
|
|
82
|
+
value = (params or {}).get(name)
|
|
83
|
+
if value is None or str(value) == "":
|
|
84
|
+
raise TypeError(f'Missing path parameter "{name}" for {template}')
|
|
85
|
+
return quote(str(value), safe="")
|
|
86
|
+
|
|
87
|
+
return re.sub(r"\{([^}]+)\}", sub, template)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _query_pairs(query: Mapping[str, Any] | None) -> list[tuple[str, str]]:
|
|
91
|
+
out: list[tuple[str, str]] = []
|
|
92
|
+
for key, raw in (query or {}).items():
|
|
93
|
+
if raw is None:
|
|
94
|
+
continue
|
|
95
|
+
for v in raw if isinstance(raw, (list, tuple)) else [raw]:
|
|
96
|
+
if v is None:
|
|
97
|
+
continue
|
|
98
|
+
if isinstance(v, bool):
|
|
99
|
+
out.append((key, "true" if v else "false"))
|
|
100
|
+
elif isinstance(v, (dict, list)):
|
|
101
|
+
out.append((key, json.dumps(v)))
|
|
102
|
+
else:
|
|
103
|
+
out.append((key, str(v)))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _accept(stream: str) -> str:
|
|
108
|
+
return {
|
|
109
|
+
"ndjson": "application/x-ndjson",
|
|
110
|
+
"sse": "text/event-stream",
|
|
111
|
+
"ndjson_or_json": "application/x-ndjson, application/json",
|
|
112
|
+
"sse_or_json": "text/event-stream, application/json",
|
|
113
|
+
"bytes": "*/*",
|
|
114
|
+
"bytes_or_json": "*/*, application/json",
|
|
115
|
+
}.get(stream, "application/json")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ApiStream(Generic[E]):
|
|
119
|
+
"""An open streaming response. Iterate with ``async for``; ``aclose()`` (or the context manager) releases it."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, response: httpx.Response, request_id: str, kind: str) -> None:
|
|
122
|
+
self.response = response
|
|
123
|
+
self.request_id = request_id
|
|
124
|
+
self._kind = kind
|
|
125
|
+
self._started = False
|
|
126
|
+
|
|
127
|
+
async def _iterate(self) -> AsyncIterator[Any]:
|
|
128
|
+
try:
|
|
129
|
+
if self._kind.startswith("ndjson"):
|
|
130
|
+
nd = NdjsonFramer()
|
|
131
|
+
async for chunk in self.response.aiter_bytes():
|
|
132
|
+
for frame in nd.push_bytes(chunk):
|
|
133
|
+
yield frame
|
|
134
|
+
for frame in nd.finish():
|
|
135
|
+
yield frame
|
|
136
|
+
elif self._kind.startswith("sse"):
|
|
137
|
+
sse = SseFramer()
|
|
138
|
+
async for chunk in self.response.aiter_bytes():
|
|
139
|
+
for frame in sse.push_bytes(chunk):
|
|
140
|
+
yield frame
|
|
141
|
+
for frame in sse.finish():
|
|
142
|
+
yield frame
|
|
143
|
+
else:
|
|
144
|
+
async for chunk in self.response.aiter_bytes():
|
|
145
|
+
yield chunk
|
|
146
|
+
except httpx.TransportError as exc:
|
|
147
|
+
raise MatrxApiError(status=0, path=str(self.response.url), message=f"stream interrupted: {exc}", code="network_error") from exc
|
|
148
|
+
finally:
|
|
149
|
+
await self.response.aclose()
|
|
150
|
+
|
|
151
|
+
def __aiter__(self) -> AsyncIterator[E]:
|
|
152
|
+
if self._started:
|
|
153
|
+
raise RuntimeError("an ApiStream can only be iterated once")
|
|
154
|
+
self._started = True
|
|
155
|
+
return self._iterate()
|
|
156
|
+
|
|
157
|
+
async def aclose(self) -> None:
|
|
158
|
+
await self.response.aclose()
|
|
159
|
+
|
|
160
|
+
async def __aenter__(self) -> "ApiStream[E]":
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
164
|
+
await self.aclose()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class JsonOrStream(Generic[J, E]):
|
|
169
|
+
"""Conditional streams: ``kind`` is ``"json"`` (``data``) or ``"stream"`` (``stream``)."""
|
|
170
|
+
|
|
171
|
+
kind: Literal["json", "stream"]
|
|
172
|
+
data: J | None = None
|
|
173
|
+
stream: ApiStream[E] | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MatrxApi:
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
credentials: CredentialsSetting = None,
|
|
181
|
+
organization_id: str | None | Callable[[], str | None] = None,
|
|
182
|
+
environment: Environment = "production",
|
|
183
|
+
base_urls: Mapping[str, str] | None = None,
|
|
184
|
+
resolve_base_url: Callable[[str, OperationDescriptor], str | None] | None = None,
|
|
185
|
+
http_client: httpx.AsyncClient | None = None,
|
|
186
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
187
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
188
|
+
total_timeout: float | None = None,
|
|
189
|
+
retry_attempts: int = 3,
|
|
190
|
+
retry_backoff: float = 0.25,
|
|
191
|
+
request_id: Callable[[], str] | None = None,
|
|
192
|
+
headers: Mapping[str, str] | None = None,
|
|
193
|
+
on_request: Callable[[RequestInfo], None] | None = None,
|
|
194
|
+
sleep: Callable[[float], Any] = asyncio.sleep,
|
|
195
|
+
) -> None:
|
|
196
|
+
self._credentials = credentials
|
|
197
|
+
self._organization_id = organization_id
|
|
198
|
+
self._environment = environment
|
|
199
|
+
self._base_urls = dict(base_urls or {})
|
|
200
|
+
self._resolve_base_url = resolve_base_url
|
|
201
|
+
self._owns_client = http_client is None
|
|
202
|
+
self._client = http_client or httpx.AsyncClient(transport=transport, follow_redirects=False)
|
|
203
|
+
self._connect_timeout = connect_timeout
|
|
204
|
+
self._total_timeout = total_timeout
|
|
205
|
+
self._retry_attempts = max(1, retry_attempts)
|
|
206
|
+
self._retry_backoff = retry_backoff
|
|
207
|
+
self._mint_request_id = request_id or (lambda: str(uuid.uuid4()))
|
|
208
|
+
self._headers = dict(headers or {})
|
|
209
|
+
self._on_request = on_request
|
|
210
|
+
self._sleep = sleep
|
|
211
|
+
|
|
212
|
+
async def aclose(self) -> None:
|
|
213
|
+
if self._owns_client:
|
|
214
|
+
await self._client.aclose()
|
|
215
|
+
|
|
216
|
+
async def __aenter__(self) -> "MatrxApi":
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
220
|
+
await self.aclose()
|
|
221
|
+
|
|
222
|
+
# -- routing -----------------------------------------------------------------------------
|
|
223
|
+
def base_url(self, op: OperationDescriptor) -> str:
|
|
224
|
+
if self._resolve_base_url:
|
|
225
|
+
dynamic = self._resolve_base_url(op.deployment, op)
|
|
226
|
+
if dynamic:
|
|
227
|
+
return dynamic.rstrip("/")
|
|
228
|
+
override = self._base_urls.get(op.deployment)
|
|
229
|
+
if override:
|
|
230
|
+
return override.rstrip("/")
|
|
231
|
+
dep = DEPLOYMENTS[op.deployment]
|
|
232
|
+
fallback = dep["base_url"].get(self._environment)
|
|
233
|
+
if not fallback:
|
|
234
|
+
server_env = dep.get("env", {}).get("server")
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f'matrx_api: no base URL for deployment "{op.deployment}" in {self._environment} '
|
|
237
|
+
f'(operation {op.name}). Pass MatrxApi(base_urls={{"{op.deployment}": "https://..."}})'
|
|
238
|
+
+ (f" - servers read it from {server_env}." if server_env else ".")
|
|
239
|
+
)
|
|
240
|
+
return fallback
|
|
241
|
+
|
|
242
|
+
def url(self, op: OperationDescriptor, *, path: Mapping[str, Any] | None = None) -> str:
|
|
243
|
+
return f"{self.base_url(op)}{_interpolate(op.path, path)}"
|
|
244
|
+
|
|
245
|
+
def _organization_for(self, op: OperationDescriptor, options: CallOptions | None) -> str | None:
|
|
246
|
+
if op.org == "exempt":
|
|
247
|
+
return None
|
|
248
|
+
bound = self._organization_id() if callable(self._organization_id) else self._organization_id
|
|
249
|
+
selected = (options.organization_id if options and options.organization_id else None) or bound
|
|
250
|
+
if op.org == "required":
|
|
251
|
+
if not isinstance(selected, str) or not selected.strip():
|
|
252
|
+
raise OrganizationContextError(
|
|
253
|
+
"organization_context_required", f"Select an organization before calling {op.name}."
|
|
254
|
+
)
|
|
255
|
+
normalized = selected.strip()
|
|
256
|
+
try:
|
|
257
|
+
uuid.UUID(normalized)
|
|
258
|
+
except ValueError:
|
|
259
|
+
raise OrganizationContextError(
|
|
260
|
+
"organization_context_invalid", "The selected organization ID is invalid."
|
|
261
|
+
) from None
|
|
262
|
+
return normalized.lower()
|
|
263
|
+
return selected.strip() if isinstance(selected, str) and selected.strip() else None
|
|
264
|
+
|
|
265
|
+
# -- send --------------------------------------------------------------------------------
|
|
266
|
+
async def _build(
|
|
267
|
+
self,
|
|
268
|
+
op: OperationDescriptor,
|
|
269
|
+
path: Mapping[str, Any] | None,
|
|
270
|
+
query: Mapping[str, Any] | None,
|
|
271
|
+
body: Any,
|
|
272
|
+
options: CallOptions | None,
|
|
273
|
+
) -> tuple[httpx.Request, str]:
|
|
274
|
+
url = self.url(op, path=path)
|
|
275
|
+
org = self._organization_for(op, options)
|
|
276
|
+
request_id = self._mint_request_id()
|
|
277
|
+
headers: dict[str, str] = {
|
|
278
|
+
**self._headers,
|
|
279
|
+
**(options.headers if options and options.headers else {}),
|
|
280
|
+
"Accept": _accept(op.stream),
|
|
281
|
+
}
|
|
282
|
+
headers[MATRX_HEADERS["request_id"]] = request_id
|
|
283
|
+
content: Any = None
|
|
284
|
+
data: Any = None
|
|
285
|
+
files: Any = None
|
|
286
|
+
json_body: Any = None
|
|
287
|
+
if body is not None and op.method not in ("GET", "HEAD"):
|
|
288
|
+
if isinstance(body, Multipart):
|
|
289
|
+
data, files = body.data, body.files
|
|
290
|
+
elif isinstance(body, (bytes, bytearray)):
|
|
291
|
+
content = bytes(body)
|
|
292
|
+
elif isinstance(body, str):
|
|
293
|
+
content = body.encode()
|
|
294
|
+
else:
|
|
295
|
+
json_body = body
|
|
296
|
+
headers.update(credential_to_headers(await resolve_credential(self._credentials)))
|
|
297
|
+
if org is not None:
|
|
298
|
+
headers[MATRX_HEADERS["organization"]] = org
|
|
299
|
+
if self._on_request:
|
|
300
|
+
self._on_request(RequestInfo(op.name, op.method, url, request_id, op.deployment))
|
|
301
|
+
connect = options.connect_timeout if options and options.connect_timeout is not None else self._connect_timeout
|
|
302
|
+
total = options.total_timeout if options and options.total_timeout is not None else self._total_timeout
|
|
303
|
+
# streams are long-lived: only the connect phase is bounded unless a total is requested
|
|
304
|
+
timeout = httpx.Timeout(total, connect=connect)
|
|
305
|
+
request = self._client.build_request(
|
|
306
|
+
op.method,
|
|
307
|
+
url,
|
|
308
|
+
params=_query_pairs(query) or None,
|
|
309
|
+
headers=headers,
|
|
310
|
+
content=content,
|
|
311
|
+
data=data,
|
|
312
|
+
files=files,
|
|
313
|
+
json=json_body,
|
|
314
|
+
timeout=timeout,
|
|
315
|
+
)
|
|
316
|
+
return request, request_id
|
|
317
|
+
|
|
318
|
+
async def _error(self, response: httpx.Response, op: OperationDescriptor, path: Mapping[str, Any] | None) -> MatrxApiError:
|
|
319
|
+
try:
|
|
320
|
+
await response.aread()
|
|
321
|
+
except httpx.HTTPError:
|
|
322
|
+
pass
|
|
323
|
+
try:
|
|
324
|
+
text = response.text
|
|
325
|
+
except httpx.ResponseNotRead:
|
|
326
|
+
text = ""
|
|
327
|
+
try:
|
|
328
|
+
detail: Any = json.loads(text) if text else None
|
|
329
|
+
except ValueError:
|
|
330
|
+
detail = text or None
|
|
331
|
+
await response.aclose()
|
|
332
|
+
return MatrxApiError(status=response.status_code, path=_interpolate(op.path, path), server_detail=detail)
|
|
333
|
+
|
|
334
|
+
async def send(
|
|
335
|
+
self,
|
|
336
|
+
op: OperationDescriptor,
|
|
337
|
+
*,
|
|
338
|
+
path: Mapping[str, Any] | None = None,
|
|
339
|
+
query: Mapping[str, Any] | None = None,
|
|
340
|
+
body: Any = None,
|
|
341
|
+
options: CallOptions | None = None,
|
|
342
|
+
stream: bool = False,
|
|
343
|
+
) -> tuple[httpx.Response, str]:
|
|
344
|
+
"""Send one operation; raises :class:`MatrxApiError` on non-2xx or transport failure."""
|
|
345
|
+
idempotent = op.method in ("GET", "HEAD") and not stream
|
|
346
|
+
attempts = self._retry_attempts if idempotent else 1
|
|
347
|
+
last: httpx.TransportError | None = None
|
|
348
|
+
for attempt in range(attempts):
|
|
349
|
+
request, request_id = await self._build(op, path, query, body, options)
|
|
350
|
+
try:
|
|
351
|
+
response = await self._client.send(request, stream=stream)
|
|
352
|
+
except httpx.TransportError as exc:
|
|
353
|
+
last = exc
|
|
354
|
+
if attempt + 1 < attempts:
|
|
355
|
+
await self._sleep(self._retry_backoff * (2**attempt))
|
|
356
|
+
continue
|
|
357
|
+
raise MatrxApiError(
|
|
358
|
+
status=0,
|
|
359
|
+
path=_interpolate(op.path, path),
|
|
360
|
+
message=f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__,
|
|
361
|
+
code="network_error",
|
|
362
|
+
) from exc
|
|
363
|
+
if response.status_code >= 400:
|
|
364
|
+
raise await self._error(response, op, path)
|
|
365
|
+
return response, request_id
|
|
366
|
+
raise AssertionError(last) # pragma: no cover
|
|
367
|
+
|
|
368
|
+
async def call(
|
|
369
|
+
self,
|
|
370
|
+
op: OperationDescriptor,
|
|
371
|
+
*,
|
|
372
|
+
path: Mapping[str, Any] | None = None,
|
|
373
|
+
query: Mapping[str, Any] | None = None,
|
|
374
|
+
body: Any = None,
|
|
375
|
+
options: CallOptions | None = None,
|
|
376
|
+
) -> Any:
|
|
377
|
+
"""A plain JSON operation: the parsed body (None for 204/HEAD, text when not JSON)."""
|
|
378
|
+
response, _ = await self.send(op, path=path, query=query, body=body, options=options)
|
|
379
|
+
if response.status_code == 204 or op.method == "HEAD" or not response.content:
|
|
380
|
+
return None
|
|
381
|
+
if "json" in response.headers.get("content-type", ""):
|
|
382
|
+
return response.json()
|
|
383
|
+
try:
|
|
384
|
+
return json.loads(response.text)
|
|
385
|
+
except ValueError:
|
|
386
|
+
return response.text
|
|
387
|
+
|
|
388
|
+
async def raw(self, op: OperationDescriptor, **kwargs: Any) -> httpx.Response:
|
|
389
|
+
"""The complete raw response (non-streamed bytes operations)."""
|
|
390
|
+
response, _ = await self.send(op, **kwargs)
|
|
391
|
+
return response
|
|
392
|
+
|
|
393
|
+
async def open_stream(
|
|
394
|
+
self,
|
|
395
|
+
op: OperationDescriptor,
|
|
396
|
+
*,
|
|
397
|
+
path: Mapping[str, Any] | None = None,
|
|
398
|
+
query: Mapping[str, Any] | None = None,
|
|
399
|
+
body: Any = None,
|
|
400
|
+
options: CallOptions | None = None,
|
|
401
|
+
) -> ApiStream[Any]:
|
|
402
|
+
response, request_id = await self.send(op, path=path, query=query, body=body, options=options, stream=True)
|
|
403
|
+
return ApiStream(response, request_id, op.stream)
|
|
404
|
+
|
|
405
|
+
async def open_json_or_stream(
|
|
406
|
+
self,
|
|
407
|
+
op: OperationDescriptor,
|
|
408
|
+
*,
|
|
409
|
+
path: Mapping[str, Any] | None = None,
|
|
410
|
+
query: Mapping[str, Any] | None = None,
|
|
411
|
+
body: Any = None,
|
|
412
|
+
options: CallOptions | None = None,
|
|
413
|
+
) -> JsonOrStream[Any, Any]:
|
|
414
|
+
response, request_id = await self.send(op, path=path, query=query, body=body, options=options, stream=True)
|
|
415
|
+
if "application/json" in response.headers.get("content-type", ""):
|
|
416
|
+
await response.aread()
|
|
417
|
+
data = response.json()
|
|
418
|
+
await response.aclose()
|
|
419
|
+
return JsonOrStream(kind="json", data=data)
|
|
420
|
+
return JsonOrStream(kind="stream", stream=ApiStream(response, request_id, op.stream))
|
matrx_api/credentials.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The credentials port: five kinds, one credential -> header mapping (mirrors @ai-matrx/auth credentials)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
MATRX_HEADERS = {
|
|
11
|
+
"authorization": "Authorization",
|
|
12
|
+
"fingerprint": "X-Fingerprint-ID",
|
|
13
|
+
"organization": "X-Organization-Id",
|
|
14
|
+
"acting_user": "X-Matrx-User-Id",
|
|
15
|
+
"request_id": "X-Request-Id",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
API_KEY_PREFIX = "mx_"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class UserCredential:
|
|
23
|
+
"""A signed-in person: ``Authorization: Bearer <Supabase JWT>``."""
|
|
24
|
+
|
|
25
|
+
access_token: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class GuestCredential:
|
|
30
|
+
"""A fingerprint: ``X-Fingerprint-ID``."""
|
|
31
|
+
|
|
32
|
+
fingerprint_id: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ApiKeyCredential:
|
|
37
|
+
"""An AI Matrx API key (``mx_...``) as a Bearer token."""
|
|
38
|
+
|
|
39
|
+
api_key: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ServiceCredential:
|
|
44
|
+
"""One of our servers calling another: the server secret (+ the acting person)."""
|
|
45
|
+
|
|
46
|
+
secret: str
|
|
47
|
+
acting_user_id: str | None = None
|
|
48
|
+
secret_header: str = "Authorization"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ScopedCredential:
|
|
53
|
+
"""A short-lived capability token; hand a provider so it is re-read per call."""
|
|
54
|
+
|
|
55
|
+
token: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Credential = Union[UserCredential, GuestCredential, ApiKeyCredential, ServiceCredential, ScopedCredential]
|
|
59
|
+
CredentialsSetting = Union[
|
|
60
|
+
Credential,
|
|
61
|
+
None,
|
|
62
|
+
Callable[[], Union[Credential, None, Awaitable[Union[Credential, None]]]],
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def credential_to_headers(credential: Credential | None) -> dict[str, str]:
|
|
67
|
+
"""The single place that knows which header each credential kind rides in."""
|
|
68
|
+
h = MATRX_HEADERS
|
|
69
|
+
match credential:
|
|
70
|
+
case None:
|
|
71
|
+
return {}
|
|
72
|
+
case UserCredential(access_token=t):
|
|
73
|
+
return {h["authorization"]: f"Bearer {t}"}
|
|
74
|
+
case GuestCredential(fingerprint_id=f):
|
|
75
|
+
return {h["fingerprint"]: f}
|
|
76
|
+
case ScopedCredential(token=t):
|
|
77
|
+
return {h["authorization"]: f"Bearer {t}"}
|
|
78
|
+
case ServiceCredential() as c:
|
|
79
|
+
if c.secret_header.lower() == "authorization":
|
|
80
|
+
out = {h["authorization"]: f"Bearer {c.secret}"}
|
|
81
|
+
else:
|
|
82
|
+
out = {c.secret_header: c.secret}
|
|
83
|
+
if c.acting_user_id:
|
|
84
|
+
out[h["acting_user"]] = c.acting_user_id
|
|
85
|
+
return out
|
|
86
|
+
case ApiKeyCredential(api_key=k):
|
|
87
|
+
if not k.startswith(API_KEY_PREFIX):
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f'credential_to_headers: an api_key credential must start with "{API_KEY_PREFIX}" '
|
|
90
|
+
"(AI Matrx API keys look like mx_live_...)"
|
|
91
|
+
)
|
|
92
|
+
return {h["authorization"]: f"Bearer {k}"}
|
|
93
|
+
raise TypeError(f"unknown credential: {credential!r}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def resolve_credential(setting: CredentialsSetting) -> Credential | None:
|
|
97
|
+
"""A fixed credential, None, or a (sync/async) provider called on every request."""
|
|
98
|
+
if setting is None:
|
|
99
|
+
return None
|
|
100
|
+
if callable(setting):
|
|
101
|
+
value = setting()
|
|
102
|
+
if inspect.isawaitable(value):
|
|
103
|
+
value = await value
|
|
104
|
+
return value
|
|
105
|
+
return setting
|
matrx_api/deployments.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""GENERATED by matrx-api scripts/generate.py from aidream/api-contract - never edit by hand."""
|
|
2
|
+
|
|
3
|
+
CONTRACT_SHA256 = '7a09c07daad8cd41c7ae301d9ce90aace006a8f2d3a21dde98885807e5881da5'
|
|
4
|
+
|
|
5
|
+
# Which deployment answers (api-deployments.json): default base URLs and the env names that override them.
|
|
6
|
+
DEPLOYMENTS: dict[str, dict] = {
|
|
7
|
+
"aidream": {
|
|
8
|
+
"app": "aidream-main",
|
|
9
|
+
"base_url": {
|
|
10
|
+
"localhost": "http://localhost:8000",
|
|
11
|
+
"production": "https://server.app.matrxserver.com"
|
|
12
|
+
},
|
|
13
|
+
"env": {
|
|
14
|
+
"browser": "NEXT_PUBLIC_BACKEND_URL_PROD",
|
|
15
|
+
"sandbox_service_token": "AIDREAM_SANDBOX_SERVICE_TOKEN",
|
|
16
|
+
"server": "MATRX_AIDREAM_URL",
|
|
17
|
+
"service_token": "MATRX_AIDREAM_SERVICE_TOKEN"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"cloud-browser-streaming": {
|
|
21
|
+
"app": "cloud-browser-streaming",
|
|
22
|
+
"base_url": {},
|
|
23
|
+
"env": {}
|
|
24
|
+
},
|
|
25
|
+
"cloud-browser-worker": {
|
|
26
|
+
"app": "cloud-browser-worker",
|
|
27
|
+
"base_url": {},
|
|
28
|
+
"env": {}
|
|
29
|
+
},
|
|
30
|
+
"files": {
|
|
31
|
+
"app": "files-standalone",
|
|
32
|
+
"base_url": {
|
|
33
|
+
"localhost": "http://127.0.0.1:8090",
|
|
34
|
+
"production": "https://files.matrxserver.com"
|
|
35
|
+
},
|
|
36
|
+
"env": {
|
|
37
|
+
"browser": "NEXT_PUBLIC_FILES_URL",
|
|
38
|
+
"browser_local": "NEXT_PUBLIC_FILES_URL_LOCAL",
|
|
39
|
+
"server": "MATRX_FILES_URL"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"hosted-box": {
|
|
43
|
+
"app": "hosted-box",
|
|
44
|
+
"base_url": {},
|
|
45
|
+
"env": {}
|
|
46
|
+
},
|
|
47
|
+
"matrx-local": {
|
|
48
|
+
"app": "matrx-local",
|
|
49
|
+
"base_url": {
|
|
50
|
+
"localhost": "http://127.0.0.1:22140"
|
|
51
|
+
},
|
|
52
|
+
"env": {
|
|
53
|
+
"port_base": "MATRX_PORT_BASE"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"sandbox-orchestrator": {
|
|
57
|
+
"app": "sandbox-orchestrator",
|
|
58
|
+
"base_url": {
|
|
59
|
+
"production": "https://orchestrator.dev.codematrx.com"
|
|
60
|
+
},
|
|
61
|
+
"env": {
|
|
62
|
+
"server": "SANDBOX_ORCHESTRATOR_URL",
|
|
63
|
+
"service_token": "SANDBOX_ORCHESTRATOR_API_KEY"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"scraper": {
|
|
67
|
+
"app": "scraper-server",
|
|
68
|
+
"base_url": {
|
|
69
|
+
"localhost": "http://localhost:8001",
|
|
70
|
+
"production": "https://scraper.app.matrxserver.com"
|
|
71
|
+
},
|
|
72
|
+
"env": {
|
|
73
|
+
"browser": "NEXT_PUBLIC_SCRAPER_URL",
|
|
74
|
+
"browser_local": "NEXT_PUBLIC_SCRAPER_URL_LOCAL",
|
|
75
|
+
"server": "MATRX_SCRAPER_URL",
|
|
76
|
+
"service_token": "MATRX_SCRAPER_TOKEN"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"seo": {
|
|
80
|
+
"app": "seo-standalone",
|
|
81
|
+
"base_url": {
|
|
82
|
+
"localhost": "http://127.0.0.1:8081",
|
|
83
|
+
"production": "https://seo.matrxserver.com"
|
|
84
|
+
},
|
|
85
|
+
"env": {
|
|
86
|
+
"browser": "NEXT_PUBLIC_SEO_URL",
|
|
87
|
+
"browser_local": "NEXT_PUBLIC_SEO_URL_LOCAL"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# One generated module per x-matrx-domain (module name -> domain).
|
|
93
|
+
DOMAINS: dict[str, str] = {
|
|
94
|
+
"account": "account",
|
|
95
|
+
"agents": "agents",
|
|
96
|
+
"ai": "ai",
|
|
97
|
+
"apps": "apps",
|
|
98
|
+
"architecture": "architecture",
|
|
99
|
+
"audio": "audio",
|
|
100
|
+
"chat": "chat",
|
|
101
|
+
"code": "code",
|
|
102
|
+
"commerce": "commerce",
|
|
103
|
+
"communications": "communications",
|
|
104
|
+
"computer": "computer",
|
|
105
|
+
"content": "content",
|
|
106
|
+
"crm": "crm",
|
|
107
|
+
"data": "data",
|
|
108
|
+
"education": "education",
|
|
109
|
+
"files": "files",
|
|
110
|
+
"human_resources": "human-resources",
|
|
111
|
+
"integrations": "integrations",
|
|
112
|
+
"intelligence": "intelligence",
|
|
113
|
+
"knowledge": "knowledge",
|
|
114
|
+
"legal": "legal",
|
|
115
|
+
"marketing": "marketing",
|
|
116
|
+
"masterwork": "masterwork",
|
|
117
|
+
"media": "media",
|
|
118
|
+
"platform": "platform",
|
|
119
|
+
"publish": "publish",
|
|
120
|
+
"web": "web",
|
|
121
|
+
"workflows": "workflows"
|
|
122
|
+
}
|