API reference
FitGuard speaks the OpenAI API. If your code already calls OpenAI, the base URL and the key are the only things that change. This page documents what you send, what comes back, and the errors you can receive.
http://localhost:8787 locally, or your own domain in production. The OpenAI-compatible endpoints live under /v1, so point your SDK at http://localhost:8787/v1.
Authentication
Send the virtual key FitGuard issued you (from fitguard init), not your real provider key:
Authorization: Bearer sk-guard-a1b2c3...
FitGuard resolves that key to a user_id server-side, applies that user's budget, and attaches your
real provider credentials only on the upstream leg. Your application code and logs never contain them.
If keys: is empty in your config, FitGuard runs in single-tenant mode: no header is required and every
caller shares one "default" identity. Convenient locally, unsafe once more than one caller can reach the port.
POST /v1/chat/completions
OpenAI-compatible, including stream: true, multimodal (image) content, and tool/function calling.
The body is forwarded to the resolved provider largely as-is, translated where the provider needs a different shape
(see Providers).
Request
| Field | Required | Notes |
|---|---|---|
model | Yes | Determines which provider serves the request. See model routing |
messages | Yes | Standard OpenAI message array. Used to size the budget estimate |
max_tokens | No | Drives the worst-case budget reservation. Unset means a 4096-token ceiling is assumed |
stream | No | true returns SSE. Fully supported, including cache and fallback |
tools, tool_choice | No | Translated per provider |
| Everything else | — | Passed through to the provider untouched |
curl http://localhost:8787/v1/chat/completions \
-H "Authorization: Bearer sk-guard-a1b2c3..." \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Say hello"}],
"max_tokens": 100
}'from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8787/v1",
api_key="sk-guard-a1b2c3...",
)
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Say hello"}],
max_tokens=100,
)
print(resp.choices[0].message.content)import OpenAI from "openai";
const client = new OpenAI({
baseURL: "http://localhost:8787/v1",
apiKey: "sk-guard-a1b2c3...",
});
const resp = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Say hello" }],
max_tokens: 100,
});
console.log(resp.choices[0].message.content);using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("http://localhost:8787/v1/") };
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "sk-guard-a1b2c3...");
var payload = new {
model = "gpt-4o",
messages = new[] { new { role = "user", content = "Say hello" } },
max_tokens = 100
};
var response = await client.PostAsJsonAsync("chat/completions", payload);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);HttpClient client = HttpClient.newHttpClient();
String json = """
{"model":"gpt-4o","messages":[{"role":"user","content":"Say hello"}],"max_tokens":100}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8787/v1/chat/completions"))
.header("Authorization", "Bearer sk-guard-a1b2c3...")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());body, _ := json.Marshal(map[string]any{
"model": "gpt-4o",
"messages": []map[string]string{{"role": "user", "content": "Say hello"}},
"max_tokens": 100,
})
req, _ := http.NewRequest("POST", "http://localhost:8787/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer sk-guard-a1b2c3...")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
let client = reqwest::blocking::Client::new();
let resp = client.post("http://localhost:8787/v1/chat/completions")
.bearer_auth("sk-guard-a1b2c3...")
.json(&serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Say hello"}],
"max_tokens": 100
}))
.send()?;
Response headers
FitGuard adds these headers on top of a normal OpenAI response body. They're the quickest way to confirm the gateway is doing what you expect.
| Header | Value | Meaning |
|---|---|---|
X-Cache | HIT / MISS | A HIT cost you nothing and never touched the provider |
X-AI-Guard-Model-Used | model name | What actually served it. Differs from your requested model when fallback kicked in |
X-AI-Guard-Warning | truncation notice | Only present when finish_reason: "length". The strongest signal of a runaway or truncated generation |
X-Cache and X-AI-Guard-Model-Used, but never X-AI-Guard-Warning. HTTP headers are sent before the first byte of the body, and FitGuard doesn't know the finish reason until the stream ends. A truncated streamed answer is flagged in the log and the dashboard, not to the client.
Response
Standard OpenAI chat.completion shape, regardless of which provider actually served the model. OpenAI/Groq/Together responses are already this shape and pass through unmodified; Anthropic and Gemini responses are translated into it.
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1735689600,
"model": "claude-sonnet-5",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "..." },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 34,
"total_tokens": 46
}
}
See Errors below for the failure shape.
Streaming
Set stream: true and you get standard OpenAI SSE chunks terminated by data: [DONE]. Three things are worth knowing:
- Fallback still protects you. FitGuard opens the upstream stream and checks it succeeded before writing anything to you. If it fails, it tries the next fallback model. You get a clean
502, never a half-open stream. - A cache hit still returns a stream. Cached responses are synthesized back into valid SSE, so clients that always stream still get the cache savings.
- Cost is settled when the stream ends. The budget reservation is taken up front and reconciled against real usage once the last chunk lands.
Errors
All errors are OpenAI-shaped, so existing SDK error handling works unmodified:
{
"error": {
"message": "daily budget of $5.00 exceeded or would be exceeded by this request",
"type": "budget_exceeded"
}
}
| Status | type | Cause |
|---|---|---|
400 | invalid_request_error | Malformed JSON, or model is missing |
401 | invalid_api_key | Missing or unrecognized bearer token. Multi-tenant mode only |
429 | budget_exceeded | This request's worst-case cost would exceed the user's remaining daily budget. Nothing was sent upstream |
502 | upstream_error | The primary model and every configured fallback failed |
For streaming requests, all of these are returned with their normal status codes before the stream is committed.
POST /v1/embeddings
OpenAI-compatible. Routed, cached, and budget-checked exactly like chat completions, with two deliberate differences:
- No fallback. One attempt against the requested model's provider. Chat fallback models aren't valid embedding models, and a model swap partway through an indexing run would write mismatched dimensions into your vector store.
- Anthropic returns
502with a clear message. It has no embeddings API, so FitGuard reports that rather than returning an empty result.
curl http://localhost:8787/v1/embeddings \
-H "Authorization: Bearer sk-guard-a1b2c3..." \
-H "Content-Type: application/json" \
-d '{"model": "text-embedding-3-small", "input": "hello world"}'resp = client.embeddings.create(
model="text-embedding-3-small",
input="hello world",
)
print(len(resp.data[0].embedding))const resp = await client.embeddings.create({
model: "text-embedding-3-small",
input: "hello world",
});
console.log(resp.data[0].embedding.length);using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("http://localhost:8787/v1/") };
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "sk-guard-a1b2c3...");
var payload = new { model = "text-embedding-3-small", input = "hello world" };
var response = await client.PostAsJsonAsync("embeddings", payload);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);HttpClient client = HttpClient.newHttpClient();
String json = """
{"model":"text-embedding-3-small","input":"hello world"}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8787/v1/embeddings"))
.header("Authorization", "Bearer sk-guard-a1b2c3...")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());body, _ := json.Marshal(map[string]any{
"model": "text-embedding-3-small",
"input": "hello world",
})
req, _ := http.NewRequest("POST", "http://localhost:8787/v1/embeddings", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer sk-guard-a1b2c3...")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
let client = reqwest::blocking::Client::new();
let resp = client.post("http://localhost:8787/v1/embeddings")
.bearer_auth("sk-guard-a1b2c3...")
.json(&serde_json::json!({
"model": "text-embedding-3-small",
"input": "hello world"
}))
.send()?;
Response
Standard OpenAI embeddings shape. OpenAI/Groq/Together pass through unmodified; Gemini is translated into it. Anthropic returns the standard error shape (see Errors) with upstream_error, not a response like this.
{
"object": "list",
"data": [
{ "object": "embedding", "index": 0, "embedding": [0.0023, -0.009, ...] }
],
"model": "text-embedding-3-small",
"usage": { "prompt_tokens": 2, "total_tokens": 2 }
}
POST /v1/images/generations
OpenAI-compatible. Routed and budget-checked like chat completions, with two deliberate differences: no cache (image generation isn't deterministic, so caching a prompt would return the same stale image forever) and no fallback (same reasoning as embeddings — a silent model swap changes the actual image, not just how it's produced).
upstream_error naming a provider that does. See Providers.
curl http://localhost:8787/v1/images/generations \
-H "Authorization: Bearer sk-guard-a1b2c3..." \
-H "Content-Type: application/json" \
-d '{"model": "gpt-image-1", "prompt": "a red panda reading a book", "n": 1}'resp = client.images.generate(
model="gpt-image-1",
prompt="a red panda reading a book",
n=1,
)
print(resp.data[0].b64_json[:32], "...")const resp = await client.images.generate({
model: "gpt-image-1",
prompt: "a red panda reading a book",
n: 1,
});
console.log(resp.data[0].b64_json?.slice(0, 32), "...");using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("http://localhost:8787/v1/") };
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "sk-guard-a1b2c3...");
var payload = new { model = "gpt-image-1", prompt = "a red panda reading a book", n = 1 };
var response = await client.PostAsJsonAsync("images/generations", payload);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);HttpClient client = HttpClient.newHttpClient();
String json = """
{"model":"gpt-image-1","prompt":"a red panda reading a book","n":1}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8787/v1/images/generations"))
.header("Authorization", "Bearer sk-guard-a1b2c3...")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());body, _ := json.Marshal(map[string]any{
"model": "gpt-image-1",
"prompt": "a red panda reading a book",
"n": 1,
})
req, _ := http.NewRequest("POST", "http://localhost:8787/v1/images/generations", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer sk-guard-a1b2c3...")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
let client = reqwest::blocking::Client::new();
let resp = client.post("http://localhost:8787/v1/images/generations")
.bearer_auth("sk-guard-a1b2c3...")
.json(&serde_json::json!({
"model": "gpt-image-1",
"prompt": "a red panda reading a book",
"n": 1
}))
.send()?;
Response: standard OpenAI shape, {"data": [{"b64_json": "..."}]}.
Audio
Two OpenAI-compatible endpoints, both routed and budget-checked like chat completions, with the same no-cache/no-fallback reasoning as images above.
POST /v1/audio/speech
curl http://localhost:8787/v1/audio/speech \
-H "Authorization: Bearer sk-guard-a1b2c3..." \
-H "Content-Type: application/json" \
-d '{"model": "tts-1", "input": "Hello from FitGuard", "voice": "alloy"}' \
--output speech.mp3resp = client.audio.speech.create(
model="tts-1",
voice="alloy",
input="Hello from FitGuard",
)
resp.stream_to_file("speech.mp3")const resp = await client.audio.speech.create({
model: "tts-1",
voice: "alloy",
input: "Hello from FitGuard",
});
const buffer = Buffer.from(await resp.arrayBuffer());
await fs.promises.writeFile("speech.mp3", buffer);using System.Net.Http.Json;
var client = new HttpClient { BaseAddress = new Uri("http://localhost:8787/v1/") };
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "sk-guard-a1b2c3...");
var payload = new { model = "tts-1", voice = "alloy", input = "Hello from FitGuard" };
var response = await client.PostAsJsonAsync("audio/speech", payload);
await File.WriteAllBytesAsync("speech.mp3", await response.Content.ReadAsByteArrayAsync());HttpClient client = HttpClient.newHttpClient();
String json = """
{"model":"tts-1","voice":"alloy","input":"Hello from FitGuard"}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8787/v1/audio/speech"))
.header("Authorization", "Bearer sk-guard-a1b2c3...")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
Files.write(Path.of("speech.mp3"), response.body());body, _ := json.Marshal(map[string]any{
"model": "tts-1",
"voice": "alloy",
"input": "Hello from FitGuard",
})
req, _ := http.NewRequest("POST", "http://localhost:8787/v1/audio/speech", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer sk-guard-a1b2c3...")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
let client = reqwest::blocking::Client::new();
let resp = client.post("http://localhost:8787/v1/audio/speech")
.bearer_auth("sk-guard-a1b2c3...")
.json(&serde_json::json!({
"model": "tts-1",
"voice": "alloy",
"input": "Hello from FitGuard"
}))
.send()?;
Response: raw audio bytes (audio/mpeg), not JSON.
POST /v1/audio/transcriptions
multipart/form-data, not JSON — the one endpoint on this page that isn't a plain JSON body.
curl http://localhost:8787/v1/audio/transcriptions \ -H "Authorization: Bearer sk-guard-a1b2c3..." \ -F file=@meeting.mp3 \ -F model=whisper-1
with open("meeting.mp3", "rb") as f:
resp = client.audio.transcriptions.create(model="whisper-1", file=f)
print(resp.text)const resp = await client.audio.transcriptions.create({
model: "whisper-1",
file: fs.createReadStream("meeting.mp3"),
});
console.log(resp.text);using var client = new HttpClient { BaseAddress = new Uri("http://localhost:8787/v1/") };
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "sk-guard-a1b2c3...");
using var form = new MultipartFormDataContent();
form.Add(new StreamContent(File.OpenRead("meeting.mp3")), "file", "meeting.mp3");
form.Add(new StringContent("whisper-1"), "model");
var response = await client.PostAsync("audio/transcriptions", form);
Console.WriteLine(await response.Content.ReadAsStringAsync());String boundary = "----FitGuardBoundary";
Path file = Path.of("meeting.mp3");
String body =
"--" + boundary + "\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\nwhisper-1\r\n" +
"--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"meeting.mp3\"\r\n\r\n";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8787/v1/audio/transcriptions"))
.header("Authorization", "Bearer sk-guard-a1b2c3...")
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(
buildMultipartBody(body, file, boundary))) // your own multipart helper
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("model", "whisper-1")
part, _ := mw.CreateFormFile("file", "meeting.mp3")
f, _ := os.Open("meeting.mp3")
io.Copy(part, f)
mw.Close()
req, _ := http.NewRequest("POST", "http://localhost:8787/v1/audio/transcriptions", &buf)
req.Header.Set("Authorization", "Bearer sk-guard-a1b2c3...")
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, _ := http.DefaultClient.Do(req)
let client = reqwest::blocking::Client::new();
let form = reqwest::blocking::multipart::Form::new()
.text("model", "whisper-1")
.file("file", "meeting.mp3")?;
let resp = client.post("http://localhost:8787/v1/audio/transcriptions")
.bearer_auth("sk-guard-a1b2c3...")
.multipart(form)
.send()?;
Response: standard OpenAI shape, {"text": "..."}.
GET /healthz
Unauthenticated liveness check. Point your load balancer, Kubernetes probe, or uptime monitor here.
$ curl http://localhost:8787/healthz
{"status":"ok"}
Dashboard API
The dashboard is a client of a normal JSON API, so anything it shows, you can pull into your own tooling. These endpoints require a dashboard session cookie when a dashboard login is configured, and are open when it isn't. They're separate from the virtual-key auth above — see dashboard login.
| Endpoint | Returns |
|---|---|
GET /dashboard | The HTML dashboard, or the login page if you aren't signed in |
GET /dashboard/api/data | One JSON snapshot: spend, cache hit rate, per-user totals, top expensive requests, time series |
GET /dashboard/api/requests | Paginated, filterable request log |
GET /dashboard/api/report | Summary for an explicit date range. Add ?format=csv for a download |
GET /dashboard/api/whoami | Who's signed in, and whether auth is enabled at all |
GET /dashboard/api/settings | The current editable settings: cache on/off, cache TTL, per-user daily budgets, fallback list |
PUT /dashboard/api/settings | Replaces the editable settings, applies them live, and writes them to config.yaml. See below |
GET /dashboard/events | Server-Sent Events. The same snapshot pushed every 3 seconds |
POST /dashboard/login | Sets the session cookie. Rate-limited to 5 attempts/minute per IP |
POST /dashboard/logout | Clears the session cookie |
Query parameters
| Parameter | Applies to | Values |
|---|---|---|
range | data, requests, events | today (default), 7d, 30d |
user | all | Scope to one user_id |
model | requests, report | Scope to one model name |
status | requests, report | success or error |
sort | requests | Sort column for the log |
limit, offset | requests | Page size (default 25, max 100) and offset |
from, to | requests, report | YYYY-MM-DD, inclusive both ends. Required for report |
format | report | csv for a downloadable export |
# Last month's spend for one user, as CSV curl -b cookies.txt \ "http://localhost:8787/dashboard/api/report?from=2026-07-01&to=2026-07-31&user=user_123&format=csv" \ -o july.csv
Live settings
GET/PUT /dashboard/api/settings read and write the same
things the dashboard's Settings page does: cache.enabled,
cache.ttl_seconds, per-user daily_limit_usd, and the
fallback list. Changes apply to the running process immediately, no restart, and are
written back to config.yaml. The response also includes providers
(configured provider names) and available_models (chat models those providers serve) as
read-only context for building a fallback picker — sending them back in a PUT has no effect.
Provider api_key/base_url, virtual
keys:, and session_secret are not writable through this
endpoint. They aren't hidden fields, they simply aren't part of the type this endpoint reads or writes
— there is no code path from a dashboard session to a provider credential. Use
fitguard add-provider to add a provider.
curl -b cookies.txt http://localhost:8787/dashboard/api/settings
{
"cache_enabled": true,
"cache_ttl_seconds": 300,
"fallback": ["gpt-4o-mini"],
"users": { "user_123": 5 },
"providers": ["openai"],
"available_models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "..."]
}
PUT takes the same shape and replaces the whole thing (not a partial patch), returning what was actually applied:
curl -b cookies.txt -X PUT http://localhost:8787/dashboard/api/settings \
-H "Content-Type: application/json" \
-d '{"cache_enabled":true,"cache_ttl_seconds":21600,"fallback":["gpt-4o-mini"],"users":{"user_123":5}}'
Every write is validated before anything changes, so a rejected request leaves both the running config and the file untouched. Rules worth knowing:
cache_ttl_secondsmust be between 1 and 2,592,000 (30 days).- Removing a user's budget while a virtual key still maps to them is refused. A
keys:entry with no matching budget is treated as unlimited spend, so silently dropping the budget would uncap that key. Set the limit to0instead if you mean for it to be unmetered — that's explicit and stays visible inconfig.yaml. - Every
fallbackentry must be served by a configured provider, or the whole write is rejected with an error naming the missing provider and listing valid models. Add the provider first withfitguard add-provider. - Adding a new
user_idunderusersissues it a virtual API key, returned once in the response asissued_keys({"user_id": "sk-guard-..."}). It isn't retrievable again after this response, so the caller must capture it immediately.