Metadata-Version: 2.5
Name: autosynth-sdk
Version: 0.3.0
Summary: Typed Python client and CLI for the AutoSynth experiment-check API
Project-URL: Homepage, https://autosynth.bio
Project-URL: Source, https://github.com/arun-elr/warpsci/tree/main/sdk
Author: AutoSynth
License-Expression: MIT
License-File: LICENSE
Keywords: api client,autosynth,chemistry,cro,lab automation,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx2>=2.14
Requires-Dist: pydantic>=2.9
Description-Content-Type: text/markdown

# AutoSynth Python SDK

Typed Python client and CLI for the [AutoSynth](https://autosynth.bio) experiment-check API. The core flow:
a chemist registers an observation from the lab against an approved plan, submits a perception check, and gets
back the status and a structured result. The SDK also covers review, replay, lineage, datasets, and evaluation.

![autosynth CLI demo](docs/demo.gif)

- Sync and async clients (`AutoSynth`, `AsyncAutoSynth`) with pydantic models for every response.
- Retries with exponential backoff on 429/5xx and network errors, honoring `Retry-After`.
- Writes are only resent when that is safe: artifacts are content-addressed, and check submissions carry an
  auto-generated `Idempotency-Key`.
- `runs.wait()` polls with backoff. Failed runs come back as data with an error code; they don't raise.
- An `autosynth` command line, plus reproducible synthetic vial images so you can try everything without a camera.

## Install

```bash
pip install autosynth-sdk        # or: uv add autosynth-sdk
```

Requires Python 3.10+. The distribution is `autosynth-sdk`; the import name is `autosynth`.

## Quickstart

```python
from autosynth import AutoSynth, synthetic

with AutoSynth() as client:                      # AUTOSYNTH_API_KEY, AUTOSYNTH_BASE_URL from the environment
    plan = client.plans.list(status="approved")[0].plan_ref
    photo = synthetic.vial(plan, seed=7, scenario="positive").png   # or open("vial.png", "rb").read()
    run = client.checks.run(photo, plan)         # register -> submit -> wait
    print(run.status, run.outcome, run.result.confidence)           # succeeded positive 1.0
```

The same flow, one step at a time:

```python
artifact = client.artifacts.register("vial.png", "PLN-0001", metadata={"camera": "bay2-cam1"})
run = client.checks.submit("PLN-0001", artifact.artifact_id)        # 201, usually status "queued"
run = client.runs.wait(run, timeout=60)                             # polls until succeeded | failed

if run.succeeded:
    print(run.result.outcome, run.result.confidence, run.result.model, run.result.findings)
    print(run.provenance.input_sha256, run.provenance.config_hash, run.provenance.code.git_sha)
else:
    print(run.error.code, run.error.message)     # e.g. image_quality: observation too blurry to assess
```

## Authentication

Create an API key on the **Developers** page of the staff app at <https://autosynth.bio>, then export it:

```bash
export AUTOSYNTH_API_KEY=...                       # sent as  Authorization: Bearer <key>
```

You can also pass the key directly: `AutoSynth(api_key=...)`. Each key is bound to one project, and every id you
see (plans, artifacts, runs) is scoped to that project. Ids from other projects look the same as missing ones
and return `404`. Keys act in staff mode. The customer-portal endpoints (`/portal/*`) need a customer's browser
session, so the SDK doesn't wrap them.

## Hosted vs local

| | Base URL | Auth |
|---|---|---|
| Hosted (default) | `https://api.autosynth.bio` | API key required |
| Local `docker compose up` (monorepo) | `http://localhost:8000` | none in dev mode; a key from `WARP_API_KEYS` if set |

```bash
export AUTOSYNTH_BASE_URL=http://localhost:8000   # or AutoSynth(base_url="http://localhost:8000")
```

## Results and failures

A check ends with `status` set to `succeeded` or `failed`. A succeeded run has `result.outcome` of `positive`,
`negative` or `uncertain`, plus `confidence`, `findings` (for example `crystals` with `bright_pixels`, and
`sharpness`), the `model` name and version, `config_hash`, and `latency_ms`. Failed runs are persisted with
`error.code`:

| `error.code` | Meaning |
|---|---|
| `image_quality` | frame too blurry to assess (failed the sharpness gate) |
| `corrupt_input` | image can't be decoded (for example, a truncated upload) |
| `input_missing` / `input_integrity` | evidence is gone, or its bytes no longer match the sha256 |
| `timeout` | the model missed the 500 ms online deadline |
| `plan_not_approved` / `unknown_model` | the plan is draft or revoked, or the model name or version is unknown |
| `overloaded`, `inference_unavailable`, `model_error` | the inference service stayed unavailable after the server's retries |
| `storage_unavailable`, `infrastructure_error`, `orphaned`, `internal` | platform failures; no run is left `running` forever |

Registering by `uri=` (a file already in the deployment's store) copies the bytes into content-addressed storage
right away. If the source is missing, `artifacts.register` raises `InvalidRequestError` with code `input_missing`,
so the problem shows up before any check is submitted.

`run.outcome` is the **effective** outcome. If a reviewer annotated the run, it is the reviewer's label;
otherwise it is the model's. The model's own result stays in `run.result`.

## Errors, retries, timeouts

```python
from autosynth import AutoSynth, ConflictError, NotFoundError, RateLimitError, APIConnectionError

client = AutoSynth(timeout=30, max_retries=3)    # timeout: seconds or an httpx2.Timeout
```

| Exception | When |
|---|---|
| `AutoSynthError` | base class for everything below |
| `APIStatusError` (`.status`, `.code`, `.message`) | any 4xx/5xx; `.code` is the API's machine-readable code |
| `BadRequestError` 400, `AuthenticationError` 401, `PermissionDeniedError` 403, `NotFoundError` 404 | |
| `ConflictError` 409 | `idempotency_conflict`, `invalid_transition`, already released |
| `InvalidRequestError` 422 | validation failed (for example `invalid_metadata`) |
| `RateLimitError` 429, `InternalServerError` 5xx | raised after the automatic retries run out |
| `APIConnectionError`, `APITimeoutError` | no HTTP response (DNS, refused connection, TLS error, timeout) |
| `WaitTimeoutError` (also a `TimeoutError`) | `runs.wait()` gave up; `.run` holds the last state seen |

Retry behaviour:

- Reads, artifact registration, dataset snapshots, and submits/replays (which carry an `Idempotency-Key`) are
  retried on connection errors, timeouts, 408, 429 and 5xx.
- Append-only or once-only writes (annotations, releases, training runs, request transitions) are retried only
  when the server provably did not act: connection refused, 429, or 503.
- Backoff is exponential with full jitter (0.5 s, 1 s, 2 s, ... capped at 8 s). `Retry-After` and
  `Retry-After-Ms` take precedence, capped at 60 s.
- To make a submit safe across processes, pass your own `idempotency_key=`. The same key with the same body
  returns the original run; the same key with a different body raises `ConflictError`.

## Async

```python
import asyncio
from autosynth import AsyncAutoSynth, synthetic

async def main():
    async with AsyncAutoSynth() as client:
        frames = [synthetic.vial("PLN-0003", seed) for seed in range(32)]
        runs = await asyncio.gather(*(client.checks.run(f.png, "PLN-0003", priority="bulk") for f in frames))
        print(sum(r.outcome == "positive" for r in runs), "of", len(runs), "crystallized")

asyncio.run(main())
```

`AsyncAutoSynth` has the same resources and methods as `AutoSynth`; every call is awaited, and `runs.iter()` is
an async iterator.

## CLI

```text
$ autosynth synth vial.png --scenario positive          # write a synthetic observation (no API call)
$ autosynth check vial.png --plan PLN-0001 --wait
registered art_94e6989b2aff3242 (3249 bytes, sha256 a8fc26cb8bbf…)
submitted  run_8b3c181d9c973e9c on PLN-0001 (online, queued)
run_8b3c181d9c973e9c  succeeded  positive  conf 1.00  vialnet@1.3.0  72 ms
  plan PLN-0001 · artifact art_94e6989b2aff3242 · input sha256 a8fc26cb8bbf… · online
  findings: crystals 1.00 (bright_pixels 155) · sharpness 1.03
  events: queued → running → succeeded
$ autosynth runs list --status failed --limit 10
$ autosynth runs get run_8b3c181d9c973e9c
$ autosynth runs annotate run_8b3c181d9c973e9c uncertain --comment "glare on the vial wall"
$ autosynth runs replay run_8b3c181d9c973e9c --wait
$ autosynth lineage run_8b3c181d9c973e9c
run_8b3c181d9c973e9c run/perception · positive
├─ ← input_of art_94e6989b2aff3242 artifact/observation
├─ → produced art_c0bc71b9b9fa8344 artifact/result
└─ → replay_of run_babceea49588cae6 run/perception · positive
$ autosynth plans
$ autosynth health
```

Every command accepts `--json`, `--base-url` and `--api-key`, and reads the same environment variables as the
client. Exit codes:

- `0`: success.
- `1`: the check ran but failed (for example `image_quality`), or the wait timed out.
- `2`: an API, network, or usage error.

`python -m autosynth` works too.

## API reference

All methods exist on both `AutoSynth` and `AsyncAutoSynth`. Every method returns a pydantic model from
`autosynth.models`.

| Method | HTTP | Returns |
|---|---|---|
| `client.health()` | `GET /healthz` | `Health` |
| `plans.list(status=None)` | `GET /plans` | `list[Plan]` |
| `plans.approve(plan_ref)` | `POST /plans/{ref}/approve` | `dict` |
| `artifacts.register(source, plan_ref, kind="observation", metadata=None, *, uri=None, parent_artifact_id=None)` | `POST /artifacts` | `Artifact` |
| `artifacts.get(artifact_id)` | `GET /artifacts/{id}` | `Artifact` (with runs, children, metadata history) |
| `artifacts.list(kind=, plan_ref=, tag=, meta=, since=, limit=)` | `GET /artifacts` | `list[Artifact]` |
| `artifacts.content(artifact_id)` | `GET /artifacts/{id}/content` | `bytes` |
| `artifacts.presign(artifact_id)` | `GET /artifacts/{id}/content?presign=true` | `PresignedURL` |
| `artifacts.update_metadata(artifact_id, metadata, author=None)` | `PATCH /artifacts/{id}/metadata` | `Artifact` |
| `checks.submit(plan_ref, artifact_id, *, model=None, priority="online", idempotency_key=None)` | `POST /runs` | `Run` |
| `checks.run(source, plan_ref, *, metadata=, model=, priority=, wait=True, timeout=120)` | register + submit + wait | `Run` |
| `runs.get(run_id)` | `GET /runs/{id}` | `Run` |
| `runs.list(plan_ref=, status=, limit=, cursor=)` | `GET /runs` | `RunPage` |
| `runs.iter(plan_ref=, status=, page_size=100)` | `GET /runs` (all pages) | iterator of `Run` |
| `runs.wait(run, *, timeout=120, poll_interval=0.2, max_poll_interval=2.0)` | polls `GET /runs/{id}` | `Run` |
| `runs.annotate(run_id, label, *, comment="", reviewer=None, status="accepted")` | `POST /runs/{id}/annotations` | `AnnotationResult` |
| `runs.annotations(run_id)` | `GET /runs/{id}/annotations` | `RunAnnotations` |
| `runs.replay(run_id, *, model="pinned", priority="bulk", idempotency_key=None)` | `POST /runs/{id}/replay` | `Run` |
| `runs.release(run_id)` | `POST /runs/{id}/release` | `Release` |
| `runs.export(since=None)` | `GET /export/runs.jsonl` | `list[dict]` |
| `lineage.get(node_id, *, direction="both", depth=3)` | `GET /lineage/{id}` | `LineageGraph` |
| `datasets.create(name, *, plan_ref=, model=, status="succeeded", since=, until=, labeled_only=False)` | `POST /datasets` | `Artifact` |
| `datasets.manifest(artifact_id)` | `GET /artifacts/{id}/content` | `DatasetManifest` |
| `evaluation.get(*, model=None, since=None, reference="annotations")` | `GET /evaluation` | `list[ModelEvaluation]` |
| `stats.weekly(*, weeks=8, as_of=None)` | `GET /stats/weekly` | `list[WeeklyStats]` |
| `training_runs.list(kind=, limit=)` / `training_runs.create(kind, *, dataset_artifact_ids, config_hash, ...)` | `GET` / `POST /training/runs` | `TrainingRun` |
| `requests.list(status=)` / `requests.plan(id, title=)` / `requests.reject(id, reason)` | staff request workflow | `StaffRequest` |

Notes:

- `source` is a file path or `bytes`. Re-registering the same bytes returns the same artifact.
- Models keep fields they don't recognise (`extra="allow"`), so a newer server doesn't break an older SDK.

## Synthetic observations

`autosynth.synthetic.vial(plan_ref, seed, scenario)` returns a reproducible 64×64 PNG plus capture metadata. It
is byte-identical to the frames the platform's robot mock produces. The hosted mock model answers each scenario
as follows:

| Scenario | Status | Result |
|---|---|---|
| `positive` | succeeded | `positive` |
| `negative` | succeeded | `negative` |
| `uncertain` | succeeded | `uncertain` (faint evidence after a short settle) |
| `blurry` | failed | `image_quality` |
| `corrupt` | failed | `corrupt_input` (truncated upload) |
| `None` | varies | sampled from the policy |

`metadata["truth"]` holds the later lab-confirmed outcome, which `evaluation.get(reference="truth")` scores
against.

## Examples

- [`examples/quickstart.py`](examples/quickstart.py): the 10-line version.
- [`examples/chemist_demo.py`](examples/chemist_demo.py): every success, uncertain, negative, and failure case;
  idempotent resubmits; reviewer correction; replay; lineage; an async bulk batch; datasets and evaluation.
  Exits non-zero if anything deviates from what's expected.
- [`notebooks/chemist_workflow.ipynb`](notebooks/chemist_workflow.ipynb): the same workflow, executed against a
  local compose stack.

## Compatibility

`warp_sdk.WarpClient`, the monorepo's pre-0.1 flat client (`register_artifact`, `submit_check`, `get_run`,
`wait`, ...), ships in the same wheel as a thin deprecated wrapper over `AutoSynth` (available as `.client`).

## Development

```bash
cd sdk
uv venv && uv pip install -e . pytest anyio mypy
pytest                                              # unit tests (httpx2.MockTransport); integration tests skip
AUTOSYNTH_INTEGRATION_URL=http://localhost:8000 AUTOSYNTH_INTEGRATION_KEY=... pytest -m integration
mypy --strict autosynth
uv build && uvx twine check dist/*
```

Releases go out by pushing a tag `sdk-vX.Y.Z` (the version must match `pyproject.toml`). The
`sdk-publish` GitHub workflow builds, tests, and publishes to PyPI through trusted publishing.
The terminal demo is recorded with [`docs/demo.sh`](docs/demo.sh).

## License

MIT

## Training lifecycle and experiments (0.2.0)

Both sync and async clients support these helpers. Recording a training run does not launch a GPU job;
your training process creates the record, then updates it with measured metrics and a registered checkpoint.

```python
run = client.training_runs.create(
    "ppo", dataset_artifact_ids=[dataset_id], config_hash=config_hash,
    status="running", model_name="pour-policy",
)
run = client.training_runs.get(run.training_run_id)
client.training_runs.update(run.training_run_id, status="succeeded",
                            metrics={"eval_return": measured_return},
                            checkpoint_artifact_id=checkpoint_id)
# Available only when the run has readable recorded job logs:
print(client.training_runs.logs(run.training_run_id, tail=100))
for line in client.training_runs.stream_logs(run.training_run_id):
    print(line)
```

Log reads use the same project-scoped authentication. Missing/expired jobs return typed API errors.
Closing the stream stops following; streaming does not automatically reconnect or replay logs.

```python
sample = client.experiments.create(plan_ref, "SAMPLE-001", location="bay-2")
client.experiments.add_step(sample.experiment_id, "material_check", detail="Materials verified")
check = client.checks.run(image_bytes, plan_ref, experiment_id=sample.experiment_id)
sample = client.experiments.get(sample.experiment_id)
```

`experiments.list()` filters by status, finding or review state. Capture retries use
`checks.submit(..., experiment_id=..., retry_of=failed_run_id)`. Manual lab steps record events;
they do not command a physical robot. Review and customer release remain separate actions.
