Skip to Content
Tool Calling & Agents

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.

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_choiceBehaviour
"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 indexfunction.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_content back. Append role, content and tool_calls only.