test(llm): add backend unit tests for LLM provider feature (113 tests)

Comprehensive test coverage for the entire LLM provider backend:
- secrets.test.ts: AES-256-GCM encrypt/decrypt, master key lifecycle (14)
- tool-converter.test.ts: Cross-provider tool format conversion (10)
- gateway.test.ts: Role routing, error handling, cache invalidation (12)
- provider-repo.test.ts: Provider CRUD, filtering, timestamps (18)
- model-role-repo.test.ts: Role assignments, FK constraints (15)
- secret-repo.test.ts: Encrypted storage, CASCADE delete (13)
- llm-config.test.ts: Full REST API integration tests (31)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
jeffusion
2026-03-05 00:32:53 +08:00
committed by 路遥知码力
parent bc7616df42
commit 3937c678f3
7 changed files with 1748 additions and 0 deletions

View File

@@ -0,0 +1,462 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
import { initMasterKey } from '../../crypto/secrets';
import { closeDatabase, initDatabase } from '../../db/database';
import { modelRoleRepo } from '../../db/repositories/model-role-repo';
import { providerRepo } from '../../db/repositories/provider-repo';
import { secretRepo } from '../../db/repositories/secret-repo';
import { llmConfigRouter } from '../llm-config';
/**
* Create a test Hono app with the LLM config router mounted.
*/
function createTestApp(): Hono {
const app = new Hono();
app.route('/llm', llmConfigRouter);
return app;
}
/**
* Helper to make JSON requests to the test app.
*/
async function jsonRequest(
app: Hono,
method: string,
path: string,
body?: unknown
): Promise<{ status: number; data: any }> {
const init: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
const res = await app.request(`http://localhost/llm${path}`, init);
// Handle non-JSON responses (e.g. 500 errors that return HTML)
const text = await res.text();
try {
const data = JSON.parse(text);
return { status: res.status, data };
} catch {
return { status: res.status, data: { _raw: text } };
}
}
describe('llm-config controller', () => {
let dbPath: string;
let keyPath: string;
let app: Hono;
const savedDbPath = process.env.DATABASE_PATH;
const savedKeyPath = process.env.MASTER_KEY_PATH;
beforeEach(() => {
const tmpDir = join(tmpdir(), `ctrl-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
keyPath = join(tmpDir, 'master.key');
process.env.DATABASE_PATH = dbPath;
process.env.MASTER_KEY_PATH = keyPath;
initMasterKey();
initDatabase();
app = createTestApp();
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (savedKeyPath === undefined) {
delete process.env.MASTER_KEY_PATH;
} else {
process.env.MASTER_KEY_PATH = savedKeyPath;
}
try {
if (existsSync(dbPath)) unlinkSync(dbPath);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
} catch {
/* ok */
}
try {
if (existsSync(keyPath)) unlinkSync(keyPath);
} catch {
/* ok */
}
});
// ─── Provider CRUD ────────────────────────────────────────────────
describe('GET /providers', () => {
test('returns empty array when no providers', async () => {
const { status, data } = await jsonRequest(app, 'GET', '/providers');
expect(status).toBe(200);
expect(data).toEqual([]);
});
test('returns all providers with formatted fields', async () => {
providerRepo.create({
name: 'Test',
type: 'openai_compatible',
baseUrl: 'https://api.example.com/v1',
defaultModel: 'gpt-4o-mini',
});
const { status, data } = await jsonRequest(app, 'GET', '/providers');
expect(status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0]).toHaveProperty('id');
expect(data[0].name).toBe('Test');
expect(data[0].isEnabled).toBe(true);
expect(data[0].hasKey).toBe(false);
});
});
describe('POST /providers', () => {
test('creates a provider successfully', async () => {
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
name: 'New Provider',
type: 'anthropic',
defaultModel: 'claude-3-5-sonnet-20241022',
});
expect(status).toBe(201);
expect(data.name).toBe('New Provider');
expect(data.type).toBe('anthropic');
expect(data.defaultModel).toBe('claude-3-5-sonnet-20241022');
expect(data.isEnabled).toBe(true);
});
test('creates provider with API key', async () => {
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
name: 'With Key',
type: 'openai_responses',
defaultModel: 'gpt-4o',
apiKey: 'sk-test-123',
});
expect(status).toBe(201);
expect(data.hasKey).toBe(true);
});
test('auto-binds all roles when first provider is created', async () => {
await jsonRequest(app, 'POST', '/providers', {
name: 'First Provider',
type: 'gemini',
defaultModel: 'gemini-pro',
apiKey: 'test-key',
});
const { data: roles } = await jsonRequest(app, 'GET', '/roles');
const assignedRoles = roles.filter((r: any) => r.providerId !== null);
expect(assignedRoles).toHaveLength(5); // All 5 roles bound
});
test('rejects missing required fields', async () => {
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
name: 'Missing Type',
});
expect(status).toBe(400);
expect(data.message).toContain('Missing required fields');
});
test('rejects invalid provider type', async () => {
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
name: 'Bad Type',
type: 'invalid_type',
defaultModel: 'model',
});
expect(status).toBe(400);
expect(data.message).toContain('Invalid type');
});
test('requires baseUrl for openai_compatible', async () => {
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
name: 'No URL',
type: 'openai_compatible',
defaultModel: 'model',
});
expect(status).toBe(400);
expect(data.message).toContain('baseUrl is required');
});
});
describe('GET /providers/:id', () => {
test('returns provider by ID', async () => {
const created = providerRepo.create({
name: 'FindMe',
type: 'gemini',
defaultModel: 'gemini-pro',
});
const { status, data } = await jsonRequest(app, 'GET', `/providers/${created.id}`);
expect(status).toBe(200);
expect(data.name).toBe('FindMe');
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'GET', '/providers/non-existent');
expect(status).toBe(404);
});
});
describe('PUT /providers/:id', () => {
test('updates provider fields', async () => {
const created = providerRepo.create({
name: 'Original',
type: 'openai_compatible',
baseUrl: 'https://api.example.com/v1',
defaultModel: 'gpt-4o-mini',
});
const { status, data } = await jsonRequest(app, 'PUT', `/providers/${created.id}`, {
name: 'Updated',
defaultModel: 'gpt-4o',
});
expect(status).toBe(200);
expect(data.name).toBe('Updated');
expect(data.defaultModel).toBe('gpt-4o');
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'PUT', '/providers/non-existent', {
name: 'x',
});
expect(status).toBe(404);
});
});
describe('DELETE /providers/:id', () => {
test('deletes provider without role assignments', async () => {
const created = providerRepo.create({
name: 'ToDelete',
type: 'anthropic',
defaultModel: 'claude-3',
});
const { status, data } = await jsonRequest(app, 'DELETE', `/providers/${created.id}`);
expect(status).toBe(200);
expect(data.success).toBe(true);
expect(data.removedRoleAssignments).toEqual([]);
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'DELETE', '/providers/non-existent');
expect(status).toBe(404);
});
});
// ─── API Key Management ──────────────────────────────────────────
describe('PUT /providers/:id/key', () => {
test('sets API key for provider', async () => {
const created = providerRepo.create({
name: 'NeedsKey',
type: 'anthropic',
defaultModel: 'claude-3',
});
const { status, data } = await jsonRequest(app, 'PUT', `/providers/${created.id}/key`, {
apiKey: 'sk-new-key',
});
expect(status).toBe(200);
expect(data.success).toBe(true);
// Verify key is stored
expect(secretRepo.has(created.id)).toBe(true);
expect(secretRepo.get(created.id)).toBe('sk-new-key');
});
test('returns 400 when apiKey is missing', async () => {
const created = providerRepo.create({
name: 'Test',
type: 'anthropic',
defaultModel: 'claude-3',
});
const { status, data } = await jsonRequest(app, 'PUT', `/providers/${created.id}/key`, {});
expect(status).toBe(400);
expect(data.message).toContain('apiKey is required');
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'PUT', '/providers/non-existent/key', {
apiKey: 'sk-test',
});
expect(status).toBe(404);
});
});
describe('DELETE /providers/:id/key', () => {
test('deletes API key', async () => {
const created = providerRepo.create({
name: 'HasKey',
type: 'anthropic',
defaultModel: 'claude-3',
});
secretRepo.set(created.id, 'sk-key');
const { status, data } = await jsonRequest(app, 'DELETE', `/providers/${created.id}/key`);
expect(status).toBe(200);
expect(data.success).toBe(true);
expect(secretRepo.has(created.id)).toBe(false);
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'DELETE', '/providers/non-existent/key');
expect(status).toBe(404);
});
});
// ─── Role Assignments ────────────────────────────────────────────
describe('GET /roles', () => {
test('returns all MODEL_ROLES with null assignments when unassigned', async () => {
const { status, data } = await jsonRequest(app, 'GET', '/roles');
expect(status).toBe(200);
expect(data).toHaveLength(5); // 5 roles
expect(data[0]).toHaveProperty('role');
expect(data[0]).toHaveProperty('providerId');
});
test('returns assigned role info when set', async () => {
const provider = providerRepo.create({
name: 'RoleTest',
type: 'openai_compatible',
baseUrl: 'https://api.example.com/v1',
defaultModel: 'gpt-4o-mini',
});
modelRoleRepo.set('legacy', provider.id, 'gpt-4o');
const { data } = await jsonRequest(app, 'GET', '/roles');
const legacy = data.find((r: any) => r.role === 'legacy');
expect(legacy.providerId).toBe(provider.id);
expect(legacy.providerName).toBe('RoleTest');
expect(legacy.model).toBe('gpt-4o');
});
});
describe('PUT /roles/:role', () => {
test('assigns a role to a provider+model', async () => {
const provider = providerRepo.create({
name: 'AssignTarget',
type: 'anthropic',
defaultModel: 'claude-3',
});
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
providerId: provider.id,
model: 'claude-3-5-sonnet',
});
expect(status).toBe(200);
expect(data.role).toBe('planner');
expect(data.providerId).toBe(provider.id);
expect(data.model).toBe('claude-3-5-sonnet');
});
test('rejects invalid role name', async () => {
const { status, data } = await jsonRequest(app, 'PUT', '/roles/invalid_role', {
providerId: 'some-id',
model: 'model',
});
expect(status).toBe(400);
expect(data.message).toContain('Invalid role');
});
test('rejects missing providerId or model', async () => {
const { status, data } = await jsonRequest(app, 'PUT', '/roles/legacy', {
providerId: 'some-id',
});
expect(status).toBe(400);
expect(data.message).toContain('providerId and model are required');
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'PUT', '/roles/legacy', {
providerId: 'non-existent',
model: 'model',
});
expect(status).toBe(404);
});
});
// ─── Connection Test ─────────────────────────────────────────────
describe('POST /providers/:id/test', () => {
test('returns error when no API key configured', async () => {
const provider = providerRepo.create({
name: 'NoKey',
type: 'anthropic',
defaultModel: 'claude-3',
});
const { status, data } = await jsonRequest(app, 'POST', `/providers/${provider.id}/test`);
expect(status).toBe(200);
expect(data.success).toBe(false);
expect(data.error).toContain('No API key');
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'POST', '/providers/non-existent/test');
expect(status).toBe(404);
});
});
// ─── System Settings ─────────────────────────────────────────────
describe('GET /settings', () => {
test('returns empty array when no settings', async () => {
const { status, data } = await jsonRequest(app, 'GET', '/settings');
expect(status).toBe(200);
expect(data).toEqual([]);
});
});
describe('PUT /settings', () => {
test('saves and retrieves settings', async () => {
const { status } = await jsonRequest(app, 'PUT', '/settings', [
{ key: 'theme', value: 'dark' },
{ key: 'lang', value: 'zh-CN' },
]);
expect(status).toBe(200);
const { data } = await jsonRequest(app, 'GET', '/settings');
expect(data).toHaveLength(2);
expect(data.find((s: any) => s.key === 'theme')?.value).toBe('dark');
});
test('rejects non-array body', async () => {
const { status, data } = await jsonRequest(app, 'PUT', '/settings', { key: 'x' });
expect(status).toBe(400);
expect(data.message).toContain('array');
});
});
});

View File

@@ -0,0 +1,224 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
/**
* Import a fresh secrets module to bypass module cache (same pattern as config-manager.test.ts).
* Each import gets its own masterKey singleton.
*/
async function importFresh() {
return await import(`../../crypto/secrets.ts?t=${Date.now()}-${randomUUID()}`);
}
describe('secrets — AES-256-GCM encryption', () => {
let tmpDir: string;
let keyPath: string;
const savedMasterKeyPath = process.env.MASTER_KEY_PATH;
beforeEach(() => {
tmpDir = join(tmpdir(), `secrets-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
keyPath = join(tmpDir, 'master.key');
process.env.MASTER_KEY_PATH = keyPath;
});
afterEach(() => {
if (savedMasterKeyPath === undefined) {
delete process.env.MASTER_KEY_PATH;
} else {
process.env.MASTER_KEY_PATH = savedMasterKeyPath;
}
// Cleanup temp files
try {
if (existsSync(keyPath)) unlinkSync(keyPath);
} catch {
/* ok */
}
});
// ─── Master Key Init ─────────────────────────────────────────────────
describe('initMasterKey()', () => {
test('generates a new 32-byte key file when none exists', async () => {
const secrets = await importFresh();
expect(existsSync(keyPath)).toBe(false);
secrets.initMasterKey();
expect(existsSync(keyPath)).toBe(true);
const raw = readFileSync(keyPath);
expect(raw.length).toBe(32);
expect(secrets.isMasterKeyReady()).toBe(true);
});
test('loads an existing key file without overwriting', async () => {
// Pre-create a 32-byte key
const existingKey = Buffer.from(crypto.getRandomValues(new Uint8Array(32)));
writeFileSync(keyPath, existingKey, { mode: 0o600 });
const secrets = await importFresh();
secrets.initMasterKey();
const loaded = readFileSync(keyPath);
expect(Buffer.compare(loaded, existingKey)).toBe(0);
expect(secrets.isMasterKeyReady()).toBe(true);
});
test('throws if key file is wrong length', async () => {
writeFileSync(keyPath, Buffer.alloc(16)); // Wrong length
const secrets = await importFresh();
expect(() => secrets.initMasterKey()).toThrow('16 bytes, expected 32');
});
test('creates parent directories if needed', async () => {
const nestedPath = join(tmpDir, 'nested', 'deep', 'master.key');
process.env.MASTER_KEY_PATH = nestedPath;
const secrets = await importFresh();
secrets.initMasterKey();
expect(existsSync(nestedPath)).toBe(true);
expect(readFileSync(nestedPath).length).toBe(32);
});
});
// ─── isMasterKeyReady ────────────────────────────────────────────────
describe('isMasterKeyReady()', () => {
test('returns false before initMasterKey', async () => {
const secrets = await importFresh();
expect(secrets.isMasterKeyReady()).toBe(false);
});
test('returns true after initMasterKey', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
expect(secrets.isMasterKeyReady()).toBe(true);
});
});
// ─── Encrypt / Decrypt ──────────────────────────────────────────────
describe('encrypt() / decrypt()', () => {
test('throws when master key not initialized', async () => {
const secrets = await importFresh();
expect(() => secrets.encrypt('test')).toThrow('Master key not initialized');
});
test('roundtrip: encrypt then decrypt recovers plaintext', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const plaintext = 'sk-abc123-super-secret-api-key';
const encrypted = secrets.encrypt(plaintext);
const decrypted = secrets.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
test('encrypts empty string', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const encrypted = secrets.encrypt('');
expect(secrets.decrypt(encrypted)).toBe('');
});
test('encrypts unicode content', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const unicode = '密钥🔑テスト키';
const encrypted = secrets.encrypt(unicode);
expect(secrets.decrypt(encrypted)).toBe(unicode);
});
test('encrypts long content', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const long = 'A'.repeat(10000);
const encrypted = secrets.encrypt(long);
expect(secrets.decrypt(encrypted)).toBe(long);
});
test('each encryption produces unique IV (different ciphertext)', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const plaintext = 'same-content';
const enc1 = secrets.encrypt(plaintext);
const enc2 = secrets.encrypt(plaintext);
// IVs should differ
expect(Buffer.compare(enc1.iv, enc2.iv)).not.toBe(0);
// Both decrypt to same plaintext
expect(secrets.decrypt(enc1)).toBe(plaintext);
expect(secrets.decrypt(enc2)).toBe(plaintext);
});
test('encrypted payload has expected structure', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const encrypted = secrets.encrypt('test');
expect(encrypted).toHaveProperty('ciphertext');
expect(encrypted).toHaveProperty('iv');
expect(encrypted).toHaveProperty('authTag');
expect(Buffer.isBuffer(encrypted.ciphertext)).toBe(true);
expect(Buffer.isBuffer(encrypted.iv)).toBe(true);
expect(Buffer.isBuffer(encrypted.authTag)).toBe(true);
expect(encrypted.iv.length).toBe(12); // GCM nonce
expect(encrypted.authTag.length).toBe(16); // GCM tag
});
test('tampered ciphertext fails decryption', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const encrypted = secrets.encrypt('secret');
// Tamper with ciphertext
encrypted.ciphertext[0] ^= 0xff;
expect(() => secrets.decrypt(encrypted)).toThrow();
});
test('tampered auth tag fails decryption', async () => {
const secrets = await importFresh();
secrets.initMasterKey();
const encrypted = secrets.encrypt('secret');
encrypted.authTag[0] ^= 0xff;
expect(() => secrets.decrypt(encrypted)).toThrow();
});
test('wrong master key fails decryption', async () => {
const secrets1 = await importFresh();
secrets1.initMasterKey();
const encrypted = secrets1.encrypt('secret');
// Create a different key
unlinkSync(keyPath);
const secrets2 = await importFresh();
secrets2.initMasterKey(); // Generates a new key
expect(() => secrets2.decrypt(encrypted)).toThrow();
});
});
});

