uno-cli 1.0.4__tar.gz → 1.0.5__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.
@@ -8,6 +8,7 @@ __pycache__/
8
8
  node_modules/
9
9
  .next/
10
10
  frontend/.next/
11
+ frontend/.next-releases/
11
12
  frontend/node_modules/
12
13
  .DS_Store
13
14
  .idea/
@@ -19,6 +20,9 @@ deploy.sh
19
20
  data_export/
20
21
  data_export_prod/
21
22
  scripts/backup_prod*.json
23
+ scripts/probe_all.json
24
+ scripts/probe_200.json
25
+ var/mcp-url-secret-backups/
22
26
  .mcp_auth/
23
27
  .cursor/
24
28
  .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: uno-cli
3
- Version: 1.0.4
3
+ Version: 1.0.5
4
4
  Summary: Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line.
5
5
  Project-URL: Homepage, https://agentools.uno
6
6
  Project-URL: Documentation, https://agentools.uno/docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uno-cli"
3
- version = "1.0.4"
3
+ version = "1.0.5"
4
4
  description = "Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.8"
@@ -0,0 +1,165 @@
1
+ """Reference images are uploaded before they enter ``--args``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import base64
7
+ import json
8
+
9
+ import pytest
10
+
11
+ from uno_cli import cli
12
+ from uno_cli.references import split_reference_list
13
+
14
+
15
+ def _args(raw):
16
+ return argparse.Namespace(
17
+ tool="gpt-image-2.gpt_image2_submit",
18
+ args=raw,
19
+ timeout=60,
20
+ dry_run=False,
21
+ base_url="https://agentools.uno",
22
+ )
23
+
24
+
25
+ class _API:
26
+ def __init__(self):
27
+ self.uploads = []
28
+ self.call = None
29
+
30
+ def post_bytes(self, path, data, content_type, timeout=120):
31
+ self.uploads.append({"path": path, "data": data, "content_type": content_type})
32
+ file_id = "f_" + f"{len(self.uploads):02x}" + ("ab" * 15)
33
+ url = f"https://agentools.uno/v1/files/{file_id}"
34
+ assert "," not in file_id
35
+ assert "," not in url
36
+ return {"file_id": file_id, "url": url}
37
+
38
+ def post(self, path, data, timeout=60):
39
+ self.call = {"path": path, "data": data}
40
+ return {"data": {"status": "pending", "job_id": "job1"}, "meta": {}}
41
+
42
+
43
+ def test_oversized_data_uri_does_not_enter_call_args(monkeypatch, capsys):
44
+ image = b"\xff\xd8\xff" + (b"x" * 80000)
45
+ encoded = base64.b64encode(image).decode()
46
+ data_uri = f"data:image/jpeg;base64,{encoded}"
47
+ raw = json.dumps({"prompt": "cat", "reference_image_urls": data_uri})
48
+ assert len(raw) > 102400
49
+ assert "," in data_uri
50
+
51
+ api = _API()
52
+ monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
53
+ monkeypatch.setattr(cli, "_get_api", lambda args: api)
54
+
55
+ cli.cmd_call(_args(raw))
56
+
57
+ assert api.call["path"] == "/v1/call"
58
+ sent = api.call["data"]["arguments"]
59
+ ref = sent["reference_image_urls"]
60
+ assert encoded not in json.dumps(sent)
61
+ assert "base64" not in ref
62
+ assert data_uri not in json.dumps(sent)
63
+ assert "," not in ref
64
+ assert ref.startswith("https://agentools.uno/v1/files/f_")
65
+ assert len(json.dumps(sent, ensure_ascii=False)) <= 102400
66
+ assert api.uploads[0]["data"] == image
67
+ assert api.uploads[0]["content_type"] == "image/jpeg"
68
+ out = json.loads(capsys.readouterr().out)
69
+ assert out["success"] is True
70
+
71
+
72
+ def test_local_paths_upload_separately(monkeypatch, capsys, tmp_path):
73
+ first = tmp_path / "a.jpg"
74
+ second = tmp_path / "b.png"
75
+ first.write_bytes(b"\xff\xd8\xff\xd9")
76
+ second.write_bytes(b"\x89PNG\r\n")
77
+ raw = json.dumps({"prompt": "keep the subject", "reference_image_urls": f"{first},{second}"})
78
+
79
+ api = _API()
80
+ monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
81
+ monkeypatch.setattr(cli, "_get_api", lambda args: api)
82
+ cli.cmd_call(_args(raw))
83
+
84
+ assert len(api.uploads) == 2
85
+ refs = api.call["data"]["arguments"]["reference_image_urls"].split(",")
86
+ assert len(refs) == 2
87
+ assert all("," not in item for item in refs)
88
+ assert all(item.startswith("https://agentools.uno/v1/files/f_") for item in refs)
89
+ assert str(first) not in json.dumps(api.call["data"]["arguments"])
90
+
91
+
92
+ def test_data_uri_comma_is_not_a_second_image():
93
+ raw = "data:image/jpeg;base64," + ("QUJD" * 30)
94
+ assert split_reference_list(raw) == [raw]
95
+ mixed = raw + ",https://cdn.example/b.png"
96
+ parts = split_reference_list(mixed)
97
+ assert parts == [raw, "https://cdn.example/b.png"]
98
+
99
+
100
+ def test_plain_prompt_comma_is_not_uploaded(monkeypatch, capsys):
101
+ api = _API()
102
+ monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
103
+ monkeypatch.setattr(cli, "_get_api", lambda args: api)
104
+ cli.cmd_call(_args(json.dumps({"prompt": "a cat, a dog"})))
105
+ assert api.uploads == []
106
+ assert api.call["data"]["arguments"]["prompt"] == "a cat, a dog"
107
+
108
+
109
+ def test_base64_fields_keep_the_data_uri(monkeypatch, capsys):
110
+ encoded = base64.b64encode(b"\xff\xd8\xff\xd9").decode()
111
+ data_uri = f"data:image/jpeg;base64,{encoded}"
112
+ raw = json.dumps({
113
+ "prompt": "cat",
114
+ "reference_image_base64": data_uri,
115
+ "reference_images_base64": [data_uri],
116
+ "image_base64": data_uri,
117
+ "reference_image_urls": data_uri,
118
+ })
119
+ api = _API()
120
+ monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
121
+ monkeypatch.setattr(cli, "_get_api", lambda args: api)
122
+ cli.cmd_call(_args(raw))
123
+ sent = api.call["data"]["arguments"]
124
+ assert sent["reference_image_base64"] == data_uri
125
+ assert sent["reference_images_base64"] == [data_uri]
126
+ assert sent["image_base64"] == data_uri
127
+ assert data_uri not in sent["reference_image_urls"]
128
+ assert sent["reference_image_urls"].startswith("https://agentools.uno/v1/files/f_")
129
+ assert len(api.uploads) == 1
130
+
131
+
132
+ def test_non_whitelist_tool_does_not_upload(monkeypatch, capsys):
133
+ encoded = base64.b64encode(b"\xff\xd8\xff\xd9").decode()
134
+ data_uri = f"data:image/jpeg;base64,{encoded}"
135
+ raw = json.dumps({"prompt": "cat", "reference_image_urls": data_uri})
136
+ api = _API()
137
+ monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
138
+ monkeypatch.setattr(cli, "_get_api", lambda args: api)
139
+ args = _args(raw)
140
+ args.tool = "seedream.generate"
141
+ cli.cmd_call(args)
142
+ assert api.uploads == []
143
+ assert api.call["data"]["arguments"]["reference_image_urls"] == data_uri
144
+
145
+
146
+ def test_prompt_path_and_data_uri_are_not_uploaded(monkeypatch, capsys, tmp_path):
147
+ image = tmp_path / "cat.jpg"
148
+ image.write_bytes(b"\xff\xd8\xff\xd9")
149
+ data_uri = "data:image/jpeg;base64," + base64.b64encode(b"\xff\xd8\xff\xd9").decode()
150
+ prompt = f"use {image} and {data_uri} as the subject"
151
+ raw = json.dumps({"prompt": prompt})
152
+ api = _API()
153
+ monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
154
+ monkeypatch.setattr(cli, "_get_api", lambda args: api)
155
+ cli.cmd_call(_args(raw))
156
+ assert api.uploads == []
157
+ assert api.call["data"]["arguments"]["prompt"] == prompt
158
+
159
+
160
+ def test_upload_locator_rejects_comma():
161
+ from uno_cli.references import ReferenceUploadError, locator_from_upload
162
+
163
+ with pytest.raises(ReferenceUploadError):
164
+ locator_from_upload({"url": "https://example.com/a,b.png", "file_id": "f_ab,cd"})
165
+ assert "," not in locator_from_upload({"file_id": "f_" + "ab" * 16, "url": ""})
@@ -0,0 +1 @@
1
+ __version__ = "1.0.5"
@@ -22,6 +22,13 @@ import urllib.parse
22
22
  import urllib.request
23
23
 
24
24
  from uno_cli._version import __version__
25
+ from uno_cli.references import (
26
+ CALL_ARGS_MAX_CHARS,
27
+ RAW_ARGS_MAX_CHARS,
28
+ ReferenceUploadError,
29
+ locator_from_upload,
30
+ rewrite_call_arguments,
31
+ )
25
32
 
26
33
  DEFAULT_BASE = "https://agentools.uno"
27
34
  LEGACY_BASE = "https://clawdtools.uno"
@@ -134,14 +141,22 @@ class UnoAPI:
134
141
  h["Authorization"] = f"Bearer {self.api_key}"
135
142
  return h
136
143
 
137
- def _request(self, method, path, data=None, params=None, timeout=60):
144
+ def _request(self, method, path, data=None, params=None, timeout=60, *, raw=None, content_type=None):
138
145
  url = f"{self.base}{path}"
139
146
  if params:
140
147
  url += "?" + urllib.parse.urlencode(
141
148
  {k: v for k, v in params.items() if v is not None}
142
149
  )
143
- body = json.dumps(data).encode() if data is not None else None
144
- req = urllib.request.Request(url, data=body, headers=self._headers(), method=method)
150
+ headers = self._headers()
151
+ if content_type:
152
+ headers["Content-Type"] = content_type
153
+ if raw is not None:
154
+ body = raw
155
+ elif data is not None:
156
+ body = json.dumps(data).encode()
157
+ else:
158
+ body = None
159
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
145
160
  try:
146
161
  with urllib.request.urlopen(req, timeout=timeout) as resp:
147
162
  raw = resp.read().decode()
@@ -170,6 +185,12 @@ class UnoAPI:
170
185
  def post(self, path, data=None, timeout=60):
171
186
  return self._request("POST", path, data=data, timeout=timeout)
172
187
 
188
+ def post_bytes(self, path, data, content_type, timeout=120):
189
+ """POST raw bytes (reference image upload). Not the JSON ``--args`` body."""
190
+ return self._request(
191
+ "POST", path, raw=data, content_type=content_type, timeout=timeout,
192
+ )
193
+
173
194
  def delete(self, path):
174
195
  return self._request("DELETE", path)
175
196
 
@@ -700,8 +721,8 @@ def cmd_call(args):
700
721
  raw_args = getattr(args, "args", None)
701
722
  if raw_args:
702
723
  raw_args = raw_args.replace("\x00", "")
703
- if len(raw_args) > 102400:
704
- _fail("Arguments too large (max 100KB)")
724
+ if len(raw_args) > RAW_ARGS_MAX_CHARS:
725
+ _fail("Arguments too large to upload")
705
726
  try:
706
727
  arguments = json.loads(raw_args)
707
728
  if not isinstance(arguments, dict):
@@ -735,6 +756,11 @@ def cmd_call(args):
735
756
 
736
757
  api = _get_api(args)
737
758
  try:
759
+ arguments = rewrite_call_arguments(
760
+ arguments, _reference_uploader(api), tool_slug,
761
+ )
762
+ if len(json.dumps(arguments, ensure_ascii=False)) > CALL_ARGS_MAX_CHARS:
763
+ _fail("Arguments too large (max 100KB)")
738
764
  result = api.post("/v1/call", {"tool": tool_slug, "arguments": arguments}, timeout=timeout)
739
765
  if result.get("error"):
740
766
  out = {"success": False, "error": result["error"]}
@@ -778,6 +804,19 @@ def cmd_call(args):
778
804
  except APIError as e:
779
805
  _out(_format_api_error(e.status, e.body))
780
806
  sys.exit(1)
807
+ except ReferenceUploadError as e:
808
+ _fail(str(e))
809
+
810
+
811
+ def _reference_uploader(api):
812
+ """Upload one image and return a comma-free URL or file_id."""
813
+
814
+ def upload(data, filename, media_type):
815
+ del filename
816
+ result = api.post_bytes("/v1/files", data, media_type, timeout=120)
817
+ return locator_from_upload(result)
818
+
819
+ return upload
781
820
 
782
821
 
783
822
  def cmd_rate(args):
@@ -0,0 +1,205 @@
1
+ """Upload local paths and data URIs before they land in ``--args``.
2
+
3
+ The call payload stays under 100KB. Each upload locator is a public URL or
4
+ ``file_id`` and does not contain a comma, so a later comma-split stays intact.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+
12
+ CALL_ARGS_MAX_CHARS = 102400
13
+ RAW_ARGS_MAX_CHARS = 32 * 1024 * 1024
14
+ MAX_IMAGE_BYTES = 8 * 1024 * 1024
15
+
16
+ # Link fields only. A name containing "base64" is never uploaded, even if it
17
+ # is later added here. Tools absent from this map are left untouched.
18
+ _LINK_FIELDS = frozenset({
19
+ "reference_image_urls",
20
+ "reference_image_url",
21
+ "image_url",
22
+ "image_urls",
23
+ })
24
+ _UPLOAD_FIELDS = {
25
+ "gpt-image-2.gpt_image2_submit": _LINK_FIELDS,
26
+ }
27
+ _IMAGE_EXT = {
28
+ ".jpg": "image/jpeg",
29
+ ".jpeg": "image/jpeg",
30
+ ".png": "image/png",
31
+ ".webp": "image/webp",
32
+ ".gif": "image/gif",
33
+ }
34
+ _NEXT_REF = ("http://", "https://", "data:")
35
+
36
+
37
+ class ReferenceUploadError(Exception):
38
+ pass
39
+
40
+
41
+ def split_reference_list(raw: str) -> list:
42
+ """Split comma-separated refs without breaking a data URI on its comma."""
43
+ text = raw.strip()
44
+ if not text:
45
+ return []
46
+ parts = []
47
+ index = 0
48
+ length = len(text)
49
+ while index < length:
50
+ while index < length and text[index] in ", \t\r\n":
51
+ index += 1
52
+ if index >= length:
53
+ break
54
+ if text.startswith("data:", index):
55
+ comma = text.find(",", index)
56
+ if comma == -1:
57
+ token = text[index:].strip()
58
+ if token:
59
+ parts.append(token)
60
+ break
61
+ cursor = comma + 1
62
+ end = length
63
+ while True:
64
+ nxt = text.find(",", cursor)
65
+ if nxt == -1:
66
+ break
67
+ rest = text[nxt + 1:].lstrip()
68
+ if rest.startswith(_NEXT_REF):
69
+ end = nxt
70
+ break
71
+ cursor = nxt + 1
72
+ token = text[index:end].strip()
73
+ if token:
74
+ parts.append(token)
75
+ index = end
76
+ continue
77
+ comma = text.find(",", index)
78
+ if comma == -1:
79
+ token = text[index:].strip()
80
+ if token:
81
+ parts.append(token)
82
+ break
83
+ token = text[index:comma].strip()
84
+ if token:
85
+ parts.append(token)
86
+ index = comma + 1
87
+ return parts
88
+
89
+
90
+ def locator_from_upload(payload) -> str:
91
+ """Pick the public URL, else file_id. Either must be free of commas."""
92
+ if not isinstance(payload, dict):
93
+ raise ReferenceUploadError("reference upload returned no locator")
94
+ url = payload.get("url") or ""
95
+ file_id = payload.get("file_id") or ""
96
+ if isinstance(url, str) and url and "," not in url:
97
+ return url
98
+ if isinstance(file_id, str) and file_id and "," not in file_id:
99
+ return file_id
100
+ raise ReferenceUploadError("upload locator must not contain a comma")
101
+
102
+
103
+ def decode_data_uri(value: str):
104
+ if not value.startswith("data:") or "," not in value:
105
+ raise ReferenceUploadError("invalid data uri")
106
+ header, payload = value.split(",", 1)
107
+ if ";base64" not in header.lower():
108
+ raise ReferenceUploadError("data uri must be base64")
109
+ media = header[5:].split(";", 1)[0].strip().lower() or "application/octet-stream"
110
+ if media not in _IMAGE_EXT.values():
111
+ raise ReferenceUploadError("unsupported image type")
112
+ try:
113
+ data = base64.b64decode(payload, validate=True)
114
+ except Exception as exc:
115
+ raise ReferenceUploadError("invalid base64") from exc
116
+ return data, media
117
+
118
+
119
+ def _is_data_uri(value: str) -> bool:
120
+ return value.startswith("data:image/") and "," in value
121
+
122
+
123
+ def _local_image(value: str):
124
+ if value.startswith(("http://", "https://", "data:")):
125
+ return None
126
+ path = os.path.expanduser(value)
127
+ ext = os.path.splitext(path)[1].lower()
128
+ media = _IMAGE_EXT.get(ext)
129
+ if not media or not os.path.isfile(path):
130
+ return None
131
+ return path, media
132
+
133
+
134
+ def _should_upload(value: str) -> bool:
135
+ if _is_data_uri(value):
136
+ return True
137
+ return _local_image(value) is not None
138
+
139
+
140
+ def _field_allows_upload(field, allowed) -> bool:
141
+ if not isinstance(field, str) or "base64" in field.lower():
142
+ return False
143
+ return field in allowed
144
+
145
+
146
+ def _upload_one(value: str, upload) -> str:
147
+ if _is_data_uri(value):
148
+ data, media = decode_data_uri(value)
149
+ filename = "reference." + next(ext.lstrip(".") for ext, kind in _IMAGE_EXT.items() if kind == media)
150
+ else:
151
+ found = _local_image(value)
152
+ if not found:
153
+ raise ReferenceUploadError("not a reference image")
154
+ path, media = found
155
+ filename = os.path.basename(path)
156
+ with open(path, "rb") as handle:
157
+ data = handle.read()
158
+ if not data:
159
+ raise ReferenceUploadError("reference image is empty")
160
+ if len(data) > MAX_IMAGE_BYTES:
161
+ raise ReferenceUploadError("reference image exceeds 8MB")
162
+ locator = upload(data, filename, media)
163
+ if not isinstance(locator, str) or not locator or "," in locator:
164
+ raise ReferenceUploadError("upload locator must not contain a comma")
165
+ return locator
166
+
167
+
168
+ def _rewrite_string(value: str, upload) -> str:
169
+ if "," not in value and not _is_data_uri(value):
170
+ if _should_upload(value):
171
+ return _upload_one(value, upload)
172
+ return value
173
+ parts = split_reference_list(value)
174
+ if len(parts) == 1 and _should_upload(parts[0]):
175
+ return _upload_one(parts[0], upload)
176
+ if not any(_should_upload(part) for part in parts):
177
+ return value
178
+ rewritten = []
179
+ for part in parts:
180
+ if _should_upload(part):
181
+ rewritten.append(_upload_one(part, upload))
182
+ else:
183
+ rewritten.append(part)
184
+ return ",".join(rewritten)
185
+
186
+
187
+ def _rewrite(obj, upload, allowed, field):
188
+ if isinstance(obj, dict):
189
+ return {key: _rewrite(val, upload, allowed, key) for key, val in obj.items()}
190
+ if isinstance(obj, list):
191
+ return [_rewrite(item, upload, allowed, field) for item in obj]
192
+ if isinstance(obj, str) and _field_allows_upload(field, allowed):
193
+ return _rewrite_string(obj, upload)
194
+ return obj
195
+
196
+
197
+ def rewrite_call_arguments(obj, upload, tool_slug=None, field=None):
198
+ """Replace inline images on whitelisted link fields.
199
+
200
+ ``upload(data, filename, media) -> str``. Fields whose names contain
201
+ ``base64``, every other field (including ``prompt``), and tools that are
202
+ not listed are returned unchanged.
203
+ """
204
+ allowed = _UPLOAD_FIELDS.get(tool_slug or "", frozenset())
205
+ return _rewrite(obj, upload, allowed, field)
@@ -1 +0,0 @@
1
- __version__ = "1.0.4"
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