# 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.

```bash
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.

```python
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

```javascript
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.

```python
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:

```json
{
  "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_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.

```python
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](/choosing-a-model#the-max_tokens-gotcha).

## Related

- [A small agent loop](/recipes/agent-loop): this pattern, in a loop, doing real work.
- [Structured JSON output](/recipes/structured-output): when you want data, not a tool call.
- [Streaming chat](/recipes/streaming): tool calls stream too.
