asymmetric-py 0.1.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.
- asymmetric_py-0.1.0/.gitignore +7 -0
- asymmetric_py-0.1.0/LICENSE +85 -0
- asymmetric_py-0.1.0/PKG-INFO +234 -0
- asymmetric_py-0.1.0/PUBLISH.md +25 -0
- asymmetric_py-0.1.0/README.md +128 -0
- asymmetric_py-0.1.0/asymmetric/__init__.py +31 -0
- asymmetric_py-0.1.0/asymmetric/client.py +291 -0
- asymmetric_py-0.1.0/asymmetric/py.typed +0 -0
- asymmetric_py-0.1.0/asymmetric/types.py +102 -0
- asymmetric_py-0.1.0/pyproject.toml +47 -0
- asymmetric_py-0.1.0/tests/test_client.py +197 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
PolyForm Noncommercial License 1.0.0
|
|
2
|
+
|
|
3
|
+
Acceptance
|
|
4
|
+
|
|
5
|
+
In order to get any license under these terms, you must agree
|
|
6
|
+
to them as both strict obligations and conditions to all your
|
|
7
|
+
licenses.
|
|
8
|
+
|
|
9
|
+
Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a copyright license for the software to
|
|
12
|
+
do everything you might do with the software that would otherwise
|
|
13
|
+
infringe the licensor's copyright in it for any permitted purpose.
|
|
14
|
+
|
|
15
|
+
Patent License
|
|
16
|
+
|
|
17
|
+
The licensor grants you a patent license for the software that
|
|
18
|
+
covers patent claims the licensor can license, or becomes able to
|
|
19
|
+
license, that you would otherwise infringe by using the software
|
|
20
|
+
for any permitted purpose.
|
|
21
|
+
|
|
22
|
+
Noncommercial Purposes
|
|
23
|
+
|
|
24
|
+
Any noncommercial purpose is a permitted purpose.
|
|
25
|
+
|
|
26
|
+
Distribution
|
|
27
|
+
|
|
28
|
+
You may distribute copies of the software.
|
|
29
|
+
|
|
30
|
+
Conditions
|
|
31
|
+
|
|
32
|
+
Your licenses are subject to the following conditions:
|
|
33
|
+
|
|
34
|
+
No Trademark License
|
|
35
|
+
|
|
36
|
+
Neither this license nor any other license granted under these
|
|
37
|
+
terms gives you any right in the licensor's trademarks or any
|
|
38
|
+
other rights in the licensor's name, logo, or brand.
|
|
39
|
+
|
|
40
|
+
Notices
|
|
41
|
+
|
|
42
|
+
You must ensure that anyone who gets a copy of any part of the
|
|
43
|
+
software from you also gets a copy of these terms or a link to
|
|
44
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>.
|
|
45
|
+
|
|
46
|
+
Changes
|
|
47
|
+
|
|
48
|
+
You must not remove any copyright notice from the software.
|
|
49
|
+
|
|
50
|
+
You must cause any modified files to carry prominent notices
|
|
51
|
+
stating that you changed the files.
|
|
52
|
+
|
|
53
|
+
No Compensation
|
|
54
|
+
|
|
55
|
+
You may not use this software or provide it to others for
|
|
56
|
+
compensation or other consideration.
|
|
57
|
+
|
|
58
|
+
If you are already using this software for compensation or other
|
|
59
|
+
consideration, you must stop.
|
|
60
|
+
|
|
61
|
+
Excuse
|
|
62
|
+
|
|
63
|
+
If anyone notifies you in writing that you have not complied with
|
|
64
|
+
No Compensation, you can keep your license by taking all practical
|
|
65
|
+
steps to comply within 32 days after the notice. Otherwise, your
|
|
66
|
+
license ends immediately.
|
|
67
|
+
|
|
68
|
+
Patent Defense
|
|
69
|
+
|
|
70
|
+
If you make any written claim that the software infringes or
|
|
71
|
+
contributes to infringement of any patent, your patent license
|
|
72
|
+
for the software ends immediately. If your company makes such a
|
|
73
|
+
claim, your patent license ends immediately for work on behalf of
|
|
74
|
+
your company.
|
|
75
|
+
|
|
76
|
+
Violations
|
|
77
|
+
|
|
78
|
+
If you violate these terms, your licenses end immediately.
|
|
79
|
+
|
|
80
|
+
No Liability
|
|
81
|
+
|
|
82
|
+
As far as the law allows, the software comes as is, without any
|
|
83
|
+
warranty or condition, and the licensor will not be liable to you
|
|
84
|
+
for any damages arising out of these terms or the use or nature of
|
|
85
|
+
the software, under any kind of legal claim.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: asymmetric-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight Python SDK for the Asymmetric API
|
|
5
|
+
Project-URL: Homepage, https://github.com/asymmetric-dev/asymmetric-py
|
|
6
|
+
Project-URL: Repository, https://github.com/asymmetric-dev/asymmetric-py
|
|
7
|
+
Project-URL: Issues, https://github.com/asymmetric-dev/asymmetric-py/issues
|
|
8
|
+
Author: Asymmetric
|
|
9
|
+
License: PolyForm Noncommercial License 1.0.0
|
|
10
|
+
|
|
11
|
+
Acceptance
|
|
12
|
+
|
|
13
|
+
In order to get any license under these terms, you must agree
|
|
14
|
+
to them as both strict obligations and conditions to all your
|
|
15
|
+
licenses.
|
|
16
|
+
|
|
17
|
+
Copyright License
|
|
18
|
+
|
|
19
|
+
The licensor grants you a copyright license for the software to
|
|
20
|
+
do everything you might do with the software that would otherwise
|
|
21
|
+
infringe the licensor's copyright in it for any permitted purpose.
|
|
22
|
+
|
|
23
|
+
Patent License
|
|
24
|
+
|
|
25
|
+
The licensor grants you a patent license for the software that
|
|
26
|
+
covers patent claims the licensor can license, or becomes able to
|
|
27
|
+
license, that you would otherwise infringe by using the software
|
|
28
|
+
for any permitted purpose.
|
|
29
|
+
|
|
30
|
+
Noncommercial Purposes
|
|
31
|
+
|
|
32
|
+
Any noncommercial purpose is a permitted purpose.
|
|
33
|
+
|
|
34
|
+
Distribution
|
|
35
|
+
|
|
36
|
+
You may distribute copies of the software.
|
|
37
|
+
|
|
38
|
+
Conditions
|
|
39
|
+
|
|
40
|
+
Your licenses are subject to the following conditions:
|
|
41
|
+
|
|
42
|
+
No Trademark License
|
|
43
|
+
|
|
44
|
+
Neither this license nor any other license granted under these
|
|
45
|
+
terms gives you any right in the licensor's trademarks or any
|
|
46
|
+
other rights in the licensor's name, logo, or brand.
|
|
47
|
+
|
|
48
|
+
Notices
|
|
49
|
+
|
|
50
|
+
You must ensure that anyone who gets a copy of any part of the
|
|
51
|
+
software from you also gets a copy of these terms or a link to
|
|
52
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>.
|
|
53
|
+
|
|
54
|
+
Changes
|
|
55
|
+
|
|
56
|
+
You must not remove any copyright notice from the software.
|
|
57
|
+
|
|
58
|
+
You must cause any modified files to carry prominent notices
|
|
59
|
+
stating that you changed the files.
|
|
60
|
+
|
|
61
|
+
No Compensation
|
|
62
|
+
|
|
63
|
+
You may not use this software or provide it to others for
|
|
64
|
+
compensation or other consideration.
|
|
65
|
+
|
|
66
|
+
If you are already using this software for compensation or other
|
|
67
|
+
consideration, you must stop.
|
|
68
|
+
|
|
69
|
+
Excuse
|
|
70
|
+
|
|
71
|
+
If anyone notifies you in writing that you have not complied with
|
|
72
|
+
No Compensation, you can keep your license by taking all practical
|
|
73
|
+
steps to comply within 32 days after the notice. Otherwise, your
|
|
74
|
+
license ends immediately.
|
|
75
|
+
|
|
76
|
+
Patent Defense
|
|
77
|
+
|
|
78
|
+
If you make any written claim that the software infringes or
|
|
79
|
+
contributes to infringement of any patent, your patent license
|
|
80
|
+
for the software ends immediately. If your company makes such a
|
|
81
|
+
claim, your patent license ends immediately for work on behalf of
|
|
82
|
+
your company.
|
|
83
|
+
|
|
84
|
+
Violations
|
|
85
|
+
|
|
86
|
+
If you violate these terms, your licenses end immediately.
|
|
87
|
+
|
|
88
|
+
No Liability
|
|
89
|
+
|
|
90
|
+
As far as the law allows, the software comes as is, without any
|
|
91
|
+
warranty or condition, and the licensor will not be liable to you
|
|
92
|
+
for any damages arising out of these terms or the use or nature of
|
|
93
|
+
the software, under any kind of legal claim.
|
|
94
|
+
License-File: LICENSE
|
|
95
|
+
Classifier: Development Status :: 3 - Alpha
|
|
96
|
+
Classifier: Intended Audience :: Developers
|
|
97
|
+
Classifier: Programming Language :: Python :: 3
|
|
98
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
99
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
100
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
101
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
102
|
+
Requires-Python: >=3.10
|
|
103
|
+
Requires-Dist: httpx>=0.27
|
|
104
|
+
Requires-Dist: python-dotenv>=1.0
|
|
105
|
+
Description-Content-Type: text/markdown
|
|
106
|
+
|
|
107
|
+
<h1 align="center">asymmetric-py</h1>
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
## Install
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install asymmetric-py
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Or install from source:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/asymmetric-dev/asymmetric-py.git
|
|
120
|
+
cd asymmetric-py
|
|
121
|
+
pip install .
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Publishing
|
|
125
|
+
|
|
126
|
+
See [PUBLISH.md](PUBLISH.md).
|
|
127
|
+
|
|
128
|
+
## Usage
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from asymmetric import Asymmetric, GuardrailViolation
|
|
132
|
+
|
|
133
|
+
client = Asymmetric(api_key="sk_live_...")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Guardrails ([example](examples/guardrails.py))
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
try:
|
|
140
|
+
response = client.chat.completions.create(
|
|
141
|
+
model="openai/gpt-4o-mini",
|
|
142
|
+
messages=[{"role": "user", "content": "Tell me about Caltech"}],
|
|
143
|
+
guardrail_policy="Flag any content mentioning Caltech",
|
|
144
|
+
)
|
|
145
|
+
print(response.choices[0].message.content)
|
|
146
|
+
except GuardrailViolation as e:
|
|
147
|
+
print(e.policy)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Streaming ([example](examples/streaming.py))
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
try:
|
|
154
|
+
for chunk in client.chat.completions.create(
|
|
155
|
+
model="openai/gpt-4o-mini",
|
|
156
|
+
messages=[{"role": "user", "content": "Tell me about Caltech"}],
|
|
157
|
+
guardrail_policy="Flag any content mentioning Caltech",
|
|
158
|
+
stream=True,
|
|
159
|
+
):
|
|
160
|
+
if chunk.choices[0].delta.content:
|
|
161
|
+
print(chunk.choices[0].delta.content, end="")
|
|
162
|
+
except GuardrailViolation as e:
|
|
163
|
+
print(f"\nViolation: {e.policy}")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Multiple policies ([example](examples/multiple_policies.py))
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
response = client.chat.completions.create(
|
|
170
|
+
model="openai/gpt-4o-mini",
|
|
171
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
172
|
+
guardrail_policy=[
|
|
173
|
+
"Flag any content promoting violence",
|
|
174
|
+
"Flag any content containing profanity",
|
|
175
|
+
],
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Violation history ([example](examples/violations.py))
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
violations = client.guardrails.list_violations()
|
|
183
|
+
for v in violations:
|
|
184
|
+
print(v.timestamp, v.guardrail_policy)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Finetuning ([example](examples/finetuning.py))
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
# Trigger training
|
|
191
|
+
job = client.finetuning.train(
|
|
192
|
+
memory_group="darwin_agent",
|
|
193
|
+
lora_name="darwin_adapter",
|
|
194
|
+
)
|
|
195
|
+
print(job.status, job.message)
|
|
196
|
+
|
|
197
|
+
# Check status
|
|
198
|
+
status = client.finetuning.status(
|
|
199
|
+
memory_group="darwin_agent",
|
|
200
|
+
lora_name="darwin_adapter",
|
|
201
|
+
)
|
|
202
|
+
print(f"Ready: {status.lora_ready}")
|
|
203
|
+
|
|
204
|
+
# List all adapters
|
|
205
|
+
adapters = client.finetuning.list()
|
|
206
|
+
for a in adapters:
|
|
207
|
+
print(f"{a.lora_name}: {a.status}")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### LoRA inference ([example](examples/finetuning.py))
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
response = client.chat.completions.create(
|
|
214
|
+
model="asymmetric/Qwen3-8B",
|
|
215
|
+
messages=[{"role": "user", "content": "Tell me about Darwin's voyages"}],
|
|
216
|
+
finetuning={"lora_name": "darwin_adapter"},
|
|
217
|
+
)
|
|
218
|
+
print(response.choices[0].message.content)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Auto-training with memory ([example](examples/finetuning_with_memory.py))
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
response = client.chat.completions.create(
|
|
225
|
+
model="openai/gpt-4o-mini",
|
|
226
|
+
messages=[{"role": "user", "content": "What did Darwin discover?"}],
|
|
227
|
+
memory=[{"group": "darwin_agent", "goal": "Historical records about Darwin"}],
|
|
228
|
+
finetuning={
|
|
229
|
+
"lora_name": "darwin_adapter",
|
|
230
|
+
"finetune_thresh": 3,
|
|
231
|
+
"min_finetune_group": 5,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Publishing
|
|
2
|
+
|
|
3
|
+
Build the package locally before uploading:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv build --no-sources
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This writes a wheel and source distribution to `dist/`. Check those files before publishing.
|
|
10
|
+
|
|
11
|
+
## TestPyPI
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
export UV_PUBLISH_TOKEN=pypi-...
|
|
15
|
+
uv publish \
|
|
16
|
+
--publish-url https://test.pypi.org/legacy/ \
|
|
17
|
+
--check-url https://test.pypi.org/simple/
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## PyPI
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export UV_PUBLISH_TOKEN=pypi-...
|
|
24
|
+
uv publish
|
|
25
|
+
```
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<h1 align="center">asymmetric-py</h1>
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## Install
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install asymmetric-py
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Or install from source:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
git clone https://github.com/asymmetric-dev/asymmetric-py.git
|
|
14
|
+
cd asymmetric-py
|
|
15
|
+
pip install .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Publishing
|
|
19
|
+
|
|
20
|
+
See [PUBLISH.md](PUBLISH.md).
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from asymmetric import Asymmetric, GuardrailViolation
|
|
26
|
+
|
|
27
|
+
client = Asymmetric(api_key="sk_live_...")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Guardrails ([example](examples/guardrails.py))
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
try:
|
|
34
|
+
response = client.chat.completions.create(
|
|
35
|
+
model="openai/gpt-4o-mini",
|
|
36
|
+
messages=[{"role": "user", "content": "Tell me about Caltech"}],
|
|
37
|
+
guardrail_policy="Flag any content mentioning Caltech",
|
|
38
|
+
)
|
|
39
|
+
print(response.choices[0].message.content)
|
|
40
|
+
except GuardrailViolation as e:
|
|
41
|
+
print(e.policy)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Streaming ([example](examples/streaming.py))
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
try:
|
|
48
|
+
for chunk in client.chat.completions.create(
|
|
49
|
+
model="openai/gpt-4o-mini",
|
|
50
|
+
messages=[{"role": "user", "content": "Tell me about Caltech"}],
|
|
51
|
+
guardrail_policy="Flag any content mentioning Caltech",
|
|
52
|
+
stream=True,
|
|
53
|
+
):
|
|
54
|
+
if chunk.choices[0].delta.content:
|
|
55
|
+
print(chunk.choices[0].delta.content, end="")
|
|
56
|
+
except GuardrailViolation as e:
|
|
57
|
+
print(f"\nViolation: {e.policy}")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Multiple policies ([example](examples/multiple_policies.py))
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
response = client.chat.completions.create(
|
|
64
|
+
model="openai/gpt-4o-mini",
|
|
65
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
66
|
+
guardrail_policy=[
|
|
67
|
+
"Flag any content promoting violence",
|
|
68
|
+
"Flag any content containing profanity",
|
|
69
|
+
],
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Violation history ([example](examples/violations.py))
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
violations = client.guardrails.list_violations()
|
|
77
|
+
for v in violations:
|
|
78
|
+
print(v.timestamp, v.guardrail_policy)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Finetuning ([example](examples/finetuning.py))
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# Trigger training
|
|
85
|
+
job = client.finetuning.train(
|
|
86
|
+
memory_group="darwin_agent",
|
|
87
|
+
lora_name="darwin_adapter",
|
|
88
|
+
)
|
|
89
|
+
print(job.status, job.message)
|
|
90
|
+
|
|
91
|
+
# Check status
|
|
92
|
+
status = client.finetuning.status(
|
|
93
|
+
memory_group="darwin_agent",
|
|
94
|
+
lora_name="darwin_adapter",
|
|
95
|
+
)
|
|
96
|
+
print(f"Ready: {status.lora_ready}")
|
|
97
|
+
|
|
98
|
+
# List all adapters
|
|
99
|
+
adapters = client.finetuning.list()
|
|
100
|
+
for a in adapters:
|
|
101
|
+
print(f"{a.lora_name}: {a.status}")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### LoRA inference ([example](examples/finetuning.py))
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
response = client.chat.completions.create(
|
|
108
|
+
model="asymmetric/Qwen3-8B",
|
|
109
|
+
messages=[{"role": "user", "content": "Tell me about Darwin's voyages"}],
|
|
110
|
+
finetuning={"lora_name": "darwin_adapter"},
|
|
111
|
+
)
|
|
112
|
+
print(response.choices[0].message.content)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Auto-training with memory ([example](examples/finetuning_with_memory.py))
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
response = client.chat.completions.create(
|
|
119
|
+
model="openai/gpt-4o-mini",
|
|
120
|
+
messages=[{"role": "user", "content": "What did Darwin discover?"}],
|
|
121
|
+
memory=[{"group": "darwin_agent", "goal": "Historical records about Darwin"}],
|
|
122
|
+
finetuning={
|
|
123
|
+
"lora_name": "darwin_adapter",
|
|
124
|
+
"finetune_thresh": 3,
|
|
125
|
+
"min_finetune_group": 5,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from .client import Asymmetric
|
|
2
|
+
from .types import (
|
|
3
|
+
ChatCompletion,
|
|
4
|
+
ChatCompletionChunk,
|
|
5
|
+
Choice,
|
|
6
|
+
Delta,
|
|
7
|
+
GuardrailViolation,
|
|
8
|
+
LoraAdapter,
|
|
9
|
+
Message,
|
|
10
|
+
StreamChoice,
|
|
11
|
+
TrainingJob,
|
|
12
|
+
TrainingStatus,
|
|
13
|
+
Usage,
|
|
14
|
+
Violation,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Asymmetric",
|
|
19
|
+
"ChatCompletion",
|
|
20
|
+
"ChatCompletionChunk",
|
|
21
|
+
"Choice",
|
|
22
|
+
"Delta",
|
|
23
|
+
"GuardrailViolation",
|
|
24
|
+
"LoraAdapter",
|
|
25
|
+
"Message",
|
|
26
|
+
"StreamChoice",
|
|
27
|
+
"TrainingJob",
|
|
28
|
+
"TrainingStatus",
|
|
29
|
+
"Usage",
|
|
30
|
+
"Violation",
|
|
31
|
+
]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Iterator, Literal, overload
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .types import (
|
|
9
|
+
ChatCompletion,
|
|
10
|
+
ChatCompletionChunk,
|
|
11
|
+
Choice,
|
|
12
|
+
Delta,
|
|
13
|
+
GuardrailViolation,
|
|
14
|
+
LoraAdapter,
|
|
15
|
+
Message,
|
|
16
|
+
StreamChoice,
|
|
17
|
+
TrainingJob,
|
|
18
|
+
TrainingStatus,
|
|
19
|
+
Usage,
|
|
20
|
+
Violation,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_DEFAULT_BASE_URL = "https://rkdune--symmetry.modal.run"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Asymmetric:
|
|
27
|
+
"""Asymmetric API client with guardrail support."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
api_key: str,
|
|
32
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
33
|
+
timeout: float = 120.0,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._http = httpx.Client(
|
|
36
|
+
base_url=base_url.rstrip("/"),
|
|
37
|
+
headers={
|
|
38
|
+
"Authorization": f"Bearer {api_key}",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
},
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
)
|
|
43
|
+
self.chat = _Chat(self)
|
|
44
|
+
self.guardrails = _Guardrails(self)
|
|
45
|
+
self.finetuning = _Finetuning(self)
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
self._http.close()
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> Asymmetric:
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *args: Any) -> None:
|
|
54
|
+
self.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Chat:
|
|
58
|
+
def __init__(self, client: Asymmetric) -> None:
|
|
59
|
+
self.completions = _Completions(client)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Completions:
|
|
63
|
+
def __init__(self, client: Asymmetric) -> None:
|
|
64
|
+
self._client = client
|
|
65
|
+
|
|
66
|
+
@overload
|
|
67
|
+
def create(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
model: str,
|
|
71
|
+
messages: list[dict[str, str]],
|
|
72
|
+
stream: Literal[False] = ...,
|
|
73
|
+
guardrail_policy: str | list[str] | None = ...,
|
|
74
|
+
chunk_size: int = ...,
|
|
75
|
+
sliding_window: int = ...,
|
|
76
|
+
finetuning: dict[str, Any] | None = ...,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> ChatCompletion: ...
|
|
79
|
+
|
|
80
|
+
@overload
|
|
81
|
+
def create(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
model: str,
|
|
85
|
+
messages: list[dict[str, str]],
|
|
86
|
+
stream: Literal[True],
|
|
87
|
+
guardrail_policy: str | list[str] | None = ...,
|
|
88
|
+
chunk_size: int = ...,
|
|
89
|
+
sliding_window: int = ...,
|
|
90
|
+
finetuning: dict[str, Any] | None = ...,
|
|
91
|
+
**kwargs: Any,
|
|
92
|
+
) -> Iterator[ChatCompletionChunk]: ...
|
|
93
|
+
|
|
94
|
+
def create(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
model: str,
|
|
98
|
+
messages: list[dict[str, str]],
|
|
99
|
+
stream: bool = False,
|
|
100
|
+
guardrail_policy: str | list[str] | None = None,
|
|
101
|
+
chunk_size: int = 10,
|
|
102
|
+
sliding_window: int = 5,
|
|
103
|
+
finetuning: dict[str, Any] | None = None,
|
|
104
|
+
**kwargs: Any,
|
|
105
|
+
) -> ChatCompletion | Iterator[ChatCompletionChunk]:
|
|
106
|
+
body: dict[str, Any] = {
|
|
107
|
+
"model": model,
|
|
108
|
+
"messages": messages,
|
|
109
|
+
"stream": stream,
|
|
110
|
+
**kwargs,
|
|
111
|
+
}
|
|
112
|
+
if guardrail_policy is not None:
|
|
113
|
+
body["guardrail_policy"] = guardrail_policy
|
|
114
|
+
body["chunk_size"] = chunk_size
|
|
115
|
+
body["sliding_window"] = sliding_window
|
|
116
|
+
if finetuning is not None:
|
|
117
|
+
body.update(finetuning)
|
|
118
|
+
|
|
119
|
+
if stream:
|
|
120
|
+
return self._stream(body)
|
|
121
|
+
return self._complete(body)
|
|
122
|
+
|
|
123
|
+
def _complete(self, body: dict[str, Any]) -> ChatCompletion:
|
|
124
|
+
resp = self._client._http.post("/v1/chat/completions", json=body)
|
|
125
|
+
if resp.status_code == 400:
|
|
126
|
+
detail = resp.json().get("detail", "")
|
|
127
|
+
if "Guardrail violation" in detail:
|
|
128
|
+
raise _parse_violation(detail)
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
return _build_completion(resp.json())
|
|
131
|
+
|
|
132
|
+
def _stream(
|
|
133
|
+
self, body: dict[str, Any]
|
|
134
|
+
) -> Iterator[ChatCompletionChunk]:
|
|
135
|
+
with self._client._http.stream(
|
|
136
|
+
"POST", "/v1/chat/completions", json=body
|
|
137
|
+
) as resp:
|
|
138
|
+
resp.raise_for_status()
|
|
139
|
+
for line in resp.iter_lines():
|
|
140
|
+
if not line.startswith("data: "):
|
|
141
|
+
continue
|
|
142
|
+
payload = line[6:]
|
|
143
|
+
if payload == "[DONE]":
|
|
144
|
+
return
|
|
145
|
+
data = json.loads(payload)
|
|
146
|
+
if "error" in data:
|
|
147
|
+
msg = data["error"]
|
|
148
|
+
if "Guardrail violation" in msg:
|
|
149
|
+
raise _parse_violation(msg)
|
|
150
|
+
raise RuntimeError(msg)
|
|
151
|
+
yield _build_chunk(data)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _Guardrails:
|
|
155
|
+
def __init__(self, client: Asymmetric) -> None:
|
|
156
|
+
self._client = client
|
|
157
|
+
|
|
158
|
+
def list_violations(self) -> list[Violation]:
|
|
159
|
+
resp = self._client._http.get("/guardrail/violations")
|
|
160
|
+
resp.raise_for_status()
|
|
161
|
+
return [
|
|
162
|
+
Violation(
|
|
163
|
+
id=v["id"],
|
|
164
|
+
timestamp=v["timestamp"],
|
|
165
|
+
user_input=v.get("user_input"),
|
|
166
|
+
model_output=v["model_output"],
|
|
167
|
+
guardrail_policy=v["guardrail_policy"],
|
|
168
|
+
)
|
|
169
|
+
for v in resp.json().get("violations", [])
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _Finetuning:
|
|
174
|
+
def __init__(self, client: Asymmetric) -> None:
|
|
175
|
+
self._client = client
|
|
176
|
+
|
|
177
|
+
def train(self, memory_group: str, lora_name: str) -> TrainingJob:
|
|
178
|
+
resp = self._client._http.post(
|
|
179
|
+
"/finetuning/train",
|
|
180
|
+
json={"memory_group": memory_group, "lora_name": lora_name},
|
|
181
|
+
)
|
|
182
|
+
resp.raise_for_status()
|
|
183
|
+
data = resp.json()
|
|
184
|
+
return TrainingJob(
|
|
185
|
+
status=data["status"],
|
|
186
|
+
message=data["message"],
|
|
187
|
+
lora_name=data.get("lora_name"),
|
|
188
|
+
version=data.get("version"),
|
|
189
|
+
memories_count=data.get("memories_count"),
|
|
190
|
+
job_id=data.get("job_id"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def status(self, memory_group: str, lora_name: str) -> TrainingStatus:
|
|
194
|
+
resp = self._client._http.get(
|
|
195
|
+
"/finetuning/status",
|
|
196
|
+
params={"memory_group": memory_group, "lora_name": lora_name},
|
|
197
|
+
)
|
|
198
|
+
resp.raise_for_status()
|
|
199
|
+
data = resp.json()
|
|
200
|
+
return TrainingStatus(
|
|
201
|
+
memory_group=data["memory_group"],
|
|
202
|
+
lora_name=data["lora_name"],
|
|
203
|
+
queued_count=data["queued_count"],
|
|
204
|
+
trained_count=data["trained_count"],
|
|
205
|
+
training_status=data["training_status"],
|
|
206
|
+
current_version=data["current_version"],
|
|
207
|
+
last_training_started=data.get("last_training_started"),
|
|
208
|
+
last_training_completed=data.get("last_training_completed"),
|
|
209
|
+
last_error=data.get("last_error"),
|
|
210
|
+
lora_status=data.get("lora_status"),
|
|
211
|
+
lora_ready=data["lora_ready"],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def list(self) -> list[LoraAdapter]:
|
|
215
|
+
resp = self._client._http.get("/finetuning/loras")
|
|
216
|
+
resp.raise_for_status()
|
|
217
|
+
return [
|
|
218
|
+
LoraAdapter(
|
|
219
|
+
lora_name=a["lora_name"],
|
|
220
|
+
memory_group=a["memory_group"],
|
|
221
|
+
status=a["status"],
|
|
222
|
+
active_version=a["active_version"],
|
|
223
|
+
created_at=a["created_at"],
|
|
224
|
+
updated_at=a["updated_at"],
|
|
225
|
+
)
|
|
226
|
+
for a in resp.json().get("loras", [])
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --- Helpers ---
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_violation(detail: str) -> GuardrailViolation:
|
|
234
|
+
"""Parse violation detail string from the API."""
|
|
235
|
+
policy = ""
|
|
236
|
+
text = ""
|
|
237
|
+
try:
|
|
238
|
+
if "Policy: '" in detail:
|
|
239
|
+
after = detail.split("Policy: '", 1)[1]
|
|
240
|
+
policy = after.split("'. ", 1)[0]
|
|
241
|
+
for marker in ("Text in violation: '", "Text: '"):
|
|
242
|
+
if marker in detail:
|
|
243
|
+
raw = detail.split(marker, 1)[1]
|
|
244
|
+
text = raw[:-1] if raw.endswith("'") else raw
|
|
245
|
+
break
|
|
246
|
+
except (IndexError, ValueError):
|
|
247
|
+
pass
|
|
248
|
+
return GuardrailViolation(policy=policy, text=text)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _build_completion(data: dict[str, Any]) -> ChatCompletion:
|
|
252
|
+
choices = [
|
|
253
|
+
Choice(
|
|
254
|
+
index=c.get("index", i),
|
|
255
|
+
message=Message(
|
|
256
|
+
role=c["message"]["role"],
|
|
257
|
+
content=c["message"].get("content"),
|
|
258
|
+
),
|
|
259
|
+
finish_reason=c.get("finish_reason"),
|
|
260
|
+
)
|
|
261
|
+
for i, c in enumerate(data["choices"])
|
|
262
|
+
]
|
|
263
|
+
usage = None
|
|
264
|
+
if u := data.get("usage"):
|
|
265
|
+
usage = Usage(
|
|
266
|
+
prompt_tokens=u.get("prompt_tokens", 0),
|
|
267
|
+
completion_tokens=u.get("completion_tokens", 0),
|
|
268
|
+
total_tokens=u.get("total_tokens", 0),
|
|
269
|
+
)
|
|
270
|
+
return ChatCompletion(
|
|
271
|
+
id=data["id"], model=data["model"], choices=choices, usage=usage
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_chunk(data: dict[str, Any]) -> ChatCompletionChunk:
|
|
276
|
+
choices = [
|
|
277
|
+
StreamChoice(
|
|
278
|
+
index=c.get("index", i),
|
|
279
|
+
delta=Delta(
|
|
280
|
+
role=c.get("delta", {}).get("role"),
|
|
281
|
+
content=c.get("delta", {}).get("content"),
|
|
282
|
+
),
|
|
283
|
+
finish_reason=c.get("finish_reason"),
|
|
284
|
+
)
|
|
285
|
+
for i, c in enumerate(data.get("choices", []))
|
|
286
|
+
]
|
|
287
|
+
return ChatCompletionChunk(
|
|
288
|
+
id=data.get("id", ""),
|
|
289
|
+
model=data.get("model", ""),
|
|
290
|
+
choices=choices,
|
|
291
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GuardrailViolation(Exception):
|
|
5
|
+
"""Raised when a guardrail policy violation is detected."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, policy: str, text: str) -> None:
|
|
8
|
+
self.policy = policy
|
|
9
|
+
self.text = text
|
|
10
|
+
super().__init__(f"Guardrail violation detected. Policy: '{policy}'")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Message:
|
|
15
|
+
role: str
|
|
16
|
+
content: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Delta:
|
|
21
|
+
role: str | None = None
|
|
22
|
+
content: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Choice:
|
|
27
|
+
index: int
|
|
28
|
+
message: Message
|
|
29
|
+
finish_reason: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class StreamChoice:
|
|
34
|
+
index: int
|
|
35
|
+
delta: Delta
|
|
36
|
+
finish_reason: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Usage:
|
|
41
|
+
prompt_tokens: int
|
|
42
|
+
completion_tokens: int
|
|
43
|
+
total_tokens: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ChatCompletion:
|
|
48
|
+
id: str
|
|
49
|
+
model: str
|
|
50
|
+
choices: list[Choice]
|
|
51
|
+
usage: Usage | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ChatCompletionChunk:
|
|
56
|
+
id: str
|
|
57
|
+
model: str
|
|
58
|
+
choices: list[StreamChoice]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Violation:
|
|
63
|
+
id: str
|
|
64
|
+
timestamp: str
|
|
65
|
+
user_input: str | None
|
|
66
|
+
model_output: str
|
|
67
|
+
guardrail_policy: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class TrainingJob:
|
|
72
|
+
status: str
|
|
73
|
+
message: str
|
|
74
|
+
lora_name: str | None = None
|
|
75
|
+
version: int | None = None
|
|
76
|
+
memories_count: int | None = None
|
|
77
|
+
job_id: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class TrainingStatus:
|
|
82
|
+
memory_group: str
|
|
83
|
+
lora_name: str
|
|
84
|
+
queued_count: int
|
|
85
|
+
trained_count: int
|
|
86
|
+
training_status: str
|
|
87
|
+
current_version: int
|
|
88
|
+
last_training_started: int | None
|
|
89
|
+
last_training_completed: int | None
|
|
90
|
+
last_error: str | None
|
|
91
|
+
lora_status: str | None
|
|
92
|
+
lora_ready: bool
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class LoraAdapter:
|
|
97
|
+
lora_name: str
|
|
98
|
+
memory_group: str
|
|
99
|
+
status: str
|
|
100
|
+
active_version: int
|
|
101
|
+
created_at: int
|
|
102
|
+
updated_at: int
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "asymmetric-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Lightweight Python SDK for the Asymmetric API"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = ["httpx>=0.27", "python-dotenv>=1.0"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [{ name = "Asymmetric" }]
|
|
9
|
+
license = { file = "LICENSE" }
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/asymmetric-dev/asymmetric-py"
|
|
22
|
+
Repository = "https://github.com/asymmetric-dev/asymmetric-py"
|
|
23
|
+
Issues = "https://github.com/asymmetric-dev/asymmetric-py/issues"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=8.0"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = [
|
|
34
|
+
"/asymmetric",
|
|
35
|
+
"/tests",
|
|
36
|
+
"/README.md",
|
|
37
|
+
"/PUBLISH.md",
|
|
38
|
+
"/pyproject.toml",
|
|
39
|
+
"/LICENSE",
|
|
40
|
+
]
|
|
41
|
+
exclude = ["/.gitignore"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["asymmetric"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from asymmetric import Asymmetric, GuardrailViolation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_client(handler):
|
|
10
|
+
client = Asymmetric(api_key="test-key", base_url="https://api.example.com/")
|
|
11
|
+
client._http.close()
|
|
12
|
+
client._http = httpx.Client(
|
|
13
|
+
transport=httpx.MockTransport(handler),
|
|
14
|
+
base_url="https://api.example.com",
|
|
15
|
+
headers={
|
|
16
|
+
"Authorization": "Bearer test-key",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
return client
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_chat_completion_builds_expected_request():
|
|
24
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
25
|
+
assert request.method == "POST"
|
|
26
|
+
assert str(request.url) == "https://api.example.com/v1/chat/completions"
|
|
27
|
+
assert request.headers["Authorization"] == "Bearer test-key"
|
|
28
|
+
|
|
29
|
+
body = json.loads(request.content)
|
|
30
|
+
assert body == {
|
|
31
|
+
"model": "openai/gpt-4o-mini",
|
|
32
|
+
"messages": [{"role": "user", "content": "Hello"}],
|
|
33
|
+
"stream": False,
|
|
34
|
+
"temperature": 0.2,
|
|
35
|
+
"guardrail_policy": "No secrets",
|
|
36
|
+
"chunk_size": 12,
|
|
37
|
+
"sliding_window": 3,
|
|
38
|
+
"lora_name": "adapter-v1",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return httpx.Response(
|
|
42
|
+
200,
|
|
43
|
+
json={
|
|
44
|
+
"id": "chatcmpl_123",
|
|
45
|
+
"model": "openai/gpt-4o-mini",
|
|
46
|
+
"choices": [
|
|
47
|
+
{
|
|
48
|
+
"message": {"role": "assistant", "content": "Hi"},
|
|
49
|
+
"finish_reason": "stop",
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"usage": {
|
|
53
|
+
"prompt_tokens": 1,
|
|
54
|
+
"completion_tokens": 1,
|
|
55
|
+
"total_tokens": 2,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
client = make_client(handler)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
completion = client.chat.completions.create(
|
|
64
|
+
model="openai/gpt-4o-mini",
|
|
65
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
66
|
+
guardrail_policy="No secrets",
|
|
67
|
+
chunk_size=12,
|
|
68
|
+
sliding_window=3,
|
|
69
|
+
finetuning={"lora_name": "adapter-v1"},
|
|
70
|
+
temperature=0.2,
|
|
71
|
+
)
|
|
72
|
+
finally:
|
|
73
|
+
client.close()
|
|
74
|
+
|
|
75
|
+
assert completion.choices[0].message.content == "Hi"
|
|
76
|
+
assert completion.usage is not None
|
|
77
|
+
assert completion.usage.total_tokens == 2
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_chat_completion_raises_guardrail_violation():
|
|
81
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
82
|
+
return httpx.Response(
|
|
83
|
+
400,
|
|
84
|
+
json={
|
|
85
|
+
"detail": "Guardrail violation detected. Policy: 'No secrets'. "
|
|
86
|
+
"Text in violation: 'secret token'"
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
client = make_client(handler)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
with pytest.raises(GuardrailViolation) as exc_info:
|
|
94
|
+
client.chat.completions.create(
|
|
95
|
+
model="openai/gpt-4o-mini",
|
|
96
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
97
|
+
)
|
|
98
|
+
finally:
|
|
99
|
+
client.close()
|
|
100
|
+
|
|
101
|
+
assert exc_info.value.policy == "No secrets"
|
|
102
|
+
assert exc_info.value.text == "secret token"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_streaming_chat_completion_yields_chunks():
|
|
106
|
+
payload = (
|
|
107
|
+
'data: {"id":"chatcmpl_123","model":"openai/gpt-4o-mini",'
|
|
108
|
+
'"choices":[{"delta":{"role":"assistant","content":"Hi"}}]}\n\n'
|
|
109
|
+
'data: {"id":"chatcmpl_123","model":"openai/gpt-4o-mini",'
|
|
110
|
+
'"choices":[{"delta":{"content":" there"},"finish_reason":"stop"}]}\n\n'
|
|
111
|
+
"data: [DONE]\n\n"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
115
|
+
return httpx.Response(200, text=payload)
|
|
116
|
+
|
|
117
|
+
client = make_client(handler)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
chunks = list(
|
|
121
|
+
client.chat.completions.create(
|
|
122
|
+
model="openai/gpt-4o-mini",
|
|
123
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
124
|
+
stream=True,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
finally:
|
|
128
|
+
client.close()
|
|
129
|
+
|
|
130
|
+
assert [chunk.choices[0].delta.content for chunk in chunks] == ["Hi", " there"]
|
|
131
|
+
assert chunks[0].choices[0].delta.role == "assistant"
|
|
132
|
+
assert chunks[-1].choices[0].finish_reason == "stop"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_list_violations_parses_response():
|
|
136
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
137
|
+
assert request.method == "GET"
|
|
138
|
+
assert str(request.url) == "https://api.example.com/guardrail/violations"
|
|
139
|
+
return httpx.Response(
|
|
140
|
+
200,
|
|
141
|
+
json={
|
|
142
|
+
"violations": [
|
|
143
|
+
{
|
|
144
|
+
"id": "vio_123",
|
|
145
|
+
"timestamp": "2026-04-04T12:00:00Z",
|
|
146
|
+
"user_input": "hello",
|
|
147
|
+
"model_output": "blocked",
|
|
148
|
+
"guardrail_policy": "No secrets",
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
client = make_client(handler)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
violations = client.guardrails.list_violations()
|
|
158
|
+
finally:
|
|
159
|
+
client.close()
|
|
160
|
+
|
|
161
|
+
assert len(violations) == 1
|
|
162
|
+
assert violations[0].guardrail_policy == "No secrets"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_finetuning_status_parses_response():
|
|
166
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
167
|
+
assert request.method == "GET"
|
|
168
|
+
assert str(request.url) == (
|
|
169
|
+
"https://api.example.com/finetuning/status"
|
|
170
|
+
"?memory_group=darwin&lora_name=adapter-v1"
|
|
171
|
+
)
|
|
172
|
+
return httpx.Response(
|
|
173
|
+
200,
|
|
174
|
+
json={
|
|
175
|
+
"memory_group": "darwin",
|
|
176
|
+
"lora_name": "adapter-v1",
|
|
177
|
+
"queued_count": 1,
|
|
178
|
+
"trained_count": 4,
|
|
179
|
+
"training_status": "ready",
|
|
180
|
+
"current_version": 2,
|
|
181
|
+
"last_training_started": 10,
|
|
182
|
+
"last_training_completed": 20,
|
|
183
|
+
"last_error": None,
|
|
184
|
+
"lora_status": "ready",
|
|
185
|
+
"lora_ready": True,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
client = make_client(handler)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
status = client.finetuning.status("darwin", "adapter-v1")
|
|
193
|
+
finally:
|
|
194
|
+
client.close()
|
|
195
|
+
|
|
196
|
+
assert status.current_version == 2
|
|
197
|
+
assert status.lora_ready is True
|