Composed operations
What about when one call is not enough?
Three patterns that come up in every real integration, and the detail in each that is easy to get wrong.
A tool-use loop
The model asks for a tool, you run it, you send the result back. What makes this awkward in most SDKs is that the shape you receive is not the shape you send, so every loop has a conversion in the middle. Here it is the same type in both directions:
messages = [{"role": "user", "content": "What is the weather in Lima?"}]
while True:
completion = client.chat.completions.create(
model="qwen3-0.6b", messages=messages, tools=TOOLS,
)
calls = completion.tool_calls
if not calls:
break
messages.append(completion.choices[0].message) # sent straight back
for call in calls:
result = run_tool(call.name, call.parse_arguments())
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
print(completion.content)for {
completion, err := client.Chat.Create(ctx, axonium.ChatRequest{
Model: "qwen3-0.6b", Messages: messages, Tools: tools,
})
if err != nil {
return err
}
calls := completion.ToolCalls()
if len(calls) == 0 {
break
}
messages = append(messages, completion.Choices[0].Message)
for _, call := range calls {
args, err := call.ParseArguments()
if err != nil {
return err
}
messages = append(messages, axonium.Message{
Role: "tool",
ToolCallID: call.ID,
Content: run(call.Function.Name, args),
})
}
}loop {
let completion = client
.chat(&ChatRequest {
model: "qwen3-0.6b".into(),
messages: messages.clone(),
tools: tools.clone(),
..Default::default()
})
.await?;
let calls = completion.tool_calls();
if calls.is_empty() {
break;
}
messages.push(completion.choices[0].message.clone());
for call in calls {
let result = run(call.name(), call.parse_arguments()?);
messages.push(Message {
role: "tool".into(),
tool_call_id: call.id.clone(),
content: Some(result.into()),
..Default::default()
});
}
}var messages: [Message] = [.user("What is the weather in Lima?")]
while true {
var request = ChatRequest(model: "qwen3-0.6b", messages: messages)
request.tools = tools
let completion = try await client.chat(request)
let calls = completion.choices.first?.message.toolCalls ?? []
if calls.isEmpty { break }
messages.append(completion.choices[0].message) // sent straight back
for call in calls {
let result = run(call.name, try call.decodedArguments())
messages.append(Message(role: "tool", content: .text(result), toolCallID: call.id))
}
}import { decodedArguments } from "axonium";
const messages = [{ role: "user", content: "What is the weather in Lima?" }];
for (;;) {
const completion = await client.chat.completions.create({
model: "qwen3-0.6b",
messages,
tools: TOOLS,
});
const calls = completion.toolCalls;
if (calls.length === 0) break;
messages.push(completion.choices[0]!.message); // sent straight back
for (const call of calls) {
const result = await runTool(call.name, decodedArguments(call));
messages.push({ role: "tool", tool_call_id: call.id, content: result });
}
}Two things to know:
arguments stays the model's JSON string. Decoding it eagerly would mean a response model that
blows up from the inside when max_tokens cuts a call in half. parse_arguments() decodes it and
fails with its own error, leaving the raw string reachable — because a truncated tool call is a
thing you want to see, not a thing you want to have become an empty object. An empty object would
run the tool with no arguments, which is worse than failing.
Streamed tool calls arrive in fragments that are individually invalid JSON, keyed by index,
with the identity only in the first. The SDKs reassemble them into exactly the non-streaming shape,
so the loop above is the same loop whether you streamed or not.
Retrieval: embed, rank, answer
query_vector = client.embeddings.create(model="qwen3-embedding", input=query)
candidates = vector_store.nearest(query_vector.data[0].embedding, k=50)
ranked = client.rerank.create(
model="qwen3-reranker",
query=query,
documents=[c.text for c in candidates],
)
best = [candidates[r.index] for r in ranked.results[:5]]
answer = client.chat.completions.create(
model="qwen3-0.6b",
messages=[{"role": "user", "content": prompt_with(best, query)}],
)vector, err := client.Embeddings.Create(ctx, axonium.EmbeddingRequest{
Model: "qwen3-embedding", Input: []string{query},
})
candidates := store.Nearest(vector.Data[0].Embedding, 50)
ranked, err := client.Rerank.Create(ctx, axonium.RerankRequest{
Model: "qwen3-reranker", Query: query, Documents: texts(candidates),
})
best := pick(candidates, ranked.Ranking()[:5])
answer, err := client.Chat.Create(ctx, axonium.ChatRequest{
Model: "qwen3-0.6b",
Messages: []axonium.Message{axonium.TextMessage("user", promptWith(best, query))},
})let vector = client
.embeddings(&EmbeddingRequest {
model: "qwen3-embedding".into(),
input: vec![query.clone()],
..Default::default()
})
.await?;
let candidates = store.nearest(&vector.data[0].embedding, 50);
let ranked = client
.rerank(&RerankRequest {
model: "qwen3-reranker".into(),
query: query.clone(),
documents: texts(&candidates),
..Default::default()
})
.await?;
let best = pick(&candidates, &ranked.ranking()[..5]);
let answer = client
.chat(&ChatRequest {
model: "qwen3-0.6b".into(),
messages: vec![Message::text("user", &prompt_with(&best, &query))],
..Default::default()
})
.await?;let vector = try await client.embeddings(
EmbeddingRequest(model: "qwen3-embedding", input: [query]))
let candidates = store.nearest(vector.data[0].embedding, k: 50)
let ranked = try await client.rerank(
RerankRequest(
model: "qwen3-reranker", query: query, documents: candidates.map(\.text)))
let best = ranked.ranking.prefix(5).map { candidates[$0] }
let answer = try await client.chat(
ChatRequest(model: "qwen3-0.6b", messages: [.user(promptWith(best, query))]))const queryVector = await client.embeddings.create({
model: "qwen3-embedding",
input: query,
});
const candidates = vectorStore.nearest(queryVector.data[0]!.embedding, 50);
const ranked = await client.rerank.create({
model: "qwen3-reranker",
query,
documents: candidates.map((c) => c.text),
});
const best = ranked.ranking.slice(0, 5).map((index) => candidates[index]!);
const answer = await client.chat.completions.create({
model: "qwen3-0.6b",
messages: [{ role: "user", content: promptWith(best, query) }],
});Three calls, three separate rate-limit budgets: each endpoint holds its own, and the response
names which one it is reporting in rate_limit.scope. So this pipeline costs one unit from each, not three from one, and the budget
you need to watch is the one for the endpoint you are about to call:
budget = client.rate_limits.get("embeddings")
if budget and budget.remaining_requests == 0:
...if budget := client.RateLimits()["embeddings"]; budget != nil &&
budget.RemainingRequests != nil && *budget.RemainingRequests == 0 {
// ...
}if let Some(budget) = client.rate_limits().get("embeddings") {
if budget.remaining_requests == Some(0) {
// ...
}
}// No client-held map here: key the snapshot off the response you just got.
if vector.meta.rateLimit?.remainingRequests == 0 {
// ...
}// Likewise -- `client.lastRateLimit` is the most recent reading from ANY endpoint, so for a
// specific budget read it off that endpoint's own response.
if (queryVector.meta.rateLimit?.remainingRequests === 0) {
// ...
}Do not read client.last_rate_limit here. After the chat call it describes the chat budget,
and nothing in the numbers says so.
The reranker scores all 50 candidates in one request. Doing the same through chat would be 50.
Reconciling what you were charged
completion = client.chat.completions.create(model="qwen3-0.6b", messages=[...])
row = client.usage.retrieve(completion.meta.request_id)
row.usage.total_tokens
row.cost_usd
row.termination_reason # "complete", or why it stopped earlycompletion, err := client.Chat.Create(ctx, request)
row, err := client.Usage.Retrieve(ctx, completion.Meta.RequestID)
row.Usage.TotalTokens
row.CostUSD
row.TerminationReason // "complete", or why it stopped earlylet completion = client.chat(&request).await?;
let row = client.usage(&completion.meta.request_id).await?;
row.usage.total_tokens;
row.cost_usd;
row.termination_reason; // "complete", or why it stopped earlylet completion = try await client.chat(request)
let row = try await client.usage(requestID: completion.meta.requestID)
row.usage.totalTokens
row.costUSD
row.terminationReason // "complete", or why it stopped earlyconst completion = await client.chat.completions.create(request);
const row = await client.usage.get(completion.meta.requestId!);
row.usage.totalTokens;
row.costUsd;
row.terminationReason; // "complete", or why it stopped earlyNo admin:read needed — this is per-request, and it is your request.
Two traps:
A replay has no usage row. If meta.idempotent_replay is true, retrieve on that
request_id returns 404 — correctly, since replaying reached no model. Use
meta.idempotent_replay_of instead. See Failure.
cost_usd can be null, and null is not zero. It is None where no price is configured. If you
store it in a column that is NOT NULL DEFAULT 0, an unpriced call and a free call become the same
row, and a dashboard sums them into a total that is wrong in the direction nobody checks.
Running calls concurrently
One client, many tasks. The token is fetched once — the manager uses double-checked locking, so a burst of first calls produces one token request rather than one each.
import asyncio
from axonium import AsyncAxonium
async def main():
async with AsyncAxonium(client_id="...", client_secret="...") as client:
results = await asyncio.gather(*(
client.chat.completions.create(model="qwen3-0.6b",
messages=[{"role": "user", "content": q}])
for q in questions
))var wg sync.WaitGroup
results := make([]*axonium.ChatCompletion, len(questions))
for i, q := range questions {
wg.Add(1)
go func(i int, q string) {
defer wg.Done()
results[i], _ = client.Chat.Create(ctx, axonium.ChatRequest{
Model: "qwen3-0.6b",
Messages: []axonium.Message{axonium.TextMessage("user", q)},
})
}(i, q)
}
wg.Wait()let results = futures::future::join_all(questions.iter().map(|q| {
client.chat(&ChatRequest {
model: "qwen3-0.6b".into(),
messages: vec![Message::text("user", q)],
..Default::default()
})
}))
.await;let results = try await withThrowingTaskGroup(of: ChatCompletion.self) { group in
for question in questions {
group.addTask {
try await client.chat(
ChatRequest(model: "qwen3-0.6b", messages: [.user(question)]))
}
}
return try await group.reduce(into: []) { $0.append($1) }
}const results = await Promise.all(
questions.map((question) =>
client.chat.completions.create({
model: "qwen3-0.6b",
messages: [{ role: "user", content: question }],
}),
),
);Concurrency is bounded by your rate-limit budget, not by the client. Sixty requests a minute against one endpoint is sixty, however many tasks you start.
Next: Testing against Axonium.