mmar-mcli 1.0.6__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.
- mmar_mcli-1.0.6/LICENSE +21 -0
- mmar_mcli-1.0.6/PKG-INFO +29 -0
- mmar_mcli-1.0.6/README.md +1 -0
- mmar_mcli-1.0.6/pyproject.toml +69 -0
- mmar_mcli-1.0.6/src/mmar_mcli/__init__.py +5 -0
- mmar_mcli-1.0.6/src/mmar_mcli/io_aiohttp.py +55 -0
- mmar_mcli-1.0.6/src/mmar_mcli/maestro_client.py +150 -0
- mmar_mcli-1.0.6/src/mmar_mcli/maestro_client_dummy.py +38 -0
- mmar_mcli-1.0.6/src/mmar_mcli/models.py +20 -0
- mmar_mcli-1.0.6/src/mmar_mcli/utils.py +0 -0
mmar_mcli-1.0.6/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 AIRI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mmar_mcli-1.0.6/PKG-INFO
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mmar-mcli
|
|
3
|
+
Version: 1.0.6
|
|
4
|
+
Summary: Client to Maestro
|
|
5
|
+
Keywords:
|
|
6
|
+
Author: tagin
|
|
7
|
+
Author-email: tagin <tagin@airi.net>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Documentation
|
|
19
|
+
Classifier: Topic :: Software Development
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Dist: aiohttp==3.12.15
|
|
23
|
+
Requires-Dist: loguru==0.7.3
|
|
24
|
+
Requires-Dist: mmar-mapi~=1.2.5
|
|
25
|
+
Requires-Dist: mmar-utils~=1.1.3
|
|
26
|
+
Requires-Python: >=3.12
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# MMAR Maestro Client
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# MMAR Maestro Client
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mmar-mcli"
|
|
3
|
+
# dynamic version is not supported yet on uv_build
|
|
4
|
+
version = "1.0.6"
|
|
5
|
+
description = "Client to Maestro"
|
|
6
|
+
authors = [{name = "tagin", email = "tagin@airi.net"}]
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
keywords = []
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Documentation",
|
|
22
|
+
"Topic :: Software Development",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"aiohttp==3.12.15",
|
|
28
|
+
"loguru==0.7.3",
|
|
29
|
+
|
|
30
|
+
"mmar_mapi~=1.2.5",
|
|
31
|
+
"mmar_utils~=1.1.3",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["uv_build>=0.8.14,<0.9.0"]
|
|
36
|
+
build-backend = "uv_build"
|
|
37
|
+
|
|
38
|
+
[tool.uv.build-backend]
|
|
39
|
+
module-name = "mmar_mcli"
|
|
40
|
+
source-exclude = [".ruff_cache"]
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
ci = [
|
|
44
|
+
"ruff>=0.4",
|
|
45
|
+
"pytest>=8.2",
|
|
46
|
+
"pytest-asyncio>=1.0.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.uv]
|
|
50
|
+
default-groups = ["ci"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 120
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
|
|
58
|
+
[pytest]
|
|
59
|
+
python_files = [ "test_*.py" ]
|
|
60
|
+
testpaths = [ "tests" ]
|
|
61
|
+
|
|
62
|
+
# action:message_regex:warning_class:module_regex:line
|
|
63
|
+
filterwarnings = [ "error" ]
|
|
64
|
+
|
|
65
|
+
[mypy]
|
|
66
|
+
ignore_missing_imports = true
|
|
67
|
+
exclude = "tests/fixtures/"
|
|
68
|
+
warn_unused_ignores = true
|
|
69
|
+
show_error_codes = true
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from mmar_mcli.maestro_client import MaestroClient, MESSAGE_START
|
|
2
|
+
from mmar_mcli.maestro_client_dummy import MaestroClientDummy
|
|
3
|
+
from mmar_mcli.models import FileData, FileName, MessageData
|
|
4
|
+
|
|
5
|
+
__all__ = [MaestroClient, MaestroClientDummy, FileName, FileData, MessageData, MESSAGE_START]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from mimetypes import guess_type
|
|
3
|
+
|
|
4
|
+
from aiohttp import ClientConnectionError, ClientError, ClientResponseError, ClientSession, ClientTimeout, FormData
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from mmar_utils import on_error_log_and_none, retry_on_ex
|
|
7
|
+
|
|
8
|
+
from mmar_mcli.models import FileData
|
|
9
|
+
|
|
10
|
+
POST_ERRORS = (asyncio.TimeoutError, ClientConnectionError, ClientResponseError)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_file_form_data(file_data: FileData) -> FormData:
|
|
14
|
+
fd = FormData()
|
|
15
|
+
fname, fbytes = file_data
|
|
16
|
+
content_type = guess_type(fname)[0] or "application/octet-stream"
|
|
17
|
+
fd.add_field(name="file", filename=fname, content_type=content_type, value=fbytes)
|
|
18
|
+
return fd
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@on_error_log_and_none(logger.exception)
|
|
22
|
+
@retry_on_ex(attempts=3, wait_seconds=1, catch=POST_ERRORS, logger=logger.warning)
|
|
23
|
+
async def request_with_session(
|
|
24
|
+
*,
|
|
25
|
+
method: str,
|
|
26
|
+
url: str,
|
|
27
|
+
json: dict | None = None,
|
|
28
|
+
headers: dict[str, str] | None = None,
|
|
29
|
+
params: dict[str, str] | None = None,
|
|
30
|
+
timeout: int | None = None,
|
|
31
|
+
data: FormData | None = None,
|
|
32
|
+
headers_extra: dict[str, str] | None = None,
|
|
33
|
+
) -> bytes | dict:
|
|
34
|
+
headers_all = headers | (headers_extra or {})
|
|
35
|
+
async with ClientSession(headers={}) as session:
|
|
36
|
+
timeout_ = ClientTimeout(timeout) if isinstance(timeout, int) else None
|
|
37
|
+
async with session.request(
|
|
38
|
+
method=method,
|
|
39
|
+
url=url,
|
|
40
|
+
json=json,
|
|
41
|
+
headers=headers_all,
|
|
42
|
+
params=params,
|
|
43
|
+
timeout=timeout_,
|
|
44
|
+
data=data,
|
|
45
|
+
) as resp:
|
|
46
|
+
content_type = resp.headers.get("Content-Type", "").lower()
|
|
47
|
+
if "application/json" in content_type:
|
|
48
|
+
body: dict = await resp.json()
|
|
49
|
+
try:
|
|
50
|
+
resp.raise_for_status()
|
|
51
|
+
except Exception as ex:
|
|
52
|
+
raise ClientError(f"{ex}\nResponse body: {body}") from ex
|
|
53
|
+
return body
|
|
54
|
+
else:
|
|
55
|
+
return await resp.read()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from functools import cache, partial
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
from aiohttp import FormData
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from mmar_mapi import AIMessage, Context, FileStorage, HumanMessage, ResourceId, make_content
|
|
8
|
+
from mmar_utils import remove_prefix_if_present
|
|
9
|
+
|
|
10
|
+
from mmar_mcli.io_aiohttp import make_file_form_data, request_with_session
|
|
11
|
+
from mmar_mcli.models import FileData, MaestroConfig, MessageData, RequestCall
|
|
12
|
+
|
|
13
|
+
ROUTES = SimpleNamespace(
|
|
14
|
+
send="api/v0/send",
|
|
15
|
+
download="api/v2/files/download_bytes",
|
|
16
|
+
upload="api/v2/files/upload",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
MESSAGE_START: MessageData = make_content(text="/start"), None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fix_maestro_address(maestro_address: str) -> str:
|
|
24
|
+
if not maestro_address.startswith("https:"):
|
|
25
|
+
if maestro_address.startswith(":"):
|
|
26
|
+
maestro_address = f"localhost{maestro_address}"
|
|
27
|
+
maestro_address = "http://" + remove_prefix_if_present(maestro_address, "http://")
|
|
28
|
+
return maestro_address
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RemoteStorager:
|
|
32
|
+
def __init__(self, request: RequestCall, url_base: str, client_id: str):
|
|
33
|
+
self.request = request
|
|
34
|
+
self.url_download = url_base.replace(ROUTES.send, ROUTES.download)
|
|
35
|
+
self.url_upload = url_base.replace(ROUTES.send, ROUTES.upload)
|
|
36
|
+
self.client_id = client_id
|
|
37
|
+
self.headers = {"client-id": client_id}
|
|
38
|
+
|
|
39
|
+
async def upload_async(self, content: bytes | str, fname: str) -> ResourceId:
|
|
40
|
+
content = content if isinstance(content, bytes) else content.encode()
|
|
41
|
+
data: FormData = make_file_form_data((fname, content))
|
|
42
|
+
response_data = await self.request(method="post", url=self.url_upload, headers=self.headers, data=data)
|
|
43
|
+
if not isinstance(response_data, dict):
|
|
44
|
+
raise ValueError(f"POST {self.url_upload}: expected json, found {type(response_data)}")
|
|
45
|
+
resource_id: str = response_data["ResourceId"]
|
|
46
|
+
return resource_id
|
|
47
|
+
|
|
48
|
+
async def download_async(self, resource_id: str) -> bytes:
|
|
49
|
+
params = dict(resource_id=resource_id)
|
|
50
|
+
resource_bytes = await self.request(method="get", url=self.url_download, params=params, headers=self.headers)
|
|
51
|
+
if not isinstance(resource_bytes, bytes):
|
|
52
|
+
raise ValueError(f"GET {self.url_download}: expected bytes, found {type(resource_bytes)}")
|
|
53
|
+
return resource_bytes
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MaestroClientI:
|
|
57
|
+
async def send(self, context: Context, msg_data: MessageData | str) -> list[MessageData]:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
async def upload_resource(self, file_data: FileData, client_id: str) -> str | None:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
async def download_resource(self, resource_id: str, client_id: str) -> bytes:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MaestroClient(MaestroClientI):
|
|
68
|
+
def __init__(self, config: MaestroConfig):
|
|
69
|
+
addresses__maestro: str = getattr(config, "addresses__maestro", "https://maestro.airi.net")
|
|
70
|
+
error: str = getattr(getattr(config, "res", SimpleNamespace()), "error", "Server is not available")
|
|
71
|
+
headers_extra: dict[str, str] | None = getattr(config, "headers_extra", "")
|
|
72
|
+
files_dir: str | None = getattr(config, "files_dir", None)
|
|
73
|
+
timeout: int = getattr(config, "timeout", 120)
|
|
74
|
+
|
|
75
|
+
self.url = fix_maestro_address(addresses__maestro) + "/" + ROUTES.send
|
|
76
|
+
logger.info(f"Creating client, maestro url: {self.url}")
|
|
77
|
+
self.request = partial(request_with_session, timeout=timeout, headers_extra=headers_extra)
|
|
78
|
+
self.msg_data_response_error: MessageData = make_content(text=error), None
|
|
79
|
+
|
|
80
|
+
self.file_storage: FileStorage | None = files_dir and FileStorage(files_dir)
|
|
81
|
+
|
|
82
|
+
@cache
|
|
83
|
+
def get_file_storage(self, client_id: str) -> FileStorage:
|
|
84
|
+
return self.file_storage or RemoteStorager(self.request, self.url, client_id)
|
|
85
|
+
|
|
86
|
+
async def send(self, context: Context, msg_data: MessageData | str) -> list[MessageData]:
|
|
87
|
+
start = time.time()
|
|
88
|
+
msg_datas_response = await self._send(context, msg_data)
|
|
89
|
+
elapsed = time.time() - start
|
|
90
|
+
|
|
91
|
+
entrypoint_key = (context.extra or {}).get("entrypoint_key", "")
|
|
92
|
+
cd = f"{context.track_id}.{context.session_id}.{entrypoint_key}"
|
|
93
|
+
log_suffix = " (NULL!)" if not msg_datas_response else ""
|
|
94
|
+
logger.info(f"BotResponse processing time for {cd}: {elapsed:.2f} s{log_suffix}")
|
|
95
|
+
|
|
96
|
+
msg_datas_response = msg_datas_response or [self.msg_data_response_error]
|
|
97
|
+
return msg_datas_response
|
|
98
|
+
|
|
99
|
+
async def upload_resource(self, file_data: FileData, client_id: str) -> str | None:
|
|
100
|
+
file_name, file_bytes = file_data
|
|
101
|
+
resourse_id: str = await self.get_file_storage(client_id).upload_async(file_bytes, file_name)
|
|
102
|
+
return resourse_id
|
|
103
|
+
|
|
104
|
+
async def download_resource(self, resource_id: str, client_id: str) -> bytes:
|
|
105
|
+
res: bytes = await self.get_file_storage(client_id).download_async(resource_id)
|
|
106
|
+
return res
|
|
107
|
+
|
|
108
|
+
async def _download_file_data_maybe(self, msg: HumanMessage, client_id: str) -> FileData | None:
|
|
109
|
+
resource_id = msg.resource_id
|
|
110
|
+
if not resource_id:
|
|
111
|
+
return None
|
|
112
|
+
logger.info(f"Downloading resource: {resource_id}")
|
|
113
|
+
resource_name = msg.resource_name
|
|
114
|
+
if not resource_name:
|
|
115
|
+
resource_ext = resource_id.split(".")[-1]
|
|
116
|
+
resource_name = f"result.{resource_ext}"
|
|
117
|
+
resource_bytes = await self.download_resource(resource_id, client_id)
|
|
118
|
+
return resource_name, resource_bytes
|
|
119
|
+
|
|
120
|
+
async def _send(self, context: Context, msg_data: MessageData | str) -> list[MessageData] | None:
|
|
121
|
+
if isinstance(msg_data, str):
|
|
122
|
+
msg_data = msg_data, None
|
|
123
|
+
content, file_data = msg_data
|
|
124
|
+
|
|
125
|
+
resource_id = file_data and await self.upload_resource(file_data, context.client_id)
|
|
126
|
+
content = make_content(content=content, resource_id=resource_id)
|
|
127
|
+
msg = HumanMessage(content=content)
|
|
128
|
+
ai_messages = await self._send_raw(context, msg)
|
|
129
|
+
if not ai_messages:
|
|
130
|
+
return None
|
|
131
|
+
download = partial(self._download_file_data_maybe, client_id=context.client_id)
|
|
132
|
+
res = [(ai_msg.content, await download(ai_msg)) for ai_msg in ai_messages]
|
|
133
|
+
return res
|
|
134
|
+
|
|
135
|
+
async def _send_raw(self, context: Context, msg: HumanMessage) -> list[AIMessage] | None:
|
|
136
|
+
dict_user_message = msg.model_dump()
|
|
137
|
+
dict_ctx = context.model_dump()
|
|
138
|
+
data_json = {"context": dict_ctx, "messages": [dict_user_message]}
|
|
139
|
+
headers = {"client-id": context.client_id}
|
|
140
|
+
try:
|
|
141
|
+
response_data = await self.request(method="post", url=self.url, json=data_json, headers=headers)
|
|
142
|
+
except Exception:
|
|
143
|
+
logger.exception(f"Failed to send request {msg}")
|
|
144
|
+
return None
|
|
145
|
+
if response_data is None:
|
|
146
|
+
return None
|
|
147
|
+
logger.trace(f"Response data: {response_data}")
|
|
148
|
+
response_messages_raw = response_data["response_messages"]
|
|
149
|
+
ai_messages = list(map(AIMessage.model_validate, response_messages_raw))
|
|
150
|
+
return ai_messages
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
from mmar_mapi import Context
|
|
5
|
+
from mmar_mapi.models.chat import _get_command, _get_text
|
|
6
|
+
from mmar_utils import try_parse_int
|
|
7
|
+
|
|
8
|
+
from mmar_mcli.maestro_client import MaestroClientI
|
|
9
|
+
from mmar_mcli.models import MessageData
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MaestroClientDummy(MaestroClientI):
|
|
13
|
+
def __init__(self, config):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
async def send(self, context: Context, msg_data: MessageData | str) -> list[MessageData]:
|
|
17
|
+
if isinstance(msg_data, str):
|
|
18
|
+
msg_data = msg_data, None
|
|
19
|
+
content, file_data = msg_data
|
|
20
|
+
|
|
21
|
+
text = _get_text(content)
|
|
22
|
+
command = _get_command(content)
|
|
23
|
+
|
|
24
|
+
if text.lower().startswith("wait"):
|
|
25
|
+
seconds = try_parse_int(text[len("wait") :].strip())
|
|
26
|
+
logger.info(f"Going to wait {seconds} seconds")
|
|
27
|
+
if seconds:
|
|
28
|
+
await asyncio.sleep(seconds)
|
|
29
|
+
return [(f"After waiting {seconds} seconds", None)]
|
|
30
|
+
|
|
31
|
+
text_response_lines = [
|
|
32
|
+
f"Your context: {context}",
|
|
33
|
+
f"Your text: {text}",
|
|
34
|
+
f"Your command: {command}",
|
|
35
|
+
f"Your file_data: {file_data and (file_data[0], len(file_data[1]))}",
|
|
36
|
+
]
|
|
37
|
+
text_response = "\n".join(text_response_lines)
|
|
38
|
+
return [(text_response, None)]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Awaitable, NamedTuple, Protocol
|
|
3
|
+
|
|
4
|
+
from mmar_mapi import Content
|
|
5
|
+
|
|
6
|
+
FileName = str
|
|
7
|
+
FileData = tuple[FileName, bytes]
|
|
8
|
+
MessageData = tuple[Content | None, FileData | None]
|
|
9
|
+
|
|
10
|
+
RequestCall = Callable[..., Awaitable[bytes | dict]]
|
|
11
|
+
BotConfig = NamedTuple("BotConfig", [("timeout", int)])
|
|
12
|
+
ResourcesConfig = NamedTuple("ResourcesConfig", [("error", str)])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MaestroConfig(Protocol):
|
|
16
|
+
addresses__maestro: str
|
|
17
|
+
res: ResourcesConfig
|
|
18
|
+
headers_extra: dict[str, str] | None
|
|
19
|
+
files_dir: str | None
|
|
20
|
+
timeout: int
|
|
File without changes
|