Adding unlimited key
This commit is contained in:
@@ -4,6 +4,10 @@ DATABASE_URL=postgres://user:password@localhost:5432/opencode_proxy
|
|||||||
# Optional: use this instead of keys.txt. Must be a JSON array of strings.
|
# Optional: use this instead of keys.txt. Must be a JSON array of strings.
|
||||||
# OPENCODE_API_KEYS=["go-first-key","go-second-key"]
|
# OPENCODE_API_KEYS=["go-first-key","go-second-key"]
|
||||||
|
|
||||||
|
# Optional: client keys that bypass proxy rate limits. Must be a JSON array of strings.
|
||||||
|
# Send one in Authorization: Bearer <key> or x-api-key.
|
||||||
|
# UNLIMITED_API_KEYS=["personal-unlimited-key"]
|
||||||
|
|
||||||
# Optional server settings
|
# Optional server settings
|
||||||
PORT=4005
|
PORT=4005
|
||||||
MAX_CONTEXT_TOKENS=262144
|
MAX_CONTEXT_TOKENS=262144
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ The global context limit defaults to 256k tokens and can be changed with `MAX_CO
|
|||||||
|
|
||||||
Inference requests are limited per client IP to 2 requests per second, 100 requests per five hours, and $10 of reported upstream cost per five hours. Railway's `X-Real-IP` header is used to identify clients.
|
Inference requests are limited per client IP to 2 requests per second, 100 requests per five hours, and $10 of reported upstream cost per five hours. Railway's `X-Real-IP` header is used to identify clients.
|
||||||
|
|
||||||
|
To exempt trusted clients from those proxy limits, set `UNLIMITED_API_KEYS` to a JSON array and have the client send a configured key using `Authorization: Bearer <key>` or `x-api-key`. These keys only bypass this proxy's rate and spend limits; they do not bypass upstream OpenCode Go limits.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
UNLIMITED_API_KEYS='["personal-unlimited-key"]'
|
||||||
|
```
|
||||||
|
|
||||||
Set `DATABASE_URL` in `.env` (or the process environment) to a Postgres connection string. The proxy creates its `requests` table and index automatically on startup. Only requests to the OpenAI Chat Completions and Responses endpoints and the Anthropic Messages endpoints are stored. Their complete responses—including streaming responses and errors—are stored with headers, status, model, client IP, and timestamp. On startup, stored requests for all other endpoints are deleted. Successful chat completion requests also store a normalized training conversation containing the full chat history and generated assistant output, including reasoning, tool calls, and tool results.
|
Set `DATABASE_URL` in `.env` (or the process environment) to a Postgres connection string. The proxy creates its `requests` table and index automatically on startup. Only requests to the OpenAI Chat Completions and Responses endpoints and the Anthropic Messages endpoints are stored. Their complete responses—including streaming responses and errors—are stored with headers, status, model, client IP, and timestamp. On startup, stored requests for all other endpoints are deleted. Successful chat completion requests also store a normalized training conversation containing the full chat history and generated assistant output, including reasoning, tool calls, and tool results.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+42
-8
@@ -123,7 +123,32 @@ function withContextLimit(payload, maxContextTokens) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBaseUrl = DEFAULT_UPSTREAM_BASE_URL, maxContextTokens = maxContextFromEnv(), rateLimiter = createRateLimiter(), pagesDirectory = defaultPagesDirectory, requestLogger } = {}) {
|
export function unlimitedKeysFromEnv(value = process.env.UNLIMITED_API_KEYS) {
|
||||||
|
if (value === undefined || value.trim() === '') return new Set();
|
||||||
|
|
||||||
|
let keys;
|
||||||
|
try {
|
||||||
|
keys = JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`UNLIMITED_API_KEYS must be valid JSON: ${error.message}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(keys) || keys.some((key) => typeof key !== 'string' || key.trim() === '')) {
|
||||||
|
throw new Error('UNLIMITED_API_KEYS must be a JSON array of non-empty strings');
|
||||||
|
}
|
||||||
|
return new Set(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestApiKey(request) {
|
||||||
|
const authorization = request.get('authorization');
|
||||||
|
const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
||||||
|
return bearer || request.get('x-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnlimitedKey(request, unlimitedKeys) {
|
||||||
|
return unlimitedKeys.has(requestApiKey(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBaseUrl = DEFAULT_UPSTREAM_BASE_URL, maxContextTokens = maxContextFromEnv(), rateLimiter = createRateLimiter(), unlimitedKeys = unlimitedKeysFromEnv(), pagesDirectory = defaultPagesDirectory, requestLogger } = {}) {
|
||||||
const rotator = createKeyRotator(keys);
|
const rotator = createKeyRotator(keys);
|
||||||
const app = express();
|
const app = express();
|
||||||
if (requestLogger) app.post(CHAT_COMPLETION_ENDPOINTS, collectRequestData(requestLogger));
|
if (requestLogger) app.post(CHAT_COMPLETION_ENDPOINTS, collectRequestData(requestLogger));
|
||||||
@@ -190,8 +215,11 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
try {
|
try {
|
||||||
validateContext(request.body, maxContextTokens);
|
validateContext(request.body, maxContextTokens);
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
|
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||||
|
if (!unlimited) {
|
||||||
const limit = rateLimiter.check(ip);
|
const limit = rateLimiter.check(ip);
|
||||||
if (!limit.allowed) return rateLimitError(response, false, limit);
|
if (!limit.allowed) return rateLimitError(response, false, limit);
|
||||||
|
}
|
||||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -219,21 +247,21 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
if (!recordedCost && data && data !== '[DONE]') {
|
if (!recordedCost && data && data !== '[DONE]') {
|
||||||
try {
|
try {
|
||||||
const cost = numericCost(JSON.parse(data));
|
const cost = numericCost(JSON.parse(data));
|
||||||
if (cost !== null) { rateLimiter.recordCost(ip, cost); recordedCost = true; }
|
if (cost !== null) { if (!unlimited) rateLimiter.recordCost(ip, cost); recordedCost = true; }
|
||||||
} catch { /* ignore malformed stream events */ }
|
} catch { /* ignore malformed stream events */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!recordedCost) {
|
if (!recordedCost) {
|
||||||
const data = streamBuffer.split('\n').find((line) => line.startsWith('data:'))?.slice(5).trim();
|
const data = streamBuffer.split('\n').find((line) => line.startsWith('data:'))?.slice(5).trim();
|
||||||
try { const cost = numericCost(JSON.parse(data)); if (cost !== null) rateLimiter.recordCost(ip, cost); } catch { /* ignore */ }
|
try { const cost = numericCost(JSON.parse(data)); if (cost !== null && !unlimited) rateLimiter.recordCost(ip, cost); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
response.end();
|
response.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await upstream.text();
|
const body = await upstream.text();
|
||||||
try { rateLimiter.recordCost(ip, numericCost(JSON.parse(body))); } catch { /* non-JSON upstream response */ }
|
try { if (!unlimited) rateLimiter.recordCost(ip, numericCost(JSON.parse(body))); } catch { /* non-JSON upstream response */ }
|
||||||
response.status(upstream.status).send(body);
|
response.status(upstream.status).send(body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!response.headersSent) {
|
if (!response.headersSent) {
|
||||||
@@ -255,8 +283,11 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
|
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||||
|
if (!unlimited) {
|
||||||
const limit = rateLimiter.check(ip);
|
const limit = rateLimiter.check(ip);
|
||||||
if (!limit.allowed) return rateLimitError(response, false, limit);
|
if (!limit.allowed) return rateLimitError(response, false, limit);
|
||||||
|
}
|
||||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json', accept: translated.stream ? 'text/event-stream' : 'application/json' },
|
headers: { 'content-type': 'application/json', accept: translated.stream ? 'text/event-stream' : 'application/json' },
|
||||||
@@ -268,7 +299,7 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
if (!translated.stream) {
|
if (!translated.stream) {
|
||||||
const body = await upstream.text();
|
const body = await upstream.text();
|
||||||
const payload = JSON.parse(body);
|
const payload = JSON.parse(body);
|
||||||
rateLimiter.recordCost(ip, numericCost(payload));
|
if (!unlimited) rateLimiter.recordCost(ip, numericCost(payload));
|
||||||
return response.type('application/json').send(JSON.stringify(translateResponsesResponse(payload, translated.model, responseId)));
|
return response.type('application/json').send(JSON.stringify(translateResponsesResponse(payload, translated.model, responseId)));
|
||||||
}
|
}
|
||||||
if (!upstream.body) return response.status(502).json({ error: { type: 'upstream_error', message: 'Upstream returned no streaming body' } });
|
if (!upstream.body) return response.status(502).json({ error: { type: 'upstream_error', message: 'Upstream returned no streaming body' } });
|
||||||
@@ -287,7 +318,7 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data);
|
const payload = JSON.parse(data);
|
||||||
const cost = numericCost(payload);
|
const cost = numericCost(payload);
|
||||||
if (!recordedCost && cost !== null) { rateLimiter.recordCost(ip, cost); recordedCost = true; }
|
if (!recordedCost && cost !== null) { if (!unlimited) rateLimiter.recordCost(ip, cost); recordedCost = true; }
|
||||||
response.write(translateResponsesChunk(payload, state));
|
response.write(translateResponsesChunk(payload, state));
|
||||||
} catch { /* ignore malformed upstream events */ }
|
} catch { /* ignore malformed upstream events */ }
|
||||||
}
|
}
|
||||||
@@ -320,8 +351,11 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
try {
|
try {
|
||||||
validateContext(translated, maxContextTokens);
|
validateContext(translated, maxContextTokens);
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
|
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||||
|
if (!unlimited) {
|
||||||
const limit = rateLimiter.check(ip);
|
const limit = rateLimiter.check(ip);
|
||||||
if (!limit.allowed) return rateLimitError(response, true, limit);
|
if (!limit.allowed) return rateLimitError(response, true, limit);
|
||||||
|
}
|
||||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json', accept: translated.stream ? 'text/event-stream' : 'application/json' },
|
headers: { 'content-type': 'application/json', accept: translated.stream ? 'text/event-stream' : 'application/json' },
|
||||||
@@ -330,7 +364,7 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
if (!upstream.ok) return forwardError(upstream, response);
|
if (!upstream.ok) return forwardError(upstream, response);
|
||||||
if (!translated.stream) {
|
if (!translated.stream) {
|
||||||
const payload = JSON.parse(await upstream.text());
|
const payload = JSON.parse(await upstream.text());
|
||||||
rateLimiter.recordCost(ip, numericCost(payload));
|
if (!unlimited) rateLimiter.recordCost(ip, numericCost(payload));
|
||||||
return response.type('application/json').send(JSON.stringify(translateAnthropicResponse(payload, translated.model)));
|
return response.type('application/json').send(JSON.stringify(translateAnthropicResponse(payload, translated.model)));
|
||||||
}
|
}
|
||||||
if (!upstream.body) return response.status(502).json({ type: 'error', error: { type: 'upstream_error', message: 'Upstream returned no streaming body' } });
|
if (!upstream.body) return response.status(502).json({ type: 'error', error: { type: 'upstream_error', message: 'Upstream returned no streaming body' } });
|
||||||
@@ -349,7 +383,7 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data);
|
const payload = JSON.parse(data);
|
||||||
const cost = numericCost(payload);
|
const cost = numericCost(payload);
|
||||||
if (!recordedCost && cost !== null) { rateLimiter.recordCost(ip, cost); recordedCost = true; }
|
if (!recordedCost && cost !== null) { if (!unlimited) rateLimiter.recordCost(ip, cost); recordedCost = true; }
|
||||||
response.write(translateOpenAIChunk(payload, state));
|
response.write(translateOpenAIChunk(payload, state));
|
||||||
} catch { /* ignore malformed upstream event */ }
|
} catch { /* ignore malformed upstream event */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { createProxyApp } from '../src/proxy.js';
|
import { createProxyApp, unlimitedKeysFromEnv } from '../src/proxy.js';
|
||||||
|
|
||||||
const response = (body, options = {}) => new Response(body, {
|
const response = (body, options = {}) => new Response(body, {
|
||||||
status: options.status ?? 200,
|
status: options.status ?? 200,
|
||||||
@@ -69,6 +69,44 @@ describe('proxy', () => {
|
|||||||
await request(app).post('/oai/v1/chat/completions').set('X-Real-IP', '198.51.100.1').send({ model: 'm', max_tokens: 1 }).expect(429);
|
await request(app).post('/oai/v1/chat/completions').set('X-Real-IP', '198.51.100.1').send({ model: 'm', max_tokens: 1 }).expect(429);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('bypasses rate checks and spend accounting for configured unlimited keys', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({ id: 'c', cost: 1 })));
|
||||||
|
const rateLimiter = { check: vi.fn(() => ({ allowed: false, retryAfter: 1, reason: 'rate' })), recordCost: vi.fn() };
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl, rateLimiter, unlimitedKeys: new Set(['unlimited-key']) });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/oai/v1/chat/completions')
|
||||||
|
.set('Authorization', 'Bearer unlimited-key')
|
||||||
|
.send({ model: 'm' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(rateLimiter.check).not.toHaveBeenCalled();
|
||||||
|
expect(rateLimiter.recordCost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes x-api-key unlimited keys for Anthropic requests', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
||||||
|
id: 'c', model: 'm', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], cost: 1,
|
||||||
|
})));
|
||||||
|
const rateLimiter = { check: vi.fn(() => ({ allowed: false, retryAfter: 1, reason: 'rate' })), recordCost: vi.fn() };
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl, rateLimiter, unlimitedKeys: new Set(['unlimited-key']) });
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/ant/v1/messages')
|
||||||
|
.set('x-api-key', 'unlimited-key')
|
||||||
|
.send({ model: 'm', max_tokens: 10, messages: [{ role: 'user', content: 'Hi' }] })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(rateLimiter.check).not.toHaveBeenCalled();
|
||||||
|
expect(rateLimiter.recordCost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates the unlimited API key environment variable', () => {
|
||||||
|
expect(unlimitedKeysFromEnv('["key-a", "key-b"]')).toEqual(new Set(['key-a', 'key-b']));
|
||||||
|
expect(() => unlimitedKeysFromEnv('key-a')).toThrow('UNLIMITED_API_KEYS must be valid JSON');
|
||||||
|
expect(() => unlimitedKeysFromEnv('[""]')).toThrow('UNLIMITED_API_KEYS must be a JSON array of non-empty strings');
|
||||||
|
});
|
||||||
|
|
||||||
it('adds the global context limit to model listings', async () => {
|
it('adds the global context limit to model listings', async () => {
|
||||||
const fetchImpl = vi.fn().mockResolvedValue(response('{"data":[{"id":"m"}]}'));
|
const fetchImpl = vi.fn().mockResolvedValue(response('{"data":[{"id":"m"}]}'));
|
||||||
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 123 });
|
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 123 });
|
||||||
|
|||||||
Reference in New Issue
Block a user