Metadata-Version: 2.5
Name: sqwishai
Version: 0.3.6
Summary: Python SDK for OneHot API by Sqwish Labs
Project-URL: Homepage, https://docs.sqwish.ai
Project-URL: Documentation, https://docs.sqwish.ai/guides/sdks
Project-URL: API reference, https://docs.sqwish.ai/api-reference/overview
Project-URL: Console, https://console.sqwish.ai
Author-email: Sqwish Labs <help@sqwish.ai>
Maintainer-email: Sqwish Labs <help@sqwish.ai>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: ai,classification,decisions,kettei,llm,onehot,probabilities,sdk,sqwish,sqwish labs
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx<1,>=0.28
Description-Content-Type: text/markdown

# OneHot Python SDK

Synchronous and asynchronous clients for the OneHot API. A decision is one question
with a fixed set of allowed outcomes, and OneHot answers it with a probability for
each outcome. The SDK needs Python 3.11 or newer, and its only dependency is `httpx`.

## Install

```sh
pip install sqwishai
```

The package installs and imports as `sqwishai`: `from sqwishai import OneHot`.

## Your first decision

Make a key in the console at [console.sqwish.ai](https://console.sqwish.ai). Keys look like
`onehot_sk_...`.

```python
import os

from sqwishai import OneHot

with OneHot("https://api.sqwish.ai", api_key=os.environ["SQWISH_API_KEY"]) as client:
    result = client.decide(
        context="Please refund the duplicate charge on my invoice.",
        decisions=[{
            "id": "team", "question": "Which team should handle this?",
            "outcomes": ["billing", "technical", "sales"],
        }],
    )
    team = result["decisions"]["team"]
    print(team["top"], team["p_top"], team["probabilities"])
```

The address is the server's origin, without `/v1`. An argument you pass wins. If you leave
out the address or the key, the client reads `SQWISH_BASE_URL` or `SQWISH_API_KEY`, and with no
address there either it uses `https://api.sqwish.ai`. With no key anywhere, a call
raises before anything is sent; only `lint` and `systemone_translate` work without one.

Leaving `model` out uses the server's default base size. To pick a size, pass its id as
`model`, such as `"kettei-core"`: `client.models.list()` lists every size with its status.
A family you have trained is called by its name, such as `"router"`, and one of its
models by its number, such as `"router@3"`.

Each decision has an `id`, a `question` and its outcomes. `kind` is `"single"` (the
default), `"binary"` (yes or no) or `"ordinal"` (levels `"0"`, `"1"` and so on, with one
`rubric` line per level). `temperature`, from 0.2 to 5, reshapes every answer's
probabilities: below 1 sharper, above 1 softer, and the top answer never changes. Left out,
the model's own default applies, and `temperature=1` gives the probabilities as the model
scored them.

Replies are plain dicts, typed in `sqwishai.types`, and every type imports from the package as
well: `from sqwishai import Job`. `AsyncOneHot` has the same methods, used with `await` and
`async with`.

## What else it covers

| Call | What it does |
|---|---|
| `client.lint(context, decisions)` | Warns about how questions are asked. No model runs. |
| `client.datasets` | `create`, `upload` (JSONL, up to 16 MiB), `get`, `list`, `iter`, `rows` (of any version), `rename`, `delete` and `restore`; `add_rows` and `upload_rows` (JSONL or CSV) add a version, `versions` lists them and `download` reads one; `labels_to_check` lists the labels prompt tuning doubted, each with its row |
| `client.evaluations` | Score models on a dataset's evaluation rows: `create`, `get`, `list`, `iter`, `cancel`, `retry` |
| `client.fine_tuning` | `create` (starts a family), `continue_family`, `estimate` and `estimate_continue` (their exact fee first), `get`, `wait`, `events`, `records` (the held-out answers), `cancel`, `list`, `iter` |
| `client.prompt_tuning` | Better wording for one decision: `create`, `get`, `wait`, `cancel`, `list`, `iter`; `dismiss_label_issues` takes a job's doubted labels off its dataset's labels to check |
| `client.families` | `list`, `get`, `switch`, `keep_warm`, `rollback` and `delete`; `wait` until a family serves its new model |
| `client.models` | `list`, `get`, `ready`, `switch`, `keep_warm` and `delete`, each model by its number, such as `"router@3"` |
| `client.labeling` | Teacher labels for your own queries: `teachers`, then `sources.upload` and `jobs.create`, `wait`, `results`, `finalize`; `sources.delete` removes a source and its teacher answers for good |
| `client.account` | `get`, and `keys.list`, a page at a time, active or revoked, each with its `scope` and, for an inference key, its monthly cap. Keys are made and revoked in the console. |
| `client.capabilities()` | Models, recipes, limits, training availability and capacity warnings |

Most `list` calls return one page with `has_more` and `next_cursor`, and `iter` walks every
page. `client.models.list()`, `client.families.list()` and `client.datasets.versions()`
return the whole list. The lists of jobs filter by `status`, one or a list, and those of
fine-tunes and prompt-tuning jobs by `dataset_id` too:
`client.fine_tuning.list(status=["queued", "running"], dataset_id=dataset_id)`.
`wait` polls a job until it ends. The [fine-tuning](https://docs.sqwish.ai/guides/fine-tuning)
and [labelling](https://docs.sqwish.ai/guides/labelling) guides describe both flows step by step.

`client.capabilities().get("warnings", [])` contains advisory messages. `engine_busy`
names the affected catalogue model in `model`; `queue_full` and `tenant_queue_full`
concern fine-tuning. Display `message` beside the relevant action. Read again to clear
warnings as capacity returns. These snapshots do not reserve capacity, and missing or
stale engine measurements produce no warning, not a promise of spare capacity.

## Families and models

A family, such as `router`, is a line of models, each trained from the one before:
`router@1`, `router@2` and on. A model joins only if it passes its gate. Calls to `"router"`
get the model the family serves while it is switched on, and calls to `"router@1"` get that
model while its own switch is on.

```python
quote = client.fine_tuning.estimate(dataset_id, family="router")  # the exact fee, before anything starts
job = client.fine_tuning.create(dataset_id, family="router", max_price_usd=quote["price_usd"])  # from core; start_from="router@2" starts from a model
job = client.fine_tuning.wait(job["id"])  # router@1; outcome "joined" if it passed its gate
client.datasets.upload_rows(dataset_id, "more.csv", family="router")  # the dataset's next version
quote = client.fine_tuning.estimate_continue("router")
job = client.fine_tuning.continue_family("router", dataset_version=quote["dataset_version"],
                                         max_price_usd=quote["price_usd"])  # its next model, on the rows quoted
job = client.fine_tuning.wait(job["id"])  # a family trains one model at a time
family = client.families.wait("router", job["output_model"])  # calls to "router" now get it
family = client.families.get("router")  # served, latest, move, jobs and continue
client.models.switch("router@1", True)  # answers calls to its number as well
client.families.rollback("router", "router@1", ["router@2"])  # discards router@2 for good
```

A model that joins doesn't answer calls to the family's name straight away: a family that is
switched on moves to it by itself once it has loaded and answered a probe. `client.families.wait`
returns the family once it serves the model you name, the job's `output_model` or its number.
It raises at once for a wait that can't end: a family switched off never moves, a model that
failed its gate never joined, and a family moves only to its latest model. Running out of time
raises `TimeoutError`, saying what the move waits for. Continue a family only once its last
fine-tune has finished: while one trains, `continue_family` raises `family_training`. It
trains on the dataset's newest version, or the `dataset_version` you name. The example names
the version it was quoted for, so rows added meanwhile wait for the next model. The version
must be newer than the one the family's last finished job trained on, or the call raises
`no_new_data`.

A switch or keep warm past your tier's limit raises `live_limit` or `warm_limit`. Its
`details` list what answers or is kept warm, least recently used first, and `switch_off=`
frees a place in the same call. `served=` on a family and `adapter=` on a model are the id you
read: the call raises `served_changed` or `model_changed` if it changed since.
`sqwishai.types.RefusalCode` lists the codes these calls refuse with.

## Estimates and paid requests

`client.estimate(context, decisions, model=..., fallback=...)` returns billed input tokens,
the pinned billing tokenizer, each eligible model's `quote_usd`, and
`maximum_charge_usd`, the requested model's price, which a fallback never exceeds. `client.systemone_estimate(state, questions, model=...)` quotes a
Jev request. Estimates run no model and reserve no money; a later request uses the policy
in effect when it is accepted.

`client.fine_tuning.estimate(...)` takes the arguments of `create`, and
`client.fine_tuning.estimate_continue(family, ...)` those of `continue_family`, without
`max_price_usd` or `idempotency_key`. Each returns
the exact `price_usd` the job would hold if it started now, with its `trained_tokens`, `steps`,
`seed`, `passes`, the `rows` it takes (new, earlier and evaluation) and whether
`minimum_applied`. Pass `price_usd` as `max_price_usd`: a job that costs more by then, because
a price, the dataset or the family changed, raises `price_changed` and holds nothing.

`decide` and `systemone` send one generated operation key per call and reuse it for
network and retryable API failures. To survive a restart or retry a timed-out call, persist
your own `idempotency_key="your-operation-id"` and pass the same payload and key again. A
different payload with the same key returns `idempotency_conflict`; a new key creates a new
billable operation.

When a call fails, its `APIError` names the key it sent as `idempotency_key`: yours, or the
one the SDK made. After an error that is `retryable`, or whose `idempotency_status` is
`"pending"`, call again with that key and the same payload. A pending error is a
`409 request_in_progress` with an `operation_id`: the server accepted the decision, and it is
still running or its end isn't known yet. Only its key asks about it. A call without the key
is a new operation, and you are charged for both if the first was answered after all.

```python
import time

from sqwishai import APIError

ticket = "Please refund the duplicate charge on my invoice."
refund = [{"id": "refund", "kind": "binary", "question": "Does the customer ask for money back?"}]
try:
    result = client.decide(ticket, refund)
except APIError as error:
    if not (error.retryable or error.idempotency_status == "pending"):
        raise
    time.sleep(error.retry_after or 2)
    # The same key asks about the same decision. Without it, this would be a second one.
    result = client.decide(ticket, refund, idempotency_key=error.idempotency_key)
```

The same holds for every call that takes `idempotency_key`, such as `fine_tuning.create`: a
second call without the key starts a second job. A call that got no answer leaves its outcome
unknown too: it ends with `TimeoutError`, or with the `httpx.TransportError` of a lost
connection, and that error carries the same `idempotency_key` attribute. Call again with it.
`getattr(error, "idempotency_key", None)` reads the key from any of these errors, and is
`None` for a call that sent none, such as a read.

The SDK sends a key with every `decide` and `systemone`, so it never meets
`409 outcome_unknown`. A request sent over plain HTTP without an `Idempotency-Key` gets that
when the server can't say how an accepted decision ended. It is not retryable: nothing names
that decision, so a request sent again would be a second one and could be a second charge.

The answer's `charge` contains `amount_usd` (the served model's final cost), `reserved_usd`
(the maximum reserved), `currency`, and `operation_id`. USD values are exact decimal
strings; use `decimal.Decimal` for arithmetic. Unused reserved credit is released.
Replaying a completed operation returns its stored answer without running or charging
again. Once the answer expires, the key still prevents rerunning it and returns
`idempotency_response_expired`.

A final failure releases its hold. `APIError.idempotency_status == "terminal"` and
`operation_id` identify this outcome, and the SDK stops retrying it. The one exception is
`model_warming` with `wait_for_warm`: that failure charged nothing, so the SDK sends the
decide again under a new key after each `Retry-After`, until `wait_for_warm` runs out. It
does this only for a key it made; with your own `idempotency_key` it raises, and you start
a new call deliberately.

## Errors and retries

Every API error raises `APIError`. It carries `status`, a stable `code` such as
`model_answers`, the message as `str(error)`, `request_id`, `details` (on a 422, each bad
field with its path) and `retryable`, which says whether the same request can succeed later.
`idempotency_key` is the key the call sent, or `None` for a call that sent none, such as a
read: [send a paid request again](#estimates-and-paid-requests) with it.

```python
from sqwishai import APIError

try:
    client.models.delete("router@2")
except APIError as error:
    print(error.code, error)  # model_answers: its family serves it, or its own switch is on
```

The SDK retries by itself up to `max_retries` times (default 2) when an error is
`retryable`, or when a reply without that flag is a 429, 502, 503, 504 or 529. It waits for
`Retry-After` when the server sends one, and a random tenth of it at most more, so clients refused
together don't all come back at once, unless that would leave the call no time to try again:
then it raises the error at once, with `retry_after`, as for `warm_capacity`. A create keeps one `Idempotency-Key` across those
retries, and the error that ends them names it; pass `idempotency_key=` to keep it across
restarts as well. `timeout` (default 60 seconds) bounds a whole call, retries included, and
ends it with a `TimeoutError` that names the key too.

Some calls are never repeated automatically, because a lost reply leaves their outcome
unknown: a prompt-tuning job's `cancel`, a family's `delete`, and a family's `switch` that
names `served`, since a second one would find the family gone or moved. Check `retryable`, or
read the job with `prompt_tuning.get` or the family with `families.get`, before you send one again.

A client keeps its connection open between calls for up to `keepalive_expiry` seconds (default
180), so a call made within that time skips the setup of a new connection, about 100 ms. Pass
`keepalive_expiry=None` to keep it until the server closes it; with an `http_client` of your own,
set it on that client instead. When the server has closed a kept connection just as a call goes
out, a call that is safe to send twice (a read, or one with an `Idempotency-Key`) goes again at
once on a new connection, without using a retry; any other call raises as above.

`wait()` raises `JobFailed` for a failed or cancelled job, with the whole job in
`error.job`. Running out of time stops the waiting, not the job.

## Moving from Jev

`client.systemone(state, questions, model=...)` sends TypeSafe's Jev request shape to
`POST /v1/systemone` and returns Jev's answer shape. It needs a `model`: a size's id from the
console or `client.models.list()`. Jev's own model ids are accepted too and run on one of our
base sizes, and the answer's `model` says which. A field that OneHot doesn't model is
rejected with a 422 naming it, rather than ignored.

```python
# A base model that is serving, not one of your fine-tunes (which carry an adapter, `lora`). Or
# use the id shown in the console.
size = next(m["id"] for m in client.models.list() if m["status"] == "ready" and not m.get("lora"))
answer = client.systemone(
    "My parcel arrived broken.",
    {"refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"}},
    model=size,
)
print(answer["answers"]["refund"]["noul"])
```

`client.systemone_translate(state, questions, model=...)` takes the same arguments and
returns the native request as `request`, ready for `client.decide(**translated["request"])`,
with `notes` on what changed. The notes include how a Jev confidence threshold becomes a
`p_top` threshold. It runs no model and needs no key.

## More

- [api.sqwish.ai/openapi.json](https://api.sqwish.ai/openapi.json) is the source of
  truth for every field.

Dataset creation and JSONL upload accept `evaluation_fraction` from `0.1` to `1`, defaulting to
`0.2`. Teacher-label finalisation accepts the same option. A value of `1` creates an
evaluation-only dataset. The saved split cannot be edited.

Dataset `list` and `iter` accept `archived` to read the datasets we archived, when credit
couldn't cover their storage or the account was suspended; `restore` brings one back for new
work. `rows` accepts `partition` (`all`, `training` or `evaluation`). `rename` changes only the
name. `delete` removes a dataset for good, with every version's upload and the copies its
fine-tunes kept; the fine-tunes and their models stay, and work still reading it refuses.
