uno-cli 1.0.3__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.3
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.3"
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"
@@ -137,6 +137,22 @@ class TestErrorHints:
137
137
  def test_format_api_error_str_body(self):
138
138
  out = cli._format_api_error(500, "server exploded")
139
139
  assert out["error"] == "server exploded"
140
+ assert out["success"] is False
141
+
142
+ def test_format_api_error_keeps_gateway_meta(self):
143
+ body = {
144
+ "error": "tool_not_found",
145
+ "message": "Tool not found: x.y",
146
+ "meta": {
147
+ "request_id": "abc",
148
+ "credits_used": 0,
149
+ "charged": False,
150
+ },
151
+ }
152
+ out = cli._format_api_error(404, body)
153
+ assert out["error"] == "tool_not_found"
154
+ assert out["meta"]["request_id"] == "abc"
155
+ assert out["meta"]["charged"] is False
140
156
 
141
157
  def test_missing_credentials_points_to_login_start(self, capsys, monkeypatch, tmp_path):
142
158
  cred_dir = tmp_path / ".uno"
@@ -155,6 +171,91 @@ class TestErrorHints:
155
171
  hint = cli._match_hint(msg)
156
172
  assert "uno login --start" in hint
157
173
 
174
+ def test_auth_required_fallback_hint_is_generic_oauth(self):
175
+ """``_ERROR_HINTS`` keeps a generic OAuth fallback; call path overrides."""
176
+ hint = cli._match_hint("auth_required")
177
+ assert "OAuth required" in hint
178
+ assert "auth_url" in hint
179
+
180
+
181
+ # ── cmd_call auth_type hint overrides ──────────────────────────────
182
+
183
+
184
+ class _CallArgs:
185
+ def __init__(self, tool="demo.tool", args_json=None):
186
+ self.tool = tool
187
+ self.args = args_json
188
+ self.timeout = 60
189
+ self.base_url = "https://clawdtools.uno"
190
+ self.dry_run = False
191
+
192
+
193
+ class TestCmdCallAuthHints:
194
+ """``cmd_call`` must override the generic auth_required hint by auth_type."""
195
+
196
+ def _run_auth_error(self, monkeypatch, capsys, payload):
197
+ monkeypatch.delenv("UNO_ALLOWED_TOOLS", raising=False)
198
+
199
+ class _FakeAPI:
200
+ def post(self, path, data, timeout=60):
201
+ return payload
202
+
203
+ monkeypatch.setattr(cli, "_get_api", lambda args: _FakeAPI())
204
+ with pytest.raises(SystemExit) as ei:
205
+ cli.cmd_call(_CallArgs())
206
+ assert ei.value.code == 1
207
+ return json.loads(capsys.readouterr().out)
208
+
209
+ @pytest.mark.parametrize("auth_type", ["oauth_device", "oauth_device_code", "device_code_api"])
210
+ def test_device_code_auth_hint(self, monkeypatch, capsys, auth_type):
211
+ out = self._run_auth_error(
212
+ monkeypatch,
213
+ capsys,
214
+ {
215
+ "error": "auth_required",
216
+ "auth_type": auth_type,
217
+ "auth_url": "https://example.com/device",
218
+ "get_key_url": "https://example.com/get-key",
219
+ },
220
+ )
221
+ assert out["error"] == "auth_required"
222
+ assert out["auth_type"] == auth_type
223
+ hint = out["hint"]
224
+ assert "Device-code" in hint
225
+ assert "user code" in hint
226
+ assert "get_key_url" in hint # tells agent NOT to follow it
227
+ assert "Do not ask for a key" in hint
228
+ # Must not steer toward applying for / showing a key.
229
+ assert "Show get_key_url" not in hint
230
+ assert "申请" not in hint
231
+
232
+ def test_generic_oauth_auth_url_hint(self, monkeypatch, capsys):
233
+ out = self._run_auth_error(
234
+ monkeypatch,
235
+ capsys,
236
+ {
237
+ "error": "auth_required",
238
+ "auth_type": "oauth",
239
+ "auth_url": "https://example.com/oauth",
240
+ },
241
+ )
242
+ assert out["hint"] == "OAuth required. Open auth_url in a browser to authorize."
243
+ assert "Device-code" not in out["hint"]
244
+ assert "user code" not in out["hint"]
245
+
246
+ def test_api_key_auth_hint(self, monkeypatch, capsys):
247
+ out = self._run_auth_error(
248
+ monkeypatch,
249
+ capsys,
250
+ {
251
+ "error": "auth_required",
252
+ "auth_type": "api_key",
253
+ "get_key_url": "https://example.com/get-key",
254
+ },
255
+ )
256
+ assert "API key required" in out["hint"]
257
+ assert "Show get_key_url" in out["hint"]
258
+
158
259
 
