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.
- {meshagent_api-0.52.0/meshagent_api.egg-info → meshagent_api-0.52.3}/PKG-INFO +2 -2
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/client.py +1 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/client_test.py +29 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/http.py +22 -7
- meshagent_api-0.52.3/meshagent/api/llm_router_config.py +326 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/managed_agents.py +2 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/managed_agents_test.py +38 -0
- meshagent_api-0.52.3/meshagent/api/oauth_branding.py +15 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/room_server_client.py +34 -1
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/room_server_client_test.py +52 -4
- meshagent_api-0.52.3/meshagent/api/sqlite_transfer.py +162 -0
- meshagent_api-0.52.3/meshagent/api/sqlite_transfer_test.py +116 -0
- meshagent_api-0.52.3/meshagent/api/version.py +1 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3/meshagent_api.egg-info}/PKG-INFO +2 -2
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/SOURCES.txt +4 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/requires.txt +1 -1
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/pyproject.toml +1 -1
- meshagent_api-0.52.0/meshagent/api/version.py +0 -1
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/LICENSE +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/MANIFEST.in +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/README.md +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/__init__.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/agent_content.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/aiohttp_redaction.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/chan.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/crdt.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/dataset_schema.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/entrypoint.js +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/error_codes.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/helpers.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/image_runtime.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/keys.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/messaging.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/messaging_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/mime_types.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/mime_types_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/oauth.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/oauth_scopes.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/oauth_scopes_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/participant.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/participant_token.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/participant_token_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/port_forward.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/port_forward_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/protocol.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/protocol_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/py.typed +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/reasoning_schema.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/registry_auth.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/room_ports.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/runtime.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/runtime_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_document.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_document_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_registry.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/schema_util.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/service_spec_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/service_template_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/services.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/services_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/specs/service.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/sql.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/sql_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/token_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/urls.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/webhooks.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/webhooks_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/websocket_protocol.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent/api/websocket_protocol_test.py +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/dependency_links.txt +0 -0
- {meshagent_api-0.52.0 → meshagent_api-0.52.3}/meshagent_api.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
+
Requires-Dist: pyarrow~=22.0.0
|
|
22
22
|
Requires-Dist: croniter~=6.0
|
|
23
23
|
Provides-Extra: all
|
|
24
24
|
Provides-Extra: sync
|
|
@@ -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__(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.52.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|