← vibesdk
worker/services/aigateway-proxy/controller.ts
14.6 KB
Download file
import { getConfigurationForModel } from '../../agents/inferutils/core';
import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';
import { apps } from '../../database/schema';
import { jwtVerify, SignJWT } from 'jose';
import { isDev } from 'worker/utils/envs';
import { RateLimitService } from '../rate-limit/rateLimits';
import { getUserConfigurableSettings } from 'worker/config';
import { AI_MODEL_CONFIG, AIModels } from 'worker/agents/inferutils/config.types';
// Resource caps to protect the shared platform gateway from abuse (CWE-770).
const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB total request body
const MAX_MESSAGES = 200; // messages array length
const MAX_IMAGE_BYTES = 4 * 1024 * 1024; // total base64 image payload across messages
const MAX_OUTPUT_TOKENS = 16384; // clamp for max_tokens / max_completion_tokens
const UPSTREAM_TIMEOUT_MS = 120_000; // upstream fetch timeout
// Only OpenAI-compatible inference endpoints may be reached through the proxy.
const ALLOWED_PROXY_PATHS = new Set([
'/chat/completions',
'/completions',
'/embeddings',
'/responses',
'/models',
]);
// JWT scoping claims for app-proxy tokens.
const AI_PROXY_AUDIENCE = 'vibesdk-ai-proxy';
const AI_PROXY_ISSUER = 'vibesdk';
function jsonError(status: number, message: string, type = 'invalid_request_error', extra?: Record<string, unknown>): Response {
return new Response(JSON.stringify({ error: { message, type, ...extra } }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
/**
* Sum the byte length of base64-encoded image payloads in an OpenAI-style
* messages array. Non-image content and remote image URLs are ignored.
*/
function countImageBytes(messages: unknown): number {
if (!Array.isArray(messages)) return 0;
let total = 0;
for (const message of messages) {
const content = (message as { content?: unknown } | null)?.content;
if (!Array.isArray(content)) continue;
for (const part of content) {
const url = (part as { image_url?: { url?: unknown } } | null)?.image_url?.url;
if (typeof url !== 'string') continue;
const commaIndex = url.indexOf(',');
if (url.startsWith('data:') && commaIndex !== -1) {
// base64 length -> decoded byte estimate
total += Math.floor((url.length - commaIndex - 1) * 3 / 4);
}
}
}
return total;
}
export async function proxyToAiGateway(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
console.log(`[AI Proxy] Received request: ${request.method} ${request.url}`);
if (!env.AI_PROXY_JWT_SECRET) {
console.error('AI Gateway proxy is not enabled for this platform');
// Platform doesnt have ai gateway proxy enabled, return 403
return new Response(JSON.stringify({
error: { message: 'AI Gateway proxy is not enabled for this platform', type: 'invalid_request_error' }
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Handle CORS preflight requests
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
// The proxy only serves JSON inference requests, which are always POST.
if (request.method !== 'POST') {
return jsonError(405, 'Method not allowed', 'invalid_request_error');
}
// Reject oversized bodies up front via Content-Length; the actual byte
// length is re-checked after reading to defend against a missing or
// spoofed header.
const contentLength = Number(request.headers.get('Content-Length'));
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
return jsonError(413, 'Request body too large', 'invalid_request_error');
}
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({
error: { message: 'Missing Authorization header', type: 'invalid_request_error' }
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const token = authHeader.replace(/^Bearer\s+/i, '').trim();
if (!token) {
return new Response(JSON.stringify({
error: { message: 'Invalid Authorization header format', type: 'invalid_request_error' }
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
let appId: string;
let userId: string;
try {
const jwtSecret = new TextEncoder().encode(env.AI_PROXY_JWT_SECRET);
const { payload } = await jwtVerify(token, jwtSecret, {
audience: AI_PROXY_AUDIENCE,
issuer: AI_PROXY_ISSUER,
});
if (payload.type !== 'app-proxy') {
return jsonError(401, 'Invalid token: wrong token type', 'invalid_request_error');
}
if (!payload.appId || typeof payload.appId !== 'string') {
return new Response(JSON.stringify({
error: { message: 'Invalid token: missing appId', type: 'invalid_request_error' }
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
if (!payload.userId || typeof payload.userId !== 'string') {
return new Response(JSON.stringify({
error: { message: 'Invalid token: missing userId', type: 'invalid_request_error' }
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
appId = payload.appId as string;
userId = payload.userId as string;
} catch (error) {
console.error('[AI Proxy] Token verification failed:', error);
return new Response(JSON.stringify({
error: { message: 'Invalid or expired token', type: 'invalid_request_error' }
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const db = drizzle(env.DB);
const app = await db.select({
id: apps.id,
userId: apps.userId,
title: apps.title,
status: apps.status,
})
.from(apps)
.where(eq(apps.id, appId))
.get();
if (!app) {
return new Response(JSON.stringify({
error: { message: 'App not found', type: 'invalid_request_error' }
}), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
if (app.userId !== userId) {
console.error(`[AI Proxy] UserId mismatch: token userId=${userId}, app userId=${app.userId}`);
return new Response(JSON.stringify({
error: { message: 'Token does not match app owner', type: 'invalid_request_error' }
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
console.log(`[AI Proxy] Authenticated request from app: ${app.title} (${app.id}), user: ${app.userId}`);
const url = new URL(request.url);
// Only allow OpenAI-compatible inference endpoints through the proxy.
const targetPath = url.pathname.replace('/api/proxy/openai', '');
if (!ALLOWED_PROXY_PATHS.has(targetPath)) {
return jsonError(404, `Unsupported proxy path: ${targetPath}`, 'invalid_request_error');
}
// Read the raw body and enforce the byte cap against the actual payload,
// not just the (optional/spoofable) Content-Length header.
const rawBody = await request.text();
if (new TextEncoder().encode(rawBody).length > MAX_BODY_BYTES) {
return jsonError(413, 'Request body too large', 'invalid_request_error');
}
let requestBody: { model: string; [key: string]: unknown };
try {
requestBody = JSON.parse(rawBody) as { model: string; [key: string]: unknown };
} catch {
return jsonError(400, 'Invalid JSON body', 'invalid_request_error');
}
if (!requestBody.model || typeof requestBody.model !== 'string') {
return jsonError(400, 'Missing required parameter: model', 'invalid_request_error', {
param: 'model',
code: 'missing_required_parameter',
});
}
// Cap the number of messages and the total inline image payload.
if (requestBody.messages !== undefined) {
if (!Array.isArray(requestBody.messages)) {
return jsonError(400, 'Invalid parameter: messages must be an array', 'invalid_request_error', { param: 'messages' });
}
if (requestBody.messages.length > MAX_MESSAGES) {
return jsonError(400, `Too many messages (max ${MAX_MESSAGES})`, 'invalid_request_error', { param: 'messages' });
}
if (countImageBytes(requestBody.messages) > MAX_IMAGE_BYTES) {
return jsonError(413, 'Inline image payload too large', 'invalid_request_error');
}
}
// Clamp the requested output tokens so a single call cannot exhaust the
// platform budget. Rewritten in place rather than rejected.
for (const key of ['max_tokens', 'max_completion_tokens'] as const) {
const value = requestBody[key];
if (typeof value === 'number' && value > MAX_OUTPUT_TOKENS) {
requestBody[key] = MAX_OUTPUT_TOKENS;
}
}
const modelName = requestBody.model;
// Enforce rate limit
const userConfig = await getUserConfigurableSettings(env, app.userId)
await RateLimitService.enforceLLMCallsRateLimit(env, userConfig.security.rateLimit, app.userId, modelName, "apps")
const { baseURL, apiKey, defaultHeaders } = await getConfigurationForModel(
AI_MODEL_CONFIG[modelName as AIModels],
env,
app.userId,
undefined, // User app proxy doesn't use BYOK
undefined,
null // No user gateway for app proxy
);
console.log(`[AI Proxy] Forwarding request to model: ${modelName}, baseURL: ${baseURL}`);
const proxyHeaders = new Headers();
proxyHeaders.set('Content-Type', 'application/json');
proxyHeaders.set('Authorization', `Bearer ${apiKey}`);
if (defaultHeaders) {
Object.entries(defaultHeaders).forEach(([key, value]) => {
proxyHeaders.set(key, value);
});
}
// Add metadata for tracking
proxyHeaders.set('cf-aig-metadata', JSON.stringify({
appId: app.id,
userId: app.userId,
source: 'user-app-proxy',
model: modelName
}));
const targetUrl = `${baseURL}${targetPath}${url.search}`;
console.log(`[AI Proxy] Target URL: ${targetUrl}`);
let proxyResponse: Response;
try {
proxyResponse = await fetch(targetUrl, {
method: request.method,
headers: proxyHeaders,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
} catch (error) {
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
console.error('[AI Proxy] Upstream request timed out');
return jsonError(504, 'Upstream request timed out', 'timeout_error');
}
throw error;
}
// Allowlist response headers to avoid leaking upstream cookies, cf-aig-* metadata,
// or any Cloudflare-internal auth headers back to the caller.
const SAFE_RESPONSE_HEADERS = new Set([
'content-type',
'content-length',
'content-encoding',
'cache-control',
'openai-model',
'openai-organization',
'openai-processing-ms',
'openai-version',
'x-request-id',
]);
const safeHeaders = new Headers();
for (const [name, value] of proxyResponse.headers) {
if (SAFE_RESPONSE_HEADERS.has(name.toLowerCase())) {
safeHeaders.set(name, value);
}
}
return new Response(proxyResponse.body, {
status: proxyResponse.status,
statusText: proxyResponse.statusText,
headers: safeHeaders,
});
} catch (error) {
console.error('[AI Proxy] Error processing request:', error);
return new Response(JSON.stringify({
error: {
message: error instanceof Error ? error.message : 'Internal server error',
type: 'internal_error'
}
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function generateAppProxyToken(
appId: string,
userId: string,
env: Env,
expiresInSeconds: number = 3 * 60 * 60 // 3 hours
): Promise<string> {
const jwtSecret = new TextEncoder().encode(env.AI_PROXY_JWT_SECRET);
const now = Math.floor(Date.now() / 1000);
const token = await new SignJWT({
appId,
userId,
type: 'app-proxy',
iat: now,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now)
.setIssuer(AI_PROXY_ISSUER)
.setAudience(AI_PROXY_AUDIENCE)
.setExpirationTime(now + expiresInSeconds)
.sign(jwtSecret);
return token;
}
export function generateAppProxyUrl(env: Env) {
let protocol = 'https';
const domain = env.CUSTOM_DOMAIN;
if (isDev(env)) {
protocol = 'http';
}
return `${protocol}://${domain}/api/proxy/openai`;
}