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.
Code
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 jsonimport osfrom openai import OpenAIclient = 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 jsonimport osfrom anthropic import Anthropicclient = 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:
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_choice
Result
"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_callsif 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.