159
260
  # ── JSON output shape of _out ──────────────────────────────────────
160
261
 
@@ -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
 
@@ -494,8 +515,19 @@ def _format_api_error(status, body):
494
515
  msgs.append(str(err))
495
516
  detail = "; ".join(msgs) if msgs else str(detail)
496
517
 
497
- result = {"error": str(detail), "status": status}
498
- hint = _match_hint(str(detail))
518
+ result = {"success": False, "error": str(detail), "status": status}
519
+ if isinstance(body, dict):
520
+ if body.get("message") and str(body.get("message")) != result["error"]:
521
+ result["message"] = body["message"]
522
+ if body.get("meta"):
523
+ result["meta"] = body["meta"]
524
+ if body.get("error") and result["error"] != str(body.get("error")):
525
+ # Prefer the stable gateway code when FastAPI wrapped it.
526
+ code = body.get("error")
527
+ if isinstance(code, str) and code and " " not in code:
528
+ result["error"] = code
529
+ result["message"] = str(detail)
530
+ hint = _match_hint(str(result.get("error") or detail))
499
531
  if hint:
500
532
  result["hint"] = hint
501
533
  return result
@@ -506,7 +538,7 @@ def _ok(data):
506
538
 
507
539
 
508
540
  def _fail(msg, **extra):
509
- out = {"error": msg}
541
+ out = {"success": False, "error": msg}
510
542
  hint = _match_hint(msg)
511
543
  if hint:
512
544
  out["hint"] = hint
@@ -650,6 +682,14 @@ def cmd_tool(args):
650
682
  _fail("Tool slug required. Usage: uno tool get <slug>")
651
683
  try:
652
684
  result = api.get(f"/v1/tools/{slug}")
685
+ if isinstance(result, dict) and result.get("error"):
686
+ out = {"success": False, "error": result["error"]}
687
+ if result.get("message"):
688
+ out["message"] = result["message"]
689
+ if result.get("meta"):
690
+ out["meta"] = result["meta"]
691
+ _out(out)
692
+ sys.exit(1)
653
693
  if isinstance(result, dict) and "data" in result and "error" in result:
654
694
  result = result["data"]
655
695
  if result is None:
@@ -681,8 +721,8 @@ def cmd_call(args):
681
721
  raw_args = getattr(args, "args", None)
682
722
  if raw_args:
683
723
  raw_args = raw_args.replace("\x00", "")
684
- if len(raw_args) > 102400:
685
- _fail("Arguments too large (max 100KB)")
724
+ if len(raw_args) > RAW_ARGS_MAX_CHARS:
725
+ _fail("Arguments too large to upload")
686
726
  try:
687
727
  arguments = json.loads(raw_args)
688
728
  if not isinstance(arguments, dict):
@@ -716,6 +756,11 @@ def cmd_call(args):
716
756
 
717
757
  api = _get_api(args)
718
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)")
719
764
  result = api.post("/v1/call", {"tool": tool_slug, "arguments": arguments}, timeout=timeout)
720
765
  if result.get("error"):
721
766
  out = {"success": False, "error": result["error"]}
@@ -725,8 +770,19 @@ def cmd_call(args):
725
770
  out[key] = result[key]
726
771
  if result.get("meta"):
727
772
  out["meta"] = result["meta"]
773
+ else:
774
+ out["meta"] = {
775
+ "credits_used": 0,
776
+ "credits_from": {"free": 0, "paid": 0.0},
777
+ "charged": False,
778
+ }
728
779
  auth_type = result.get("auth_type", "")
729
- if result.get("auth_url"):
780
+ if auth_type in ("oauth_device", "oauth_device_code", "device_code_api"):
781
+ out["hint"] = (
782
+ "Device-code OAuth. Open auth_url, enter the user code. "
783
+ "Do not ask for a key or follow get_key_url."
784
+ )
785
+ elif result.get("auth_url"):
730
786
  out["hint"] = "OAuth required. Open auth_url in a browser to authorize."
731
787
  elif auth_type == "api_key":
732
788
  out["hint"] = "API key required. Show get_key_url to the user."
@@ -748,6 +804,19 @@ def cmd_call(args):
748
804
  except APIError as e:
749
805
  _out(_format_api_error(e.status, e.body))
750
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
751
820
 
752
821
 
753
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.3"
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