Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat';
import type { ThinkingConfig } from '@/lib/types/provider';
import { apiError } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
const log = createLogger('Chat API');

// Allow streaming responses up to 60 seconds
Expand Down Expand Up @@ -63,8 +64,21 @@ export async function POST(req: NextRequest) {
// Resolve API key: client > server > empty
const modelString = body.model || 'gpt-4o-mini';
const { providerId, modelId } = parseModelString(modelString);
const effectiveApiKey = resolveApiKey(providerId, body.apiKey);
const effectiveBaseUrl = resolveBaseUrl(providerId, body.baseUrl);

const clientBaseUrl = body.baseUrl || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const effectiveApiKey = clientBaseUrl
? body.apiKey || ''
: resolveApiKey(providerId, body.apiKey);
const effectiveBaseUrl = clientBaseUrl
? clientBaseUrl
: resolveBaseUrl(providerId, body.baseUrl);
const proxy = resolveProxy(providerId);

if (!effectiveApiKey) {
Expand Down
14 changes: 12 additions & 2 deletions app/api/generate/image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-c
import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

const log = createLogger('ImageGeneration API');

Expand All @@ -39,7 +40,16 @@ export async function POST(request: NextRequest) {
const clientBaseUrl = request.headers.get('x-base-url') || undefined;
const clientModel = request.headers.get('x-image-model') || undefined;

const apiKey = resolveImageApiKey(providerId, clientApiKey);
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const apiKey = clientBaseUrl
? clientApiKey || ''
: resolveImageApiKey(providerId, clientApiKey);
if (!apiKey) {
return apiError(
'MISSING_API_KEY',
Expand All @@ -48,7 +58,7 @@ export async function POST(request: NextRequest) {
);
}

const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl);
const baseUrl = clientBaseUrl ? clientBaseUrl : resolveImageBaseUrl(providerId, clientBaseUrl);

// Resolve dimensions from aspect ratio if not explicitly set
if (!body.width && !body.height && body.aspectRatio) {
Expand Down
18 changes: 15 additions & 3 deletions app/api/generate/tts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { resolveTTSApiKey, resolveTTSBaseUrl } from '@/lib/server/provider-confi
import type { TTSProviderId } from '@/lib/audio/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

const log = createLogger('TTS API');

Expand Down Expand Up @@ -45,9 +46,20 @@ export async function POST(req: NextRequest) {
return apiError('INVALID_REQUEST', 400, 'browser-native-tts must be handled client-side');
}

// Resolve API key and base URL (server-side fallback)
const apiKey = resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined);
const baseUrl = resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined);
const clientBaseUrl = ttsBaseUrl || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const apiKey = clientBaseUrl
? ttsApiKey || ''
: resolveTTSApiKey(ttsProviderId, ttsApiKey || undefined);
const baseUrl = clientBaseUrl
? clientBaseUrl
: resolveTTSBaseUrl(ttsProviderId, ttsBaseUrl || undefined);

// Build TTS config
const config = {
Expand Down
14 changes: 12 additions & 2 deletions app/api/generate/video/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-c
import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

const log = createLogger('VideoGeneration API');

Expand All @@ -40,7 +41,16 @@ export async function POST(request: NextRequest) {
const clientBaseUrl = request.headers.get('x-base-url') || undefined;
const clientModel = request.headers.get('x-video-model') || undefined;

const apiKey = resolveVideoApiKey(providerId, clientApiKey);
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const apiKey = clientBaseUrl
? clientApiKey || ''
: resolveVideoApiKey(providerId, clientApiKey);
if (!apiKey) {
return apiError(
'MISSING_API_KEY',
Expand All @@ -49,7 +59,7 @@ export async function POST(request: NextRequest) {
);
}

const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl);
const baseUrl = clientBaseUrl ? clientBaseUrl : resolveVideoBaseUrl(providerId, clientBaseUrl);

// Normalize options against provider capabilities
const options = normalizeVideoOptions(providerId, body);
Expand Down
17 changes: 15 additions & 2 deletions app/api/parse-pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { PDFProviderId } from '@/lib/pdf/types';
import type { ParsedPdfContent } from '@/lib/types/pdf';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
const log = createLogger('Parse PDF');

export async function POST(req: NextRequest) {
Expand Down Expand Up @@ -32,10 +33,22 @@ export async function POST(req: NextRequest) {
// providerId is required from the client — no server-side store to fall back to
const effectiveProviderId = providerId || ('unpdf' as PDFProviderId);

const clientBaseUrl = baseUrl || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const config = {
providerId: effectiveProviderId,
apiKey: resolvePDFApiKey(effectiveProviderId, apiKey || undefined),
baseUrl: resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined),
apiKey: clientBaseUrl
? apiKey || ''
: resolvePDFApiKey(effectiveProviderId, apiKey || undefined),
baseUrl: clientBaseUrl
? clientBaseUrl
: resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined),
};

// Convert PDF to buffer
Expand Down
17 changes: 15 additions & 2 deletions app/api/transcription/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolveASRApiKey, resolveASRBaseUrl } from '@/lib/server/provider-confi
import type { ASRProviderId } from '@/lib/audio/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
const log = createLogger('Transcription');

export const maxDuration = 60;
Expand All @@ -24,11 +25,23 @@ export async function POST(req: NextRequest) {
// providerId is required from the client — no server-side store to fall back to
const effectiveProviderId = providerId || ('openai-whisper' as ASRProviderId);

const clientBaseUrl = baseUrl || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const config = {
providerId: effectiveProviderId,
language: language || 'auto',
apiKey: resolveASRApiKey(effectiveProviderId, apiKey || undefined),
baseUrl: resolveASRBaseUrl(effectiveProviderId, baseUrl || undefined),
apiKey: clientBaseUrl
? apiKey || ''
: resolveASRApiKey(effectiveProviderId, apiKey || undefined),
baseUrl: clientBaseUrl
? clientBaseUrl
: resolveASRBaseUrl(effectiveProviderId, baseUrl || undefined),
};

// Convert audio file to buffer
Expand Down
14 changes: 12 additions & 2 deletions app/api/verify-image-provider/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-c
import type { ImageProviderId } from '@/lib/media/types';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

const log = createLogger('VerifyImageProvider');

Expand All @@ -30,8 +31,17 @@ export async function POST(request: NextRequest) {
const clientApiKey = request.headers.get('x-api-key') || undefined;
const clientBaseUrl = request.headers.get('x-base-url') || undefined;

const apiKey = resolveImageApiKey(providerId, clientApiKey);
const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl);
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const apiKey = clientBaseUrl
? clientApiKey || ''
: resolveImageApiKey(providerId, clientApiKey);
const baseUrl = clientBaseUrl ? clientBaseUrl : resolveImageBaseUrl(providerId, clientBaseUrl);

if (!apiKey) {
return apiError('MISSING_API_KEY', 400, 'No API key configured');
Expand Down
20 changes: 18 additions & 2 deletions app/api/verify-pdf-provider/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { resolvePDFApiKey, resolvePDFBaseUrl } from '@/lib/server/provider-config';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

const log = createLogger('Verify PDF Provider');

Expand All @@ -13,12 +14,22 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Provider ID is required');
}

const resolvedBaseUrl = resolvePDFBaseUrl(providerId, baseUrl);
const clientBaseUrl = (baseUrl as string | undefined) || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const resolvedBaseUrl = clientBaseUrl ? clientBaseUrl : resolvePDFBaseUrl(providerId, baseUrl);
if (!resolvedBaseUrl) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL is required');
}

const resolvedApiKey = resolvePDFApiKey(providerId, apiKey);
const resolvedApiKey = clientBaseUrl
? (apiKey as string | undefined) || ''
: resolvePDFApiKey(providerId, apiKey);

const headers: Record<string, string> = {};
if (resolvedApiKey) {
Expand All @@ -28,8 +39,13 @@ export async function POST(req: NextRequest) {
const response = await fetch(resolvedBaseUrl, {
headers,
signal: AbortSignal.timeout(10000),
redirect: 'manual',
});

if (response.status >= 300 && response.status < 400) {
return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed');
}

// MinerU's FastAPI root returns 404 (no root route), but the server is reachable.
// Any HTTP response (including 404) means the server is up.
return apiSuccess({
Expand Down
14 changes: 12 additions & 2 deletions app/api/verify-video-provider/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-c
import type { VideoProviderId } from '@/lib/media/types';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

const log = createLogger('VerifyVideoProvider');

Expand All @@ -30,8 +31,17 @@ export async function POST(request: NextRequest) {
const clientApiKey = request.headers.get('x-api-key') || undefined;
const clientBaseUrl = request.headers.get('x-base-url') || undefined;

const apiKey = resolveVideoApiKey(providerId, clientApiKey);
const baseUrl = resolveVideoBaseUrl(providerId, clientBaseUrl);
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const apiKey = clientBaseUrl
? clientApiKey || ''
: resolveVideoApiKey(providerId, clientApiKey);
const baseUrl = clientBaseUrl ? clientBaseUrl : resolveVideoBaseUrl(providerId, clientBaseUrl);

if (!apiKey) {
return apiError('MISSING_API_KEY', 400, 'No API key configured');
Expand Down
16 changes: 14 additions & 2 deletions lib/server/resolve-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { NextRequest } from 'next/server';
import { getModel, parseModelString, type ModelWithInfo } from '@/lib/ai/providers';
import { resolveApiKey, resolveBaseUrl, resolveProxy } from '@/lib/server/provider-config';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';

export interface ResolvedModel extends ModelWithInfo {
/** Original model string (e.g. "openai/gpt-4o-mini") */
Expand All @@ -28,8 +29,19 @@ export function resolveModel(params: {
}): ResolvedModel {
const modelString = params.modelString || process.env.DEFAULT_MODEL || 'gpt-4o-mini';
const { providerId, modelId } = parseModelString(modelString);
const apiKey = resolveApiKey(providerId, params.apiKey || '');
const baseUrl = resolveBaseUrl(providerId, params.baseUrl);

const clientBaseUrl = params.baseUrl || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
throw new Error(ssrfError);
}
}

const apiKey = clientBaseUrl
? params.apiKey || ''
: resolveApiKey(providerId, params.apiKey || '');
const baseUrl = clientBaseUrl ? clientBaseUrl : resolveBaseUrl(providerId, params.baseUrl);
const proxy = resolveProxy(providerId);
const { model, modelInfo } = getModel({
providerId,
Expand Down
Loading