FreeLLMAPI / server /src /__tests__ /routes /custom-provider.test.ts
Nryn215's picture
Upload folder using huggingface_hub
077865a verified
Raw
History Blame Contribute Delete
13.5 kB
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import type { Express } from 'express';
import { createApp } from '../../app.js';
import { initDb, getDb, getUnifiedApiKey } from '../../db/index.js';
import { routeRequest } from '../../services/router.js';
import { resolveProvider, getProvider } from '../../providers/index.js';
import { mintDashboardToken, isGatedApiPath } from '../helpers/auth.js';
let dashToken = '';
async function post(app: Express, path: string, body: any) {
const server = app.listen(0);
const addr = server.address() as any;
const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(isGatedApiPath(path) ? { Authorization: `Bearer ${dashToken}` } : {}),
},
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
server.close();
return { status: res.status, body: data };
}
async function get(app: Express, path: string) {
const server = app.listen(0);
const addr = server.address() as any;
const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, {
headers: isGatedApiPath(path) ? { Authorization: `Bearer ${dashToken}` } : {},
});
const data = await res.json().catch(() => null);
server.close();
return { status: res.status, body: data };
}
async function del(app: Express, path: string) {
const server = app.listen(0);
const addr = server.address() as any;
const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, {
method: 'DELETE',
headers: isGatedApiPath(path) ? { Authorization: `Bearer ${dashToken}` } : {},
});
const data = await res.json().catch(() => null);
server.close();
return { status: res.status, body: data };
}
describe('resolveProvider (#117)', () => {
it('builds a custom provider bound to the supplied base URL', () => {
const p = resolveProvider('custom', 'http://127.0.0.1:8080/v1');
expect(p).toBeDefined();
expect(p!.platform).toBe('custom');
expect((p as any).baseUrl).toBe('http://127.0.0.1:8080/v1');
});
it('returns undefined for a custom provider with no base URL', () => {
expect(resolveProvider('custom', null)).toBeUndefined();
expect(resolveProvider('custom', ' ')).toBeUndefined();
});
it('returns the registered singleton for built-in platforms', () => {
expect(resolveProvider('groq')).toBe(getProvider('groq'));
});
});
describe('POST /api/keys/custom (#117)', () => {
let app: Express;
beforeAll(() => {
process.env.ENCRYPTION_KEY = '0'.repeat(64);
initDb(':memory:');
app = createApp();
dashToken = mintDashboardToken();
});
it('rejects an invalid base URL', async () => {
const { status } = await post(app, '/api/keys/custom', { baseUrl: 'not-a-url', model: 'm' });
expect(status).toBe(400);
});
it('registers a custom endpoint, model, and fallback entry', async () => {
const { status, body } = await post(app, '/api/keys/custom', {
baseUrl: 'http://127.0.0.1:11434/v1/',
model: 'qwen3:4b',
displayName: 'Local Qwen3 4B',
});
expect(status).toBe(201);
expect(body.platform).toBe('custom');
expect(body.baseUrl).toBe('http://127.0.0.1:11434/v1'); // trailing slash trimmed
expect(body.model).toBe('qwen3:4b');
const db = getDb();
const key = db.prepare("SELECT * FROM api_keys WHERE platform = 'custom'").get() as any;
expect(key.base_url).toBe('http://127.0.0.1:11434/v1');
const model = db.prepare("SELECT * FROM models WHERE platform = 'custom' AND model_id = 'qwen3:4b'").get() as any;
expect(model).toBeDefined();
const fc = db.prepare('SELECT * FROM fallback_config WHERE model_db_id = ?').get(model.id);
expect(fc).toBeDefined();
});
it('reuses the single custom key when a second model is added', async () => {
await post(app, '/api/keys/custom', { baseUrl: 'http://127.0.0.1:11434/v1', model: 'llama3:8b' });
const db = getDb();
const keys = db.prepare("SELECT * FROM api_keys WHERE platform = 'custom'").all();
expect(keys.length).toBe(1); // not a second key
const models = db.prepare("SELECT * FROM models WHERE platform = 'custom'").all();
expect(models.length).toBe(2);
});
it('surfaces baseUrl in the keys listing', async () => {
const { body } = await get(app, '/api/keys');
const custom = body.find((k: any) => k.platform === 'custom');
expect(custom.baseUrl).toBe('http://127.0.0.1:11434/v1');
});
it('routes a request to the custom model through its base URL', () => {
// The seeded built-in models have no keys, so the only routable model is
// the custom one we registered above.
const route = routeRequest(1000);
expect(route.platform).toBe('custom');
expect((route.provider as any).baseUrl).toBe('http://127.0.0.1:11434/v1');
expect(['qwen3:4b', 'llama3:8b']).toContain(route.modelId);
});
it('deleting the custom key cascades its models out of the fallback chain (#189)', async () => {
const db = getDb();
const key = db.prepare("SELECT id FROM api_keys WHERE platform = 'custom'").get() as { id: number };
const customModelIds = (db.prepare("SELECT id FROM models WHERE platform = 'custom'").all() as { id: number }[]).map(r => r.id);
expect(customModelIds.length).toBe(2); // qwen3:4b + llama3:8b from earlier tests
const builtinModels = (db.prepare("SELECT COUNT(*) AS n FROM models WHERE platform != 'custom'").get() as { n: number }).n;
const { status } = await del(app, `/api/keys/${key.id}`);
expect(status).toBe(200);
// Custom models and their fallback entries are gone — not orphaned.
expect((db.prepare("SELECT COUNT(*) AS n FROM models WHERE platform = 'custom'").get() as { n: number }).n).toBe(0);
const placeholders = customModelIds.map(() => '?').join(',');
expect((db.prepare(`SELECT COUNT(*) AS n FROM fallback_config WHERE model_db_id IN (${placeholders})`).get(...customModelIds) as { n: number }).n).toBe(0);
// Built-in catalog rows are untouched.
expect((db.prepare("SELECT COUNT(*) AS n FROM models WHERE platform != 'custom'").get() as { n: number }).n).toBe(builtinModels);
});
it('deleting a built-in platform key does NOT cascade its catalog models', async () => {
const db = getDb();
const r = db.prepare(`
INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled)
VALUES ('groq', 'test', 'x', 'x', 'x', 'unknown', 1)
`).run();
const groqModels = (db.prepare("SELECT COUNT(*) AS n FROM models WHERE platform = 'groq'").get() as { n: number }).n;
expect(groqModels).toBeGreaterThan(0);
const { status } = await del(app, `/api/keys/${r.lastInsertRowid}`);
expect(status).toBe(200);
expect((db.prepare("SELECT COUNT(*) AS n FROM models WHERE platform = 'groq'").get() as { n: number }).n).toBe(groqModels);
});
it('re-adding a custom provider after deletion starts a fresh chain entry', async () => {
const { status, body } = await post(app, '/api/keys/custom', {
baseUrl: 'http://127.0.0.1:8080/v1',
model: 'mistral:7b',
});
expect(status).toBe(201);
const db = getDb();
expect((db.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE platform = 'custom'").get() as { n: number }).n).toBe(1);
const fc = db.prepare('SELECT * FROM fallback_config WHERE model_db_id = ?').get(body.modelDbId);
expect(fc).toBeDefined();
});
it('surfaces a clear error when the custom endpoint speaks NDJSON, not OpenAI (#189)', async () => {
// Real upstream that answers like Ollama's native /api/chat: HTTP 200,
// newline-delimited JSON documents — res.json() in the provider would die
// with "Unexpected non-whitespace character after JSON at position …".
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
res.end(
JSON.stringify({ model: 'qwen3:4b', message: { role: 'assistant', content: 'hi' } }, null, 2) +
'\n' +
JSON.stringify({ done: true }) +
'\n',
);
});
await new Promise<void>(resolve => upstream.listen(0, resolve));
const upstreamPort = (upstream.address() as any).port;
// Point the custom provider at the NDJSON upstream and pin its model.
const reg = await post(app, '/api/keys/custom', {
baseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
model: 'ndjson-model',
});
expect(reg.status).toBe(201);
const server = app.listen(0);
const addr = server.address() as any;
const res = await fetch(`http://127.0.0.1:${addr.port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getUnifiedApiKey()}` },
body: JSON.stringify({ model: 'ndjson-model', messages: [{ role: 'user', content: 'hi' }] }),
});
const body = await res.json().catch(() => null);
server.close();
upstream.close();
expect(res.status).toBe(502);
expect(JSON.stringify(body)).toMatch(/not OpenAI-compatible/);
expect(JSON.stringify(body)).not.toMatch(/Unexpected non-whitespace/);
});
// #212: adding a second custom provider used to overwrite the first one's
// endpoint — one shared key row held THE base_url. Now each endpoint gets
// its own key row and models bind to their endpoint via models.key_id.
describe('multiple custom providers (#212)', () => {
beforeAll(async () => {
// Sweep custom state left by the tests above for a deterministic start.
const db = getDb();
db.prepare("DELETE FROM fallback_config WHERE model_db_id IN (SELECT id FROM models WHERE platform = 'custom')").run();
db.prepare("DELETE FROM models WHERE platform = 'custom'").run();
db.prepare("DELETE FROM api_keys WHERE platform = 'custom'").run();
const a = await post(app, '/api/keys/custom', { baseUrl: 'http://127.0.0.1:11434/v1', model: 'llama3:8b', label: 'Ollama box' });
const b = await post(app, '/api/keys/custom', { baseUrl: 'http://127.0.0.1:1234/v1', model: 'qwen3:4b', label: 'LM Studio' });
expect(a.status).toBe(201);
expect(b.status).toBe(201);
});
it('keeps a separate key row per endpoint instead of overwriting', () => {
const db = getDb();
const keys = db.prepare("SELECT id, base_url FROM api_keys WHERE platform = 'custom' ORDER BY id").all() as any[];
expect(keys.length).toBe(2);
expect(keys.map(k => k.base_url).sort()).toEqual(['http://127.0.0.1:11434/v1', 'http://127.0.0.1:1234/v1'].sort());
});
it('binds each model to its own endpoint key', () => {
const db = getDb();
const llama = db.prepare("SELECT m.key_id, k.base_url FROM models m JOIN api_keys k ON k.id = m.key_id WHERE m.platform = 'custom' AND m.model_id = 'llama3:8b'").get() as any;
const qwen = db.prepare("SELECT m.key_id, k.base_url FROM models m JOIN api_keys k ON k.id = m.key_id WHERE m.platform = 'custom' AND m.model_id = 'qwen3:4b'").get() as any;
expect(llama.base_url).toBe('http://127.0.0.1:11434/v1');
expect(qwen.base_url).toBe('http://127.0.0.1:1234/v1');
});
it('routes each model through ITS endpoint, never the other one', () => {
const db = getDb();
const llamaId = (db.prepare("SELECT id FROM models WHERE platform = 'custom' AND model_id = 'llama3:8b'").get() as any).id;
const qwenId = (db.prepare("SELECT id FROM models WHERE platform = 'custom' AND model_id = 'qwen3:4b'").get() as any).id;
const llamaRoute = routeRequest(1000, undefined, llamaId);
expect(llamaRoute.modelId).toBe('llama3:8b');
expect((llamaRoute.provider as any).baseUrl).toBe('http://127.0.0.1:11434/v1');
const qwenRoute = routeRequest(1000, undefined, qwenId);
expect(qwenRoute.modelId).toBe('qwen3:4b');
expect((qwenRoute.provider as any).baseUrl).toBe('http://127.0.0.1:1234/v1');
});
it('re-submitting an existing endpoint updates it instead of adding a third', async () => {
const { status } = await post(app, '/api/keys/custom', { baseUrl: 'http://127.0.0.1:11434/v1', model: 'mistral:7b', label: 'Ollama box renamed' });
expect(status).toBe(201);
const db = getDb();
expect((db.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE platform = 'custom'").get() as any).n).toBe(2);
const key = db.prepare("SELECT label FROM api_keys WHERE platform = 'custom' AND base_url = 'http://127.0.0.1:11434/v1'").get() as any;
expect(key.label).toBe('Ollama box renamed');
});
it('deleting one endpoint removes only ITS models from catalog and chain', async () => {
const db = getDb();
const ollamaKey = db.prepare("SELECT id FROM api_keys WHERE platform = 'custom' AND base_url = 'http://127.0.0.1:11434/v1'").get() as any;
const { status } = await del(app, `/api/keys/${ollamaKey.id}`);
expect(status).toBe(200);
const remainingModels = (db.prepare("SELECT model_id FROM models WHERE platform = 'custom'").all() as any[]).map(r => r.model_id);
expect(remainingModels).toEqual(['qwen3:4b']); // llama3:8b + mistral:7b cascaded with their key
const keys = db.prepare("SELECT base_url FROM api_keys WHERE platform = 'custom'").all() as any[];
expect(keys.length).toBe(1);
expect(keys[0].base_url).toBe('http://127.0.0.1:1234/v1');
});
});
});