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.
|
||||
# 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
|
||||
PORT=4005
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
```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 app = express();
|
||||
if (requestLogger) app.post(CHAT_COMPLETION_ENDPOINTS, collectRequestData(requestLogger));
|
||||
@@ -190,8 +215,11 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
||||
try {
|
||||
validateContext(request.body, maxContextTokens);
|
||||
const ip = clientIp(request);
|
||||
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||
if (!unlimited) {
|
||||
const limit = rateLimiter.check(ip);
|
||||
if (!limit.allowed) return rateLimitError(response, false, limit);
|
||||
}
|
||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -219,21 +247,21 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
||||
if (!recordedCost && data && data !== '[DONE]') {
|
||||
try {
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!recordedCost) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
if (!response.headersSent) {
|
||||
@@ -255,8 +283,11 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
||||
|
||||
try {
|
||||
const ip = clientIp(request);
|
||||
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||
if (!unlimited) {
|
||||
const limit = rateLimiter.check(ip);
|
||||
if (!limit.allowed) return rateLimitError(response, false, limit);
|
||||
}
|
||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
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) {
|
||||
const body = await upstream.text();
|
||||
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)));
|
||||
}
|
||||
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 {
|
||||
const payload = JSON.parse(data);
|
||||
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));
|
||||
} catch { /* ignore malformed upstream events */ }
|
||||
}
|
||||
@@ -320,8 +351,11 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
||||
try {
|
||||
validateContext(translated, maxContextTokens);
|
||||
const ip = clientIp(request);
|
||||
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||
if (!unlimited) {
|
||||
const limit = rateLimiter.check(ip);
|
||||
if (!limit.allowed) return rateLimitError(response, true, limit);
|
||||
}
|
||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
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 (!translated.stream) {
|
||||
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)));
|
||||
}
|
||||
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 {
|
||||
const payload = JSON.parse(data);
|
||||
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));
|
||||
} catch { /* ignore malformed upstream event */ }
|
||||
}
|
||||
|
||||
+39
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createProxyApp } from '../src/proxy.js';
|
||||
import { createProxyApp, unlimitedKeysFromEnv } from '../src/proxy.js';
|
||||
|
||||
const response = (body, options = {}) => new Response(body, {
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(response('{"data":[{"id":"m"}]}'));
|
||||
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 123 });
|
||||
|
||||
Reference in New Issue
Block a user