View File

@@ -0,0 +1,213 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { closeDatabase, initDatabase } from '../database';
import { modelRoleRepo } from '../repositories/model-role-repo';
import type { ModelRole } from '../repositories/model-role-repo';
import { providerRepo } from '../repositories/provider-repo';
import type { CreateProviderInput } from '../repositories/provider-repo';
describe('model-role-repo', () => {
let dbPath: string;
let providerId: string;
const savedDbPath = process.env.DATABASE_PATH;
const providerInput: CreateProviderInput = {
name: 'Test Provider',
type: 'openai_compatible',
baseUrl: 'https://api.example.com/v1',
defaultModel: 'gpt-4o-mini',
};
beforeEach(() => {
const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
initDatabase();
const created = providerRepo.create(providerInput);
providerId = created.id;
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = savedDbPath;
}
try {
if (existsSync(dbPath)) unlinkSync(dbPath);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
} catch {
/* ok */
}
});
// ─── Set (upsert) ─────────────────────────────────────────────────
describe('set()', () => {
test('creates a new role assignment', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
const assignment = modelRoleRepo.getByRole('legacy');
expect(assignment).not.toBeNull();
expect(assignment!.role).toBe('legacy');
expect(assignment!.provider_id).toBe(providerId);
expect(assignment!.model).toBe('gpt-4o-mini');
});
test('upserts: updates existing role assignment', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('legacy', providerId, 'gpt-4o');
const assignment = modelRoleRepo.getByRole('legacy');
expect(assignment!.model).toBe('gpt-4o');
});
test('can assign different roles', () => {
const roles: ModelRole[] = ['legacy', 'planner', 'specialist', 'judge', 'embedding'];
for (const role of roles) {
modelRoleRepo.set(role, providerId, `model-for-${role}`);
}
for (const role of roles) {
const a = modelRoleRepo.getByRole(role);
expect(a!.model).toBe(`model-for-${role}`);
}
});
});
// ─── GetByRole ────────────────────────────────────────────────────
describe('getByRole()', () => {
test('returns null when no assignment exists', () => {
expect(modelRoleRepo.getByRole('legacy')).toBeNull();
});
test('returns the correct assignment', () => {
modelRoleRepo.set('planner', providerId, 'gpt-4o');
const a = modelRoleRepo.getByRole('planner');
expect(a!.provider_id).toBe(providerId);
expect(a!.model).toBe('gpt-4o');
});
});
// ─── List ─────────────────────────────────────────────────────────
describe('list()', () => {
test('returns empty array when no assignments exist', () => {
expect(modelRoleRepo.list()).toEqual([]);
});
test('returns all assignments with provider info (JOIN)', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o');
const all = modelRoleRepo.list();
expect(all).toHaveLength(2);
expect(all[0].provider_name).toBe('Test Provider');
expect(all[0].provider_type).toBe('openai_compatible');
});
test('results are ordered by role', () => {
modelRoleRepo.set('specialist', providerId, 'model-a');
modelRoleRepo.set('embedding', providerId, 'model-b');
modelRoleRepo.set('legacy', providerId, 'model-c');
const all = modelRoleRepo.list();
const roles = all.map((a) => a.role);
expect(roles).toEqual([...roles].sort());
});
});
// ─── Delete ───────────────────────────────────────────────────────
describe('delete()', () => {
test('deletes existing assignment, returns true', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
expect(modelRoleRepo.delete('legacy')).toBe(true);
expect(modelRoleRepo.getByRole('legacy')).toBeNull();
});
test('returns false for non-existent role', () => {
expect(modelRoleRepo.delete('legacy')).toBe(false);
});
});
// ─── GetRolesByProvider ───────────────────────────────────────────
describe('getRolesByProvider()', () => {
test('returns empty array when no roles assigned', () => {
expect(modelRoleRepo.getRolesByProvider(providerId)).toEqual([]);
});
test('returns all roles assigned to a provider', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o');
modelRoleRepo.set('judge', providerId, 'gpt-4o');
const roles = modelRoleRepo.getRolesByProvider(providerId);
expect(roles).toHaveLength(3);
expect(roles).toContain('legacy');
expect(roles).toContain('planner');
expect(roles).toContain('judge');
});
test('does not return roles assigned to other providers', () => {
const p2 = providerRepo.create({
...providerInput,
name: 'Other Provider',
type: 'anthropic',
});
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', p2.id, 'claude-3-5-sonnet');
const roles1 = modelRoleRepo.getRolesByProvider(providerId);
expect(roles1).toEqual(['legacy']);
const roles2 = modelRoleRepo.getRolesByProvider(p2.id);
expect(roles2).toEqual(['planner']);
});
});
// ─── CASCADE on provider delete ───────────────────────────────────
describe('foreign key constraint', () => {
test('cannot delete provider while role assignments exist (no CASCADE)', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o');
// FK constraint prevents delete — must remove assignments first
expect(() => providerRepo.delete(providerId)).toThrow();
// Clean up assignments first, then delete succeeds
modelRoleRepo.delete('legacy');
modelRoleRepo.delete('planner');
expect(providerRepo.delete(providerId)).toBe(true);
});
});
});

