Tool use and function calling (OpenAI)

OpenAI’s tool surface in 2026 has two layers: custom functions (you declare a JSON-Schema spec, the model emits a tool_calls[] array, you execute and return results) and hosted tools on the Responses API (web_search_preview, file_search, computer_use_preview, code_interpreter, plus a few others) — Google-style server-managed tools that run on OpenAI’s infrastructure. The biggest 2024-2025 change was strict mode (strict: true on a function) — when set with additionalProperties: false and all fields in required, OpenAI uses constrained decoding to guarantee the model’s arguments match your schema (not just probable, mathematically forced). parallel_tool_calls=True (the default) lets the model emit multiple tool calls in one turn; tool_choice controls routing semantics ("auto", "none", "required", or a specific {type: "function", function: {name}}).

See also

1. Function declaration shape

Chat Completions (legacy nested shape)

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather for a city.",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "City name"},
                    "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
                },
                "required": ["city"],
                "additionalProperties": False,
            },
            "strict": True,                # constrained decoding
        },
    },
]

Responses API (flatter shape)

tools = [
    {
        "type": "function",
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city"],
            "additionalProperties": False,
        },
        "strict": True,
    },
]

The schema is JSON Schema, lowercase ("type": "object" not "OBJECT" — unlike Gemini). Supported features:

  • Basic types: string, number, integer, boolean, object, array, null
  • enum
  • description, title
  • properties, required, additionalProperties
  • items (for arrays), minItems, maxItems
  • minimum, maximum, pattern
  • anyOf (in strict mode, has restrictions)

In strict mode:

  • additionalProperties: false is required on every object
  • All fields must be in required (no truly-optional fields — make them nullable via ["type", "null"] instead)
  • No recursive $ref
  • oneOf / allOf not allowed
  • Tuple-typed arrays (heterogeneous) not allowed

2. Strict mode mechanics

Per platform.openai.com/docs/guides/function-calling:

When strict: true, the model uses constrained decoding — token sampling at each step is restricted to tokens that keep the output on a valid schema-completion path. The result: emitted arguments are guaranteed to parse against the schema (syntactically), with the format/type constraints satisfied.

Tradeoffs:

  • ✅ Eliminates schema validation errors from malformed args
  • ✅ Reduces retries
  • ⚠️ Slightly higher latency (decoding constraint adds work)
  • ⚠️ Strict mode rules limit schema expressiveness
  • ⚠️ First request with a new strict schema includes a one-time schema-validation pass

For most production use, strict: true is the right default. Non-strict mode (strict: false or omitted) is for legacy schemas you can’t refactor.

3. The call → execute → respond loop

Chat Completions

messages = [
    {"role": "system", "content": "You are a weather assistant."},
    {"role": "user", "content": "What's the weather in Paris?"},
]
 
# Pass 1: model decides to call function
resp = client.chat.completions.create(
    model="gpt-5",
    messages=messages,
    tools=tools,
)
msg = resp.choices[0].message
if msg.tool_calls:
    messages.append(msg)                       # append assistant turn
    for tc in msg.tool_calls:
        args = json.loads(tc.function.arguments)
        result = execute_locally(tc.function.name, args)
        messages.append({
            "role": "tool",
            "tool_call_id": tc.id,
            "content": json.dumps(result),
        })
    # Pass 2: feed results back
    resp = client.chat.completions.create(
        model="gpt-5",
        messages=messages,
        tools=tools,
    )
print(resp.choices[0].message.content)

Responses API

resp = client.responses.create(
    model="gpt-5",
    input="What's the weather in Paris?",
    tools=tools,
    store=True,
)
# Find function calls in the output array
for item in resp.output:
    if item.type == "function_call":
        args = json.loads(item.arguments)
        result = execute_locally(item.name, args)
        # Submit tool result via continuation
        resp2 = client.responses.create(
            model="gpt-5",
            previous_response_id=resp.id,
            input=[
                {
                    "type": "function_call_output",
                    "call_id": item.call_id,
                    "output": json.dumps(result),
                }
            ],
            tools=tools,
        )
        print(resp2.output_text)

The Responses API uses function_call_output items rather than role: "tool" messages, and the call_id field rather than tool_call_id. Migrate carefully.

4. tool_choice semantics

