HyphenHyphen
HomeConsole
  • Get Started
  • Connect your coding agent
  • Build with Hyphen
  • Chat UIs & automation
  • Recipes
  • Guides
  • API Reference
Streaming chatTool callingStructured JSONAgent loopHandling 429Long context
powered by Zudoku
Recipes

Tool / function calling

Tool calling works on both surfaces. On /v1/chat/completions you get OpenAI tool_calls. On /v1/messages you get Anthropic tool_use content blocks. Same model, two wire formats. Pick whichever your stack already speaks.

Use minimax-m3 for tool calling. Give it max_tokens of 4000 or more: the model reasons before it decides on a call, and that reasoning spends the same budget.

TerminalCode
export HYPHEN_API_KEY="sk-..."

OpenAI surface, Python

The full two-round trip. Round one the model asks for a tool, round two it answers with the result.

Code
import json import os from openai import OpenAI client = OpenAI( base_url="https://api.hyphen-solution.com/v1", api_key=os.environ["HYPHEN_API_KEY"], ) TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "Current temperature and conditions for a city.", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "City name, e.g. Kuala Lumpur"} }, "required": ["city"], }, }, } ] def get_weather(city: str) -> dict: # Your real implementation goes here. return {"city": city, "temp_c": 31, "conditions": "thunderstorms"} messages = [{"role": "user", "content": "What's the weather in Kuala Lumpur?"}] resp = client.chat.completions.create( model="minimax-m3", messages=messages, tools=TOOLS, max_tokens=4000, ) msg = resp.choices[0].message # Append the assistant turn verbatim, including the tool call IDs. entry = {"role": "assistant", "content": msg.content or ""} if msg.tool_calls: entry["tool_calls"] = [ { "id": c.id, "type": "function", "function": {"name": c.function.name, "arguments": c.function.arguments}, } for c in msg.tool_calls ] messages.append(entry) # Run each requested tool and append the result, matched by tool_call_id. for call in msg.tool_calls or []: args = json.loads(call.function.arguments) result = get_weather(**args) messages.append( { "role": "tool", "tool_call_id": call.id, "content": json.dumps(result), } ) final = client.chat.completions.create( model="minimax-m3", messages=messages, tools=TOOLS, max_tokens=4000, ) print(final.choices[0].message.content)

Three things that are easy to get wrong:

  • The assistant turn must go back into messages with its tool_calls intact. Drop it and the next request 400s on an orphaned tool result.
  • tool_call_id must match. The role: "tool" message is bound to a specific call by that ID.
  • content of a tool message is a string. Serialise your dict with json.dumps.

OpenAI surface, Node

Code
import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.hyphen-solution.com/v1", apiKey: process.env.HYPHEN_API_KEY, }); const tools = [ { type: "function", function: { name: "get_weather", description: "Current temperature and conditions for a city.", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"], }, }, }, ]; function getWeather({ city }) { return { city, temp_c: 31, conditions: "thunderstorms" }; } const messages = [{ role: "user", content: "What's the weather in Kuala Lumpur?" }]; const resp = await client.chat.completions.create({ model: "minimax-m3", messages, tools, max_tokens: 4000, }); const msg = resp.choices[0].message; messages.push(msg); for (const call of msg.tool_calls ?? []) { const args = JSON.parse(call.function.arguments); messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(getWeather(args)), }); } const final = await client.chat.completions.create({ model: "minimax-m3", messages, tools, max_tokens: 4000, }); console.log(final.choices[0].message.content);

In JavaScript you can push the SDK's message object straight back onto messages. It already has the right shape.

Anthropic surface, Python

Same job on /v1/messages. Note the differences: the base URL has no /v1, tools use input_schema instead of parameters, and results go back as a user turn containing tool_result blocks.

Code
import json import os from anthropic import Anthropic client = Anthropic( base_url="https://api.hyphen-solution.com", api_key=os.environ["HYPHEN_API_KEY"], ) TOOLS = [ { "name": "get_weather", "description": "Current temperature and conditions for a city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, } ] def get_weather(city: str) -> dict: return {"city": city, "temp_c": 31, "conditions": "thunderstorms"} messages = [{"role": "user", "content": "What's the weather in Kuala Lumpur?"}] resp = client.messages.create( model="minimax-m3", max_tokens=4000, tools=TOOLS, messages=messages, ) while resp.stop_reason == "tool_use": messages.append({"role": "assistant", "content": resp.content}) results = [] for block in resp.content: if block.type == "tool_use": output = get_weather(**block.input) results.append( { "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(output), } ) messages.append({"role": "user", "content": results}) resp = client.messages.create( model="minimax-m3", max_tokens=4000, tools=TOOLS, messages=messages, ) print("".join(b.text for b in resp.content if b.type == "text"))

The while loop handles chained calls. If the model wants a second tool after seeing the first result, it comes back with stop_reason == "tool_use" again and the loop runs another round.

Reading the response shape

Anthropic responses are a list of content blocks. A tool-calling turn looks like this:

Code
{ "id": "msg_...", "model": "minimax-m3", "role": "assistant", "stop_reason": "tool_use", "content": [ { "type": "text", "text": "Let me check that." }, { "type": "tool_use", "id": "toolu_...", "name": "get_weather", "input": { "city": "Kuala Lumpur" } } ] }

Never assume content[0] is the tool call. Filter by block.type.

You cannot force a tool call

This is the important limitation, and it is worth knowing before you design around it. The M-series models honour tool_choice: "auto" and decide for themselves. Every stronger form is accepted by the API and then ignored.

Tested against the gateway on 2026-07-26 with minimax-m2.7:

tool_choiceResult
"auto" (default)Works. The model calls the tool when the prompt calls for it.
"required"Ignored. The model answered in prose and made no call.
{"type": "function", "function": {"name": "..."}}Ignored. Same as above.
"none"Blocks calls for the turn.

So design for a model that chooses. Make the prompt state the job plainly, give the tool a clear description, and always handle the case where no call comes back.

Code
resp = client.chat.completions.create( model="minimax-m2.7", tools=tools, messages=[{"role": "user", "content": "What is the weather in Kuala Lumpur?"}], ) calls = resp.choices[0].message.tool_calls if not calls: # Normal, not exceptional. Re-prompt or fall back. ...

A specific prompt gets the call reliably. "What is the weather in Kuala Lumpur?" produced get_weather({"city": "Kuala Lumpur"}) on the first try, while "Hello, how are you today?" correctly produced no call at all.

Empty response instead of a tool call

Raise max_tokens. Reasoning happens before the tool call is emitted, so a tight budget can be spent entirely on thinking and return nothing. 4000 is a safe floor for tool work. See Choosing a model.

Related

  • A small agent loop: this pattern, in a loop, doing real work.
  • Structured JSON output: when you want data, not a tool call.
  • Streaming chat: tool calls stream too.
Last modified on July 28, 2026
Streaming chatStructured JSON
On this page
  • OpenAI surface, Python
  • OpenAI surface, Node
  • Anthropic surface, Python
  • Reading the response shape
  • You cannot force a tool call
  • Empty response instead of a tool call
  • Related
Javascript
JSON