Call models through the gateway
Send model calls to the gateway's /llm route with the OpenAI or Anthropic wire format, point existing SDKs at it, and know which budgets, token limits and DLP checks apply.
A model call is one request from a session to a model through the gateway's model route, /llm/{route}. Your agent never learns the real model URL or API key. It uses the session token as its API key and the gateway's route as its base URL, so the official OpenAI and Anthropic SDKs work with two settings changed. The gateway forces the route's model, meters tokens against budgets, runs data loss prevention (DLP) on the prompt and the completion, and writes one audit record per call. Prompts are not stored in the ledger, only a hash of the request body.
flowchart LR
A["Agent<br/>OpenAI or Anthropic SDK"] -->|"base URL {gateway}/llm/route/...<br/>api key = session token"| G["Gateway /llm"]
G --> C{"scope models:route,<br/>budgets, token limits,<br/>DLP on prompt"}
C -->|denied| E["OpenAI-style error<br/>401 / 402 / 403 / 429"]
C -->|allowed| P["Provider adapter<br/>openai, azure-openai, bedrock,<br/>vertex, gemini, anthropic, oci-genai"]
P --> M["Model endpoint<br/>(primary, then fallbacks)"]
M --> D["DLP on the completion,<br/>meter tokens, audit"]
D --> A
Which wire formats the gateway accepts#
Your agent speaks one of two formats to the gateway. The route's provider adapter translates to whatever the upstream needs (internal/llmwire), so the agent's code does not change when the platform team moves the route from vLLM to Bedrock.
| Agent sends | Path | Operation in policy and audit |
|---|---|---|
| OpenAI Chat Completions | POST /llm/{route}/v1/chat/completions |
chat.completions |
| OpenAI Completions | POST /llm/{route}/v1/completions |
completions |
| OpenAI Embeddings | POST /llm/{route}/v1/embeddings |
embeddings |
| OpenAI Responses | POST /llm/{route}/v1/responses |
responses |
| OpenAI model list | GET /llm/{route}/v1/models |
models |
| Anthropic Messages | POST /llm/{route}/anthropic/v1/messages |
messages |
| Anthropic token count | POST /llm/{route}/anthropic/v1/messages/count_tokens |
messages.count_tokens (not metered) |
The Anthropic paths work only on routes whose adapter is anthropic or bedrock (Agent.spec.model.adapter). On another route the gateway answers 400 invalid_request_error with unsupported_by_provider. Upstream adapters and their settings are in Connect model providers.
Step 1: Get a model route#
| Where | Route | Variables your agent gets |
|---|---|---|
| Cluster | <namespace>.<agent>, created from Agent.spec.model.endpoint |
MAQPNA_MODEL_ENDPOINT, MAQPNA_MODEL_ROUTE, MAQPNA_MODEL_NAME, MAQPNA_MODEL_PROVIDER; MAQPNA_ANTHROPIC_BASE_URL for Anthropic-adapter routes |
| Local, scripted model | maqpna dev up --stub-llm gives route stub |
OPENAI_BASE_URL, OPENAI_API_KEY (the session token), MAQPNA_MODEL_ENDPOINT, MAQPNA_MODEL_NAME=stub |
| Local, a real model | maqpna dev up --model local=http://127.0.0.1:11434/v1,qwen2.5:0.5b (any OpenAI-compatible server) |
the same, when it is the only route |
maqpna dev run exports the model variables for route stub when it exists, otherwise for the only --model route. The token's models:* scope (the local default) or models:<route> (in a cluster, granted automatically) lets the session use it.
Step 2: Make a call#
from openai import OpenAI
from maqpna import MaqpnaClient
client = MaqpnaClient.from_env()
llm = OpenAI(base_url=client.model_endpoint(), api_key=client.token())
reply = llm.chat.completions.create(
model=client.model_name() or "default", # the gateway forces the route's model anyway
messages=[{"role": "user", "content": "Summarise issue 4821"}],
extra_headers=client.openai_headers(), # fresh token per request; it rotates in place
)
print(reply.choices[0].message)
import OpenAI from "openai";
import { MaqpnaClient } from "@kosa8/maqpna";
const client = MaqpnaClient.fromEnv();
const llm = new OpenAI({ baseURL: client.modelEndpoint(), apiKey: await client.token() });
const reply = await llm.chat.completions.create(
{ model: client.modelName() ?? "default", messages: [{ role: "user", content: "Summarise issue 4821" }] },
{ headers: await client.openaiHeaders() },
);
console.log(reply.choices[0].message);
hdr, err := client.OpenAIHeaders(ctx) // Authorization: Bearer <current token> + traceparent
if err != nil {
return err
}
body := `{"model":"default","messages":[{"role":"user","content":"Summarise issue 4821"}]}`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
client.ModelBaseURL("")+"/chat/completions", strings.NewReader(body))
req.Header = hdr
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
ModelBaseURL("") returns MAQPNA_MODEL_ENDPOINT, or "" when no route is configured; ModelBaseURL("stub") builds {gateway}/llm/stub/v1. sdk/go/examples/model-loop is a complete tool-calling loop.
curl -sS "$MAQPNA_MODEL_ENDPOINT/chat/completions" \
-H "Authorization: Bearer $MAQPNA_TOKEN" -H 'Content-Type: application/json' \
-d '{"model":"stub","messages":[{"role":"user","content":"hi"}]}'
With --stub-llm the scripted model answers with a tool call:
{"choices":[{"finish_reason":"tool_calls","index":0,"message":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\"text\":\"hello from stub-llm\"}","name":"echo"},"id":"call_chatcmpl-stub-1_0","type":"function"}]}}],"created":1792359118,"id":"chatcmpl-stub-1","model":"stub","object":"chat.completion","usage":{"completion_tokens":44,"prompt_tokens":8,"total_tokens":52}}
Without a route, model_endpoint() / modelEndpoint() raise ConfigError. They never fall back to a public API. Locally you can also pass the route name: client.model_endpoint("stub").
Point an existing SDK at the gateway#
Agents that already use an SDK need two settings, no code change:
# OpenAI SDK: reads OPENAI_BASE_URL and OPENAI_API_KEY (maqpna dev run sets both with --stub-llm)
export OPENAI_BASE_URL="$MAQPNA_MODEL_ENDPOINT"
export OPENAI_API_KEY="$(cat "$MAQPNA_TOKEN_FILE")"
# Anthropic SDK and Claude Agent SDK, on a route with adapter anthropic or bedrock
export ANTHROPIC_BASE_URL="$MAQPNA_ANTHROPIC_BASE_URL"
export ANTHROPIC_API_KEY="$(cat "$MAQPNA_TOKEN_FILE")"
import Anthropic from "@anthropic-ai/sdk";
// On a route with adapter anthropic or bedrock; the SDK sends the key as x-api-key,
// which the gateway accepts as the session token.
const anthropic = new Anthropic({ baseURL: process.env.MAQPNA_ANTHROPIC_BASE_URL, apiKey: await client.token() });
An API key exported once from the file goes stale when the operator refreshes the token (at most every 15 minutes in a cluster). For long-running agents, build the client per task, or pass the SDK's per-request headers (client.openai_headers(), await client.openaiHeaders(), client.OpenAIHeaders(ctx)).
Streaming (stream=True, stream: true) works on chat completions, completions, responses and Anthropic messages. The gateway relays the server-sent events, runs DLP on the stream and always asks the upstream for usage, so streamed calls are metered.
What applies to model calls#
| Control | Set in | Effect on your call |
|---|---|---|
| Scope | Token models:<route> |
Without it: 403 maqpna_unauthorized. |
| Budgets | Agent.spec.budget, BudgetPolicy |
Spent: 402 maqpna_budget_exceeded. Model spend also counts against tool-call budgets. |
| Tokens per minute | Agent.spec.model.tokensPerMinute |
Exhausted: 429 maqpna_rate_limited with Retry-After. |
| Max tokens per request | Agent.spec.model.maxTokensPerRequest |
max_tokens (or max_completion_tokens, max_output_tokens) is clamped or set. No error. |
| DLP | Agent.spec.model.dlpProfile, gateway dlp bindings |
Prompt or completion blocked: 403 maqpna_dlp_blocked; or the match is redacted. |
| Kill switch | maqpna kill |
403 maqpna_revoked. |
| Policy | Only with the gateway's modelPolicy: policy |
403 maqpna_policy_denied. The default scope-only does not evaluate ToolPolicy rules for model calls. |
| Approvals | — | Model calls never wait: a require_approval decision is a denial (maqpna_approval_required). |
Errors are OpenAI-shaped (Anthropic-shaped on /anthropic/ paths), with MAQPNA's reason and domain, so the OpenAI SDKs raise their normal APIStatusError subclasses:
{"error":{"code":"budget_exceeded","domain":"maqpna.com","message":"namespace budget (day) exhausted for team-a: spent 200.000000 of 200.000000 USD","reason":"budget_exceeded","type":"maqpna_budget_exceeded"}}
How to react to each in code is in Handle decisions. The full list is in Error and reason codes.
Check it worked#
maqpna dev timeline --last
TIME KIND SERVER/TOOL DECISION DETAIL
17:31:58 model_call llm/stub/chat.completions allow scope:models:stub; model:stub; usage:prompt=8,completion=44
Each model call is one row with its token usage. maqpna costs summary --scope agent --window day totals spend and tokens per agent; maqpna session cost SESSION shows one session.
What can go wrong#
| Symptom | Reason | Fix |
|---|---|---|
ConfigError: no model route configured |
No MAQPNA_MODEL_ENDPOINT. |
Locally start with --stub-llm or --model; in a cluster set Agent.spec.model.endpoint. |
404 maqpna_not_found route_not_found |
Wrong route name in the URL. | Use MAQPNA_MODEL_ENDPOINT as is. |
400 unsupported_by_provider on /anthropic/v1/messages |
The route's adapter is not anthropic or bedrock. |
Use the OpenAI format, or ask for an Anthropic-adapter route. |
401 after some minutes |
The API key was read once and the token rotated. | Pass fresh headers per request. |
502 maqpna_upstream_error |
No endpoint answered (all down or circuits open). | Retry with backoff; check fallbacks with the platform team. |