Lesson 13 / 27

A Complete Tool Loop With the OpenAI SDK

Define a function tool, read tool_calls, return role=tool messages.

tool_calls and role "tool" messages

With OpenAI Chat Completions, tools are {"type": "function", "function": {name, description, parameters}}. When the model wants a tool, message.tool_calls is filled and finish_reason == "tool_calls"; each call has an id and function.name, and function.arguments is a JSON string you must json.loads (and validate: it can be malformed or contain unexpected values). You append the assistant message, then for each call append a message with role: "tool", the matching tool_call_id and the result as a string. Models may return several tool calls in one reply; run them all (concurrently if independent) and return all results. OpenAI's newer Responses API uses different item types for the same idea; the loop logic is the same.

OpenAI tool loop, run

I ran this in a Python virtual environment with the anthropic 1.11.0 and openai 3.22.1 SDKs against a small local stand-in server (shown in the testing topic, saved as mock.py). The server returns canned replies, so no key, network or real model is involved: it proves how the SDK builds requests and handles replies, not what a real model would say. The arguments arrive as a JSON string and are parsed with json.loads before the local function runs. The tool result goes back as a role: "tool" message and the second call returns the final text.

import json
import mock, anthropic, openai
srv = mock.start(); base = f"http://127.0.0.1:{srv.server_address[1]}"      # local stand-in server, not a real API

client = openai.OpenAI(api_key="k", base_url=base + "/v1")
tools = [{"type": "function", "function": {"name": "get_order_status", "description": "Look up an order by 6-digit id.",
          "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]}}}]
def get_order_status(order_id): return {"order_id": order_id, "status": "shipped"}

messages = [{"role": "user", "content": "Where is order 481516?"}]
for step in range(5):
    resp = client.chat.completions.create(model="demo-model", tools=tools, messages=messages)
    msg = resp.choices[0].message
    if not msg.tool_calls:
        print("final:", msg.content); break
    messages.append(msg.model_dump(exclude_none=True))
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)                # arguments arrive as a JSON string
        print("model asked for:", call.function.name, args)
        messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(get_order_status(**args))})
print("calls to the API:", len(mock.STATE["log"]))

Output:

model asked for: get_order_status {'order_id': '481516'}
final: Your order 481516 has shipped.
calls to the API: 2

Parse arguments defensively

Wrap json.loads in try/except and validate fields; a malformed argument string should become a tool error, not a crash.

Quick check: In OpenAI tool calls, what type is function.arguments?

  • An already validated Python dict
  • A JSON string you must parse and validate
  • A number
  • A file
Answer

A JSON string you must parse and validate — Arguments are model-generated text; never trust them unchecked.