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.
@@ -0,0 +1,7 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ dist/*
7
+ !dist/.gitignore
@@ -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