SDKs & CLI

Python SDK

The official Python library for Wity. You give it some text and a few questions, and it gives back an answer for each question as a Python object, with a probability for every option.

The SDK handles the parts of calling an API that are easy to get wrong:

  • It sends your API key, and keeps it out of logs and error messages.
  • It retries requests that failed for a passing reason, like a busy server.
  • It turns API errors into Python exceptions you can catch.
  • It gives answers as typed objects, so your editor can suggest their fields.

Install#

pip install wity-sdk
  • Needs Python 3.10 or newer.
  • It also installs httpx, which sends the requests, and pydantic, which builds the answer objects.
  • The package is called wity-sdk, but in code you import it as wity.

API key#

Get an API key from the keys page. Save it in your environment variables as WITY_API_KEY, then pass it to the client:

import os
from wity import WityClient
client = WityClient(api_key=os.environ.get("WITY_API_KEY"))

You can pass any key as api_key, for example one from a secret manager. If you leave api_key out, the client reads WITY_API_KEY by itself.

Your first request#

This example reads a customer's message and asks Wity three questions about it: which team should handle it, whether it's urgent, and how much it hurts the customer. All three are answered in one request.

import os
from wity import WityClient, choice, noul, score
# The text you want Wity to judge.
ticket = "I was charged twice for my subscription this month. Please refund one of them today."
with WityClient(api_key=os.environ.get("WITY_API_KEY")) as client:
res = client.system_one(
state=ticket,
questions={
# Pick one of these teams.
"team": choice("Which team should handle this ticket?", {
"billing": "Payments, charges and refunds",
"technical": "Bugs, errors and outages",
"other": "Anything else",
}),
# Yes or no.
"urgent": noul(
"Does the customer ask for something to happen soon?",
yes="They ask for fast action",
no="No time pressure",
),
# A level on a scale, from lowest to highest.
"severity": score("How much is this hurting the customer?", ["Not at all", "A little", "A lot"]),
},
)
print(res.choices["team"].choice) # billing
print(res.nouls["urgent"].noul) # 0.99989...
print(res.scores["severity"].score) # 1.97322...

Every call to system_one has this syntax. Words in <angle brackets> are placeholders for your own values:

res = client.system_one(
state=<the text to judge>, # required: a str, dict or list
questions={<name>: <question>, ...}, # required: one or more questions
reasoning=<"auto", "off" or "always">, # optional. Default: "auto"
max_latency_ms=<200 to 120000>, # optional: a time limit in ms
image=<data URL>, # optional: one image
)

The sections below go through each part step by step.

1. Create a client

WityClient holds your key and the connection to the API. Writing with WityClient(...) as client: closes the connection when the block ends. In an app that keeps running, like a web server, create one client when the app starts and use it for every request. Close it with client.close() when the app stops.

2. Pass the state

state is what you want Wity to judge: an email, a support ticket, a log line, a product review. It can be a string, or a dict or list, which Wity reads as JSON. It can be up to 32,000 characters long.

state="<some text>" # a string
state={"<field>": <value>, ...} # a dict, read as JSON
state=[<item>, ...] # a list, read as JSON

3. Write the questions

questions is a dict. Each key is a name you choose, like "team". You use that name later to find the answer.

questions={
"<name>": <question>, # a name you choose, then a question built with choice, noul or score
"<name>": <question>, # add as many questions as you need
}

Each value is a question, built with one of three helpers:

choice
pick one
Wity picks exactly one option from a list you write. Each option has an id ("billing") and a description ("Payments, charges and refunds"). Wity reads the description to understand the option, and answers with the id.
noul
yes or no
A yes-or-no question. yes and no are optional. They say what yes and no mean in your case, which helps when the question alone is vague.
score
a scale
Wity places the text on a scale you write, from the lowest level to the highest. The levels are numbered from 0. You can have 2 to 10 levels.

Each helper takes the question first. Write it the way you'd ask a colleague. See Writing good questions for tips.

4. Read the answers

system_one sends the request and waits for the answer. The response files each answer under the name you gave its question:

team = res.choices["team"]
team.choice # "billing": the option with the highest probability
team.probabilities # {"billing": 0.99993..., "technical": 0.00003..., "other": 0.00003...}
urgent = res.nouls["urgent"]
urgent.noul # 0.99989...: the probability that the answer is yes
severity = res.scores["severity"]
severity.score # 1.97322...: the expected level. 0 is "Not at all" and 2 is "A lot",
# so this is very close to "A lot"
  • res.choices, res.nouls and res.scores group the answers by question type. Because each group holds one type, your editor knows which fields its answers have.
  • res.answers holds every answer in one dict, whatever its type.
  • Answers are Pydantic models. res.model_dump() turns the whole response into a plain dict, for example to save it as JSON.

