uno-sdk 1.0.0__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.
@@ -0,0 +1,22 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ *.db
8
+ node_modules/
9
+ .next/
10
+ frontend/.next/
11
+ frontend/node_modules/
12
+ .DS_Store
13
+ .idea/
14
+ *.jpeg
15
+ *.png
16
+ *.db-shm
17
+ *.db-wal
18
+ deploy.sh
19
+ data_export/
20
+ data_export_prod/
21
+ scripts/backup_prod*.json
22
+ .mcp_auth/
uno_sdk-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ClawdChat
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.
uno_sdk-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: uno-sdk
3
+ Version: 1.0.0
4
+ Summary: Python SDK for Uno — the ClawdChat agent tool gateway (2000+ tools). Sync & async clients, OpenAI/Anthropic adapters.
5
+ Project-URL: Homepage, https://clawdtools.uno
6
+ Project-URL: Documentation, https://clawdtools.uno/docs/sdk/python
7
+ Project-URL: Repository, https://github.com/xray918/uno-sdk
8
+ Project-URL: Issues, https://github.com/xray918/uno-sdk/issues
9
+ Author-email: ClawdChat <dev@clawdchat.cn>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,anthropic,clawdchat,llm,mcp,openai,sdk,tool-gateway,uno
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.27
25
+ Provides-Extra: all
26
+ Requires-Dist: anthropic>=0.30; extra == 'all'
27
+ Requires-Dist: openai>=1.0; extra == 'all'
28
+ Provides-Extra: anthropic
29
+ Requires-Dist: anthropic>=0.30; extra == 'anthropic'
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
33
+ Requires-Dist: respx>=0.21.0; extra == 'dev'
34
+ Provides-Extra: openai
35
+ Requires-Dist: openai>=1.0; extra == 'openai'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # Uno SDK for Python
39
+
40
+ > Search and call **2000+ real-world tools** from Python in two lines. Powered by [ClawdChat](https://clawdtools.uno).
41
+
42
+ [![PyPI](https://img.shields.io/pypi/v/uno-sdk.svg)](https://pypi.org/project/uno-sdk/)
43
+ [![Python](https://img.shields.io/pypi/pyversions/uno-sdk.svg)](https://pypi.org/project/uno-sdk/)
44
+ [![License](https://img.shields.io/pypi/l/uno-sdk.svg)](https://github.com/xray918/uno-sdk/blob/main/LICENSE)
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install uno-sdk # core
50
+ pip install uno-sdk[openai] # + OpenAI adapter
51
+ pip install uno-sdk[anthropic] # + Anthropic adapter
52
+ pip install uno-sdk[all] # everything
53
+ ```
54
+
55
+ > Note on naming: the PyPI distribution is **`uno-sdk`** and the import name is **`uno_sdk`**. The bare `uno` PyPI slot is held by an unrelated Python 2-era package (2014, no longer installable) — `uno_sdk` keeps our namespace clean.
56
+
57
+ ## Quick Start
58
+
59
+ ```python
60
+ from uno_sdk import Uno
61
+
62
+ uno = Uno(api_key="uk-xxx")
63
+
64
+ # Search tools
65
+ tools = uno.search("send email")
66
+ print(tools[0].name, tools[0].description)
67
+
68
+ # Call a tool
69
+ result = uno.call("email.send_email", {
70
+ "to": "alice@example.com",
71
+ "subject": "Hello",
72
+ "body": "Hi from Uno!"
73
+ })
74
+ print(result.data)
75
+ ```
76
+
77
+ ## OpenAI Integration
78
+
79
+ ```python
80
+ from uno_sdk import Uno
81
+ from uno_sdk.adapters import OpenAIAdapter
82
+ from openai import OpenAI
83
+
84
+ uno = Uno(api_key="uk-xxx")
85
+ openai_client = OpenAI()
86
+
87
+ # Get tools in OpenAI format
88
+ tools = uno.search("weather", adapter=OpenAIAdapter())
89
+
90
+ # Use with chat completions
91
+ response = openai_client.chat.completions.create(
92
+ model="gpt-4o",
93
+ messages=[{"role": "user", "content": "What's the weather in Beijing?"}],
94
+ tools=tools,
95
+ )
96
+
97
+ # Execute the tool call
98
+ if response.choices[0].message.tool_calls:
99
+ tc = response.choices[0].message.tool_calls[0]
100
+ import json
101
+ slug = OpenAIAdapter.slug_from_function_name(tc.function.name)
102
+ result = uno.call(slug, json.loads(tc.function.arguments))
103
+ print(result.data)
104
+ ```
105
+
106
+ ## Anthropic Integration
107
+
108
+ ```python
109
+ from uno_sdk import Uno
110
+ from uno_sdk.adapters import AnthropicAdapter
111
+ import anthropic
112
+
113
+ uno = Uno(api_key="uk-xxx")
114
+ client = anthropic.Anthropic()
115
+
116
+ tools = uno.search("search", adapter=AnthropicAdapter())
117
+ response = client.messages.create(
118
+ model="claude-sonnet-4-20250514",
119
+ messages=[{"role": "user", "content": "Search for AI news"}],
120
+ tools=tools,
121
+ max_tokens=1024,
122
+ )
123
+ ```
124
+
125
+ ## Async
126
+
127
+ ```python
128
+ from uno_sdk import AsyncUno
129
+
130
+ async with AsyncUno(api_key="uk-xxx") as uno:
131
+ tools = await uno.search("translate")
132
+ result = await uno.call("translate.text", {"text": "hello", "to": "zh"})
133
+ ```
134
+
135
+ ## MCP (Claude Desktop / Cursor)
136
+
137
+ No SDK needed — connect directly:
138
+
139
+ ```json
140
+ {
141
+ "mcpServers": {
142
+ "uno": {
143
+ "url": "https://clawdtools.uno/mcp"
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ OAuth login opens automatically in your browser.
150
+
151
+ ## Error Handling
152
+
153
+ ```python
154
+ from uno_sdk.exceptions import AuthRequiredError, QuotaError, ToolNotFoundError
155
+
156
+ try:
157
+ result = uno.call("github.list_repos", {})
158
+ except AuthRequiredError as e:
159
+ print(f"Please authorize: {e.auth_url}")
160
+ except QuotaError:
161
+ print("Out of credits — visit https://clawdtools.uno/pricing")
162
+ except ToolNotFoundError:
163
+ print("Tool not found — search first")
164
+ ```
165
+
166
+ ## API
167
+
168
+ ### `Uno(api_key, base_url="https://clawdtools.uno", timeout=180)`
169
+
170
+ | Method | Returns | Description |
171
+ |---|---|---|
172
+ | `search(query, limit=10, adapter=None)` | `list[Tool]` or `list[dict]` | Search tools |
173
+ | `call(tool, arguments={})` | `CallResult` | Call a tool |
174
+ | `me()` | `dict` | Current user info |
175
+
176
+ `AsyncUno` has the same methods, all `async`.
177
+
178
+ ### `Tool`
179
+
180
+ | Field | Type | Description |
181
+ |---|---|---|
182
+ | `slug` | `str` | Tool identifier (e.g. `weather.get_current`) |
183
+ | `name` | `str` | Display name |
184
+ | `description` | `str` | What the tool does |
185
+ | `input_schema` | `dict` | JSON Schema for arguments |
186
+ | `auth_required` | `bool` | Needs OAuth? |
187
+ | `pricing_mode` | `str` | `free` / `per_call` / `per_token` |
188
+ | `credit_cost` | `float` | Credits per call |
189
+
190
+ ### `CallResult`
191
+
192
+ | Field | Type | Description |
193
+ |---|---|---|
194
+ | `data` | `Any` | Tool response data |
195
+ | `error` | `str \| None` | Error message if failed |
196
+ | `meta` | `dict` | Latency, credits used |
197
+ | `ok` | `bool` | `True` if no error |
198
+
199
+ ## Companion: Uno CLI
200
+
201
+ Prefer a command-line flow? `pip install uno-cli` ships the `uno` command (search, call, multi-account OAuth, scope enforcement) — same credentials file, same gateway. See [`uno-cli`](https://pypi.org/project/uno-cli/).
202
+
203
+ ## Get an API Key
204
+
205
+ 1. Visit [clawdtools.uno/login](https://clawdtools.uno/login)
206
+ 2. Log in with ClawdChat / Google / Phone
207
+ 3. Copy your API key from the Dashboard
208
+
209
+ ## License
210
+
211
+ MIT © ClawdChat.
@@ -0,0 +1,174 @@
1
+ # Uno SDK for Python
2
+
3
+ > Search and call **2000+ real-world tools** from Python in two lines. Powered by [ClawdChat](https://clawdtools.uno).
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/uno-sdk.svg)](https://pypi.org/project/uno-sdk/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/uno-sdk.svg)](https://pypi.org/project/uno-sdk/)
7
+ [![License](https://img.shields.io/pypi/l/uno-sdk.svg)](https://github.com/xray918/uno-sdk/blob/main/LICENSE)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install uno-sdk # core
13
+ pip install uno-sdk[openai] # + OpenAI adapter
14
+ pip install uno-sdk[anthropic] # + Anthropic adapter
15
+ pip install uno-sdk[all] # everything
16
+ ```
17
+
18
+ > Note on naming: the PyPI distribution is **`uno-sdk`** and the import name is **`uno_sdk`**. The bare `uno` PyPI slot is held by an unrelated Python 2-era package (2014, no longer installable) — `uno_sdk` keeps our namespace clean.
19
+
20
+ ## Quick Start
21
+
22
+ ```python
23
+ from uno_sdk import Uno
24
+
25
+ uno = Uno(api_key="uk-xxx")
26
+
27
+ # Search tools
28
+ tools = uno.search("send email")
29
+ print(tools[0].name, tools[0].description)
30
+
31
+ # Call a tool
32
+ result = uno.call("email.send_email", {
33
+ "to": "alice@example.com",
34
+ "subject": "Hello",
35
+ "body": "Hi from Uno!"
36
+ })
37
+ print(result.data)
38
+ ```
39
+
40
+ ## OpenAI Integration
41
+
42
+ ```python
43
+ from uno_sdk import Uno
44
+ from uno_sdk.adapters import OpenAIAdapter
45
+ from openai import OpenAI
46
+
47
+ uno = Uno(api_key="uk-xxx")
48
+ openai_client = OpenAI()
49
+
50
+ # Get tools in OpenAI format
51
+ tools = uno.search("weather", adapter=OpenAIAdapter())
52
+
53
+ # Use with chat completions
54
+ response = openai_client.chat.completions.create(
55
+ model="gpt-4o",
56
+ messages=[{"role": "user", "content": "What's the weather in Beijing?"}],
57
+ tools=tools,
58
+ )
59
+
60
+ # Execute the tool call
61
+ if response.choices[0].message.tool_calls:
62
+ tc = response.choices[0].message.tool_calls[0]
63
+ import json
64
+ slug = OpenAIAdapter.slug_from_function_name(tc.function.name)
65
+ result = uno.call(slug, json.loads(tc.function.arguments))
66
+ print(result.data)
67
+ ```
68
+
69
+ ## Anthropic Integration
70
+
71
+ ```python
72
+ from uno_sdk import Uno
73
+ from uno_sdk.adapters import AnthropicAdapter
74
+ import anthropic
75
+
76
+ uno = Uno(api_key="uk-xxx")
77
+ client = anthropic.Anthropic()
78
+
79
+ tools = uno.search("search", adapter=AnthropicAdapter())
80
+ response = client.messages.create(
81
+ model="claude-sonnet-4-20250514",
82
+ messages=[{"role": "user", "content": "Search for AI news"}],
83
+ tools=tools,
84
+ max_tokens=1024,
85
+ )
86
+ ```
87
+
88
+ ## Async
89
+
90
+ ```python
91
+ from uno_sdk import AsyncUno
92
+
93
+ async with AsyncUno(api_key="uk-xxx") as uno:
94
+ tools = await uno.search("translate")
95
+ result = await uno.call("translate.text", {"text": "hello", "to": "zh"})
96
+ ```
97
+
98
+ ## MCP (Claude Desktop / Cursor)
99
+
100
+ No SDK needed — connect directly:
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "uno": {
106
+ "url": "https://clawdtools.uno/mcp"
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ OAuth login opens automatically in your browser.
113
+
114
+ ## Error Handling
115
+
116
+ ```python
117
+ from uno_sdk.exceptions import AuthRequiredError, QuotaError, ToolNotFoundError
118
+
119
+ try:
120
+ result = uno.call("github.list_repos", {})
121
+ except AuthRequiredError as e:
122
+ print(f"Please authorize: {e.auth_url}")
123
+ except QuotaError:
124
+ print("Out of credits — visit https://clawdtools.uno/pricing")
125
+ except ToolNotFoundError:
126
+ print("Tool not found — search first")
127
+ ```
128
+
129
+ ## API
130
+
131
+ ### `Uno(api_key, base_url="https://clawdtools.uno", timeout=180)`
132
+
133
+ | Method | Returns | Description |
134
+ |---|---|---|
135
+ | `search(query, limit=10, adapter=None)` | `list[Tool]` or `list[dict]` | Search tools |
136
+ | `call(tool, arguments={})` | `CallResult` | Call a tool |
137
+ | `me()` | `dict` | Current user info |
138
+
139
+ `AsyncUno` has the same methods, all `async`.
140
+
141
+ ### `Tool`
142
+
143
+ | Field | Type | Description |
144
+ |---|---|---|
145
+ | `slug` | `str` | Tool identifier (e.g. `weather.get_current`) |
146
+ | `name` | `str` | Display name |
147
+ | `description` | `str` | What the tool does |
148
+ | `input_schema` | `dict` | JSON Schema for arguments |
149
+ | `auth_required` | `bool` | Needs OAuth? |
150
+ | `pricing_mode` | `str` | `free` / `per_call` / `per_token` |
151
+ | `credit_cost` | `float` | Credits per call |
152
+
153
+ ### `CallResult`
154
+
155
+ | Field | Type | Description |
156
+ |---|---|---|
157
+ | `data` | `Any` | Tool response data |
158
+ | `error` | `str \| None` | Error message if failed |
159
+ | `meta` | `dict` | Latency, credits used |
160
+ | `ok` | `bool` | `True` if no error |
161
+
162
+ ## Companion: Uno CLI
163
+
164
+ Prefer a command-line flow? `pip install uno-cli` ships the `uno` command (search, call, multi-account OAuth, scope enforcement) — same credentials file, same gateway. See [`uno-cli`](https://pypi.org/project/uno-cli/).
165
+
166
+ ## Get an API Key
167
+
168
+ 1. Visit [clawdtools.uno/login](https://clawdtools.uno/login)
169
+ 2. Log in with ClawdChat / Google / Phone
170
+ 3. Copy your API key from the Dashboard
171
+
172
+ ## License
173
+
174
+ MIT © ClawdChat.
@@ -0,0 +1,67 @@
1
+ [project]
2
+ name = "uno-sdk"
3
+ version = "1.0.0"
4
+ description = "Python SDK for Uno — the ClawdChat agent tool gateway (2000+ tools). Sync & async clients, OpenAI/Anthropic adapters."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "ClawdChat", email = "dev@clawdchat.cn" },
10
+ ]
11
+ keywords = [
12
+ "agent",
13
+ "sdk",
14
+ "mcp",
15
+ "tool-gateway",
16
+ "clawdchat",
17
+ "uno",
18
+ "llm",
19
+ "openai",
20
+ "anthropic",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 5 - Production/Stable",
24
+ "Intended Audience :: Developers",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Topic :: Software Development :: Libraries :: Python Modules",
33
+ ]
34
+ dependencies = ["httpx>=0.27"]
35
+
36
+ [project.optional-dependencies]
37
+ openai = ["openai>=1.0"]
38
+ anthropic = ["anthropic>=0.30"]
39
+ all = ["openai>=1.0", "anthropic>=0.30"]
40
+ dev = [
41
+ "pytest>=8.0.0",
42
+ "pytest-asyncio>=0.24.0",
43
+ "respx>=0.21.0",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://clawdtools.uno"
48
+ Documentation = "https://clawdtools.uno/docs/sdk/python"
49
+ Repository = "https://github.com/xray918/uno-sdk"
50
+ Issues = "https://github.com/xray918/uno-sdk/issues"
51
+
52
+ [build-system]
53
+ requires = ["hatchling"]
54
+ build-backend = "hatchling.build"
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["uno_sdk"]
58
+
59
+ [tool.hatch.build.targets.sdist]
60
+ include = [
61
+ "uno_sdk",
62
+ "README.md",
63
+ "tests",
64
+ ]
65
+
66
+ [tool.pytest.ini_options]
67
+ asyncio_mode = "auto"
File without changes
@@ -0,0 +1,85 @@
1
+ """Unit tests for the OpenAI / Anthropic framework adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uno_sdk.adapters import OpenAIAdapter
6
+ from uno_sdk.adapters.anthropic import AnthropicAdapter
7
+ from uno_sdk.models import Tool
8
+
9
+
10
+ def _weather_tool(**overrides) -> Tool:
11
+ defaults = dict(
12
+ slug="weather-free.get_current_weather",
13
+ name="get_current_weather",
14
+ description="Get current weather",
15
+ input_schema={
16
+ "type": "object",
17
+ "properties": {"location": {"type": "string"}},
18
+ "required": ["location"],
19
+ },
20
+ auth_required=False,
21
+ )
22
+ defaults.update(overrides)
23
+ return Tool(**defaults)
24
+
25
+
26
+ class TestOpenAIAdapter:
27
+ def test_dots_in_slug_become_double_underscores(self):
28
+ """OpenAI function names only allow ``[a-zA-Z0-9_-]`` — dots must be encoded."""
29
+ out = OpenAIAdapter().wrap_tool(_weather_tool())
30
+ assert out["type"] == "function"
31
+ assert out["function"]["name"] == "weather-free__get_current_weather"
32
+
33
+ def test_slug_roundtrip(self):
34
+ original = "weather-free.get_current_weather"
35
+ encoded = original.replace(".", "__")
36
+ assert OpenAIAdapter.slug_from_function_name(encoded) == original
37
+
38
+ def test_auth_required_prefixes_description(self):
39
+ out = OpenAIAdapter().wrap_tool(_weather_tool(auth_required=True))
40
+ assert out["function"]["description"].startswith("[OAuth required]")
41
+
42
+ def test_description_truncated_at_1024_chars(self):
43
+ long = "x" * 5000
44
+ out = OpenAIAdapter().wrap_tool(_weather_tool(description=long))
45
+ assert len(out["function"]["description"]) == 1024
46
+
47
+ def test_adds_type_object_if_missing(self):
48
+ out = OpenAIAdapter().wrap_tool(
49
+ _weather_tool(input_schema={"properties": {"x": {"type": "string"}}})
50
+ )
51
+ assert out["function"]["parameters"]["type"] == "object"
52
+
53
+ def test_falls_back_to_name_when_description_empty(self):
54
+ out = OpenAIAdapter().wrap_tool(_weather_tool(description=""))
55
+ assert out["function"]["description"] == "get_current_weather"
56
+
57
+ def test_wrap_tools_preserves_order(self):
58
+ tools = [_weather_tool(slug=f"server.tool_{i}") for i in range(3)]
59
+ wrapped = OpenAIAdapter().wrap_tools(tools)
60
+ assert [w["function"]["name"] for w in wrapped] == [
61
+ "server__tool_0",
62
+ "server__tool_1",
63
+ "server__tool_2",
64
+ ]
65
+
66
+
67
+ class TestAnthropicAdapter:
68
+ def test_produces_anthropic_shape(self):
69
+ out = AnthropicAdapter().wrap_tool(_weather_tool())
70
+ assert set(out.keys()) == {"name", "description", "input_schema"}
71
+ assert out["name"] == "weather-free__get_current_weather"
72
+ assert out["input_schema"]["type"] == "object"
73
+
74
+ def test_slug_roundtrip(self):
75
+ assert (
76
+ AnthropicAdapter.slug_from_tool_name("weather-free__get_current_weather")
77
+ == "weather-free.get_current_weather"
78
+ )
79
+
80
+ def test_does_not_mutate_caller_schema(self):
81
+ """The caller's ``tool.input_schema`` must not be modified in place."""
82
+ original = {"properties": {"x": {"type": "string"}}}
83
+ tool = _weather_tool(input_schema=original)
84
+ AnthropicAdapter().wrap_tool(tool)
85
+ assert "type" not in original # unchanged
@@ -0,0 +1,169 @@
1
+ """HTTP-level tests for ``Uno`` and ``AsyncUno`` using ``respx`` to mock httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+
9
+ from uno_sdk import AsyncUno, Uno
10
+ from uno_sdk.exceptions import (
11
+ AuthError,
12
+ AuthRequiredError,
13
+ QuotaError,
14
+ ToolNotFoundError,
15
+ UnoError,
16
+ )
17
+
18
+ BASE = "https://clawdtools.uno"
19
+
20
+
21
+ # ── Sync ───────────────────────────────────────────────────────────
22
+
23
+
24
+ @respx.mock
25
+ class TestSyncClient:
26
+ def test_search_parses_tools(self):
27
+ respx.get(f"{BASE}/v1/tools").respond(
28
+ 200,
29
+ json={
30
+ "tools": [
31
+ {
32
+ "tool": "weather-free.get_current_weather",
33
+ "name": "get_current_weather",
34
+ "desc": "Get current weather",
35
+ "auth_required": False,
36
+ "pricing": {"mode": "per_call", "cost": 1},
37
+ }
38
+ ]
39
+ },
40
+ )
41
+ tools = Uno(api_key="uk-test").search("weather")
42
+ assert len(tools) == 1
43
+ assert tools[0].slug == "weather-free.get_current_weather"
44
+
45
+ def test_search_sends_bearer_auth(self):
46
+ route = respx.get(f"{BASE}/v1/tools").respond(200, json={"tools": []})
47
+ Uno(api_key="uk-secret-42").search("anything")
48
+ assert route.calls.last.request.headers["Authorization"] == "Bearer uk-secret-42"
49
+
50
+ def test_search_with_adapter_returns_wrapped_dicts(self):
51
+ from uno_sdk.adapters import OpenAIAdapter
52
+
53
+ respx.get(f"{BASE}/v1/tools").respond(
54
+ 200,
55
+ json={
56
+ "tools": [
57
+ {
58
+ "tool": "weather-free.get_current_weather",
59
+ "name": "get_current_weather",
60
+ "desc": "Get current weather",
61
+ "input_schema": {"type": "object"},
62
+ }
63
+ ]
64
+ },
65
+ )
66
+ out = Uno(api_key="uk-test").search("weather", adapter=OpenAIAdapter())
67
+ assert isinstance(out[0], dict)
68
+ assert out[0]["type"] == "function"
69
+ assert out[0]["function"]["name"].startswith("weather-free__")
70
+
71
+ def test_call_success_returns_call_result(self):
72
+ respx.post(f"{BASE}/v1/call").respond(
73
+ 200,
74
+ json={"data": {"temp": 25}, "meta": {"latency_ms": 120, "credits_used": 1}},
75
+ )
76
+ r = Uno(api_key="uk-test").call("weather-free.get_current_weather", {"location": "Beijing"})
77
+ assert r.ok
78
+ assert r.data == {"temp": 25}
79
+ assert r.meta["credits_used"] == 1
80
+
81
+ def test_call_sends_correct_body(self):
82
+ route = respx.post(f"{BASE}/v1/call").respond(200, json={"data": {}})
83
+ Uno(api_key="uk-test").call("a.b", {"x": 1})
84
+ body = route.calls.last.request.read()
85
+ import json as _json
86
+
87
+ assert _json.loads(body) == {"tool": "a.b", "arguments": {"x": 1}}
88
+
89
+ def test_call_default_arguments_is_empty_dict(self):
90
+ route = respx.post(f"{BASE}/v1/call").respond(200, json={"data": {}})
91
+ Uno(api_key="uk-test").call("a.b")
92
+ import json as _json
93
+
94
+ assert _json.loads(route.calls.last.request.read())["arguments"] == {}
95
+
96
+ @pytest.mark.parametrize(
97
+ "error_payload,exc_type",
98
+ [
99
+ ({"error": "auth_required", "auth_url": "https://a/u", "message": "oauth"}, AuthRequiredError),
100
+ ({"error": "insufficient_credits", "message": "no credits"}, QuotaError),
101
+ ({"error": "tool_not_found", "message": "nope"}, ToolNotFoundError),
102
+ ({"error": "invalid_api_key", "message": "bad key"}, AuthError),
103
+ ({"error": "server_error", "message": "boom"}, UnoError),
104
+ ],
105
+ )
106
+ def test_call_error_dispatch(self, error_payload, exc_type):
107
+ respx.post(f"{BASE}/v1/call").respond(200, json=error_payload)
108
+ with pytest.raises(exc_type) as excinfo:
109
+ Uno(api_key="uk-test").call("any.tool")
110
+ if exc_type is AuthRequiredError:
111
+ assert excinfo.value.auth_url == "https://a/u"
112
+
113
+ def test_401_raises_auth_error_even_without_json(self):
114
+ respx.post(f"{BASE}/v1/call").respond(401, json={"error": "whatever"})
115
+ with pytest.raises(AuthError):
116
+ Uno(api_key="uk-bad").call("a.b")
117
+
118
+ def test_context_manager_closes_client(self):
119
+ respx.get(f"{BASE}/v1/auth/me").respond(200, json={"email": "a@b.c"})
120
+ with Uno(api_key="uk-test") as uno:
121
+ uno.me()
122
+ # ``close`` is idempotent; calling it again after __exit__ must not crash.
123
+ uno.close()
124
+
125
+ def test_base_url_override(self):
126
+ custom_base = "https://preview.clawdtools.uno"
127
+ respx.get(f"{custom_base}/v1/auth/me").respond(200, json={"email": "x@x"})
128
+ Uno(api_key="uk-test", base_url=custom_base).me()
129
+
130
+
131
+ # ── Async ──────────────────────────────────────────────────────────
132
+
133
+
134
+ class TestAsyncClient:
135
+ @pytest.mark.asyncio
136
+ @respx.mock
137
+ async def test_async_search(self):
138
+ respx.get(f"{BASE}/v1/tools").respond(200, json={"tools": []})
139
+ async with AsyncUno(api_key="uk-test") as uno:
140
+ out = await uno.search("x")
141
+ assert out == []
142
+
143
+ @pytest.mark.asyncio
144
+ @respx.mock
145
+ async def test_async_call_error_dispatch(self):
146
+ respx.post(f"{BASE}/v1/call").respond(
147
+ 200, json={"error": "tool_not_found", "message": "nope"}
148
+ )
149
+ async with AsyncUno(api_key="uk-test") as uno:
150
+ with pytest.raises(ToolNotFoundError):
151
+ await uno.call("a.b")
152
+
153
+ @pytest.mark.asyncio
154
+ @respx.mock
155
+ async def test_async_me(self):
156
+ respx.get(f"{BASE}/v1/auth/me").respond(
157
+ 200, json={"email": "a@b.c", "credits": 500}
158
+ )
159
+ async with AsyncUno(api_key="uk-test") as uno:
160
+ me = await uno.me()
161
+ assert me["credits"] == 500
162
+
163
+ @pytest.mark.asyncio
164
+ @respx.mock
165
+ async def test_async_401_raises_auth_error(self):
166
+ respx.post(f"{BASE}/v1/call").respond(401, json={})
167
+ async with AsyncUno(api_key="uk-bad") as uno:
168
+ with pytest.raises(AuthError):
169
+ await uno.call("a.b")
@@ -0,0 +1,54 @@
1
+ """Unit tests for the plain-dataclass models in ``uno_sdk.models``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uno_sdk.models import CallResult, Tool
6
+
7
+
8
+ class TestToolFromApi:
9
+ def test_full_payload(self):
10
+ t = Tool.from_api(
11
+ {
12
+ "tool": "weather.get_current",
13
+ "name": "get_current",
14
+ "desc": "Current conditions",
15
+ "input_schema": {"type": "object"},
16
+ "auth_required": True,
17
+ "pricing": {"mode": "per_call", "cost": 2.5},
18
+ "stats": {"rating": 4.8},
19
+ }
20
+ )
21
+ assert t.slug == "weather.get_current"
22
+ assert t.name == "get_current"
23
+ assert t.description == "Current conditions"
24
+ assert t.auth_required is True
25
+ assert t.pricing_mode == "per_call"
26
+ assert t.credit_cost == 2.5
27
+ assert t.stats == {"rating": 4.8}
28
+
29
+ def test_missing_fields_defaults(self):
30
+ t = Tool.from_api({})
31
+ assert t.slug == ""
32
+ assert t.pricing_mode == "free"
33
+ assert t.credit_cost == 0
34
+ assert t.input_schema == {}
35
+
36
+ def test_description_falls_back_to_description_field(self):
37
+ """Legacy payloads use ``description`` instead of ``desc``."""
38
+ t = Tool.from_api({"description": "Legacy description"})
39
+ assert t.description == "Legacy description"
40
+
41
+
42
+ class TestCallResultFromApi:
43
+ def test_success(self):
44
+ r = CallResult.from_api(
45
+ {"data": {"temp": 25}, "meta": {"latency_ms": 120, "credits_used": 1}}
46
+ )
47
+ assert r.ok is True
48
+ assert r.data == {"temp": 25}
49
+ assert r.meta["latency_ms"] == 120
50
+
51
+ def test_error(self):
52
+ r = CallResult.from_api({"error": "tool_not_found", "meta": {}})
53
+ assert r.ok is False
54
+ assert r.error == "tool_not_found"
@@ -0,0 +1,19 @@
1
+ """Uno SDK — search and call 2000+ tools in two lines of Python.
2
+
3
+ Install::
4
+
5
+ pip install uno-sdk
6
+
7
+ Quick start::
8
+
9
+ from uno_sdk import Uno
10
+ uno = Uno(api_key="uk-...")
11
+ tools = uno.search("weather")
12
+ result = uno.call("weather-free.get_current_weather", {"location": "Beijing"})
13
+ """
14
+
15
+ from uno_sdk._version import __version__
16
+ from uno_sdk.client import AsyncUno, Uno
17
+ from uno_sdk.models import CallResult, Tool
18
+
19
+ __all__ = ["AsyncUno", "CallResult", "Tool", "Uno", "__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,11 @@
1
+ """Framework adapters — convert UNO tools to framework-native format."""
2
+
3
+ from uno_sdk.adapters.openai import OpenAIAdapter
4
+
5
+ __all__ = ["OpenAIAdapter"]
6
+
7
+ try:
8
+ from uno_sdk.adapters.anthropic import AnthropicAdapter
9
+ __all__.append("AnthropicAdapter")
10
+ except ImportError:
11
+ pass
@@ -0,0 +1,60 @@
1
+ """Anthropic tool_use adapter.
2
+
3
+ Converts UNO Tool objects to the format expected by
4
+ anthropic.messages.create(tools=[...]).
5
+
6
+ Usage::
7
+
8
+ from uno_sdk import Uno
9
+ from uno_sdk.adapters import AnthropicAdapter
10
+
11
+ uno = Uno(api_key="uk-xxx")
12
+ tools = uno.search("weather", adapter=AnthropicAdapter())
13
+
14
+ import anthropic
15
+ response = anthropic.messages.create(
16
+ model="claude-sonnet-4-20250514",
17
+ messages=[{"role": "user", "content": "What's the weather?"}],
18
+ tools=tools,
19
+ )
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ from uno_sdk.models import Tool
27
+
28
+
29
+ class AnthropicAdapter:
30
+ """Convert UNO tools to Anthropic tool_use format."""
31
+
32
+ def wrap_tool(self, tool: Tool) -> dict[str, Any]:
33
+ """Convert a single tool to Anthropic tool schema.
34
+
35
+ Anthropic tool names must match ^[a-zA-Z0-9_-]{1,64}$ so we
36
+ replace dots with double underscores (same as OpenAI adapter).
37
+ """
38
+ func_name = tool.slug.replace(".", "__")
39
+
40
+ schema = dict(tool.input_schema) if tool.input_schema else {}
41
+ if "type" not in schema:
42
+ schema["type"] = "object"
43
+
44
+ desc = tool.description or tool.name
45
+ if tool.auth_required:
46
+ desc = f"[OAuth required] {desc}"
47
+
48
+ return {
49
+ "name": func_name,
50
+ "description": desc[:1024],
51
+ "input_schema": schema,
52
+ }
53
+
54
+ def wrap_tools(self, tools: list[Tool]) -> list[dict[str, Any]]:
55
+ return [self.wrap_tool(t) for t in tools]
56
+
57
+ @staticmethod
58
+ def slug_from_tool_name(tool_name: str) -> str:
59
+ """Reverse: weather__get_current → weather.get_current."""
60
+ return tool_name.replace("__", ".", 1)
@@ -0,0 +1,66 @@
1
+ """OpenAI function calling adapter.
2
+
3
+ Converts UNO Tool objects to the format expected by
4
+ openai.chat.completions.create(tools=[...]).
5
+
6
+ Usage::
7
+
8
+ from uno_sdk import Uno
9
+ from uno_sdk.adapters import OpenAIAdapter
10
+
11
+ uno = Uno(api_key="uk-xxx")
12
+ tools = uno.search("email", adapter=OpenAIAdapter())
13
+
14
+ import openai
15
+ response = openai.chat.completions.create(
16
+ model="gpt-4o",
17
+ messages=[{"role": "user", "content": "Send an email to ..."}],
18
+ tools=tools,
19
+ )
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ from uno_sdk.models import Tool
27
+
28
+
29
+ class OpenAIAdapter:
30
+ """Convert UNO tools to OpenAI function calling format."""
31
+
32
+ def wrap_tool(self, tool: Tool) -> dict[str, Any]:
33
+ """Convert a single tool to OpenAI function schema.
34
+
35
+ OpenAI function names must match ^[a-zA-Z0-9_-]+$ so we replace
36
+ dots in UNO slugs with double underscores.
37
+ """
38
+ # OpenAI function name: replace dots with __ (reversible)
39
+ func_name = tool.slug.replace(".", "__")
40
+
41
+ schema = dict(tool.input_schema) if tool.input_schema else {}
42
+ # Ensure schema has type: object (OpenAI requirement)
43
+ if "type" not in schema:
44
+ schema["type"] = "object"
45
+
46
+ desc = tool.description or tool.name
47
+ if tool.auth_required:
48
+ desc = f"[OAuth required] {desc}"
49
+
50
+ return {
51
+ "type": "function",
52
+ "function": {
53
+ "name": func_name,
54
+ "description": desc[:1024],
55
+ "parameters": schema,
56
+ },
57
+ }
58
+
59
+ def wrap_tools(self, tools: list[Tool]) -> list[dict[str, Any]]:
60
+ """Convert a list of tools."""
61
+ return [self.wrap_tool(t) for t in tools]
62
+
63
+ @staticmethod
64
+ def slug_from_function_name(func_name: str) -> str:
65
+ """Reverse the name encoding: weather__get_current → weather.get_current."""
66
+ return func_name.replace("__", ".", 1)
@@ -0,0 +1,136 @@
1
+ """Uno SDK client — synchronous and asynchronous."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from uno_sdk.exceptions import AuthError, AuthRequiredError, QuotaError, ToolNotFoundError, UnoError
10
+ from uno_sdk.models import CallResult, Tool
11
+
12
+ _DEFAULT_BASE = "https://clawdtools.uno"
13
+
14
+
15
+ def _handle_error(resp_data: dict) -> None:
16
+ """Raise the appropriate exception based on error code."""
17
+ error = resp_data.get("error")
18
+ if not error:
19
+ return
20
+ msg = resp_data.get("message", str(error))
21
+ code = error if isinstance(error, str) else None
22
+
23
+ if code == "auth_required":
24
+ raise AuthRequiredError(msg, auth_url=resp_data.get("auth_url"), code=code)
25
+ if code == "insufficient_credits":
26
+ raise QuotaError(msg, code=code)
27
+ if code == "tool_not_found":
28
+ raise ToolNotFoundError(msg, code=code)
29
+ if code in ("invalid_api_key", "not_authenticated"):
30
+ raise AuthError(msg, code=code)
31
+ raise UnoError(msg, code=code)
32
+
33
+
34
+ class Uno:
35
+ """Synchronous Uno client.
36
+
37
+ Usage::
38
+
39
+ from uno_sdk import Uno
40
+ uno = Uno(api_key="uk-xxx")
41
+ tools = uno.search("send email")
42
+ result = uno.call("email.send_email", {"to": "a@b.com", "subject": "Hi"})
43
+ """
44
+
45
+ def __init__(self, api_key: str, base_url: str = _DEFAULT_BASE, timeout: float = 180):
46
+ self._base = base_url.rstrip("/")
47
+ self._client = httpx.Client(
48
+ base_url=self._base,
49
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
50
+ timeout=timeout,
51
+ )
52
+
53
+ def search(self, query: str = "", *, limit: int = 10, adapter: Any = None) -> list[Tool] | list[dict]:
54
+ """Search tools. If adapter is provided, return framework-native format."""
55
+ resp = self._client.get("/v1/tools", params={"q": query, "limit": limit})
56
+ resp.raise_for_status()
57
+ data = resp.json()
58
+ tools = [Tool.from_api(t) for t in data.get("tools", [])]
59
+ if adapter:
60
+ return adapter.wrap_tools(tools)
61
+ return tools
62
+
63
+ def call(self, tool: str, arguments: dict[str, Any] | None = None) -> CallResult:
64
+ """Call a tool and return the result."""
65
+ resp = self._client.post("/v1/call", json={"tool": tool, "arguments": arguments or {}})
66
+ if resp.status_code == 401:
67
+ raise AuthError("Invalid API key", code="invalid_api_key", status=401)
68
+ data = resp.json()
69
+ _handle_error(data)
70
+ return CallResult.from_api(data)
71
+
72
+ def me(self) -> dict:
73
+ """Get current user info (credits, plan, etc.)."""
74
+ resp = self._client.get("/v1/auth/me")
75
+ resp.raise_for_status()
76
+ return resp.json()
77
+
78
+ def close(self):
79
+ self._client.close()
80
+
81
+ def __enter__(self):
82
+ return self
83
+
84
+ def __exit__(self, *args):
85
+ self.close()
86
+
87
+
88
+ class AsyncUno:
89
+ """Asynchronous Uno client.
90
+
91
+ Usage::
92
+
93
+ from uno_sdk import AsyncUno
94
+ async with AsyncUno(api_key="uk-xxx") as uno:
95
+ tools = await uno.search("weather")
96
+ result = await uno.call("weather.get_current", {"city": "Beijing"})
97
+ """
98
+
99
+ def __init__(self, api_key: str, base_url: str = _DEFAULT_BASE, timeout: float = 180):
100
+ self._base = base_url.rstrip("/")
101
+ self._client = httpx.AsyncClient(
102
+ base_url=self._base,
103
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
104
+ timeout=timeout,
105
+ )
106
+
107
+ async def search(self, query: str = "", *, limit: int = 10, adapter: Any = None) -> list[Tool] | list[dict]:
108
+ resp = await self._client.get("/v1/tools", params={"q": query, "limit": limit})
109
+ resp.raise_for_status()
110
+ data = resp.json()
111
+ tools = [Tool.from_api(t) for t in data.get("tools", [])]
112
+ if adapter:
113
+ return adapter.wrap_tools(tools)
114
+ return tools
115
+
116
+ async def call(self, tool: str, arguments: dict[str, Any] | None = None) -> CallResult:
117
+ resp = await self._client.post("/v1/call", json={"tool": tool, "arguments": arguments or {}})
118
+ if resp.status_code == 401:
119
+ raise AuthError("Invalid API key", code="invalid_api_key", status=401)
120
+ data = resp.json()
121
+ _handle_error(data)
122
+ return CallResult.from_api(data)
123
+
124
+ async def me(self) -> dict:
125
+ resp = await self._client.get("/v1/auth/me")
126
+ resp.raise_for_status()
127
+ return resp.json()
128
+
129
+ async def close(self):
130
+ await self._client.aclose()
131
+
132
+ async def __aenter__(self):
133
+ return self
134
+
135
+ async def __aexit__(self, *args):
136
+ await self.close()
@@ -0,0 +1,31 @@
1
+ """Uno SDK exceptions."""
2
+
3
+
4
+ class UnoError(Exception):
5
+ """Base exception for Uno SDK."""
6
+ def __init__(self, message: str, code: str | None = None, status: int | None = None):
7
+ super().__init__(message)
8
+ self.code = code
9
+ self.status = status
10
+
11
+
12
+ class AuthError(UnoError):
13
+ """Authentication failed (invalid or missing API key)."""
14
+ pass
15
+
16
+
17
+ class QuotaError(UnoError):
18
+ """Insufficient credits."""
19
+ pass
20
+
21
+
22
+ class ToolNotFoundError(UnoError):
23
+ """The requested tool does not exist."""
24
+ pass
25
+
26
+
27
+ class AuthRequiredError(UnoError):
28
+ """The tool requires OAuth authorization."""
29
+ def __init__(self, message: str, auth_url: str | None = None, **kw):
30
+ super().__init__(message, **kw)
31
+ self.auth_url = auth_url
@@ -0,0 +1,52 @@
1
+ """Data models for the Uno SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Tool:
11
+ """A tool returned by search."""
12
+ slug: str
13
+ name: str
14
+ description: str
15
+ input_schema: dict[str, Any] = field(default_factory=dict)
16
+ auth_required: bool = False
17
+ pricing_mode: str = "free"
18
+ credit_cost: float = 0
19
+ stats: dict[str, Any] = field(default_factory=dict)
20
+
21
+ @classmethod
22
+ def from_api(cls, data: dict) -> Tool:
23
+ return cls(
24
+ slug=data.get("tool", ""),
25
+ name=data.get("name", ""),
26
+ description=data.get("desc") or data.get("description", ""),
27
+ input_schema=data.get("input_schema", {}),
28
+ auth_required=data.get("auth_required", False),
29
+ pricing_mode=data.get("pricing", {}).get("mode", "free"),
30
+ credit_cost=data.get("pricing", {}).get("cost", 0),
31
+ stats=data.get("stats", {}),
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class CallResult:
37
+ """Result of calling a tool."""
38
+ data: Any = None
39
+ error: str | None = None
40
+ meta: dict[str, Any] = field(default_factory=dict)
41
+
42
+ @property
43
+ def ok(self) -> bool:
44
+ return self.error is None
45
+
46
+ @classmethod
47
+ def from_api(cls, data: dict) -> CallResult:
48
+ return cls(
49
+ data=data.get("data"),
50
+ error=data.get("error"),
51
+ meta=data.get("meta", {}),
52
+ )