AI models
One OpenAI-shaped endpoint in front of every provider. Bring your own keys and the spend stays on your account — we never mark them up.
What it is
AI models is one HTTP endpoint in front of every AI provider your app uses — OpenAI, Anthropic, Google and Ollama — speaking the OpenAI chat-completions shape, so one client library reaches all of them. It is bring-your-own-key: you store your provider keys against your app, we call the provider with your key, on your provider account, and add nothing to what they charge you.
Before you start
You need your app API key. It is shown once, when the app is created. If you no longer have it, open the Smart Services console → your app → Settings and use Rotate Key — the old key stops working immediately.
Send it on every request, in either header:
Authorization: Bearer <key>
x-api-key: <key>
Both are accepted. Authorization: Bearer wins if you send both.
Base URL: https://smart-services.io
The key is server-side only. It never belongs in browser code.
Two more things before the first call:
- The AI models service must be enabled on your app, or every endpoint here returns
403. - You must have added a provider key in the console (your app → AI models → Provider Keys). We hold no key for you by default, and a model whose provider you have not configured returns
403.
Your first call
A chat completion against OpenAI. The model field is provider:model — see Gotchas, this is the single most common mistake.
curl -X POST https://smart-services.io/api/v1/maas/chat/completions \
-H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"model": "openai:gpt-4o-mini",
"messages": [
{ "role": "user", "content": "Name the capital of Morocco. One word." }
]
}'
The same call with fetch:
const res = await fetch(
"https://smart-services.io/api/v1/maas/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "openai:gpt-4o-mini",
messages: [
{ role: "user", content: "Name the capital of Morocco. One word." },
],
}),
}
);
const data = await res.json();
console.log(data.choices[0].message.content);
The response:
{
"id": "chatcmpl_k2f9d1xq",
"object": "chat.completion",
"created": 1758182400,
"model": "openai:gpt-4o-mini",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Rabat" },
"finish_reason": "stop"
}
],
"meta": {
"provider": "openai",
"usage": { "prompt_tokens": 18, "completion_tokens": 2, "total_tokens": 20 },
"cost": 0.0000039,
"timing": {
"totalLatencyMs": 612,
"phases": {
"authMs": 11,
"bodyParseMs": 1,
"modelResolutionMs": 0,
"providerConfigMs": 8,
"ragQueryMs": 0,
"contextCompressionMs": 0,
"providerCallMs": 585,
"responseProcessingMs": 7
}
}
}
}
choices is OpenAI-shaped, so an existing OpenAI client reads it unchanged. meta is ours: which provider actually answered, the token counts we recorded, the computed cost of that call against your provider's published prices, and where the latency went.
The rest of the endpoints
| Method | Path | What it does |
|---|---|---|
| POST | /api/v1/maas/chat/completions | Chat completion. Set "stream": true for SSE. |
| GET | /api/v1/maas/models | Live model list, pulled from each provider you have configured. Add ?provider=openai for one. |
| GET | /api/v1/maas/providers | Which providers your app has configured, whether a key is present, and your preferred-model list. Makes no upstream calls. |
| POST | /api/v1/maas/embeddings | Embeddings from the platform's own model. No provider key needed. |
POST /api/v1/chat/completions is the same handler as /api/v1/maas/chat/completions; both paths work.
Tool calling
OpenAI's shape, so an agent loop written against any OpenAI-compatible client works unchanged. Send tools and optionally tool_choice ("auto" | "none" | "required" | {"type":"function","function":{"name":"..."}}):
{
"model": "ollama:kimi-k3",
"messages": [{"role": "user", "content": "What time is it in Tokyo?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_time",
"description": "Get the current time in a timezone.",
"parameters": {"type":"object","properties":{"timezone":{"type":"string"}},"required":["timezone"]}
}
}]
}
A turn that calls tools comes back with finish_reason: "tool_calls" and choices[0].message.tool_calls[], each {id, type:"function", function:{name, arguments}} where arguments is a JSON string. Run the tool, then send the assistant turn back unchanged followed by a tool turn:
{"role": "tool", "tool_call_id": "call_abc", "content": "{\"time\":\"2026-10-09T18:30:00+09:00\"}"}
Providers disagree about nearly every part of this — Ollama keys results by name and wants the arguments as an object, Anthropic carries them as content blocks keyed by tool_use_id — and we map all of it. You send and read OpenAI's shape whichever model you pick.
Two limits worth knowing before you build on it:
- Streaming does not carry tool-call deltas yet. A streamed request with
toolsstreams content only. Use"stream": falsefor the tool-calling turn. - Verified on Ollama (
kimi-k3,minimax-m3,glm-5.3). The Anthropic mapping is implemented but has not been exercised against a live key — treat it as unproven until you have run it.
Limits and errors
Errors are JSON with an error string, and sometimes a details field carrying the specifics:
{ "error": "No provider key configured for 'anthropic'. Configure it in App > AI models > Provider Keys." }
| Status | Means |
|---|---|
400 | Bad JSON, no model and no app default, or a model string that does not name its provider. |
401 | Missing or invalid API key. |
403 | AI models is not enabled on your app, or you have no key for that model's provider. |
500 | The provider call threw. error carries the provider's message. |
502 | Every candidate model failed. details lists each attempt and its error. |
There is no per-request rate limit on this endpoint and no token metering. AI models is a flat, untiered service: we do not resell tokens and take no cut. Your spend is with your provider, on your provider account, under your provider's own rate limits — when you get throttled, it is them, not us, and the provider's message comes back in error. What we record is a usage log per call so the console can show you token counts and computed cost. Current service state and tier for your app show in the console.
Gotchas
A model must name its provider. "gpt-4o-mini" is a 400. "openai:gpt-4o-mini" works. We used to guess the provider from the model name and it was wrong in both directions — gpt-oss is an open-weight family served by Ollama, not by OpenAI's API, and guessing sent it to an endpoint that had never heard of it. Everything after the first colon is the model id, so ollama:llama3.1:8b is correct and parses as you would expect. Known providers: openai, google, anthropic, ollama, ninjacat.
Don't hardcode model ids — ask /models. We keep no catalogue of model names. GET /api/v1/maas/models returns each provider's own live list, already prefixed as provider:model, ready to paste into the model field. When a provider is down, that provider's entry appears in an errors array alongside the data rather than silently shortening the list, so you can tell "down" from "has no models".
Your app's preferred models are silent fallbacks. If the model you ask for fails, we walk the rest of your app's preferred-models list in order before giving up. A 502 means every one of them failed — read details. And if your app has forceDefaultModel set, the model you send in the body is overridden by the app default; the model field in the response tells you what actually ran.
Fallback does not survive streaming. Once bytes are on the wire we are committed. Fallback only covers establishing the stream; a mid-stream provider failure surfaces as a stream error, not a retry on the next model.
meta.usage can be missing. Not every provider reports tokens on every path, and where usage is absent, cost is absent too. Treat both as optional. For streaming calls, usage only arrives at end-of-stream.
Structured output: set response_format AND say so in the prompt. On Ollama models the schema alone is not enough — these are reasoning models, and the schema does not override what the prompt appears to be asking for. Measured on 2026-10-09, same schema, eight calls per cell:
| model | prompt doesn't mention JSON | prompt says "as JSON" |
|---|---|---|
minimax-m3 | 4/8 | 8/8 |
glm-5.3 | 0/8 | 8/8 |
(kimi-k3 was reliable either way — 10/10 on a separate ten-call run.)
Two extra words in the prompt take the two weaker models from unusable to perfect. It costs you nothing, so do it on every structured-output call regardless of model.
You do not need to strip markdown fences: these models often wrap JSON in ```json, and we unwrap it for you when you asked for json_schema or json_object. A normal text completion that happens to contain a code fence is left alone.
Long prompts get trimmed by default, and we tell you. The budget is 128k tokens, minus ~8k reserved so the model has room to answer. Past that we drop the OLDEST turns and report it as meta.truncated — {droppedMessages, truncatedLastMessage, estimatedTokens, contextWindow}. If meta.truncated is absent, nothing was cut.
Send "strict": true to get a 400 instead of a trim. An agent loop should: a silently shortened history makes its next decision wrong in a way it cannot detect.
A single message larger than the whole budget is truncated at the end with a visible marker rather than dropped. It used to be dropped, which meant an empty 200 and no explanation.
If you would rather have the content compressed with a map-reduce pass — slower, and it makes extra provider calls billed to your account — send "contextCompression": true.
Embeddings are one model for everyone, on purpose. /api/v1/maas/embeddings serves the platform's own model and rejects any other model value, and rejects dimensions outright, rather than quietly returning off-model vectors. Mixed vectors in one corpus are unsearchable and the damage is invisible until you try. Store the returned model alongside every vector so a future model change is detectable.
Rotating your key is instant and total. There is no overlap window. The old key dies the moment the new one appears, so deploy the new one before you rotate, not after.