meshagent-api 0.52.0__tar.gz → 0.52.3__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.
Files changed (74) hide show
  1. {meshagent_api-0.52.0/meshagent_api.egg-info → meshagent_api-0.52.3}/PKG-INFO +2 -2
  2. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/client.py +1 -0
  3. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/client_test.py +29 -0
  4. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/http.py +22 -7
  5. meshagent_api-0.52.3/meshagent/api/llm_router_config.py +326 -0
  6. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/managed_agents.py +2 -0
  7. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/managed_agents_test.py +38 -0
  8. meshagent_api-0.52.3/meshagent/api/oauth_branding.py +15 -0
  9. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/room_server_client.py +34 -1
  10. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/room_server_client_test.py +52 -4
  11. meshagent_api-0.52.3/meshagent/api/sqlite_transfer.py +162 -0
  12. meshagent_api-0.52.3/meshagent/api/sqlite_transfer_test.py +116 -0
  13. meshagent_api-0.52.3/meshagent/api/version.py +1 -0
  14. {meshagent_api-0.52.0 → meshagent_api-0.52.3/meshagent_api.egg-info}/PKG-INFO +2 -2
  15. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/SOURCES.txt +4 -0
  16. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/requires.txt +1 -1
  17. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/pyproject.toml +1 -1
  18. meshagent_api-0.52.0/meshagent/api/version.py +0 -1
  19. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/LICENSE +0 -0
  20. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/MANIFEST.in +0 -0
  21. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/README.md +0 -0
  22. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/__init__.py +0 -0
  23. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/agent_content.py +0 -0
  24. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/aiohttp_redaction.py +0 -0
  25. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/chan.py +0 -0
  26. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/crdt.py +0 -0
  27. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/dataset_schema.py +0 -0
  28. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/entrypoint.js +0 -0
  29. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/error_codes.py +0 -0
  30. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/helpers.py +0 -0
  31. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/image_runtime.py +0 -0
  32. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/keys.py +0 -0
  33. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/messaging.py +0 -0
  34. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/messaging_test.py +0 -0
  35. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/mime_types.py +0 -0
  36. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/mime_types_test.py +0 -0
  37. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/oauth.py +0 -0
  38. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/oauth_scopes.py +0 -0
  39. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/oauth_scopes_test.py +0 -0
  40. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/participant.py +0 -0
  41. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/participant_token.py +0 -0
  42. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/participant_token_test.py +0 -0
  43. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/port_forward.py +0 -0
  44. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/port_forward_test.py +0 -0
  45. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/protocol.py +0 -0
  46. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/protocol_test.py +0 -0
  47. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/py.typed +0 -0
  48. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/reasoning_schema.py +0 -0
  49. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/registry_auth.py +0 -0
  50. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/room_ports.py +0 -0
  51. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/runtime.py +0 -0
  52. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/runtime_test.py +0 -0
  53. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema.py +0 -0
  54. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_document.py +0 -0
  55. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_document_test.py +0 -0
  56. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_registry.py +0 -0
  57. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_test.py +0 -0
  58. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_util.py +0 -0
  59. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/service_spec_test.py +0 -0
  60. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/service_template_test.py +0 -0
  61. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/services.py +0 -0
  62. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/services_test.py +0 -0
  63. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/specs/service.py +0 -0
  64. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/sql.py +0 -0
  65. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/sql_test.py +0 -0
  66. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/token_test.py +0 -0
  67. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/urls.py +0 -0
  68. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/webhooks.py +0 -0
  69. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/webhooks_test.py +0 -0
  70. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/websocket_protocol.py +0 -0
  71. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/websocket_protocol_test.py +0 -0
  72. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/dependency_links.txt +0 -0
  73. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/top_level.txt +0 -0
  74. {meshagent_api-0.52.0 → meshagent_api-0.52.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-api
3
- Version: 0.52.0
3
+ Version: 0.52.3
4
4
  Summary: Python Server API for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
@@ -18,7 +18,7 @@ Requires-Dist: pydantic~=2.11.7
18
18
  Requires-Dist: jinja2~=3.1.6
19
19
  Requires-Dist: pyyaml~=6.0.2
20
20
  Requires-Dist: certifi~=2026.1.4
21
- Requires-Dist: pyarrow~=21.0.0
21
+ Requires-Dist: pyarrow~=22.0.0
22
22
  Requires-Dist: croniter~=6.0
23
23
  Provides-Extra: all
24
24
  Provides-Extra: sync
@@ -1496,6 +1496,7 @@ class Meshagent:
1496
1496
  "admission": "admission",
1497
1497
  "room": "room",
1498
1498
  "room_roles": "room-roles",
1499
+ "router": "router",
1499
1500
  }
1500
1501
  try:
1501
1502
  return paths[name]
@@ -591,6 +591,35 @@ def test_project_settings_document_methods_reject_unknown_names() -> None:
591
591
  client._project_settings_document_path("legacy")
592
592
 
593
593
 
