Quick answer
How do I add web search to an OpenAI model with function calling?
Declare a search_web function with a JSON schema, and when the model calls it, POST the query to LLMWebSearch's https://llmwebsearch.com/api/v1/search. Return the numbered sources, with the top 3 pages as Markdown, as a function_call_output; the model answers and cites them. A search costs 1 credit, from $1.20/1K searches as of October 2026. No KYC; pay in crypto.
What you need
| Packages | openai and requests (or openai-agents for the Agents SDK version) |
|---|---|
| Keys | OPENAI_API_KEY for the model, LLMWEBSEARCH_API_KEY for search |
| Model | Any model that supports function calling, set in OPENAI_MODEL |
| API used | Responses API: client.responses.create with tools and input |
| Billing on our side | One credit pays for one search: up to 20 ranked results, with the pages of the first 3 read and returned as Markdown. Reading 4 to 10 pages makes it 2 credits. A call that ends in an error costs nothing |
What does the tool schema look like?
A function tool has a name, a description and JSON Schema parameters. Keep the description concrete: what the tool returns, and that sources should be cited by number. Two parameters are enough for most agents: the query, and how many top results to read in full.
The model never sees your API key. It only proposes arguments; your code decides how to call the API and what to send back.
How does the call loop work?
Send the user's message with the tools. If the response holds items of type function_call, parse each one's JSON arguments, run the search, and append a function_call_output with the same call_id. Then call the model again with the longer input list. When a response contains no function call, its text is the answer.
The loop below stops after five rounds, a guard against a model that keeps searching. OpenAI's function-calling guide describes the same function_call and function_call_output items, read on 22 October 2026.
import json
import os
import requests
from openai import OpenAI
client = OpenAI()
MODEL = os.environ["OPENAI_MODEL"] # any model that supports function calling
SEARCH = "https://llmwebsearch.com/api/v1/search"
tools = [
{
"type": "function",
"name": "search_web",
"description": "Search the live web. Returns numbered sources with URLs; the first `read` include page text. Cite sources as [1], [2].",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for, as a person would type it."},
"read": {"type": "integer", "minimum": 0, "maximum": 10, "description": "How many top results to read in full."},
},
"required": ["query"],
"additionalProperties": False,
},
}
]
def search_web(query, read=3):
resp = requests.post(
SEARCH,
headers={"Authorization": "Bearer " + os.environ["LLMWEBSEARCH_API_KEY"]},
json={"q": query, "max_results": 5, "read": read, "max_chars": 5000},
timeout=60,
)
if resp.status_code != 200:
return "Search failed: " + resp.json().get("error", {}).get("code", str(resp.status_code))
parts = []
for i, r in enumerate(resp.json()["results"], start=1):
text = r["content"] if r["content_status"] == "ok" else "(snippet only) " + r["snippet"]
parts.append(f"[{i}] {r['title']} - {r['url']}\n{text}")
return "\n\n".join(parts)
input_list = [{"role": "user", "content": "How should I size a Postgres connection pool?"}]
for _ in range(5):
response = client.responses.create(model=MODEL, tools=tools, input=input_list)
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
print(response.output_text)
break
input_list += response.output
for item in calls:
args = json.loads(item.arguments)
output = search_web(args["query"], int(args.get("read", 3)))
input_list.append({"type": "function_call_output", "call_id": item.call_id, "output": output})
Is there a shorter version with the Agents SDK?
Yes. The OpenAI Agents SDK turns a Python function into a tool with a decorator and runs the call loop for you. Its tools documentation, read on 22 October 2026, shows the @tool decorator from agents.decorators, with the description taken from the docstring and each argument's description from its Args section. The same page also mentions the @function_tool form.
Runner.run_sync runs the agent until it produces a final answer, calling search_web as often as the model asks.
import os
import requests
from agents import Agent, Runner
from agents.decorators import tool
@tool
def search_web(query: str, read: int = 3) -> str:
"""Search the live web and return numbered sources with their URLs.
Args:
query: What to search for, as a person would type it.
read: How many top results to read in full, from 0 to 10.
"""
resp = requests.post(
"https://llmwebsearch.com/api/v1/search",
headers={"Authorization": "Bearer " + os.environ["LLMWEBSEARCH_API_KEY"]},
json={"q": query, "max_results": 5, "read": max(0, min(read, 10)), "max_chars": 5000},
timeout=60,
)
resp.raise_for_status()
parts = []
for i, r in enumerate(resp.json()["results"], start=1):
text = r["content"] if r["content_status"] == "ok" else "(snippet only) " + r["snippet"]
parts.append(f"[{i}] {r['title']} - {r['url']}\n{text}")
return "\n\n".join(parts)
agent = Agent(
name="Researcher",
instructions="Answer from search_web results only and cite them as [1], [2].",
tools=[search_web],
)
result = Runner.run_sync(agent, "How should I size a Postgres connection pool?")
print(result.final_output)
Why use an API tool instead of OpenAI's built-in web search?
Mostly for control over what the model reads and what it costs. Our tool hands your code the raw sources, with URLs and page text, so you decide what enters the context, can log it and can store it. We do not train any model on your queries or on what the API returns, and we add no rule of our own against storing results; the rights of the sites whose pages you read still apply.
The price is also fixed per search instead of depending on how much retrieved content the model reads. The built-in tools comparison puts the per-call prices of OpenAI, Anthropic, Google and others side by side, with dates.
How do I stop the model from searching too much?
Say it in the instructions: search once, read the sources, search again only if they disagree or miss the point. Cap the number of rounds in your own loop, as the script above does with five.
Keep max_chars modest. Three pages of 5,000 characters is about 4,000 tokens per search, and each round adds them to the input of the next model call.
OpenAI function calling: common questions
Does this work with the Chat Completions API?
Yes, with the older tools format of Chat Completions, where the function sits inside a function object and results go back as tool messages. The search function itself does not change.
Can the model read a page it finds in a source?
Add a second function, read_urls, that posts URLs to https://llmwebsearch.com/api/v1/extract. Reading pages whose URLs you already have costs 1 credit for each started group of 3 pages returned with content, up to 20 URLs per call; refused or failed URLs are not counted.
Which model should I use?
Any model that supports function calling. Try two or three on your own questions: the answer depends on the sources as much as on the model, and a smaller model costs less per round.
Sources (2)
- Function calling — OpenAI API docs (read 2026-10-22)
- Tools (OpenAI Agents SDK) — OpenAI Agents SDK docs (read 2026-10-22)