adding model limits
This commit is contained in:
@@ -8,6 +8,10 @@ DATABASE_URL=postgres://user:password@localhost:5432/opencode_proxy
|
|||||||
# Send one in Authorization: Bearer <key> or x-api-key.
|
# Send one in Authorization: Bearer <key> or x-api-key.
|
||||||
# UNLIMITED_API_KEYS=["personal-unlimited-key"]
|
# UNLIMITED_API_KEYS=["personal-unlimited-key"]
|
||||||
|
|
||||||
|
# Optional: override the built-in comma-separated model allowlist for ordinary clients.
|
||||||
|
# Unlimited API keys bypass this restriction. Set this to an empty value to allow every upstream model.
|
||||||
|
# ALLOWED_MODELS=kimi-k3,gpt-4.1
|
||||||
|
|
||||||
# Optional server settings
|
# Optional server settings
|
||||||
PORT=4005
|
PORT=4005
|
||||||
MAX_CONTEXT_TOKENS=262144
|
MAX_CONTEXT_TOKENS=262144
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ To exempt trusted clients from those proxy limits, set `UNLIMITED_API_KEYS` to a
|
|||||||
UNLIMITED_API_KEYS='["personal-unlimited-key"]'
|
UNLIMITED_API_KEYS='["personal-unlimited-key"]'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
By default, ordinary clients may use only `minimax-m3`, `minimax-m2.7`, `minimax-m2.5`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`, `glm-5.2`, `glm-5.1`, `glm-5`, `deepseek-v4-pro`, `deepseek-v4-flash`, `qwen3.5-plus`, `mimo-v2.5`, `hy3-preview`, and `grok-4.5`. To replace that list, set `ALLOWED_MODELS` to a comma-separated allowlist. The proxy removes every other model from the OpenAI and Anthropic model lists and rejects inference requests using a model outside the list. Clients using an `UNLIMITED_API_KEYS` key bypass this restriction and see the complete model list. Set `ALLOWED_MODELS` to an empty value to allow every upstream model.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ALLOWED_MODELS=kimi-k3,gpt-4.1
|
||||||
|
```
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
+49
-7
@@ -94,6 +94,23 @@ async function forwardError(upstream, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_MAX_CONTEXT_TOKENS = 262144;
|
export const DEFAULT_MAX_CONTEXT_TOKENS = 262144;
|
||||||
|
export const DEFAULT_ALLOWED_MODELS = [
|
||||||
|
'minimax-m3',
|
||||||
|
'minimax-m2.7',
|
||||||
|
'minimax-m2.5',
|
||||||
|
'kimi-k2.7-code',
|
||||||
|
'kimi-k2.6',
|
||||||
|
'kimi-k2.5',
|
||||||
|
'glm-5.2',
|
||||||
|
'glm-5.1',
|
||||||
|
'glm-5',
|
||||||
|
'deepseek-v4-pro',
|
||||||
|
'deepseek-v4-flash',
|
||||||
|
'qwen3.5-plus',
|
||||||
|
'mimo-v2.5',
|
||||||
|
'hy3-preview',
|
||||||
|
'grok-4.5',
|
||||||
|
];
|
||||||
const defaultPagesDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pages');
|
const defaultPagesDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pages');
|
||||||
|
|
||||||
function maxContextFromEnv(value = process.env.MAX_CONTEXT_TOKENS) {
|
function maxContextFromEnv(value = process.env.MAX_CONTEXT_TOKENS) {
|
||||||
@@ -111,10 +128,12 @@ function validateContext(body, maxContextTokens) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function withContextLimit(payload, maxContextTokens) {
|
function withContextLimit(payload, maxContextTokens, allowedModels) {
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
data: (payload.data || []).map((model) => ({
|
data: (payload.data || [])
|
||||||
|
.filter((model) => !allowedModels || allowedModels.has(model.id))
|
||||||
|
.map((model) => ({
|
||||||
...model,
|
...model,
|
||||||
context_length: maxContextTokens,
|
context_length: maxContextTokens,
|
||||||
context_window: maxContextTokens,
|
context_window: maxContextTokens,
|
||||||
@@ -138,6 +157,17 @@ export function unlimitedKeysFromEnv(value = process.env.UNLIMITED_API_KEYS) {
|
|||||||
return new Set(keys);
|
return new Set(keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function allowedModelsFromEnv(value = process.env.ALLOWED_MODELS) {
|
||||||
|
if (value === undefined) return new Set(DEFAULT_ALLOWED_MODELS);
|
||||||
|
if (value.trim() === '') return null;
|
||||||
|
|
||||||
|
const models = value.split(',').map((model) => model.trim());
|
||||||
|
if (models.some((model) => model === '')) {
|
||||||
|
throw new Error('ALLOWED_MODELS must be a comma-separated list of non-empty model IDs');
|
||||||
|
}
|
||||||
|
return new Set(models);
|
||||||
|
}
|
||||||
|
|
||||||
function requestApiKey(request) {
|
function requestApiKey(request) {
|
||||||
const authorization = request.get('authorization');
|
const authorization = request.get('authorization');
|
||||||
const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
||||||
@@ -148,7 +178,16 @@ function hasUnlimitedKey(request, unlimitedKeys) {
|
|||||||
return unlimitedKeys.has(requestApiKey(request));
|
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 } = {}) {
|
function validateAllowedModel(model, allowedModels) {
|
||||||
|
if (!allowedModels) return;
|
||||||
|
if (!allowedModels.has(model)) {
|
||||||
|
const error = new Error(`Model ${JSON.stringify(model)} is not allowed`);
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBaseUrl = DEFAULT_UPSTREAM_BASE_URL, maxContextTokens = maxContextFromEnv(), rateLimiter = createRateLimiter(), unlimitedKeys = unlimitedKeysFromEnv(), allowedModels = allowedModelsFromEnv(), 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));
|
||||||
@@ -187,24 +226,24 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/oai/v1/models', async (_request, response) => {
|
app.get('/oai/v1/models', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/models`, {
|
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/models`, {
|
||||||
headers: { accept: 'application/json' },
|
headers: { accept: 'application/json' },
|
||||||
});
|
});
|
||||||
if (!upstream.ok) return forwardError(upstream, response);
|
if (!upstream.ok) return forwardError(upstream, response);
|
||||||
const payload = withContextLimit(JSON.parse(await upstream.text()), maxContextTokens);
|
const payload = withContextLimit(JSON.parse(await upstream.text()), maxContextTokens, hasUnlimitedKey(request, unlimitedKeys) ? null : allowedModels);
|
||||||
response.type('application/json').status(upstream.status).send(JSON.stringify(payload));
|
response.type('application/json').status(upstream.status).send(JSON.stringify(payload));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
response.status(502).json({ error: { message: `Unable to reach OpenCode Go: ${error.message}`, type: 'upstream_error' } });
|
response.status(502).json({ error: { message: `Unable to reach OpenCode Go: ${error.message}`, type: 'upstream_error' } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(['/ant/v1/models', '/ant/v1/v1/models'], async (_request, response) => {
|
app.get(['/ant/v1/models', '/ant/v1/v1/models'], async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/models`, { headers: { accept: 'application/json' } });
|
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/models`, { headers: { accept: 'application/json' } });
|
||||||
if (!upstream.ok) return forwardError(upstream, response);
|
if (!upstream.ok) return forwardError(upstream, response);
|
||||||
const payload = withContextLimit(JSON.parse(await upstream.text()), maxContextTokens);
|
const payload = withContextLimit(JSON.parse(await upstream.text()), maxContextTokens, hasUnlimitedKey(request, unlimitedKeys) ? null : allowedModels);
|
||||||
response.type('application/json').send(JSON.stringify(translateAnthropicModels(payload)));
|
response.type('application/json').send(JSON.stringify(translateAnthropicModels(payload)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
response.status(502).json({ type: 'error', error: { type: 'upstream_error', message: `Unable to reach OpenCode Go: ${error.message}` } });
|
response.status(502).json({ type: 'error', error: { type: 'upstream_error', message: `Unable to reach OpenCode Go: ${error.message}` } });
|
||||||
@@ -216,6 +255,7 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
validateContext(request.body, maxContextTokens);
|
validateContext(request.body, maxContextTokens);
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||||
|
if (!unlimited) validateAllowedModel(request.body?.model, allowedModels);
|
||||||
if (!unlimited) {
|
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);
|
||||||
@@ -284,6 +324,7 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
try {
|
try {
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||||
|
if (!unlimited) validateAllowedModel(translated.model, allowedModels);
|
||||||
if (!unlimited) {
|
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);
|
||||||
@@ -352,6 +393,7 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
validateContext(translated, maxContextTokens);
|
validateContext(translated, maxContextTokens);
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
const unlimited = hasUnlimitedKey(request, unlimitedKeys);
|
||||||
|
if (!unlimited) validateAllowedModel(translated.model, allowedModels);
|
||||||
if (!unlimited) {
|
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);
|
||||||
|
|||||||
+68
-5
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { createProxyApp, unlimitedKeysFromEnv } from '../src/proxy.js';
|
import { allowedModelsFromEnv, createProxyApp, DEFAULT_ALLOWED_MODELS, 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,
|
||||||
@@ -8,6 +8,17 @@ const response = (body, options = {}) => new Response(body, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('proxy', () => {
|
describe('proxy', () => {
|
||||||
|
const originalAllowedModels = process.env.ALLOWED_MODELS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.ALLOWED_MODELS = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalAllowedModels === undefined) delete process.env.ALLOWED_MODELS;
|
||||||
|
else process.env.ALLOWED_MODELS = originalAllowedModels;
|
||||||
|
});
|
||||||
|
|
||||||
it('forwards models and rotates credentials', async () => {
|
it('forwards models and rotates credentials', async () => {
|
||||||
const fetchImpl = vi.fn()
|
const fetchImpl = vi.fn()
|
||||||
.mockResolvedValueOnce(response('{"object":"list","data":[]}'))
|
.mockResolvedValueOnce(response('{"object":"list","data":[]}'))
|
||||||
@@ -107,13 +118,65 @@ describe('proxy', () => {
|
|||||||
expect(() => unlimitedKeysFromEnv('[""]')).toThrow('UNLIMITED_API_KEYS must be a JSON array of non-empty strings');
|
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('validates the allowed models environment variable', () => {
|
||||||
const fetchImpl = vi.fn().mockResolvedValue(response('{"data":[{"id":"m"}]}'));
|
expect(allowedModelsFromEnv(' model-a,model-b ')).toEqual(new Set(['model-a', 'model-b']));
|
||||||
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 123 });
|
expect(allowedModelsFromEnv('')).toBeNull();
|
||||||
|
expect(() => allowedModelsFromEnv('model-a,')).toThrow('ALLOWED_MODELS must be a comma-separated list of non-empty model IDs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the built-in allowlist when ALLOWED_MODELS is unset', () => {
|
||||||
|
delete process.env.ALLOWED_MODELS;
|
||||||
|
expect(allowedModelsFromEnv()).toEqual(new Set(DEFAULT_ALLOWED_MODELS));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the global context limit and filters model listings for restricted clients', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response('{"data":[{"id":"m"},{"id":"hidden"}]}'));
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl, maxContextTokens: 123, allowedModels: new Set(['m']) });
|
||||||
const result = await request(app).get('/oai/v1/models').expect(200);
|
const result = await request(app).get('/oai/v1/models').expect(200);
|
||||||
|
expect(result.body.data).toHaveLength(1);
|
||||||
|
expect(result.body.data[0].id).toBe('m');
|
||||||
expect(result.body.data[0]).toMatchObject({ context_length: 123, context_window: 123, max_context_tokens: 123 });
|
expect(result.body.data[0]).toMatchObject({ context_length: 123, context_window: 123, max_context_tokens: 123 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enforces allowed models on every inference API and lets unlimited keys bypass it', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
||||||
|
id: 'c', model: 'blocked', choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: {},
|
||||||
|
})));
|
||||||
|
const app = createProxyApp({
|
||||||
|
keys: ['key'], fetchImpl, allowedModels: new Set(['allowed']), unlimitedKeys: new Set(['unlimited-key']),
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app).post('/oai/v1/chat/completions').send({ model: 'blocked' }).expect(400, {
|
||||||
|
error: { message: 'Model "blocked" is not allowed', type: 'invalid_request_error' },
|
||||||
|
});
|
||||||
|
await request(app).post('/oai/v1/responses').send({ model: 'blocked', input: 'Hi' }).expect(400, {
|
||||||
|
error: { message: 'Model "blocked" is not allowed', type: 'invalid_request_error' },
|
||||||
|
});
|
||||||
|
await request(app).post('/ant/v1/messages').send({ model: 'blocked', max_tokens: 1, messages: [] }).expect(400, {
|
||||||
|
type: 'error', error: { type: 'invalid_request_error', message: 'Model "blocked" is not allowed' },
|
||||||
|
});
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/oai/v1/chat/completions')
|
||||||
|
.set('Authorization', 'Bearer unlimited-key')
|
||||||
|
.send({ model: 'blocked' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets unlimited keys see the complete model list', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockImplementation(() => response('{"data":[{"id":"allowed"},{"id":"blocked"}]}'));
|
||||||
|
const app = createProxyApp({
|
||||||
|
keys: ['key'], fetchImpl, allowedModels: new Set(['allowed']), unlimitedKeys: new Set(['unlimited-key']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const restricted = await request(app).get('/ant/v1/models').expect(200);
|
||||||
|
expect(restricted.body.data.map((model) => model.id)).toEqual(['allowed']);
|
||||||
|
|
||||||
|
const unlimited = await request(app).get('/oai/v1/models').set('x-api-key', 'unlimited-key').expect(200);
|
||||||
|
expect(unlimited.body.data.map((model) => model.id)).toEqual(['allowed', 'blocked']);
|
||||||
|
});
|
||||||
|
|
||||||
it('passes through streaming responses', async () => {
|
it('passes through streaming responses', async () => {
|
||||||
const fetchImpl = vi.fn().mockResolvedValue(response('data: {"delta":"Hi"}\n\ndata: [DONE]\n\n', {
|
const fetchImpl = vi.fn().mockResolvedValue(response('data: {"delta":"Hi"}\n\ndata: [DONE]\n\n', {
|
||||||
headers: { 'content-type': 'text/event-stream' },
|
headers: { 'content-type': 'text/event-stream' },
|
||||||
|
|||||||
Reference in New Issue
Block a user