← Templates

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 deploy

Three 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 curlOrigin 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.

Deploy this template