Theme
Language
·5 min read

Function calling with an LLM API: a complete working loop

How function calling (tool use) works on an OpenAI-compatible API: a full Python loop we ran on GPT-6 Luna, DeepSeek V4.1 Flash and Claude Haiku 5.5, forcing a call with tool_choice, and the Anthropic Messages format.

Function calling with an LLM API: a complete working loop

Function calling (also called tool calling or tool use) is how you give a model access to your code. You describe functions such as "look up an order" or "quote shipping". The model decides which one to call and with what arguments; your code runs it and sends back the result. Every agent, from a store support bot to Claude Code, is built on this loop.

On GetVibe API function calling works through the standard /v1/chat/completions endpoint in the OpenAI format and through /v1/messages in the Anthropic format. Below is a complete loop that we ran on three models from three different makers.

How the loop works

  1. You send messages plus a list of tools. Each tool has a name, a description and a JSON Schema for its parameters.
  2. Instead of text, the model returns tool_calls: which function to call and with which arguments. finish_reason is tool_calls.
  3. Your code runs the functions and appends one message with the role tool per call, carrying the same tool_call_id.
  4. You send the history again. The model either asks for more calls or answers the user in text.

The model never runs anything itself. It only asks for a call. What actually runs, and with which permissions, is decided by your code.

A complete example: a store support assistant

Two functions: order status and a shipping quote. The user asks about both at once, and the model calls both in a single turn.

import json
import os
from openai import OpenAI

client = OpenAI(base_url="https://getvibeapi.com/v1", api_key=os.environ["GETVIBE_API_KEY"])
MODEL = "gpt-6-luna"

def get_order_status(order_id: str) -> dict:
    # your database or CRM lookup goes here
    return {"order_id": order_id, "status": "out for delivery", "eta": "2026-10-24"}

def get_shipping_quote(zip_code: str) -> dict:
    return {"zip_code": zip_code, "price_usd": 7.5, "days": 2}

TOOLS = [
    {"type": "function", "function": {
        "name": "get_order_status",
        "description": "Look up an order by its ID",
        "parameters": {"type": "object",
                       "properties": {"order_id": {"type": "string", "description": "Order ID, e.g. A-1042"}},
                       "required": ["order_id"]}}},
    {"type": "function", "function": {
        "name": "get_shipping_quote",
        "description": "Courier shipping price and delivery time to a US ZIP code",
        "parameters": {"type": "object",
                       "properties": {"zip_code": {"type": "string"}},
                       "required": ["zip_code"]}}},
]
FUNCTIONS = {"get_order_status": get_order_status, "get_shipping_quote": get_shipping_quote}

messages = [
    {"role": "system", "content": "You are a store support assistant. Be brief."},
    {"role": "user", "content": "Where is my order A-1042? And how much is shipping to 94110?"},
]

for _ in range(5):  # cap the number of rounds
    resp = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
    msg = resp.choices[0].message
    if not msg.tool_calls:
        print(msg.content)
        break
    messages.append(msg.model_dump(exclude_none=True))
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        result = FUNCTIONS[call.function.name](**args)
        messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})

We ran this code unchanged on gpt-6-luna, deepseek-v4.1-flash and claude-haiku-5.5, changing only MODEL. In the first round all three called both functions, get_order_status with A-1042 and get_shipping_quote with 94110. In the second round all three answered in text with the delivery status, the ETA, the 7.50 price and the two-day delivery time.

What matters in this code

  • Append the model's tool-call message to the history in full. Without it, your tool message answers a call the model cannot see, and many models simply call the same function again, so the loop goes in circles.
  • One tool message per call, with the matching tool_call_id. The model may ask for several calls at once, as it did here.
  • Arguments arrive as a string. arguments is JSON inside a string. Parse it and validate it, because the model can get a value wrong.
  • Cap the rounds. Without a limit, a bug in a function can turn the loop into an endless one, and into an endless token bill.

tool_choice: when a call is mandatory

By default the model decides whether to call a tool or answer in text ("auto"). tool_choice changes that:

  • "none" — never call tools;
  • "required" — call at least one tool;
  • {"type": "function", "function": {"name": "..."}} — call this specific function.

The last form is handy for data extraction. The function's parameter schema becomes the shape of the answer:

curl https://getvibeapi.com/v1/chat/completions \
  -H "Authorization: Bearer $GETVIBE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v4.1-flash",
    "messages": [{"role": "user", "content": "Jane Doe, [email protected], wants a demo on Thursday"}],
    "tools": [{
      "type": "function",
      "function": {
        "name": "save_lead",
        "description": "Save a sales lead to the CRM",
        "parameters": {
          "type": "object",
          "properties": {"name": {"type": "string"}, "email": {"type": "string"}, "request": {"type": "string"}},
          "required": ["name", "email", "request"]
        }
      }
    }],
    "tool_choice": {"type": "function", "function": {"name": "save_lead"}}
  }'

The response was a save_lead call with name "Jane Doe", email "[email protected]" and request "Wants a demo on Thursday".

One caveat: some Claude models, including Claude Sonnet 5.5 and Claude Opus 5.5, do not accept a forced tool choice. GetVibe turns such a request into auto with the same tools, so you get an answer instead of an error, but the model then decides on its own whether to call. For strict JSON from those models, use structured outputs.

The Anthropic format: /v1/messages

With the Anthropic SDK, tools look slightly different: the parameter schema goes in input_schema, and the call comes back as a tool_use content block.

curl https://getvibeapi.com/v1/messages \
  -H "x-api-key: $GETVIBE_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-haiku-5.5",
    "max_tokens": 512,
    "tools": [{
      "name": "get_order_status",
      "description": "Look up an order by its ID",
      "input_schema": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]}
    }],
    "messages": [{"role": "user", "content": "Where is order A-1042?"}]
  }'

The response had stop_reason tool_use and a tool_use block calling get_order_status with order_id A-1042. You send the result back as a tool_result block in the next user message, with the same tool_use_id.

Which model to use for tools

Agent loops resend the same long prefix every round, so prompt caching matters a lot here. More detail on the request fields is in the tool calling docs.

function calling APItool calling LLMOpenAI function calling exampletool_choiceAnthropic tool useLLM agents tools

Try GetVibe API for free

GPT, Claude, Gemini and more through one OpenAI-compatible API. Pay in USD by card or crypto.

Related articles

Function calling with an LLM API: a complete working loop | GetVibe API