API reference · v1
API documentation
ChromaLab is a gateway: you bring the client, it handles protocol translation, routing, context management and billing. Anthropic Messages is the primary surface because that is what most agent harnesses already speak. The OpenAI, Gemini and MARI shapes reach the same catalog with the same key.
https://chromalab.inkIntroduction
Every request goes through the same path: your key is looked up, the model is checked against the key's filters and your plan, the conversation is trimmed to fit the model's context window without splitting a tool call from its result, the request is sent upstream, and usage is recorded once the response completes. Nothing is stored between requests — chroma has no conversation identity, so each call carries its own full transcript.
- Quick start — set up a client with two environment variables and a key.
- Messages — the primary protocol.
- Request units — how a call is priced against a plan or the wallet.
Authentication
Keys are created on the API keys page and look like chroma-xxxxxxxxxxx-xxxxxxxxxxx. Every gateway endpoint requires one. Which header carries it depends on the protocol you are speaking, because each SDK sends its own — chroma accepts all three, so the client does not need to be patched.
| Header | Accepted on | Sent by |
|---|---|---|
Authorization: Bearer | Every endpoint | OpenAI SDKs, cURL, anything generic |
x-api-key | /v1/messages | The Anthropic SDK and Claude Code |
x-goog-api-key | /v1beta/models/* | The Google GenAI SDK |
curl https://chromalab.ink/v1/models \
-H "Authorization: Bearer chroma-xxxxxxxxxxx-xxxxxxxxxxx"Keep them out of client-side code and committed config. A leaked key spends your plan window. Rotate one by deleting it and creating a replacement — deletion takes effect on the next request.
A key can be given an expiry date, a model whitelist or blacklist, and a daily token budget. Those are enforced before the request leaves chroma — see per-key controls.
Quick start
Create an account, make a key, and point your client at the base URL. Nothing else changes: the model id, the message shape and the streaming format stay whatever your client already sends.
export ANTHROPIC_BASE_URL="https://chromalab.ink"
export ANTHROPIC_AUTH_TOKEN="chroma-xxxxxxxxxxx-xxxxxxxxxxx"
export ANTHROPIC_MODEL="anthropic/claude-sonnet-4.6"
claudefrom openai import OpenAI
client = OpenAI(
base_url="https://chromalab.ink/v1",
api_key="chroma-xxxxxxxxxxx-xxxxxxxxxxx",
)
completion = client.chat.completions.create(
model="deepseek/deepseek-v3.2",
messages=[{"role": "user", "content": "Summarise this diff."}],
)
print(completion.choices[0].message.content)import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'https://chromalab.ink/v1',
apiKey: process.env.CHROMA_API_KEY,
});
const stream = await client.chat.completions.create({
model: 'openai/gpt-5.1-codex',
messages: [{ role: 'user', content: 'Write a git pre-commit hook.' }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}curl https://chromalab.ink/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer chroma-xxxxxxxxxxx-xxxxxxxxxxx" \
-d '{
"model": "deepseek/deepseek-v3.2",
"messages": [{"role": "user", "content": "Hello!"}]
}'/v1 itself.Give it https://chromalab.ink with no path. OpenAI SDKs do not, so they get https://chromalab.ink/v1. Passing the wrong one produces a 404 on /v1/v1/messages.
Choosing a protocol
All five surfaces convert to the same internal request and reach the same catalog. They differ in how well they stream and how much of a tool loop survives the round trip. If you have a choice, use Messages.
| Endpoint | Format | Streaming | Tools |
|---|---|---|---|
POST /v1/messages | Anthropic Messages | Incremental | Full round trip |
POST /v1/chat/completions | OpenAI Chat Completions | Incremental | Full round trip |
POST /v1/responses | OpenAI Responses | Incremental | Plus web_search_preview |
POST /v1beta/models/* | Google Gemini | Buffered | Text only |
POST /mari/ | MARI (chroma-native) | Incremental | Full round trip |
Buffered means the upstream response is read to completion before chroma re-encodes it into that format, so the client sees one delayed burst rather than a live stream. Only Gemini is still on that path.
MessagesPOST /v1/messages
The Anthropic Messages API. This is the surface chroma optimises first, so pointing the Anthropic SDK or Claude Code at chroma reaches any catalog model, not only Anthropic ones. Three things this path does that a naive proxy does not:
tool_useandtool_resultcontent blocks round-trip intact in both directions, along withtoolsandtool_choice.- Tool-argument deltas stream as they arrive (
input_json_delta) rather than being buffered and re-encoded at the end. cache_controlmarkers on system and text blocks are forwarded upstream rather than stripped, so client-side prompt caching keeps working.
Request body
| Parameter | Type | Description |
|---|---|---|
modelrequired | string | Any catalog id, e.g. anthropic/claude-sonnet-4.6 |
messagesrequired | array | Turns with role and string or block content |
max_tokensrequired | integer | Output cap. A key's own maxOutputTokens lowers it further |
system | string | array | System prompt; block form accepts cache_control |
tools | array | Tool definitions, converted to OpenAI function tools upstream |
tool_choice | object | auto, any or a named tool |
temperature, top_p | number | Sampling controls, passed through |
stop_sequences | array | Stop strings |
stream | boolean | Emit Anthropic SSE events as the provider produces them |
Example
import anthropic
client = anthropic.Anthropic(
api_key="chroma-xxxxxxxxxxx-xxxxxxxxxxx",
base_url="https://chromalab.ink", # no /v1 — the SDK appends it
)
message = client.messages.create(
model="anthropic/claude-sonnet-4.6",
max_tokens=1024,
system="You are a terse code reviewer.",
messages=[{"role": "user", "content": "Why is this loop O(n^2)?"}],
)
print(message.content[0].text)curl https://chromalab.ink/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: chroma-xxxxxxxxxxx-xxxxxxxxxxx" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "anthropic/claude-sonnet-4.6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello!"}]
}'{
"model": "anthropic/claude-sonnet-4.6",
"max_tokens": 2048,
"tools": [{
"name": "read_file",
"description": "Read a file from the repo",
"input_schema": {
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}
}],
"tool_choice": { "type": "auto" },
"messages": [
{ "role": "user", "content": "What does src/index.js export?" },
{ "role": "assistant", "content": [
{ "type": "tool_use", "id": "toolu_01", "name": "read_file",
"input": { "path": "src/index.js" } }
]},
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "toolu_01",
"content": "module.exports = { start, stop };" }
]}
]
}{
"id": "msg_abc123",
"type": "message",
"role": "assistant",
"model": "anthropic/claude-sonnet-4.6",
"content": [{ "type": "text", "text": "It exports start and stop." }],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": { "input_tokens": 15, "output_tokens": 10 }
}Not every provider emits per-chunk usage, so a streamed response's prompt tokens are estimated from the outgoing messages. Buffered responses carry the provider's real numbers.
Chat CompletionsPOST /v1/chat/completions
The OpenAI-native surface. Anything that speaks the OpenAI SDK — most tooling, most plugins, most quick scripts — works here unchanged.
Request body
| Parameter | Type | Description |
|---|---|---|
modelrequired | string | Catalog id, e.g. openai/gpt-5.1-codex |
messagesrequired | array | system, user, assistant and tool turns |
tools, tool_choice | array, string | object | Function tools in OpenAI shape |
max_tokens | integer | Output cap |
temperature, top_p | number | Sampling controls |
stream | boolean | Emit OpenAI SSE chunks |
Example response
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1677652288,
"model": "openai/gpt-5.1-codex",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Done." },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 9, "completion_tokens": 12, "total_tokens": 21 }
}ResponsesPOST /v1/responses
The OpenAI Responses API, with its own incremental stream converter. Passing a web_search_preview tool routes the request through chroma's search mode — see web search for what that actually does.
| Parameter | Type | Description |
|---|---|---|
modelrequired | string | Catalog id |
inputrequired | string | array | A prompt, or an array of input items |
instructions | string | System / developer prompt |
tools | array | Pass {"type":"web_search_preview"} to enable search |
max_output_tokens | integer | Output cap |
stream | boolean | Emit Responses-format SSE events |
from openai import OpenAI
client = OpenAI(
base_url="https://chromalab.ink/v1",
api_key="chroma-xxxxxxxxxxx-xxxxxxxxxxx",
)
response = client.responses.create(
model="openai/gpt-5.4",
input="Rewrite this regex so it is readable.",
)
print(response.output[0].content[0].text)response = client.responses.create(
model="openai/gpt-5.4",
input="What changed in the latest Fastify release?",
tools=[{"type": "web_search_preview"}],
)
print(response.output[0].content[0].text){
"id": "resp_abc123",
"object": "response",
"created_at": 1700000000,
"status": "completed",
"model": "openai/gpt-5.4",
"output": [{
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": "..." }],
"status": "completed"
}],
"usage": { "input_tokens": 12, "output_tokens": 10, "total_tokens": 22 }
}GeminiPOST /v1beta/models/{model}:generateContent
The Google generateContent and streamGenerateContent shapes, so the official GenAI SDK works without code changes. This path is deprioritised: it is still buffered rather than incrementally streamed, and tool calls are not converted.
| Parameter | Type | Description |
|---|---|---|
contentsrequired | array | Turns with role (user/model) and parts |
systemInstruction | object | {"parts":[{"text":"..."}]} |
generationConfig.maxOutputTokens | integer | Output cap |
generationConfig.temperature, .topP | number | Sampling controls |
generationConfig.stopSequences | array | Stop strings |
curl https://chromalab.ink/v1beta/models/gemini-2.5-flash:generateContent \
-H "Content-Type: application/json" \
-H "x-goog-api-key: chroma-xxxxxxxxxxx-xxxxxxxxxxx" \
-d '{
"contents": [{ "role": "user", "parts": [{ "text": "Hello!" }] }],
"generationConfig": { "maxOutputTokens": 512 }
}'The slug in the URL is sent upstream as google/{model}, so use plain Gemini slugs such as gemini-2.5-flash or gemini-2.5-pro. Reaching a non-Google model needs one of the other endpoints.
MARIPOST /mari/
Chroma's own request shape. It exists because the four standard formats each drop something on conversion — MARI carries reasoning (cot), multimodal attachments and tool calls as first-class fields rather than as content-block conventions. It is optional; use it only if you are writing the client yourself and want that shape.
Request body
| Field | Type | Description |
|---|---|---|
modelrequired | string | Catalog id |
turnsrequired | array | Conversation turns, see below |
tools | object | Map of tool name to definition |
tool_choice | string | object | auto, none, required, or {"name":"..."} |
stream | boolean | Emit MARI SSE events |
Turn and response fields
| Field | Where | Description |
|---|---|---|
role | turn, response | user, assistant or tool; responses are always assistant |
content | turn, response | Text; null on a response carrying tool calls |
attachments | turn, response | {type, attachment} — image or audio, base64 |
tool_calls | assistant turn, response | {id, name, arguments} |
tool_call_id | tool turn | Which call this turn answers |
cot | response | Reasoning content, when the model emits any |
tokens_out | response | Completion tokens generated |
end_reason | response | stop, max_tokens or tool_calls |
curl https://chromalab.ink/mari/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer chroma-xxxxxxxxxxx-xxxxxxxxxxx" \
-d '{
"model": "openai/gpt-5.4",
"turns": [{ "role": "user", "content": "What can you do?" }]
}'{
"role": "assistant",
"content": "Quite a lot, given the right tools.",
"cot": null,
"tokens_out": 42,
"attachments": [],
"tool_calls": [],
"end_reason": "stop"
}data: {"role":"assistant","cot":null,"content":"Hello","tokens_out":0,"attachments":[],"tool_calls":[],"end_reason":null}
data: {"role":"assistant","cot":null,"content":"!","tokens_out":0,"attachments":[],"tool_calls":[],"end_reason":null}
data: {"role":"assistant","cot":null,"content":null,"tokens_out":12,"attachments":[],"tool_calls":[],"end_reason":"stop"}
data: [DONE]Content chunks arrive as plain data: events; tool-call chunks are preceded by event: tool_call_delta. The stream ends with data: [DONE].
List modelsGET /v1/models
Returns every id the gateway will accept, in OpenAI catalog shape. It needs no key and sends permissive CORS headers, so a browser can read it directly. Pricing, context length and the per-request unit price are not in this response — the models page shows those, refreshed from the upstream catalog every fifteen minutes.
curl https://chromalab.ink/v1/models{
"object": "list",
"data": [
{
"id": "anthropic/claude-sonnet-4.6",
"object": "model",
"owned_by": "openai",
"permission": [],
"created": 1677649963
}
]
}Tool calling
Tool definitions and tool results convert between all the tool-capable formats: an Anthropic tool_use block becomes an OpenAI tool_calls entry going out, and a tool_result block becomes one role: "tool" message per result coming back, because OpenAI requires that shape. Nothing is dropped in either direction.
Not every model in the catalog handles tools equally well. Each entry carries a tool_call_reliability tag of high or unverified. Treat high as plausible rather than certified — it reflects provider documentation, not a live test against an agent loop. A model that emits XML-shaped tool calls in plain content instead of the structured field is detected and counted, but not repaired; if a harness misbehaves on an unfamiliar model, that is the first thing to check.
Context and memory
Every request is trimmed to a safety ceiling before it goes upstream. The trim groups messages into tool-call and tool-result units first, so it can never split a call from its result at the boundary — a common way for a proxy to break an agent loop.
Past that floor, and only when the conversation still does not fit, a key can opt into more aggressive handling. Four tiers, selected by two per-key toggles:
| Tier | Selected by | What it adds |
|---|---|---|
| lite | default | The mandatory tool-aware trim, nothing else |
| standard | neither toggle | Tool-output compaction |
| deep | ragMode | Retrieval of previously compacted tool output |
| full | deepMemory | A task and repo state summary |
- Compaction truncates large, non-recent tool outputs in place, leaving the first 300 characters plus a
[compacted tool output, N chars omitted, ref:<hash>]marker. The two most recent tool turns are always left alone. - Retrieval reinjects the full cached text when your next turn refers back to a compacted output, either by echoing its
ref:marker or by matching a distinctive word in the visible head. This is lexical, not embedding-based, and it says so in the metrics rather than pretending otherwise. - Task state extracts file paths and shell commands from the tool calls already in context into a single
[Task state]system block. No extra model call, no extra latency.
The cache lives in the gateway process and only covers content resent in the current request's own history. There is no cross-request store, because chroma has no session identity — every call is stateless. Content your client has already dropped is gone.
Web search
Search is a tool, not an invisible prompt injection. When it is enabled, chroma adds two function tools — search and fetch — to the outbound request with tool_choice: "auto", executes whichever the model calls, and appends the results as ordinary tool messages. The search shows up in the model's transcript as a normal tool call, rather than being invisibly injected into the prompt.
Enable it per key (webSearch: "tool"), by passing a web_search_preview tool on Responses, or with a legacy :search suffix on the model name. The key setting wins if more than one applies.
The implementation scrapes DuckDuckGo's lite interface, fetches up to three result pages, strips scripts and page chrome, and truncates the combined text to 15,000 characters. Every fetch times out at 15 seconds. There is no paid search provider behind it, so treat it as best-effort: markup changes upstream can degrade results.
Per-key controls
Each key carries its own settings, edited on the API keys page. They are enforced at admission, before anything is sent upstream, so a blocked request costs nothing.
| Setting | Values | Effect |
|---|---|---|
| Model filter | none, whitelist, blacklist | Restricts which catalog ids this key may call |
| Expiry | date | Requests after it fail with api_key_expired |
| Token budget | disabled or a number | Daily token ceiling for this key, reset at UTC midnight. Suits bursty tool loops better than a request count |
| Max output tokens | integer | Caps max_tokens on /v1/messages regardless of what the client asks for |
| Tool choice enforcement | none, required | required forces tool_choice: "required" whenever the request carries tools |
| Web search | tool or off | Adds the search and fetch tools |
| Memory tier | ragMode, deepMemory | Selects the deep or full memory tier |
| Economy mode | none, light, medium, heavy | Reroutes every 20th / 10th / 5th turn to a cheaper fallback model. Off unless you turn it on |
Collected chats are stored without your account or key and used to train Lucidity models. Turn collection off on your Account page and nothing about later conversations is written down. The memory features work on the transcript inside the request you sent, not on a stored history.
Request units
A plan is sold in request units rather than requests, because the catalog spans a 200x price range and a flat request count would price a cheap model and a flagship the same. One unit buys a baseline model's worth of inference; a model costing N times the baseline costs N units.
The baseline is $0.15 per million tokens blended — input weighted three parts to output one, which is the conventional single-number quote and, on agent traffic where prompt tokens dominate, a conservative one. Prices come from the live catalog, refreshed every fifteen minutes, so the ladder moves with them rather than going stale.
| Blended price | Units per call | Notes |
|---|---|---|
| $0.00 / M | 0 | Free models draw a separate daily counter instead |
| $0.15 / M | 1× | The baseline. Cheaper models still cost one unit |
| $1.25 / M | 8× | At and above this, the memory pipeline treats a model as premium |
| $6.00 / M | 40× | Needs Plus or above to draw from a plan |
A model with no readable price is charged one unit, not zero — an unknown price is still a price. Meta-routing entries whose cost depends on where they route are treated the same way.
Sessions, windows and plans
Allowance is a rolling coding session, not a calendar day. A session opens on your first request and runs five hours; a longer weekly cap stops one account living inside back-to-back sessions. A UTC-midnight bucket counts agent traffic badly in both directions — a loop that starts at 22:00 gets two hours and then a cliff — which is why it is shaped this way.
| Plan | Price | Units / 5h session | Units / week | Model ceiling | Free-model calls |
|---|---|---|---|---|---|
| No plan | — | Wallet credits only | — | Any, if funded | 200 / day |
| Basic | $5 | 250 | 3,000 | 15× | 500 / day |
| Plus | $10 | 600 | 7,500 | 50× | Unlimited |
| Pro | $15 | 1,200 | 15,000 | No ceiling | Unlimited |
| Enterprise | $50 | 5,000 | 60,000 | No ceiling | Unlimited |
A model above your plan's ceiling is not blocked outright — the account can still pay for it from wallet credits, it just cannot draw it from the plan. Adding credits also raises the free-model limit from 200 to 500 calls a day. Full pricing lives on the plans page.
Errors
Errors come back in the shape the endpoint speaks: an OpenAI-style error object on the OpenAI paths, an Anthropic {"type":"error"} envelope on /v1/messages. The code field is the stable part — match on it rather than on the message text, which is written for humans and changes.
{
"error": {
"message": "Model foo/bar was not found. Check the model ID or use /v1/models.",
"type": "invalid_request_error",
"code": "model_not_found"
}
}| Status | Code | What to do |
|---|---|---|
| 401 | invalid_api_key | The key is missing, mistyped or deleted. Check the header the client sends |
| 403 | api_key_expired | Past the key's expiry date. Create a replacement |
| 403 | model_not_allowed, model_blocked | This key's filter excludes the model |
| 403 | account_banned | Contact support through feedback |
| 404 | model_not_found | Not a catalog id. Check GET /v1/models |
| 429 | rate_limit_exceeded | The free-model daily limit is spent. Wait, add credits, or take a plan |
| 429 | token_budget_exceeded | This key's own daily token budget is spent. Resets at UTC midnight |
| 402 | coding_window_exhausted | The session's units are gone. Wait for the window, or fund the wallet |
| 402 | plan_multiplier_locked | The model is above your plan's ceiling. Upgrade, or pay from credits |
| 402 | insufficient_credits | No plan and no funded wallet |
| 402 | spending_limit_reached | The account's daily spend ceiling is reached |
| 500 | api_error | Upstream or adapter failure. Retry; if it persists, send us the request |
Lab
Install Lab, sign in and start your coding agent from the Lab setup page. You can also open Lab directly from the dashboard sidebar.
Claude Code
Claude Code speaks Anthropic Messages, the path chroma optimises first. Setup is two environment variables and a model id.
Install the CLI
Shellnpm install -g @anthropic-ai/claude-codePoint it at chroma
Shellexport ANTHROPIC_BASE_URL="https://chromalab.ink" export ANTHROPIC_AUTH_TOKEN="chroma-xxxxxxxxxxx-xxxxxxxxxxx"Choose the models it uses
Any catalog id works, not only Anthropic ones. A cheap model for the background tasks keeps the unit spend down.
Shellexport ANTHROPIC_MODEL="anthropic/claude-sonnet-4.6" export ANTHROPIC_SMALL_FAST_MODEL="anthropic/claude-haiku-4.5" claude
Claude Code sets cache_control markers automatically. Chroma forwards them upstream instead of stripping them, so cache-hit pricing still applies on Anthropic-backed models.
Cursor and VS Code
Cursor's custom OpenAI setting, and most VS Code AI extensions, take a base URL and a key. In Cursor: Settings → Models → OpenAI API Key, enable Override OpenAI Base URL, then add the model ids you want by hand — Cursor does not read the catalog endpoint.
Base URL: https://chromalab.ink/v1
API key: chroma-xxxxxxxxxxx-xxxxxxxxxxx
Model: openai/gpt-5.1-codexOpenCode
OpenCode treats chroma as a custom OpenAI-compatible provider. Install it, then declare the provider in your config with the models you intend to use.
curl -fsSL https://opencode.ai/install | bash{
"provider": {
"chroma": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://chromalab.ink/v1",
"apiKey": "chroma-xxxxxxxxxxx-xxxxxxxxxxx"
},
"models": {
"anthropic/claude-sonnet-4.6": { "name": "Sonnet 4.6" },
"deepseek/deepseek-v3.2": { "name": "DeepSeek v3.2" }
}
}
}
}aider
aider routes through LiteLLM, so chroma is reached with the openai/ provider prefix on top of the catalog id. The doubled prefix is expected: the first names the protocol, the rest is the model.
export OPENAI_API_BASE="https://chromalab.ink/v1"
export OPENAI_API_KEY="chroma-xxxxxxxxxxx-xxxxxxxxxxx"
aider --model openai/anthropic/claude-sonnet-4.6Open WebUI
Add chroma as an OpenAI connection under Settings → Connections → OpenAI API. Open WebUI reads GET /v1/models, so the full catalog appears in the model picker on its own — expect a long list.
API Base URL: https://chromalab.ink/v1
API Key: chroma-xxxxxxxxxxx-xxxxxxxxxxxThe same two fields work for Ollama-style front ends and anything else that reads OPENAI_API_BASE and OPENAI_API_KEY from the environment.
SDKs and your own loop
Any official SDK works by changing the base URL. Pick the one matching the protocol you want: the Anthropic SDK for Messages, an OpenAI SDK for Chat Completions or Responses, the Google GenAI SDK for Gemini.
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'https://chromalab.ink/v1',
apiKey: process.env.CHROMA_API_KEY,
});
const models = await client.models.list();
console.log(models.data.length);import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({
baseURL: 'https://chromalab.ink',
apiKey: process.env.CHROMA_API_KEY,
});
const message = await client.messages.create({
model: 'anthropic/claude-sonnet-4.6',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Explain this stack trace.' }],
});import google.generativeai as genai
genai.configure(
api_key="chroma-xxxxxxxxxxx-xxxxxxxxxxx",
client_options={"api_endpoint": "chromalab.ink"},
transport="rest",
)
model = genai.GenerativeModel("gemini-2.5-flash")
print(model.generate_content("Explain WAL mode simply.").text)- Create a key — set its filters, budget and memory tier.
- Model catalog — pricing, context length and unit cost per model.
- Feedback — report a bug, with the request if you have it.