594
+ @pytest.mark.asyncio
595
+ async def test_router_settings_document_uses_router_route() -> None:
596
+ session = _FakeSession(
597
+ [
598
+ _FakeResponse(status=200, payload={"rules": []}),
599
+ _FakeResponse(status=200, payload={}),
600
+ ]
601
+ )
602
+ client = Meshagent(base_url="http://example.test", token="token", session=session)
603
+
604
+ assert await client.get_project_settings_document("proj_123", "router") == {
605
+ "rules": []
606
+ }
607
+ await client.set_project_settings_document("proj_123", "router", {"rules": []})
608
+
609
+ assert session.calls == [
610
+ (
611
+ "get",
612
+ "http://example.test/accounts/projects/proj_123/settings/router",
613
+ None,
614
+ ),
615
+ (
616
+ "put",
617
+ "http://example.test/accounts/projects/proj_123/settings/router",
618
+ {"rules": []},
619
+ ),
620
+ ]
621
+
622
+
594
623
  @pytest.mark.asyncio
595
624
  async def test_connect_agent_normalizes_legacy_messages_url():
596
625
  session = _FakeSession(
@@ -1,14 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Mapping
4
- from typing import Any, Literal
5
3
  import os
6
4
  import ssl
5
+ from collections.abc import Mapping
6
+ from typing import Any, Literal
7
7
 
8
+ import certifi
8
9
  from aiohttp import ClientSession, TCPConnector
9
10
  from aiohttp.abc import AbstractResolver
10
- from aiohttp.resolver import DefaultResolver
11
- import certifi
11
+ from aiohttp.resolver import DefaultResolver, ThreadedResolver
12
12
 
13
13
  LLM_ANNOTATION_HEADER_PREFIX = "X-Meshagent-Annotation-"
14
14
  _LLM_ANNOTATION_HEADER_PREFIX_LOWER = LLM_ANNOTATION_HEADER_PREFIX.lower()
@@ -27,9 +27,16 @@ def normalize_meshagent_consistency(value: str | None) -> MeshagentConsistency:
27
27
 
28
28
 
29
29
  class _HostAliasResolver(AbstractResolver):
30
- def __init__(self, aliases: Mapping[str, str]) -> None:
30
+ def __init__(
31
+ self,
32
+ aliases: Mapping[str, str],
33
+ *,
34
+ use_threaded_resolver: bool = False,
35
+ ) -> None:
31
36
  self._aliases = aliases
32
- self._resolver = DefaultResolver()
37
+ self._resolver: AbstractResolver = (
38
+ ThreadedResolver() if use_threaded_resolver else DefaultResolver()
39
+ )
33
40
 
34
41
  async def resolve(
35
42
  self,
@@ -69,8 +76,16 @@ def new_tcp_connector(*args: Any, **kwargs: Any) -> TCPConnector:
69
76
  kwargs["ssl"] = ssl_context
70
77
  if "resolver" not in kwargs:
71
78
  aliases = _http_host_aliases()
79
+ use_threaded_resolver = os.environ.get(
80
+ "MESHAGENT_HTTP_THREADED_RESOLVER", ""
81
+ ).strip().lower() in {"1", "true", "yes"}
72
82
  if aliases:
73
- kwargs["resolver"] = _HostAliasResolver(aliases)
83
+ kwargs["resolver"] = _HostAliasResolver(
84
+ aliases,
85
+ use_threaded_resolver=use_threaded_resolver,
86
+ )
87
+ elif use_threaded_resolver:
88
+ kwargs["resolver"] = ThreadedResolver()
74
89
  return TCPConnector(*args, **kwargs)
75
90
 
76
91
 
@@ -0,0 +1,326 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from enum import StrEnum
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8
+
9
+ _PORTABLE_TOOL_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]{0,63}$")
10
+ _DOMAIN_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
11
+
12
+
13
+ def _non_empty(value: str, *, field: str) -> str:
14
+ if value.strip() == "":
15
+ raise ValueError(f"{field} must not be empty")
16
+ return value
17
+
18
+
19
+ def _portable_tool_name(value: str) -> str:
20
+ value = value.strip()
21
+ if _PORTABLE_TOOL_NAME.fullmatch(value) is None:
22
+ raise ValueError(
23
+ "name must start with a letter or underscore, contain only letters, "
24
+ "numbers, underscores, or dashes, and be at most 64 characters"
25
+ )
26
+ return value
27
+
28
+
29
+ def _domain_name(value: str) -> str:
30
+ value = value.strip().lower().rstrip(".")
31
+ if (
32
+ value == ""
33
+ or len(value) > 253
34
+ or "://" in value
35
+ or "/" in value
36
+ or any(_DOMAIN_LABEL.fullmatch(label) is None for label in value.split("."))
37
+ ):
38
+ raise ValueError("domain entries must be bare DNS host names")
39
+ return value
40
+
41
+
42
+ class _StrictModel(BaseModel):
43
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
44
+
45
+
46
+ class AllowedLlmModel(_StrictModel):
47
+ provider: str
48
+ model: str
49
+
50
+ @field_validator("provider")
51
+ @classmethod
52
+ def _provider(cls, value: str) -> str:
53
+ return _non_empty(value, field="provider").strip().lower()
54
+
55
+ @field_validator("model")
56
+ @classmethod
57
+ def _model(cls, value: str) -> str:
58
+ return _non_empty(value, field="model").strip()
59
+
60
+
61
+ class LlmAppFilterMode(StrEnum):
62
+ BLOCKED = "blocked"
63
+ ALLOWED = "allowed"
64
+
65
+
66
+ class LlmAppFilter(_StrictModel):
67
+ mode: LlmAppFilterMode = LlmAppFilterMode.BLOCKED
68
+ user_agent_patterns: list[str] = Field(
69
+ default_factory=list, alias="userAgentPatterns"
70
+ )
71
+ block_missing_user_agent: bool = Field(default=False, alias="blockMissingUserAgent")
72
+
73
+ @field_validator("user_agent_patterns")
74
+ @classmethod
75
+ def _patterns(cls, values: list[str]) -> list[str]:
76
+ return [_non_empty(value, field="user-agent pattern") for value in values]
77
+
78
+
79
+ class LlmRouterProvider(StrEnum):
80
+ OPENAI = "openai"
81
+ ANTHROPIC = "anthropic"
82
+ GROK = "grok"
83
+
84
+
85
+ class LlmRouterApi(StrEnum):
86
+ COMPLETIONS = "completions"
87
+ CHAT_COMPLETIONS = "chat_completions"
88
+ RESPONSES = "responses"
89
+ MESSAGES = "messages"
90
+ REALTIME = "realtime"
91
+
92
+
93
+ class LlmRouterTransport(StrEnum):
94
+ HTTP = "http"
95
+ WEBSOCKET = "websocket"
96
+
97
+
98
+ class LlmRouterMatch(_StrictModel):
99
+ header: dict[str, str] = Field(default_factory=dict)
100
+ models: list[str] = Field(default_factory=list)
101
+ providers: list[LlmRouterProvider] = Field(default_factory=list)
102
+ apis: list[LlmRouterApi] = Field(default_factory=list)
103
+ transports: list[LlmRouterTransport] = Field(default_factory=list)
104
+
105
+ @field_validator("header")
106
+ @classmethod
107
+ def _headers(cls, values: dict[str, str]) -> dict[str, str]:
108
+ result: dict[str, str] = {}
109
+ for name, pattern in values.items():
110
+ name = name.strip().lower()
111
+ if name == "" or not all(
112
+ character.isalnum() or character in "!#$%&'*+-.^_`|~"
113
+ for character in name
114
+ ):
115
+ raise ValueError("header names must be valid HTTP field names")
116
+ if name in result:
117
+ raise ValueError(f"duplicate header name: {name}")
118
+ result[name] = _non_empty(pattern, field="header pattern")
119
+ return result
120
+
121
+ @field_validator("models")
122
+ @classmethod
123
+ def _models(cls, values: list[str]) -> list[str]:
124
+ return [_non_empty(value, field="model pattern") for value in values]
125
+
126
+ @field_validator("providers", mode="before")
127
+ @classmethod
128
+ def _providers(cls, value: object) -> object:
129
+ if isinstance(value, list):
130
+ return [
131
+ item.strip().lower() if isinstance(item, str) else item
132
+ for item in value
133
+ ]
134
+ return value
135
+
136
+
137
+ class LlmInstructionTransform(_StrictModel):
138
+ replace: str | None = None
139
+ append: str | None = None
140
+
141
+ @field_validator("replace", "append")
142
+ @classmethod
143
+ def _text(cls, value: str | None) -> str | None:
144
+ if value is not None:
145
+ _non_empty(value, field="instruction")
146
+ return value
147
+
148
+ @model_validator(mode="after")
149
+ def _operation(self) -> LlmInstructionTransform:
150
+ if self.replace is None and self.append is None:
151
+ raise ValueError("instructions requires replace or append")
152
+ return self
153
+
154
+
155
+ class LlmToolSelector(_StrictModel):
156
+ type: str
157
+ name: str | None = None
158
+
159
+ @field_validator("type")
160
+ @classmethod
161
+ def _type(cls, value: str) -> str:
162
+ return _non_empty(value, field="tool type")
163
+
164
+ @field_validator("name")
165
+ @classmethod
166
+ def _name(cls, value: str | None) -> str | None:
167
+ return None if value is None else _non_empty(value, field="tool name")
168
+
169
+
170
+ class LlmWebSearchTool(_StrictModel):
171
+ type: Literal["web_search"]
172
+ search_context_size: Literal["low", "medium", "high"] | None = None
173
+ allowed_domains: list[str] = Field(default_factory=list)
174
+ blocked_domains: list[str] = Field(default_factory=list)
175
+
176
+ @field_validator("allowed_domains", "blocked_domains")
177
+ @classmethod
178
+ def _domains(cls, values: list[str]) -> list[str]:
179
+ result = [_domain_name(value) for value in values]
180
+ if len(result) != len(set(result)):
181
+ raise ValueError("domain lists must not contain duplicates")
182
+ return result
183
+
184
+ @model_validator(mode="after")
185
+ def _domain_mode(self) -> LlmWebSearchTool:
186
+ if self.allowed_domains and self.blocked_domains:
187
+ raise ValueError(
188
+ "allowed_domains and blocked_domains are mutually exclusive"
189
+ )
190
+ return self
191
+
192
+
193
+ class LlmStaticTool(_StrictModel):
194
+ type: Literal["static"]
195
+ name: str
196
+ description: str
197
+ content: str
198
+
199
+ @field_validator("name")
200
+ @classmethod
201
+ def _name(cls, value: str) -> str:
202
+ return _portable_tool_name(value)
203
+
204
+ @field_validator("description", "content")
205
+ @classmethod
206
+ def _text(cls, value: str) -> str:
207
+ return _non_empty(value, field="static tool text")
208
+
209
+
210
+ class LlmAdvisor(_StrictModel):
211
+ provider: LlmRouterProvider
212
+ model: str
213
+ instructions: str | None = None
214
+ max_output_tokens: int | None = Field(default=None, ge=1)
215
+
216
+ @field_validator("provider", mode="before")
217
+ @classmethod
218
+ def _provider(cls, value: object) -> object:
219
+ return value.strip().lower() if isinstance(value, str) else value
220
+
221
+ @field_validator("model")
222
+ @classmethod
223
+ def _model(cls, value: str) -> str:
224
+ return _non_empty(value, field="advisor model").strip()
225
+
226
+ @field_validator("instructions")
227
+ @classmethod
228
+ def _instructions(cls, value: str | None) -> str | None:
229
+ if value is not None:
230
+ _non_empty(value, field="advisor instructions")
231
+ return value
232
+
233
+
234
+ class LlmAdviceTool(_StrictModel):
235
+ type: Literal["advice"]
236
+ name: str
237
+ description: str
238
+ strategy: Literal["parallel"] = "parallel"
239
+ advisors: list[LlmAdvisor] = Field(min_length=1, max_length=8)
240
+
241
+ @field_validator("name")
242
+ @classmethod
243
+ def _name(cls, value: str) -> str:
244
+ return _portable_tool_name(value)
245
+
246
+ @field_validator("description")
247
+ @classmethod
248
+ def _description(cls, value: str) -> str:
249
+ return _non_empty(value, field="advice tool description")
250
+
251
+
252
+ LlmEnsureTool = Annotated[
253
+ LlmWebSearchTool | LlmStaticTool | LlmAdviceTool,
254
+ Field(discriminator="type"),
255
+ ]
256
+
257
+
258
+ class LlmToolTransform(_StrictModel):
259
+ deny: list[LlmToolSelector] = Field(default_factory=list)
260
+ ignore: list[LlmToolSelector] = Field(default_factory=list)
261
+ ensure: list[LlmEnsureTool] = Field(default_factory=list)
262
+
263
+ @model_validator(mode="after")
264
+ def _operations(self) -> LlmToolTransform:
265
+ if not self.deny and not self.ignore and not self.ensure:
266
+ raise ValueError("tools requires deny, ignore, or ensure")
267
+ identities: set[tuple[str, str | None]] = set()
268
+ for tool in self.ensure:
269
+ name = (
270
+ tool.name if isinstance(tool, (LlmStaticTool, LlmAdviceTool)) else None
271
+ )
272
+ identity = (
273
+ "function"
274
+ if isinstance(tool, (LlmStaticTool, LlmAdviceTool))
275
+ else tool.type,
276
+ name,
277
+ )
278
+ if identity in identities:
279
+ raise ValueError(f"duplicate ensured tool: {tool.type}")
280
+ identities.add(identity)
281
+ return self
282
+
283
+
284
+ class LlmRouterThen(_StrictModel):
285
+ instructions: LlmInstructionTransform | None = None
286
+ tools: LlmToolTransform | None = None
287
+
288
+ @model_validator(mode="after")
289
+ def _transform(self) -> LlmRouterThen:
290
+ if self.instructions is None and self.tools is None:
291
+ raise ValueError("then requires instructions or tools")
292
+ return self
293
+
294
+
295
+ class LlmRouterRule(_StrictModel):
296
+ name: str | None = None
297
+ when: LlmRouterMatch = Field(default_factory=LlmRouterMatch)
298
+ then: LlmRouterThen
299
+
300
+ @field_validator("name")
301
+ @classmethod
302
+ def _name(cls, value: str | None) -> str | None:
303
+ return None if value is None else _non_empty(value, field="rule name").strip()
304
+
305
+
306
+ class LlmRouterConfig(_StrictModel):
307
+ allowed_models: list[AllowedLlmModel] | None = Field(
308
+ default=None, alias="allowedModels"
309
+ )
310
+ apps: LlmAppFilter = Field(default_factory=LlmAppFilter)
311
+ rules: list[LlmRouterRule] = Field(default_factory=list)
312
+
313
+ @model_validator(mode="before")
314
+ @classmethod
315
+ def _legacy_list(cls, value: Any) -> Any:
316
+ return {"allowedModels": value} if isinstance(value, list) else value
317
+
318
+ @model_validator(mode="after")
319
+ def _rule_names(self) -> LlmRouterConfig:
320
+ names = [rule.name for rule in self.rules if rule.name is not None]
321
+ if len(names) != len(set(names)):
322
+ raise ValueError("rule names must be unique")
323
+ return self
324
+
325
+ def to_document(self) -> dict[str, Any]:
326
+ return self.model_dump(mode="json", by_alias=True)
@@ -77,6 +77,8 @@ class ManagedAgentToolkit(BaseModel):
77
77
 
78
78
  class ManagedAgentWebSearch(ManagedAgentToolkit):
79
79
  type: Literal["web_search"] = "web_search"
80
+ allowed_domains: list[str] | None = None
81
+ blocked_domains: list[str] | None = None
80
82
 
81
83
 
82
84
  class ManagedAgentWebFetch(ManagedAgentToolkit):
@@ -187,3 +187,41 @@ def test_managed_agent_spec_drops_legacy_mcp_secret_authorization():
187
187
  server_data = toolkit.servers[0].model_dump(mode="json")
188
188
  assert server_data["server_label"] == "linear"
189
189
  assert "authorization" not in server_data
190
+
191
+
192
+ @pytest.mark.parametrize(
193
+ "filters",
194
+ [
195
+ {},
196
+ {"allowed_domains": []},
197
+ {"blocked_domains": []},
198
+ {"allowed_domains": ["example.com"]},
199
+ {"blocked_domains": ["blocked.example.com"]},
200
+ {
201
+ "allowed_domains": ["example.com"],
202
+ "blocked_domains": ["blocked.example.com"],
203
+ },
204
+ ],
205
+ )
206
+ def test_managed_web_search_domain_filters_round_trip(filters):
207
+ toolkit = {"type": "web_search", **filters}
208
+ spec = ManagedAgentSpec.model_validate(
209
+ {
210
+ "metadata": {"name": "researcher"},
211
+ "allowed_models": [{"provider": "openai", "model": "gpt-4.1"}],
212
+ "toolkits": [toolkit],
213
+ }
214
+ )
215
+ assert spec.model_dump(exclude_none=True)["toolkits"] == [toolkit]
216
+
217
+
218
+ @pytest.mark.parametrize("field", ["allowed_domains", "blocked_domains"])
219
+ def test_managed_web_search_domain_filters_require_lists(field):
220
+ with pytest.raises(ValidationError):
221
+ ManagedAgentSpec.model_validate(
222
+ {
223
+ "metadata": {"name": "researcher"},
224
+ "allowed_models": [{"provider": "openai", "model": "gpt-4.1"}],
225
+ "toolkits": [{"type": "web_search", field: "example.com"}],
226
+ }
227
+ )
@@ -0,0 +1,15 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, HttpUrl, field_validator
4
+
5
+
6
+ class OAuthClientBranding(BaseModel):
7
+ """Optional login branding stored in an OAuth client's metadata."""
8
+
9
+ logo_url: HttpUrl | None = None
10
+ theme: Literal["light", "dark", "auto"] = "auto"
11
+
12
+ @field_validator("logo_url", mode="before")
13
+ @classmethod
14
+ def empty_logo_is_default(cls, value: object) -> object:
15
+ return None if value == "" else value
@@ -710,7 +710,7 @@ def _is_retryable_startup_close(
710
710
  kind: ProtocolCloseKind,
711
711
  reason: str | None,
712
712
  ) -> bool:
713
- if kind == ProtocolCloseKind.ERROR:
713
+ if kind in (ProtocolCloseKind.ERROR, ProtocolCloseKind.SERVER):
714
714
  return True
715
715
 
716
716
  normalized_reason = (reason or "").lower()
@@ -8677,6 +8677,39 @@ class SqliteClient:
8677
8677
  finally:
8678
8678
  input_stream.close()
8679
8679
 
8680
+ async def backup(
8681
+ self,
8682
+ *,
8683
+ database: str,
8684
+ namespace: Optional[list[str]] = None,
8685
+ ) -> AsyncIterator[bytes]:
8686
+ """Stream a consistent SQLite file, verifying its size and SHA-256 at EOF.
8687
+
8688
+ Consume the entire iterator before publishing the downloaded file.
8689
+ Requires unrestricted table access to this database.
8690
+ """
8691
+ from meshagent.api.sqlite_transfer import backup
8692
+
8693
+ async for chunk in backup(self, database, namespace):
8694
+ yield chunk
8695
+
8696
+ async def restore(
8697
+ self,
8698
+ *,
8699
+ database: str,
8700
+ source: AsyncIterable[bytes],
8701
+ namespace: Optional[list[str]] = None,
8702
+ ) -> None:
8703
+ """Restore chunks of at most 256 KiB as a new, durably stored database.
8704
+
8705
+ Existing names are rejected. The server validates integrity and foreign
8706
+ keys before creating the target. A lost final acknowledgement leaves the
8707
+ outcome uncertain; retries never overwrite an existing database.
8708
+ """
8709
+ from meshagent.api.sqlite_transfer import restore
8710
+
8711
+ await restore(self, database, namespace, source)
8712
+
8680
8713
  async def list_databases(
8681
8714
  self, *, namespace: Optional[list[str]] = None
8682
8715
  ) -> list[str]:
@@ -702,15 +702,16 @@ class _ClosingProtocol(_FakeProtocol):
702
702
 
703
703
 
704
704
  class _ClosingProtocolWithReason(_FakeProtocol):
705
- def __init__(self) -> None:
705
+ def __init__(self, reason: str = "websocket closed with code 1008") -> None:
706
706
  super().__init__()
707
707
  self._close_kind = ProtocolCloseKind.SERVER
708
+ self.reason = reason
708
709
 
709
710
  async def wait_for_close(self) -> None:
710
711
  return None
711
712
 
712
713
  def close_reason(self) -> str | None:
713
- return "websocket closed with code 1008"
714
+ return self.reason
714
715
 
715
716
 
716
717
  class _StatusClosingProtocol(_FakeProtocol):
@@ -1803,7 +1804,7 @@ class _SharedReconnectRoomController:
1803
1804
  @pytest.mark.asyncio
1804
1805
  async def test_room_client_enter_raises_if_connection_closes_before_ready() -> None:
1805
1806
  protocol = _ClosingProtocol()
1806
- client = RoomClient(protocol_factory=protocol.create_factory())
1807
+ client = RoomClient(protocol_factory=protocol.create_factory(), reconnect_timeout=0)
1807
1808
 
1808
1809
  with pytest.raises(
1809
1810
  RoomException,
@@ -1823,7 +1824,7 @@ async def test_room_client_enter_includes_close_reason_when_connection_closes_ea
1823
1824
  None
1824
1825
  ):
1825
1826
  protocol = _ClosingProtocolWithReason()
1826
- client = RoomClient(protocol_factory=protocol.create_factory())
1827
+ client = RoomClient(protocol_factory=protocol.create_factory(), reconnect_timeout=0)
1827
1828
 
1828
1829
  with pytest.raises(
1829
1830
  RoomException,
@@ -1852,6 +1853,53 @@ async def test_room_client_enter_does_not_include_last_room_status_when_connecti
1852
1853
  await client.__aenter__()
1853
1854
 
1854
1855
 
1856
+ @pytest.mark.asyncio
1857
+ @pytest.mark.parametrize("reason", [None, "websocket closed with code 1000"])
1858
+ async def test_room_client_enter_retries_server_close_before_ready(reason) -> None:
1859
+ controller = _ReconnectRoomController(schema=_simple_thread_schema())
1860
+ failed_protocols = []
1861
+
1862
+ def protocol_factory():
1863
+ if len(failed_protocols) < 2:
1864
+ protocol = (
1865
+ _ClosingProtocol()
1866
+ if reason is None
1867
+ else _ClosingProtocolWithReason(reason=reason)
1868
+ )
1869
+ failed_protocols.append(protocol)
1870
+ return protocol
1871
+ return controller.protocol_factory()
1872
+
1873
+ async with RoomClient(
1874
+ protocol_factory=protocol_factory,
1875
+ reconnect_timeout=2,
1876
+ ) as room:
1877
+ assert room.is_connected
1878
+ assert len(failed_protocols) == 2
1879
+ assert all(protocol.exited for protocol in failed_protocols)
1880
+ assert len(controller.protocols) == 1
1881
+
1882
+
1883
+ @pytest.mark.asyncio
1884
+ async def test_room_client_server_close_startup_retries_respect_timeout() -> None:
1885
+ protocols = []
1886
+
1887
+ def protocol_factory():
1888
+ protocol = _ClosingProtocolWithReason(reason="websocket closed with code 1000")
1889
+ protocols.append(protocol)
1890
+ return protocol
1891
+
1892
+ room = RoomClient(
1893
+ protocol_factory=protocol_factory,
1894
+ reconnect_timeout=0.03,
1895
+ )
1896
+ with pytest.raises(RoomException, match="room reconnect timed out"):
1897
+ await asyncio.wait_for(room.__aenter__(), timeout=1)
1898
+ assert len(protocols) >= 2
1899
+ assert all(protocol.exited for protocol in protocols)
1900
+ assert room.is_closed
1901
+
1902
+
1855
1903
  @pytest.mark.asyncio
1856
1904
  async def test_room_client_enter_retries_1013_server_close_before_ready() -> None:
1857
1905
  controller = _ReconnectRoomController(schema=_simple_thread_schema())
@@ -0,0 +1,162 @@
1
+ """Bounded, checksum-verified whole-database transfers for the SQLite toolkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ from collections.abc import AsyncIterable, AsyncIterator
8
+ from typing import TYPE_CHECKING, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
11
+
12
+ from meshagent.api.messaging import BinaryContent, Content, ErrorContent
13
+ from meshagent.api.room_server_client import RoomException
14
+
15
+ if TYPE_CHECKING:
16
+ from meshagent.api.room_server_client import SqliteClient
17
+
18
+ CHUNK_SIZE = 256 * 1024
19
+
20
+
21
+ class TransferComplete(BaseModel):
22
+ model_config = ConfigDict(extra="forbid", strict=True)
23
+ kind: Literal["complete"]
24
+ size: int = Field(ge=0)
25
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
26
+
27
+
28
+ class _TransferInput:
29
+ def __init__(
30
+ self,
31
+ database: str,
32
+ namespace: list[str] | None,
33
+ source: AsyncIterable[bytes] | None = None,
34
+ ) -> None:
35
+ self.start = {"kind": "start", "database": database, "namespace": namespace}
36
+ self.source = source.__aiter__() if source is not None else None
37
+ self.pulls: asyncio.Queue[None] = asyncio.Queue()
38
+ self.closed = False
39
+ self.sent_start = False
40
+ self.complete: TransferComplete | None = None
41
+ self.digest = hashlib.sha256()
42
+ self.size = 0
43
+ self.error: Exception | None = None
44
+
45
+ def request_next(self) -> None:
46
+ self.pulls.put_nowait(None)
47
+
48
+ def close(self) -> None:
49
+ self.closed = True
50
+ self.request_next()
51
+
52
+ def __aiter__(self) -> _TransferInput:
53
+ return self
54
+
55
+ async def __anext__(self) -> Content:
56
+ if not self.sent_start:
57
+ self.sent_start = True
58
+ return BinaryContent(data=b"", headers=self.start)
59
+ await self.pulls.get()
60
+ if self.closed:
61
+ raise StopAsyncIteration
62
+ if self.source is None:
63
+ return BinaryContent(data=b"", headers={"kind": "pull"})
64
+ if self.complete is not None:
65
+ raise RoomException("Unexpected pull after SQLite upload completed")
66
+ try:
67
+ data = await self.source.__anext__()
68
+ if not isinstance(data, bytes) or not 0 < len(data) <= CHUNK_SIZE:
69
+ raise ValueError(
70
+ f"SQLite upload chunks must contain 1–{CHUNK_SIZE} bytes"
71
+ )
72
+ except StopAsyncIteration:
73
+ self.complete = TransferComplete(
74
+ kind="complete", size=self.size, sha256=self.digest.hexdigest()
75
+ )
76
+ return BinaryContent(data=b"", headers=self.complete.model_dump())
77
+ except Exception as error:
78
+ self.error = error
79
+ return BinaryContent(data=b"", headers={"kind": "abort"})
80
+ self.digest.update(data)
81
+ self.size += len(data)
82
+ return BinaryContent(data=data, headers={"kind": "data"})
83
+
84
+
85
+ def _binary(chunk: Content) -> BinaryContent:
86
+ if isinstance(chunk, ErrorContent):
87
+ raise RoomException(chunk.text, code=chunk.code)
88
+ if not isinstance(chunk, BinaryContent):
89
+ raise RoomException("SQLite transfer ended without verified completion")
90
+ return chunk
91
+
92
+
93
+ def _complete(chunk: BinaryContent) -> TransferComplete:
94
+ try:
95
+ result = TransferComplete.model_validate(chunk.headers)
96
+ except ValidationError as error:
97
+ raise RoomException("Invalid SQLite transfer completion") from error
98
+ if chunk.data:
99
+ raise RoomException("Invalid SQLite transfer completion payload")
100
+ return result
101
+
102
+
103
+ async def backup(
104
+ client: SqliteClient,
105
+ database: str,
106
+ namespace: list[str] | None,
107
+ ) -> AsyncIterator[bytes]:
108
+ """Yield file bytes; callers must consume through verified completion."""
109
+ inputs = _TransferInput(database, namespace)
110
+ digest = hashlib.sha256()
111
+ size = 0
112
+ try:
113
+ responses = await client._invoke_stream(operation="backup", input=inputs)
114
+ inputs.request_next()
115
+ async for response in responses:
116
+ chunk = _binary(response)
117
+ if chunk.headers.get("kind") == "complete":
118
+ result = _complete(chunk)
119
+ if result.size != size or result.sha256 != digest.hexdigest():
120
+ raise RoomException("SQLite backup size or SHA-256 mismatch")
121
+ return
122
+ if (
123
+ chunk.headers.get("kind") != "data"
124
+ or not 0 < len(chunk.data) <= CHUNK_SIZE
125
+ ):
126
+ raise RoomException("Invalid SQLite backup data frame")
127
+ digest.update(chunk.data)
128
+ size += len(chunk.data)
129
+ yield chunk.data
130
+ inputs.request_next()
131
+ raise RoomException("SQLite backup interrupted before completion")
132
+ finally:
133
+ inputs.close()
134
+
135
+
136
+ async def restore(
137
+ client: SqliteClient,
138
+ database: str,
139
+ namespace: list[str] | None,
140
+ source: AsyncIterable[bytes],
141
+ ) -> None:
142
+ inputs = _TransferInput(database, namespace, source)
143
+ try:
144
+ responses = await client._invoke_stream(operation="restore", input=inputs)
145
+ async for response in responses:
146
+ if inputs.error is not None:
147
+ raise inputs.error
148
+ chunk = _binary(response)
149
+ if chunk.headers.get("kind") == "pull" and not chunk.data:
150
+ inputs.request_next()
151
+ elif chunk.headers.get("kind") == "complete":
152
+ result = _complete(chunk)
153
+ if inputs.complete is None or result != inputs.complete:
154
+ raise RoomException(
155
+ "SQLite restore acknowledgement does not match upload"
156
+ )
157
+ return
158
+ else:
159
+ raise RoomException("Invalid SQLite restore response")
160
+ raise RoomException("SQLite restore ended without durable completion")
161
+ finally:
162
+ inputs.close()
@@ -0,0 +1,116 @@
1
+ import hashlib
2
+ from collections.abc import AsyncIterable
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+
7
+ from meshagent.api.messaging import BinaryContent, Content, ErrorContent
8
+ from meshagent.api.room_server_client import RoomClient, RoomException, SqliteClient
9
+ from meshagent.api.sqlite_transfer import CHUNK_SIZE
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ @pytest.mark.parametrize("failure", [None, "checksum", "truncated", "server"])
14
+ async def test_backup_checks_completion_and_requests_one_chunk_at_a_time(failure):
15
+ client = SqliteClient(AsyncMock(spec=RoomClient))
16
+ payload = b"SQLite format 3\x00" + b"x" * CHUNK_SIZE
17
+
18
+ async def invoke(*, operation: str, input: AsyncIterable[Content]):
19
+ assert operation == "backup"
20
+ iterator = input.__aiter__()
21
+
22
+ async def responses():
23
+ start = await iterator.__anext__()
24
+ assert start.headers == {
25
+ "kind": "start",
26
+ "database": "app",
27
+ "namespace": ["team"],
28
+ }
29
+ for offset in range(0, len(payload), CHUNK_SIZE):
30
+ pull = await iterator.__anext__()
31
+ assert pull.headers == {"kind": "pull"}
32
+ yield BinaryContent(
33
+ headers={"kind": "data"}, data=payload[offset : offset + CHUNK_SIZE]
34
+ )
35
+ if failure == "truncated":
36
+ return
37
+ if failure == "server":
38
+ yield ErrorContent(text="read failed", code=1002)
39
+ return
40
+ yield BinaryContent(
41
+ data=b"",
42
+ headers={
43
+ "kind": "complete",
44
+ "size": len(payload),
45
+ "sha256": "0" * 64
46
+ if failure == "checksum"
47
+ else hashlib.sha256(payload).hexdigest(),
48
+ },
49
+ )
50
+
51
+ return responses()
52
+
53
+ client._invoke_stream = invoke
54
+ if failure:
55
+ with pytest.raises(RoomException):
56
+ _ = [
57
+ chunk
58
+ async for chunk in client.backup(database="app", namespace=["team"])
59
+ ]
60
+ else:
61
+ assert (
62
+ b"".join(
63
+ [
64
+ chunk
65
+ async for chunk in client.backup(database="app", namespace=["team"])
66
+ ]
67
+ )
68
+ == payload
69
+ )
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ @pytest.mark.parametrize("failure", [None, "truncated", "mismatch", "source"])
74
+ async def test_restore_requires_matching_server_acknowledgement(failure):
75
+ client = SqliteClient(AsyncMock(spec=RoomClient))
76
+ payload = b"SQLite format 3\x00" + b"x" * 512
77
+
78
+ async def source():
79
+ yield payload
80
+ if failure == "source":
81
+ raise OSError("local read failed")
82
+
83
+ async def invoke(*, operation, input):
84
+ assert operation == "restore"
85
+ iterator = input.__aiter__()
86
+
87
+ async def responses():
88
+ assert (await iterator.__anext__()).headers["database"] == "new"
89
+ yield BinaryContent(data=b"", headers={"kind": "pull"})
90
+ assert (await iterator.__anext__()).data == payload
91
+ yield BinaryContent(data=b"", headers={"kind": "pull"})
92
+ final = await iterator.__anext__()
93
+ if failure == "source":
94
+ assert final.headers["kind"] == "abort"
95
+ yield ErrorContent(text="aborted", code=1002)
96
+ return
97
+ assert final.headers == {
98
+ "kind": "complete",
99
+ "size": len(payload),
100
+ "sha256": hashlib.sha256(payload).hexdigest(),
101
+ }
102
+ if failure == "truncated":
103
+ return
104
+ headers = dict(final.headers)
105
+ if failure == "mismatch":
106
+ headers["size"] += 1
107
+ yield BinaryContent(data=b"", headers=headers)
108
+
109
+ return responses()
110
+
111
+ client._invoke_stream = invoke
112
+ if failure:
113
+ with pytest.raises(OSError if failure == "source" else RoomException):
114
+ await client.restore(database="new", source=source())
115
+ else:
116
+ await client.restore(database="new", source=source())
@@ -0,0 +1 @@
1
+ __version__ = "0.52.3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-api
3
- Version: 0.52.0
3
+ Version: 0.52.3
4
4
  Summary: Python Server API for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
@@ -18,7 +18,7 @@ Requires-Dist: pydantic~=2.11.7
18
18
  Requires-Dist: jinja2~=3.1.6
19
19
  Requires-Dist: pyyaml~=6.0.2
20
20
  Requires-Dist: certifi~=2026.1.4
21
- Requires-Dist: pyarrow~=21.0.0
21
+ Requires-Dist: pyarrow~=22.0.0
22
22
  Requires-Dist: croniter~=6.0
23
23
  Provides-Extra: all
24
24
  Provides-Extra: sync
@@ -16,6 +16,7 @@ meshagent/api/helpers.py
16
16
  meshagent/api/http.py
17
17
  meshagent/api/image_runtime.py
18
18
  meshagent/api/keys.py
19
+ meshagent/api/llm_router_config.py
19
20
  meshagent/api/managed_agents.py
20
21
  meshagent/api/managed_agents_test.py
21
22
  meshagent/api/messaging.py
@@ -23,6 +24,7 @@ meshagent/api/messaging_test.py
23
24
  meshagent/api/mime_types.py
24
25
  meshagent/api/mime_types_test.py
25
26
  meshagent/api/oauth.py
27
+ meshagent/api/oauth_branding.py
26
28
  meshagent/api/oauth_scopes.py
27
29
  meshagent/api/oauth_scopes_test.py
28
30
  meshagent/api/participant.py
@@ -52,6 +54,8 @@ meshagent/api/services.py
52
54
  meshagent/api/services_test.py
53
55
  meshagent/api/sql.py
54
56
  meshagent/api/sql_test.py
57
+ meshagent/api/sqlite_transfer.py
58
+ meshagent/api/sqlite_transfer_test.py
55
59
  meshagent/api/token_test.py
56
60
  meshagent/api/urls.py
57
61
  meshagent/api/version.py
@@ -7,7 +7,7 @@ pydantic~=2.11.7
7
7
  jinja2~=3.1.6
8
8
  pyyaml~=6.0.2
9
9
  certifi~=2026.1.4
10
- pyarrow~=21.0.0
10
+ pyarrow~=22.0.0
11
11
  croniter~=6.0
12
12
 
13
13
  [all]
@@ -19,7 +19,7 @@ dependencies = [
19
19
  "jinja2~=3.1.6",
20
20
  "pyyaml~=6.0.2",
21
21
  "certifi~=2026.1.4",
22
- "pyarrow~=21.0.0",
22
+ "pyarrow~=22.0.0",
23
23
  "croniter~=6.0",
24
24
  ]
25
25
  classifiers = [
@@ -1 +0,0 @@
1
- __version__ = "0.52.0"
File without changes
File without changes
File without changes