-
-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathchat-proxy-ratelimit.test.js
More file actions
136 lines (120 loc) · 4.22 KB
/
Copy pathchat-proxy-ratelimit.test.js
File metadata and controls
136 lines (120 loc) · 4.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
// /api/chat/proxy.js forwards requests to OpenRouter using our shared API key.
// The endpoint is intentionally unauthenticated (it's the in-browser fallback
// chat used by anonymous visitors), but only :free upstream models are allowed.
// Without per-IP rate limiting, a single client could drain the shared free
// quota for everyone — this test pins the rate-limit behavior.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Readable } from 'node:stream';
const rateLimitState = {
chatIp: { success: true },
};
vi.mock('../../api/_lib/env.js', () => ({
env: { APP_ORIGIN: 'http://localhost:3000', OPENROUTER_API_KEY: 'sk-or-test', OPENROUTER_FALLBACK_KEYS: [] },
}));
vi.mock('../../api/_lib/rate-limit.js', () => ({
limits: {
chatIp: vi.fn(async () => rateLimitState.chatIp),
},
clientIp: vi.fn(() => '203.0.113.7'),
}));
vi.mock('../../api/_lib/sentry.js', () => ({ captureException: vi.fn() }));
vi.mock('../../api/_lib/zauth.js', () => ({
instrument: vi.fn(() => null),
drain: vi.fn(async () => {}),
}));
// Stub global fetch — we never want to hit OpenRouter from a unit test.
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
const { default: handler } = await import('../../api/chat/proxy.js');
function makeReq({ method = 'POST', headers = {}, body = null } = {}) {
const stream = body
? Readable.from([Buffer.from(JSON.stringify(body))])
: Readable.from([]);
stream.method = method;
stream.url = '/api/chat/proxy';
stream.headers = {
host: 'localhost',
...(body ? { 'content-type': 'application/json' } : {}),
...headers,
};
return stream;
}
function makeRes() {
return {
statusCode: 200,
headers: {},
body: '',
writableEnded: false,
setHeader(k, v) {
this.headers[k.toLowerCase()] = v;
},
getHeader(k) {
return this.headers[k.toLowerCase()];
},
end(chunk) {
if (chunk !== undefined) this.body += String(chunk);
this.writableEnded = true;
},
write(chunk) {
if (chunk !== undefined) this.body += String(chunk);
},
};
}
beforeEach(() => {
rateLimitState.chatIp = { success: true };
fetchMock.mockReset();
});
describe('POST /api/chat/proxy — per-IP rate limit', () => {
it('returns 429 when the per-IP limiter rejects the request', async () => {
rateLimitState.chatIp = { success: false };
const req = makeReq({ body: { model: 'meta-llama/llama-3-8b-instruct:free', messages: [] } });
const res = makeRes();
await handler(req, res);
expect(res.statusCode).toBe(429);
const body = JSON.parse(res.body);
expect(body.error).toBe('rate_limited');
// rateLimited() derives Retry-After from the limiter result, flooring at 1s
// when the mock provides no reset window.
expect(res.getHeader('retry-after')).toBe('1');
// Upstream fetch must NOT have been called.
expect(fetchMock).not.toHaveBeenCalled();
});
it('rate-limit runs before upstream, even with valid free-tier model', async () => {
rateLimitState.chatIp = { success: false };
fetchMock.mockResolvedValue({
status: 200,
body: null,
text: async () => '',
headers: { get: () => null },
});
const req = makeReq({ body: { model: 'mistral:free', messages: [{ role: 'user', content: 'hi' }] } });
const res = makeRes();
await handler(req, res);
expect(res.statusCode).toBe(429);
expect(fetchMock).not.toHaveBeenCalled();
});
it('passes through when limiter approves the request', async () => {
rateLimitState.chatIp = { success: true };
fetchMock.mockResolvedValue({
status: 200,
body: null,
text: async () => '{"id":"x"}',
headers: { get: (k) => (k === 'content-type' ? 'application/json' : null) },
});
const req = makeReq({ body: { model: 'mistral:free', messages: [] } });
const res = makeRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain('openrouter.ai');
});
it('rejects non-:free models without calling upstream', async () => {
rateLimitState.chatIp = { success: true };
const req = makeReq({ body: { model: 'gpt-4', messages: [] } });
const res = makeRes();
await handler(req, res);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toBe('invalid_model');
expect(fetchMock).not.toHaveBeenCalled();
});
});