On 17 October, people using OpenCode woke up to bills they didn't run up. OpenCode confirmed a security breach linked to what it called "a large scale distillation attack" that hit 1.4% of its Console users. Stolen API keys were used to rack up charges. It told customers to revoke keys and change passwords, and said it is working with Google, OpenAI and Anthropic to clean up.
OpenCode hasn't said exactly how the keys were taken, so I won't guess. But the pattern is old and it keeps getting more expensive: a raw provider key sits on a laptop, in a CI secret or in a client app, someone gets it, and they spend your money as fast as the API allows. With agents running models in loops, "as fast as the API allows" is now a big number.
The fix isn't one setting. It's two habits: real keys never leave one server, and every app has a hard daily budget. Here's a small, tested proxy that does both, and a checklist for the rest.
TL;DR
- Apps and laptops get an internal key. Only the proxy holds the real provider key.
- The proxy prices each response from its token usage and returns
429once an app passes its daily budget. - It also caps
max_tokensper app, refuses unknown keys, and prices unknown models high on purpose. - About 115 lines of standard-library Python. Tested below against a mock API.
The proxy
#!/usr/bin/env python3
"""llm_budget_proxy.py: keep real LLM keys off laptops and cap spend per app.
Apps call this proxy with their own internal key. The proxy holds the real
provider key, forwards the request, reads token usage from the response, prices
it, and refuses calls once an app passes its daily budget.
REAL_API_KEY=sk-... UPSTREAM=https://api.deepseek.com python3 llm_budget_proxy.py
Config is the APPS and PRICES dicts below. Standard library only. Non-streaming
OpenAI-compatible /v1/chat/completions requests only (see caveats in the post).
"""
import json, os, sys, threading, time, urllib.request, urllib.error
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
UPSTREAM = os.environ.get("UPSTREAM", "https://api.deepseek.com").rstrip("/")
REAL_KEY = os.environ.get("REAL_API_KEY", "")
LEDGER = os.environ.get("LEDGER", "spend.json")
# internal key -> app name, daily budget (USD), largest max_tokens allowed
APPS = {
"app-support-bot-7f3a": {"name": "support-bot", "daily_usd": 5.00, "max_tokens": 1024},
"app-dev-laptop-91c2": {"name": "dev-laptop", "daily_usd": 2.00, "max_tokens": 4096},
}
# USD per million tokens: (input, output), keyed by the exact model id you send.
# Example: DeepSeek V4.1 Flash list price, 17 Oct 2026. Add your own models.
PRICES = {"deepseek-v4.1-flash": (0.15, 0.60)}
FALLBACK = (10.0, 50.0) # unknown model ids are priced high on purpose
ALERT_AT = 0.8 # log a warning when an app passes 80% of its budget
lock = threading.Lock()
def today():
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def load():
try:
with open(LEDGER) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save(ledger):
tmp = LEDGER + ".tmp"
with open(tmp, "w") as f:
json.dump(ledger, f, indent=1)
os.replace(tmp, LEDGER)
def cost(model, usage):
pin, pout = PRICES.get(model, FALLBACK)
return (usage.get("prompt_tokens", 0) * pin + usage.get("completion_tokens", 0) * pout) / 1e6
class Proxy(BaseHTTPRequestHandler):
def reply(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != "/v1/chat/completions":
return self.reply(404, {"error": "only /v1/chat/completions is proxied"})
key = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
app = APPS.get(key)
if not app:
return self.reply(401, {"error": "unknown app key"})
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
if req.get("stream"):
return self.reply(400, {"error": "streaming is not supported by this proxy"})
req["max_tokens"] = min(int(req.get("max_tokens", app["max_tokens"])), app["max_tokens"])
with lock:
spent = load().get(today(), {}).get(app["name"], 0.0)
if spent >= app["daily_usd"]:
return self.reply(429, {"error": f"daily budget reached for {app['name']}",
"spent_usd": round(spent, 4), "budget_usd": app["daily_usd"]})
up = urllib.request.Request(UPSTREAM + self.path, data=json.dumps(req).encode(), method="POST",
headers={"Authorization": f"Bearer {REAL_KEY}",
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(up, timeout=120) as r:
status, data = r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return self.reply(e.code, {"error": "upstream error", "detail": e.read().decode()[:500]})
usd = cost(req.get("model", ""), data.get("usage", {}))
with lock:
ledger = load()
day = ledger.setdefault(today(), {})
day[app["name"]] = day.get(app["name"], 0.0) + usd
save(ledger)
total = day[app["name"]]
if total >= ALERT_AT * app["daily_usd"]:
print(f"[budget] {app['name']} at ${total:.4f} of ${app['daily_usd']:.2f}", file=sys.stderr)
self.reply(status, data)
def log_message(self, fmt, *args):
sys.stderr.write(f"{time.strftime('%H:%M:%S')} {self.address_string()} {fmt % args}\n")
if __name__ == "__main__":
if not REAL_KEY:
sys.exit("set REAL_API_KEY")
port = int(os.environ.get("PORT", "8787"))
print(f"proxying {UPSTREAM} on 127.0.0.1:{port}", file=sys.stderr)
ThreadingHTTPServer(("127.0.0.1", port), Proxy).serve_forever()
Run it next to your app:
REAL_API_KEY=sk-your-real-key UPSTREAM=https://api.deepseek.com python3 llm_budget_proxy.py
Then point your SDK at it. With the OpenAI Python SDK, for example:
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8787/v1", api_key="app-support-bot-7f3a")
The app's "key" is now worthless outside your network. If it leaks, you delete one line from APPS. The real key never left the server.
Testing it
I ran the proxy against a mock upstream that returns 120,000 input tokens and up to 900 output tokens per call, priced at DeepSeek V4.1 Flash's list price of $0.15 per million input tokens and $0.60 per million output. That's $0.01854 per call, so a $5 budget should last about 270 calls.
1 unknown key:
{"error": "unknown app key"} HTTP401
2 max_tokens clamp:
"seen_max_tokens": 1024
HTTP200
3 burn the budget:
call 270 -> {"error": "daily budget reached for support-bot", "spent_usd": 5.0058, "budget_usd": 5.0} HTTP429
4 other app:
HTTP200
5 unknown model priced high:
HTTP200
6 streaming:
{"error": "streaming is not supported by this proxy"} HTTP400
The ledger after the run:
{
"2026-10-18": {
"support-bot": 5.005799999999964,
"dev-laptop": 1.26354
}
}
What each line shows:
- A key that isn't in
APPSgets nothing. - The app asked for 50,000 output tokens and the upstream saw 1,024, the app's cap.
- Call 270 was refused after spend passed $5. The budget check runs before each call, so an app can overshoot by at most one call. Keep
max_tokenscaps tight and that overshoot stays small. - One app hitting its limit doesn't block the others.
- An unknown model ID was priced at the fallback rate ($10 / $50 per million), which is why
dev-laptopjumped to $1.26 in one call. If someone swaps in an expensive model, it burns budget fast instead of silently. - Streaming is refused rather than left unmetered.
The proxy also logs a warning once an app passes 80% of its budget:
[budget] support-bot at $4.9687 of $5.00
[budget] support-bot at $4.9873 of $5.00
[budget] support-bot at $5.0058 of $5.00
What this does not do
Be honest with yourself about the gaps before you rely on it:
- No streaming. Streaming responses report usage differently per provider. Add it before you route chat UIs through this.
- OpenAI-compatible chat only. Many providers offer that format (DeepSeek does), but native APIs with different request shapes need their own handler.
- The ledger is a JSON file with a lock, fine for one process. Use Redis or Postgres if you run several instances.
-
Prices are yours to maintain. The
PRICEStable has to match your provider's price page. That's why unknown models default to a high price. -
It doesn't stop a stolen real key. If the server holding
REAL_API_KEYis compromised, the proxy can't help. That's what the provider-side limits below are for.
The checklist for the rest
- Set spend limits at the provider too. Most providers let you cap spend or set budget alerts per organization, project or workspace. Use the lowest level they offer. The proxy is your first line, the provider limit is your backstop.
- One key per app and per environment. Never share a key between staging, production and laptops. When one leaks, you rotate one thing.
- No provider keys in client apps. Mobile and browser apps call your backend, never the model API directly.
- Keep keys out of repos and their history. Scan history, not just new commits. I published a script for that yesterday.
- Alert on spend velocity, not just totals. A $50 day that normally costs $5 is the signal. A monthly invoice is far too late.
- Rotate on a schedule, and immediately when a vendor in your chain reports a breach, like today.
Why this matters more with agents
A chat app makes one model call per message. An agent can make dozens per task, in a loop, unattended. A leaked key used to mean a surprise bill. With agents and fast, cheap models it can mean thousands of calls before anyone wakes up. Budgets per app turn "unlimited" into "annoying".
If you are putting LLMs into a product, this is part of what we harden at Axionry: key isolation, budgets, tenant separation and least-privilege tools for agents. And if you want to know what your AI feature will cost before you build it, our AI product cost estimator does the token math.
Sources: HuggingNews, "OpenCode Confirms Distillation Attack Hit 1.4% of Console Users" (17 October 2026); DeepSeek V4.1-Flash-1017 model card and pricing (17 October 2026). The proxy and test output above are from a real run on Python 3.
Top comments (0)