OpenAI function calling
Copy the schema, copy the dispatcher, done. No key to configure, because there is no key.
Tool schema
Descriptions here are written for the model, not for you — they tell it how to phrase a query and when a parameter is expensive. That is why they are long.
{
"name": "search_news",
"description": "Search worldwide news articles published in the last 30 days. Returns title, description, publisher, country, language, publication time and URL for each match. Use this whenever the user asks about current events, recent developments, or anything that may have changed since training. Free and keyless.",
"parameters": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Search phrase. All words must appear in the article, so keep it to 2-5 meaningful keywords. Prefer 'opec production cut' over 'what did OPEC decide about cutting oil production'."
},
"country": {
"type": "string",
"description": "Comma-separated ISO 3166-1 alpha-2 country codes of the publisher, uppercase. Example: 'UA' or 'DE,AT,CH'."
},
"lang": {
"type": "string",
"description": "Comma-separated ISO 639-1 language codes, lowercase. Example: 'en' or 'es,pt'."
},
"date": {
"type": "string",
"enum": ["today", "yesterday", "24h", "48h", "7d", "30d"],
"description": "Preset time window. Cheaper than an explicit date range; prefer it when the user says 'today', 'this week' and so on."
},
"sort": {
"type": "string",
"enum": ["date", "relevance", "crawled"],
"description": "'date' for the newest first, 'relevance' for the best textual match, 'crawled' when polling for newly ingested articles. Default 'date'."
},
"size": {
"type": "integer",
"description": "Number of articles to return, 1-100. Use 5-10 when answering a question, 50-100 when building a dataset. Default 20."
},
"full_text": {
"type": "boolean",
"description": "Include the full article body. Expensive: roughly 900 tokens per article. Leave false to scan headlines, then call get_article for the few that matter."
}
},
"required": ["q"]
}
}Second tool: the article body
{
"name": "get_article",
"description": "Fetch the full text of one news article by its exact URL, as returned in the 'url' field of a search_news result. Call this only for articles you have already decided are relevant \u2014 each body is roughly 900 tokens.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Exact article URL from a search_news result."
}
},
"required": ["url"]
}
}Third tool: counting without reading
{
"name": "news_stats",
"description": "Count news articles matching a query, broken down by country, language, domain zone, publisher and day. Use this to answer 'how much coverage', 'where is this being reported' or 'when did this start' without spending tokens on article text.",
"parameters": {
"type": "object",
"properties": {
"q": { "type": "string", "description": "Search phrase." },
"date": { "type": "string", "enum": ["today","yesterday","24h","48h","7d","30d"] },
"top": { "type": "integer", "description": "Buckets per facet, 1-100. Default 20." }
},
"required": ["q"]
}
}Dispatcher
import json, requests
from openai import OpenAI
BASE = "https://freenewsapi.ai"
client = OpenAI()
TOOLS = [json.load(open(f)) for f in
("search_news.json", "get_article.json", "news_stats.json")]
TOOLS = [{"type": "function", "function": t} for t in TOOLS]
def call_tool(name, args):
if name == "search_news":
r = requests.get(f"{BASE}/v1/search", params=args, timeout=20)
elif name == "get_article":
r = requests.get(f"{BASE}/v1/article", params=args, timeout=20)
elif name == "news_stats":
r = requests.get(f"{BASE}/v1/stats", params=args, timeout=30)
else:
return {"error": f"unknown tool {name}"}
if r.status_code == 429:
return {"error": "rate limited", "retry_after_seconds": 2}
if r.status_code >= 500:
return {"error": "news service unavailable, do not retry immediately"}
if r.status_code >= 400:
return {"error": r.json().get("detail", "bad request"), "retry": False}
return r.json()
def ask(question):
msgs = [{"role": "user", "content": question}]
while True:
resp = client.chat.completions.create(
model="gpt-4o", messages=msgs, tools=TOOLS,
)
m = resp.choices[0].message
msgs.append(m)
if not m.tool_calls:
return m.content
for tc in m.tool_calls:
result = call_tool(tc.function.name, json.loads(tc.function.arguments))
msgs.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result)[:60000],
})
print(ask("What happened with the chip export rules this week? Cite your sources."))Note the [:60000] truncation. A size=100 search with
full_text=true can exceed 100,000 tokens and blow the context window. Cap it
in the dispatcher rather than trusting the model to ask for a sensible size.
See token costs.
Three things that break this integration
Parallel tool calls arrive together
A model will happily issue three search_news calls in one message —
for instance one per country. The loop above handles that by iterating
m.tool_calls, but a naive implementation that reads only
tool_calls[0] will hang forever waiting for a turn that never completes,
because the API requires a result for every call id.
The model writes a question, not a query
Search is AND across all terms, so q=what happened with the election
matches nothing. Models default to conversational phrasing unless told otherwise, which is
why the parameter description above spells out the rule and gives a good and a bad example
inside the schema itself. Descriptions are the cheapest place to fix model behaviour: they
travel with the tool and cost nothing at call time.
Silence is read as evidence
An empty result means this corpus has no matching article. Models routinely conclude
that the event did not happen. Say otherwise in the system prompt, and consider returning
an explicit hint from your dispatcher when total is zero rather than an empty
list.
Pair it with a system prompt
A tool schema tells the model what it can do. A system prompt tells it when it should. Without one, models either call the news tool for arithmetic questions or never call it at all. See system prompts.