Using the proxy / BYOK.
How this site keeps the TypeSafe key on the server, and how to bring your own.
Why a proxy#
A TypeSafe key in client-side JavaScript is a key on the internet. So the studio and every “run it” button on this site never talk to TypeSafe directly: the browser runs the chain (the runtime, the trace, the decisions are all local), and only the HTTP calls to Jev go through one small route handler on this server, /api/jev, which adds the key and forwards them.
- The key stays server-side.
TYPESAFE_API_KEYis read from the server's environment and sent upstream as a bearer token. It never reaches the browser. - Bodies are shape-checked first. JSON only, 64 KB max, a
model, astate, and 1 to 64 named questions that each have atype. Garbage is rejected before it costs anyone a request. - Everything else passes through. TypeSafe's status, body and
retry-aftercome back untouched, so the client's error classes and retries work exactly as they would against the real API. Upstream gets 30 seconds before the proxy answers 504.
/api/jev.My toaster whispers my name at 3am and the bread comes out cold.
Pointing the client at it#
The client builds its endpoint as baseURL + path. Point baseURL at the proxy, empty the path, and pass apiKey: null so no authorization header is sent. A browser client for this site's proxy looks like this:
import { createJev } from "jevchain";
import { jevHeaders } from "@/lib/byok";
export const jev = createJev({
apiKey: null, // no key in the browser, ever
baseURL: "/api/jev", // our route handler
path: "", // baseURL is already the whole endpoint
// Add the BYOK header (if any) per request, so changing keys needs no new client.
fetch: (url, init) => fetch(url, { ...init, headers: jevHeaders(init?.headers) }),
});Batching, concurrency limits, timeouts and retries all still happen in the browser, before the proxy sees anything. A four-way parallel over the same state is still one request.
Building your own? The core is a dozen lines:
// app/api/jev/route.ts: the smallest proxy that works
export async function POST(req: Request) {
const upstream = await fetch("https://api.typesafe.ai/v1/systemone", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.TYPESAFE_API_KEY}`,
"content-type": "application/json",
},
body: await req.text(),
signal: AbortSignal.timeout(_000),
});
// Pass status, body and retry-after through untouched, so the client's
// error classes and retry logic behave exactly as if it talked to TypeSafe.
const headers = new Headers({ "content-type": upstream.headers.get("content-type") ?? "application/json" });
const retryAfter = upstream.headers.get("retry-after");
if (retryAfter) headers.set("retry-after", retryAfter);
return new Response(await upstream.text(), { status: upstream.status, headers });
}Bring your own key#
Hit the key button in the top bar to use your own TypeSafe key instead of the shared one. Where it goes:
- It's saved in this browser's localStorage (
jevchain.byok) and nowhere else. - It's sent only to this site's own
/api/jev, as thex-typesafe-keyheader, which forwards it to TypeSafe as the bearer token for that one request. The server doesn't log or store it. - A header that isn't plausibly a key (whitespace, or over 512 characters) is rejected with a 400. Remove the key and you're back on the shared one.
With no BYOK header and no TYPESAFE_API_KEY on the server, the proxy answers 401 missing_key, which the client surfaces as a JevAuthError. GET /api/jev is a tiny health check that says whether a shared key is configured:
GET /api/jev
→ { "ok": true, "serverKey": true }Rate limiting#
| name | type | what it does |
|---|---|---|
| shared key | 60 / minute | Per client (first x-forwarded-for address, else x-real-ip). Keeps one tab from melting the demo key. |
| your key | 600 / minute | You're spending your own quota; this only stops runaway loops. |
The limiter is a sliding-window log: it keeps each client's request timestamps for the last 60 seconds and admits a request only while that window holds fewer than the limit. It's in memory, per server instance, so it's a guard rail, not a distributed quota. Every response past the limiter carries x-ratelimit-limit and x-ratelimit-remaining; a refusal is a 429 with retry-after:
HTTP/1.1 429 Too Many Requests
retry-after: 12
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
{ "error": { "type": "rate_limited",
"message": "the shared key needs a breather — 60 requests a minute per person. ..." } }Because it's a real 429 with retry-after, the client treats it like TypeSafe's own: it waits and retries (up to maxRetryAfterMs), and records each retry in the trace. All of the proxy's own errors share the { error: { type, message } } shape, with type one of rate_limited, payload_too_large, invalid_request, missing_key or upstream_unreachable.