Metadata-Version: 2.3
Name: onepot
Version: 0.2.1
Summary: Python client for the onepot API
Requires-Dist: httpx==0.28.1
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# onepot-python

Python client for the [onepot](https://onepot.ai) API — find purchasable analogs of your query molecules, price exact molecules directly, with optional retrosynthesis decomposition and per-position building-block filtering.

## Installation

```bash
uv add onepot
# or
pip install onepot
```

## Quick start

```python
from onepot import Client

client = Client(api_key="your-api-key")

resp = client.search(
    smiles_list=["c1ccc(NC(=O)c2ccccc2)cc1"],
    max_results=10,
)
for r in resp["queries"][0]["results"]:
    print(r["smiles"], r["similarity"], r["price_usd"])
```

## Features

- **Similarity search** — Tanimoto nearest analogs from the onepot catalog
- **Exact pricing** — price the exact query molecule directly, skipping the analog search; fast, cheap bulk pricing of pre-enumerated libraries
- **Substructure search** — purchasable molecules containing a SMILES/SMARTS pattern
- **Decomposition + BB filters** — inspect the retro paths the system considered for your query, then refine which candidate BBs are eligible per position
- **Risk and price filters** — exclude results above a chemistry-risk, supplier-risk, or price threshold
- **Streaming** — single-molecule searches with SSE progress updates
- **Ordering** — submit results for synthesis quoting
- **Screening-space sampling** — seeded random or scaffold-balanced draws from the CORE pool

## Search

### Basic

```python
resp = client.search(smiles_list=["c1ccc(-c2ccccc2)cc1"], max_results=10)
```

```bash
curl -X POST https://api.onepot.ai/v1/search \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{"smiles_list": ["c1ccc(-c2ccccc2)cc1"], "max_results": 10}'
```

### Exact lookup

Use `exact_lookup=True` to price each query molecule **directly** and skip the analog/similarity search. Each query returns at most one result — the query molecule itself (`similarity` 1.0), priced from a catalog match or its cheapest single-step decomposition — or no result if it can't be made from catalog building blocks. It never substitutes an analog. This is the fast, cheap path for bulk pricing of a pre-enumerated library, and bills at 0.1× (see [Pricing](#pricing)).

```python
resp = client.search(
    smiles_list=my_enumerated_library,   # e.g. thousands of SMILES
    exact_lookup=True,
    include_chemistry_risk=True,
)
for q in resp["queries"]:
    if q["results"]:
        print(q["query_smiles"], q["results"][0]["price_usd"])
    else:
        print(q["query_smiles"], "not priceable")
```

```bash
curl -X POST https://api.onepot.ai/v1/search \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{"smiles_list": ["c1ccc(NC(=O)c2ccccc2)cc1"], "exact_lookup": true}'
```

The response uses the standard shape, with `results` holding 0 or 1 entry per query. Exact-lookup results are not annotated with `reaction_class` / `bbs`. Cannot be combined with `substructure_search`, `decompose`, or `bb_filters` (rejected as 422). Streaming supports it too via `client.search_stream(..., exact_lookup=True)`.

### Streaming

For single-molecule searches with real-time progress events. Status lifecycle: `starting` → `synthesis` → `rescoring` → `complete`. The final event includes a `results` list with the same fields as the batch endpoint.

```python
for event in client.search_stream("c1ccc(NC(=O)c2ccccc2)cc1", max_results=10):
    print(event["status"], event["message"])
    if event["status"] == "complete":
        results = event["results"]
```

```bash
curl -sN -X POST https://api.onepot.ai/v1/search/stream \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{"smiles": "c1ccc(NC(=O)c2ccccc2)cc1", "max_results": 5}'
```

### Substructure search

Pass `substructure_search=True` to return purchasable molecules that contain the query as a substructure, instead of similarity hits. The query can be a SMILES or a SMARTS pattern.

```python
resp = client.search(
    smiles_list=["c1ccc(C(=O)N)cc1"],
    max_results=10,
    substructure_search=True,
)
```

```bash
curl -X POST https://api.onepot.ai/v1/search \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{"smiles_list": ["c1ccc(C(=O)N)cc1"], "max_results": 10, "substructure_search": true}'
```

### Risk and price filters

All optional. When set, results that exceed the threshold are excluded.

| Parameter | Type | Values |
|-----------|------|--------|
| `max_price` | int | USD, e.g. `200`, `500` |
| `max_supplier_risk` | string | `"low"`, `"medium"`, `"high"` |
| `max_chemistry_risk` | string | `"low"`, `"medium"`, `"high"` |

Setting `max_chemistry_risk` automatically includes the `chemistry_risk` field in the response. Pass `include_chemistry_risk_score=True` for the raw probability score.

```python
resp = client.search(
    smiles_list=["c1ccc(-c2ccccc2)cc1"],
    max_results=10,
    max_price=500,
    max_supplier_risk="medium",
    max_chemistry_risk="low",
    include_chemistry_risk_score=True,
)
```

### Decompose & bb_filters

Use `decompose=True` to receive the retrosynthetic paths the system considered for each query — every `reaction_class` it found and the BB SMILES of your query at each position. Then call back with `bb_filters` to constrain which candidate BBs are eligible per position. Every enumerated result is automatically tagged with the `reaction_class` it was made from and the `bbs` that built it.

**Call 1 — discover.**

```python
resp = client.search(
    smiles_list=["c1ccc(NC(=O)c2ccccc2)cc1"],
    max_results=5,
    decompose=True,
)
decompositions = resp["queries"][0]["decompositions"]
rxn = decompositions[0]["reaction_class"]   # e.g. "rxn_5e820be4"
```

**Call 2 — refine.** Force the building block at position 1 to vary (Tanimoto ≤ 0.7 to the query's position-1 BB) while leaving position 0 free.

```python
resp = client.search(
    smiles_list=["c1ccc(NC(=O)c2ccccc2)cc1"],
    max_results=10,
    bb_filters=[{"reaction_class": rxn, "bb_index": 1, "max_similarity": 0.7}],
)
for r in resp["queries"][0]["results"]:
    if r.get("reaction_class") == rxn:
        bb_smiles = [b["smiles"] for b in r["bbs"]]
        print(r["smiles"], "←", " + ".join(bb_smiles))
```

```bash
curl -X POST https://api.onepot.ai/v1/search \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{
    "smiles_list": ["c1ccc(NC(=O)c2ccccc2)cc1"],
    "max_results": 10,
    "bb_filters": [
      {"reaction_class": "rxn_<from-call-1>", "bb_index": 1, "max_similarity": 0.7}
    ]
  }'
```

`reaction_class` values like `"rxn_5e820be4"` come from a prior `decompose=True` response and are stable across calls — pass them through as strings. Each `bb_filters` entry takes optional `min_similarity` and `max_similarity` (Tanimoto, 0.0–1.0); omit a bound to leave that side open. Combine multiple entries to constrain multiple positions in one call. `bb_index` is the 0-based position of the building block within the reaction, matching the ordering in the `bbs` field of a decomposition or annotated result. Unknown `reaction_class` or `min_similarity > max_similarity` is rejected as 422. Streaming searches accept the same parameters via `client.search_stream(...)`.

When a retro decomposition produces multiple paths under the same `reaction_class`, filters apply to each path's candidates independently (similarity is measured against that path's BB SMILES, so the same SMILES can pass one path's filter and fail another's).

### Response shape

```python
{
    "queries": [
        {
            "query_smiles": "c1ccc(NC(=O)c2ccccc2)cc1",
            "query_inchikey": "...",
            "results": [
                {
                    "smiles": "...",
                    "inchikey": "...",
                    "similarity": 0.92,
                    "price_usd": 590,
                    "supplier_risk": "low",
                    "chemistry_risk": "medium",       # if include_chemistry_risk=True
                    "chemistry_risk_score": 0.5,      # if include_chemistry_risk_score=True
                    # present on enumerated results (synthesized analogs):
                    "reaction_class": "rxn_5e820be4",
                    "bbs": [
                        {"bb_index": 0, "smiles": "<bb-smiles>"},
                        {"bb_index": 1, "smiles": "<bb-smiles>"},
                    ],
                },
                ...
            ],
            # if decompose=True:
            "decompositions": [
                {
                    "reaction_class": "rxn_5e820be4",
                    "bbs": [
                        {"bb_index": 0, "smiles": "<bb-smiles>"},
                        {"bb_index": 1, "smiles": "<bb-smiles>"},
                    ],
                },
                ...
            ],
        },
        ...
    ],
    "credits_used": 10,
    "credits_remaining": 990,
}
```

## Sample screening space

Use `sample_space()` to draw virtual- or physical-screening libraries from the
precomputed CORE v1.1 index. Each successful request returns exactly 1–10,000
molecules and can be replayed with its seed.

### Quick start

Random sampling is the default and molecule properties are omitted by default,
which keeps large responses compact:

```python
from onepot import Client

client = Client(api_key="your-api-key")
sample = client.sample_space(count=384, seed=42)

print(len(sample["molecules"]), sample["seed"])
for molecule in sample["molecules"]:
    print(molecule["smiles"], molecule["inchikey"], molecule["price_usd"])
```

If `seed` is omitted, the API generates one and returns it in
`sample["seed"]`. Store that value with the request to replay the same sample
while the serving index is unchanged.

### Screening-oriented property window

All property bounds are optional and inclusive. This example is a useful
starting point for a lead-like screen, not an additional admission rule:

```python
sample = client.sample_space(
    count=384,
    strategy="diverse",
    seed=42,
    include_properties=True,
    properties={
        "molecular_weight": {"min": 250, "max": 450},
        "clogp": {"min": 1, "max": 4},
        "tpsa": {"min": 40, "max": 120},
        "hbd": {"max": 3},
        "hba": {"max": 8},
        "rotatable_bonds": {"max": 8},
        "qed": {"min": 0.5},
    },
)

for molecule in sample["molecules"]:
    print(molecule["smiles"], molecule["price_usd"], molecule["properties"]["qed"])
```

### Parameters

| Parameter | Type | Default | Meaning |
|-----------|------|---------|---------|
| `count` | `int` | `384` | Number of molecules requested; 1–10,000. |
| `strategy` | `"random"` or `"diverse"` | `"random"` | Random baseline or generic-scaffold balancing. |
| `seed` | `int` or `None` | `None` | Unsigned 32-bit seed (`0`–`2**32 - 1`); generated and returned when omitted. |
| `properties` | mapping | none | Inclusive molecular-property ranges applied before sampling. |
| `include_properties` | `bool` | `False` | Return descriptors and Murcko scaffold strings on each molecule. |
| `exclude_inchikeys` | list of strings | none | InChIKeys to omit; maximum 100,000. |
| `exclude_generic_scaffolds` | list of strings | none | Exact generic Bemis–Murcko scaffold SMILES to omit; maximum 10,000. |

Each property range must contain `min`, `max`, or both, and `min` cannot exceed
`max`. Unknown property names are rejected.

| Property | Unit/domain | Description |
|----------|-------------|-------------|
| `molecular_weight` | Da, ≥ 0 | Average molecular weight. |
| `clogp` | dimensionless | Crippen calculated logP. |
| `tpsa` | Å², ≥ 0 | Topological polar surface area. |
| `hbd` | count, ≥ 0 | Lipinski hydrogen-bond donors. |
| `hba` | count, ≥ 0 | Lipinski hydrogen-bond acceptors. |
| `rotatable_bonds` | count, ≥ 0 | Rotatable bonds. |
| `heavy_atoms` | count, ≥ 0 | Non-hydrogen atoms. |
| `fraction_csp3` | 0–1 | Fraction of carbon atoms that are sp³. |
| `aromatic_rings` | count, ≥ 0 | Aromatic rings. |
| `rings` | count, ≥ 0 | Total rings. |
| `qed` | 0–1 | Quantitative estimate of drug-likeness. |

### Choosing a strategy

- `strategy="random"` visits hash buckets in a seeded, size-weighted order and
  ranks matching molecules with a deterministic 64-bit hash of InChIKey and
  seed. It is fast, reproducible, and independent of Parquet row ordering.
- `strategy="diverse"` first draws a seeded reservoir of up to 10× the
  requested count (capped at 100,000), then round-robins across generic
  Bemis–Murcko frameworks. It improves framework coverage within the reservoir;
  it is not a global fingerprint MaxMin over the entire index.

The same request and seed produce the same ordered result while the serving
index is unchanged. Changing filters, exclusions, strategy, or the index changes
the sample.

### Response

`sample_space()` returns the molecules and the effective seed:

```python
{
    "molecules": [
        {
            "smiles": "...",
            "inchikey": "...",
            "price_usd": 125,
            # Present only when include_properties=True:
            "properties": {
                "molecular_weight": 347.4,
                "clogp": 2.6,
                "tpsa": 73.1,
                "hbd": 1,
                "hba": 5,
                "rotatable_bonds": 4,
                "heavy_atoms": 24,
                "fraction_csp3": 0.36,
                "aromatic_rings": 2,
                "rings": 3,
                "qed": 0.71,
                "murcko_scaffold": "...",
                "generic_murcko_scaffold": "...",
            },
        }
    ],
    "seed": 42,
}
```

There is no partial-success response: the API returns exactly `count` molecules
or an HTTP error with a useful detail message. For a restrictive property
window, reduce `count` or relax the filters and retry.

### Follow-up batches and scaffold exclusions

InChIKey is the stable molecule identity. Pass previously returned InChIKeys to
avoid repeats in follow-up batches:

```python
first = client.sample_space(count=384, seed=100)
seen = [molecule["inchikey"] for molecule in first["molecules"]]

second = client.sample_space(
    count=384,
    seed=101,
    exclude_inchikeys=seen,
)
```

To exclude whole framework families, first request properties, collect the exact
`generic_murcko_scaffold` strings, and pass them back unchanged:

```python
first = client.sample_space(count=384, seed=100, include_properties=True)
scaffolds = sorted({
    molecule["properties"]["generic_murcko_scaffold"]
    for molecule in first["molecules"]
})

second = client.sample_space(
    count=384,
    seed=101,
    exclude_generic_scaffolds=scaffolds,
)
```

### HTTP request

```bash
curl -X POST https://api.onepot.ai/v1/space/sample \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{
    "count": 384,
    "strategy": "diverse",
    "seed": 42,
    "properties": {
      "molecular_weight": {"min": 250, "max": 450},
      "clogp": {"min": 1, "max": 4}
    },
    "include_properties": true
  }'
```

Validation failures return HTTP 422. The Python client raises
`httpx.HTTPStatusError` and includes the server's `detail` message, which makes
invalid ranges and property names visible without inspecting the raw response.

## Order

Submit results for synthesis quoting. Returns an `order_id` you can reference in followup.

```python
order = client.order(
    smiles=["CCO", "c1ccccc1"],
    email="you@example.com",
    notes="Optional notes",
)
# {"order_id": "a1b2c3d4-...", "molecule_count": 2}
```

```bash
curl -X POST https://api.onepot.ai/v1/order \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{
    "smiles": ["CCO", "c1ccccc1"],
    "email": "you@example.com",
    "notes": "Optional notes"
  }'
```

## Pricing

Credits are charged per SMILES in the query, by mode and chemistry-risk tier:

| Tier | Full search | Exact lookup |
|------|-------------|--------------|
| Base | 1 | 0.1 |
| `include_chemistry_risk=True` | 5 | 0.5 |
| `include_chemistry_risk_score=True` | 10 | 1.0 |

`decompose`, `bb_filters`, and `substructure_search` don't change the price.

`exact_lookup=True` bills at 0.1× the full-search rate (it skips the analog search). The total is charged as a whole number per request — the per-SMILES rate × molecule count, rounded, with a minimum of 1 credit per request. So a 5,000-molecule exact base search costs 500 credits, while a single molecule costs 1.

Screening-space sampling currently uses 0 credits; the response reports this as
`credits_used=0`.
