Fix AI Gateway CORS by proxying through Vite dev server
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 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,17 @@ export async function requestSongTweak({ spec, track, look, hint = '', config =
|
|||||||
const messages = buildTweakPrompt({ spec, track, look, hint });
|
const messages = buildTweakPrompt({ spec, track, look, hint });
|
||||||
recordInvocation();
|
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;
|
const modelId = cfg.model;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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({
|
export default defineConfig({
|
||||||
plugins: [measurementWriter()],
|
plugins: [measurementWriter(), llmGatewayProxy()],
|
||||||
server: {
|
server: {
|
||||||
port: 5180,
|
port: 5180,
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
|
|||||||
Reference in New Issue
Block a user