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.
Base URL: https://api.mindshub.ai/v1. The same first call is below in curl, Python, and JavaScript. The two SDK tabs use the official OpenAI client: pip install openai or npm install openai.
- curl
- Python
- JavaScript
curl https://api.mindshub.ai/v1/chat/completions \
-H "Authorization: Bearer $MINDSHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "mindshub_air",
"messages": [
{"role": "system", "content": "You are a terse assistant."},
{"role": "user", "content": "What is the capital of Australia?"}
]
}'
from openai import OpenAI
import os
client = OpenAI(
base_url="https://api.mindshub.ai/v1",
api_key=os.environ["MINDSHUB_API_KEY"],
)
response = client.chat.completions.create(
model="mindshub_air",
messages=[
{"role": "system", "content": "You are a terse assistant."},
{"role": "user", "content": "What is the capital of Australia?"},
],
)
print(response.choices[0].message.content)
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.mindshub.ai/v1",
apiKey: process.env.MINDSHUB_API_KEY,
});
const response = await client.chat.completions.create({
model: "mindshub_air",
messages: [
{ role: "system", content: "You are a terse assistant." },
{ role: "user", content: "What is the capital of Australia?" },
],
});
console.log(response.choices[0].message.content);
Three notes on running these as they are:
- Set
MINDSHUB_API_KEYto a key from console.mindshub.ai. The API key is the only value you have to supply. - The samples call
mindshub_air, the alias your monthly included tokens cover, so they work on an account with no payment method on file. Change themodelstring to any alias in Models once you have a wallet balance; nothing else about the request changes. - The JavaScript sample uses top-level
await, so run it as an ES module: a.mjsfile, or"type": "module"in yourpackage.json.
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. |
response_format | object | no | Structured output: {"type": "json_schema", "json_schema": {...}}, {"type": "json_object"}, or {"type": "text"}. See Structured output. The one parameter that is rejected rather than dropped when a model can't honor it — prose handed to a caller who will json.loads() it is a different kind of answer, not a differently-flavored one. json_object is a 400 on the Claude family, which has no schema-less JSON mode. |
parallel_tool_calls | boolean | no | Honored, including on the Claude family, which spells it inverted. Gemini models can't express it and report it as dropped. |
top_k | integer | no | A MindsHub extension, not an OpenAI parameter. Forwarded where the model takes it; always dropped on the GPT and Grok families. |
seed | integer | no | Best-effort determinism, honored on gemini/gemini-flash and kimi and dropped elsewhere. Never a reproducibility guarantee even where it applies. |
presence_penalty, frequency_penalty | number | no | As seed. |
verbosity | string | no | "low", "medium", or "high" — how much prose to spend on an answer of the same substance, as distinct from reasoning_effort, which buys thinking. Honored on the GPT family; dropped elsewhere, including on grok. |
web_search_options | object | no | Enables web search, the same as sending {"type": "web_search"} in tools. Its search_context_size and user_location sub-options are dropped. See Built-in web search. |
functions, function_call | varies | no | OpenAI's pre-tools spelling, deprecated by OpenAI but still served by them and by us. See Legacy function calling. Send tools alongside either and the request is treated as modern. |
metadata | object | no | Accepted for OpenAI compatibility and ignored. Must be an object if present. |
Every parameter OpenAI defines is accepted, and any other top-level parameter is accepted and ignored — unknown parameters are tolerated for SDK compatibility, since coding agents and SDKs treat request rejections as hard errors. (This applies to top-level request fields; unknown nested content, like an unrecognized message part type, can reach the provider and fail there.)
What the API can't honor, it names in X-MindsHub-Dropped-Params rather than discarding silently:
| You send | What actually happens |
|---|---|
n | Always exactly one choice. Reported as dropped unless you sent 1. |
logprobs, top_logprobs, logit_bias, prediction, modalities, audio | Nothing, and reported as dropped. No model behind this endpoint can return log probabilities or accept a bias map. |
user, safety_identifier, service_tier, store, prompt_cache_key, prompt_cache_retention | Nothing, and not reported — these describe you or your account rather than the generation, so nothing about the answer changes. Requests are attributed by API key; caching is automatic. |
The full per-parameter contract lives in the codebase, in PARAM_SUPPORT in minds/requests/chat_completions_request.py, and is held to the OpenAI SDK's own parameter list by a test.
Message content
Roles are system, user, assistant, and tool. developer is accepted and read as system — it is OpenAI's newer spelling, recommended for reasoning models, and the two mean the same thing here.
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, so it changes with the model you asked for, and it changes again whenever we repoint an alias. The response above came from asonnetrequest. What each alias resolves to today, and what to key on instead, are both on the models page, which is the one place that mapping is stated.- 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.message.refusalcarries the model's stated reason when it declined, on the models whose provider gives one (the Claude and GPT families). It is absent or null — never an empty string — when the model refused without saying why, because those are different facts.finish_reasoniscontent_filtereither way.usage.completion_tokens_detailsis on every response, as it is on OpenAI's. Itsreasoning_tokensis a subset ofcompletion_tokens, not an addition to it, and is broken out by the GPT/Grok families and Gemini models. Elsewhere it reads0, which means no separately-reported reasoning rather than no reasoning: the Claude family bills thinking as output without separating it, so a Claude turn reports0however much it thought. Don't read0as "this model doesn't reason" — Models is the place that answers that.service_tieris always"default". There is one serving tier; the field exists so a client reading it gets a string rather than a missing key.- 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 MindsHub marks the provider's cache breakpoints on the stable prefix for you, and on the rest the provider caches implicitly. A request that sends its owncache_controlbreakpoints, as Claude Code does on Messages, is passed through untouched.
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. |
A 400 from a malformed request body comes back in the standard OpenAI error shape — {"error": {"message", "type", "param", "code"}}, with param naming the field — so client.chat.completions.create(...) raises BadRequestError. (Until 2026-09 it was a 422 carrying FastAPI's {"detail": [...]}, which raised UnprocessableEntityError and had no param to branch on.)
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 honored everywhere. On the Claude family it is sent as Anthropic's ownnone; onmindshub_air,deepseek,qwen,glm, andmuse-spark, whose upstreams have no word for it, it is honored by dropping thetoolsfrom that request instead — reported astool_choice=none>droppedinX-MindsHub-Clamped-Params. (Until 2026-09 it was silently ignored on the Claude family and the model could still call tools.) -
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. The rewrite is reported on
X-MindsHub-Clamped-Params, in the samename=requested>appliedform as any other clamp:tool_choice=required>auto,tool_choice=named>auto, ortool_choice=none>droppedwhen the tools were dropped instead. The values are category labels rather than your literaltool_choice, so a named choice reports asnamed. Non-forcing dict forms that restrict rather than force the callable set are never rewritten.
Legacy function calling
functions and function_call are OpenAI's original function-calling fields, deprecated in favour of tools/tool_choice — their own SDK marks them so. They still work on OpenAI, and they work here, in all four places OpenAI switches shape when you use them:
| You send / receive | |
|---|---|
| Request | functions: [{name, description, parameters}], function_call: "auto" | "none" | {"name": ...} |
| Response | message.function_call: {name, arguments}, finish_reason: "function_call" |
| Streaming | delta.function_call — the name on the opening chunk, then arguments fragments |
| Replay | the assistant turn with its function_call, then {"role": "function", "name": ..., "content": ...} |
Two limits worth knowing:
- One call per turn. The legacy field is a single object, so a model that wants two calls has the extras dropped. That limitation is the shape's own, and the reason OpenAI replaced it — use
toolsif you need parallel calls. - Sending
toolsalongsidefunctionsis read as a modern request and answered withtool_calls. If you want the legacy shape back, send only the legacy fields.
New code should use Tool calling above. These fields exist so existing code doesn't have to change.
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). - OpenAI's
web_search_optionsparameter does the same thing as a{"type": "web_search"}tool entry, so either spelling works. Itssearch_context_sizeanduser_locationsub-options have no cross-provider equivalent and are dropped.
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,deepseektakeslow,highandmax, andgpt-miniaddsnone). Read the model's own list rather than assuming a family shares one ladder:deepseeklistednoneuntil 2026-08-18, when the alias moved to DeepSeek-V4-Pro-0813, whose three levels do not include it. - Requests never fail over
reasoning_effort. A recognized level outside a model's ladder is clamped onto it: below the floor, includingnoneon a model without an off switch, you get the cheapest supported rung (which can cost more than the unsupported level you requested); above the ceiling you get the top rung; between rungs you get the lower one. Clamps are reported inX-MindsHub-Clamped-Params. Only an unrecognized value such as a typo is dropped and reported inX-MindsHub-Dropped-Params, letting the model's default apply. 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.