From a0eaf2ef95e86dcb114e308b6090310ae78d8f9f Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sun, 13 Sep 2026 20:25:19 +0200 Subject: [PATCH] Fix AI Gateway CORS by proxying through Vite dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct browser fetch to https://ai-gateway.vercel.sh/v1 is blocked (No Access-Control-Allow-Origin). In dev the AI SDK now calls /__llm/v1 via same-origin Vite middleware (llmGatewayProxy), which forwards server-side to the real gateway — no CORS. Production still calls the gateway directly (deployments needing CORS-free prod should add a serverless /api/llm proxy). - vite.config.js: new llmGatewayProxy middleware for /__llm/* — handles OPTIONS preflight, forwards via Node fetch to x-llm-gateway (fallback Vercel), mirrors status/headers + access-control-allow-origin, preserves query/body. - src/llm/llmTweak.js: in DEV use baseURL /__llm/v1 + proxyFetch that injects x-llm-gateway; prod keeps cfg.gatewayUrl. Co-Authored-By: Claude --- flow-state/src/llm/llmTweak.js | 12 ++++- flow-state/vite.config.js | 99 +++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/flow-state/src/llm/llmTweak.js b/flow-state/src/llm/llmTweak.js index ffc8402..b1f89df 100644 --- a/flow-state/src/llm/llmTweak.js +++ b/flow-state/src/llm/llmTweak.js @@ -87,7 +87,17 @@ export async function requestSongTweak({ spec, track, look, hint = '', config = const messages = buildTweakPrompt({ spec, track, look, hint }); recordInvocation(); - const provider = createOpenAI({ baseURL: cfg.gatewayUrl, apiKey: cfg.apiKey }); + const isDev = import.meta.env && import.meta.env.DEV; + const proxyFetch = async (url, init = {}) => { + const headers = new Headers(init.headers || {}); + if (isDev) headers.set('x-llm-gateway', cfg.gatewayUrl); + return fetch(url, { ...init, headers }); + }; + const provider = createOpenAI({ + baseURL: isDev ? '/__llm/v1' : cfg.gatewayUrl, + apiKey: cfg.apiKey, + fetch: isDev ? proxyFetch : undefined, + }); const modelId = cfg.model; try { diff --git a/flow-state/vite.config.js b/flow-state/vite.config.js index 7f2dfc9..2d8a66d 100644 --- a/flow-state/vite.config.js +++ b/flow-state/vite.config.js @@ -46,8 +46,105 @@ function measurementWriter() { }; } +/** + * Proxy LLM calls through the dev server so the browser stays same-origin. + * + * The Vercel AI Gateway does not send CORS headers, so a direct + * `fetch('https://ai-gateway.vercel.sh/v1/...')` from + * `http://localhost:5180` is blocked. The AI SDK (`ai` + `@ai-sdk/openai`) + * runs in the browser and would hit the gateway directly. + * + * In dev the app instead calls `http://localhost:5180/__llm/v1/...` and this + * middleware forwards the request server-side (Node `fetch`) to the real + * gateway — no CORS involved. The target gateway is taken from the + * `x-llm-gateway` request header (client's configured gatewayUrl) so custom + * gateways keep working; absent, it falls back to Vercel. + * + * Production builds are static; there this route does not exist and the + * client falls through to a direct fetch. Deployments that need CORS-free + * production should add an equivalent `/api/llm` serverless proxy. + */ +function llmGatewayProxy() { + return { + name: 'flow-state-llm-gateway-proxy', + apply: 'serve', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (!req.url.startsWith('/__llm/')) return next(); + + // CORS preflight from the browser's fetch + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-llm-gateway', + 'Access-Control-Max-Age': '86400', + }); + res.end(); + return; + } + + const gatewayBase = (req.headers['x-llm-gateway'] && String(req.headers['x-llm-gateway'])) || 'https://ai-gateway.vercel.sh'; + // Strip the proxy prefix; the client sends `/__llm/v1/...`, + // so after stripping we have `/v1/...` which joins correctly. + const pathAfter = req.url.replace(/^\/__llm/, '') || '/'; + // Preserve query string + const targetUrl = new URL(pathAfter, gatewayBase.endsWith('/') ? gatewayBase : gatewayBase + '/'); + // But `new URL('/v1/x', 'https://host/v1')` drops the base path — + // `/v1/x` is absolute. Since `pathAfter` already starts with `/v1`, + // joining against the origin is correct; gatewayBase's path is just + // the fallback origin. So rebuild against origin only. + // Simpler: construct from origin + pathAfter + let origin; + try { + origin = new URL(gatewayBase).origin; + } catch { + origin = 'https://ai-gateway.vercel.sh'; + } + const finalUrl = origin + pathAfter; + + let bodyChunks = []; + req.on('data', (c) => bodyChunks.push(c)); + req.on('end', async () => { + try { + const body = bodyChunks.length ? Buffer.concat(bodyChunks) : undefined; + const headers = {}; + // Forward relevant headers + if (req.headers['content-type']) headers['content-type'] = req.headers['content-type']; + if (req.headers['authorization']) headers['authorization'] = req.headers['authorization']; + else if (req.headers['x-api-key']) headers['authorization'] = `Bearer ${req.headers['x-api-key']}`; + // Also forward any x- header the gateway might care about + for (const [k, v] of Object.entries(req.headers)) { + if (k.startsWith('x-') && k !== 'x-llm-gateway') headers[k] = v; + } + + const upstream = await fetch(finalUrl, { + method: req.method, + headers, + body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body, + }); + + // Mirror status and content headers + const contentType = upstream.headers.get('content-type'); + res.writeHead(upstream.status, { + ...(contentType ? { 'content-type': contentType } : {}), + 'access-control-allow-origin': '*', + }); + + const buf = Buffer.from(await upstream.arrayBuffer()); + res.end(buf); + } catch (err) { + res.writeHead(502, { 'access-control-allow-origin': '*' }); + res.end(String(err && err.message ? err.message : err)); + } + }); + }); + }, + }; +} + export default defineConfig({ - plugins: [measurementWriter()], + plugins: [measurementWriter(), llmGatewayProxy()], server: { port: 5180, host: '127.0.0.1',