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.

Base URL. Everything below is relative to wherever FitGuard is running: 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

FieldRequiredNotes
modelYesDetermines which provider serves the request. See model routing
messagesYesStandard OpenAI message array. Used to size the budget estimate
max_tokensNoDrives the worst-case budget reservation. Unset means a 4096-token ceiling is assumed
streamNotrue returns SSE. Fully supported, including cache and fallback
tools, tool_choiceNoTranslated per provider
Everything elsePassed 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.

HeaderValueMeaning
X-CacheHIT / MISSA HIT cost you nothing and never touched the provider
X-AI-Guard-Model-Usedmodel nameWhat actually served it. Differs from your requested model when fallback kicked in
X-AI-Guard-Warningtruncation noticeOnly present when finish_reason: "length". The strongest signal of a runaway or truncated generation
Streaming caveat. Streamed responses set 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:

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"
  }
}
StatustypeCause
400invalid_request_errorMalformed JSON, or model is missing
401invalid_api_keyMissing or unrecognized bearer token. Multi-tenant mode only
429budget_exceededThis request's worst-case cost would exceed the user's remaining daily budget. Nothing was sent upstream
502upstream_errorThe 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:

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).

Not every provider supports this. OpenAI, Gemini, and Together do. Anthropic and Groq don't and return a clear 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.

Support varies by provider. OpenAI and Together support both. Gemini supports speech only (no transcription endpoint yet). Groq supports transcription only (its only TTS model is preview/evaluation-only, deliberately not wired in). Anthropic supports neither. See Providers.

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.mp3
resp = 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.

EndpointReturns
GET /dashboardThe HTML dashboard, or the login page if you aren't signed in
GET /dashboard/api/dataOne JSON snapshot: spend, cache hit rate, per-user totals, top expensive requests, time series
GET /dashboard/api/requestsPaginated, filterable request log
GET /dashboard/api/reportSummary for an explicit date range. Add ?format=csv for a download
GET /dashboard/api/whoamiWho's signed in, and whether auth is enabled at all
GET /dashboard/api/settingsThe current editable settings: cache on/off, cache TTL, per-user daily budgets, fallback list
PUT /dashboard/api/settingsReplaces the editable settings, applies them live, and writes them to config.yaml. See below
GET /dashboard/eventsServer-Sent Events. The same snapshot pushed every 3 seconds
POST /dashboard/loginSets the session cookie. Rate-limited to 5 attempts/minute per IP
POST /dashboard/logoutClears the session cookie

Query parameters

ParameterApplies toValues
rangedata, requests, eventstoday (default), 7d, 30d
userallScope to one user_id
modelrequests, reportScope to one model name
statusrequests, reportsuccess or error
sortrequestsSort column for the log
limit, offsetrequestsPage size (default 25, max 100) and offset
from, torequests, reportYYYY-MM-DD, inclusive both ends. Required for report
formatreportcsv 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:

Esc