Skip to content
Open
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
91 changes: 91 additions & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { NextRequest } from 'next/server';
import { zstdCompressSync } from 'node:zlib';
import type { User } from '@kilocode/db/schema';
import { getUserFromAuth } from '@/lib/user/server';
import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage';
Expand Down Expand Up @@ -294,6 +296,95 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () =>
jest.useRealTimers();
});

it.each(['openrouter', 'gateway'])(
'decodes zstd JSON before blank-model validation through /api/%s',
async alias => {
const { POST } = await import('@/app/api/gateway/[...path]/route');
const response = await POST(
new NextRequest(`http://localhost/api/${alias}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'content-encoding': 'zstd' },
body: zstdCompressSync(JSON.stringify(makeBody(''))),
})
);

expect(response.status).toBe(404);
expect(await response.json()).toMatchObject({
error: 'Model not found',
error_type: 'model_not_found',
});
expect(mockedGetProvider).not.toHaveBeenCalled();
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
}
);

it.each([
{ encoding: 'zstd', status: 400, error: 'Invalid zstd request body.' },
{
encoding: 'zstd, identity',
status: 415,
error: 'Unsupported Content-Encoding. Use identity or zstd.',
},
])(
'returns a stable $status error for invalid $encoding bodies',
async ({ encoding, status, error }) => {
const { POST } = await import('./route');
const response = await POST(
new NextRequest('http://localhost/api/openrouter/chat/completions', {
method: 'POST',
headers: { 'content-type': 'application/json', 'content-encoding': encoding },
body: JSON.stringify(makeBody('')),
})
);

expect(response.status).toBe(status);
expect(await response.json()).toEqual({
error,
error_type: 'invalid_request',
message: error,
});
expect(mockedGetUserFromAuth).not.toHaveBeenCalled();
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
}
);

it('forwards decoded JSON without compressed transport headers', async () => {
const actual = jest.requireActual<{ upstreamRequest: typeof upstreamRequest }>(
'@/lib/ai-gateway/providers/upstream-request'
);
mockedUpstreamRequest.mockImplementation(actual.upstreamRequest);
const fetch = jest
.spyOn(global, 'fetch')
.mockResolvedValue(upstreamJsonResponse({ choices: [] }));
try {
const { POST } = await import('./route');
const body = zstdCompressSync(JSON.stringify(makeBody()));
const response = await POST(
new NextRequest('http://localhost/api/openrouter/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-encoding': 'zstd',
'content-length': String(body.length),
'x-forwarded-for': '127.0.0.1',
},
body,
})
);

expect(response.status).toBe(200);
expect(fetch).toHaveBeenCalledTimes(1);
const init = fetch.mock.calls.at(0)?.[1];
const headers = new Headers(init?.headers);
expect(headers.get('content-type')).toBe('application/json');
expect(headers.has('content-encoding')).toBe(false);
expect(headers.has('content-length')).toBe(false);
expect(JSON.parse(String(init?.body))).toMatchObject(makeBody());
} finally {
fetch.mockRestore();
}
});

it('rejects providerOptions and directs clients to provider', async () => {
const { POST } = await import('./route');
const response = await POST(
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type NextRequest } from 'next/server';
import { stripRequiredPrefix, toMicrodollars } from '@/lib/utils';
import { extractPromptInfo } from '@/lib/ai-gateway/extractPromptInfo';
import { determineFallbackFeature } from '@/lib/ai-gateway/determineFallbackFeature';
import { readGatewayRequestBody } from '@/lib/ai-gateway/request-body';
import {
validateFeatureHeader,
FEATURE_HEADER,
Expand Down Expand Up @@ -187,7 +188,21 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
const { path } = pathResult;

// Parse body first to check model before auth (needed for anonymous access)
const requestBodyText = await request.text();
const decoded = await readGatewayRequestBody(request);
if ('error' in decoded) {
return NextResponse.json(
{
error: decoded.error,
error_type:
decoded.status === 499
? ProxyErrorType.client_disconnect
: ProxyErrorType.invalid_request,
message: decoded.error,
},
{ status: decoded.status }
);
}
const requestBodyText = decoded.text;
const authPromise = getUserFromAuth({ adminOnly: false });
debugSaveProxyRequest(requestBodyText);
let requestBodyParsed: GatewayRequest;
Expand Down
210 changes: 210 additions & 0 deletions apps/web/src/lib/ai-gateway/request-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { constants, zstdCompressSync } from 'node:zlib';
import { readGatewayRequestBody } from './request-body';

const text = JSON.stringify(
{
model: '',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Preserve café, 日本語, and whitespace.\n' },
{
type: 'image_url',
image_url: {
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jK1sAAAAASUVORK5CYII=',
},
},
],
},
{
role: 'assistant',
content: '',
reasoning_details: [
{
type: 'reasoning.encrypted',
data: 'cHJlc2VydmUtb3BhcXVlLWJ5dGVz+/==',
format: 'opaque',
},
],
},
],
},
null,
2
);
const bytes = Buffer.from(text);
const frame = zstdCompressSync(bytes, { params: { [constants.ZSTD_c_checksumFlag]: 1 } });

function request(body: BodyInit | null, encoding?: string, signal?: AbortSignal) {
const init = {
method: 'POST',
body,
signal,
duplex: 'half',
headers: {
'content-type': 'application/json',
...(encoding === undefined ? {} : { 'content-encoding': encoding }),
},
};
return new Request('http://localhost/api/openrouter/chat/completions', init);
}

function chunked(buffer: Buffer) {
return new ReadableStream<Uint8Array>({
start(controller) {
for (let offset = 0; offset < buffer.length; offset += 7) {
controller.enqueue(buffer.subarray(offset, offset + 7));
}
controller.close();
},
});
}

describe('readGatewayRequestBody', () => {
it.each([undefined, 'identity', ' IDENTITY\t', 'zstd', '\tZsTd '])(
'preserves exact image and encrypted reasoning JSON with encoding %s',
async encoding => {
const body = encoding?.trim().toLowerCase() === 'zstd' ? frame : bytes;
await expect(readGatewayRequestBody(request(chunked(body), encoding))).resolves.toEqual({
text,
});
}
);

it('matches Request.text UTF-8 and BOM handling', async () => {
const body = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), bytes, Buffer.from([0xff])]);
const expected = await request(body).text();
await expect(readGatewayRequestBody(request(chunked(body)))).resolves.toEqual({
text: expected,
});
await expect(readGatewayRequestBody(request(zstdCompressSync(body), 'zstd'))).resolves.toEqual({
text: expected,
});
});

it('preserves empty identity bodies', async () => {
await expect(readGatewayRequestBody(request(null))).resolves.toEqual({ text: '' });
});

it.each(['', 'gzip', 'br', 'unknown', 'zstd, identity', 'identity, zstd', 'zstd, zstd'])(
'rejects unsupported or stacked encoding %s',
async encoding => {
const input = request(frame, encoding);
await expect(readGatewayRequestBody(input)).resolves.toEqual({
status: 415,
error: 'Unsupported Content-Encoding. Use identity or zstd.',
});
expect(input.bodyUsed).toBe(false);
}
);

it.each([0, 4, 6, frame.length - 1])('rejects a frame truncated to %i bytes', async length => {
await expect(
readGatewayRequestBody(request(chunked(frame.subarray(0, length)), 'zstd'))
).resolves.toEqual({ status: 400, error: 'Invalid zstd request body.' });
});

it.each([bytes, Buffer.concat([frame, Buffer.from([0])])])(
'rejects invalid frame data',
async body => {
await expect(readGatewayRequestBody(request(body, 'zstd'))).resolves.toEqual({
status: 400,
error: 'Invalid zstd request body.',
});
}
);

it('rejects a complete frame with a corrupt checksum', async () => {
const corrupt = Buffer.from(frame);
corrupt.writeUInt8(corrupt.readUInt8(corrupt.length - 1) ^ 0xff, corrupt.length - 1);
await expect(readGatewayRequestBody(request(corrupt, 'zstd'))).resolves.toEqual({
status: 400,
error: 'Invalid zstd request body.',
});
});

it('rejects concatenated and skippable frames instead of silently dropping bytes', async () => {
const skip = Buffer.from([0x50, 0x2a, 0x4d, 0x18, 3, 0, 0, 0, 1, 2, 3]);
for (const body of [Buffer.concat([frame, frame]), Buffer.concat([frame, skip]), skip]) {
await expect(readGatewayRequestBody(request(body, 'zstd'))).resolves.toEqual({
status: 400,
error: 'Invalid zstd request body.',
});
}
});

it('accepts frames without a declared content size', async () => {
const body = zstdCompressSync(bytes, { params: { [constants.ZSTD_c_contentSizeFlag]: 0 } });
await expect(readGatewayRequestBody(request(body, 'zstd'))).resolves.toEqual({ text });
});

it.each(['identity', 'zstd'])(
'enforces actual input and output byte limits for %s',
async encoding => {
const body = encoding === 'zstd' ? frame : bytes;
await expect(
readGatewayRequestBody(request(chunked(body), encoding), {
input: body.length,
output: bytes.length,
})
).resolves.toEqual({ text });
const input = request(chunked(body), encoding);
input.headers.set('content-length', '1');
await expect(
readGatewayRequestBody(input, { input: body.length - 1, output: bytes.length })
).resolves.toEqual({
status: 413,
error: 'Request body exceeds the gateway resource limit.',
});
await expect(
readGatewayRequestBody(request(body, encoding), { output: bytes.length - 1 })
).resolves.toEqual({
status: 413,
error: 'Request body exceeds the gateway resource limit.',
});
}
);

it('enforces the native decoder window limit before output exceeds its limit', async () => {
const body = zstdCompressSync(Buffer.alloc(2048, 65), {
params: { [constants.ZSTD_c_contentSizeFlag]: 0, [constants.ZSTD_c_windowLog]: 12 },
});
await expect(
readGatewayRequestBody(request(body, 'zstd'), { window: 10, output: 4096 })
).resolves.toEqual({ status: 413, error: 'Request body exceeds the gateway resource limit.' });
});

it.each(['identity', 'zstd'])('cancels a stalled %s body read', async encoding => {
const controller = new AbortController();
const started = Promise.withResolvers<void>();
const cancelled = Promise.withResolvers<unknown>();
const body = new ReadableStream<Uint8Array>({
start(stream) {
stream.enqueue((encoding === 'zstd' ? frame : bytes).subarray(0, 4));
},
pull() {
started.resolve();
},
cancel(reason) {
cancelled.resolve(reason);
},
});
const result = readGatewayRequestBody(request(body, encoding, controller.signal));
await started.promise;
controller.abort();
await expect(result).resolves.toEqual({
status: 499,
error: 'Request cancelled while reading request body.',
});
await expect(cancelled.promise).resolves.toMatchObject({ name: 'AbortError' });
});

it('does not decode an already cancelled request', async () => {
const controller = new AbortController();
controller.abort();
await expect(
readGatewayRequestBody(request(frame, 'zstd', controller.signal))
).resolves.toMatchObject({ status: 499 });
});
});
Loading