Skip to main content

Packages

6 min read

vite-plugin-api-proxy – dev-server proxy for AI providers

A thin Vite middleware that handles CORS preflight, injects API keys from .env server-side, and forwards request bodies to OpenAI, Anthropic, Ollama, Gemini, and anywhere else your dev client wants to reach. Dev-only. Not for production.


Threat model

The plugin runs inside Vite's middleware stack on your dev machine. Its job is to make localhost:5173 look, to your client code, like it can talk to api.openai.com – which the browser cannot do directly because of two real constraints:

  • CORS. OpenAI / Anthropic / Gemini do not ship permissive Access-Control-Allow-Origin headers. Direct browser calls are blocked at preflight.
  • Secrets. Embedding OPENAI_API_KEY in client code ships your billing key to GitHub the moment someone runs git push.

The plugin closes both gaps by becoming a same-origin endpoint (/api/openai) that injects the key from process.env and forwards the body. The threat model it accepts:

  • Untrusted request body. Capped at 10 MB (MAX_BODY_BYTES). Larger uploads are rejected with 413 Payload Too Large and remaining attacker bytes are drained without buffering, so the connection cannot snowball into an unbounded buffer.
  • Slowloris. Idle requests aborted after 30 seconds (REQUEST_TIMEOUT_MS).
  • Upstream header leaks. Only a narrow allowlist of upstream response headers reaches the browser – content-type, content-length, content-encoding, content-language, cache-control, etag, last-modified, expires, pragma, vary. Everything else – set-cookie, authorization, x-api-key, every x-internal-* header – is dropped explicitly so provider session cookies and rate-limit identifiers never reach client storage.
  • NOT a production gateway. The plugin runs in Vite's dev process only. It does not bundle into your client build. In production, use a real reverse proxy you can monitor, rate-limit, and lock down per-route.

Setup

// vite.config.ts
import { defineConfig } from "vite";
import { apiProxy } from "@directive-run/vite-plugin-api-proxy";

export default defineConfig({
  plugins: [
    apiProxy({
      routes: {
        "/api/openai": {
          target: "https://api.openai.com/v1/chat/completions",
          envKey: "OPENAI_API_KEY",  // loaded via Vite loadEnv
          headerKey: "Authorization", // sent as "Bearer ${key}" upstream
        },
        "/api/anthropic": {
          target: "https://api.anthropic.com/v1/messages",
          envKey: "ANTHROPIC_API_KEY",
          headerKey: "x-api-key",
          headers: { "anthropic-version": "2023-06-01" },
        },
      },
    }),
  ],
});

Client code now points at /api/openai instead of upstream – the plugin injects the secret and forwards.

// client – no API key in this file
const res = await fetch("/api/openai", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ model: "gpt-4o", messages }),
});
// → upstream sees Authorization: Bearer sk-...

Options

ApiProxyOptions

FieldTypeWhat
routesRecord<string, ProxyRoute>Path-to-route map. Key is the local path your client hits.

ProxyRoute

FieldTypeDefaultWhat
targetstringUpstream URL to forward to (full URL with path).
methodstring"POST"Single HTTP method the route accepts. Others get 405.
headersRecord<string, string>Extra headers forwarded upstream (e.g. anthropic-version).
envKeystring.env var name loaded via Vite loadEnv.
headerKeystringUpstream header that carries the key (e.g. Authorization, x-api-key). Also read from the client request – the request header wins over the env var if both are present.
envPrefixstringderived from envKeyloadEnv prefix filter. Derived as envKey.split("_")[0] if omitted.

Constants (re-exported)

SymbolValueWhat
MAX_BODY_BYTES10 * 1024 * 1024Body cap – 413 past this.
REQUEST_TIMEOUT_MS30_000Slowloris timeout – 408 past this.
RESPONSE_HEADER_ALLOWLISTSet<string>Forwardable upstream response headers. Anything else is dropped. Re-exported for tests + security-aware consumers who want to assert the surface stays narrow.

Examples

Multiple providers in one config

apiProxy({
  routes: {
    "/api/openai": {
      target: "https://api.openai.com/v1/chat/completions",
      envKey: "OPENAI_API_KEY",
      headerKey: "Authorization",
    },
    "/api/anthropic": {
      target: "https://api.anthropic.com/v1/messages",
      envKey: "ANTHROPIC_API_KEY",
      headerKey: "x-api-key",
      headers: { "anthropic-version": "2023-06-01" },
    },
    "/api/ollama": {
      target: "http://localhost:11434/api/chat",
      // No envKey/headerKey – local Ollama doesn't need auth
    },
  },
}),
// → three same-origin endpoints, three different auth schemes

Client-supplied key (BYOK)

When headerKey is set, the proxy reads the key from the client request first, falling back to envKey only if absent. That lets you ship a dev UI where the user pastes their own key for testing:

const res = await fetch("/api/openai", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    Authorization: userPastedKey, // ← overrides .env for this request
  },
  body: JSON.stringify(...),
});
// → upstream gets the user-pasted key

If neither the request header nor envKey resolves a key and envKey was configured, the proxy returns 401. If envKey was not configured at all, no key is injected and the upstream sees whatever the client sent.

Edge case – body too large

// 11 MB payload from a buggy script
const huge = new ArrayBuffer(11 * 1024 * 1024);
await fetch("/api/openai", { method: "POST", body: huge });
// → { error: "Payload too large" } with status 413

The proxy rejects with 413 as soon as the running total exceeds MAX_BODY_BYTES, drains remaining attacker bytes (without buffering), and avoids tearing down the socket – so the 413 response is actually readable by the client. No silent OOM under sustained abuse.


Security model – what stays narrow

  • ✅ Per-route method allowlist (405 for anything else).
  • ✅ Per-route env var resolution via Vite loadEnv (server-side only).
  • ✅ Response-header allowlist (RESPONSE_HEADER_ALLOWLIST).
  • ✅ Explicit deny on set-cookie, authorization, x-api-key, and x-internal-*.
  • ✅ Body cap (10 MB) with proper drain-on-reject.
  • ✅ Idle timeout (30 s) with proper 408.
  • ❌ No rate limiting – dev process, single user.
  • ❌ No request-body validation – upstream owns its schema.
  • ❌ No retry / backoff – your client handles transport errors.

What it does NOT do

  • ✅ Solves dev-time CORS for AI provider APIs.
  • ✅ Keeps API keys off the client bundle.
  • ✅ Drops cookies + auth headers from upstream responses.
  • ✅ Caps body size and idle time as DoS guards.
  • ❌ Not a production proxy – runs in Vite's dev server only.
  • ❌ Not a managed gateway – no rate limiting, no per-user quotas, no telemetry.
  • ❌ Not a generic reverse proxy – one route, one method, one upstream URL.
  • ❌ Not a streaming-aware client – body is collected in full before forwarding (the upstream response does stream back via proxyRes.pipe(res)).
  • ❌ Not bundled into your client build – the plugin lives in vite.config.ts only.

Production note

Dev-only plugin

Use a real reverse proxy in production – Cloudflare Workers, a Node server, Nginx with auth headers, or a managed gateway. Things you want that this plugin does not give you: per-user rate limits, per-key quotas, structured logging, request schema validation, retry/backoff, circuit breaking, observability hooks. Never ship a bundled API key.


See also

Previous
Query (data fetching)

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management