Call a model from your frontend without shipping your API key
A deployable proxy that keeps your OpenAI key and your system prompt on the server, puts a hard cost ceiling on every request, and shows you the tokens each one spent.
The key, the prompt, and the budget all stay on the server
Calling a model from the browser means shipping your API key in the bundle, your system prompt in plain sight, and an unmetered endpoint anyone can point a script at. This template moves all three behind a Function you deploy in about a minute: the key lives in write-only storage, the prompt is compiled into the deployed code where no request field can replace it, and every request has a ceiling before it costs anything.
The whole template is public — read it before you trust it.
Or scaffold it
$ npx wawesome init --template llm-proxy
$ npx wawesome deployThree things leak when a browser calls a model
The key. dangerouslyAllowBrowser is named that for a reason. Anything your
JavaScript can read, a visitor can read: the bundle is served to them, and the
key is in it. Bots scrape public bundles for exactly this, and the first sign is
usually the bill.
The prompt. A system prompt you have iterated on against real users is genuinely valuable work — the scope fence, the refusal policy, the tone. Sent from the browser, it is one open network tab away from being copied into a competitor’s product.
The budget. A key in the browser is a key with no ceiling on it: whoever takes it can send conversations as long as the model allows, on your account, until you notice. Moving the call server-side is what makes a ceiling possible at all — see below for exactly which ones this template puts on it, and which it does not.
None of these are fixed by an environment variable in your frontend build. A
VITE_ or NEXT_PUBLIC_ prefix is the build tool telling you it is going into
the bundle.
The endpoint
POST / ──▶ origin allowed? ──▶ under the ceilings? ──▶ provider
│ │
└── 403 └── 413
The client’s half of the contract is deliberately tiny:
const response = await fetch(PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: 'Where is my order?' }] }),
});
const { reply } = await response.json();
model, temperature and the output ceiling are not fields. A request
carrying one is refused rather than honoured — that is the difference between a
proxy and an open relay. So is a client-sent system message: it is refused
outright rather than sanitised, because sanitising it would imply the prompt is
part of this API. It is not. It is in src/system-prompt.ts, on the server.
Every request has a ceiling
Four constants at the top of src/policy.ts, so the most expensive request
anyone can send is bounded and calculable, whatever they put in the body.
| Limit | Default | What it stops |
|---|---|---|
| Body size | 24,000 chars | Parsing megabytes of JSON before refusing it |
| Messages | 12 | A client replaying a thousand-turn history at you |
| Prompt text | 8,000 chars | The input that actually costs money |
| Output tokens | 512 | An answer that bills for as long as it likes |
They are yours to change. The module that enforces them is pure and has no dependencies, which is why every refusal path is covered by tests without a single mock — you can read what it refuses before you trust it with a key.
Watch what each request costs, as it happens
A proxy that hides the key also hides the bill: the frontend no longer sees what it spent, and neither does the network tab. So the endpoint writes one line per request:
$ wawesome logs --follow
usage model=gpt-4o-mini prompt_tokens=412 completion_tokens=118 total_tokens=530 ms=1843
Set two per-million rates and the same line carries money:
usage model=gpt-4o-mini prompt_tokens=412 completion_tokens=118 total_tokens=530 ms=1843 cost_usd=0.000133
Rates are configuration rather than constants in the source, because model prices change and a hard-coded price silently becomes a lie.
A bad prompt is a rollback, not a redeploy
Prompts are edited far more often than code, and a prompt change that looked fine in testing can be obviously wrong on the first real conversation. Because the prompt is compiled into the Function, every version of it is a version you already have: promote the previous one and the old prompt is live again — no redeploy, no git revert, no waiting for a build.
What this is not
The origin list stops another website spending your budget from a visitor’s
tab, and the browser enforces it for you. It is not a defence against someone
running curl — Origin is a header, and a header is whatever the caller says
it is. If you need to stop that, check a session your app already issues, in the
same place the origin is checked.
There is also no cross-request rate limit here, deliberately. A limiter worth having counts requests across invocations, and a counter that resets between them would advertise protection it does not provide. What is here instead is a hard ceiling on each individual request, which needs no shared state to be true.
Getting it live
npx wawesome init --template llm-proxy
It asks for your API key and the origins allowed to call the endpoint, stores the key write-only, enables outbound calls to OpenAI for your App, deploys, and prints the URL to paste into your frontend.
Outbound access is default-deny per App, which is why that last step exists:
your Function can reach the provider it declared and nothing else. Pointing it
at a different OpenAI-compatible host means adding that host first — a fetch
to somewhere you did not authorise does not silently succeed.
- openai
- llm
- ai
- proxy
- api-key
- typescript
Ready in about a minute
Sign in with GitHub, deploy, and get a public HTTPS endpoint.