Tool Calling & Agents
Kimi K3 calls functions using the OpenAI tool-calling protocol, and the gateway passes tools,
tool_choice, tool_calls and role: "tool" messages through untouched. Any OpenAI-compatible
agent framework works against https://api.devotel.com/v1 unchanged.
The loop
You send tools with the request
A JSON Schema per function, so the model knows what it may call.
The model answers with tool_calls
finish_reason is tool_calls and message.content is usually null.
You run the function and append the result
Append the assistant message verbatim, then a role: "tool" message carrying the same
tool_call_id.
You call the API again
The model either answers (finish_reason: "stop") or requests more tools. Repeat until it stops.
What a tool call looks like
This is a real response from kimi-k3 for “What is the weather in Izmir right now? Use the tool.”:
{
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"reasoning_content": "The user wants current weather for Izmir. I have a get_weather tool...",
"tool_calls": [
{
"id": "chatcmpl-tool-9ef6b57934696a26",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"Izmir\", \"unit\": \"celsius\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": { "prompt_tokens": 200, "completion_tokens": 115, "total_tokens": 315 }
}function.arguments is a JSON string, not an object — json.loads / JSON.parse it. The
model can also get it wrong, so validate before use and return an error string as the tool result
rather than crashing; the model will usually correct itself on the next turn.
Complete agent loop
Both programs define get_weather, run the loop until the model stops, and print the final answer.
They are complete files — save, set DEVOTEL_API_KEY, run.
Python
pip install openai"""A complete Devotel AI agent loop with one tool."""
import json
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["DEVOTEL_API_KEY"],
base_url="https://api.devotel.com/v1",
)
MODEL = "kimi-k3"
MAX_TURNS = 10
# --- 1. The tool implementation ------------------------------------------------
def get_weather(city: str, unit: str = "celsius") -> dict:
"""Pretend weather service. Call a real API here."""
temp_c = 31
temp = temp_c if unit == "celsius" else round(temp_c * 9 / 5 + 32)
return {"city": city, "temperature": temp, "unit": unit, "condition": "sunny"}
TOOL_IMPLEMENTATIONS = {"get_weather": get_weather}
# --- 2. The schema the model sees ----------------------------------------------
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. Izmir"},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit",
},
},
"required": ["city"],
},
},
}
]
# --- 3. The loop ---------------------------------------------------------------
def run_agent(question: str) -> str:
messages = [{"role": "user", "content": question}]
for turn in range(MAX_TURNS):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
tool_choice="auto",
# No max_tokens: the cap would cover reasoning tokens too.
)
message = response.choices[0].message
# Append the assistant turn exactly as returned, minus the reasoning.
messages.append(
{
"role": "assistant",
"content": message.content,
**(
{"tool_calls": [tc.model_dump() for tc in message.tool_calls]}
if message.tool_calls
else {}
),
}
)
if not message.tool_calls:
return message.content or ""
for call in message.tool_calls:
name = call.function.name
implementation = TOOL_IMPLEMENTATIONS.get(name)
if implementation is None:
result = {"error": f"unknown tool: {name}"}
else:
try:
arguments = json.loads(call.function.arguments)
result = implementation(**arguments)
except Exception as error: # bad JSON, wrong arguments, tool failure
result = {"error": str(error)}
print(f"[turn {turn + 1}] {name}({call.function.arguments}) -> {result}")
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
}
)
return "Gave up: the model kept asking for tools."
if __name__ == "__main__":
print(run_agent("What is the weather in Izmir right now? Use the tool."))Running either one prints something like:
[turn 1] get_weather({"city": "Izmir", "unit": "celsius"}) -> {'city': 'Izmir', 'temperature': 31, 'unit': 'celsius', 'condition': 'sunny'}
The weather in Izmir right now is **31°C and sunny** ☀️Controlling when tools are used
tool_choice | Behaviour |
|---|---|
"auto" (default when tools is present) | The model decides |
"none" | Never call a tool; answer directly |
"required" | Must call some tool |
{"type":"function","function":{"name":"get_weather"}} | Must call that specific function |
Streaming tool calls
With stream: true, tool calls arrive as delta.tool_calls fragments that you assemble by
index — function.arguments comes in pieces:
{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"chatcmpl-tool-9ef6","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}
{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]}
{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Izmir\"}"}}]}}]}
{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}The OpenAI SDKs’ streaming helpers assemble this for you. If you parse it yourself, concatenate
arguments per index and only parse the JSON once finish_reason arrives.
Practical notes
- Cap your turns. A loop with no bound can spin. The examples stop after 10.
- Return errors as tool results. A failed tool should produce
{"error": "..."}rather than an exception, so the model can recover. - Reasoning tokens add up. Every turn re-reads the whole message list and thinks again. A four-turn agent run costs noticeably more than one completion — watch your usage dashboard .
- Do not echo
reasoning_contentback. Appendrole,contentandtool_callsonly.