Calling DeepSeek, GLM, Kimi or Qwen from Python uses
the official openai package — there is no separate SDK to learn. Install it, set
base_url, and every method you already know works. This guide covers installation, sync and
async clients, streaming, structured output, tool calling, retries, cost tracking and the testing
patterns that keep an LLM-backed service maintainable.
pip install openai
# or, with uv
uv add openai
That is the only dependency required. Version 1.x of the SDK is what the examples below assume; if you are on 0.x, the client construction differs and upgrading is worth doing first.
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1",
)
resp = client.chat.completions.create(
model="deepseek-v4-pro",
messages=[{"role": "user", "content": "Explain MoE routing in two sentences."}],
)
print(resp.choices[0].message.content)
print(resp.usage) # exact token counts
Create a key in the console — email or GitHub signup, no Chinese phone number, billing in USD. The endpoint reference is in the API documentation and key handling in authentication.
The SDK also reads OPENAI_BASE_URL and OPENAI_API_KEY from the environment,
so OpenAI() with no arguments works if you prefer configuration over code.
Current rates, read from the live pricing endpoint:
| Model ID | Input / 1M tokens | Output / 1M tokens |
|---|---|---|
deepseek-v4-flash | $0.206 | $0.412 |
deepseek-v4-pro | $1.09 | $2.17 |
glm-4.7-flash | free | free |
glm-5 | $1.55 | $4.96 |
kimi-k2.5 | $0.66 | $3.30 |
qwen3-coder-480b-a35b-instruct | $0.12 | $0.36 |
ernie-4.0-turbo-8k | $0.0012 | $0.0012 |
Rates read from the AIWave pricing endpoint on 2026-07-26. Check live pricing before budgeting — providers revise rates.
Do not hard-code a model string and forget about it. Model IDs are versioned; read the served list at startup so a retired ID fails loudly at boot rather than on a user request:
MODEL = "deepseek-v4-pro"
available = {m.id for m in client.models.list().data}
if MODEL not in available:
raise SystemExit(f"{MODEL} is no longer served; available: {sorted(available)[:10]}")
stream = client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": "Write a haiku about cold starts."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
Streaming responses do not carry a usage object per chunk. If you need exact accounting,
measure with a sampled non-streaming call rather than estimating. The wire format is documented in
streaming responses.
Anything processing more than a handful of requests should be async. The API is identical apart from
await:
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1",
)
sem = asyncio.Semaphore(8) # bound concurrency, not just retries
async def ask(prompt: str) -> str:
async with sem:
r = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": prompt}],
)
return r.choices[0].message.content
async def main(prompts):
return await asyncio.gather(*(ask(p) for p in prompts))
print(asyncio.run(main(["Summarise X", "Summarise Y", "Summarise Z"])))
The semaphore matters more than most people expect. Firing a thousand coroutines at once produces
429 responses that retries then amplify. Bounding concurrency prevents the problem instead
of recovering from it — see rate limits.
When the response feeds code rather than a human, constrain it structurally and validate the result. Pydantic makes the validation honest:
import json
from pydantic import BaseModel, ValidationError
class Ticket(BaseModel):
severity: str
component: str
summary: str
resp = client.chat.completions.create(
model="deepseek-v4-pro",
temperature=0,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content":
'Return JSON only: {"severity": "low|medium|high|critical", '
'"component": string, "summary": string}'},
{"role": "user", "content": "Checkout 500s since the 14:20 deploy."},
],
)
try:
ticket = Ticket.model_validate_json(resp.choices[0].message.content)
except ValidationError as e:
ticket = None
print("model returned unusable output:", e)
Three details make this reliable. Set temperature=0 — sampling variety has no
upside in extraction. State the schema in the prompt as well as via response_format, because
JSON mode is an upstream capability that varies by model. And always validate; a parser that assumes
success will fail in production on the one response that came back wrapped in prose.
import json
tools = [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the status of an order by ID",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
},
},
}]
messages = [{"role": "user", "content": "Where is order A-4471?"}]
resp = client.chat.completions.create(
model="deepseek-v4-pro", messages=messages, tools=tools)
msg = resp.choices[0].message
if msg.tool_calls:
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = lookup_order(args["order_id"]) # your code
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
final = client.chat.completions.create(
model="deepseek-v4-pro", messages=messages, tools=tools)
print(final.choices[0].message.content)
Tool support is a property of the upstream model rather than the gateway — verify on the model directory before depending on it. The protocol in more depth is in function calling with Chinese AI models.
The SDK retries some failures itself, but it does not know which model you would rather fall back to. Wrap it:
import time, random
from openai import OpenAI, APIStatusError, APITimeoutError
client = OpenAI(
api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1",
timeout=60.0, # never rely on an unbounded default
max_retries=0, # we control the policy below
)
RETRYABLE = {408, 429, 500, 502, 503}
def chat(messages, model="deepseek-v4-pro",
fallback="deepseek-v4-flash", attempts=5, **kw):
for i in range(attempts):
try:
return client.chat.completions.create(
model=model, messages=messages, **kw)
except APITimeoutError:
pass
except APIStatusError as e:
if e.status_code not in RETRYABLE:
raise # permanent: fix the request
if i == attempts - 2 and fallback:
model = fallback # last attempt on another model
time.sleep(min(32, 2 ** i) * (0.5 + random.random()))
raise RuntimeError("all retries exhausted")
Retrying a 400 or 401 forever is a common and expensive bug. The decision
table for every status code is in error codes.
import logging
PRICES = { # USD per 1M tokens
"deepseek-v4-flash": (0.206, 0.412), # fetch these from /pricing
"deepseek-v4-pro": (1.0875, 2.175),
}
def priced_chat(messages, model="deepseek-v4-flash", **kw):
r = client.chat.completions.create(model=model, messages=messages, **kw)
pin, pout = PRICES.get(model, (0, 0))
cost = (r.usage.prompt_tokens / 1e6) * pin + (r.usage.completion_tokens / 1e6) * pout
logging.info("model=%s in=%d out=%d cost=$%.6f",
model, r.usage.prompt_tokens, r.usage.completion_tokens, cost)
return r
Keep the price table in configuration rather than code, and refresh it from the pricing page — providers revise rates. Logging per-request cost from the start turns a monthly invoice surprise into a metric you can alert on.
LLM calls make tests slow, expensive and flaky if you let them. The fix is to isolate the boundary.
from unittest.mock import Mock, patch
def test_summarise_extracts_text():
fake = Mock()
fake.choices = [Mock(message=Mock(content="a summary"))]
fake.usage = Mock(prompt_tokens=10, completion_tokens=3)
with patch.object(client.chat.completions, "create", return_value=fake):
assert summarise("long text") == "a summary"
Mock for unit tests, which should cover your parsing, retry and error paths without a network call.
Keep a small number of integration tests that do hit the API, run them on the cheapest model, and pin
temperature=0 so output is as stable as the model allows. There is no reason for CI to spend
premium rates verifying that your plumbing works.
The API is stateless: every request carries the full conversation. That is simple to reason about and easy to make expensive, because history is resent as input tokens on every turn.
class Conversation:
def __init__(self, client, model, system=None, budget_chars=12_000):
self.client, self.model = client, model
self.budget = budget_chars
self.messages = [{"role": "system", "content": system}] if system else []
def _trim(self):
system = [m for m in self.messages if m["role"] == "system"]
rest = [m for m in self.messages if m["role"] != "system"]
used, kept = 0, []
for m in reversed(rest): # keep the most recent turns
size = len(m.get("content") or "")
if used + size > self.budget and kept:
break
used += size
kept.insert(0, m)
self.messages = system + kept
def ask(self, text, **kw):
self.messages.append({"role": "user", "content": text})
self._trim()
r = self.client.chat.completions.create(
model=self.model, messages=self.messages, **kw)
reply = r.choices[0].message.content
self.messages.append({"role": "assistant", "content": reply})
return reply, r.usage
Trim by size rather than message count — ten short exchanges may cost less than one pasted stack trace. Always preserve the system message; dropping it mid-conversation produces a model that suddenly forgets its instructions, which is a confusing bug to diagnose.
For long-running assistants, summarising older turns beats discarding them. Run the summary on a cheap model: condensing history is exactly the kind of undemanding work that does not need a premium model.
In most production workloads a meaningful share of requests are exact repeats. A cache in front of the API removes them entirely.
import hashlib, json, sqlite3
db = sqlite3.connect("llm_cache.db")
db.execute("CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, v TEXT)")
def key_for(model, messages, **kw):
blob = json.dumps({"model": model, "messages": messages, **kw}, sort_keys=True)
return hashlib.sha256(blob.encode()).hexdigest()
def cached_chat(messages, model="deepseek-v4-flash", **kw):
k = key_for(model, messages, **kw)
row = db.execute("SELECT v FROM cache WHERE k=?", (k,)).fetchone()
if row:
return row[0]
r = client.chat.completions.create(model=model, messages=messages, **kw)
text = r.choices[0].message.content
db.execute("INSERT OR REPLACE INTO cache VALUES (?,?)", (k, text))
db.commit()
return text
Two caveats worth stating. Only cache deterministic calls — include temperature in the key, and do not cache anything above temperature 0 unless variability is acceptable. And include the model in the key, otherwise switching models silently serves stale answers from the previous one.
When you have many small independent items, one request handling twenty of them costs far less than twenty requests: the system prompt is sent once and per-request overhead disappears.
def classify_batch(items, model="deepseek-v4-flash"):
numbered = "\n".join(f"[{i}] {t}" for i, t in enumerate(items))
r = client.chat.completions.create(
model=model,
temperature=0,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content":
'Return {"results": [{"index": int, "label": string}]} with one '
'entry per input. Labels: billing, technical, sales, spam.'},
{"role": "user", "content": numbered},
],
)
data = json.loads(r.choices[0].message.content)
out = {d["index"]: d["label"] for d in data["results"]}
return [out.get(i, "unknown") for i in range(len(items))] # never silently drop
Keep batches modest — ten to fifty items — and always reconcile the returned indices against what you sent. A batch that comes back short should fall back to per-item processing rather than losing records. The dedicated treatment is batch processing with Chinese AI models.
Because the model is a parameter, routing by difficulty is trivial to implement and usually the single largest saving available.
CHEAP, STRONG = "deepseek-v4-flash", "deepseek-v4-pro"
def smart_chat(messages, hard=False, **kw):
model = STRONG if hard else CHEAP
r = client.chat.completions.create(model=model, messages=messages, **kw)
return r
def with_escalation(messages, validate, **kw):
"""Try cheap first; escalate only if the result fails your own check."""
r = smart_chat(messages, hard=False, **kw)
text = r.choices[0].message.content
if validate(text):
return text
r = smart_chat(messages, hard=True, **kw)
return r.choices[0].message.content
The validate callback is the important part: schema validation, a required substring, a
length check — whatever “good enough” means for that call site. Escalating on failure
rather than in advance means you pay premium rates only for the requests that genuinely need them. The
full pattern is in building a multi-model router.
A FastAPI endpoint that streams shows most of the pieces together:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
app = FastAPI()
client = AsyncOpenAI(api_key=os.environ["AIWAVE_API_KEY"],
base_url="https://aiwave.live/v1")
@app.post("/chat")
async def chat(payload: dict):
async def gen():
stream = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=payload["messages"],
stream=True,
max_tokens=1024, # a cost ceiling, always set one
)
async for chunk in stream:
piece = chunk.choices[0].delta.content
if piece:
yield f"data: {piece}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
Three production notes. Set max_tokens — an unbounded generation is an unbounded
bill. Rate-limit the endpoint, because it spends money on every call and an unprotected route is an open
invitation. And if chunks arrive all at once in production but stream fine locally, a proxy in front of
your app is buffering; disable buffering there rather than changing the code.
The pattern that ages well is keeping all model access behind one module, so switching models, adding caching or changing retry policy is one file rather than a search across the codebase.
myapp/
llm/
__init__.py # exports: chat(), stream(), embed()
client.py # client construction, timeouts, base_url
retry.py # backoff policy and fallback model
cache.py
prompts/ # prompt templates, in version control
classify.txt
summarise.txt
services/
tickets.py # imports from myapp.llm, never constructs a client
Keep prompts as files rather than string literals scattered through business logic. A quality
regression then shows up in git log, which makes it bisectable — and prompt changes
cause quality regressions far more often than code changes do.
max_tokens on every generation.usage per request and alert on daily spend.models.list().temperature=0 for anything parsed downstream.You do not need one. The official openai package works against any OpenAI-compatible
endpoint — set base_url and use the model ID.
Yes. OpenAI and AsyncOpenAI are independent and can coexist in one
application.
Tokenisers differ between model families, so a count from one family is approximate for another. The
authoritative number is the usage field on the response; log it rather than estimating.
No. Email or GitHub signup, USD billing. See accessing Chinese AI models without a Chinese phone number.
Every claim above traces back to one of these. Pricing figures come from the AIWave pricing endpoint on 2026-07-26; capability claims come from the vendors’ own documentation.