modus-sdk 0.2.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.
- modus/__init__.py +144 -0
- modus/_auth.py +111 -0
- modus/_chat.py +411 -0
- modus/_client.py +240 -0
- modus/_config.py +89 -0
- modus/_deprecation.py +29 -0
- modus/_exceptions.py +149 -0
- modus/_generated/__init__.py +341 -0
- modus/_generated/models.py +3182 -0
- modus/_generated/operations.py +891 -0
- modus/_http.py +471 -0
- modus/_openapi_invoke.py +137 -0
- modus/_pagination.py +175 -0
- modus/_query.py +38 -0
- modus/_retry.py +123 -0
- modus/_streaming.py +115 -0
- modus/_validation.py +61 -0
- modus/_version.py +24 -0
- modus/management/__init__.py +74 -0
- modus/management/_client.py +168 -0
- modus/management/resources/__init__.py +21 -0
- modus/management/resources/_access_config_write.py +31 -0
- modus/management/resources/context/__init__.py +0 -0
- modus/management/resources/context/_content_merge.py +128 -0
- modus/management/resources/context/context.py +362 -0
- modus/management/resources/context/custom_items.py +318 -0
- modus/management/resources/context/items.py +234 -0
- modus/management/resources/organization.py +52 -0
- modus/management/resources/scopes/__init__.py +0 -0
- modus/management/resources/scopes/_update_body.py +38 -0
- modus/management/resources/scopes/conversations.py +177 -0
- modus/management/resources/scopes/evaluations.py +142 -0
- modus/management/resources/scopes/memories.py +273 -0
- modus/management/resources/scopes/scopes.py +764 -0
- modus/management/resources/scopes/supervision.py +85 -0
- modus/management/resources/usage.py +92 -0
- modus/management/resources/users.py +56 -0
- modus/management/resources/workflows/__init__.py +0 -0
- modus/management/resources/workflows/_update_body.py +29 -0
- modus/management/resources/workflows/interfaces.py +136 -0
- modus/management/resources/workflows/workflows.py +647 -0
- modus/management/types/__init__.py +57 -0
- modus/management/types/_guardrails_access.py +33 -0
- modus/management/types/_serialize.py +52 -0
- modus/management/types/agent_selection.py +66 -0
- modus/management/types/agents.py +5 -0
- modus/management/types/context.py +25 -0
- modus/management/types/conversations.py +5 -0
- modus/management/types/evaluations.py +25 -0
- modus/management/types/json_blob.py +51 -0
- modus/management/types/memories.py +69 -0
- modus/management/types/ownership.py +9 -0
- modus/management/types/skills.py +5 -0
- modus/management/types/toolset.py +236 -0
- modus/management/types/triggers.py +284 -0
- modus/management/types/usage.py +13 -0
- modus/management/types/workflow.py +45 -0
- modus/py.typed +0 -0
- modus/resources/__init__.py +21 -0
- modus/resources/connections/__init__.py +3 -0
- modus/resources/connections/connections.py +189 -0
- modus/resources/context/__init__.py +3 -0
- modus/resources/context/context.py +48 -0
- modus/resources/context/items.py +410 -0
- modus/resources/modus/__init__.py +3 -0
- modus/resources/modus/conversations.py +144 -0
- modus/resources/modus/modus.py +138 -0
- modus/resources/scopes/__init__.py +3 -0
- modus/resources/scopes/conversations.py +213 -0
- modus/resources/scopes/scopes.py +431 -0
- modus/resources/suggestions/__init__.py +3 -0
- modus/resources/suggestions/suggestions.py +207 -0
- modus/resources/workflows/__init__.py +15 -0
- modus/resources/workflows/runs.py +833 -0
- modus/resources/workflows/workflow_actions.py +122 -0
- modus/resources/workflows/workflows.py +358 -0
- modus/types/__init__.py +80 -0
- modus/types/agent_runs.py +70 -0
- modus/types/agents.py +17 -0
- modus/types/chat.py +9 -0
- modus/types/compose.py +21 -0
- modus/types/connections.py +9 -0
- modus/types/context.py +28 -0
- modus/types/context_accessors.py +89 -0
- modus/types/context_compose.py +18 -0
- modus/types/context_values.py +21 -0
- modus/types/conversations.py +30 -0
- modus/types/json_blob.py +24 -0
- modus/types/pagination.py +142 -0
- modus/types/runs.py +71 -0
- modus/types/skills.py +16 -0
- modus/types/suggestions.py +16 -0
- modus/types/tool_calls.py +34 -0
- modus/types/views.py +6 -0
- modus_sdk-0.2.0.dist-info/METADATA +225 -0
- modus_sdk-0.2.0.dist-info/RECORD +98 -0
- modus_sdk-0.2.0.dist-info/WHEEL +4 -0
- modus_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
modus/_pagination.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers to build Page[T] / AsyncPage[T] from Modus API list responses.
|
|
3
|
+
|
|
4
|
+
Public list endpoints return resource-plural arrays and ``nextPageToken``:
|
|
5
|
+
{ "skills": [...], "nextPageToken": "..." }
|
|
6
|
+
|
|
7
|
+
Query params use ``pageSize`` and ``pageToken``. Python resources expose
|
|
8
|
+
``page_size`` / ``page_token``; ``Page.next_page_token`` maps from ``nextPageToken``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Type, TypeVar
|
|
14
|
+
|
|
15
|
+
from ._exceptions import ModusError
|
|
16
|
+
from .types.pagination import AsyncPage, Page
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_page_token(token: Optional[str]) -> Optional[str]:
|
|
22
|
+
"""Treat None and blank strings as no page token."""
|
|
23
|
+
if token is None or not str(token).strip():
|
|
24
|
+
return None
|
|
25
|
+
return token
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def aip_list_params(
|
|
29
|
+
page_size: int,
|
|
30
|
+
page_token: Optional[str] = None,
|
|
31
|
+
**extra: Any,
|
|
32
|
+
) -> Dict[str, Any]:
|
|
33
|
+
"""Build cursor-pagination query params (pageSize, pageToken) plus optional filters."""
|
|
34
|
+
params: Dict[str, Any] = {"pageSize": page_size, **extra}
|
|
35
|
+
token = normalize_page_token(page_token)
|
|
36
|
+
if token is not None:
|
|
37
|
+
params["pageToken"] = token
|
|
38
|
+
return params
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _next_page_token_from_response(data: Dict[str, Any]) -> Optional[str]:
|
|
42
|
+
return normalize_page_token(data.get("nextPageToken"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_items_list(data: Dict[str, Any], items_key: str) -> List[Any]:
|
|
46
|
+
"""Return ``data[items_key]`` when it is a list; otherwise raise ModusError."""
|
|
47
|
+
value = data.get(items_key)
|
|
48
|
+
if not isinstance(value, list):
|
|
49
|
+
raise ModusError(
|
|
50
|
+
f"Unexpected list response shape: {items_key!r} must be a list, "
|
|
51
|
+
f"got {type(value).__name__}. This is likely a backend error."
|
|
52
|
+
)
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_aip_page(
|
|
57
|
+
data: Dict[str, Any],
|
|
58
|
+
items_key: str,
|
|
59
|
+
item_class: Type[T],
|
|
60
|
+
fetch_page: Callable[[str], Page[T]],
|
|
61
|
+
) -> Page[T]:
|
|
62
|
+
"""Construct Page[T] from an AIP list response with a Pydantic item class."""
|
|
63
|
+
if not isinstance(data, dict) or items_key not in data:
|
|
64
|
+
raise ModusError(
|
|
65
|
+
f"Unexpected list response shape: missing {items_key!r}. "
|
|
66
|
+
f"Got: {type(data).__name__}. This is likely a backend error."
|
|
67
|
+
)
|
|
68
|
+
raw_items: List[Dict[str, Any]] = _require_items_list(data, items_key)
|
|
69
|
+
items = [item_class.model_validate(raw) for raw in raw_items] # type: ignore[attr-defined]
|
|
70
|
+
return Page(
|
|
71
|
+
items=items,
|
|
72
|
+
next_page_token=_next_page_token_from_response(data),
|
|
73
|
+
_fetch_page=fetch_page,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_async_aip_page(
|
|
78
|
+
data: Dict[str, Any],
|
|
79
|
+
items_key: str,
|
|
80
|
+
item_class: Type[T],
|
|
81
|
+
fetch_page: Callable[[str], Awaitable[AsyncPage[T]]],
|
|
82
|
+
) -> AsyncPage[T]:
|
|
83
|
+
"""Construct AsyncPage[T] from an AIP list response with a Pydantic item class."""
|
|
84
|
+
if not isinstance(data, dict) or items_key not in data:
|
|
85
|
+
raise ModusError(
|
|
86
|
+
f"Unexpected list response shape: missing {items_key!r}. "
|
|
87
|
+
f"Got: {type(data).__name__}. This is likely a backend error."
|
|
88
|
+
)
|
|
89
|
+
raw_items: List[Dict[str, Any]] = _require_items_list(data, items_key)
|
|
90
|
+
items = [item_class.model_validate(raw) for raw in raw_items] # type: ignore[attr-defined]
|
|
91
|
+
return AsyncPage(
|
|
92
|
+
items=items,
|
|
93
|
+
next_page_token=_next_page_token_from_response(data),
|
|
94
|
+
_fetch_page=fetch_page,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_aip_page_mapped(
|
|
99
|
+
data: Dict[str, Any],
|
|
100
|
+
items_key: str,
|
|
101
|
+
map_item: Callable[[Dict[str, Any]], T],
|
|
102
|
+
fetch_page: Callable[[str], Page[T]],
|
|
103
|
+
) -> Page[T]:
|
|
104
|
+
"""Construct Page[T] from an AIP list response using a per-item mapper."""
|
|
105
|
+
if not isinstance(data, dict) or items_key not in data:
|
|
106
|
+
raise ModusError(
|
|
107
|
+
f"Unexpected list response shape: missing {items_key!r}. "
|
|
108
|
+
f"Got: {type(data).__name__}. This is likely a backend error."
|
|
109
|
+
)
|
|
110
|
+
raw_items: List[Dict[str, Any]] = _require_items_list(data, items_key)
|
|
111
|
+
items = [map_item(raw) for raw in raw_items]
|
|
112
|
+
return Page(
|
|
113
|
+
items=items,
|
|
114
|
+
next_page_token=_next_page_token_from_response(data),
|
|
115
|
+
_fetch_page=fetch_page,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_async_aip_page_mapped(
|
|
120
|
+
data: Dict[str, Any],
|
|
121
|
+
items_key: str,
|
|
122
|
+
map_item: Callable[[Dict[str, Any]], T],
|
|
123
|
+
fetch_page: Callable[[str], Awaitable[AsyncPage[T]]],
|
|
124
|
+
) -> AsyncPage[T]:
|
|
125
|
+
"""Construct AsyncPage[T] from an AIP list response using a per-item mapper."""
|
|
126
|
+
if not isinstance(data, dict) or items_key not in data:
|
|
127
|
+
raise ModusError(
|
|
128
|
+
f"Unexpected list response shape: missing {items_key!r}. "
|
|
129
|
+
f"Got: {type(data).__name__}. This is likely a backend error."
|
|
130
|
+
)
|
|
131
|
+
raw_items: List[Dict[str, Any]] = _require_items_list(data, items_key)
|
|
132
|
+
items = [map_item(raw) for raw in raw_items]
|
|
133
|
+
return AsyncPage(
|
|
134
|
+
items=items,
|
|
135
|
+
next_page_token=_next_page_token_from_response(data),
|
|
136
|
+
_fetch_page=fetch_page,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_aip_page_raw(
|
|
141
|
+
data: Dict[str, Any],
|
|
142
|
+
items_key: str,
|
|
143
|
+
fetch_page: Callable[[str], Page[Dict[str, Any]]],
|
|
144
|
+
) -> Page[Dict[str, Any]]:
|
|
145
|
+
"""Construct Page[dict] from an AIP list response (e.g. context item values)."""
|
|
146
|
+
if not isinstance(data, dict) or items_key not in data:
|
|
147
|
+
raise ModusError(
|
|
148
|
+
f"Unexpected list response shape: missing {items_key!r}. "
|
|
149
|
+
f"Got: {type(data).__name__}. This is likely a backend error."
|
|
150
|
+
)
|
|
151
|
+
items: List[Dict[str, Any]] = [dict(x) for x in _require_items_list(data, items_key)]
|
|
152
|
+
return Page(
|
|
153
|
+
items=items,
|
|
154
|
+
next_page_token=_next_page_token_from_response(data),
|
|
155
|
+
_fetch_page=fetch_page,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_async_aip_page_raw(
|
|
160
|
+
data: Dict[str, Any],
|
|
161
|
+
items_key: str,
|
|
162
|
+
fetch_page: Callable[[str], Awaitable[AsyncPage[Dict[str, Any]]]],
|
|
163
|
+
) -> AsyncPage[Dict[str, Any]]:
|
|
164
|
+
"""Construct AsyncPage[dict] from an AIP list response."""
|
|
165
|
+
if not isinstance(data, dict) or items_key not in data:
|
|
166
|
+
raise ModusError(
|
|
167
|
+
f"Unexpected list response shape: missing {items_key!r}. "
|
|
168
|
+
f"Got: {type(data).__name__}. This is likely a backend error."
|
|
169
|
+
)
|
|
170
|
+
items: List[Dict[str, Any]] = [dict(x) for x in _require_items_list(data, items_key)]
|
|
171
|
+
return AsyncPage(
|
|
172
|
+
items=items,
|
|
173
|
+
next_page_token=_next_page_token_from_response(data),
|
|
174
|
+
_fetch_page=fetch_page,
|
|
175
|
+
)
|
modus/_query.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Small helpers for building request URLs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _validate_update_mask(update_mask: str) -> None:
|
|
9
|
+
if not update_mask.strip():
|
|
10
|
+
raise ValueError(
|
|
11
|
+
"update_mask must be a non-empty comma-separated list of field names"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def update_mask_query(update_mask: Optional[str]) -> Optional[dict[str, str]]:
|
|
16
|
+
"""Build an ``updateMask`` query dict for PATCH via ``invoke_operation``."""
|
|
17
|
+
if update_mask is None:
|
|
18
|
+
return None
|
|
19
|
+
_validate_update_mask(update_mask)
|
|
20
|
+
return {"updateMask": update_mask}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def with_update_mask(path: str, update_mask: Optional[str]) -> str:
|
|
24
|
+
"""Append an ``updateMask`` query parameter to ``path`` when provided.
|
|
25
|
+
|
|
26
|
+
The API uses ``updateMask`` (a comma-separated list of field names) on PATCH
|
|
27
|
+
requests to explicitly clear fields. When ``update_mask`` is ``None`` the path
|
|
28
|
+
is returned unchanged.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If ``update_mask`` is given but empty or whitespace-only — an
|
|
32
|
+
empty ``updateMask=`` query value has ambiguous PATCH semantics.
|
|
33
|
+
"""
|
|
34
|
+
if update_mask is None:
|
|
35
|
+
return path
|
|
36
|
+
_validate_update_mask(update_mask)
|
|
37
|
+
sep = "&" if "?" in path else "?"
|
|
38
|
+
return f"{path}{sep}updateMask={quote(update_mask, safe='')}"
|
modus/_retry.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry logic for transient errors.
|
|
3
|
+
|
|
4
|
+
RateLimitError, InternalServerError, and APIConnectionError are retried.
|
|
5
|
+
Client errors (4xx except 429) are not retried — they represent caller mistakes.
|
|
6
|
+
|
|
7
|
+
Backoff strategy:
|
|
8
|
+
- attempt 0 -> no sleep (immediate retry)
|
|
9
|
+
- attempt 1 -> ~1s + jitter
|
|
10
|
+
- attempt 2 -> ~2s + jitter
|
|
11
|
+
- If Retry-After header is present on 429, use that value instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
from typing import Awaitable, Callable, TypeVar
|
|
20
|
+
|
|
21
|
+
from ._exceptions import APIConnectionError, InternalServerError, NotFoundError, RateLimitError
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
_RETRYABLE_ERRORS = (RateLimitError, InternalServerError, APIConnectionError)
|
|
26
|
+
_DEPLOY_NOT_FOUND_BACKOFF_SECONDS = 0.5
|
|
27
|
+
_MAX_DEPLOY_NOT_FOUND_RETRIES = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _backoff_seconds(attempt: int, exc: Exception) -> float:
|
|
31
|
+
"""Calculate how long to sleep before the next retry attempt."""
|
|
32
|
+
if isinstance(exc, RateLimitError) and exc.retry_after is not None:
|
|
33
|
+
return max(0.0, float(exc.retry_after))
|
|
34
|
+
if attempt == 0:
|
|
35
|
+
return 0.0
|
|
36
|
+
# Exponential backoff: 1s, 2s, ... with ±0.3s jitter
|
|
37
|
+
return max(0.5, (2 ** (attempt - 1)) + (random.random() * 0.6 - 0.3))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def with_retry(
|
|
41
|
+
func: Callable[[], T],
|
|
42
|
+
max_retries: int,
|
|
43
|
+
*,
|
|
44
|
+
retry_not_found_on_deploy: bool = False,
|
|
45
|
+
) -> T:
|
|
46
|
+
"""
|
|
47
|
+
Call func, retrying on RateLimitError, InternalServerError, and APIConnectionError.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
func: Zero-argument callable to invoke.
|
|
51
|
+
max_retries: Maximum number of additional attempts after the first.
|
|
52
|
+
0 means no retries (single attempt total).
|
|
53
|
+
retry_not_found_on_deploy:
|
|
54
|
+
When True, retry HTTP 404 once with a short fixed delay.
|
|
55
|
+
This deploy-specific retry does not consume max_retries.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The return value of func on success.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
The last exception if all attempts are exhausted.
|
|
62
|
+
Any non-retryable exception immediately (no retry).
|
|
63
|
+
"""
|
|
64
|
+
deploy_not_found_retries = 0
|
|
65
|
+
attempt = 0
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
return func()
|
|
69
|
+
except NotFoundError:
|
|
70
|
+
if (
|
|
71
|
+
retry_not_found_on_deploy
|
|
72
|
+
and deploy_not_found_retries < _MAX_DEPLOY_NOT_FOUND_RETRIES
|
|
73
|
+
):
|
|
74
|
+
deploy_not_found_retries += 1
|
|
75
|
+
time.sleep(_DEPLOY_NOT_FOUND_BACKOFF_SECONDS)
|
|
76
|
+
continue
|
|
77
|
+
raise
|
|
78
|
+
except _RETRYABLE_ERRORS as exc:
|
|
79
|
+
if attempt >= max_retries:
|
|
80
|
+
raise
|
|
81
|
+
time.sleep(_backoff_seconds(attempt, exc))
|
|
82
|
+
attempt += 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def async_with_retry(
|
|
86
|
+
func: Callable[[], Awaitable[T]],
|
|
87
|
+
max_retries: int,
|
|
88
|
+
*,
|
|
89
|
+
retry_not_found_on_deploy: bool = False,
|
|
90
|
+
) -> T:
|
|
91
|
+
"""
|
|
92
|
+
Async version of with_retry. Uses asyncio.sleep so the event loop is
|
|
93
|
+
not blocked during backoff waits.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
func: Zero-argument async callable (coroutine function) to invoke.
|
|
97
|
+
max_retries: Maximum number of additional attempts after the first.
|
|
98
|
+
retry_not_found_on_deploy:
|
|
99
|
+
When True, retry HTTP 404 once with a short fixed delay.
|
|
100
|
+
This deploy-specific retry does not consume max_retries.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The return value of func on success.
|
|
104
|
+
"""
|
|
105
|
+
deploy_not_found_retries = 0
|
|
106
|
+
attempt = 0
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
return await func()
|
|
110
|
+
except NotFoundError:
|
|
111
|
+
if (
|
|
112
|
+
retry_not_found_on_deploy
|
|
113
|
+
and deploy_not_found_retries < _MAX_DEPLOY_NOT_FOUND_RETRIES
|
|
114
|
+
):
|
|
115
|
+
deploy_not_found_retries += 1
|
|
116
|
+
await asyncio.sleep(_DEPLOY_NOT_FOUND_BACKOFF_SECONDS)
|
|
117
|
+
continue
|
|
118
|
+
raise
|
|
119
|
+
except _RETRYABLE_ERRORS as exc:
|
|
120
|
+
if attempt >= max_retries:
|
|
121
|
+
raise
|
|
122
|
+
await asyncio.sleep(_backoff_seconds(attempt, exc))
|
|
123
|
+
attempt += 1
|
modus/_streaming.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSE stream parser.
|
|
3
|
+
|
|
4
|
+
parse_sse_stream() converts raw SSE lines into typed RunEvent objects.
|
|
5
|
+
|
|
6
|
+
Events and their handling:
|
|
7
|
+
token -> TokenEvent (yielded to text_stream)
|
|
8
|
+
done -> DoneEvent (ends iteration; use ChatResult from chat())
|
|
9
|
+
error -> ErrorEvent (raised as ModusError)
|
|
10
|
+
cancelled -> CancelledEvent (raised as RunCancelledError)
|
|
11
|
+
stream_timeout -> StreamTimeoutEvent (raised as StreamTimeoutError)
|
|
12
|
+
all others -> silently ignored
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any, Dict, Iterator, Optional
|
|
19
|
+
|
|
20
|
+
from .types.runs import (
|
|
21
|
+
AssistantContentResetEvent,
|
|
22
|
+
CancelledEvent,
|
|
23
|
+
DoneEvent,
|
|
24
|
+
ErrorEvent,
|
|
25
|
+
RunEvent,
|
|
26
|
+
StreamTimeoutEvent,
|
|
27
|
+
TokenEvent,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_SILENTLY_IGNORED = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"heartbeat",
|
|
33
|
+
"ws_chunk",
|
|
34
|
+
"unknown",
|
|
35
|
+
"tool_start",
|
|
36
|
+
"tool_end",
|
|
37
|
+
"tool_chunk",
|
|
38
|
+
"agent_thinking",
|
|
39
|
+
"warning",
|
|
40
|
+
"selected_final_content",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_line(line: str) -> Optional[Dict[str, Any]]:
|
|
46
|
+
"""
|
|
47
|
+
Parse a single SSE line. Returns the parsed JSON payload or None.
|
|
48
|
+
SSE lines that carry data start with "data: ".
|
|
49
|
+
"""
|
|
50
|
+
if not line.startswith("data: "):
|
|
51
|
+
return None
|
|
52
|
+
raw = line[6:].strip()
|
|
53
|
+
if not raw or raw == "[DONE]":
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(raw)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_sse_stream(lines: Iterator[str]) -> Iterator[RunEvent]:
|
|
62
|
+
"""
|
|
63
|
+
Convert a raw SSE line iterator into typed RunEvent objects.
|
|
64
|
+
|
|
65
|
+
Silently drops keepalive and unrecognized event types.
|
|
66
|
+
Yields only: TokenEvent, DoneEvent, ErrorEvent, CancelledEvent,
|
|
67
|
+
StreamTimeoutEvent.
|
|
68
|
+
"""
|
|
69
|
+
for line in lines:
|
|
70
|
+
data = _parse_line(line)
|
|
71
|
+
if data is None:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
event_type = data.get("type", "unknown")
|
|
75
|
+
|
|
76
|
+
if event_type == "token":
|
|
77
|
+
yield TokenEvent(content=data.get("content", ""))
|
|
78
|
+
elif event_type == "done":
|
|
79
|
+
yield DoneEvent(
|
|
80
|
+
run_id=data.get("runId", ""),
|
|
81
|
+
thread_id=data.get("threadId", ""),
|
|
82
|
+
)
|
|
83
|
+
elif event_type == "error":
|
|
84
|
+
msg = (
|
|
85
|
+
data.get("error")
|
|
86
|
+
or data.get("message")
|
|
87
|
+
or data.get("content")
|
|
88
|
+
or "Unknown run error"
|
|
89
|
+
)
|
|
90
|
+
yield ErrorEvent(message=str(msg))
|
|
91
|
+
elif event_type == "cancelled":
|
|
92
|
+
yield CancelledEvent()
|
|
93
|
+
elif event_type == "stream_timeout":
|
|
94
|
+
yield StreamTimeoutEvent()
|
|
95
|
+
elif event_type == "assistant_content_reset":
|
|
96
|
+
content = data.get("content")
|
|
97
|
+
if not isinstance(content, str):
|
|
98
|
+
continue
|
|
99
|
+
yield AssistantContentResetEvent(
|
|
100
|
+
content=content,
|
|
101
|
+
visible_content=(
|
|
102
|
+
str(data["visibleContent"])
|
|
103
|
+
if isinstance(data.get("visibleContent"), str)
|
|
104
|
+
else None
|
|
105
|
+
),
|
|
106
|
+
attempt=(int(data["attempt"]) if isinstance(data.get("attempt"), int) else 1),
|
|
107
|
+
reason=(
|
|
108
|
+
str(data["reason"])
|
|
109
|
+
if isinstance(data.get("reason"), str)
|
|
110
|
+
else "provider_stream_failed"
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
elif event_type in _SILENTLY_IGNORED:
|
|
114
|
+
continue
|
|
115
|
+
# Truly unknown types: silently ignored for forward compatibility
|
modus/_validation.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tiny client-side validators shared across resources.
|
|
2
|
+
|
|
3
|
+
These exist so we fail fast on obvious bad inputs (empty IDs, negative limits)
|
|
4
|
+
before making a network call. They raise ``ValueError`` — caller-visible bugs,
|
|
5
|
+
not server-side conditions.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
# API max page size for context list and values surfaces.
|
|
12
|
+
DEFAULT_MAX_PAGE_SIZE = 200
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_page_size(page_size: int, *, max_page_size: Optional[int] = None) -> None:
|
|
16
|
+
"""Reject page_size values that the backend would reject anyway, locally."""
|
|
17
|
+
if not isinstance(page_size, int) or isinstance(page_size, bool) or page_size < 1:
|
|
18
|
+
raise ValueError(f"page_size must be a positive int, got {page_size!r}")
|
|
19
|
+
if max_page_size is not None and page_size > max_page_size:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"page_size must be <= {max_page_size}, got {page_size!r}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_id(resource_id: Union[int, str], *, name: str = "id") -> None:
|
|
26
|
+
"""Reject empty/whitespace-only string IDs that would produce ambiguous URLs."""
|
|
27
|
+
if isinstance(resource_id, str) and not resource_id.strip():
|
|
28
|
+
raise ValueError(f"{name} must be a non-empty string or int")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Canonical chat model ids from SkillChatRequestDto (keep in sync with the API spec).
|
|
32
|
+
ALLOWED_CHAT_MODELS = frozenset({
|
|
33
|
+
"claude-sonnet-5",
|
|
34
|
+
"claude-sonnet-4.6",
|
|
35
|
+
"claude-opus-4.8",
|
|
36
|
+
"gpt-5.2",
|
|
37
|
+
"gpt-5.5",
|
|
38
|
+
"gpt-5-mini",
|
|
39
|
+
"gpt-oss-120b",
|
|
40
|
+
"gpt-oss-20b",
|
|
41
|
+
"qwen3-235b-a22b-2507",
|
|
42
|
+
"qwen3-coder",
|
|
43
|
+
"qwen3-32b",
|
|
44
|
+
"deepseek-chat-v3.1",
|
|
45
|
+
"minimax-m2.7",
|
|
46
|
+
"llama-4-maverick",
|
|
47
|
+
"llama-4-scout",
|
|
48
|
+
"llama-3.3-70b-instruct",
|
|
49
|
+
"gemini-3.1-pro-preview",
|
|
50
|
+
"gemini-3-flash-preview",
|
|
51
|
+
"grok-4.3-fast",
|
|
52
|
+
"grok-4.3",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_chat_model(model: str) -> None:
|
|
57
|
+
if model not in ALLOWED_CHAT_MODELS:
|
|
58
|
+
supported = ", ".join(sorted(ALLOWED_CHAT_MODELS))
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Unsupported model {model!r}. Choose one of: {supported}."
|
|
61
|
+
)
|
modus/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
modus.management — configuration surface for the Modus SDK.
|
|
3
|
+
|
|
4
|
+
from modus.management import ModusManagement
|
|
5
|
+
mgmt = ModusManagement(api_key="modus_...")
|
|
6
|
+
mgmt.skills.create(name="Analyst", model="gpt-4o")
|
|
7
|
+
|
|
8
|
+
Errors are the shared modus.* hierarchy (modus.NotFoundError, etc.).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._client import AsyncModusManagement, ModusManagement
|
|
12
|
+
from .types import (
|
|
13
|
+
Agent,
|
|
14
|
+
AgentSelection,
|
|
15
|
+
AgentSelectionEntry,
|
|
16
|
+
AgentStatus,
|
|
17
|
+
AgentTrigger,
|
|
18
|
+
AgentTriggerValue,
|
|
19
|
+
AgentType,
|
|
20
|
+
AsyncPage,
|
|
21
|
+
ContextItem,
|
|
22
|
+
ContextItemDeletion,
|
|
23
|
+
ContextItemUpdate,
|
|
24
|
+
ContextType,
|
|
25
|
+
Conversation,
|
|
26
|
+
ConversationListItem,
|
|
27
|
+
CreatedContextItem,
|
|
28
|
+
JsonObject,
|
|
29
|
+
ManualTrigger,
|
|
30
|
+
Memory,
|
|
31
|
+
MemorySearchRequest,
|
|
32
|
+
MemoryUpdate,
|
|
33
|
+
Message,
|
|
34
|
+
Page,
|
|
35
|
+
ScheduleTrigger,
|
|
36
|
+
Skill,
|
|
37
|
+
SkillStatus,
|
|
38
|
+
Toolset,
|
|
39
|
+
TriggerSchedule,
|
|
40
|
+
WorkflowGraph,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Agent",
|
|
45
|
+
"AgentSelection",
|
|
46
|
+
"AgentSelectionEntry",
|
|
47
|
+
"AgentStatus",
|
|
48
|
+
"AgentTrigger",
|
|
49
|
+
"AgentTriggerValue",
|
|
50
|
+
"AgentType",
|
|
51
|
+
"AsyncModusManagement",
|
|
52
|
+
"AsyncPage",
|
|
53
|
+
"ContextItem",
|
|
54
|
+
"ContextItemDeletion",
|
|
55
|
+
"ContextItemUpdate",
|
|
56
|
+
"ContextType",
|
|
57
|
+
"Conversation",
|
|
58
|
+
"ConversationListItem",
|
|
59
|
+
"CreatedContextItem",
|
|
60
|
+
"JsonObject",
|
|
61
|
+
"ManualTrigger",
|
|
62
|
+
"Memory",
|
|
63
|
+
"MemorySearchRequest",
|
|
64
|
+
"MemoryUpdate",
|
|
65
|
+
"Message",
|
|
66
|
+
"ModusManagement",
|
|
67
|
+
"Page",
|
|
68
|
+
"ScheduleTrigger",
|
|
69
|
+
"Skill",
|
|
70
|
+
"SkillStatus",
|
|
71
|
+
"Toolset",
|
|
72
|
+
"TriggerSchedule",
|
|
73
|
+
"WorkflowGraph",
|
|
74
|
+
]
|