Acting on an answer#

The probabilities let your code decide when to act on its own and when to ask a person. A common pattern: act when the top option is very likely, and send everything else to a person.

team = res.choices["team"]
if team.probabilities[team.choice] >= 0.9:
assign_to_team(team.choice, ticket) # Wity is sure: route it automatically
else:
send_to_review(ticket) # not sure enough: a person decides
if res.nouls["urgent"].noul >= 0.8:
mark_urgent(ticket)

Probabilities add up to 1, and a higher number means Wity is more sure. The 0.9 above is only an example. Pick thresholds that suit your data. See Evaluating on your data and Probabilities & confidence.

Question types#

A closer look at each helper, with what its answer contains. The numbers in these examples are illustrations.

choice: pick one option

choice(
"<question>",
{
"<id>": "<description>", # one line per option
"<id>": "<description>",
},
)
question = choice("What is this email about?", {
"invoice": "A bill or payment request",
"meeting": "Planning or moving a meeting",
"spam": "Unwanted advertising or a scam",
})
res = client.system_one(state=email, questions={"topic": question})
topic = res.choices["topic"]
topic.choice # "invoice"
topic.probabilities # {"invoice": 0.94, "meeting": 0.04, "spam": 0.02}
topic.probabilities["spam"] # the probability of any option, not only the winner
choice
str
The id of the option with the highest probability.
probabilities
dict[str, float]
A probability for every option, by id. They add up to 1.
confidence
float
How concentrated the probabilities are, from 0 to 1. It's 1 when one option has all the probability, and 0 when it's spread evenly. Use it to sort answers for review. For decisions, use probabilities.

Option ids written as numbers ({1: "Ground"}) come back as strings ("1").

noul: yes or no

# without descriptions
noul("<question>")
# with descriptions of yes and no (give both)
noul("<question>", yes="<what yes means>", no="<what no means>")
question = noul(
"Is this message trying to steal a password?",
yes="It asks for a password, a login or a code", # optional: what yes means here
no="It doesn't ask for any login details", # optional: what no means here
)
res = client.system_one(state=email, questions={"phishing": question})
res.nouls["phishing"].noul # 0.97: the probability of yes. The probability of no is 1 - 0.97
noul
float
The probability that the answer is yes, from 0 to 1. To turn it into a yes or a no, compare it with a threshold you choose, like noul >= 0.8.

Give both yes and no, or neither.

score: a level on a scale

score(
"<question>",
["<lowest level>", "<next level>", ..., "<highest level>"], # 2 to 10 levels, lowest first
)
question = score(
"How angry is the customer?",
["Calm", "Annoyed", "Angry"], # lowest first. These become levels 0, 1 and 2
)
res = client.system_one(state=ticket, questions={"anger": question})
anger = res.scores["anger"]
anger.score # 1.42: the expected level, between "Annoyed" (1) and "Angry" (2)
anger.probabilities # {"0": 0.07, "1": 0.44, "2": 0.49}: one probability per level
anger.legend # {"0": "Calm", "1": "Annoyed", "2": "Angry"}
# The single most likely level, as text:
best = max(anger.probabilities, key=anger.probabilities.get)
anger.legend[best] # "Angry"
score
float
The expected level: each level number weighted by its probability. It can fall between two levels, so 1.42 means "between level 1 and level 2".
probabilities
dict[str, float]
A probability for every level, keyed by level number as a string ("0", "1", …).
legend
dict[str, str]
Maps each level number back to the text you wrote for it.
confidence
float
How concentrated the probabilities are, as for choice.

The levels can come from a variable, like a list loaded from a database.

Other ways to write a question

The helpers return Choice, Noul and Score models, which you can also create directly. A plain dict in the API's own format works too, for example {"type": "noul", "instructions": "Is this urgent?"}. See the API reference for that format.

Limits

Wity checks the size of every request (text length, number of options and more). If a limit is broken, the SDK raises a BadRequestError that names it, and the call isn't billed. See Errors, limits & billing.

Reasoning#

Most questions are clear, and Wity answers them in one quick pass, in about a tenth of a second. Some are harder: two options are almost equally likely, or the question asks how likely something is. For those, Wity first thinks step by step, which usually takes 1 to 4 seconds. Thinking isn't billed.

You choose when Wity may think with reasoning. Pass it to system_one, next to state and questions. It applies to every question in the request.

reasoning="auto" # think only when the question needs it (the default)
reasoning="off" # never think
reasoning="always" # think before every answer
"auto"
default
Think only when the question needs it.
"off"
fastest
Never think. Use it when speed matters more than the hard cases.
"always"
slowest
Think before every answer.
res = client.system_one(
state=ticket,
questions=questions,
reasoning="off", # "auto" (the default), "off" or "always"
max_latency_ms=3000, # stop thinking after 3 seconds and answer with what it has
)

