Quickstart
Point the OpenAI or Anthropic SDK you already use at the router and make a first call.
Use this page to send your first request through the router with the SDK you already have. The router speaks three wire formats: the OpenAI chat completions format, the OpenAI responses format and the Anthropic messages format, so you change only the base URL and the key.
Get a relay key
A relay key is the API key the router issues to your workspace; every request carries it as a bearer token. Today a signed-in workspace creates one on the Keys page of the console. Signing up and receiving a key in one flow is not available yet: join the waiting list and we send you access. See Authentication for scopes and key management.
Set the base URL
Use https://api.massrelay.ai/v1 for production. Staging is https://relay-stag.massrelay.ai/v1.
Make your first request
The model field is optional. When it is absent, the key's routing picks the model. To choose one yourself, list the models your key may use with GET /v1/models (see Models).
curl https://api.massrelay.ai/v1/chat/completions \
-H "Authorization: Bearer $RELAY_KEY" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Say hello in one sentence."}]}'from openai import OpenAI
client = OpenAI(base_url="https://api.massrelay.ai/v1", api_key="YOUR_RELAY_KEY")
reply = client.chat.completions.create(
model="MODEL_ID_FROM_GET_V1_MODELS",
messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
print(reply.choices[0].message.content)import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.massrelay.ai/v1",
apiKey: process.env.RELAY_KEY,
});
const reply = await client.chat.completions.create({
model: "MODEL_ID_FROM_GET_V1_MODELS",
messages: [{ role: "user", content: "Say hello in one sentence." }],
});
console.log(reply.choices[0].message.content);Every answer carries an id of the form chatcmpl-<decisionId>. Keep it: the explain endpoint reads it back (see Routing).
POST /v1/responses for code written against the responses format.Use the Anthropic SDK
The Anthropic SDK appends /v1/messages itself, so its base URL has no /v1.
import anthropic
client = anthropic.Anthropic(base_url="https://api.massrelay.ai", api_key="YOUR_RELAY_KEY")
msg = client.messages.create(
model="MODEL_ID_FROM_GET_V1_MODELS",
max_tokens=256,
messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
print(msg.content[0].text)import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://api.massrelay.ai",
apiKey: process.env.RELAY_KEY,
});
const msg = await client.messages.create({
model: "MODEL_ID_FROM_GET_V1_MODELS",
max_tokens: 256,
messages: [{ role: "user", content: "Say hello in one sentence." }],
});
console.log(msg.content[0].type === "text" ? msg.content[0].text : "");Stream the answer
Set stream to true on any of the three endpoints. The answer is then a server-sent event stream in the format of that endpoint.
Before you ship
- Check your key with
GET /v1/key: it reads back the key's scopes and spend. See the reference. - Handle errors. Every error has the same JSON shape; a 429 carries
Retry-After, so wait that long and retry. See Errors and Limits. - Watch for changes. Changes to the API are listed in the Changelog.
API reference
POST /v1/chat/completions
Authentication relay key (bearer)
Request body
| Field | Type | Description |
|---|---|---|
model | string | A model id or an alias from GET /v1/models. Optional: the key's routing decides when absent. |
messages required | array of object | Required and non-empty; an empty or missing list is a 400. |
messages[].role | string | Who wrote the message: system, user, assistant or tool. |
messages[].content | string or array | Text; the gateway reads the text of a message. |
max_completion_tokens | number | Upper bound on the generated tokens, the newer OpenAI name for max_tokens. Send one of the two. |
max_tokens | number | Upper bound on the tokens the model may generate for this answer. Send this or max_completion_tokens, not both. |
temperature | number | Sampling temperature: lower is more deterministic, higher more varied. Usually between 0 and 2. |
stream | boolean | Default false. |
tools | array | Carried and forwarded verbatim upstream; never validated here. |
response_format | object | Carried and forwarded verbatim upstream. |
reasoning_effort | string | The o-series field; read as the request's effort. |
Example request
curl -X POST https://api.massrelay.ai/v1/chat/completions \
-H "Authorization: Bearer $RELAY_KEY" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Say hello in one sentence."}]}'import os, requests
r = requests.post(
"https://api.massrelay.ai/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['RELAY_KEY']}"},
json={
"messages": [
{
"role": "user",
"content": "Say hello in one sentence."
}
]
},
)
r.raise_for_status()
print(r.json())const r = await fetch("https://api.massrelay.ai/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RELAY_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({"messages":[{"role":"user","content":"Say hello in one sentence."}]}),
});
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
console.log(await r.json());Response
| Field | Type | Description |
|---|---|---|
id required | string | chatcmpl-<decisionId>; the decision id is the key of GET /v1/route/explain/{decisionId}. |
object required | string | The kind of object returned, for example chat.completion. Always "chat.completion". |
created required | integer | When the answer was created, in Unix seconds. |
model required | string | The model id. In a request it is optional: without it the key's routing chooses. |
choices required | array of object | The generated answers; one unless n asks for more. |
choices[].index | integer | The position of this choice in the list. |
choices[].message | object | The generated message: its role and content. |
choices[].message.role | string | Who wrote the message: system, user, assistant or tool. Always "assistant". |
choices[].message.content | string | The text of the message. |
choices[].finish_reason | string | Why generation stopped, for example stop or length. |
usage required | object | Tokens counted for this call. |
usage.prompt_tokens | number | Tokens in the input. |
usage.completion_tokens | number | Tokens generated. |
usage.total_tokens | number | Input plus generated tokens. |
{
"id": "chatcmpl-1f2e3d4c",
"object": "chat.completion",
"created": 1761400000,
"model": "provider/model-id",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello there."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 6,
"total_tokens": 18
}
}Common errors
401invalid_api_key400the body is not usable402out of credit or over a budget413the body is over the limit403insufficient_scope429over the request rate; read Retry-After
Every error has the same JSON shape; see Errors and Limits.
Full schema (JSON)
Request schema
{
"description": "Every other field is accepted and not read (for example n, stop, top_p, seed are ignored). Fields the gateway does not read are not documented as honoured.",
"type": "object",
"required": [
"messages"
],
"additionalProperties": true,
"properties": {
"model": {
"type": "string",
"description": "A model id or an alias from GET /v1/models. Optional: the key's routing decides when absent."
},
"messages": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"role": {
"type": "string"
},
"content": {
"description": "Text; the gateway reads the text of a message.",
"type": [
"string",
"array"
]
}
}
},
"description": "Required and non-empty; an empty or missing list is a 400."
},
"max_completion_tokens": {
"type": "number"
},
"max_tokens": {
"type": "number"
},
"temperature": {
"type": "number"
},
"stream": {
"type": "boolean",
"description": "Default false."
},
"tools": {
"description": "Carried and forwarded verbatim upstream; never validated here.",
"type": "array"
},
"response_format": {
"description": "Carried and forwarded verbatim upstream.",
"type": "object",
"additionalProperties": true
},
"reasoning_effort": {
"type": "string",
"description": "The o-series field; read as the request's effort."
}
}
}Response schema
{
"description": "Every other field is accepted and not read (streaming responses are server-sent events and are not described here). Fields the gateway does not read are not documented as honoured.",
"type": "object",
"additionalProperties": true,
"required": [
"id",
"object",
"created",
"model",
"choices",
"usage"
],
"properties": {
"id": {
"type": "string",
"description": "chatcmpl-<decisionId>; the decision id is the key of GET /v1/route/explain/{decisionId}."
},
"object": {
"const": "chat.completion"
},
"created": {
"type": "integer"
},
"model": {
"type": "string"
},
"choices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"index": {
"type": "integer"
},
"message": {
"type": "object",
"properties": {
"role": {
"const": "assistant"
},
"content": {
"type": "string"
}
}
},
"finish_reason": {
"type": "string"
}
}
}
},
"usage": {
"type": "object",
"properties": {
"prompt_tokens": {
"type": "number"
},
"completion_tokens": {
"type": "number"
},
"total_tokens": {
"type": "number"
}
}
}
}
}Every answer carries a decision id; the explain endpoint returns why that model was chosen.
POST /v1/responses
Authentication relay key (bearer)
Request body
| Field | Type | Description |
|---|---|---|
model | string | The model id. In a request it is optional: without it the key's routing chooses. |
input required | string or array | Required: a string or a list of {role?, content} items (role defaults to user). |
instructions | string | Becomes a leading system message; blank text contributes nothing. |
max_output_tokens | number | |
temperature | number | Sampling temperature: lower is more deterministic, higher more varied. Usually between 0 and 2. |
stream | boolean | |
reasoning | object | Only reasoning.effort is read. |
reasoning.effort | string |
Example request
curl -X POST https://api.massrelay.ai/v1/responses \
-H "Authorization: Bearer $RELAY_KEY" \
-H "Content-Type: application/json" \
-d '{"input":"Say hello in one sentence."}'import os, requests
r = requests.post(
"https://api.massrelay.ai/v1/responses",
headers={"Authorization": f"Bearer {os.environ['RELAY_KEY']}"},
json={
"input": "Say hello in one sentence."
},
)
r.raise_for_status()
print(r.json())const r = await fetch("https://api.massrelay.ai/v1/responses", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RELAY_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({"input":"Say hello in one sentence."}),
});
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
console.log(await r.json());Response
| Field | Type | Description |
|---|---|---|
id required | string | resp_<decisionId> |
object required | string | The kind of object returned, for example chat.completion. Always "response". |
created_at | integer | |
model | string | The model id. In a request it is optional: without it the key's routing chooses. |
status required | string | Always "completed". |
output required | array of object | |
output[].type | string | Always "message". |
output[].id | string | The answer's id. |
output[].status | string | |
output[].role | string | Who wrote the message: system, user, assistant or tool. Always "assistant". |
output[].content | array of object | The text of the message. |
output[].content[].type | string | Always "output_text". |
output[].content[].text | string | |
output[].content[].annotations | array | |
output_text required | string | |
usage required | object | Tokens counted for this call. |
usage.input_tokens | number | Tokens in the input. |
usage.output_tokens | number | Tokens generated. |
usage.total_tokens | number | Input plus generated tokens. |
{
"id": "resp_1f2e3d4c",
"object": "response",
"created_at": 1761400000,
"model": "provider/model-id",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "Hello there."
}
]
}
],
"output_text": "Hello there.",
"usage": {
"input_tokens": 12,
"output_tokens": 6,
"total_tokens": 18
}
}Common errors
401invalid_api_key400the body is not usable402out of credit or over a budget413the body is over the limit403insufficient_scope429over the request rate; read Retry-After
Every error has the same JSON shape; see Errors and Limits.
Full schema (JSON)
Request schema
{
"description": "Every other field is accepted and not read (tools and previous_response_id are not read on this surface). Fields the gateway does not read are not documented as honoured.",
"type": "object",
"required": [
"input"
],
"additionalProperties": true,
"properties": {
"model": {
"type": "string"
},
"input": {
"description": "Required: a string or a list of {role?, content} items (role defaults to user).",
"type": [
"string",
"array"
]
},
"instructions": {
"type": "string",
"description": "Becomes a leading system message; blank text contributes nothing."
},
"max_output_tokens": {
"type": "number"
},
"temperature": {
"type": "number"
},
"stream": {
"type": "boolean"
},
"reasoning": {
"type": "object",
"properties": {
"effort": {
"type": "string"
}
},
"additionalProperties": true,
"description": "Only reasoning.effort is read."
}
}
}Response schema
{
"description": "Every other field is accepted and not read (streaming is not described here). Fields the gateway does not read are not documented as honoured.",
"type": "object",
"additionalProperties": true,
"required": [
"id",
"object",
"status",
"output",
"output_text",
"usage"
],
"properties": {
"id": {
"type": "string",
"description": "resp_<decisionId>"
},
"object": {
"const": "response"
},
"created_at": {
"type": "integer"
},
"model": {
"type": "string"
},
"status": {
"const": "completed"
},
"output": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"const": "message"
},
"id": {
"type": "string"
},
"status": {
"type": "string"
},
"role": {
"const": "assistant"
},
"content": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"const": "output_text"
},
"text": {
"type": "string"
},
"annotations": {
"type": "array"
}
}
}
}
}
}
},
"output_text": {
"type": "string"
},
"usage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "number"
},
"output_tokens": {
"type": "number"
},
"total_tokens": {
"type": "number"
}
}
}
}
}Every answer carries a decision id; the explain endpoint returns why that model was chosen.
POST /v1/messages
Authentication relay key (bearer)
Request body
| Field | Type | Description |
|---|---|---|
model | string | The model id. In a request it is optional: without it the key's routing chooses. |
messages required | array of object | |
messages[].role | string | Who wrote the message: system, user, assistant or tool. |
messages[].content | string or array | The text of the message. |
system | string or array | A string, or a list of {type:text,text} blocks (joined). An empty list contributes nothing. |
max_tokens | number | Upper bound on the tokens the model may generate for this answer. Send this or max_completion_tokens, not both. |
temperature | number | Sampling temperature: lower is more deterministic, higher more varied. Usually between 0 and 2. |
stream | boolean | |
thinking | string or object | This gateway reads a plain effort string, or an object with an effort field; it does not translate Anthropic's budget_tokens block. |
Example request
curl -X POST https://api.massrelay.ai/v1/messages \
-H "Authorization: Bearer $RELAY_KEY" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Say hello in one sentence."}],"max_tokens":256}'import os, requests
r = requests.post(
"https://api.massrelay.ai/v1/messages",
headers={"Authorization": f"Bearer {os.environ['RELAY_KEY']}"},
json={
"messages": [
{
"role": "user",
"content": "Say hello in one sentence."
}
],
"max_tokens": 256
},
)
r.raise_for_status()
print(r.json())const r = await fetch("https://api.massrelay.ai/v1/messages", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RELAY_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({"messages":[{"role":"user","content":"Say hello in one sentence."}],"max_tokens":256}),
});
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
console.log(await r.json());Response
| Field | Type | Description |
|---|---|---|
id required | string | msg_<decisionId> |
type required | string | Always "message". |
role required | string | Who wrote the message: system, user, assistant or tool. Always "assistant". |
model required | string | The model id. In a request it is optional: without it the key's routing chooses. |
content required | array of object | The text of the message. |
content[].type | string | Always "text". |
content[].text | string | |
stop_reason required | string | |
stop_sequence | null | |
usage required | object | Tokens counted for this call. |
usage.input_tokens | number | Tokens in the input. |
usage.output_tokens | number | Tokens generated. |
{
"id": "msg_1f2e3d4c",
"type": "message",
"role": "assistant",
"model": "provider/model-id",
"content": [
{
"type": "text",
"text": "Hello there."
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 12,
"output_tokens": 6
}
}Common errors
401invalid_api_key400the body is not usable402out of credit or over a budget413the body is over the limit403insufficient_scope429over the request rate; read Retry-After
Every error has the same JSON shape; see Errors and Limits.
Full schema (JSON)
Request schema
{
"description": "Every other field is accepted and not read (for example top_p, top_k, stop_sequences, tools are not read on this surface). Fields the gateway does not read are not documented as honoured.",
"type": "object",
"required": [
"messages"
],
"additionalProperties": true,
"properties": {
"model": {
"type": "string"
},
"messages": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"role": {
"type": "string"
},
"content": {
"type": [
"string",
"array"
]
}
}
}
},
"system": {
"description": "A string, or a list of {type:text,text} blocks (joined). An empty list contributes nothing.",
"type": [
"string",
"array"
]
},
"max_tokens": {
"type": "number"
},
"temperature": {
"type": "number"
},
"stream": {
"type": "boolean"
},
"thinking": {
"description": "This gateway reads a plain effort string, or an object with an effort field; it does not translate Anthropic's budget_tokens block.",
"type": [
"string",
"object"
]
}
}
}Response schema
{
"description": "Every other field is accepted and not read (streaming is not described here). Fields the gateway does not read are not documented as honoured.",
"type": "object",
"additionalProperties": true,
"required": [
"id",
"type",
"role",
"model",
"content",
"stop_reason",
"usage"
],
"properties": {
"id": {
"type": "string",
"description": "msg_<decisionId>"
},
"type": {
"const": "message"
},
"role": {
"const": "assistant"
},
"model": {
"type": "string"
},
"content": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"const": "text"
},
"text": {
"type": "string"
}
}
}
},
"stop_reason": {
"type": "string"
},
"stop_sequence": {
"type": "null"
},
"usage": {
"type": "object",
"properties": {
"input_tokens": {
"type": "number"
},
"output_tokens": {
"type": "number"
}
}
}
}
}Every answer carries a decision id; the explain endpoint returns why that model was chosen.