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, andpydantic, which builds the answer objects. - The package is called
wity-sdk, but in code you import it aswity.
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 osfrom wity import WityClientclient = 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 osfrom 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) # billingprint(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 listquestions={<name>: <question>, ...}, # required: one or more questionsreasoning=<"auto", "off" or "always">, # optional. Default: "auto"max_latency_ms=<200 to 120000>, # optional: a time limit in msimage=<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 stringstate={"<field>": <value>, ...} # a dict, read as JSONstate=[<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:
"billing") and a description ("Payments, charges and refunds"). Wity reads the description to understand the option, and answers with the id.yes and no are optional. They say what yes and no mean in your case, which helps when the question alone is vague.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 probabilityteam.probabilities # {"billing": 0.99993..., "technical": 0.00003..., "other": 0.00003...}urgent = res.nouls["urgent"]urgent.noul # 0.99989...: the probability that the answer is yesseverity = 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.noulsandres.scoresgroup the answers by question type. Because each group holds one type, your editor knows which fields its answers have.res.answersholds 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 automaticallyelse:send_to_review(ticket) # not sure enough: a person decidesif 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
probabilities.Option ids written as numbers ({1: "Ground"}) come back as strings ("1").
noul: yes or no
# without descriptionsnoul("<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 hereno="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 >= 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 levelanger.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"
"0", "1", …).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
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 thinkreasoning="always" # 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 answeringteam.reasoning.reason # why it thought, like "close_call". None if it didn't thinkteam.reasoning.budget_limited # True if max_latency_ms cut the thinking shortteam.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/pnginstead ofimage/jpegfor a PNG file. stateis still required. Use it to say what the image shows, or to add text that goes with it.generatetakes 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 dictelse:# "length": Wity ran out of max_tokens. The JSON is incomplete and value is None.send_to_review(ticket)
finish_reasonis"stop"when Wity finished. The JSON then matches your shape, andvalueholds it as a dict.finish_reasonis"length"when Wity hitmax_tokensfirst.textis cut off, andvalueisNone. Raisemax_tokens, or send the item to a person.textalways holds the raw output, as a string.
Heads up
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 asyncioimport osfrom wity import AsyncWityClient, noultickets = ["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 withcloses the connection at the end, likewithdoes forWityClient.asyncio.gatherruns all the requests at once and returns their results in the same order astickets.- 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, WityErrortry: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 helpexcept AuthenticationError:... # the key is missing, wrong or revokedexcept RateLimitError:... # still too many requests after the SDK's own retries. Try again laterexcept APIError as err:print(err.status, err.request_id) # any other error from the API. Quote request_id when you report itexcept WityError as err:print(err) # anything else, like no connection or no key set
status, body, headers and request_id, the server's id for the request. Quote request_id when you report a problem.Checks before sending
- A misspelled argument, like
max_latency=3000instead ofmax_latency_ms=3000, raisesTypeErrorstraight away. - A question with the wrong types, like
Choice(criteria=["a", "b"]), raises Pydantic'sValidationErroron 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-Afterheader asks. - If
Retry-Afterasks 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: 60max_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 totalres = 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 logginglogging.basicConfig() # only if your app hasn't set up logging yetlogging.getLogger("wity").setLevel(logging.DEBUG)
Retry-After too long to wait for. Nothing when calls succeed.- Messages go to your app's log handlers. With
WITY_LOG_LEVELand no logging set up, they go to stderr with a[wity]prefix. - Logs never contain your API key or the
statetext, 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:
WITY_API_KEY.WITY_BASE_URL, or the production API. Only needed for testing against another server. client.base_url shows the one in use.60.2.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_urlmust usehttps://. Plainhttp://is allowed only forlocalhost,127.0.0.1and[::1].- The client never follows redirects, so the key only goes to the address you set. This holds even if your own
http_clientis 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.