max_latency_ms caps how long one request may take, from 200 to 120000 ms. When the time runs out, Wity stops thinking and answers with what it has. The SDK waits at least max_latency_ms plus 10 seconds before it gives up, even if timeout is shorter.

Each answer says whether Wity thought:

team = res.choices["team"]
team.reasoning.thought # True if Wity thought before answering
team.reasoning.reason # why it thought, like "close_call". None if it didn't think
team.reasoning.budget_limited # True if max_latency_ms cut the thinking short
team.direct_probabilities # the answer before thinking. None if it didn't think

With reasoning="off", reasoning on the answer is None. For a noul, the answer before thinking is in direct_noul. More in Reasoning & auto mode.

Images#

A request can include one image, like a photo to check. Send it as a data URL: the image type, then the file's bytes encoded as base64 text.

import base64
# Turn the image file into a data URL: its type, then its bytes in base64.
with open("parcel.jpg", "rb") as f:
image = "data:image/jpeg;base64," + base64.b64encode(f.read()).decode()
res = client.system_one(
state="Photo of the parcel, taken by the driver at delivery",
questions={"damaged": noul("Is the parcel damaged?")},
image=image,
)
  • Use image/png instead of image/jpeg for a PNG file.
  • state is still required. Use it to say what the image shows, or to add text that goes with it.
  • generate takes an image the same way. See Images.

Generate#

Questions pick from options you wrote in advance. generate writes new text instead: a value read off a document, a form field, a one-line reply. It returns one piece of text per call, with no probabilities. If the answer is one of a known set, ask a question instead: you get probabilities, and the answer is easier to check.

Free text

reply = client.generate(
state=ticket,
instructions="Write the one-sentence reply the agent sends to the customer.",
max_tokens=80, # the longest the answer may be. 1 to 512, default 128
)
reply.text # "Thank you for bringing this to our attention; I've reviewed your account..."

instructions says what to write. max_tokens caps the length. A token is a word or a piece of a word.

JSON in a shape you define

Pass a shape to get JSON back instead of free text. The shape is a JSON Schema, the standard way to describe JSON: which fields it has, and what each one holds. Its top level must be an object.

origin = client.generate(
state="Ticket scan: LISBOA (LIS) -> BERLIN BRANDENBURG (BER), 14 Oct, seat 23C.",
instructions="Origin city name, in English",
# A JSON Schema: the answer must be an object with a "city" field
# holding 1 to 40 letters or spaces.
shape={
"type": "object",
"properties": {"city": {"type": "string", "pattern": "^[A-Za-z ]{1,40}$"}},
"required": ["city"],
},
max_tokens=40,
)
if origin.finish_reason == "stop":
origin.value["city"] # "Lisbon": the JSON, already parsed into a dict
else:
# "length": Wity ran out of max_tokens. The JSON is incomplete and value is None.
send_to_review(ticket)
  • finish_reason is "stop" when Wity finished. The JSON then matches your shape, and value holds it as a dict.
  • finish_reason is "length" when Wity hit max_tokens first. text is cut off, and value is None. Raise max_tokens, or send the item to a person.
  • text always holds the raw output, as a string.

Heads up

Check generated text before acting on it. It can state things that aren't in state.

Async#

AsyncWityClient works the same as WityClient, but its methods are async. Use it in async code, like a FastAPI app, or to send many requests at the same time:

import asyncio
import os
from wity import AsyncWityClient, noul
tickets = [
"The site has been down for an hour and we're losing sales.",
"Could you add a dark mode some day?",
]
async def main():
async with AsyncWityClient(api_key=os.environ.get("WITY_API_KEY")) as client:
# Send one request per ticket, all at the same time, and wait for all of them.
results = await asyncio.gather(*(
client.system_one(state=t, questions={"urgent": noul("Is this urgent?")})
for t in tickets
))
for ticket, res in zip(tickets, results):
print(round(res.nouls["urgent"].noul, 2), ticket)
asyncio.run(main())
  • async with closes the connection at the end, like with does for WityClient.
  • asyncio.gather runs all the requests at once and returns their results in the same order as tickets.
  • Each key allows a limited number of requests at a time (see limits). The SDK retries when you go over, but for large batches, send them in smaller groups.

Errors#

When a request fails, the SDK raises an exception. Every one of them extends WityError, so except WityError catches them all. Catch the specific ones you want to handle differently:

