Getting Started#
Installation#
pip install saia-python
Or install from source:
git clone https://github.com/fschwar4/saia_python.git
cd saia_python
pip install -e .
Authentication#
The library resolves the API key automatically from the following sources, checked in order:
SAIA_API_KEYenvironment variable.saia_apifile (current directory, then home directory) containing the raw key.envfile (current directory, then home directory) withSAIA_API_KEY=...
API keys are obtained through the KISSKI LLM Service booking page.
Username#
The username corresponds to your Academic Cloud account and serves as the
owner prefix in ARCANA IDs (e.g. saiauser123 in saiauser123/My-Arcana).
It can be set via the SAIA_USERNAME environment variable, .env, or
config.toml:
from saia_python import load_username
username = load_username()
ARCANA IDs#
ARCANA IDs are resolved from .env, config.toml, and environment
variables via load_arcana_ids().
Use config.toml for multiple IDs:
[saia.arcana]
default = "username/My-Default-Arcana"
ids = ["username/First-Arcana", "username/Second-Arcana"]
Or set a single default in .env:
SAIA_ARCANA_ID=username/My-Arcana
from saia_python import load_arcana_ids
arcana_ids = load_arcana_ids()
# {'default': 'username/My-Arcana', '0': 'username/First', ...}
The full priority chain is documented in Implementation Details.
The distinction between .env and config.toml is described in
Configuration.
Note
Management endpoints (get, upload) accept both the full
owner/name format and the plain name. The owner prefix is stripped
automatically. The chat endpoint requires the full owner/name format.
OOP Interface#
from saia_python import SAIAClient
client = SAIAClient()
# Connectivity + auth check (combines /models GET with the ARCANA
# heartbeat). Returns bool by default; verbose=True returns a
# diagnostic dict listing which leg succeeded/failed.
client.health_check()
client.health_check(verbose=True)
# Models
client.models.list_ids()
# Chat completion
client.chat.completions(
model="meta-llama-3.1-8b-instruct",
messages=[{"role": "user", "content": "Hello!"}],
)
# Streaming
for chunk in client.chat.completions(
model="meta-llama-3.1-8b-instruct",
messages=[{"role": "user", "content": "Count to 5."}],
stream=True,
):
choices = chunk.get("choices", [])
if choices:
print(choices[0].get("delta", {}).get("content", ""), end="")
# Voice AI
client.voice.transcribe("audio.wav", language="de")
client.voice.translate("audio.wav")
# Non-blocking: wait=False returns a concurrent.futures.Future, so the
# call doesn't block. Resolve it later with .result() (re-raises errors).
future = client.voice.transcribe("audio.wav", wait=False)
transcript = future.result()
# ARCANA (RAG)
from saia_python import load_arcana_ids
arcana_ids = load_arcana_ids()
client.arcana.list()
print(client.arcana.info(arcana_ids["default"]))
client.arcana.upload(arcana_ids["default"], "document.pdf")
client.arcana.upload_directory(arcana_ids["default"], "path/to/docs/")
client.arcana.upload_directory(
arcana_ids["default"], "path/to/docs/", pattern="*.pdf", recursive=True
)
client.arcana.list_files(arcana_ids["default"])
client.arcana.delete_file(arcana_ids["default"], "document.pdf")
client.arcana.generate_index(arcana_ids["default"])
client.arcana.delete_index(arcana_ids["default"])
# End-to-end: create a new arcana, upload a directory, build the
# index — one call. The UUID-suffixed name from create() flows
# through to upload + index automatically.
result = client.arcana.setup_from_directory(
"MyKB", "./markdown/",
pattern="**/*.md",
update_toml=True, toml_label="my_kb",
)
print(result["arcana"]["id"]) # owner/MyKB-<uuid>
print(len(result["uploads"])) # number of files uploaded
print(result["index"]) # final index status
response = client.arcana.chat(
model="deepseek-v4-flash-0731",
messages=[{"role": "user", "content": "Summarize the document."}],
arcana_id=arcana_ids["default"],
)
# Pull the assistant text out of any chat / arcana response.
# Safe against empty `choices` and missing `content` fields —
# returns "" + logs a warning rather than raising.
from saia_python import text_of
print(text_of(response))
# Rate limits
print(client.get_rate_limits())
Tool Use (Function Calling)#
The SAIA API follows the OpenAI tool-calling protocol. Tools are defined as JSON schemas; the model decides when to invoke them:
import json
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
response = client.chat.completions(
model="deepseek-v4-flash-0731",
messages=[{"role": "user", "content": "Weather in Berlin?"}],
tools=tools,
)
msg = response["choices"][0]["message"]
if msg.get("tool_calls"):
tc = msg["tool_calls"][0]
args = json.loads(tc["function"]["arguments"])
result = {"temp_c": 18, "condition": "partly cloudy"}
final = client.chat.completions(
model="deepseek-v4-flash-0731",
messages=[
{"role": "user", "content": "Weather in Berlin?"},
msg,
{"role": "tool", "tool_call_id": tc["id"], "content": json.dumps(result)},
],
tools=tools,
)
print(final["choices"][0]["message"]["content"])
Structured Output#
Pass a Pydantic v2 model and get a validated instance back. SAIA enforces the model’s JSON Schema on the server — the inference backend only lets the model write tokens that fit the schema — so the answer parses without a retry loop:
from typing import Literal
from pydantic import BaseModel
class Sentiment(BaseModel):
label: Literal["positive", "negative", "neutral"]
confidence: float
result = client.chat.completions_structured(
model="meta-llama-3.1-8b-instruct",
messages=[{"role": "user", "content": "Sentiment of: 'The update broke my build.'"}],
response_model=Sentiment,
)
print(result.label) # negative
The schema shapes the answer but is not added to the prompt, so still say in the messages what each field should hold.
Reasoning models think before they answer, and the thinking counts against
max_tokens. If the budget runs out first, the call raises
StructuredOutputError instead of returning half an
object. Leave room, or turn thinking off where the chat template allows it:
from saia_python import StructuredOutputError
try:
result = client.chat.completions_structured(
model="qwen3.6-35b-a3b",
messages=[...],
response_model=Sentiment,
chat_template_kwargs={"enable_thinking": False}, # Qwen templates
)
except StructuredOutputError as err:
print(err) # why the answer is unusable
print(err.finish_reason) # "length" when the budget ran out
print(err.response["usage"]) # the tokens were still spent
completions_structured returns only the instance. To keep the raw response
too (e.g. its usage), use the two helpers it is built from:
from saia_python import parse_structured, response_format_for
response = client.chat.completions(
model="meta-llama-3.1-8b-instruct",
messages=[...],
response_format=response_format_for(Sentiment),
)
result = parse_structured(response, Sentiment)
print(response["usage"]["total_tokens"])
The async client has the same method (await
client.chat.completions_structured(...)), and the OpenAI SDK route works too:
client.openai.chat.completions.parse(..., response_format=Sentiment).
OpenAI SDK Integration#
The library provides an OpenAI-compatible client for use with tools that
require the openai SDK (RAGAS, LangChain, instructor, etc.).
Requires pip install saia-python[openai].
from saia_python import create_openai_client
# Credentials resolved automatically (same as SAIAClient)
openai_client = create_openai_client()
# Chat completions via OpenAI SDK
response = openai_client.chat.completions.create(
model="deepseek-v4-flash-0731",
messages=[{"role": "user", "content": "Hello!"}],
)
# Embeddings
embedding = openai_client.embeddings.create(
model="e5-mistral-7b-instruct",
input="Text to embed",
)
Or via the SAIAClient property:
client = SAIAClient()
client.openai.chat.completions.create(model="...", messages=[...])
Integration with ecosystem tools:
# instructor
import instructor
patched = instructor.from_openai(create_openai_client())
# RAGAS
from ragas.llms import LangchainLLMWrapper
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="deepseek-v4-flash-0731",
openai_api_key=client._api_key,
openai_api_base=client._base_url,
)
Tokenizers#
Download the open-weight models’ tokenizers and measure prompts offline — token
counts, the special/structural-token overhead a chat template adds, and subword
fertility. Requires pip install saia-python[tokenizer]. Tokenizer files
(never the weights) are cached under ~/saia_python/tokenizers/.
from saia_python import chat_template_tokens, token_distribution
# How many tokens does a system prompt cost on Gemma 4?
r = chat_template_tokens(
"gemma-4-31b-it",
system="You are a careful assistant.",
user="Summarise the attached report.",
)
print(r.num_tokens, "tokens;", r.overhead_tokens, "from special tokens")
print(f"overhead is {r.overhead_ratio_total:.0%} of the templated total")
print("subword fertility:", round(r.fertility, 3))
# The system prompt may also be read from a .txt / .md file:
r = chat_template_tokens("gemma-4-31b-it", system_file="system_prompt.md")
# Size a RAG corpus against the embedding model ARCANA uses internally:
dist = token_distribution("path/to/markdown", "qwen3-embedding-4b")
print(dist.summary())
See Tokenizers for the full surface (downloading, loading,
client.tokenizers, and the tiktoken path for the external OpenAI models).
Functional Interface#
All services are accessible as standalone functions. The API key is resolved automatically when omitted:
from saia_python import list_model_ids, chat_completion, get_rate_limits
list_model_ids()
chat_completion(
model="meta-llama-3.1-8b-instruct",
messages=[{"role": "user", "content": "Hello!"}],
)
print(get_rate_limits())