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.
- uno_sdk-1.0.0/.gitignore +22 -0
- uno_sdk-1.0.0/LICENSE +21 -0
- uno_sdk-1.0.0/PKG-INFO +211 -0
- uno_sdk-1.0.0/README.md +174 -0
- uno_sdk-1.0.0/pyproject.toml +67 -0
- uno_sdk-1.0.0/tests/__init__.py +0 -0
- uno_sdk-1.0.0/tests/test_adapters.py +85 -0
- uno_sdk-1.0.0/tests/test_client.py +169 -0
- uno_sdk-1.0.0/tests/test_models.py +54 -0
- uno_sdk-1.0.0/uno_sdk/__init__.py +19 -0
- uno_sdk-1.0.0/uno_sdk/_version.py +1 -0
- uno_sdk-1.0.0/uno_sdk/adapters/__init__.py +11 -0
- uno_sdk-1.0.0/uno_sdk/adapters/anthropic.py +60 -0
- uno_sdk-1.0.0/uno_sdk/adapters/openai.py +66 -0
- uno_sdk-1.0.0/uno_sdk/client.py +136 -0
- uno_sdk-1.0.0/uno_sdk/exceptions.py +31 -0
- uno_sdk-1.0.0/uno_sdk/models.py +52 -0
uno_sdk-1.0.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/uno-sdk/)
|
|
43
|
+
[](https://pypi.org/project/uno-sdk/)
|
|
44
|
+
[](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.
|
uno_sdk-1.0.0/README.md
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/uno-sdk/)
|
|
6
|
+
[](https://pypi.org/project/uno-sdk/)
|
|
7
|
+
[](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
|
+
)
|