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:
@@ -26,6 +26,9 @@ import { SecretMigrateService } from './services/secret-migrate.service.js';
|
||||
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
|
||||
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
|
||||
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
|
||||
import { LlmRepository } from './repositories/llm.repository.js';
|
||||
import { LlmService } from './services/llm.service.js';
|
||||
import { registerLlmRoutes } from './routes/llms.js';
|
||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
||||
@@ -117,6 +120,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||
'prompts': 'prompts',
|
||||
'promptrequests': 'promptrequests',
|
||||
'mcptokens': 'mcptokens',
|
||||
'llms': 'llms',
|
||||
};
|
||||
|
||||
const resource = resourceMap[segment];
|
||||
@@ -271,6 +275,7 @@ async function main(): Promise<void> {
|
||||
const serverRepo = new McpServerRepository(prisma);
|
||||
const secretRepo = new SecretRepository(prisma);
|
||||
const secretBackendRepo = new SecretBackendRepository(prisma);
|
||||
const llmRepo = new LlmRepository(prisma);
|
||||
const instanceRepo = new McpInstanceRepository(prisma);
|
||||
const projectRepo = new ProjectRepository(prisma);
|
||||
const auditLogRepo = new AuditLogRepository(prisma);
|
||||
@@ -294,6 +299,7 @@ async function main(): Promise<void> {
|
||||
projects: projectRepo,
|
||||
groups: groupRepo,
|
||||
mcptokens: mcpTokenRepo,
|
||||
llms: llmRepo,
|
||||
};
|
||||
|
||||
// Migrate legacy 'admin' role → granular roles
|
||||
@@ -327,6 +333,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
const secretService = new SecretService(secretRepo, secretBackendService);
|
||||
const secretMigrateService = new SecretMigrateService(secretRepo, secretBackendService);
|
||||
const llmService = new LlmService(llmRepo, secretService);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
|
||||
serverService.setInstanceService(instanceService);
|
||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||
@@ -467,6 +474,7 @@ async function main(): Promise<void> {
|
||||
registerSecretRoutes(app, secretService);
|
||||
registerSecretBackendRoutes(app, secretBackendService);
|
||||
registerSecretMigrateRoutes(app, secretMigrateService);
|
||||
registerLlmRoutes(app, llmService);
|
||||
registerInstanceRoutes(app, instanceService);
|
||||
registerProjectRoutes(app, projectService);
|
||||
registerAuditLogRoutes(app, auditLogService);
|
||||
|
||||
89
src/mcpd/src/repositories/llm.repository.ts
Normal file
89
src/mcpd/src/repositories/llm.repository.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { PrismaClient, Llm, Prisma } from '@prisma/client';
|
||||
|
||||
export interface CreateLlmInput {
|
||||
name: string;
|
||||
type: string;
|
||||
model: string;
|
||||
url?: string;
|
||||
tier?: string;
|
||||
description?: string;
|
||||
apiKeySecretId?: string | null;
|
||||
apiKeySecretKey?: string | null;
|
||||
extraConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateLlmInput {
|
||||
model?: string;
|
||||
url?: string;
|
||||
tier?: string;
|
||||
description?: string;
|
||||
apiKeySecretId?: string | null;
|
||||
apiKeySecretKey?: string | null;
|
||||
extraConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ILlmRepository {
|
||||
findAll(): Promise<Llm[]>;
|
||||
findById(id: string): Promise<Llm | null>;
|
||||
findByName(name: string): Promise<Llm | null>;
|
||||
findByTier(tier: string): Promise<Llm[]>;
|
||||
create(data: CreateLlmInput): Promise<Llm>;
|
||||
update(id: string, data: UpdateLlmInput): Promise<Llm>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class LlmRepository implements ILlmRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<Llm[]> {
|
||||
return this.prisma.llm.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Llm | null> {
|
||||
return this.prisma.llm.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Llm | null> {
|
||||
return this.prisma.llm.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async findByTier(tier: string): Promise<Llm[]> {
|
||||
return this.prisma.llm.findMany({ where: { tier }, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async create(data: CreateLlmInput): Promise<Llm> {
|
||||
return this.prisma.llm.create({
|
||||
data: {
|
||||
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 Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateLlmInput): Promise<Llm> {
|
||||
const updateData: Prisma.LlmUpdateInput = {};
|
||||
if (data.model !== undefined) updateData.model = data.model;
|
||||
if (data.url !== undefined) updateData.url = data.url;
|
||||
if (data.tier !== undefined) updateData.tier = data.tier;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.apiKeySecretId !== undefined) {
|
||||
updateData.apiKeySecret = data.apiKeySecretId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: data.apiKeySecretId } };
|
||||
}
|
||||
if (data.apiKeySecretKey !== undefined) updateData.apiKeySecretKey = data.apiKeySecretKey;
|
||||
if (data.extraConfig !== undefined) updateData.extraConfig = data.extraConfig as Prisma.InputJsonValue;
|
||||
return this.prisma.llm.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.llm.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
64
src/mcpd/src/routes/llms.ts
Normal file
64
src/mcpd/src/routes/llms.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { LlmService } from '../services/llm.service.js';
|
||||
import { NotFoundError, ConflictError } from '../services/mcp-server.service.js';
|
||||
|
||||
export function registerLlmRoutes(
|
||||
app: FastifyInstance,
|
||||
service: LlmService,
|
||||
): void {
|
||||
app.get('/api/v1/llms', async () => {
|
||||
return service.list();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/llms/:id', async (request, reply) => {
|
||||
try {
|
||||
return await service.getById(request.params.id);
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/llms', async (request, reply) => {
|
||||
try {
|
||||
const row = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return row;
|
||||
} catch (err) {
|
||||
if (err instanceof ConflictError) {
|
||||
reply.code(409);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/llms/:id', async (request, reply) => {
|
||||
try {
|
||||
return await service.update(request.params.id, request.body);
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/llms/:id', async (request, reply) => {
|
||||
try {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
180
src/mcpd/src/services/llm.service.ts
Normal file
180
src/mcpd/src/services/llm.service.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* LlmService — CRUD over `Llm` rows plus credential resolution.
|
||||
*
|
||||
* Credentials are stored by reference: the row carries `(apiKeySecretId,
|
||||
* apiKeySecretKey)`. Callers that need the raw key (the inference proxy, once
|
||||
* it lands in Phase 2) call `resolveApiKey()`, which reads through the
|
||||
* SecretService (whose own backend dispatch transparently hits plaintext or
|
||||
* OpenBao as configured).
|
||||
*
|
||||
* The CLI/API accepts `apiKeyRef: { name, key }` — the service translates
|
||||
* that to the FK pair.
|
||||
*/
|
||||
import type { Llm } from '@prisma/client';
|
||||
import type { ILlmRepository } from '../repositories/llm.repository.js';
|
||||
import type { SecretService } from './secret.service.js';
|
||||
import {
|
||||
CreateLlmSchema,
|
||||
UpdateLlmSchema,
|
||||
type CreateLlmInput,
|
||||
type ApiKeyRef,
|
||||
} from '../validation/llm.schema.js';
|
||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
|
||||
/** Shape returned by API layer — merges DB row with a human-readable apiKeyRef. */
|
||||
export interface LlmView {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
model: string;
|
||||
url: string;
|
||||
tier: string;
|
||||
description: string;
|
||||
apiKeyRef: ApiKeyRef | null;
|
||||
extraConfig: Record<string, unknown>;
|
||||
version: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class LlmService {
|
||||
constructor(
|
||||
private readonly repo: ILlmRepository,
|
||||
private readonly secrets: SecretService,
|
||||
) {}
|
||||
|
||||
async list(): Promise<LlmView[]> {
|
||||
const rows = await this.repo.findAll();
|
||||
return Promise.all(rows.map((r) => this.toView(r)));
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<LlmView> {
|
||||
const row = await this.repo.findById(id);
|
||||
if (row === null) throw new NotFoundError(`Llm not found: ${id}`);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async getByName(name: string): Promise<LlmView> {
|
||||
const row = await this.repo.findByName(name);
|
||||
if (row === null) throw new NotFoundError(`Llm not found: ${name}`);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async create(input: unknown): Promise<LlmView> {
|
||||
const data = CreateLlmSchema.parse(input);
|
||||
const existing = await this.repo.findByName(data.name);
|
||||
if (existing !== null) throw new ConflictError(`Llm already exists: ${data.name}`);
|
||||
|
||||
const apiKeyFields = await this.resolveApiKeyRefToIds(data.apiKeyRef);
|
||||
const row = await this.repo.create({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
model: data.model,
|
||||
url: data.url ?? '',
|
||||
tier: data.tier,
|
||||
description: data.description,
|
||||
apiKeySecretId: apiKeyFields.id,
|
||||
apiKeySecretKey: apiKeyFields.key,
|
||||
extraConfig: data.extraConfig,
|
||||
});
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async update(id: string, input: unknown): Promise<LlmView> {
|
||||
const data = UpdateLlmSchema.parse(input);
|
||||
await this.getById(id);
|
||||
|
||||
const updateFields: Parameters<ILlmRepository['update']>[1] = {};
|
||||
if (data.model !== undefined) updateFields.model = data.model;
|
||||
if (data.url !== undefined) updateFields.url = data.url;
|
||||
if (data.tier !== undefined) updateFields.tier = data.tier;
|
||||
if (data.description !== undefined) updateFields.description = data.description;
|
||||
if (data.extraConfig !== undefined) updateFields.extraConfig = data.extraConfig;
|
||||
|
||||
// apiKeyRef: null → explicit unlink; object → replace; undefined → leave alone.
|
||||
if (data.apiKeyRef !== undefined) {
|
||||
if (data.apiKeyRef === null) {
|
||||
updateFields.apiKeySecretId = null;
|
||||
updateFields.apiKeySecretKey = null;
|
||||
} else {
|
||||
const resolved = await this.resolveApiKeyRefToIds(data.apiKeyRef);
|
||||
updateFields.apiKeySecretId = resolved.id;
|
||||
updateFields.apiKeySecretKey = resolved.key;
|
||||
}
|
||||
}
|
||||
|
||||
const row = await this.repo.update(id, updateFields);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the raw API key string for a given Llm. Called by the inference
|
||||
* proxy in Phase 2. Throws NotFoundError if the Llm has no apiKeyRef, or the
|
||||
* referenced secret/key doesn't exist.
|
||||
*/
|
||||
async resolveApiKey(llmName: string): Promise<string> {
|
||||
const row = await this.repo.findByName(llmName);
|
||||
if (row === null) throw new NotFoundError(`Llm not found: ${llmName}`);
|
||||
if (row.apiKeySecretId === null || row.apiKeySecretKey === null) {
|
||||
throw new NotFoundError(`Llm '${llmName}' has no apiKeyRef configured`);
|
||||
}
|
||||
const secret = await this.secrets.getById(row.apiKeySecretId);
|
||||
const data = await this.secrets.resolveData(secret);
|
||||
const value = data[row.apiKeySecretKey];
|
||||
if (value === undefined) {
|
||||
throw new NotFoundError(`Secret '${secret.name}' has no key '${row.apiKeySecretKey}'`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private async resolveApiKeyRefToIds(ref: ApiKeyRef | undefined): Promise<{ id: string | null; key: string | null }> {
|
||||
if (ref === undefined) return { id: null, key: null };
|
||||
const secret = await this.secrets.getByName(ref.name);
|
||||
return { id: secret.id, key: ref.key };
|
||||
}
|
||||
|
||||
private async toView(row: Llm): Promise<LlmView> {
|
||||
let apiKeyRef: ApiKeyRef | null = null;
|
||||
if (row.apiKeySecretId !== null && row.apiKeySecretKey !== null) {
|
||||
const secret = await this.secrets.getById(row.apiKeySecretId).catch(() => null);
|
||||
if (secret !== null) {
|
||||
apiKeyRef = { name: secret.name, key: row.apiKeySecretKey };
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
model: row.model,
|
||||
url: row.url,
|
||||
tier: row.tier,
|
||||
description: row.description,
|
||||
apiKeyRef,
|
||||
extraConfig: row.extraConfig as Record<string, unknown>,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Backup/restore helpers ──
|
||||
|
||||
async upsertByName(input: CreateLlmInput): Promise<LlmView> {
|
||||
const existing = await this.repo.findByName(input.name);
|
||||
if (existing !== null) {
|
||||
return this.update(existing.id, input);
|
||||
}
|
||||
return this.create(input);
|
||||
}
|
||||
|
||||
async deleteByName(name: string): Promise<void> {
|
||||
const row = await this.repo.findByName(name);
|
||||
if (row === null) return;
|
||||
await this.delete(row.id);
|
||||
}
|
||||
}
|
||||
39
src/mcpd/src/validation/llm.schema.ts
Normal file
39
src/mcpd/src/validation/llm.schema.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LLM_TYPES = ['anthropic', 'openai', 'deepseek', 'vllm', 'ollama', 'gemini-cli'] as const;
|
||||
export const LLM_TIERS = ['fast', 'heavy'] as const;
|
||||
|
||||
/**
|
||||
* Reference to a key inside a Secret. `name` is the Secret resource name;
|
||||
* `key` is the JSON key inside that secret's `data` map. mcpd resolves the
|
||||
* pair through SecretService at inference time, so credentials never leave
|
||||
* the server.
|
||||
*/
|
||||
export const ApiKeyRefSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
export const CreateLlmSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
type: z.enum(LLM_TYPES),
|
||||
model: z.string().min(1),
|
||||
url: z.string().url().optional(),
|
||||
tier: z.enum(LLM_TIERS).default('fast'),
|
||||
description: z.string().max(500).default(''),
|
||||
apiKeyRef: ApiKeyRefSchema.optional(),
|
||||
extraConfig: z.record(z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const UpdateLlmSchema = z.object({
|
||||
model: z.string().min(1).optional(),
|
||||
url: z.string().url().or(z.literal('')).optional(),
|
||||
tier: z.enum(LLM_TIERS).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
apiKeyRef: ApiKeyRefSchema.nullable().optional(),
|
||||
extraConfig: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type CreateLlmInput = z.infer<typeof CreateLlmSchema>;
|
||||
export type UpdateLlmInput = z.infer<typeof UpdateLlmSchema>;
|
||||
export type ApiKeyRef = z.infer<typeof ApiKeyRefSchema>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
|
||||
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const;
|
||||
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const;
|
||||
|
||||
/** Singular→plural map for resource names. */
|
||||
const RESOURCE_ALIASES: Record<string, string> = {
|
||||
@@ -16,6 +16,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
||||
promptrequest: 'promptrequests',
|
||||
mcptoken: 'mcptokens',
|
||||
secretbackend: 'secretbackends',
|
||||
llm: 'llms',
|
||||
};
|
||||
|
||||
/** Normalize a resource name to its canonical plural form. */
|
||||
|
||||
175
src/mcpd/tests/llm-routes.test.ts
Normal file
175
src/mcpd/tests/llm-routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
232
src/mcpd/tests/llm-service.test.ts
Normal file
232
src/mcpd/tests/llm-service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user