synthiaresearch 0.0.1__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.
- synthiaresearch-0.0.1/.gitignore +11 -0
- synthiaresearch-0.0.1/PKG-INFO +6 -0
- synthiaresearch-0.0.1/README.md +33 -0
- synthiaresearch-0.0.1/pyproject.toml +15 -0
- synthiaresearch-0.0.1/src/synthia/__init__.py +17 -0
- synthiaresearch-0.0.1/src/synthia/client.py +637 -0
- synthiaresearch-0.0.1/uv.lock +104 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# synthia
|
|
2
|
+
|
|
3
|
+
Python SDK for the Synthia API.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from synthia import Synthia
|
|
7
|
+
|
|
8
|
+
client = Synthia(api_key="sk_...")
|
|
9
|
+
|
|
10
|
+
# 1. Probe your agent to infer an ideal user model.
|
|
11
|
+
# The agent runs locally; any `(probe: str) -> str` callable works.
|
|
12
|
+
user_model = client.user_models.create_from_probe(my_agent, max_turns=10)
|
|
13
|
+
|
|
14
|
+
# 2. Generate synthetic data from it.
|
|
15
|
+
job = client.datasets.generate(user_model, count=1000)
|
|
16
|
+
dataset = job.wait()
|
|
17
|
+
|
|
18
|
+
# 3. Play the scenarios against your agent, then quality-check the rollouts:
|
|
19
|
+
# a server-side parallel job analyzes each rollout's agentic states and
|
|
20
|
+
# judges whether the agent passed each scenario.
|
|
21
|
+
results = client.rollouts.run(my_rollout_agent, dataset)
|
|
22
|
+
evaluations = client.rollouts.quality_check(results).wait().rollouts()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Layout
|
|
26
|
+
|
|
27
|
+
- `src/synthia/client.py` — the whole SDK: the `Synthia` client, resource classes (seeds, user models, datasets), and the local probe loop with tool-call tracing.
|
|
28
|
+
- `src/synthia/__init__.py` — public exports.
|
|
29
|
+
- `pyproject.toml` — the `synthia` package definition (httpx as the sole runtime dependency).
|
|
30
|
+
- `uv.lock` — uv lockfile.
|
|
31
|
+
|
|
32
|
+
For an end-to-end demo against a real tool-using agent, see
|
|
33
|
+
`examples/turo-support-agent/` at the repo root.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "synthiaresearch"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Python SDK for the Synthia API"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"httpx>=0.27",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/synthia"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
AgentReply,
|
|
3
|
+
Dataset,
|
|
4
|
+
GenerationJob,
|
|
5
|
+
PrepareResult,
|
|
6
|
+
QualityCheck,
|
|
7
|
+
RolloutResult,
|
|
8
|
+
Synthia,
|
|
9
|
+
ToolCall,
|
|
10
|
+
ToolSandbox,
|
|
11
|
+
UserModel,
|
|
12
|
+
ValidationRun,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = ["Synthia", "UserModel", "GenerationJob", "Dataset", "AgentReply",
|
|
16
|
+
"ToolCall", "ValidationRun", "QualityCheck", "RolloutResult",
|
|
17
|
+
"ToolSandbox", "PrepareResult"]
|
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://synthia-research--synthia-api-web.modal.run"
|
|
16
|
+
|
|
17
|
+
# Launcher argv[0] values that don't identify a script (notebooks, REPLs).
|
|
18
|
+
_INTERACTIVE_STEMS = {"", "-c", "-m", "ipykernel_launcher", "pydevconsole"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_session_name() -> str:
|
|
22
|
+
"""Stable session name derived from the entry-point script.
|
|
23
|
+
|
|
24
|
+
Last two path components only ("project/script"): readable, survives
|
|
25
|
+
moving the project, and never leaks the full directory layout. Falls
|
|
26
|
+
back to the working directory's name for notebooks and REPLs.
|
|
27
|
+
"""
|
|
28
|
+
argv0 = sys.argv[0] if sys.argv else ""
|
|
29
|
+
if not argv0 or Path(argv0).stem in _INTERACTIVE_STEMS:
|
|
30
|
+
return Path.cwd().resolve().name
|
|
31
|
+
p = Path(argv0).resolve()
|
|
32
|
+
return f"{p.parent.name}/{p.stem}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sdk_version() -> str | None:
|
|
36
|
+
try:
|
|
37
|
+
return metadata.version("synthia")
|
|
38
|
+
except metadata.PackageNotFoundError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolCall:
|
|
44
|
+
"""One traced tool invocation made by the agent while answering a probe."""
|
|
45
|
+
name: str
|
|
46
|
+
input: dict
|
|
47
|
+
output: dict | None = None
|
|
48
|
+
is_error: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AgentReply:
|
|
53
|
+
"""An agent's reply plus the tool calls it made producing it."""
|
|
54
|
+
reply: str
|
|
55
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# An agent takes a probe question and returns its reply — a plain string, or
|
|
59
|
+
# an AgentReply carrying traced tool calls.
|
|
60
|
+
Agent = Callable[[str], Union[str, AgentReply]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ToolSandbox:
|
|
64
|
+
"""Local replica of the server's deterministic tool sandbox.
|
|
65
|
+
|
|
66
|
+
Must stay hash-identical to synthia_api.pipeline.rollout.ToolSandbox:
|
|
67
|
+
outputs are a pure function of (tool name, input, state version, seed),
|
|
68
|
+
which is how the server reproduces the same tool behavior from the
|
|
69
|
+
events the SDK reports. Tool inputs must be JSON-native values that
|
|
70
|
+
round-trip exactly (e.g. tuples arrive as lists, 1 and 1.0 differ).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, seed: int, fail_tools: set[str] | None = None,
|
|
74
|
+
state: dict | None = None):
|
|
75
|
+
self.seed = seed
|
|
76
|
+
self.fail_tools = fail_tools or set()
|
|
77
|
+
self.state = dict(state or {})
|
|
78
|
+
self.events: list[dict] = []
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_config(cls, config: dict) -> "ToolSandbox":
|
|
82
|
+
return cls(seed=config["seed"], fail_tools=set(config["fail_tools"]),
|
|
83
|
+
state=config["state"])
|
|
84
|
+
|
|
85
|
+
def call(self, name: str, tool_input: dict) -> dict:
|
|
86
|
+
is_error = name in self.fail_tools
|
|
87
|
+
if is_error:
|
|
88
|
+
output = {"error": f"{name} is unavailable"}
|
|
89
|
+
else:
|
|
90
|
+
digest = hashlib.sha256(
|
|
91
|
+
json.dumps([name, tool_input, self.state.get("version", 0),
|
|
92
|
+
self.seed], sort_keys=True).encode()
|
|
93
|
+
).hexdigest()[:8]
|
|
94
|
+
output = {"ok": True, "result_id": digest}
|
|
95
|
+
self.state["version"] = self.state.get("version", 0) + 1
|
|
96
|
+
self.state[f"last_{name}"] = digest
|
|
97
|
+
self.events.append({"name": name, "input": tool_input,
|
|
98
|
+
"output": output, "is_error": is_error})
|
|
99
|
+
return output
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# A rollout agent takes the conversation transcript ([{role, content}]) and a
|
|
103
|
+
# ToolSandbox for its tool calls, and returns its reply text. It is invoked
|
|
104
|
+
# from worker threads when rollouts run concurrently, so it must be
|
|
105
|
+
# thread-safe.
|
|
106
|
+
RolloutAgent = Callable[[list, ToolSandbox], str]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _EventStream:
|
|
110
|
+
"""Cursor-based poller that prints a run's server-side telemetry."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, http: httpx.Client, path: str, enabled: bool):
|
|
113
|
+
self._http = http
|
|
114
|
+
self._path = path
|
|
115
|
+
self._after = 0
|
|
116
|
+
self._enabled = enabled
|
|
117
|
+
|
|
118
|
+
def pump(self) -> None:
|
|
119
|
+
"""Fetch and print events newer than the cursor."""
|
|
120
|
+
if not self._enabled:
|
|
121
|
+
return
|
|
122
|
+
r = self._http.get(self._path, params={"after": self._after})
|
|
123
|
+
r.raise_for_status()
|
|
124
|
+
for event in r.json()["data"]:
|
|
125
|
+
self._after = event["seq"]
|
|
126
|
+
detail = " ".join(f"{k}={v}" for k, v in event["data"].items())
|
|
127
|
+
print(f" ~ [{event['stage']}] {event['message']}"
|
|
128
|
+
+ (f" ({detail})" if detail else ""))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class UserModel:
|
|
133
|
+
id: str
|
|
134
|
+
probe_session_id: str
|
|
135
|
+
persona: str
|
|
136
|
+
traits: list[str]
|
|
137
|
+
representation_id: str | None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ValidationRun:
|
|
142
|
+
"""A validation of a dataset: a per-scenario judge gate plus
|
|
143
|
+
collective fidelity/diversity reports and an advisory verdict."""
|
|
144
|
+
id: str
|
|
145
|
+
dataset_id: str
|
|
146
|
+
status: str
|
|
147
|
+
verdict: str | None
|
|
148
|
+
reference: dict | None
|
|
149
|
+
validity: dict | None
|
|
150
|
+
fidelity: dict | None
|
|
151
|
+
diversity: dict | None
|
|
152
|
+
error: str | None
|
|
153
|
+
_http: httpx.Client
|
|
154
|
+
|
|
155
|
+
def wait(self, poll_interval: float = 2.0, timeout: float = 1800.0,
|
|
156
|
+
verbose: bool = False) -> "ValidationRun":
|
|
157
|
+
"""Poll until validation finishes; verbose prints server telemetry."""
|
|
158
|
+
events = _EventStream(
|
|
159
|
+
self._http, f"/v1/validation-runs/{self.id}/events", verbose)
|
|
160
|
+
deadline = time.monotonic() + timeout
|
|
161
|
+
while self.status == "running":
|
|
162
|
+
if time.monotonic() > deadline:
|
|
163
|
+
raise TimeoutError(
|
|
164
|
+
f"validation run {self.id} still running after {timeout}s")
|
|
165
|
+
time.sleep(poll_interval)
|
|
166
|
+
r = self._http.get(f"/v1/validation-runs/{self.id}")
|
|
167
|
+
r.raise_for_status()
|
|
168
|
+
data = r.json()
|
|
169
|
+
for key in ("status", "verdict", "reference", "validity",
|
|
170
|
+
"fidelity", "diversity", "error"):
|
|
171
|
+
setattr(self, key, data[key])
|
|
172
|
+
events.pump()
|
|
173
|
+
events.pump() # catch events written after the final status poll
|
|
174
|
+
if self.status != "succeeded":
|
|
175
|
+
raise RuntimeError(f"validation run {self.id} failed: {self.error}")
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
def scenarios(self) -> list[dict]:
|
|
179
|
+
"""Per-scenario judge verdicts: scenario_id, passed, judge."""
|
|
180
|
+
r = self._http.get(f"/v1/validation-runs/{self.id}/scenarios")
|
|
181
|
+
r.raise_for_status()
|
|
182
|
+
return r.json()["data"]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass
|
|
186
|
+
class Dataset:
|
|
187
|
+
id: str
|
|
188
|
+
generation_id: str
|
|
189
|
+
user_model_id: str
|
|
190
|
+
row_count: int
|
|
191
|
+
_http: httpx.Client
|
|
192
|
+
|
|
193
|
+
def download(self) -> list[dict]:
|
|
194
|
+
r = self._http.get(f"/v1/datasets/{self.id}/rows")
|
|
195
|
+
r.raise_for_status()
|
|
196
|
+
return r.json()["data"]
|
|
197
|
+
|
|
198
|
+
def validate(self) -> ValidationRun:
|
|
199
|
+
"""Start an async validation run over this dataset."""
|
|
200
|
+
r = self._http.post(f"/v1/datasets/{self.id}/validations")
|
|
201
|
+
r.raise_for_status()
|
|
202
|
+
return ValidationRun(**r.json(), _http=self._http)
|
|
203
|
+
|
|
204
|
+
def rollout(self, agent: "RolloutAgent", **kwargs) -> "list[RolloutResult]":
|
|
205
|
+
"""Play this dataset's scenarios against a local agent."""
|
|
206
|
+
return Rollouts(self._http).run(agent, dataset=self, **kwargs)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class GenerationJob:
|
|
211
|
+
id: str
|
|
212
|
+
status: str
|
|
213
|
+
user_model_id: str
|
|
214
|
+
count: int
|
|
215
|
+
dataset_id: str | None
|
|
216
|
+
error: str | None
|
|
217
|
+
_http: httpx.Client
|
|
218
|
+
|
|
219
|
+
def wait(self, poll_interval: float = 2.0, timeout: float = 1800.0,
|
|
220
|
+
verbose: bool = False) -> Dataset:
|
|
221
|
+
"""Poll until the job finishes; verbose prints server telemetry live."""
|
|
222
|
+
events = _EventStream(
|
|
223
|
+
self._http, f"/v1/generations/{self.id}/events", verbose)
|
|
224
|
+
deadline = time.monotonic() + timeout
|
|
225
|
+
while self.status == "running":
|
|
226
|
+
if time.monotonic() > deadline:
|
|
227
|
+
raise TimeoutError(f"generation {self.id} still running after {timeout}s")
|
|
228
|
+
time.sleep(poll_interval)
|
|
229
|
+
r = self._http.get(f"/v1/generations/{self.id}")
|
|
230
|
+
r.raise_for_status()
|
|
231
|
+
data = r.json()
|
|
232
|
+
self.status = data["status"]
|
|
233
|
+
self.dataset_id = data["dataset_id"]
|
|
234
|
+
self.error = data["error"]
|
|
235
|
+
events.pump()
|
|
236
|
+
events.pump() # catch events written after the final status poll
|
|
237
|
+
if self.status != "succeeded":
|
|
238
|
+
raise RuntimeError(f"generation {self.id} failed: {self.error}")
|
|
239
|
+
r = self._http.get(f"/v1/datasets/{self.dataset_id}")
|
|
240
|
+
r.raise_for_status()
|
|
241
|
+
return Dataset(**r.json(), _http=self._http)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Seeds:
|
|
245
|
+
def __init__(self, http: httpx.Client):
|
|
246
|
+
self._http = http
|
|
247
|
+
|
|
248
|
+
def ingest(self, *, kind: str, source: str, content: dict,
|
|
249
|
+
version: str = "1", metadata: dict | None = None) -> dict:
|
|
250
|
+
"""Upload seed material (documents, tool schemas, policies, traces...)."""
|
|
251
|
+
r = self._http.post("/v1/seeds", json={
|
|
252
|
+
"kind": kind, "source": source, "content": content,
|
|
253
|
+
"version": version, "metadata": metadata or {},
|
|
254
|
+
})
|
|
255
|
+
r.raise_for_status()
|
|
256
|
+
return r.json()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class UserModels:
|
|
260
|
+
def __init__(self, http: httpx.Client):
|
|
261
|
+
self._http = http
|
|
262
|
+
|
|
263
|
+
def create_from_probe(self, agent: Agent, max_turns: int = 10,
|
|
264
|
+
verbose: bool = False) -> UserModel:
|
|
265
|
+
"""Probe `agent` until the server converges on a user model.
|
|
266
|
+
|
|
267
|
+
The agent runs locally; only probe questions, replies, and traced
|
|
268
|
+
tool calls travel over the wire. `verbose` prints the server's
|
|
269
|
+
telemetry (probe decisions, ingestion, inference) after each turn.
|
|
270
|
+
"""
|
|
271
|
+
r = self._http.post("/v1/probe-sessions", json={"max_turns": max_turns})
|
|
272
|
+
r.raise_for_status()
|
|
273
|
+
session = r.json()
|
|
274
|
+
events = _EventStream(
|
|
275
|
+
self._http, f"/v1/probe-sessions/{session['id']}/events", verbose)
|
|
276
|
+
while session["status"] == "active":
|
|
277
|
+
raw = agent(session["next_probe"])
|
|
278
|
+
reply = raw if isinstance(raw, str) else raw.reply
|
|
279
|
+
tool_calls = [] if isinstance(raw, str) else [
|
|
280
|
+
asdict(tc) for tc in raw.tool_calls
|
|
281
|
+
]
|
|
282
|
+
r = self._http.post(
|
|
283
|
+
f"/v1/probe-sessions/{session['id']}/responses",
|
|
284
|
+
json={"reply": reply, "tool_calls": tool_calls},
|
|
285
|
+
)
|
|
286
|
+
r.raise_for_status()
|
|
287
|
+
session = r.json()
|
|
288
|
+
events.pump()
|
|
289
|
+
return self.get(session["user_model_id"])
|
|
290
|
+
|
|
291
|
+
def get(self, model_id: str) -> UserModel:
|
|
292
|
+
r = self._http.get(f"/v1/user-models/{model_id}")
|
|
293
|
+
r.raise_for_status()
|
|
294
|
+
return UserModel(**r.json())
|
|
295
|
+
|
|
296
|
+
def list(self, session: str | None = None) -> list[UserModel]:
|
|
297
|
+
params = {"sdk_session": session} if session else {}
|
|
298
|
+
r = self._http.get("/v1/user-models", params=params)
|
|
299
|
+
r.raise_for_status()
|
|
300
|
+
return [UserModel(**m) for m in r.json()["data"]]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class Datasets:
|
|
304
|
+
def __init__(self, http: httpx.Client):
|
|
305
|
+
self._http = http
|
|
306
|
+
|
|
307
|
+
def get(self, dataset_id: str) -> Dataset:
|
|
308
|
+
r = self._http.get(f"/v1/datasets/{dataset_id}")
|
|
309
|
+
r.raise_for_status()
|
|
310
|
+
return Dataset(**r.json(), _http=self._http)
|
|
311
|
+
|
|
312
|
+
def list(self, session: str | None = None) -> list[Dataset]:
|
|
313
|
+
"""Datasets newest first; `session` filters to one SDK session."""
|
|
314
|
+
params = {"sdk_session": session} if session else {}
|
|
315
|
+
r = self._http.get("/v1/datasets", params=params)
|
|
316
|
+
r.raise_for_status()
|
|
317
|
+
return [Dataset(**d, _http=self._http) for d in r.json()["data"]]
|
|
318
|
+
|
|
319
|
+
def generate(self, user_model: UserModel | str, count: int = 20, *,
|
|
320
|
+
quality_check_id: str | None = None) -> GenerationJob:
|
|
321
|
+
"""Start a generation job. quality_check_id names a completed quality
|
|
322
|
+
check whose results calibrate the batch's difficulty and coverage."""
|
|
323
|
+
model_id = user_model.id if isinstance(user_model, UserModel) else user_model
|
|
324
|
+
body = {"user_model_id": model_id, "count": count}
|
|
325
|
+
if quality_check_id:
|
|
326
|
+
body["quality_check_id"] = quality_check_id
|
|
327
|
+
r = self._http.post("/v1/generations", json=body)
|
|
328
|
+
r.raise_for_status()
|
|
329
|
+
return GenerationJob(**r.json(), _http=self._http)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass
|
|
333
|
+
class PrepareResult:
|
|
334
|
+
"""Outcome of Synthia.prepare(): the dataset to roll out, the user model
|
|
335
|
+
behind it, and how the decision was made."""
|
|
336
|
+
dataset: Dataset
|
|
337
|
+
user_model: UserModel
|
|
338
|
+
action: str # "generated" | "reused"
|
|
339
|
+
reason: str # human-readable decision trail
|
|
340
|
+
success_rate: float | None # latest completed check's rate, if any
|
|
341
|
+
quality_check_id: str | None # check that calibrated generation, if any
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class QualityCheck:
|
|
346
|
+
"""An async evaluation of finished rollouts: per rollout, the server
|
|
347
|
+
analyzes the agent's state trajectory and judges pass/fail. The
|
|
348
|
+
per-rollout results are the product; there is no aggregate verdict."""
|
|
349
|
+
id: str
|
|
350
|
+
status: str
|
|
351
|
+
rollout_ids: list[str]
|
|
352
|
+
error: str | None
|
|
353
|
+
_http: httpx.Client
|
|
354
|
+
|
|
355
|
+
def wait(self, poll_interval: float = 2.0, timeout: float = 1800.0,
|
|
356
|
+
verbose: bool = False) -> "QualityCheck":
|
|
357
|
+
"""Poll until the check finishes; verbose prints server telemetry."""
|
|
358
|
+
events = _EventStream(
|
|
359
|
+
self._http, f"/v1/quality-checks/{self.id}/events", verbose)
|
|
360
|
+
deadline = time.monotonic() + timeout
|
|
361
|
+
while self.status == "running":
|
|
362
|
+
if time.monotonic() > deadline:
|
|
363
|
+
raise TimeoutError(
|
|
364
|
+
f"quality check {self.id} still running after {timeout}s")
|
|
365
|
+
time.sleep(poll_interval)
|
|
366
|
+
r = self._http.get(f"/v1/quality-checks/{self.id}")
|
|
367
|
+
r.raise_for_status()
|
|
368
|
+
data = r.json()
|
|
369
|
+
self.status = data["status"]
|
|
370
|
+
self.error = data["error"]
|
|
371
|
+
events.pump()
|
|
372
|
+
events.pump() # catch events written after the final status poll
|
|
373
|
+
if self.status != "succeeded":
|
|
374
|
+
raise RuntimeError(f"quality check {self.id} failed: {self.error}")
|
|
375
|
+
return self
|
|
376
|
+
|
|
377
|
+
def rollouts(self) -> list[dict]:
|
|
378
|
+
"""Per-rollout results: rollout_id, passed, states (the agentic-state
|
|
379
|
+
trajectory), and judge (dimensions + issues)."""
|
|
380
|
+
r = self._http.get(f"/v1/quality-checks/{self.id}/rollouts")
|
|
381
|
+
r.raise_for_status()
|
|
382
|
+
return r.json()["data"]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass
|
|
386
|
+
class RolloutResult:
|
|
387
|
+
"""One finished rollout: the conversation a scenario produced, plus every
|
|
388
|
+
tool call the agent made along the way (each tagged with its turn_idx)."""
|
|
389
|
+
rollout_id: str
|
|
390
|
+
scenario_id: str
|
|
391
|
+
status: str
|
|
392
|
+
turns: int
|
|
393
|
+
transcript: list[dict]
|
|
394
|
+
tool_events: list[dict]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class Rollouts:
|
|
398
|
+
def __init__(self, http: httpx.Client, session_id: str | None = None):
|
|
399
|
+
self._http = http
|
|
400
|
+
self._session_id = session_id
|
|
401
|
+
|
|
402
|
+
def get(self, rollout_id: str) -> dict:
|
|
403
|
+
"""A stored rollout's full captured state: status, seed, transcript,
|
|
404
|
+
tool events, and sandbox."""
|
|
405
|
+
r = self._http.get(f"/v1/rollouts/{rollout_id}")
|
|
406
|
+
r.raise_for_status()
|
|
407
|
+
return r.json()
|
|
408
|
+
|
|
409
|
+
def run(self, agent: RolloutAgent, dataset: Union[Dataset, str, None] = None,
|
|
410
|
+
*, max_turns: int = 12, concurrency: int = 4) -> list[RolloutResult]:
|
|
411
|
+
"""Play a dataset's scenarios against `agent` (most recent dataset
|
|
412
|
+
when none is given).
|
|
413
|
+
|
|
414
|
+
The agent runs locally: each turn it gets the transcript so far and
|
|
415
|
+
a deterministic ToolSandbox for its tool calls; only its reply and
|
|
416
|
+
tool events travel over the wire. Scenarios run on `concurrency`
|
|
417
|
+
worker threads (turns within one conversation are sequential), so
|
|
418
|
+
the agent must be thread-safe — pass concurrency=1 to opt out.
|
|
419
|
+
"""
|
|
420
|
+
if dataset is None:
|
|
421
|
+
# Session-scoped default: this script's latest dataset, so two
|
|
422
|
+
# concurrent scripts never pick up each other's data.
|
|
423
|
+
data = []
|
|
424
|
+
if self._session_id:
|
|
425
|
+
r = self._http.get("/v1/datasets",
|
|
426
|
+
params={"sdk_session": self._session_id})
|
|
427
|
+
r.raise_for_status()
|
|
428
|
+
data = r.json()["data"]
|
|
429
|
+
if not data:
|
|
430
|
+
r = self._http.get("/v1/datasets")
|
|
431
|
+
r.raise_for_status()
|
|
432
|
+
data = r.json()["data"]
|
|
433
|
+
if data and self._session_id:
|
|
434
|
+
print(f"note: no dataset in this session yet; "
|
|
435
|
+
f"using latest dataset {data[0]['id']}")
|
|
436
|
+
if not data:
|
|
437
|
+
raise RuntimeError("no datasets exist yet; generate one first")
|
|
438
|
+
dataset_id = data[0]["id"]
|
|
439
|
+
else:
|
|
440
|
+
dataset_id = dataset.id if isinstance(dataset, Dataset) else dataset
|
|
441
|
+
r = self._http.get(f"/v1/datasets/{dataset_id}/rows")
|
|
442
|
+
r.raise_for_status()
|
|
443
|
+
rows = r.json()["data"]
|
|
444
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
445
|
+
return list(pool.map(
|
|
446
|
+
lambda row: self.run_scenario(
|
|
447
|
+
agent, row["scenario_id"], max_turns=max_turns),
|
|
448
|
+
rows))
|
|
449
|
+
|
|
450
|
+
def quality_check(
|
|
451
|
+
self, rollouts: "list[Union[RolloutResult, str]]") -> QualityCheck:
|
|
452
|
+
"""Start an async quality check over finished rollouts: the server
|
|
453
|
+
analyzes each rollout's agentic states in parallel and judges
|
|
454
|
+
whether the agent passed each scenario."""
|
|
455
|
+
rollout_ids = [r.rollout_id if isinstance(r, RolloutResult) else r
|
|
456
|
+
for r in rollouts]
|
|
457
|
+
r = self._http.post("/v1/quality-checks",
|
|
458
|
+
json={"rollout_ids": rollout_ids})
|
|
459
|
+
r.raise_for_status()
|
|
460
|
+
return QualityCheck(**r.json(), _http=self._http)
|
|
461
|
+
|
|
462
|
+
def run_scenario(self, agent: RolloutAgent, scenario_id: str, *,
|
|
463
|
+
max_turns: int = 12,
|
|
464
|
+
random_seed: int | None = None) -> RolloutResult:
|
|
465
|
+
"""Run one rollout session; one HTTP round-trip per agent turn."""
|
|
466
|
+
r = self._http.post("/v1/rollouts", json={
|
|
467
|
+
"scenario_id": scenario_id, "random_seed": random_seed,
|
|
468
|
+
"max_turns": max_turns,
|
|
469
|
+
})
|
|
470
|
+
r.raise_for_status()
|
|
471
|
+
session = r.json()
|
|
472
|
+
while session["status"] == "running":
|
|
473
|
+
sandbox = ToolSandbox.from_config(session["sandbox"])
|
|
474
|
+
reply = agent(session["transcript"], sandbox)
|
|
475
|
+
r = self._http.post(
|
|
476
|
+
f"/v1/rollouts/{session['id']}/turns",
|
|
477
|
+
json={"reply": reply, "tool_calls": sandbox.events},
|
|
478
|
+
)
|
|
479
|
+
r.raise_for_status()
|
|
480
|
+
session = r.json()
|
|
481
|
+
return RolloutResult(
|
|
482
|
+
rollout_id=session["id"], scenario_id=scenario_id,
|
|
483
|
+
status=session["status"], turns=session["turn"],
|
|
484
|
+
transcript=session["transcript"],
|
|
485
|
+
tool_events=session["tool_events"])
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class Synthia:
|
|
489
|
+
"""Client entry point.
|
|
490
|
+
|
|
491
|
+
Session identity: every client belongs to a named session — the stable,
|
|
492
|
+
account-scoped identity of one script, persisted across executions
|
|
493
|
+
(same name resumes the same session; re-runs reuse its datasets instead
|
|
494
|
+
of re-probing/re-generating). Resolution order: `session` arg >
|
|
495
|
+
SYNTHIA_SESSION env var > derived "project/script" name from the entry
|
|
496
|
+
point. `session=False` opts out into a fresh ephemeral session.
|
|
497
|
+
|
|
498
|
+
Degradation: an old server without /v1/sdk-sessions -> no session
|
|
499
|
+
(today's behavior); keyless against a keyed server -> anonymous session;
|
|
500
|
+
an invalid api_key fails immediately with the server's message.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None,
|
|
504
|
+
session: Union[str, bool, None] = None):
|
|
505
|
+
api_key = api_key or os.environ.get("SYNTHIA_API_KEY")
|
|
506
|
+
base_url = base_url or os.environ.get("SYNTHIA_BASE_URL", DEFAULT_BASE_URL)
|
|
507
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
508
|
+
# Generous timeout: probe convergence runs model inference server-side.
|
|
509
|
+
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=300.0)
|
|
510
|
+
if session is False:
|
|
511
|
+
self.session_name = (f"{_default_session_name()}"
|
|
512
|
+
f"/eph-{uuid.uuid4().hex[:8]}")
|
|
513
|
+
elif isinstance(session, str) and session:
|
|
514
|
+
self.session_name = session
|
|
515
|
+
else:
|
|
516
|
+
self.session_name = (os.environ.get("SYNTHIA_SESSION")
|
|
517
|
+
or _default_session_name())
|
|
518
|
+
self.session_id: str | None = None
|
|
519
|
+
self.invocation_id: str | None = None
|
|
520
|
+
self._start_session()
|
|
521
|
+
self.seeds = Seeds(self._http)
|
|
522
|
+
self.user_models = UserModels(self._http)
|
|
523
|
+
self.datasets = Datasets(self._http)
|
|
524
|
+
self.rollouts = Rollouts(self._http, session_id=self.session_id)
|
|
525
|
+
|
|
526
|
+
def _start_session(self) -> None:
|
|
527
|
+
"""One handshake per process: get-or-create the named session and
|
|
528
|
+
mint this invocation; all later requests carry both ids as headers."""
|
|
529
|
+
try:
|
|
530
|
+
r = self._http.post("/v1/sdk-sessions", json={
|
|
531
|
+
"name": self.session_name, "sdk_version": _sdk_version(),
|
|
532
|
+
})
|
|
533
|
+
except httpx.HTTPError:
|
|
534
|
+
return # transient network trouble: run without session tracking
|
|
535
|
+
if r.status_code == 404:
|
|
536
|
+
return # server predates sessions: degrade to old behavior
|
|
537
|
+
if r.status_code == 401:
|
|
538
|
+
raise RuntimeError(r.json().get("detail", "invalid API key"))
|
|
539
|
+
r.raise_for_status()
|
|
540
|
+
data = r.json()
|
|
541
|
+
self.session_id = data["sdk_session_id"]
|
|
542
|
+
self.invocation_id = data["sdk_invocation_id"]
|
|
543
|
+
self._http.headers["X-Synthia-Session"] = self.session_id
|
|
544
|
+
self._http.headers["X-Synthia-Invocation"] = self.invocation_id
|
|
545
|
+
|
|
546
|
+
def prepare(self, agent: Agent, *, count: int = 20, max_turns: int = 10,
|
|
547
|
+
min_success_rate: float = 0.6, max_success_rate: float = 0.9,
|
|
548
|
+
verbose: bool = False) -> PrepareResult:
|
|
549
|
+
"""Probe + generate only when needed; otherwise reuse the latest dataset.
|
|
550
|
+
|
|
551
|
+
The main entry point for the probe and generation steps. `count` is
|
|
552
|
+
exact: the returned dataset has exactly that many rows, so reuse
|
|
553
|
+
requires the latest dataset to match it in addition to the quality
|
|
554
|
+
gate. Probing and generation run only when no dataset exists yet,
|
|
555
|
+
when the row count differs (generation-only — the session's probed
|
|
556
|
+
user model is reused), or when the latest completed quality check's
|
|
557
|
+
pass rate falls outside [min_success_rate, max_success_rate].
|
|
558
|
+
Out-of-band regeneration passes that quality check to the server,
|
|
559
|
+
which feeds its real results (pass rate, per-scenario outcomes,
|
|
560
|
+
judge issues) into scenario generation so the new batch recalibrates
|
|
561
|
+
difficulty and coverage.
|
|
562
|
+
|
|
563
|
+
All lookups are scoped to this client's session: re-running the
|
|
564
|
+
same script reuses its own dataset, and drift signals from other
|
|
565
|
+
scripts/sessions never trigger regeneration here.
|
|
566
|
+
"""
|
|
567
|
+
existing = (self.datasets.list(session=self.session_id)
|
|
568
|
+
if self.session_id else self.datasets.list()) # newest first
|
|
569
|
+
if not existing:
|
|
570
|
+
return self._probe_and_generate(
|
|
571
|
+
agent, count=count, max_turns=max_turns, verbose=verbose,
|
|
572
|
+
reason=("no datasets in this session yet" if self.session_id
|
|
573
|
+
else "no datasets exist yet"),
|
|
574
|
+
success_rate=None, quality_check_id=None)
|
|
575
|
+
|
|
576
|
+
r = self._http.get(
|
|
577
|
+
"/v1/quality-checks/latest",
|
|
578
|
+
params={"sdk_session": self.session_id} if self.session_id else {})
|
|
579
|
+
r.raise_for_status()
|
|
580
|
+
latest = r.json()
|
|
581
|
+
rate = (latest["passed"] / latest["total"]
|
|
582
|
+
if latest["id"] is not None and latest["total"] > 0 else None)
|
|
583
|
+
|
|
584
|
+
if rate is not None and not (min_success_rate <= rate <= max_success_rate):
|
|
585
|
+
direction = ("below" if rate < min_success_rate else "above")
|
|
586
|
+
bound = (min_success_rate if rate < min_success_rate
|
|
587
|
+
else max_success_rate)
|
|
588
|
+
return self._probe_and_generate(
|
|
589
|
+
agent, count=count, max_turns=max_turns, verbose=verbose,
|
|
590
|
+
reason=f"success rate {rate:.0%} {direction} {bound:.0%}; "
|
|
591
|
+
f"regenerating calibrated on {latest['id']}",
|
|
592
|
+
success_rate=rate, quality_check_id=latest["id"])
|
|
593
|
+
|
|
594
|
+
# Quality is in band (or unjudged): reuse only on an exact size
|
|
595
|
+
# match; otherwise regenerate at the requested count — without
|
|
596
|
+
# re-probing, since nothing suggests the agent changed.
|
|
597
|
+
if existing[0].row_count != count:
|
|
598
|
+
return self._probe_and_generate(
|
|
599
|
+
agent, count=count, max_turns=max_turns, verbose=verbose,
|
|
600
|
+
reason=f"latest dataset has {existing[0].row_count} rows; "
|
|
601
|
+
f"requested {count}",
|
|
602
|
+
success_rate=rate, quality_check_id=None)
|
|
603
|
+
|
|
604
|
+
return PrepareResult(
|
|
605
|
+
dataset=existing[0],
|
|
606
|
+
user_model=self.user_models.get(existing[0].user_model_id),
|
|
607
|
+
action="reused",
|
|
608
|
+
reason=(f"success rate {rate:.0%} within "
|
|
609
|
+
f"{min_success_rate:.0%}-{max_success_rate:.0%} band"
|
|
610
|
+
if rate is not None else
|
|
611
|
+
"no completed quality check to judge by; "
|
|
612
|
+
"reusing latest dataset"),
|
|
613
|
+
success_rate=rate, quality_check_id=None)
|
|
614
|
+
|
|
615
|
+
def _probe_and_generate(self, agent: Agent, *, count: int, max_turns: int,
|
|
616
|
+
verbose: bool, reason: str,
|
|
617
|
+
success_rate: float | None,
|
|
618
|
+
quality_check_id: str | None) -> PrepareResult:
|
|
619
|
+
# Without a drift signal the agent hasn't been shown to change, so a
|
|
620
|
+
# user model this session already probed is still good — skip the
|
|
621
|
+
# probe. Drift-triggered regeneration re-probes deliberately.
|
|
622
|
+
user_model = None
|
|
623
|
+
if self.session_id and not quality_check_id:
|
|
624
|
+
session_models = self.user_models.list(session=self.session_id)
|
|
625
|
+
if session_models:
|
|
626
|
+
user_model = session_models[-1] # newest (list is oldest-first)
|
|
627
|
+
reason += "; reusing session user model (no drift signal)"
|
|
628
|
+
if user_model is None:
|
|
629
|
+
user_model = self.user_models.create_from_probe(
|
|
630
|
+
agent, max_turns=max_turns, verbose=verbose)
|
|
631
|
+
job = self.datasets.generate(user_model, count=count,
|
|
632
|
+
quality_check_id=quality_check_id)
|
|
633
|
+
dataset = job.wait(verbose=verbose)
|
|
634
|
+
return PrepareResult(dataset=dataset, user_model=user_model,
|
|
635
|
+
action="generated", reason=reason,
|
|
636
|
+
success_rate=success_rate,
|
|
637
|
+
quality_check_id=quality_check_id)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.10"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "anyio"
|
|
7
|
+
version = "4.14.1"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
11
|
+
{ name = "idna" },
|
|
12
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
13
|
+
]
|
|
14
|
+
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
|
15
|
+
wheels = [
|
|
16
|
+
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[package]]
|
|
20
|
+
name = "certifi"
|
|
21
|
+
version = "2026.6.17"
|
|
22
|
+
source = { registry = "https://pypi.org/simple" }
|
|
23
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
|
24
|
+
wheels = [
|
|
25
|
+
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[package]]
|
|
29
|
+
name = "exceptiongroup"
|
|
30
|
+
version = "1.3.1"
|
|
31
|
+
source = { registry = "https://pypi.org/simple" }
|
|
32
|
+
dependencies = [
|
|
33
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
34
|
+
]
|
|
35
|
+
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
36
|
+
wheels = [
|
|
37
|
+
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[[package]]
|
|
41
|
+
name = "h11"
|
|
42
|
+
version = "0.16.0"
|
|
43
|
+
source = { registry = "https://pypi.org/simple" }
|
|
44
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
45
|
+
wheels = [
|
|
46
|
+
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[[package]]
|
|
50
|
+
name = "httpcore"
|
|
51
|
+
version = "1.0.9"
|
|
52
|
+
source = { registry = "https://pypi.org/simple" }
|
|
53
|
+
dependencies = [
|
|
54
|
+
{ name = "certifi" },
|
|
55
|
+
{ name = "h11" },
|
|
56
|
+
]
|
|
57
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
58
|
+
wheels = [
|
|
59
|
+
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[[package]]
|
|
63
|
+
name = "httpx"
|
|
64
|
+
version = "0.28.1"
|
|
65
|
+
source = { registry = "https://pypi.org/simple" }
|
|
66
|
+
dependencies = [
|
|
67
|
+
{ name = "anyio" },
|
|
68
|
+
{ name = "certifi" },
|
|
69
|
+
{ name = "httpcore" },
|
|
70
|
+
{ name = "idna" },
|
|
71
|
+
]
|
|
72
|
+
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
73
|
+
wheels = [
|
|
74
|
+
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[[package]]
|
|
78
|
+
name = "idna"
|
|
79
|
+
version = "3.18"
|
|
80
|
+
source = { registry = "https://pypi.org/simple" }
|
|
81
|
+
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
|
82
|
+
wheels = [
|
|
83
|
+
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[[package]]
|
|
87
|
+
name = "synthia"
|
|
88
|
+
version = "0.0.1"
|
|
89
|
+
source = { editable = "." }
|
|
90
|
+
dependencies = [
|
|
91
|
+
{ name = "httpx" },
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[package.metadata]
|
|
95
|
+
requires-dist = [{ name = "httpx", specifier = ">=0.27" }]
|
|
96
|
+
|
|
97
|
+
[[package]]
|
|
98
|
+
name = "typing-extensions"
|
|
99
|
+
version = "4.16.0"
|
|
100
|
+
source = { registry = "https://pypi.org/simple" }
|
|
101
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
|
102
|
+
wheels = [
|
|
103
|
+
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
|
104
|
+
]
|