Chat completions
POST /v1/chat/completions is the main inference endpoint. It follows the OpenAI chat completions shape: send a model and a list of messages, get back a completion. The OpenAI SDKs work against it with only a base_url change.
curl https://api.mindshub.ai/v1/chat/completions \
-H "Authorization: Bearer $MINDSHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "sonnet",
"messages": [
{"role": "system", "content": "You are a terse assistant."},
{"role": "user", "content": "What is the capital of Australia?"}
]
}'
Request parameters
| Parameter | Type | Required | Notes |
|---|---|---|---|
model | string | yes | A model alias from Models. |
messages | array | yes | Conversation so far. Roles: system, user, assistant, tool. |
stream | boolean | no | Default false. See Streaming. |
max_tokens | integer | no | Output token cap. A value above the model's own ceiling is clamped down and reported in X-MindsHub-Clamped-Params; above the hard maximum 131,072 the request is rejected with 400 max_tokens_exceeded. If omitted, a default of 16,384 applies on the Claude family, mindshub_air, deepseek, qwen, glm, and muse-spark; other models use their provider's default. Two sizing notes: the value is reserved against your per-minute token budget up front, and models that reason internally need headroom (see Reasoning effort). |
max_completion_tokens | integer | no | OpenAI's newer spelling. If both are set to positive values, max_completion_tokens wins; a value of 0 is treated as unset. |
temperature, top_p, stop | varies | no | Handled per model: forwarded where the target model takes the parameter, dropped where it doesn't (named in X-MindsHub-Dropped-Params; stop is reported there as stop_sequences). A value the model restricts still comes back as that provider's 400: kimi takes temperature only at 1 and top_p only at 0.95, and gpt rejects top_p outright. opus, sonnet, fable, and the Gemini models drop temperature and top_p (stop still works on Claude); haiku forwards all three, and Anthropic rejects temperature and top_p together. When in doubt, omit sampling parameters. |
stream_options | object | no | {"include_usage": true} ends the stream with a usage-bearing chunk whose choices array is empty. See Streaming. |
tools | array | no | See Tool calling. |
tool_choice | string or object | no | "auto", "required", "none", or {"type": "function", "function": {"name": "..."}}. "none" is unreliable on some models, and a forced choice is rewritten rather than rejected on models that restrict it; see Tool calling. |
reasoning_effort | string | no | See Reasoning effort. Models that don't take it, or don't take your level, get it dropped or clamped rather than failing the request. |
metadata | object | no | Accepted for OpenAI compatibility and ignored. Must be an object if present. |
Any other top-level parameter is accepted and silently ignored. Unknown parameters are tolerated for SDK compatibility (coding agents and SDKs treat request rejections as hard errors), and the API does not warn when it ignores one. (This applies to top-level request fields; unknown nested content, like an unrecognized message part type, can reach the provider and fail there.) The ignored parameters that most often matter:
| You send | What actually happens |
|---|---|
response_format | No guaranteed JSON. Prompt for JSON and validate the output yourself. |
n | Always exactly one choice. |
seed, presence_penalty, frequency_penalty, logit_bias, logprobs, user, parallel_tool_calls | Nothing. |
Message content
content can be a plain string or an array of parts:
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR..."}}
]
}
Supported part types are text and image_url. For images:
urlcan be adata:URL (data:<media type>;base64,<data>) or a publichttp(s)URL, which the upstream provider fetches.- JPEG, PNG, GIF, and WebP are passed through. Other inline raster formats (
data:URLs) are transcoded to PNG server-side; remote URLs are handed to the provider unchanged. - A malformed data URL or invalid base64 returns
400.
Image parts are accepted on every chat model. The catalog doesn't yet flag which models have vision; if the underlying model can't process images, the upstream provider's error is relayed back to you.
The response
{
"id": "chatcmpl-636a4f9b-80c4-4989-a48d-0c9bed215ba7",
"object": "chat.completion",
"created": 1785401283,
"model": "claude-sonnet-5",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Canberra." },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 4,
"total_tokens": 16
}
}
Notes on the shape:
modelis the resolved provider ID, not your alias (why, and what to key on).- There is always exactly one choice.
message.contentisnull(not an empty string) when the model produced no text, for example when it only called tools.- When the request touched a provider's prompt cache, reads or writes,
usagegainsprompt_tokens_details:{"cached_tokens": ..., "cache_write_tokens": ...}. Don't treat the field's presence as "a cache read happened": writes alone populate it too.prompt_tokensalways includes cached tokens. (cache_write_tokensis a MindsHub extension to the OpenAI shape.) Caching is automatic on most of the catalog; Claude-family targets currently cache only when a request markscache_controlbreakpoints on Messages, which Claude Code does for you.
finish_reason
| Value | Meaning |
|---|---|
stop | The model finished normally. |
length | Output was truncated at max_tokens (or the model's context limit). |
tool_calls | The model stopped to call one or more tools. |
content_filter | The upstream provider refused to continue. |
null / absent | Abnormal end: the provider reported a failure or an unfinished turn. Treat as unsuccessful. |
Model-family differences to know:
- On the Claude family,
mindshub_air,deepseek,qwen,glm, andmuse-spark, a turn that was truncated and contains tool calls reportslength(truncation wins). On Gemini models it reportstool_calls. - Gemini models never report
nullorcontent_filter: refusals and abnormal ends collapse intostop. Don't build safety-refusal detection onfinish_reasonforgemini/gemini-flash. kimiresponses come through with the provider's own finish reasons, unmapped.
Streaming
Set "stream": true. The response is text/event-stream: a series of data: <json> lines, each carrying a chat.completion.chunk, terminated by data: [DONE].
data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"","role":"assistant"},"index":0}],"created":1785401212,"model":"claude-haiku-4-5-20251001","object":"chat.completion.chunk"}
data: {"id":"chatcmpl-...","choices":[{"delta":{"content":"Hello"},"index":0}],"created":1785401212,"model":"claude-haiku-4-5-20251001","object":"chat.completion.chunk"}
data: {"id":"chatcmpl-...","choices":[{"delta":{},"finish_reason":"stop","index":0}],"created":1785401212,"model":"claude-haiku-4-5-20251001","object":"chat.completion.chunk"}
data: [DONE]
Five differences from OpenAI's stream that your client has to handle:
- Keys with null values are omitted rather than sent as
null. Non-terminal chunks have nofinish_reasonkey at all. - Usage arrives on request. Send
stream_options: {"include_usage": true}and the stream ends with a usage-bearing chunk whosechoicesarray is empty, so guard onchunk.choicesbeing non-empty.kimistreams end with that chunk even without asking, and may carry extra provider fields (system_fingerprint, usage nested in the finish chunk); don't use a strict parser that rejects unknown fields. - Tool-call streaming varies by model family. OpenAI- and Claude-family models stream a tool call as an opening delta (with
id,type, and the function name) followed by argument fragments. Gemini models deliver each tool call as a single chunk carrying the complete arguments. - Errors before the first token are plain JSON. If the request fails before generation starts, you get an ordinary JSON error response with an error status, not an SSE stream, even though you asked for
stream: true. - A mid-stream error closes the stream without an SSE error event. A stream that ends without a
finish_reasonchunk was a failed generation, except on Gemini models, which always send one; see Errors → Streaming failures for the full detection rules.
Tool calling
Declare tools the OpenAI way:
{
"model": "sonnet",
"messages": [{"role": "user", "content": "What's the weather in Paris?"}],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}
]
}
When the model calls a tool, the response has finish_reason: "tool_calls" and the assistant message carries the calls:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "toolu_01V9g8n42TGjbfJmvecVydN3",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}
}
]
}
Run the tool, then call the API again with the assistant turn you received, followed by one tool message per call, and the same tools array as the first request:
{
"model": "sonnet",
"messages": [
{"role": "user", "content": "What's the weather in Paris?"},
{"role": "assistant", "content": null, "tool_calls": [
{"id": "toolu_01V9g8n42TGjbfJmvecVydN3", "type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}
]},
{"role": "tool", "tool_call_id": "toolu_01V9g8n42TGjbfJmvecVydN3", "content": "18°C, clear"}
],
"tools": [
{"type": "function", "function": {"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}
]
}
Notes:
-
A single response can contain multiple tool calls. Execute them all and return one
toolmessage per call. -
Echo back
tool_calls[].idvalues exactly as you received them. On Gemini models the IDs carry provider state, and a mismatched ID fails the request. -
Use
role: "tool"for results. (role: "function"is accepted for backward compatibility but not handled as a tool result; don't use it.) -
tool_choice: "none"is ignored on the Claude family,mindshub_air,deepseek,qwen, andglm: the model may still call tools. It works on the GPT family,gemini/gemini-flash,grok,kimi, andmuse-spark. If you need tools off for a turn everywhere, omittoolsfrom that request instead. -
A forced
tool_choicea model can't accept is rewritten rather than rejected. Two models restrict it today, and on both the request is served instead of returning the provider's 400:Model What it restricts What we send instead kimiA named choice ( {"type": "function", …}) 400s while the model's thinking is on, which is the provider default."required"works.Named → "auto"muse-sparkOnly "auto"is accepted; named,"required", and"none"all 400.Named and "required"→"auto";"none"honored by droppingtoolsThe practical consequence: on these two models a forced choice becomes a strong hint, so the model may answer in prose instead of calling your tool. Keep the "call the tool first" instruction in your prompt, and validate that you got a tool call before relying on one. Unlike dropped and clamped parameters, this rewrite is not reported in a response header, so there is no wire signal — treat the table above as the contract. Non-forcing dict forms that restrict rather than force the callable set are never rewritten.
Built-in web search
Two special tool types ask the platform to give the model web access, on models where it's available:
{
"model": "sonnet",
"messages": [{"role": "user", "content": "What changed in the EU AI Act this month?"}],
"tools": [{"type": "web_search"}, {"type": "fetch"}]
}
web_searchlets the model run web searches;fetchlets it retrieve a URL.- On models where search isn't available, the tool entries are dropped silently and the model answers from its own knowledge.
- Searches carry a per-search charge on top of tokens; see Billing.
- When your
toolsarray contains only web tools, anytool_choiceyou send is ignored (forced tool choice can't be applied to server-side search).
Reasoning effort
Models whose catalog entry lists reasoning_efforts accept a reasoning_effort string:
{
"model": "deepseek",
"messages": [{"role": "user", "content": "Prove that √2 is irrational."}],
"reasoning_effort": "high"
}
- The valid levels per model are in
GET /v1/models; they vary (for examplesonnetsupportslowthroughmax,deepseekaddsnone). - Requests never fail over
reasoning_effort. A recognized level above a model's ladder is clamped down into it and reported inX-MindsHub-Clamped-Params. A level the ladder can't place (noneon models without an off switch, or a typo) is dropped, reported inX-MindsHub-Dropped-Params, and the model's default applies, so send a listed level if cost matters. On models withreasoning_efforts: nullthe drop currently comes with no header. - If you send nothing, the model's
default_reasoning_effortapplies, which for most reasoning models is not "off". Send the lowest listed level explicitly if you want speed over depth. - Reasoning content is never returned; you get the final answer. Reasoning tokens are billed as output tokens.
- Some models reason internally even though the level isn't adjustable (
mindshub_air,kimi). Budgetmax_tokensgenerously for them (a few hundred tokens of headroom) or the reasoning uses the cap and the visible answer arrives truncated withfinish_reason: "length". More in Models → Reasoning effort.
What surprises people
- Funding is checked before the model runs. An empty wallet or exhausted allowance refuses the request up front (
402or429) at no cost; admission checks your balance, not the request's estimated size. See Billing. - There is no explicit failover indicator. During a provider outage the platform may retry or serve your request through a fallback route. The resolved
modelin the response may reveal a changed route, but don't treat it as a reliable failover signal.
The same models and features are available on POST /v1/responses, the shape OpenAI Codex speaks, and on Messages for the Anthropic ecosystem. Choosing an API compares the three.