Files
mcpctl/src/mcpd/tests/llm-routes.test.ts

176 lines
5.4 KiB
TypeScript
Raw Normal View History

feat(mcpd): Llm resource — CRUD + CLI + apply Why: every client that wants an LLM (the agent, HTTP-mode mcplocal, Claude Code's STDIO mcplocal) today has to know the provider URL + key, and each user's ~/.mcpctl/config.json carries them. Centralising the catalogue on the server is the prerequisite for Phase 2 (mcpd proxies inference so credentials never leave the cluster). This phase adds the `Llm` resource and its CRUD surface — no proxy yet, no client pivot yet. Just enough to register what you have. Schema: - New `Llm` model: name/type/model/url/tier/description + {apiKeySecretId, apiKeySecretKey} FK pair. Reverse `llms` relation on Secret. - Provider types: anthropic | openai | deepseek | vllm | ollama | gemini-cli. - Tiers: fast | heavy. mcpd: - LlmRepository + LlmService + Zod validation schema + /api/v1/llms routes. - API surface exposes `apiKeyRef: {name, key}` — the service translates to/ from the FK pair so clients never deal in cuids. - `resolveApiKey(llmName)` reads through SecretService (which itself dispatches to the right SecretBackend). That's the hook Phase 2's inference proxy uses. - RBAC: added `'llms'` to RBAC_RESOURCES + resource alias. Standard view/create/edit/delete semantics. - Wired into main.ts (repo, service, routes). CLI: - `mcpctl create llm <name> --type X --model Y --tier fast|heavy --api-key-ref SECRET/KEY [--url ...] [--extra k=v ...]` - `mcpctl get|describe|delete llm` — standard resource verbs. - `mcpctl apply -f` with `kind: llm` (single- or multi-doc yaml/json). Applied after secrets, before servers — apiKeyRef resolves an existing Secret. - Shell completions regenerated. Tests: 11 service unit tests + 9 route tests (happy path, 404s, 409, validation). Full suite 1812/1812 (+20 from the 1792 Phase 0 baseline). TypeScript clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:28:43 +01:00
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerLlmRoutes } from '../src/routes/llms.js';
import { LlmService } from '../src/services/llm.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { ILlmRepository } from '../src/repositories/llm.repository.js';
import type { Llm, Secret } from '@prisma/client';
let app: FastifyInstance;
function makeLlm(overrides: Partial<Llm> = {}): Llm {
return {
id: 'llm-1',
name: 'claude',
type: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
url: '',
tier: 'heavy',
description: '',
apiKeySecretId: null,
apiKeySecretKey: null,
extraConfig: {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockRepo(initial: Llm[] = []): ILlmRepository {
const rows = new Map(initial.map((r) => [r.id, r]));
return {
findAll: vi.fn(async () => [...rows.values()]),
findById: vi.fn(async (id: string) => rows.get(id) ?? null),
findByName: vi.fn(async (name: string) => {
for (const r of rows.values()) if (r.name === name) return r;
return null;
}),
findByTier: vi.fn(async () => []),
create: vi.fn(async (data) => {
const row = makeLlm({ id: 'new-id', name: data.name, type: data.type, model: data.model });
rows.set(row.id, row);
return row;
}),
update: vi.fn(async (id, data) => {
const existing = rows.get(id)!;
const next: Llm = {
...existing,
...(data.model !== undefined ? { model: data.model } : {}),
};
rows.set(id, next);
return next;
}),
delete: vi.fn(async (id) => { rows.delete(id); }),
};
}
function mockSecretService() {
const sec: Secret = {
id: 'sec-1', name: 'anthropic-key', backendId: 'b', data: {}, externalRef: '',
version: 1, createdAt: new Date(), updatedAt: new Date(),
};
return {
getById: vi.fn(async (id: string) => {
if (id === sec.id) return sec;
throw new Error('not found');
}),
getByName: vi.fn(async (name: string) => {
if (name === sec.name) return sec;
throw new Error('not found');
}),
resolveData: vi.fn(async () => ({ token: 'sk-ant-xyz' })),
};
}
afterEach(async () => {
if (app) await app.close();
});
async function createApp(repo: ILlmRepository): Promise<FastifyInstance> {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const service = new LlmService(repo, mockSecretService() as any);
registerLlmRoutes(app, service);
await app.ready();
return app;
}
describe('Llm Routes', () => {
it('GET /api/v1/llms returns a list', async () => {
await createApp(mockRepo([makeLlm()]));
const res = await app.inject({ method: 'GET', url: '/api/v1/llms' });
expect(res.statusCode).toBe(200);
const body = res.json<Array<{ name: string }>>();
expect(body).toHaveLength(1);
expect(body[0]!.name).toBe('claude');
});
it('GET /api/v1/llms/:id returns 404 when missing', async () => {
await createApp(mockRepo());
const res = await app.inject({ method: 'GET', url: '/api/v1/llms/missing' });
expect(res.statusCode).toBe(404);
});
it('POST /api/v1/llms creates and returns 201', async () => {
await createApp(mockRepo());
const res = await app.inject({
method: 'POST',
url: '/api/v1/llms',
payload: {
name: 'ollama-local',
type: 'ollama',
model: 'llama3',
url: 'http://localhost:11434',
},
});
expect(res.statusCode).toBe(201);
expect(res.json<{ name: string }>().name).toBe('ollama-local');
});
it('POST /api/v1/llms rejects bad input with 400', async () => {
await createApp(mockRepo());
const res = await app.inject({
method: 'POST',
url: '/api/v1/llms',
payload: { name: '', type: 'anthropic', model: 'x' },
});
expect(res.statusCode).toBe(400);
});
it('POST /api/v1/llms returns 409 when name exists', async () => {
await createApp(mockRepo([makeLlm({ name: 'claude' })]));
const res = await app.inject({
method: 'POST',
url: '/api/v1/llms',
payload: { name: 'claude', type: 'anthropic', model: 'x' },
});
expect(res.statusCode).toBe(409);
});
it('PUT /api/v1/llms/:id updates model', async () => {
await createApp(mockRepo([makeLlm({ id: 'llm-1' })]));
const res = await app.inject({
method: 'PUT',
url: '/api/v1/llms/llm-1',
payload: { model: 'claude-3-opus' },
});
expect(res.statusCode).toBe(200);
expect(res.json<{ model: string }>().model).toBe('claude-3-opus');
});
it('PUT /api/v1/llms/:id returns 404 when missing', async () => {
await createApp(mockRepo());
const res = await app.inject({
method: 'PUT',
url: '/api/v1/llms/missing',
payload: { model: 'x' },
});
expect(res.statusCode).toBe(404);
});
it('DELETE /api/v1/llms/:id returns 204', async () => {
await createApp(mockRepo([makeLlm({ id: 'llm-1' })]));
const res = await app.inject({ method: 'DELETE', url: '/api/v1/llms/llm-1' });
expect(res.statusCode).toBe(204);
});
it('DELETE /api/v1/llms/:id returns 404 when missing', async () => {
await createApp(mockRepo());
const res = await app.inject({ method: 'DELETE', url: '/api/v1/llms/missing' });
expect(res.statusCode).toBe(404);
});
});