# Tool calling in Node.js: one loop that works with any model

> Write one OpenAI-format tool calling loop and switch models by changing a string. Node.js code, loop guards, and what 4 models did in October 2026.

- Published: 2026-10-17
- Updated: 2026-11-02
- Author: Gael — Tchavi founder
- Canonical: https://tchavi.com/en/blog/tool-calling-one-loop-any-model

---


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 `tools` format 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.

```js
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.parameters` becomes an `input_schema`;
- an `assistant` message with `tool_calls` becomes `tool_use` blocks;
- a message with the `tool` role becomes a `tool_result` block on a user message, since Anthropic has no `tool` role;
- `tool_choice: 'required'` becomes `any` on Anthropic's side;
- in the response, `tool_use` blocks become `tool_calls` again, and a stop on a tool gives `finish_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_price` with `Porto-Novo`.
- **B**: "Do you still have 65 W chargers?" → `stock` with 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](https://tchavi.com/en/docs/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, code `unsupported_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 400 `unsupported_parameter` as DeepSeek V4 Flash, because the parameter is not honoured on this model. Remove it for Gemini too.
- **GPT-6 Luna with an explicit `reasoning_effort` and tools**: 502 `provider_error`, with no detail, on `high` as on `low` (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 503 `provider_saturated` error, 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](https://tchavi.com/en/docs/errors) page lists the codes, and [the chat completions reference](https://tchavi.com/en/docs/chat-completions) says that tool support depends on the model: each model's page (for example [GPT-6 Luna](https://tchavi.com/en/m/openai/gpt-6-luna), [Claude Haiku 5.5](https://tchavi.com/en/m/anthropic/claude-haiku-5-5), [DeepSeek V4 Flash](https://tchavi.com/en/m/deepseek/deepseek-v4-flash) or [Gemini 3.1 Flash Lite](https://tchavi.com/en/m/google/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](https://tchavi.com/en/register?next=%2Fdashboard%2Fbilling%3Famount%3D250%26currency%3DKES%26from%3Dblog-tool-calling-en)

> _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](https://tchavi.com/en/docs/payments)). More guides on [the blog](https://tchavi.com/en/blog). Follow us: [LinkedIn](https://www.linkedin.com/company/tchavi) and [X](https://x.com/tchaviai)._

