mirror of
https://github.com/readest/readest.git
synced 2026-10-19 10:13:29 +00:00
fix(api): validate DeepL and AI gateway request limits (#6818)
* fix(translate): validate DeepL requests and check quota per request Validate that `text` is an array of strings and cap a request at 100 items and 50,000 characters. Read the cache first, then check the daily quota against the whole request's uncached characters before any upstream call, instead of checking each item against the same starting usage in parallel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ai): require the caller's gateway key on the AI proxy routes The chat and embed routes fell back to a server-side AI Gateway key when the request carried none. Every client already sends the user's own key, so drop the fallback and its env examples, and cap request size (500 messages / 500k chars for chat, 100 texts / 500k chars for embed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ai): reject a non-string system prompt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e949536b0f
commit
b06c11f030
7 changed files with 287 additions and 32 deletions
|
|
@ -1,2 +1 @@
|
|||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
AI_GATEWAY_API_KEY=your_key_here
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
AI_GATEWAY_API_KEY=your_key_here
|
||||
NEXT_PUBLIC_AI_GATEWAY_API_KEY=your_key_here
|
||||
READEST_TTS_CATALOG_URL=
|
||||
# Optional: fixed daily TTS character quota for every plan (nonzero).
|
||||
TTS_FIXED_QUOTA=
|
||||
|
|
|
|||
111
apps/readest-app/src/__tests__/app/api/ai-gateway-routes.test.ts
Normal file
111
apps/readest-app/src/__tests__/app/api/ai-gateway-routes.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const createGatewayMock = vi.fn();
|
||||
const streamTextMock = vi.fn();
|
||||
const embedManyMock = vi.fn();
|
||||
|
||||
vi.mock('@/utils/access', () => ({
|
||||
validateUserAndToken: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, token: 'tok' }),
|
||||
}));
|
||||
vi.mock('ai', () => ({
|
||||
createGateway: (...a: unknown[]) => createGatewayMock(...a),
|
||||
streamText: (...a: unknown[]) => streamTextMock(...a),
|
||||
embed: vi.fn(),
|
||||
embedMany: (...a: unknown[]) => embedManyMock(...a),
|
||||
}));
|
||||
|
||||
import { POST as chatPOST } from '@/app/api/ai/chat/route';
|
||||
import { POST as embedPOST } from '@/app/api/ai/embed/route';
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
new Request(`https://web.readest.com/api/ai/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer tok', 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('AI_GATEWAY_API_KEY', 'server-key');
|
||||
const gateway = Object.assign(
|
||||
vi.fn(() => 'model'),
|
||||
{ embeddingModel: vi.fn(() => 'embedding-model') },
|
||||
);
|
||||
createGatewayMock.mockReset().mockReturnValue(gateway);
|
||||
streamTextMock
|
||||
.mockReset()
|
||||
.mockReturnValue({ toTextStreamResponse: () => new Response('streamed') });
|
||||
embedManyMock.mockReset().mockResolvedValue({ embeddings: [[0.1]] });
|
||||
});
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe('AI gateway routes', () => {
|
||||
it('chat requires the caller to supply an API key', async () => {
|
||||
const res = await chatPOST(post('chat', { messages: [{ role: 'user', content: 'hi' }] }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(createGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('embed requires the caller to supply an API key', async () => {
|
||||
const res = await embedPOST(post('embed', { texts: ['hi'] }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(createGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('chat rejects a non-string system prompt', async () => {
|
||||
const res = await chatPOST(
|
||||
post('chat', {
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
system: { text: 'x'.repeat(600_000) },
|
||||
apiKey: 'user-key',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(streamTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('chat uses the caller key', async () => {
|
||||
const res = await chatPOST(
|
||||
post('chat', { messages: [{ role: 'user', content: 'hi' }], apiKey: 'user-key' }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(createGatewayMock).toHaveBeenCalledWith({ apiKey: 'user-key' });
|
||||
});
|
||||
|
||||
it('chat rejects too many messages', async () => {
|
||||
const messages = Array.from({ length: 501 }, () => ({ role: 'user', content: 'hi' }));
|
||||
const res = await chatPOST(post('chat', { messages, apiKey: 'user-key' }));
|
||||
expect(res.status).toBe(413);
|
||||
expect(streamTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('chat rejects oversized input', async () => {
|
||||
const res = await chatPOST(
|
||||
post('chat', {
|
||||
messages: [{ role: 'user', content: 'a'.repeat(400_000) }],
|
||||
system: 'a'.repeat(100_001),
|
||||
apiKey: 'user-key',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(413);
|
||||
expect(streamTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('embed rejects more texts than one embedding call takes', async () => {
|
||||
const texts = Array.from({ length: 101 }, () => 'hi');
|
||||
const res = await embedPOST(post('embed', { texts, apiKey: 'user-key' }));
|
||||
expect(res.status).toBe(413);
|
||||
expect(embedManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('embed rejects non-string texts', async () => {
|
||||
const res = await embedPOST(post('embed', { texts: ['hi', { a: 1 }], apiKey: 'user-key' }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(embedManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('embed uses the caller key', async () => {
|
||||
const res = await embedPOST(post('embed', { texts: ['a', 'b'], apiKey: 'user-key' }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(createGatewayMock).toHaveBeenCalledWith({ apiKey: 'user-key' });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
const getCurrentUsageMock = vi.fn();
|
||||
const trackUsageMock = vi.fn();
|
||||
|
||||
vi.mock('@/utils/cors', () => ({ corsAllMethods: {}, runMiddleware: vi.fn() }));
|
||||
vi.mock('@opennextjs/cloudflare', () => ({
|
||||
getCloudflareContext: () => {
|
||||
throw new Error('no cloudflare context');
|
||||
},
|
||||
}));
|
||||
vi.mock('@/utils/access', () => ({
|
||||
validateUserAndToken: vi.fn().mockResolvedValue({ user: { id: 'user-1' }, token: 'tok' }),
|
||||
getSubscriptionPlan: () => 'free',
|
||||
getDailyTranslationPlanData: () => ({ quota: 1000 }),
|
||||
}));
|
||||
vi.mock('@/utils/usage', () => ({
|
||||
UsageStatsManager: {
|
||||
getCurrentUsage: (...a: unknown[]) => getCurrentUsageMock(...a),
|
||||
trackUsage: (...a: unknown[]) => trackUsageMock(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
import handler from '@/pages/api/deepl/translate';
|
||||
|
||||
const call = async (body: unknown) => {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
await handler(
|
||||
{ method: 'POST', headers: { authorization: 'Bearer tok' }, body } as NextApiRequest,
|
||||
res as unknown as NextApiResponse,
|
||||
);
|
||||
return res;
|
||||
};
|
||||
|
||||
const deeplResponse = () =>
|
||||
new Response(JSON.stringify({ translations: [{ text: 'ok' }] }), { status: 200 });
|
||||
|
||||
beforeEach(() => {
|
||||
getCurrentUsageMock.mockReset().mockResolvedValue(0);
|
||||
trackUsageMock.mockReset().mockResolvedValue(100);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('DeepL translate request limits', () => {
|
||||
it.each([
|
||||
['a string instead of an array', 'hello'],
|
||||
['a missing text field', undefined],
|
||||
['a non-string item', ['hello', 42]],
|
||||
])('rejects %s without calling DeepL', async (_label, text) => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(deeplResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await call({ text, target_lang: 'EN' });
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a request with too many items', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(deeplResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await call({ text: Array.from({ length: 101 }, () => 'a'), target_lang: 'EN' });
|
||||
expect(res.status).toHaveBeenCalledWith(413);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a request with too many characters', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(deeplResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await call({ text: ['a'.repeat(50_001)], target_lang: 'EN' });
|
||||
expect(res.status).toHaveBeenCalledWith(413);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks the whole request against the quota before any upstream call', async () => {
|
||||
// 10 x 200 chars: each item alone fits the 1000-char quota, the request does not.
|
||||
const fetchMock = vi.fn().mockResolvedValue(deeplResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await call({ text: Array.from({ length: 10 }, () => 'a'.repeat(200)) });
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(trackUsageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates a request that fits the quota and records usage once', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () => deeplResponse());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const res = await call({ text: ['hello', '', 'world'], target_lang: 'EN' });
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(trackUsageMock).toHaveBeenCalledTimes(1);
|
||||
const { translations } = res.json.mock.calls[0]![0] as { translations: { text: string }[] };
|
||||
expect(translations.map((t) => t.text)).toEqual(['ok', '', 'ok']);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,9 @@ import { validateUserAndToken } from '@/utils/access';
|
|||
import { streamText, createGateway } from 'ai';
|
||||
import type { ModelMessage } from 'ai';
|
||||
|
||||
const MAX_MESSAGES = 500;
|
||||
const MAX_INPUT_CHARS = 500_000;
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
try {
|
||||
const { user, token } = await validateUserAndToken(req.headers.get('authorization'));
|
||||
|
|
@ -17,16 +20,29 @@ export async function POST(req: Request): Promise<Response> {
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (system !== undefined && typeof system !== 'string') {
|
||||
return new Response(JSON.stringify({ error: 'Invalid system prompt' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const gatewayApiKey = apiKey || process.env['AI_GATEWAY_API_KEY'];
|
||||
if (!gatewayApiKey) {
|
||||
const inputChars = JSON.stringify(messages).length + (system?.length ?? 0);
|
||||
if (messages.length > MAX_MESSAGES || inputChars > MAX_INPUT_CHARS) {
|
||||
return new Response(JSON.stringify({ error: 'Input too large' }), {
|
||||
status: 413,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: 'API key required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const gateway = createGateway({ apiKey: gatewayApiKey });
|
||||
const gateway = createGateway({ apiKey });
|
||||
const languageModel = gateway(model || 'google/gemini-2.5-flash-lite');
|
||||
|
||||
const result = streamText({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { NextResponse } from 'next/server';
|
|||
import { embed, embedMany, createGateway } from 'ai';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
|
||||
// Matches maxEmbeddingsPerCall in ProxiedGatewayEmbedding.
|
||||
const MAX_TEXTS = 100;
|
||||
const MAX_INPUT_CHARS = 500_000;
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
try {
|
||||
const { user, token } = await validateUserAndToken(req.headers.get('authorization'));
|
||||
|
|
@ -11,22 +15,29 @@ export async function POST(req: Request): Promise<Response> {
|
|||
|
||||
const { texts, single, apiKey } = await req.json();
|
||||
|
||||
if (!texts || !Array.isArray(texts) || texts.length === 0) {
|
||||
if (
|
||||
!Array.isArray(texts) ||
|
||||
texts.length === 0 ||
|
||||
!texts.every((text): text is string => typeof text === 'string')
|
||||
) {
|
||||
return NextResponse.json({ error: 'Texts array required' }, { status: 400 });
|
||||
}
|
||||
const inputChars = texts.reduce((sum, text) => sum + text.length, 0);
|
||||
if (texts.length > MAX_TEXTS || inputChars > MAX_INPUT_CHARS) {
|
||||
return NextResponse.json({ error: 'Input too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
const gatewayApiKey = apiKey || process.env['AI_GATEWAY_API_KEY'];
|
||||
if (!gatewayApiKey) {
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'API key required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const gateway = createGateway({ apiKey: gatewayApiKey });
|
||||
const gateway = createGateway({ apiKey });
|
||||
const model = gateway.embeddingModel(
|
||||
process.env['AI_GATEWAY_EMBEDDING_MODEL'] || 'openai/text-embedding-3-small',
|
||||
);
|
||||
|
||||
if (single) {
|
||||
const { embedding } = await embed({ model, value: texts[0] });
|
||||
const { embedding } = await embed({ model, value: texts[0]! });
|
||||
return NextResponse.json({ embedding });
|
||||
} else {
|
||||
const { embeddings } = await embedMany({ model, values: texts });
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import { UsageStatsManager } from '@/utils/usage';
|
|||
|
||||
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
|
||||
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
|
||||
// Clients send one paragraph, one selection or a short list of release notes per request.
|
||||
const MAX_TEXT_ITEMS = 100;
|
||||
const MAX_TEXT_CHARS = 50_000;
|
||||
|
||||
interface KVNamespace {
|
||||
get(key: string): Promise<string | null>;
|
||||
|
|
@ -112,34 +115,57 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
source_lang: sourceLang = 'AUTO',
|
||||
target_lang: targetLang = 'EN',
|
||||
use_cache: useCache = false,
|
||||
}: { text: string[]; source_lang: string; target_lang: string; use_cache: boolean } = req.body;
|
||||
}: { text: unknown; source_lang: string; target_lang: string; use_cache: boolean } = req.body;
|
||||
|
||||
if (!Array.isArray(text) || !text.every((item): item is string => typeof item === 'string')) {
|
||||
return res.status(400).json({ error: 'Invalid text' });
|
||||
}
|
||||
if (
|
||||
text.length > MAX_TEXT_ITEMS ||
|
||||
text.reduce((sum, item) => sum + item.length, 0) > MAX_TEXT_CHARS
|
||||
) {
|
||||
return res.status(413).json({ error: 'Too much text' });
|
||||
}
|
||||
|
||||
try {
|
||||
const translations = await Promise.all(
|
||||
const cached = await Promise.all(
|
||||
text.map(async (singleText) => {
|
||||
if (!singleText?.trim()) {
|
||||
if (!singleText.trim() || !useCache || !hasKVCache) return null;
|
||||
try {
|
||||
const cacheKey = generateCacheKey(singleText, sourceLang, targetLang);
|
||||
return await env['TRANSLATIONS_KV']!.get(cacheKey);
|
||||
} catch (cacheError) {
|
||||
console.error('Cache retrieval error:', cacheError);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Check the quota for the whole request up front so one request can't
|
||||
// overshoot it by fanning out parallel upstream calls.
|
||||
const uncachedChars = text.reduce(
|
||||
(sum, singleText, i) => (singleText.trim() && !cached[i] ? sum + singleText.length : sum),
|
||||
0,
|
||||
);
|
||||
if (uncachedChars > 0) {
|
||||
if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
|
||||
await checkDailyUsage(user.id, token, uncachedChars);
|
||||
}
|
||||
|
||||
const translations = await Promise.all(
|
||||
text.map(async (singleText, i) => {
|
||||
if (!singleText.trim()) {
|
||||
return { text: '', daily_usage: 0 };
|
||||
}
|
||||
if (useCache && hasKVCache) {
|
||||
try {
|
||||
const cacheKey = generateCacheKey(singleText, sourceLang, targetLang);
|
||||
const cachedTranslation = await env['TRANSLATIONS_KV']!.get(cacheKey);
|
||||
|
||||
if (cachedTranslation) {
|
||||
return {
|
||||
text: cachedTranslation,
|
||||
daily_usage: 0,
|
||||
detected_source_language: sourceLang,
|
||||
};
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.error('Cache retrieval error:', cacheError);
|
||||
}
|
||||
const cachedTranslation = cached[i];
|
||||
if (cachedTranslation) {
|
||||
return {
|
||||
text: cachedTranslation,
|
||||
daily_usage: 0,
|
||||
detected_source_language: sourceLang,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
|
||||
await checkDailyUsage(user?.id, token, singleText.length);
|
||||
|
||||
return await callDeepLAPI(
|
||||
singleText,
|
||||
sourceLang,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue