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.
- {uno_cli-1.0.3 → uno_cli-1.0.5}/.gitignore +4 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/PKG-INFO +1 -1
- {uno_cli-1.0.3 → uno_cli-1.0.5}/pyproject.toml +1 -1
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/test_cli.py +101 -0
- uno_cli-1.0.5/tests/test_reference_upload.py +165 -0
- uno_cli-1.0.5/uno_cli/_version.py +1 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/uno_cli/cli.py +78 -9
- uno_cli-1.0.5/uno_cli/references.py +205 -0
- uno_cli-1.0.3/uno_cli/_version.py +0 -1
- {uno_cli-1.0.3 → uno_cli-1.0.5}/LICENSE +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/README.md +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/__init__.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/test_auth.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/test_connections_logs.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/test_dry_run.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/test_register.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/test_scope.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/tests/test_token_masking.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/uno_cli/__init__.py +0 -0
- {uno_cli-1.0.3 → uno_cli-1.0.5}/uno_cli/__main__.py +0 -0
|
@@ -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
|
+
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
|
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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) >
|
|
685
|
-
_fail("Arguments too large
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|