View File

@@ -0,0 +1,233 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { closeDatabase, initDatabase } from '../database';
import { providerRepo } from '../repositories/provider-repo';
import type { CreateProviderInput } from '../repositories/provider-repo';
describe('provider-repo', () => {
let dbPath: string;
const savedDbPath = process.env.DATABASE_PATH;
beforeEach(() => {
const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
initDatabase();
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = savedDbPath;
}
try {
if (existsSync(dbPath)) unlinkSync(dbPath);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
} catch {
/* ok */
}
});
const sampleInput: CreateProviderInput = {
name: 'Test OpenAI',
type: 'openai_compatible',
baseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o-mini',
extraConfig: { org: 'test-org' },
};
// ─── Create ────────────────────────────────────────────────────────
describe('create()', () => {
test('creates a provider and returns it with auto-generated ID', () => {
const created = providerRepo.create(sampleInput);
expect(created.id).toBeTruthy();
expect(typeof created.id).toBe('string');
expect(created.name).toBe('Test OpenAI');
expect(created.type).toBe('openai_compatible');
expect(created.base_url).toBe('https://api.openai.com/v1');
expect(created.default_model).toBe('gpt-4o-mini');
expect(created.is_enabled).toBe(1);
expect(JSON.parse(created.extra_config)).toEqual({ org: 'test-org' });
expect(created.created_at).toBeTruthy();
expect(created.updated_at).toBeTruthy();
});
test('creates provider with null baseUrl', () => {
const input: CreateProviderInput = {
name: 'Anthropic',
type: 'anthropic',
defaultModel: 'claude-3-5-sonnet-20241022',
};
const created = providerRepo.create(input);
expect(created.base_url).toBeNull();
});
test('creates provider with default empty extraConfig', () => {
const input: CreateProviderInput = {
name: 'Gemini',
type: 'gemini',
defaultModel: 'gemini-pro',
};
const created = providerRepo.create(input);
expect(JSON.parse(created.extra_config)).toEqual({});
});
test('each create generates unique IDs', () => {
const p1 = providerRepo.create({ ...sampleInput, name: 'Provider 1' });
const p2 = providerRepo.create({ ...sampleInput, name: 'Provider 2' });
expect(p1.id).not.toBe(p2.id);
});
});
// ─── List ──────────────────────────────────────────────────────────
describe('list()', () => {
test('returns empty array when no providers exist', () => {
expect(providerRepo.list()).toEqual([]);
});
test('returns all providers ordered by created_at', () => {
providerRepo.create({ ...sampleInput, name: 'First' });
providerRepo.create({ ...sampleInput, name: 'Second' });
providerRepo.create({ ...sampleInput, name: 'Third' });
const all = providerRepo.list();
expect(all).toHaveLength(3);
expect(all[0].name).toBe('First');
expect(all[2].name).toBe('Third');
});
test('enabledOnly=true filters disabled providers', () => {
providerRepo.create({ ...sampleInput, name: 'Enabled' });
const p2 = providerRepo.create({ ...sampleInput, name: 'Disabled' });
providerRepo.update(p2.id, { isEnabled: false });
const enabled = providerRepo.list(true);
expect(enabled).toHaveLength(1);
expect(enabled[0].name).toBe('Enabled');
});
});
// ─── GetById ───────────────────────────────────────────────────────
describe('getById()', () => {
test('returns provider by ID', () => {
const created = providerRepo.create(sampleInput);
const fetched = providerRepo.getById(created.id);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id);
expect(fetched!.name).toBe(created.name);
});
test('returns null for non-existent ID', () => {
expect(providerRepo.getById('non-existent')).toBeNull();
});
});
// ─── Update ────────────────────────────────────────────────────────
describe('update()', () => {
test('updates name only', () => {
const created = providerRepo.create(sampleInput);
const updated = providerRepo.update(created.id, { name: 'Updated Name' });
expect(updated!.name).toBe('Updated Name');
expect(updated!.type).toBe(created.type);
expect(updated!.default_model).toBe(created.default_model);
});
test('updates multiple fields at once', () => {
const created = providerRepo.create(sampleInput);
const updated = providerRepo.update(created.id, {
name: 'New Name',
defaultModel: 'gpt-4o',
isEnabled: false,
baseUrl: 'https://new-url.com',
extraConfig: { newKey: 'newVal' },
});
expect(updated!.name).toBe('New Name');
expect(updated!.default_model).toBe('gpt-4o');
expect(updated!.is_enabled).toBe(0);
expect(updated!.base_url).toBe('https://new-url.com');
expect(JSON.parse(updated!.extra_config)).toEqual({ newKey: 'newVal' });
});
test('returns null for non-existent ID', () => {
expect(providerRepo.update('non-existent', { name: 'x' })).toBeNull();
});
test('returns existing row when no fields provided', () => {
const created = providerRepo.create(sampleInput);
const updated = providerRepo.update(created.id, {});
expect(updated!.name).toBe(created.name);
});
test('updates updated_at timestamp', () => {
const created = providerRepo.create(sampleInput);
const updated = providerRepo.update(created.id, { name: 'Changed' });
expect(updated!.updated_at).toBeTruthy();
});
});
// ─── Delete ────────────────────────────────────────────────────────
describe('delete()', () => {
test('deletes existing provider, returns true', () => {
const created = providerRepo.create(sampleInput);
expect(providerRepo.delete(created.id)).toBe(true);
expect(providerRepo.getById(created.id)).toBeNull();
});
test('returns false for non-existent ID', () => {
expect(providerRepo.delete('non-existent')).toBe(false);
});
test('deleting provider does not affect other providers', () => {
const p1 = providerRepo.create({ ...sampleInput, name: 'Keep' });
const p2 = providerRepo.create({ ...sampleInput, name: 'Delete' });
providerRepo.delete(p2.id);
expect(providerRepo.list()).toHaveLength(1);
expect(providerRepo.list()[0].name).toBe('Keep');
});
});
// ─── hasKey ────────────────────────────────────────────────────────
describe('hasKey()', () => {
test('returns false when no secret exists', () => {
const created = providerRepo.create(sampleInput);
expect(providerRepo.hasKey(created.id)).toBe(false);
});
});
});