ValueBehavior
"auto" (default)Model decides whether to call any tool or respond with text
"none"Disable tool calling for this request
"required"Model must call at least one tool (cannot reply with text)
{type: "function", function: {name: "..."}} (Chat Completions)Force specific function
{type: "function", name: "..."} (Responses)Force specific function (flatter shape)
# Chat Completions
tool_choice={"type": "function", "function": {"name": "classify_intent"}}
 
# Responses
tool_choice={"type": "function", "name": "classify_intent"}
 
# For hosted tools (Responses)
tool_choice={"type": "file_search"}

"required" is useful for routing / classification — guarantees an action vs a “Sorry, I can’t help” reply.

5. Parallel tool calls

Default parallel_tool_calls=True. The model can return multiple tool calls in one turn when independent:

# Chat Completions response
resp.choices[0].message.tool_calls
# [
#   ToolCall(id="call_1", function=Function(name="get_weather", arguments='{"city":"Paris"}')),
#   ToolCall(id="call_2", function=Function(name="get_weather", arguments='{"city":"Tokyo"}')),
# ]

Execute all in parallel:

import asyncio
results = await asyncio.gather(*[execute(tc) for tc in msg.tool_calls])

Then submit results in one combined message (Chat Completions) or one input batch (Responses):

# Chat Completions
for tc, result in zip(msg.tool_calls, results):
    messages.append({
        "role": "tool",
        "tool_call_id": tc.id,
        "content": json.dumps(result),
    })

Disabling parallel

client.chat.completions.create(model="gpt-5", messages=[...], tools=[...],
                                parallel_tool_calls=False)

Forces serial execution. Useful when tool calls have side effects that must be ordered (e.g., create → tag → publish).

6. Hosted tools (Responses API)

Per platform.openai.com/docs/guides/responses. Run on OpenAI infrastructure; you don’t implement them.

web_search_preview

client.responses.create(
    model="gpt-5",
    input="What's the latest news on SpaceX Starship?",
    tools=[{"type": "web_search_preview"}],
)

Returns text with inline citations + a web_search_call item in output listing the queries. Per-search billing fee.

client.responses.create(
    model="gpt-5",
    input="Find info about Q3 revenue.",
    tools=[{"type": "file_search", "vector_store_ids": ["vs_abc123"]}],
)

Pre-built RAG using your vector store (uploaded via client.vector_stores.*). Returns file_search_call items with retrieved chunks.

code_interpreter

client.responses.create(
    model="gpt-5",
    input="Run this CSV through a pandas summary.",
    tools=[{"type": "code_interpreter", "container": {"type": "auto"}}],
)

Python sandbox (similar to Gemini’s code_execution). Pre-installed scientific Python stack. Per-container-hour billing.

computer_use_preview

client.responses.create(
    model="computer-use-preview-2025-XX-XX",
    input="Book a flight on this airline website.",
    tools=[{
        "type": "computer_use_preview",
        "display_width": 1024,
        "display_height": 768,
        "environment": "browser",     # or "mac", "windows"
    }],
)

Returns computer_call items with actions (click, type, scroll, screenshot). Your code executes them on a real environment and feeds back computer_call_output with the resulting screenshot. Premium per-token rate.

Other hosted tools

  • image_generation — generate images mid-response (via DALL-E-3 / gpt-image-1)
  • local_shell — execute shell commands (Codex / agent use)
  • mcp — connect to a Model Context Protocol server

7. Custom + hosted tools together

tools = [
    {"type": "function", "name": "my_db_query", "parameters": {...}, "strict": True},
    {"type": "web_search_preview"},
    {"type": "file_search", "vector_store_ids": ["vs_abc"]},
]
client.responses.create(model="gpt-5", input="...", tools=tools)

Model picks among them. Common pattern: custom function for internal data, file_search for owned docs, web_search_preview for fresh facts.

8. Schema design tips

Make descriptions count

The model uses description to decide when to call. Be specific:

"description": "Search the internal customer database by email or customer_id. "
               "Returns name, subscription_plan, and last_login. "
               "Use this for any question about specific customers."

vs

"description": "Search customers."   # too vague, model may not call

Use enums for constrained inputs

"status": {"type": "string", "enum": ["active", "paused", "archived"]}

Strict mode enforces enums via constrained decoding — the model literally cannot generate an out-of-enum value.

Avoid deeply nested optional fields

Schema complexity hurts both reliability and latency. Flatten where possible. If your function takes a complex nested input, consider multiple simpler functions.

Use nullable instead of optional in strict mode

In strict mode, every field must be in required. For “optional” fields:

