Docs

Developer documentation

Everything you need to ship AI on odnoga — from your first cURL request to multi-tenant billing in production.

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

POST /v1/chat/completions
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

openai-sdk.ts
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

quickstart.py
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.

POST /v1/tenants
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.

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_citation

Admins 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".

Routing policy (Workspace → Routing rules, or MCP routing.upsert)
{
  "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.

template.txt
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
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?"}]
  }'
@odnoga/node
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'),
  },
});
OpenAI SDK (prompt is an odnoga extension)
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.

supabase/functions/billing-reply/index.ts
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

  1. prompt.version_idexplicit pin. Forbidden for browser keys / end-user JWTs (403).
  2. prompt.labele.g. "production", "staging".
  3. Active A/B experiment (sticky per x-airouter-end-user).
  4. The "production" label.
  5. Latest version.

Always send x-airouter-end-user — it is what makes A/B stickiness deterministic per user.

Errors

  • 400 invalid_promptrequired variable missing.
  • 400 variable_too_largevariable over its cap (8 KB default, or the declared max_bytes).
  • 404 prompt_not_foundslug does not exist in this workspace.
  • 403 version_pin_not_allowedversion_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.

claude_desktop_config.json
{
  "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.warning80% of soft cap reached
  • tenant.budget.exceededhard cap hit, requests throttled
  • key.rotatedprovider key rotated by an admin
  • prompt.version.publishednew prompt version available
  • usage.daily.summaryend-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_keybearer missing or revoked
  • 402 tenant_budget_exceededhard cap hit
  • 429 rate_limitedhonour Retry-After header
  • 502 upstream_errorprovider 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.

Stuck? We answer in hours.

Open your dashboard support widget or email support@odnoga.com