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);
|
|
|
|
|
});
|
|
|
|
|
|
feat(mcplocal): RBAC-bounded vllm-managed failover + name-based llm lookup
Why: when mcpd's inference proxy is unreachable, clients with a local
vllm-managed provider should be able to substitute — but only if they still
have view permission on the centralized Llm. Otherwise revoking an Llm
wouldn't actually stop a misbehaving client.
Infrastructure (the agent + mcplocal HTTP-mode wire-up will land separately
when those clients pivot to mcpd's proxy):
- LlmProviderFileEntry gains optional `failoverFor: <central llm name>`. The
entry is otherwise the same local provider it always was; the new field
just declares which central Llm it can substitute for.
- ProviderRegistry tracks a failover map (registerFailover / getFailoverFor /
listFailovers). Unregister removes any failover entry pointing at the
removed provider so we don't end up with dangling references.
- New FailoverRouter wraps a primary inference call. On primary failure: if
a local provider is registered for the Llm, HEAD-probe `mcpd /api/v1/llms/
:name` with the caller's bearer to verify view permission, then either
invoke the local provider (allowed) or re-throw the primary error (403,
401, network unreachable, anything else — all fail-closed).
- Server: GET /api/v1/llms/:idOrName accepts both CUID and human name. Lets
FailoverRouter probe by name without a separate id-resolution call. HEAD
derives automatically from GET in Fastify, which runs the same RBAC hook
and drops the body — exactly what the probe needs.
Tests: 11 failover unit tests (registry map, decision flow, fail-closed for
forbidden + unreachable, checkAuth status mapping) + 4 new route tests
(name lookup, HEAD existing/missing). Full suite 1844/1844 (+14 from Phase
2's 1830). TypeScript clean across mcpd + mcplocal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:05:43 +01:00
|
|
|
it('GET /api/v1/llms/:nameOrId resolves by human name when not a CUID', async () => {
|
|
|
|
|
await createApp(mockRepo([makeLlm({ id: 'llm-1', name: 'claude' })]));
|
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/llms/claude' });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
expect(res.json<{ name: string; id: string }>().name).toBe('claude');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('HEAD /api/v1/llms/:name returns 200 for an existing Llm (failover RBAC pre-check)', async () => {
|
|
|
|
|
await createApp(mockRepo([makeLlm({ name: 'claude' })]));
|
|
|
|
|
const res = await app.inject({ method: 'HEAD', url: '/api/v1/llms/claude' });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('HEAD /api/v1/llms/:name returns 404 for a missing Llm', async () => {
|
|
|
|
|
await createApp(mockRepo());
|
|
|
|
|
const res = await app.inject({ method: 'HEAD', url: '/api/v1/llms/missing' });
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|