Tools & function calling

Defining tools, the call loop, the built-in server tools (search, fetch, datetime), and why tool schemas count as input tokens.

Tools (function calling) work on both surfaces — chat/completions and messages — and in both streaming and non-streaming mode.

Defining a tool

Python
tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "آب‌وهوای فعلی یک شهر را برمی‌گرداند",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]

resp = client.chat.completions.create(
    model="openai/gpt-4o-mini",
    messages=[{"role": "user", "content": "هوای تهران چطوره؟"}],
    tools=tools,
)

The call loop

  1. Send the request with tools.
  2. If the model decides to call one, finish_reason is tool_calls and message.tool_calls is populated.
  3. Run the function yourself.
  4. Append the result with role tool and the same tool_call_id , then send again.
Python
messages.append(resp.choices[0].message)          # the assistant's tool call
messages.append({
    "role": "tool",
    "tool_call_id": resp.choices[0].message.tool_calls[0].id,
    "content": '{"temp_c": 31}',
})
final = client.chat.completions.create(model=..., messages=messages, tools=tools)
Tool schemas are part of the prompt and count as input tokens — on every call of that conversation. Ten tools with long descriptions can cost more than the user's actual question. Send only the tools that could plausibly be needed at that step.

The wallet hold accounts for tool schemas too, so the hold grows with a large tool set.

Tools we run for you

Three names in the iranrouter: namespace are run by the gateway instead of being handed back to your code: we search, or read the page, feed the result to the model, and you get one final answer. You do not write the loop.

NameArgumentsWhat it does
iranrouter:web_search{"query": "...", "n": 5}Searches the public web; returns titles, URLs and short excerpts.
iranrouter:web_fetch{"url": "https://..."}Reads one public http(s) page and returns its readable text.
iranrouter:datetime—The current date and time in Tehran and UTC, with the Jalali date.
Python
resp = client.chat.completions.create(
    model="openai/gpt-4o-mini",
    messages=[{"role": "user", "content": "امروز چندم است و قیمت دلار چقدر؟"}],
    tools=[
        {"type": "function", "function": {"name": "iranrouter:datetime"}},
        {"type": "function", "function": {"name": "iranrouter:web_search"}},
    ],
)
print(resp.choices[0].message.content)   # one answer; the searching happened on our side
  • Only these namespaced names are ours. Your own tool called web_search is still yours and still comes back to you.
  • If your SDK refuses a colon in a tool name (Anthropic's does), write the same tool as iranrouter__web_search — the two are the same.
  • The openrouter: prefix and the plugins: [{"id": "web"}] array are accepted too: the prefix for all three tools, the plugins form for web_search only.
  • If your client does not let you add a tool (agents, chat apps), send the same plugins: [{"id": "web"}] as an extra body field — most OpenAI-compatible clients have an “extra body” setting (in Hermes: extra_body on its custom_providers entry). Or with nothing to set in the client: turn on “Web search” in a preset (dashboard → Presets) and use @preset/<slug> as the model. The model must support function calling.
  • Every turn of the loop is a whole request: its own hold, settle and usage row. One answer may therefore appear as several rows in your usage, and their sum is what that request cost.
  • The loop is capped (5 turns by default, and at most 5 tool calls per turn). On the last turn the model must answer in words rather than ask for another tool. Each turn also counts as one request against your key's RPM.
  • With stream: true the final answer arrives in one piece rather than token by token: until the model has answered, nobody knows that turn is the last. A heartbeat keeps the connection open meanwhile.
  • On these requests Idempotency-Key covers the loop as a whole, not each turn, and only without streaming: a retry of a loop that finished replays its final answer and is not billed again; one still running gets 409 idempotency_in_flight. With stream: true it is ignored, and a retry runs and bills again. n is always 1.
  • web_fetch only opens an address that appears, exactly as written, in your messages or in an earlier search or fetch result. An address the model composed itself is refused — which is what stops a hostile page from walking your conversation out through a URL.
  • If your organization has a content guardrail, it applies to tool results too: sensitive data is cut out of the page text, and a page carrying instructions aimed at the model is withheld — without failing your request.
  • The page text and the search results are never stored, exactly like the rest of your request content.
web_fetch reads public http and https addresses on ports 80 and 443 only, with no user:pass in the address. Private-network addresses and localhost are refused, and every redirect is checked again from the start. Only text and HTML are read, up to a size cap.