Introduction
odnoga is an OpenAI-compatible HTTP API that routes requests to OpenAI, Anthropic, Google, Mistral, Perplexity, xAI and more. On top of routing, it provides per-tenant billing, vault key rotation, prompt versioning, an MCP server, and observability.
You can adopt odnoga incrementally:
- 1. Swap your base URL — done in 30 seconds.
- 2. Add an end-user header for per-tenant billing.
- 3. Move prompts to the registry once you want A/B.
- 4. Expose MCP to your users when ready.
Quickstart
Send your first request in under five minutes.
1. Create a workspace and key
Sign up free, create a workspace, then go to Settings → API keys and copy your test key.
2. cURL: chat completion
curl https://api.odnoga.com/functions/v1/airouter-openai-compat/v1/chat/completions \
-H "Authorization: Bearer $AIROUTER_KEY" \
-H "Content-Type: application/json" \
-H "x-airouter-end-user: tenant_acme:user_42" \
-d '{
"model": "auto",
"messages": [{"role":"user","content":"Hello world"}]
}'3. TypeScript with the OpenAI SDK
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.AIROUTER_KEY!,
baseURL: "https://api.odnoga.com/functions/v1/airouter-openai-compat/v1",
defaultHeaders: {
"x-airouter-end-user": `tenant_${tenant.slug}:user_${user.id}`,
},
});
const res = await client.chat.completions.create({
model: "auto",
messages: [{ role: "user", content: "Summarise this invoice" }],
});4. Python
from openai import OpenAI
client = OpenAI(
api_key=os.environ["AIROUTER_KEY"],
base_url="https://api.odnoga.com/functions/v1/airouter-openai-compat/v1",
default_headers={"x-airouter-end-user": f"tenant_{tenant}:user_{user_id}"},
)
resp = client.chat.completions.create(
model="auto",
messages=[{"role": "user", "content": "Hi"}],
)Authentication
Every request carries a workspace bearer key. The optional x-airouter-end-user header attributes cost and limits to a specific tenant and user inside that workspace.
Header format
Authorization: Bearer sk_live_... x-airouter-end-user: tenant_<slug>:user_<id>
Keys are scoped: a live key cannot reach test data and vice versa. Rotate keys from the dashboard or via the REST API — the old key remains valid for a configurable grace period.
Tenants & end-users
A tenant is your customer. An end-user is a person inside that tenant. odnoga treats both as first-class entities: budgets, limits and analytics attach to either.
curl https://api.odnoga.com/functions/v1/airouter-openai-compat/v1/tenants \
-H "Authorization: Bearer $AIROUTER_KEY" \
-d '{
"slug": "acme",
"name": "Acme Inc.",
"monthly_budget_usd": 50,
"default_model": "auto"
}'When a request arrives with x-airouter-end-user: tenant_acme:user_42, odnoga looks up tenant "acme", applies its budget, picks its default model (unless overridden), and writes a row attributed to user_42.
API reference
Core endpoints — same shape as OpenAI where possible.
POST /v1/chat/completions
OpenAI-compatible chat. Supports streaming (SSE).
{
"model": "auto" | "gpt-4o" | "claude-3-5-sonnet" | ...,
"messages": [...],
"stream": true,
"metadata": { "feature": "summariser" } // searchable in analytics
}"auto" is a reserved name, not a model: it follows the wildcard (*) rule in Workspace → Routing rules, trying your preferred models in the order you set and failing over automatically. The response header x-airouter-model tells you which model answered, and x-airouter-auto: 1 marks the request as auto-routed. If a workspace has no wildcard rule yet, an "auto" request is rejected with auto_not_configured instead of quietly picking a model for you.
Web access (web search)
One switch, every vendor. odnoga translates it into the vendor's own mechanism — Anthropic web_search tool, Gemini google_search grounding, xAI search_parameters, Perplexity search filters — and returns the same citations array whatever answered. Searches bill per call on top of tokens; the per-search price is on each model in the catalog. A model without web access is skipped in favour of one that has it.
{
"model": "auto",
"messages": [{ "role": "user", "content": "What shipped in the EU AI Act this week?" }],
"web_search": {
"max_results": 5,
"context_size": "medium", // low | medium | high
"allowed_domains": ["europa.eu"], // optional, workspace policy is the ceiling
"recency": "week" // day | week | month | year
}
}
// response adds, for every vendor:
// "citations": ["https://…"],
// "search_results": [{ "url": "…", "title": "…" }]
// and message.annotations[].url_citationAdmins control this per workspace under Models → Web access, or over MCP with settings.web_search.get / settings.web_search.set. A tenant admin can lock it for every workspace. When it is off, a request asking for the web returns 403 web_search_disabled — never a quiet offline answer.
POST /v1/embeddings
{
"model": "text-embedding-3-small",
"input": ["one", "two"]
}POST /v1/images/generations
{
"model": "gpt-image-1",
"prompt": "A modern dashboard",
"size": "1024x1024"
}GET /v1/usage
Returns aggregated cost by tenant, user, model, time bucket. Use to power your own usage UI.
GET /v1/usage?tenant=acme&since=2026-05-01&group_by=user,model
Routing & fallback
You configure routing per workspace: a list of rules, each mapping a requested model to an ordered fallback chain. The wildcard rule (*) applies when no exact rule matches — and it is also what the reserved model name "auto" resolves to, so sending "auto" means "use my preferred order".
{
"rules": [
{ "match": "*", "fallbacks": ["claude-3-5-sonnet", "gpt-4o-mini"] },
{ "match": "gpt-4o", "fallbacks": ["gpt-4o-mini"] }
]
}When the primary returns a retryable error (408, 425, 429, 5xx or a timeout), odnoga re-issues against the next entry, skipping any model that lacks a capability the request needs. Response headers: x-airouter-model (which model answered), x-airouter-fallback, x-airouter-skipped-models, and x-airouter-auto when the request used "auto".
Prompts & variables
Prompts live in a registry, addressed by slug + label or version. You fetch data from your own DB/system at request time and pass it as variables — the server renders the final messages before dispatching to the model.
Template syntax
Use double-curly placeholders. They must start with a letter or underscore.
You are a billing assistant for {{tenant_name}}.
The customer is {{user_full_name}} ({{user_email}}).
Their plan is {{plan}}, renews on {{renews_at}}.
Recent invoices:
{{invoices_context}}- Missing optional variable → renders as empty string.
- Missing required variable → 400 invalid_prompt.
- Max 8 KB per variable by default (not per request) — a variable's declaration can raise its own cap with max_bytes (up to 256 KB per variable, 1 MB total).
- ASCII control chars (except \n and \t) are stripped automatically.
Calling with variables
curl https://api.odnoga.com/functions/v1/airouter-openai-compat/v1/chat/completions \
-H "Authorization: Bearer $AIROUTER_KEY" \
-H "x-airouter-end-user: tenant_acme:user_42" \
-d '{
"model": "gpt-4o-mini",
"prompt": {
"slug": "billing-assistant",
"label": "production",
"variables": {
"tenant_name": "Acme",
"user_full_name": "Ada Lovelace",
"plan": "Growth"
}
},
"messages": [{"role":"user","content":"When does my plan renew?"}]
}'import { airouter } from '@odnoga/node';
const ai = airouter({ apiKey: process.env.AIROUTER_KEY!, endUser: user.id });
const res = await ai.prompts.chat({
slug: 'billing-assistant',
label: 'production',
variables: {
tenant_name: user.tenant.name,
user_full_name: user.full_name,
plan: user.plan,
renews_at: user.renews_at,
invoices_context: invoices
.map(i => `- #${i.number} ${i.amount_usd} ${i.status}`)
.join('\n'),
},
});await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: question }],
// @ts-expect-error — odnoga extension
prompt: { slug: 'billing-assistant', label: 'production', variables: { ... } },
}, { headers: { 'x-airouter-end-user': user.id } });Sourcing variables from your DB
Typical flow inside a Supabase Edge Function: fetch the rows you need, fold them into a single variables object, then hand it to odnoga. The full helper pattern lives in /docs/best-practices/prompt-variables.md.
const { data: u } = await sb.from('profiles').select('*, tenant:tenants(name)').eq('id', user_id).single();
const { data: invoices } = await sb.from('invoices')
.select('number, amount_usd, status').eq('user_id', user_id)
.order('issued_at', { ascending: false }).limit(5);
const { data, meta } = await callodnoga('/v1/chat/completions', {
model: 'gpt-4o-mini',
prompt: {
slug: 'billing-assistant',
label: 'production',
variables: {
tenant_name: u.tenant.name,
user_full_name: u.full_name,
plan: u.plan,
invoices_context: invoices.map(i => `- #${i.number} ${i.amount_usd}`).join('\n'),
},
},
messages: [{ role: 'user', content: question }],
}, { endUser: user_id });RAG and large context
- Concatenate retrieved chunks into a single {{context}} variable when it fits the cap (8 KB by default).
- Above the cap: declare max_bytes on that variable (up to 256 KB), or split into {{context_1}}, {{context_2}}, … — the cap is per variable.
- Keep a visible separator (\n---\n) so the model can tell chunks apart.
Precedence & pinning
prompt.version_id— explicit pin. Forbidden for browser keys / end-user JWTs (403).prompt.label— e.g. "production", "staging".- Active A/B experiment (sticky per x-airouter-end-user).
- The "production" label.
- Latest version.
Always send x-airouter-end-user — it is what makes A/B stickiness deterministic per user.
Errors
400 invalid_prompt— required variable missing.400 variable_too_large— variable over its cap (8 KB default, or the declared max_bytes).404 prompt_not_found— slug does not exist in this workspace.403 version_pin_not_allowed— version_id sent with a browser/EUT key.
MCP
odnoga exposes an MCP server at /mcp. Your end-users authorise once with OAuth 2.1 (DCR supported), then their MCP host — Claude Desktop, Cursor, Lovable — can call your tools through odnoga, billed against the right tenant.
{
"mcpServers": {
"airouter": {
"url": "https://api.odnoga.com/functions/v1/airouter-mcp/mcp",
"transport": "http"
}
}
}Webhooks
Subscribe to events to integrate with your billing, alerting and audit systems.
tenant.budget.warning— 80% of soft cap reachedtenant.budget.exceeded— hard cap hit, requests throttledkey.rotated— provider key rotated by an adminprompt.version.published— new prompt version availableusage.daily.summary— end-of-day usage rollup
Every webhook is signed with HMAC-SHA256.
Security
- Provider keys encrypted at rest (AES-256, envelope) and never returned in API responses.
- All traffic TLS 1.3.
- PII redaction in logs (opt-out per workspace).
- SOC 2 Type II in progress; GDPR & DPA available.
- EU data residency available on Growth, Extend and Enterprise.
Errors & limits
We use standard HTTP semantics plus a JSON body with code, message and request_id.
{
"error": {
"code": "tenant_budget_exceeded",
"message": "Tenant 'acme' has reached its monthly hard cap.",
"request_id": "req_01HZ..."
}
}401 invalid_key— bearer missing or revoked402 tenant_budget_exceeded— hard cap hit429 rate_limited— honour Retry-After header502 upstream_error— provider failed; fallback exhausted
Long-running calls
odnoga waits up to 300 seconds for an upstream model, streaming or not. Send x-airouter-timeout-ms to ask for a shorter ceiling (1000–300000 ms). If the ceiling is hit, the response says which limit fired and the fallback chain still applies. For generations that routinely run minutes, stream the response — bytes keep flowing and nothing in between can time the connection out.
Default rate limits per workspace: 600 RPM and 2M TPM. Raise on request.