from wity import APIError, AuthenticationError, BadRequestError, RateLimitError, WityError
try:
res = client.system_one(state=ticket, questions=questions)
except BadRequestError as err:
print(err) # the request is wrong, and the message says which part. Fix it: retrying won't help
except AuthenticationError:
... # the key is missing, wrong or revoked
except RateLimitError:
... # still too many requests after the SDK's own retries. Try again later
except APIError as err:
print(err.status, err.request_id) # any other error from the API. Quote request_id when you report it
except WityError as err:
print(err) # anything else, like no connection or no key set
BadRequestError
400
The request is wrong. The message says which part. Fix it: retrying won't help.
AuthenticationError
401
The key is missing, wrong or revoked.
InsufficientBalanceError
402
The account has no credit left. Add some on the Billing page.
RateLimitError
429
Too many requests, even after the SDK's retries. Slow down and try again.
InternalServerError
5xx
A problem on Wity's side, even after the SDK's retries.
APIError
any other status
The parent of all the errors above. It has status, body, headers and request_id, the server's id for the request. Quote request_id when you report a problem.
APIConnectionError
network
The API couldn't be reached, or the connection broke while the answer was arriving.
APITimeoutError
timeout
No answer within the timeout.
WityError
base class
Anything else: a setup problem like a missing key, a request that can't be turned into JSON, or an answer that isn't from Wity.

Checks before sending

  • A misspelled argument, like max_latency=3000 instead of max_latency_ms=3000, raises TypeError straight away.
  • A question with the wrong types, like Choice(criteria=["a", "b"]), raises Pydantic's ValidationError on that line.

To send a field the API supports but this SDK doesn't know yet, put it in extra_body. The SDK logs a warning that names it:

client.system_one(state=ticket, questions=questions, extra_body={"new_field": True})

Retries and timeouts#

The SDK retries some failed requests by itself, so your code doesn't have to:

  • It retries 408, 429 and 5xx errors and network failures, up to 2 times. It waits a little longer before each retry, or as long as the API's Retry-After header asks.
  • If Retry-After asks for more than 60 seconds, the SDK raises the error straight away, so you can decide what to do.
  • It doesn't retry timeouts. The request may already have reached Wity, and you've already waited the full time.
  • If the connection breaks while the answer is arriving, Wity may already have billed the call. The retry is billed again.

You can change both settings when you create the client:

client = WityClient(
api_key=os.environ.get("WITY_API_KEY"),
timeout=30, # give up on one attempt after 30 seconds. Default: 60
max_retries=0, # don't retry failed requests. Default: 2
)

To cap the total time of an async call, including its retries, wrap it in asyncio.timeout() (Python 3.11+) or asyncio.wait_for(). Cancelling it also stops any retries:

async with asyncio.timeout(20): # give up after 20 seconds in total
res = await client.system_one(state=ticket, questions=questions)

A WityClient call can't be cancelled once it has started, like any blocking HTTP call. Use AsyncWityClient if you need that.

Logging#

By default the SDK only logs warnings, like an unknown field in a request. When you're debugging, turn on more detail. It uses Python's standard logging module, under the logger name wity:

import logging
logging.basicConfig() # only if your app hasn't set up logging yet
logging.getLogger("wity").setLevel(logging.DEBUG)
warn
default
Unknown fields in a request, and a Retry-After too long to wait for. Nothing when calls succeed.
info
level
Also each retry (why, and how long it waits), timeouts and connection errors.
debug
level
Also each request: route, attempt, size in bytes, question names, status, time and request id.
off
level
Nothing.
  • Messages go to your app's log handlers. With WITY_LOG_LEVEL and no logging set up, they go to stderr with a [wity] prefix.
  • Logs never contain your API key or the state text, which is often customer data.
  • Errors are always raised, whatever the level. Logs only add detail.

Configuration#

Everything you can pass when you create a client:

api_key
str
Your API key. If left out, read from WITY_API_KEY.
base_url
str
The API's address. If left out, read from WITY_BASE_URL, or the production API. Only needed for testing against another server. client.base_url shows the one in use.
timeout
float
Seconds to wait for one attempt. Default: 60.
max_retries
int
How many times to retry a failed request. Default: 2.
http_client
httpx.Client
Your own httpx.Client (or httpx.AsyncClient), for proxies, custom certificates or HTTP/2. The SDK doesn't close a client you pass in.

Security#

  • The SDK runs on your server. It doesn't run in a browser (Pyodide or PyScript).
  • The client never shows the key when it's printed or logged, and errors never include it.
  • base_url must use https://. Plain http:// is allowed only for localhost, 127.0.0.1 and [::1].
  • The client never follows redirects, so the key only goes to the address you set. This holds even if your own http_client is set to follow them.
  • An answer larger than 10 MB can't be from Wity. The client stops reading it and raises a WityError.

Questions, bugs and security problems: info@alphanimble.com. Please report security problems by email, not in public. The SDK is licensed Apache-2.0.