You can write the tool calling loop once, in OpenAI format, and switch models by changing a string. In October 2026, through Tchavi, I ran the same loop on four models from four providers (OpenAI, Anthropic, DeepSeek, Google), on four shop questions. All four called the right tool with the right arguments, 16 times out of 16.
This guide gives you the complete loop, with its two guards (a turn cap and repeated-call detection), and the cases where a model refused a parameter.
About the test setup. The shop is fictional: a phone-accessories shop in Cotonou, Benin. The questions were asked in French, and the delivery prices in the test data are in F CFA. In the measured run the two tools were named prix_livraison and stock. Below I renamed them delivery_price and stock and translated the code for this English version; the loop logic is identical, but the results are from the French run. I did not test English or Swahili questions. Costs are in credits, converted to KES at October 2026 rates (KES 1,000 buys about 13,204 credits, so 1 credit is about KES 0.076).
In short
- Tchavi, an OpenAI-compatible AI API gateway you pay for with mobile money, lets you keep OpenAI's
toolsformat for every model: for Claude models, the gateway translates the tools to Anthropic's format, and translates the response back. - 16 correct runs out of 16: 4 models (GPT-6 Luna, Claude Haiku 5.5, DeepSeek V4 Flash, Gemini 3.1 Flash Lite) on 4 tasks, with the right tool and the right arguments. Each task took 2 turns, except one that took 3 on Luna.
- The four tasks cost 0.7 to 1.8 credits in total depending on the model (about KES 0.05 to 0.14).
- A model sometimes refuses a parameter, and Tchavi does not fake what the model cannot do: you get the error. I measured two cases, further down.
The loop, once
A tool agent is a loop: you send the question and the list of tools, and the model answers either with text or with a call request (tool_calls). If it is a request, you run the tool, send the result back with the tool role, and call the model again. You stop when it answers with text.
My two tools, for a shop in Cotonou: delivery_price(city) and stock(product). They read two small tables, so the test is reproducible.
import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.TCHAVI_API_KEY,
baseURL: 'https://tchavi.com/api/v1',
});
const tools = [
{
type: 'function',
function: {
name: 'delivery_price',
description: 'Delivery price in F CFA to a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name' } },
required: ['city'],
},
},
},
{
type: 'function',
function: {
name: 'stock',
description: 'Quantity in stock of a product in the shop.',
parameters: {
type: 'object',
properties: { product: { type: 'string', description: 'Product name' } },
required: ['product'],
},
},
},
];
const PRICES = { cotonou: 1000, 'abomey-calavi': 1500, 'porto-novo': 2500 };
const STOCK = { '65 w charger': 14, 'bluetooth headset': 3 };
function runTool(name, args) {
if (name === 'delivery_price') {
const price = PRICES[String(args.city).toLowerCase().trim()];
return price === undefined ? { error: 'city not served' } : { price_fcfa: price };
}
if (name === 'stock') {
const key = Object.keys(STOCK).find((k) => k === String(args.product).toLowerCase().trim());
return key === undefined ? { error: 'unknown product' } : { quantity: STOCK[key] };
}
return { error: 'unknown tool' };
}
export async function agent(model, question, maxTurns = 6) {
const messages = [
{
role: 'system',
content: 'You are the assistant of the shop Chez Akpè. Use the tools, invent nothing.',
},
{ role: 'user', content: question },
];
const seen = new Set();
let credits = 0;
for (let turn = 1; turn <= maxTurns; turn++) {
const { data, response } = await client.chat.completions
.create({ model, messages, tools })
.withResponse();
credits += Number(response.headers.get('x-credits-used') ?? 0);
const msg = data.choices[0].message;
messages.push(msg);
if (!msg.tool_calls?.length) return { answer: msg.content, turns: turn, credits };
let repeated = false;
for (const call of msg.tool_calls) {
const args = JSON.parse(call.function.arguments || '{}');
const signature = call.function.name + JSON.stringify(args);
repeated ||= seen.has(signature);
seen.add(signature);
const result = runTool(call.function.name, args);
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
if (repeated) return { answer: null, turns: turn, credits, stopped: 'repeated call' };
}
return { answer: null, turns: maxTurns, credits, stopped: 'turn limit' };
}
const question =
'I want a Bluetooth headset delivered to Abomey-Calavi: is it in stock, and how much is delivery?';
console.log(await agent('gpt-6-luna', question));
console.log(await agent('claude-haiku-5-5', question));
The only place that depends on the model is the first argument of agent. The rest does not change: same tools format, same reading of tool_calls, same tool role for the result.
What Tchavi translates, and what it passes through
Claude models do not speak the OpenAI format: Anthropic expects tools with an input_schema, tool_use blocks in the response and tool_result blocks in a user message. When you call a Claude model with the OpenAI format, the gateway converts:
- each tool's
function.parametersbecomes aninput_schema; - an
assistantmessage withtool_callsbecomestool_useblocks; - a message with the
toolrole becomes atool_resultblock on a user message, since Anthropic has notoolrole; tool_choice: 'required'becomesanyon Anthropic's side;- in the response,
tool_useblocks becometool_callsagain, and a stop on a tool givesfinish_reason: 'tool_calls', which is what your loop expects.
For the other three models in this test, the request stayed in OpenAI format: no tool conversion by the gateway, and nothing to adapt in my code.
There is one special case for GPT-6 Luna. OpenAI only accepts tools on this model with reasoning_effort: 'none', while the default effort is medium. Left alone, a loop written with the OpenAI SDK would fail. When you do not choose an effort, Tchavi sends none for you, which is why my loop works without any setting.
The four tasks and the results
Four questions, without changing the loop (asked in French in the measured run, glossed here):
- A: "How much is delivery to Porto-Novo?" →
delivery_pricewithPorto-Novo. - B: "Do you still have 65 W chargers?" →
stockwith the charger. - C: "I want a Bluetooth headset delivered to Abomey-Calavi: is it in stock, and how much is delivery?" → both tools.
- D: "Do you deliver to Parakou?" →
delivery_price, which answers "city not served": the model must tell the customer, not invent a price.
Credits per task, with turns in brackets. The last column is the total converted to KES at October 2026 rates:
| Model | A | B | C | D | Total | Total in KES |
|---|---|---|---|---|---|---|
| GPT-6 Luna | 0.15 (2) | 0.15 (2) | 0.26 (3) | 0.15 (2) | 0.70 | about 0.05 |
| Claude Haiku 5.5 | 0.36 (2) | 0.35 (2) | 0.50 (2) | 0.41 (2) | 1.62 | about 0.12 |
| DeepSeek V4 Flash | 0.41 (2) | 0.43 (2) | 0.49 (2) | 0.48 (2) | 1.81 | about 0.14 |
| Gemini 3.1 Flash Lite | 0.26 (2) | 0.26 (2) | 0.45 (2) | 0.26 (2) | 1.23 | about 0.09 |
These are the credits measured on 17 October. Gemini 3.1 Flash Lite's rates have risen by about 5.5% since, so the same four tasks would now cost about 1.30 credits (about KES 0.10).
In every case: the right tool, the right arguments, no extra call, and on task D all four said Parakou is not served. On task C, Luna called the two tools one after the other (3 turns); the other three called them in the same turn (2 turns). It is one run per model: a model can ask for two tools in the same turn or one after the other, so the number of turns is not a stable property.
Credits do not compare quality, only what each model consumed here: Luna was the least hungry on these four questions. These four tasks are easy, and a 16-run test cannot separate models that all succeed. To choose between them, write fifteen cases from your real shop and count the successes.
The guards
The turn cap. maxTurns = 6: without it, a model that keeps calling a tool costs you credits until you cut it off. When it is reached, do not return an invented answer: return an error or hand over to a human.
Repeated-call detection. If the model calls the same tool again with the same arguments, it will learn nothing more from the second call. The loop keeps a signature (name + arguments) and stops at the first repetition.
I checked that this second guard is worth having. I broke the stock tool so that it always answered "service unavailable, try again", then asked whether wired earphones were in stock. Luna, Haiku 5.5 and Gemini 3.1 Flash Lite made one call and then told the customer the service was unavailable. DeepSeek V4 Flash called stock again with the same arguments, and the detection cut the loop at turn 2. Without it, the loop would have sent one more request, and every turn is billed.
When a model refuses a parameter
Tchavi forwards your request to the model without emulating what it does not do (the principles). If a model does not take a parameter, you get the error instead of approximate behavior. I tried four parameters on task A, one model at a time:
| Parameter | Luna | Haiku 5.5 | DeepSeek V4 Flash | Gemini 3.1 Flash Lite |
|---|---|---|---|---|
temperature: 0 | accepted | accepted | accepted | accepted |
reasoning_effort: 'high' | 502 error | accepted | accepted | accepted |
parallel_tool_calls: false | accepted | accepted | 400 error | 400 error |
tool_choice: 'required' | accepted | accepted | 503 saturated, twice | accepted |
"Accepted" means a 200 response with the right tool called.
- DeepSeek V4 Flash and
parallel_tool_calls: 400 error, codeunsupported_parameter, with the parameter's name. Remove it from the request for that model. This is the clean case: nothing is billed, and the message says what to remove. - Gemini 3.1 Flash Lite and
parallel_tool_calls: false: accepted during this test, it now gets the same 400unsupported_parameteras DeepSeek V4 Flash, because the parameter is not honoured on this model. Remove it for Gemini too. - GPT-6 Luna with an explicit
reasoning_effortand tools: 502provider_error, with no detail, onhighas onlow(replayed twice), and nothing is billed. Do not set the effort on this model when you give it tools. - DeepSeek V4 Flash with
tool_choice: 'required': the 503provider_saturatederror, twice. I do not know whether it is the parameter or passing saturation, so I do not conclude.
One coding tip: handle the 4xx and 5xx errors in your loop. Refused calls cost nothing, but a loop that crashes midway leaves the customer without an answer. The errors page lists the codes, and the chat completions reference says that tool support depends on the model: each model's page (for example GPT-6 Luna, Claude Haiku 5.5, DeepSeek V4 Flash or Gemini 3.1 Flash Lite) says whether it accepts tools.
FAQ
Do all Tchavi models accept tools?
No. Capabilities are declared per model: GET /v1/models returns capabilities.tools for each one, and some models are false. Check that field, or the API tab on the model's page, before you wire up an agent.
Do I have to translate tools for Claude?
No. You send the OpenAI format and Tchavi translates for Claude models, in both directions. If you prefer the native Anthropic format, Tchavi also serves the Messages API with the URL https://tchavi.com/api, which Anthropic clients use.
How many turns should I allow?
For these tasks, 2 or 3 turns are enough. I set 6: enough for a chain of two or three tools, low enough that a runaway loop costs little. Adjust for your agent, and watch x-credits-used on every turn.
Do the tools run at Tchavi?
No. The model only requests the call; your code runs delivery_price or stock, and returns the result in a message with the tool role.
Why does the number of turns change from one run to another?
Because a model can ask for two tools in the same turn or one after the other. Do not make your code depend on an exact number of turns: keep only the cap.
create your account and top up KES 250
This article is part of the series "Building an AI product from Africa". Tchavi is an OpenAI-compatible AI API gateway: one key gives access to over 100 text, image, audio and video models, and you pay for it with mobile money, no bank card (countries and operators). More guides on the blog. Follow us: LinkedIn and X.