Skip to main content

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

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. Keys look like onehot_sk_....

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 and 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.

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.

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 with it.

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.

# 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

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.

Metadata

Release files for sqwishai 0.3.6

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sqwishai 0.3.6
File Size Uploaded
sqwishai-0.3.6.tar.gz 85.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sqwishai 0.3.6
File Interpreter ABI Platform
sqwishai-0.3.6-py3-none-any.whl Python 3 none any Details

Total release size: 140.7 kB

Release files / sqwishai-0.3.6.tar.gz

Download URL sqwishai-0.3.6.tar.gz
Size 85.4 kB
Tags Source
SHA-256 checksum
How to use checksums
437eb52db67fae3d7e7694f939faec80480bc40c93d5b4233f940ae30c513992
BLAKE2b-256 checksum
How to use checksums
7ce0e995d546fa5f294e85b3c464766e2824f6ebe16bc267846ebe5fd5951fe3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.9.10 {"installer":{"name":"uv","version":"0.9.10"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / sqwishai-0.3.6-py3-none-any.whl

Download URL sqwishai-0.3.6-py3-none-any.whl
Size 55.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
db2d7f7bc4573a8742d2c5a2163ac93952845c1c77144eec133961e78f3cd880
BLAKE2b-256 checksum
How to use checksums
25b7ea16b5fe3012bd89f996b7b76a472942062f9c4abea307a9ff91b44481f7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.9.10 {"installer":{"name":"uv","version":"0.9.10"},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.3.6 This release

2 release files

0.3.5

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page