Getting started
Install it, generate a config, point your app at it. Ten minutes, most of which is waiting for your first API key to save.
Install
Pick whichever fits your setup. All four produce the same single binary.
curl -fsSL https://raw.githubusercontent.com/Oluiy/ai-cost-guard/main/install.sh | sh
npm install -g fitguard
go install github.com/Oluiy/ai-cost-guard/cmd/fitguard@latest
git clone https://github.com/Oluiy/ai-cost-guard.git cd ai-cost-guard docker build -t fitguard .
The install script detects your OS and architecture, downloads the matching binary from the
releases page,
verifies its checksum, and puts it in /usr/local/bin. Set
INSTALL_DIR to install somewhere else, or
VERSION to pin a specific release.
Installing with Go instead puts fitguard in
$(go env GOPATH)/bin, which needs to be on your
PATH. Prebuilt binaries for Linux, macOS, and Windows are attached to
every release if you'd rather download one directly.
curl -fsSLO https://raw.githubusercontent.com/Oluiy/ai-cost-guard/main/install.sh less install.sh # read it sh install.shOr skip the script entirely and use
go install, or download a binary and its checksum
from the releases page by hand.
Set it up
Run this from wherever you want config.yaml and the request log to live, commonly the root of the project you're protecting:
fitguard init
It asks which providers you're using, your real API key for each, and how much daily budget to give each caller. For every budgeted user it hands back a virtual key that looks like this:
Authorization: Bearer sk-guard-f81e09e76c5533dcb8e47e6eeeca09c354f2fbc8c24b2ab5
config.yaml but never shown again by the CLI itself, and that file also holds your real provider keys. Both are already in .gitignore if you're working inside a git repo, don't check either into source control.
Last step, it'll ask you to set up a login for the dashboard — a username and password, the same way Grafana or Coolify would. Say yes unless you're the only person who can reach this machine at all.
Run it
fitguard run
Starts listening on :8787 and prints the port, the providers it loaded, and a link to the dashboard. Leave it running, this is now the thing your app talks to instead of the provider directly.
Point your app at it
Same request shape you already send, different base_url and a virtual key instead of your real one:
curl http://localhost:8787/v1/chat/completions \
-H "Authorization: Bearer sk-guard-..." \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hello"}]
}'
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8787/v1", api_key="sk-guard-...")
client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "hello"}],
)
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "http://localhost:8787/v1",
apiKey: "sk-guard-...",
});
await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "hello" }],
});
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-...");
var payload = new {
model = "gpt-4o",
messages = new[] { new { role = "user", content = "hello" } }
};
await client.PostAsJsonAsync("chat/completions", payload);
HttpClient client = HttpClient.newHttpClient();
String json = """
{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8787/v1/chat/completions"))
.header("Authorization", "Bearer sk-guard-...")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
client.send(request, HttpResponse.BodyHandlers.ofString());
body, _ := json.Marshal(map[string]any{
"model": "gpt-4o",
"messages": []map[string]string{{"role": "user", "content": "hello"}},
})
req, _ := http.NewRequest("POST", "http://localhost:8787/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer sk-guard-...")
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-...")
.json(&serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hello"}]
}))
.send()?;
Anthropic and Groq models route automatically from the model field, no other change needed. Your real provider key never leaves config.yaml.
Already using Anthropic's own SDK?
This is the part worth reading carefully if your app currently calls Claude with
anthropic.Anthropic(...) and .messages.create(...).
FitGuard's client-facing endpoint is OpenAI-shaped, not Anthropic-shaped. There is no
/v1/messages route to point the native SDK's base URL at, and the
auth header is different (Authorization: Bearer, not
x-api-key). Pointing the Anthropic SDK straight at FitGuard will not work.
The integration is a small, mechanical swap: use an OpenAI-compatible client, keep the same Claude model name.
Before — direct to Anthropic:
from anthropic import Anthropic
client = Anthropic(api_key="sk-ant-...")
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system="You are a helpful assistant.",
messages=[{"role": "user", "content": "hello"}],
)
print(resp.content[0].text)
After — the OpenAI Python package, not Anthropic's, still returning a Claude answer:
from openai import OpenAI # yes, the OpenAI package — FitGuard speaks its request format
client = OpenAI(base_url="http://localhost:8787/v1",
api_key="sk-guard-...")
resp = client.chat.completions.create(
model="claude-sonnet-5", # unchanged: the same Claude model
max_tokens=1024,
messages=[
{
"role": "system",
"content": "You are a helpful assistant."
},
{"role": "user", "content": "hello"},
],
)
print(resp.choices[0].message.content)
The system prompt moves from a top-level field into a normal role: "system" message; FitGuard translates it back to Anthropic's shape on the way out. Tool calls and image content in messages are translated the same way, in both directions.
Full example: migrating an existing Anthropic integration
A complete, concrete walkthrough of everything above — install, set up, swap the code, run in dev, deploy to production — for a real project that already calls Anthropic directly. Available in Python, TypeScript, .NET, Java, Go, and Rust.
Streaming
Set stream: true like you would against the provider directly. Two things behave a little differently because of what streaming actually is:
- Fallback still works, but only before anything reaches you. If the primary model's connection fails, the next model in
fallback:is tried before a single byte is sent to your client. Once a stream has started, there's no handing it to a different provider mid-flight. - A cache hit still returns a real stream. The full answer is already known, so it's sent as one content chunk instead of the original token-by-token timing, but the response is still valid SSE your client parses the same way.
Budgets and virtual keys
A budget only means something if it's tied to an identity the caller can't choose for
itself. That's why authentication isn't an X-User-Id header or a
user field you set on the request, either of which a bug (or a bored
intern) can just omit. It's the Authorization: Bearer key
fitguard init generated for you, mapped server-side to a
user_id and a daily_limit_usd in
config.yaml.
Go over budget and the request never reaches the provider:
HTTP/1.1 429 Too Many Requests
{"error":{"message":"daily budget of $5.00 exceeded or would be exceeded by this request (spent $4.87)","type":"budget_exceeded"}}
See the configuration reference for adding more users, and the Redis backend if you're running more than one FitGuard instance.
The dashboard
Open http://localhost:8787/dashboard while fitguard run is up. It's served by the same binary, no separate setup: spend today, cache hit rate, spend by hour, spend by user, and the requests that cost the most, updating live. Pick a different time range or a single user from the controls at the top to narrow what you're looking at.
If you set up a login during fitguard init, you'll see a login form first — that account is separate from the virtual API keys above, it's not something your app ever sends, just you in a browser. Forgot the password? Run this on the machine fitguard is on, server doesn't need to be running:
fitguard reset-dashboard-password
It also works if you skipped the login during init and want to add one now.