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>
This commit is contained in:
Michal
2026-04-18 21:28:43 +01:00
parent 029c3d5f34
commit 6ff90a8228
17 changed files with 1009 additions and 12 deletions

View File

@@ -0,0 +1,175 @@
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);
});
});

View File

@@ -0,0 +1,232 @@
import { describe, it, expect, vi } from 'vitest';
import { LlmService } from '../src/services/llm.service.js';
import type { ILlmRepository } from '../src/repositories/llm.repository.js';
import type { Llm, Secret } from '@prisma/client';
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 makeSecret(overrides: Partial<Secret> = {}): Secret {
return {
id: 'sec-anthropic',
name: 'anthropic-key',
backendId: 'backend-plaintext',
data: {},
externalRef: '',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockRepo(initial: Llm[] = []): ILlmRepository {
const rows = new Map<string, Llm>(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 (tier: string) => [...rows.values()].filter((r) => r.tier === tier)),
create: vi.fn(async (data) => {
const row = makeLlm({
id: `llm-${String(rows.size + 1)}`,
name: data.name,
type: data.type,
model: data.model,
url: data.url ?? '',
tier: data.tier ?? 'fast',
description: data.description ?? '',
apiKeySecretId: data.apiKeySecretId ?? null,
apiKeySecretKey: data.apiKeySecretKey ?? null,
extraConfig: (data.extraConfig ?? {}) as Llm['extraConfig'],
});
rows.set(row.id, row);
return row;
}),
update: vi.fn(async (id, data) => {
const existing = rows.get(id);
if (!existing) throw new Error('not found');
const next: Llm = {
...existing,
...(data.model !== undefined ? { model: data.model } : {}),
...(data.url !== undefined ? { url: data.url } : {}),
...(data.tier !== undefined ? { tier: data.tier } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.apiKeySecretId !== undefined ? { apiKeySecretId: data.apiKeySecretId } : {}),
...(data.apiKeySecretKey !== undefined ? { apiKeySecretKey: data.apiKeySecretKey } : {}),
...(data.extraConfig !== undefined ? { extraConfig: data.extraConfig as Llm['extraConfig'] } : {}),
};
rows.set(id, next);
return next;
}),
delete: vi.fn(async (id) => { rows.delete(id); }),
};
}
function mockSecrets(secretByName: Record<string, Secret>, resolved: Record<string, string> = {}): {
getById: ReturnType<typeof vi.fn>;
getByName: ReturnType<typeof vi.fn>;
resolveData: ReturnType<typeof vi.fn>;
} {
return {
getById: vi.fn(async (id: string) => {
for (const s of Object.values(secretByName)) if (s.id === id) return s;
throw new Error(`secret not found: ${id}`);
}),
getByName: vi.fn(async (name: string) => {
const s = secretByName[name];
if (!s) throw new Error(`secret not found: ${name}`);
return s;
}),
resolveData: vi.fn(async () => resolved),
};
}
describe('LlmService', () => {
it('create parses input and resolves apiKeyRef → secret id', async () => {
const repo = mockRepo();
const sec = makeSecret();
const secrets = mockSecrets({ 'anthropic-key': sec });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, secrets as any);
const view = await svc.create({
name: 'claude',
type: 'anthropic',
model: 'claude-3-5-sonnet-20241022',
tier: 'heavy',
apiKeyRef: { name: 'anthropic-key', key: 'token' },
});
expect(view.name).toBe('claude');
expect(view.apiKeyRef).toEqual({ name: 'anthropic-key', key: 'token' });
expect(secrets.getByName).toHaveBeenCalledWith('anthropic-key');
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
apiKeySecretId: sec.id,
apiKeySecretKey: 'token',
}));
});
it('create without apiKeyRef leaves FK columns null', async () => {
const repo = mockRepo();
const secrets = mockSecrets({});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, secrets as any);
const view = await svc.create({
name: 'ollama-local',
type: 'ollama',
model: 'llama3',
url: 'http://localhost:11434',
tier: 'fast',
});
expect(view.apiKeyRef).toBeNull();
expect(secrets.getByName).not.toHaveBeenCalled();
});
it('create rejects duplicate name', async () => {
const repo = mockRepo([makeLlm({ name: 'claude' })]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, mockSecrets({}) as any);
await expect(svc.create({
name: 'claude', type: 'anthropic', model: 'x',
})).rejects.toThrow(/already exists/);
});
it('update with apiKeyRef null unlinks the secret', async () => {
const sec = makeSecret();
const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'token' })]);
const secrets = mockSecrets({ 'anthropic-key': sec });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, secrets as any);
await svc.update('llm-1', { apiKeyRef: null });
expect(repo.update).toHaveBeenCalledWith('llm-1', expect.objectContaining({
apiKeySecretId: null,
apiKeySecretKey: null,
}));
});
it('resolveApiKey reads through SecretService', async () => {
const sec = makeSecret();
const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'token' })]);
const secrets = mockSecrets({ 'anthropic-key': sec }, { token: 'sk-ant-xyz' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, secrets as any);
const key = await svc.resolveApiKey('claude');
expect(key).toBe('sk-ant-xyz');
});
it('resolveApiKey throws when Llm has no apiKeyRef', async () => {
const repo = mockRepo([makeLlm()]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, mockSecrets({}) as any);
await expect(svc.resolveApiKey('claude')).rejects.toThrow(/no apiKeyRef/);
});
it('resolveApiKey throws when the secret key is missing', async () => {
const sec = makeSecret();
const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'missing-key' })]);
const secrets = mockSecrets({ 'anthropic-key': sec }, { token: 'x' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, secrets as any);
await expect(svc.resolveApiKey('claude')).rejects.toThrow(/no key 'missing-key'/);
});
it('list returns views with apiKeyRef rendered from secret name', async () => {
const sec = makeSecret();
const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'token' })]);
const secrets = mockSecrets({ 'anthropic-key': sec });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, secrets as any);
const items = await svc.list();
expect(items).toHaveLength(1);
expect(items[0]!.apiKeyRef).toEqual({ name: 'anthropic-key', key: 'token' });
});
it('delete happy path', async () => {
const repo = mockRepo([makeLlm()]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, mockSecrets({}) as any);
await svc.delete('llm-1');
expect(repo.delete).toHaveBeenCalledWith('llm-1');
});
it('validation: rejects invalid type', async () => {
const repo = mockRepo();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, mockSecrets({}) as any);
await expect(svc.create({ name: 'x', type: 'bogus', model: 'y' })).rejects.toThrow();
});
it('validation: rejects invalid tier', async () => {
const repo = mockRepo();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const svc = new LlmService(repo, mockSecrets({}) as any);
await expect(svc.create({
name: 'x', type: 'openai', model: 'gpt-4', tier: 'warp-speed',
})).rejects.toThrow();
});
});