"middle_name": {"type": ["string", "null"]}

The model emits null when the value isn’t applicable.

9. Streaming tool calls

Tool calls arrive incrementally during streaming. The arguments JSON is built up across multiple chunks.

Chat Completions streaming

stream = client.chat.completions.create(model="gpt-5", messages=[...], tools=[...], stream=True)
tool_call_chunks = {}
for chunk in stream:
    delta = chunk.choices[0].delta
    if delta.tool_calls:
        for tc_delta in delta.tool_calls:
            idx = tc_delta.index
            tool_call_chunks.setdefault(idx, {"id": "", "name": "", "args": ""})
            if tc_delta.id: tool_call_chunks[idx]["id"] = tc_delta.id
            if tc_delta.function.name: tool_call_chunks[idx]["name"] = tc_delta.function.name
            if tc_delta.function.arguments: tool_call_chunks[idx]["args"] += tc_delta.function.arguments

Responses API streaming

Cleaner event-based model:

stream = client.responses.create(model="gpt-5", input="...", tools=[...], stream=True)
for event in stream:
    if event.type == "response.function_call_arguments.delta":
        print(event.delta, end="")
    elif event.type == "response.function_call.done":
        # arguments complete
        print(f"\ncall: {event.name} args: {event.arguments}")

10. Error handling

ErrorCauseFix
MALFORMED_TOOL_CALLModel emitted invalid JSONSet strict: true; ensure schema is simpler
Schema validation error on submitstrict: true schema violates strict rules (e.g. missing additionalProperties: false)Add required strict-mode fields
Tool call but no matching toolFunction name doesn’t match declaredVerify name matches exactly
tool_choice: "required" returns textBug — should not happen on current modelsOpen ticket; check model version
Tool result not folded into next responseForgot tool_call_id (CC) or call_id (Responses)Include the matching id

11. Cost considerations

Function declarations live in the request — they’re billed as input tokens:

  • Each function declaration ≈ 50-200 tokens depending on schema complexity
  • 20 tools with verbose descriptions can be 5,000+ tokens of overhead
  • Caching applies — declared tools become part of the cacheable prefix; reuse the same tool set to maximize caching

Hosted-tool surcharges:

  • web_search_preview — per-search fee
  • file_search — vector store storage + per-search fee
  • computer_use_preview — premium per-token + per-action fees
  • code_interpreter — per-container-hour fee

12. Comparison to other vendors

AspectOpenAIClaudeGemini
Schema dialectJSON Schema (lowercase)JSON Schema (lowercase)OpenAPI subset (UPPERCASE)
Strict modestrict: true (full enforcement)strict_input: true (args only)mode: VALIDATED (constrained decode)
Parallel callsparallel_tool_calls flagDefaultDefault
Forced specific tooltool_choice: {type: "function", name}tool_choice: {type: "tool", name}mode: ANY + allowed_function_names
Hosted toolsYes (web, file, computer, code, image)Yes (web, fetch, code, computer, memory)Yes (search, code, url_context)
Unique call IDstool_call_id (CC) / call_id (Responses)tool_use_idRequired on Gemini 3 only
SDK sugar (auto-execute)Agents SDKNone nativePython SDK only

13. Common pitfalls

  1. Forgetting additionalProperties: false — strict mode requires it on every object. Validation error if missing.
  2. Forgetting to mark fields required — strict mode requires all fields in required; make truly-optional fields nullable.
  3. Recursive schemas — not supported in strict mode. Flatten.
  4. tool_choice: "required" but only one tool — the model still has the option to not call it (returns refusal). Force specific with {type: "function", name}.
  5. parallel_tool_calls=True with side-effect tools — model may call delete before confirm. Set parallel_tool_calls=False for ordered operations, or design tools so they’re commutative.
  6. Streaming JSON arguments mid-call — must accumulate across chunks; can’t json.loads() mid-stream.
  7. Migrating CC → Responses changes tool shape — flatter, call_id instead of tool_call_id. Test thoroughly.
  8. Hosted tools not in Chat Completionsweb_search_preview, file_search, computer_use_preview are Responses-only. CC code can’t use them without migrating.
  9. Tool description matters more than the name — name should be unique-ish, description is what the model reads to decide. Iterate on descriptions if calling is unreliable.
  10. No function_call_output in CC — Chat Completions uses role: "tool" messages with tool_call_id; Responses uses function_call_output items with call_id.

14. Further reading