API REFERENCE
Your first call
Send one request with a Modelvia project key, verify what it cost, then make your retries safe for production. Project keys are issued by your administrator; this page assumes you already have one.
The first call
Keep the project key on your server as MODELVIA_KEY and send one OpenAI-compatible request. A bearer key and a JSON body are all it needs.
curl https://api.modelvia.dev/v1/chat/completions \
-H "Authorization: Bearer $MODELVIA_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "auto",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Reply with one short sentence."}]
}'A success is ordinary OpenAI-compatible JSON — choices[0].message.content, finish_reason, usage — plus an X-Request-Id response header (add -i to see it in curl). Keep that header: it is how you read the price.
OpenAI SDK
The official openai packages work unchanged. Set base_url / baseURL to https://api.modelvia.dev/v1 and pass the project key; nothing else changes.
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.modelvia.dev/v1",
api_key=os.environ["MODELVIA_KEY"],
)
completion = client.chat.completions.create(
model="auto",
max_tokens=1024,
messages=[{"role": "user", "content": "Reply with one short sentence."}],
)
print(completion.choices[0].message.content)import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.modelvia.dev/v1",
apiKey: process.env.MODELVIA_KEY,
});
const completion = await client.chat.completions.create({
model: "auto",
max_tokens: 1024,
messages: [{ role: "user", content: "Reply with one short sentence." }],
});
console.log(completion.choices[0].message.content);These examples send no Idempotency-Key, and a first call needs none. Without one, Modelvia derives a key from your project key and the request, so a client that resends while its first request is still running, or right after the answer was delivered, gets the original receipt instead of paying twice. An edited prompt is a new request; an identical rerun is served once the previous answer was delivered more than 30 seconds ago, or at once if you stopped it (the exact rule is below). Do not paste a fixed Idempotency-Key into a snippet you run again: the second run returns 409 request_already_processed, and the same key with an edited prompt returns 409 idempotency_conflict. When your own code retries, follow Retries in production.
From a key to a verified receipt
- Get a project key for an active, funded project. If you are integrating a business, first verify the client connection, then bind the customer and project. Confirm accepted rates and applicable customer terms before a paid call.
- Keep the key in your backend or trusted CLI environment as
MODELVIA_KEY. ReadGET /v1/models; an empty model list is a setup state. - Send the bounded request above.
- Save
X-Request-Id, then read its receipt with the same project key. InspectstateandpriceBasisbefore showing a final amount. - For your customer dashboard, fetch customer-scoped usage from your server. A completion receipt is not a customer invoice or a payment receipt.
- Before production traffic, save an ID for each logical request and send it as
Idempotency-Key, as Retries in production describes.
Base URL and authentication
The base URL is https://api.modelvia.dev/v1. Every request carries the project key as a bearer token:
Authorization: Bearer <project key>A key is scoped to one project and one environment. It cannot read another project’s requests, and it is never accepted on the operator or workspace APIs. The secret is shown once when it is minted; if it is lost, rotate the key rather than minting a second one. Keep it on a server: a project key in a browser is a key you have given away. Use MODELVIA_KEY only for this project credential; the separate client integration key is for your backend management API.
What you can call
GET /v1/models lists exactly the routes this key may use — the intersection of your project, customer and client model policy with the routes currently approved for serving. An empty list means serving is not open for this key yet, not that the service is down.
curl https://api.modelvia.dev/v1/models \
-H "Authorization: Bearer $MODELVIA_KEY"
{
"object": "list",
"pricing_contract": 2,
"price_audience": "direct_customer",
"price_basis": "direct",
"data": [
{
"id": "auto",
"object": "model",
"created": 0,
"owned_by": "managed-ai-platform",
"context_length": 131072,
"max_output_tokens": 8192,
"capabilities": ["text", "tools", "vision"],
"pricing": {
"contract": 2,
"audience": "direct_customer",
"basis": "direct",
"currency": "AUD",
"rateVersion": "example-rates-2026-09",
"gstInclusive": true,
"units": {
"input_tokens": {"nanoAud": "1000000", "perUnits": 1},
"output_tokens": {"nanoAud": "2000000", "perUnits": 1}
}
}
},
{"id": "example-vendor/example-model", "object": "model", "created": 0, "owned_by": "managed-ai-platform",
"context_length": 131072, "max_output_tokens": 8192, "capabilities": ["text", "tools"],
"pricing": {"…": "…"}}
]
}Send "model": "auto" to let Modelvia choose within that set, or name one of the listed ids exactly to pin it. There is no partial matching and no alias.
Every entry carries context_length, max_output_tokens, capabilities and pricing — your own unit rates in AUD, marked with the listing’s pricing_contract, price_audience and price_basis exactly as the receipt below marks its amounts, and repeated inside each pricing as contract, audience and basis. When your price cannot be stated exactly, price_basis is "withheld" and no entry carries pricing. capabilities is what a request to that id may contain: "text" always, "tools" when it can call functions, and "vision" when it can read images. Unlike the limits, it is the union across the routes behind the id, because a request that carries images is narrowed to the routes that can see before anything is reserved. Because auto may land on any eligible route, it reports the smallest window and output ceiling among them, so a conversation sized against auto fits whichever route serves it. Configure an agent framework from these numbers rather than from a model’s published spec. A route whose window an operator has not yet recorded advertises a conservative 32768; that is a missing value, not a small model, and a client that needs more will correctly refuse to start.
Parameters
Every field is one of three things: honoured, accepted and inert, or refused by name as unsupported_parameter:<name> with the field repeated in the error’s param. Nothing is silently dropped, so a parameter you send is either honoured or reported.
| Field | Treatment | Notes |
|---|---|---|
model, messages, stream, stream_options.include_usage, tools | Honoured | The core of the request. |
max_tokens or max_completion_tokens | Honoured | Send one. Both together is ambiguous_output_limit. On a thinking route the reasoning counts toward this limit, so a very small value can end with finish_reason "length" before any answer text; omit it to use the route's own ceiling. |
content as [{type:"text", text}] | Honoured | Flattened to the equivalent string. |
content as [{type:"image_url", image_url:{url}}] | Honoured | In a user turn, on a route whose capabilities list vision. A data: URI (image/png, image/jpeg, image/webp, image/gif) or an https: URL. At most 8 images per request, 5 MiB each and 20 MiB in total. See Images below. |
image_url.detail | Inert | Accepted only as "auto". "low" and "high" change what the provider charges for an image, and the price you were quoted comes from the route's own per-image estimate, so they are refused as unsupported_parameter:image_url.detail rather than quoted wrongly. |
tool_choice | Honoured | "auto", "none", "required" or {type:"function", function:{name}}. A named tool must be one you offered. |
temperature, top_p, stop, seed, presence_penalty, frequency_penalty, parallel_tool_calls | Honoured | Validated, then forwarded to the provider. Out of range is invalid_<field>. |
reasoning_effort | Honoured | A routing-profile floor. See the table below. |
user, metadata | Inert | Validated and then dropped. They reach no provider and appear in no record. metadata takes at most 16 string pairs. |
n: 1, store: false, response_format: {type:"text"} | Inert | Accepted only at that exact value, because the service has exactly one behaviour. n: 2, store: true and JSON or schema response_format are refused. |
Any other content part — input_audio, file, video | Refused | unsupported_content_parts:<type>, with the type repeated in the error's param. |
Anything else — logprobs, logit_bias, functions, top_k… | Refused | unsupported_parameter:<name>, with the name repeated in the error's param. |
“Inert” is exact: user and metadata are checked so a wrong value is still reported, and are then discarded. They do not reach a provider, do not appear on a receipt, and cannot change what is served or what it costs. Do not use them to carry anything you need back.
reasoning_effort
Modelvia routes across several models, so an effort level is a floor on the routing profile rather than a knob on one model. A stronger route stays eligible; a weaker one does not.
| Value | What it guarantees |
|---|---|
"none" | Only routes that do no hidden reasoning at all. This is a constraint, not a strength — if no such route is open to your key the call is refused. |
"low" | At least the fast profile: every route stays eligible. |
"medium" | At least the medium profile. |
"high" | At least the smart profile. |
"xhigh" | A route that thinks at xhigh effort or more, such as Claude Sonnet 5.5 at xhigh. Flash does not reach it, so a key limited to Flash is refused. |
"max" | The most deliberation on offer: served like xhigh, since no route thinks harder than that today. |
If no route open to your key can reach the level you asked for, the call is refused with unsupported_parameter:reasoning_effort (400) before anything is reserved — you are never charged for a request that could not honour the constraint you set. Omit the field entirely to leave the choice to Modelvia.
Images
Send an image as an ordinary content part on a user turn, either as a data: URI you encode yourself or as an https: URL. Anthropic clients send the equivalent image block, in either its base64 or its url source form; both wires end up as the same request and cost the same.
{
"model": "auto",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What is failing in this screenshot?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo..."}}
]
}]
}Check capabilities first. Images are accepted only on routes an operator has marked vision, and GET /v1/models says which ids those are. If no route your key may use can see, the call is refused with unsupported_content_parts:image_url (400) before anything is reserved — the same code the surface returned before images existed at all.
The ceilings are 8 images per request, 5 MiB per image and 20 MiB per request, measured on the decoded bytes. A route may state tighter ones. The accepted media types are image/png, image/jpeg, image/webp and image/gif. An https: URL is passed to the provider to fetch and is never fetched by Modelvia, so it is admitted only on a route whose operator turned that on.
| Code | What it means |
|---|---|
unsupported_content_parts:image_url | No route your key may use lists vision, so the images cannot be served at all. Check capabilities on GET /v1/models. |
unsupported_image_media_type | Not one of image/png, image/jpeg, image/webp, image/gif. |
image_too_large | One image is over the per-image ceiling, or the request is over the 20 MiB total. Both are measured on the decoded bytes, not the base64. |
too_many_images | More images than the request, or the route, allows. |
image_url_scheme | The URL is not a data: URI or a plain https: URL. Modelvia never dereferences http:, file: or a URL carrying credentials. |
remote_images_not_admitted | An https: URL on a route whose operator has not admitted remote images. Inline the bytes as a data: URI instead. |
unsupported_parameter:image_url.detail | detail was "low" or "high". Only the provider default, "auto", is accepted. |
What an image costs. Most vision providers bill an image as prompt tokens on the ordinary input rate, and that is how Modelvia settles it: the tokens are inside units.input_tokens on your receipt and are charged at the input rate, never as a second unit. The reservation held before the call adds the route’s stated tokens-per-image to the worst case, so an image is always covered by the amount you were quoted. When the provider reports the split, the receipt shows it as units.image_tokens beside imageTokensBilledAs: "input_tokens"; when it folds them into the prompt count without saying, image_tokens is null — we do not know, and we will not print a zero as if we did.
Image bytes are never stored, never logged and never sent to the routing classifier: a request’s identity, including the idempotency fingerprint, uses a SHA-256 digest of each image, and the classifier sees only an [image] marker where the picture sat.
Streaming
Set stream: true for server-sent events. Frames are chat.completion.chunk objects ending with data: [DONE]. Ask for stream_options: { include_usage: true } to receive token usage in the final chunk. The X-Request-Id header is set before the first frame, so a stream that is cut off can still be looked up. The example sends a saved request ID, as Retries in production describes, so a reconnect is recognised as the same request.
// Load this stream's saved request ID; a reconnect must reuse it.
const streamRequestKey = process.env.MODELVIA_REQUEST_KEY;
if (!streamRequestKey) throw new Error("Load the saved ID for this logical request");
const stream = await client.chat.completions.create(
{
model: "auto",
messages: [{ role: "user", content: "Draft a reply." }],
stream: true,
stream_options: { include_usage: true },
},
{ headers: { "Idempotency-Key": streamRequestKey } },
);
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}Tools
Function tools are passed through. Each entry needs type: "function" and a function object with name, parameters and an optional description — no other keys. tool_choice accepts "auto", "none", "required" and {type: "function", function: {name}}; a named tool must be one you offered, and forcing a call without tools is invalid_tool_choice. Tool calls arrive whole, never as fragments, and you may send the assistant’s tool_calls and your tool results back in messages to continue the loop.
{
"model": "auto",
"messages": [{"role": "user", "content": "What is order 4182 worth?"}],
"tools": [
{
"type": "function",
"function": {
"name": "lookup_order",
"description": "Read one order by id.",
"parameters": {
"type": "object",
"properties": {"orderId": {"type": "string"}},
"required": ["orderId"]
}
}
}
]
}Embeddings
POST /v1/embeddings is the ordinary OpenAI embeddings surface on the same key, the same caps, the same Idempotency-Key rule and the same receipt as a completion — so a retrieval agent needs no second vendor account. Anthropic publishes no embeddings API, so there is no equivalent on /v1/messages; use this endpoint from an Anthropic-shaped client too.
curl https://api.modelvia.dev/v1/embeddings \
-H "Authorization: Bearer $MODELVIA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ${MODELVIA_REQUEST_KEY:?Load the saved ID for this logical request}" \
-d '{
"model": "example-vendor/example-embed",
"input": ["the first chunk of the document", "the second chunk"]
}'
{
"object": "list",
"data": [
{"object": "embedding", "index": 0, "embedding": [0.0123, -0.0456, "…"]},
{"object": "embedding", "index": 1, "embedding": [0.0789, -0.0012, "…"]}
],
"model": "example-vendor/example-embed",
"usage": {"prompt_tokens": 14, "total_tokens": 14}
}Pick a model from capabilities first. An embedding model’s entry on GET /v1/models reads "capabilities": ["embeddings"] and carries dimensions, max_input_tokens and max_inputs_per_request. That capability is exclusive — an embedding model is not a chat model — so a chat id here, or an embedding id on /v1/chat/completions, is model_route_unavailable (503). "auto" works and is narrowed to the embedding-capable routes.
input is one string or an array of them, and the reply carries one vector per input with the index it came from. A bare string and a one-element array are the same paid request, so a retry that changes spelling is not charged twice. dimensions is forwarded to the provider and may never be wider than the model’s own; encoding_format: "base64" returns little-endian float32, encoded by Modelvia from numbers it checked rather than passed through untouched.
Node
// Use the saved ID for this embedding request, not a previous chat request.
const embeddingRequestKey = process.env.MODELVIA_REQUEST_KEY;
if (!embeddingRequestKey) throw new Error("Load the saved ID for this logical request");
const { data } = await client.embeddings.create(
{
model: "example-vendor/example-embed",
input: chunks, // one string, or an array of them
// dimensions: 512, // optional, never wider than the model's own
// encoding_format: "base64", // optional, little-endian float32
},
{ headers: { "Idempotency-Key": embeddingRequestKey } },
);
for (const row of data) index.add(chunks[row.index], row.embedding);Python
import os
from openai import OpenAI
# Load the ID saved with this exact request body; reuse it for retries.
request_key = os.environ["MODELVIA_REQUEST_KEY"]
if not request_key:
raise ValueError("Load the saved ID for this logical request")
client = OpenAI(
base_url="https://api.modelvia.dev/v1",
api_key=os.environ["MODELVIA_KEY"],
)
vectors = client.embeddings.create(
model="example-vendor/example-embed",
input=["the first chunk of the document", "the second chunk"],
extra_headers={"Idempotency-Key": request_key},
)
print(len(vectors.data[0].embedding), vectors.usage.prompt_tokens)| Code | What it means |
|---|---|
model_route_unavailable | No route your key may use has capabilities ["embeddings"] — or you named a chat model. The two surfaces never serve each other's routes. Check GET /v1/models. |
input_too_large | One input is estimated above the route's maxInputTokens. Chunk it smaller; nothing was reserved. |
too_many_inputs | More inputs in one call than the route's maxInputsPerRequest (128 unless the operator says otherwise). Send them in batches. |
unsupported_dimensions | dimensions is wider than the model produces. Read dimensions from GET /v1/models. |
invalid_input | input was not a string or an array of non-empty strings. Pre-tokenised integer arrays are not accepted: Modelvia has no tokenizer and would have to guess what it embedded. |
unsupported_parameter:encoding_format | Only "float" and "base64" exist here. |
What an embedding costs. It is billed as input_tokens at the model’s ordinary input rate — there is no separate embeddings unit and no output tokens, because an embedding response generates nothing. The amount held before the call is an estimate of your inputs, capped by what the route will ever accept; the amount charged is the provider’s own prompt_tokens, which is also what usage.prompt_tokens and your receipt’s units.input_tokens report. Nothing is classified, so no routing decision is bought on top.
Connect an agent framework
Anything that speaks OpenAI chat completions connects with a base URL and a key — no plugin, no per-client patch. Set the credential through the environment, never in a config file you commit, and take every limit below from your own GET /v1/models rather than from these examples.
Hermes (Nous Research)
Hermes needs a named custom provider: a bare custom provider will not send your key to an arbitrary host. Put the key in MODELVIA_KEY and name that variable with key_env.
# ~/.hermes/config.yaml — a named custom provider, key from the environment
model:
default: auto
provider: custom:modelvia
base_url: https://api.modelvia.dev/v1
api_mode: chat_completions
context_length: 131072 # copy from /v1/models → auto.context_length
providers:
modelvia:
base_url: https://api.modelvia.dev/v1
key_env: MODELVIA_KEY
default_model: auto
api_mode: chat_completionscontext_length is the one value to get right. Hermes reads it from your configuration and refuses to start below 64,000 tokens, so a route that reports the conservative default of 32768 will stop it — correctly, because that route genuinely has not had its window verified. Ask your administrator to record the real window rather than raising the number here: a configured context larger than what the serving route can accept fails mid-conversation, after you have paid for the turn.
Hermes sends stream_options.include_usage and no Idempotency-Key, so the derived-key window described below is what protects you from a double charge if its outer loop rebuilds the transport and resends. If you need a stronger guarantee, put a small proxy in front that sets Idempotency-Key from a digest of the request bytes.
OpenClaw
OpenClaw declares models in its own configuration rather than reading /v1/models, so the entry you write is the contract — copy contextWindow and maxTokens from what the listing reports for auto.
// openclaw config — one provider, one model entry
{
"models": {
"providers": {
"modelvia": {
"baseUrl": "https://api.modelvia.dev/v1",
"api": "openai-completions",
"apiKeyEnv": "MODELVIA_KEY",
"models": [
{
"id": "auto",
"contextWindow": 131072,
"maxTokens": 8192
}
]
}
}
}
}One entry with the id auto is usually all you want: Modelvia then picks the route per request within your model policy. Add a second entry only if you mean to pin a specific route, and give it that route’s own numbers from the listing.
OpenAI SDKs and LiteLLM
Change baseURL and the key; nothing else. The default fields these libraries add — temperature, top_p, stop, seed, the penalties, n: 1, store: false, response_format: { type: "text" } and metadata — are all accepted. See the parameter table above for which of them change generation and which are inert.
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.modelvia.dev/v1",
apiKey: process.env.MODELVIA_KEY,
});
const completion = await client.chat.completions.create({
model: "auto",
max_tokens: 1024,
messages: [{ role: "user", content: "Reply with one short sentence." }],
});
console.log(completion.choices[0].message.content);Anthropic Messages clients
A client that speaks Anthropic Messages rather than OpenAI chat completions connects to the same service, with the same project key, at POST /v1/messages. The base URL for an Anthropic SDK is https://api.modelvia.dev — no /v1, because the SDK appends its own path. Send the key as x-api-key and set anthropic-version: 2023-06-01.
The two surfaces are the same service, the same key, the same price and the same receipt. A call to /v1/messages goes through the identical admission, model policy, reservation, rate card and settlement as a call to /v1/chat/completions; it produces an X-Request-Id you read back at the same GET /v1/requests/{id}, and it is charged exactly once. Pick whichever wire your client already speaks — nothing about the cost or the routing changes.
curl https://api.modelvia.dev/v1/messages \
-H "x-api-key: $MODELVIA_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ${MODELVIA_REQUEST_KEY:?Load the saved ID for this logical request}" \
-d '{
"model": "auto",
"max_tokens": 1024,
"system": "Answer in one line.",
"messages": [{"role": "user", "content": "Summarise this ticket."}]
}'
{
"id": "msg_...",
"type": "message",
"role": "assistant",
"model": "auto",
"content": [{"type": "text", "text": "..."}],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {"input_tokens": 412, "output_tokens": 78}
}Both credential headers are accepted on the key-serving routes, so x-api-key also works on GET /v1/models, POST /v1/chat/completions and GET /v1/requests/{id}. Sending Authorization and x-api-key with different values is ambiguous_credential (400) rather than a silent preference. GET /v1/models answers in the OpenAI listing shape on both surfaces; read context_length and max_output_tokens from it exactly as an OpenAI client would.
The Anthropic SDKs
Override base_url / baseURL and the key; nothing else changes.
import os
from anthropic import Anthropic
# Load the ID saved with this exact request body; reuse it for retries.
request_key = os.environ["MODELVIA_REQUEST_KEY"]
if not request_key:
raise ValueError("Load the saved ID for this logical request")
client = Anthropic(
base_url="https://api.modelvia.dev",
api_key=os.environ["MODELVIA_KEY"],
)
message = client.messages.create(
model="auto",
max_tokens=1024,
messages=[{"role": "user", "content": "Summarise this ticket."}],
extra_headers={"Idempotency-Key": request_key},
)
print(message.content[0].text)import Anthropic from "@anthropic-ai/sdk";
// Load the ID saved with this exact request body; reuse it for retries.
const requestKey = process.env.MODELVIA_REQUEST_KEY;
if (!requestKey) throw new Error("Load the saved ID for this logical request");
const client = new Anthropic({
baseURL: "https://api.modelvia.dev",
apiKey: process.env.MODELVIA_KEY,
});
const message = await client.messages.create(
{
model: "auto",
max_tokens: 1024,
messages: [{ role: "user", content: "Summarise this ticket." }],
},
{ headers: { "Idempotency-Key": requestKey } },
);Streaming is the real Anthropic event sequence with named event: lines — message_start, content_block_start, content_block_delta (text_delta, and input_json_delta for tool input), content_block_stop, message_delta carrying stop_reason and the cumulative usage, then message_stop — so an SDK’s own stream reader consumes it unchanged. Tool input arrives in one input_json_delta: tool calls are handed over whole, never as fragments. stop_sequence is always null, because no route Modelvia serves reports which stop string ended a generation.
OpenClaw over anthropic-messages
// openclaw config — the anthropic-messages transport against the same key
{
"models": {
"providers": {
"modelvia-anthropic": {
"baseUrl": "https://api.modelvia.dev",
"api": "anthropic-messages",
"apiKeyEnv": "MODELVIA_KEY",
"headers": {"anthropic-version": "2023-06-01"},
"models": [
{
"id": "auto",
"contextWindow": 131072,
"maxTokens": 8192
}
]
}
}
}
}What /v1/messages does with each field
| Field | Treatment | Notes |
|---|---|---|
model, messages, max_tokens, stream | Honoured | max_tokens is required, as it is on the real Anthropic API, and is the same output ceiling as OpenAI's max_tokens. |
system (string or text blocks) | Honoured | Becomes the first system message of the conversation. |
content blocks: text, tool_use, tool_result | Honoured | A tool_result inside a user turn becomes its own tool message, ahead of any remaining user text. |
tools ({name, description, input_schema}) | Honoured | input_schema is the function's parameter schema. No other tool type is accepted. |
tool_choice: {type:"auto"|"none"|"any"|"tool", name} | Honoured | "any" means required — some tool, your choice of which. tool_choice.disable_parallel_tool_use is refused. |
temperature, top_p, stop_sequences | Honoured | stop_sequences is the same field as OpenAI's stop: at most 4 strings, each at most 64 characters. |
metadata.user_id | Inert | Validated and then dropped, exactly like OpenAI's user. It reaches no provider and appears on no receipt. |
tool_result.is_error | Inert | Accepted so a failing tool loop still works. The failure the model sees is the result text, which is carried unchanged. |
thinking | Refused | unsupported_parameter:thinking. Anthropic's thinking is a token budget for one model's extended thinking; Modelvia's reasoning_effort is a floor on a routing profile across many models. There is no honest mapping, so it is refused rather than guessed at. |
cache_control on any block | Refused | unsupported_parameter:cache_control. Prompt caching changes what a turn costs and Modelvia does not carry it, so a cached turn is never quoted as if it had been. |
image blocks (source base64 or url) | Honoured | In a user turn, on a route whose capabilities list vision. The same media types, ceilings and refusal codes as the OpenAI surface — see Images — returned in Anthropic's error shape. source.type file needs the Files API and is unsupported_content_block:image.file. |
Anything else — top_k, service_tier, mcp_servers, document blocks… | Refused | unsupported_parameter:<name> or unsupported_content_block:<type>, in Anthropic's error shape. |
Failures on /v1/messages use Anthropic’s error shape, with the machine code in message so it stays readable however your SDK surfaces the error. The types are invalid_request_error (400 and 409), authentication_error (401), billing_error (402, the spending caps), permission_error (403), not_found_error (404), request_too_large (413), rate_limit_error (429, still with Retry-After), overloaded_error (503) and api_error for any other 5xx. The codes themselves are the same ones the table further down lists.
HTTP/1.1 402 Payment Required
{
"type": "error",
"error": {
"type": "billing_error",
"message": "project_monthly_cap_exceeded"
}
}Idempotency-Key behaves identically on both surfaces, including the derived-key window below. The derivation includes which surface the request arrived on, so the same conversation sent once as OpenAI and once as Anthropic is two independent requests — two dispatches, two charges, two receipts — while a repeat of either follows the retry rule below: refused with the original receipt while the first is unanswered or was answered moments ago, served as a new request once the first answer was delivered.
Receipt and price
The completion body carries no money. Read GET /v1/requests/{X-Request-Id} with the same project key. A refused request also returns an X-Request-Id (starting rq_) for support, but it has no receipt because nothing was charged.
curl https://api.modelvia.dev/v1/requests/$REQUEST_ID \
-H "Authorization: Bearer $MODELVIA_KEY"
{
"requestId": "...",
"state": "settled",
"projectId": "exampleco-support-assistant",
"environment": "production",
"model": "example-vendor/example-model",
"requestedModel": "auto",
"rateVersion": "example-rates-2026-09",
"units": {"input_tokens": 412, "output_tokens": 78},
"pricingContract": 2,
"priceAudience": "direct_customer",
"priceBasis": "direct",
"reservedNanoAud": "2000000000",
"chargedNanoAud": "902000",
"currency": "AUD",
"replayAvailable": false
}- pricingContract, priceAudience, priceBasis
- Which price the amounts below are.
pricingContractis2. If your account is billed by the business that gave you this key,priceAudienceis"resale_customer"and the amounts are the retail price that business invoices you (priceBasis"retail"). If Modelvia bills you directly, it is"direct_customer"withpriceBasis"direct". When your price cannot be stated exactly,priceBasisis"withheld"and both amount fields are absent; no other party’s price is ever shown in their place. - chargedNanoAud
- What this request cost you, before any credit, as an exact integer string in nano-AUD. 1 AUD = 1,000,000,000 nano-AUD, so
"902000"is 0.000902 AUD. Parse it as an integer or a decimal type, never as a float. It is"0"until the request settles. - reservedNanoAud
- The most this request could have cost you, held against your caps while it was in flight. It is released and replaced by the charged amount on settlement; it is not a price.
- rateVersion
- The identifier of the published rate card this request was priced on. Rate cards are immutable and must be accepted before they can price anything, so a receipt always names the exact terms that applied.
- currency
- Always
AUD. Amounts include GST where GST applies. - state
settledonce usage is final.releasedmeans it cost nothing: it was refused before generation, or stopped before the model received it.unknownmeans the usage of a dispatched request is being confirmed — the hold stands until it is settled from the model provider’s own record of the generation, or by an operator. A request you stopped showsunknownbriefly while the cost of its partial answer is confirmed, and is billed only for what was generated before it stopped. Otherwise, do not resend anunknownrequest under a new idempotency key; that is how you pay twice.
Retries in production
The first call relies on the key Modelvia derives when you send none. That protects a client that resends at once, but it has limits: a retry is recognised for at most 30 minutes, and an identical request sent more than 30 seconds after the gateway delivered an answer is served, and charged, as a new one — even if that answer never reached your code. When your own code retries (a job queue, a worker that restarts, a timeout you handle), give each logical request a saved ID and send it as Idempotency-Key.
- Generate one idempotency value for each logical call and save it with the unchanged request body before dispatch.
- Load that saved value as
MODELVIA_REQUEST_KEY, then send the request with it asIdempotency-Key. - On any retry, send the same value with the exact same body. A changed body under the same key is refused, not served.
For a new logical request, generate a UUID once (for example, with uuidgen) and persist it in your application’s job record or a local request record. Export that saved value as MODELVIA_REQUEST_KEY before running an example. Each distinct chat, embedding or streaming request needs its own saved ID. Retrying the same request, including after restarting your script, must reuse the original ID and body; every example that sends Idempotency-Key refuses to dispatch if the ID is missing.
A timeout, broken stream or unknown receipt is unresolved. Retain the original request identity; do not issue a new key to replay it. A 409 duplicate response can describe an in-flight or failed request as well as a settled one.
# Load MODELVIA_REQUEST_KEY from this logical request's saved record.
# On retry, reuse that value and the exact body. Do not generate another ID.
curl --fail-with-body https://api.modelvia.dev/v1/chat/completions \
-D modelvia-response.headers \
-H "Authorization: Bearer $MODELVIA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ${MODELVIA_REQUEST_KEY:?Load the saved ID for this logical request}" \
-d '{
"model": "auto",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Reply with one short sentence."}]
}'Node
The official openai package works unchanged with a different baseURL; the saved ID travels as a per-request header.
import OpenAI from "openai";
// Load the ID saved with this exact request body; reuse it for retries.
const requestKey = process.env.MODELVIA_REQUEST_KEY;
if (!requestKey) throw new Error("Load the saved ID for this logical request");
const client = new OpenAI({
baseURL: "https://api.modelvia.dev/v1",
apiKey: process.env.MODELVIA_KEY,
});
const completion = await client.chat.completions.create(
{
model: "auto",
messages: [{ role: "user", content: "Summarise this ticket in one line." }],
},
{ headers: { "Idempotency-Key": requestKey } },
);
console.log(completion.choices[0].message.content);Python
import os
from openai import OpenAI
# Load the ID saved with this exact request body; reuse it for retries.
request_key = os.environ["MODELVIA_REQUEST_KEY"]
if not request_key:
raise ValueError("Load the saved ID for this logical request")
client = OpenAI(
base_url="https://api.modelvia.dev/v1",
api_key=os.environ["MODELVIA_KEY"],
)
completion = client.chat.completions.create(
model="auto",
messages=[{"role": "user", "content": "Summarise this ticket in one line."}],
extra_headers={"Idempotency-Key": request_key},
)
print(completion.choices[0].message.content)Idempotency
Send a stable Idempotency-Key header for each logical request. If the same key arrives again, Modelvia returns HTTP 409 request_already_processed with the original receipt attached as receipt. It does not dispatch a second time, and it does not replay the completion text — treat 409 as “already admitted, inspect the receipt”, never as proof of a charge or permission to re-run.
If you omit the header, the gateway derives one from your key and the parsed request, and decides whether an identical request is a retry or a new one by whether the first answer was delivered:
- A retry, 409 with the original receipt: the first request is still running, the provider failed part-way and its usage is still being confirmed, or its answer was delivered less than 30 seconds ago — a reply can be written in full and still be lost by a proxy on its way to you, and a client resends at once.
- A new request, served and charged: the first answer was delivered and settled more than 30 seconds ago; or you stopped the first request by closing its stream or connection (pressing Stop, then Regenerate), in which case the stopped one is billed only for what the model generated before it stopped; or the first was refused before generation and cost nothing. A scheduled job running the same prompt every few minutes, or a new session asking the same first question, is served each time. The receipt shows
idempotencySource: "derived"and theidempotencySequenceit was admitted under (0, 1, 2, …).
A retry is recognised for up to 30 minutes, including across a 15-minute boundary. Delivery is remembered by the serving instance only; if it cannot vouch for delivery (after a restart, or on another instance), an identical request is refused as a retry rather than risk a second charge. So a supplied Idempotency-Key is still the only complete guarantee: the derived key protects the common retry and no longer blocks a repeat whose answer you received, but an identical re-run within 30 seconds of the last answer, or after a restart, needs its own key or a distinguishing field.
Reusing one key with a changed body is idempotency_conflict (409). An empty header value is invalid_idempotency_key (400).
Spending caps and other refusals
Refusals on the OpenAI-compatible routes use the OpenAI error shape, with the machine code repeated in message — there is no prose message to parse, and no balance is disclosed:
HTTP/1.1 402 Payment Required
{
"error": {
"message": "project_monthly_cap_exceeded",
"type": "insufficient_quota",
"code": "project_monthly_cap_exceeded",
"param": null
}
}| Code | HTTP | Meaning | What to do |
|---|---|---|---|
request_cap_exceeded | 402 | This one request reserves more than the billing account's per-request ceiling. | Shorten the prompt or lower max_tokens. Retrying the same body cannot succeed. |
project_request_cap_exceeded | 402 | The same ceiling, set on the project rather than the billing account. | As above, or ask your administrator to raise the project's request cap. |
monthly_cap_exceeded | 402 | The billing account's monthly ceiling, including everything still reserved. | Wait for the next period or ask for the cap to be raised. Do not retry in a loop. |
client_monthly_cap_exceeded | 402 | The client account's own monthly ceiling, above your project. | Only an administrator of the client can change this. |
customer_monthly_cap_exceeded | 402 | The customer account's monthly ceiling. | Only an administrator of the client can change this. |
project_monthly_cap_exceeded | 402 | This project's monthly ceiling. | Move traffic to another project or have the cap raised. |
credit_limit_exceeded | 402 | Unpaid invoices plus unbilled usage plus current holds exceed the credit limit. | Settle outstanding invoices. Capacity returns when money does, not on retry. |
client_concurrency_limit | 429 | Too many requests are in flight at once for the client account. | Lower your parallelism and retry after the Retry-After header's 2 seconds, with the same body and, if you sent one, the same Idempotency-Key. |
customer_concurrency_limit | 429 | The same limit on the customer account. | As above. |
project_concurrency_limit | 429 | The same limit on the project. | As above. |
request_already_processed | 409 | The request was already admitted under this identity. The original receipt is attached; this retry does not dispatch another request or add a charge. | Inspect the original receipt's state and amount: it may be in flight, failed, unknown or settled. A duplicate response does not prove payment or settlement. Do not resend under a new request ID to recover a lost answer. |
rate_limited | 429 | The abuse ceiling, counted per key over a short window. Separate from spending caps. | Wait the number of seconds in the Retry-After header, then retry. |
rate_limited uses the same OpenAI shape, with type: "rate_limit_error", and adds a Retry-After header — wait that many seconds. The concurrency limits also return 429 in the OpenAI shape, with Retry-After: 2: the same request is admissible as soon as one of your in-flight requests finishes, so lower your parallelism and wait that long before retrying. None of the 402 codes is retryable: retrying a capped request only spends your rate-limit budget.
You may also meet invalid_key (401), service_unavailable or model_scope_denied (403), rates_not_accepted (409) when no rate card has been accepted for the account, and serving_temporarily_unavailable (503) during maintenance. Contact your administrator for the 403 and 409 cases; they are account configuration, not transient faults.
What Modelvia does not do
- No prompt retention. Request and response content is not stored. What persists is the accounting record: identifiers, the model and configuration served, token counts, timings and money.
- No completion replay. A receipt reports what a request cost, never what it said.
replayAvailableis alwaysfalse, and a duplicate idempotency key returns the receipt rather than the original text. If you need the output, keep it when you receive it. - No silent substitution. A parameter that cannot be honoured is refused by name. A model you pin is the model you get, or the call fails.