View File

@@ -0,0 +1,193 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initMasterKey } from '../../crypto/secrets';
import { closeDatabase, initDatabase } from '../database';
import { providerRepo } from '../repositories/provider-repo';
import type { CreateProviderInput } from '../repositories/provider-repo';
import { secretRepo } from '../repositories/secret-repo';
describe('secret-repo', () => {
let dbPath: string;
let keyPath: string;
let providerId: string;
const savedDbPath = process.env.DATABASE_PATH;
const savedKeyPath = process.env.MASTER_KEY_PATH;
const providerInput: CreateProviderInput = {
name: 'Test Provider',
type: 'openai_compatible',
baseUrl: 'https://api.example.com/v1',
defaultModel: 'gpt-4o-mini',
};
beforeEach(() => {
const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
keyPath = join(tmpDir, 'master.key');
process.env.DATABASE_PATH = dbPath;
process.env.MASTER_KEY_PATH = keyPath;
initMasterKey();
initDatabase();
const created = providerRepo.create(providerInput);
providerId = created.id;
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (savedKeyPath === undefined) {
delete process.env.MASTER_KEY_PATH;
} else {
process.env.MASTER_KEY_PATH = savedKeyPath;
}
try {
if (existsSync(dbPath)) unlinkSync(dbPath);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
} catch {
/* ok */
}
try {
if (existsSync(keyPath)) unlinkSync(keyPath);
} catch {
/* ok */
}
});
// ─── Set ──────────────────────────────────────────────────────────
describe('set()', () => {
test('stores an encrypted API key', () => {
secretRepo.set(providerId, 'sk-test-key-123');
expect(secretRepo.has(providerId)).toBe(true);
});
test('upserts: overwrites existing key', () => {
secretRepo.set(providerId, 'old-key');
secretRepo.set(providerId, 'new-key');
const retrieved = secretRepo.get(providerId);
expect(retrieved).toBe('new-key');
});
});
// ─── Get ──────────────────────────────────────────────────────────
describe('get()', () => {
test('returns null when no key exists', () => {
expect(secretRepo.get(providerId)).toBeNull();
});
test('retrieves and decrypts the stored key', () => {
const apiKey = 'sk-super-secret-api-key-abc123';
secretRepo.set(providerId, apiKey);
const retrieved = secretRepo.get(providerId);
expect(retrieved).toBe(apiKey);
});
test('handles unicode API keys', () => {
const apiKey = 'key-with-特殊字符-🔑';
secretRepo.set(providerId, apiKey);
expect(secretRepo.get(providerId)).toBe(apiKey);
});
test('returns null for non-existent provider', () => {
expect(secretRepo.get('non-existent')).toBeNull();
});
});
// ─── Has ──────────────────────────────────────────────────────────
describe('has()', () => {
test('returns false when no key stored', () => {
expect(secretRepo.has(providerId)).toBe(false);
});
test('returns true when key is stored', () => {
secretRepo.set(providerId, 'sk-key');
expect(secretRepo.has(providerId)).toBe(true);
});
test('returns false for non-existent provider', () => {
expect(secretRepo.has('non-existent')).toBe(false);
});
});
// ─── Delete ───────────────────────────────────────────────────────
describe('delete()', () => {
test('deletes existing key, returns true', () => {
secretRepo.set(providerId, 'sk-key');
expect(secretRepo.delete(providerId)).toBe(true);
expect(secretRepo.has(providerId)).toBe(false);
expect(secretRepo.get(providerId)).toBeNull();
});
test('returns false when no key exists', () => {
expect(secretRepo.delete(providerId)).toBe(false);
});
});
// ─── CASCADE on provider delete ───────────────────────────────────
describe('CASCADE behavior', () => {
test('deleting provider removes its secret', () => {
secretRepo.set(providerId, 'sk-will-be-deleted');
expect(secretRepo.has(providerId)).toBe(true);
providerRepo.delete(providerId);
expect(secretRepo.has(providerId)).toBe(false);
});
});
// ─── Multiple providers ──────────────────────────────────────────
describe('multiple providers', () => {
test('each provider has independent key storage', () => {
const p2 = providerRepo.create({
...providerInput,
name: 'Second Provider',
type: 'anthropic',
});
secretRepo.set(providerId, 'key-1');
secretRepo.set(p2.id, 'key-2');
expect(secretRepo.get(providerId)).toBe('key-1');
expect(secretRepo.get(p2.id)).toBe('key-2');
secretRepo.delete(providerId);
expect(secretRepo.get(p2.id)).toBe('key-2');
});
});
});

View File

@@ -0,0 +1,245 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initMasterKey } from '../../crypto/secrets';
import { closeDatabase, initDatabase } from '../../db/database';
import { modelRoleRepo } from '../../db/repositories/model-role-repo';
import { providerRepo } from '../../db/repositories/provider-repo';
import type { CreateProviderInput } from '../../db/repositories/provider-repo';
import { secretRepo } from '../../db/repositories/secret-repo';
import { LLMGateway } from '../gateway';
describe('LLMGateway', () => {
let dbPath: string;
let keyPath: string;
let gateway: LLMGateway;
let providerId: string;
const savedDbPath = process.env.DATABASE_PATH;
const savedKeyPath = process.env.MASTER_KEY_PATH;
const providerInput: CreateProviderInput = {
name: 'Test OpenAI',
type: 'openai_compatible',
baseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o-mini',
};
beforeEach(() => {
const tmpDir = join(tmpdir(), `gw-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
keyPath = join(tmpDir, 'master.key');
process.env.DATABASE_PATH = dbPath;
process.env.MASTER_KEY_PATH = keyPath;
initMasterKey();
initDatabase();
// Create provider with key
const created = providerRepo.create(providerInput);
providerId = created.id;
secretRepo.set(providerId, 'sk-test-key');
gateway = new LLMGateway();
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (savedKeyPath === undefined) {
delete process.env.MASTER_KEY_PATH;
} else {
process.env.MASTER_KEY_PATH = savedKeyPath;
}
try {
if (existsSync(dbPath)) unlinkSync(dbPath);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
} catch {
/* ok */
}
try {
if (existsSync(keyPath)) unlinkSync(keyPath);
} catch {
/* ok */
}
});
// ─── chatForRole: Error Cases ──────────────────────────────────────
describe('chatForRole() — error handling', () => {
test('throws LLMNoProviderError when role is not assigned', async () => {
try {
await gateway.chatForRole('legacy', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false); // Should not reach
} catch (e: any) {
expect(e.name).toBe('LLMNoProviderError');
expect(e.role).toBe('legacy');
}
});
test('throws LLMError when provider is disabled', async () => {
providerRepo.update(providerId, { isEnabled: false });
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
try {
await gateway.chatForRole('legacy', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe('LLMError');
expect(e.message).toContain('disabled');
}
});
test('throws LLMAuthError when no API key configured', async () => {
secretRepo.delete(providerId);
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
try {
await gateway.chatForRole('legacy', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe('LLMAuthError');
expect(e.message).toContain('No API key');
}
});
test('throws LLMError when provider not found after role assignment manually deleted', async () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
// Must remove assignments before deleting provider (no CASCADE on model_role_assignments)
modelRoleRepo.delete('legacy');
secretRepo.delete(providerId);
providerRepo.delete(providerId);
// Re-create assignment pointing to non-existent provider
// (simulating stale data)
try {
// No assignment exists now, so this throws LLMNoProviderError
await gateway.chatForRole('legacy', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe('LLMNoProviderError');
}
});
});
// ─── chatDirect: Error Cases ──────────────────────────────────────
describe('chatDirect() — error handling', () => {
test('throws LLMError for non-existent provider ID', async () => {
try {
await gateway.chatDirect('non-existent', {
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe('LLMError');
expect(e.message).toContain('not found');
}
});
});
// ─── embedForRole: Error Cases ────────────────────────────────────
describe('embedForRole() — error handling', () => {
test('throws LLMNoProviderError when embedding role not assigned', async () => {
try {
await gateway.embedForRole(['text']);
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe('LLMNoProviderError');
expect(e.role).toBe('embedding');
}
});
});
// ─── Cache invalidation ──────────────────────────────────────────
describe('cache management', () => {
test('invalidateProvider is callable without error', () => {
gateway.invalidateProvider(providerId);
// No-op if not cached — should not throw
});
test('invalidateAll is callable without error', () => {
gateway.invalidateAll();
});
});
// ─── Provider creation/routing (integration) ─────────────────────
describe('getProviderInstance()', () => {
test('creates provider instance for valid config', () => {
const instance = gateway.getProviderInstance(providerId);
expect(instance).toBeTruthy();
expect(instance.type).toBe('openai_compatible');
});
test('caches provider instance on repeated access', () => {
const inst1 = gateway.getProviderInstance(providerId);
const inst2 = gateway.getProviderInstance(providerId);
expect(inst1).toBe(inst2); // Same reference
});
test('returns fresh instance after invalidation', () => {
const inst1 = gateway.getProviderInstance(providerId);
gateway.invalidateProvider(providerId);
const inst2 = gateway.getProviderInstance(providerId);
expect(inst1).not.toBe(inst2); // Different reference
});
test('creates provider for all supported types', () => {
const types = [
{ type: 'openai_responses' as const, baseUrl: undefined },
{ type: 'anthropic' as const, baseUrl: undefined },
{ type: 'gemini' as const, baseUrl: undefined },
];
for (const { type, baseUrl } of types) {
const p = providerRepo.create({
name: `Test ${type}`,
type,
baseUrl,
defaultModel: 'test-model',
});
secretRepo.set(p.id, 'sk-test');
const instance = gateway.getProviderInstance(p.id);
expect(instance.type).toBe(type);
}
});
});
});

View File

@@ -0,0 +1,178 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { describe, expect, test } from 'bun:test';
import { toAnthropicTools, toGeminiTools, toOpenAITools } from '../tool-converter';
import type { LLMToolDefinition } from '../types';
const SAMPLE_TOOLS: LLMToolDefinition[] = [
{
name: 'get_weather',
description: 'Get current weather for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string', description: 'City name' },
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] },
},
required: ['location'],
},
},
{
name: 'search_code',
description: 'Search codebase for a pattern',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
path: { type: 'string' },
},
required: ['query'],
},
},
];
describe('tool-converter', () => {
// ─── toOpenAITools ──────────────────────────────────────────────────
describe('toOpenAITools()', () => {
test('wraps each tool in { type: "function", function: {...} }', () => {
const result = toOpenAITools(SAMPLE_TOOLS);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'get_weather',
description: 'Get current weather for a location',
parameters: SAMPLE_TOOLS[0].parameters,
},
});
expect(result[1]).toEqual({
type: 'function',
function: {
name: 'search_code',
description: 'Search codebase for a pattern',
parameters: SAMPLE_TOOLS[1].parameters,
},
});
});
test('returns empty array for empty input', () => {
expect(toOpenAITools([])).toEqual([]);
});
test('preserves nested parameter schema', () => {
const tools: LLMToolDefinition[] = [
{
name: 'complex',
description: 'Complex params',
parameters: {
type: 'object',
properties: {
nested: {
type: 'object',
properties: {
deep: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
];
const result = toOpenAITools(tools) as any;
expect(result[0].function.parameters.properties.nested.properties.deep.type).toBe('array');
});
});
// ─── toAnthropicTools ───────────────────────────────────────────────
describe('toAnthropicTools()', () => {
test('maps to { name, description, input_schema }', () => {
const result = toAnthropicTools(SAMPLE_TOOLS);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
name: 'get_weather',
description: 'Get current weather for a location',
input_schema: SAMPLE_TOOLS[0].parameters,
});
expect(result[1]).toEqual({
name: 'search_code',
description: 'Search codebase for a pattern',
input_schema: SAMPLE_TOOLS[1].parameters,
});
});
test('returns empty array for empty input', () => {
expect(toAnthropicTools([])).toEqual([]);
});
test('uses input_schema (not parameters)', () => {
const result = toAnthropicTools(SAMPLE_TOOLS) as any;
expect(result[0]).toHaveProperty('input_schema');
expect(result[0]).not.toHaveProperty('parameters');
});
});
// ─── toGeminiTools ──────────────────────────────────────────────────
describe('toGeminiTools()', () => {
test('wraps all tools in a single functionDeclarations array', () => {
const result = toGeminiTools(SAMPLE_TOOLS);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty('functionDeclarations');
const decls = (result[0] as any).functionDeclarations;
expect(decls).toHaveLength(2);
expect(decls[0]).toEqual({
name: 'get_weather',
description: 'Get current weather for a location',
parameters: SAMPLE_TOOLS[0].parameters,
});
expect(decls[1]).toEqual({
name: 'search_code',
description: 'Search codebase for a pattern',
parameters: SAMPLE_TOOLS[1].parameters,
});
});
test('returns single-element array even for one tool', () => {
const result = toGeminiTools([SAMPLE_TOOLS[0]]);
expect(result).toHaveLength(1);
expect((result[0] as any).functionDeclarations).toHaveLength(1);
});
test('returns single-element with empty declarations for empty input', () => {
const result = toGeminiTools([]);
expect(result).toHaveLength(1);
expect((result[0] as any).functionDeclarations).toHaveLength(0);
});
});
// ─── Cross-format comparison ────────────────────────────────────────
describe('cross-format consistency', () => {
test('all formats preserve tool name and description', () => {
const openai = toOpenAITools(SAMPLE_TOOLS) as any;
const anthropic = toAnthropicTools(SAMPLE_TOOLS) as any;
const gemini = (toGeminiTools(SAMPLE_TOOLS)[0] as any).functionDeclarations;
for (let i = 0; i < SAMPLE_TOOLS.length; i++) {
expect(openai[i].function.name).toBe(SAMPLE_TOOLS[i].name);
expect(anthropic[i].name).toBe(SAMPLE_TOOLS[i].name);
expect(gemini[i].name).toBe(SAMPLE_TOOLS[i].name);
expect(openai[i].function.description).toBe(SAMPLE_TOOLS[i].description);
expect(anthropic[i].description).toBe(SAMPLE_TOOLS[i].description);
expect(gemini[i].description).toBe(SAMPLE_TOOLS[i].description);
}
});
});
});