From 6ff90a822800db59f12423ee0b8b44b10edfa9b9 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 18 Apr 2026 21:28:43 +0100 Subject: [PATCH] =?UTF-8?q?feat(mcpd):=20Llm=20resource=20=E2=80=94=20CRUD?= =?UTF-8?q?=20+=20CLI=20+=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --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) --- completions/mcpctl.bash | 9 +- completions/mcpctl.fish | 24 +- scripts/generate-completions.ts | 3 +- src/cli/src/commands/apply.ts | 36 +++ src/cli/src/commands/create.ts | 57 ++++- src/cli/src/commands/describe.ts | 46 ++++ src/cli/src/commands/get.ts | 23 ++ src/cli/src/commands/shared.ts | 2 + src/db/prisma/schema.prisma | 31 +++ src/mcpd/src/main.ts | 8 + src/mcpd/src/repositories/llm.repository.ts | 89 +++++++ src/mcpd/src/routes/llms.ts | 64 +++++ src/mcpd/src/services/llm.service.ts | 180 ++++++++++++++ src/mcpd/src/validation/llm.schema.ts | 39 +++ .../src/validation/rbac-definition.schema.ts | 3 +- src/mcpd/tests/llm-routes.test.ts | 175 +++++++++++++ src/mcpd/tests/llm-service.test.ts | 232 ++++++++++++++++++ 17 files changed, 1009 insertions(+), 12 deletions(-) create mode 100644 src/mcpd/src/repositories/llm.repository.ts create mode 100644 src/mcpd/src/routes/llms.ts create mode 100644 src/mcpd/src/services/llm.service.ts create mode 100644 src/mcpd/src/validation/llm.schema.ts create mode 100644 src/mcpd/tests/llm-routes.test.ts create mode 100644 src/mcpd/tests/llm-service.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index e5739fd..33a443c 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -8,8 +8,8 @@ _mcpctl() { local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate" local project_commands="get describe delete logs create edit attach-server detach-server" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" - local resource_aliases="servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" + local resources="servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" + local resource_aliases="servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" # Check if --project/-p was given local has_project=false @@ -175,7 +175,7 @@ _mcpctl() { create) local create_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$create_sub" ]]; then - COMPREPLY=($(compgen -W "server secret secretbackend project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "server secret llm secretbackend project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in server) @@ -184,6 +184,9 @@ _mcpctl() { secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) ;; + llm) + COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --force -h --help" -- "$cur")) + ;; secretbackend) COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --token-secret --config --force -h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 37573b3..e5b1a14 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all +set -l resources servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -60,6 +60,7 @@ function __mcpctl_resolve_resource case instance inst instances; echo instances case secret sec secrets; echo secrets case secretbackend sb secretbackends; echo secretbackends + case llm llms; echo llms case template tpl templates; echo templates case project proj projects; echo projects case user users; echo users @@ -75,7 +76,7 @@ function __mcpctl_resolve_resource end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -224,7 +225,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show detailed information about a resource' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply declarative configuration from a YAML or JSON file' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' @@ -240,7 +241,7 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show detailed information about a resource' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' @@ -283,9 +284,10 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' # create subcommands -set -l create_cmds server secret secretbackend project user group rbac mcptoken prompt serverattachment promptrequest +set -l create_cmds server secret llm secretbackend project user group rbac mcptoken prompt serverattachment promptrequest complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a llm -d 'Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secretbackend -d 'Create a secret backend (plaintext, openbao)' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user' @@ -316,6 +318,16 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l data -d 'Secret data KEY=value (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l force -d 'Update if already exists' +# create llm options +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l type -d 'Provider type (anthropic, openai, deepseek, vllm, ollama, gemini-cli)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l model -d 'Model identifier (e.g. claude-3-5-sonnet-20241022)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l url -d 'Endpoint URL (empty = provider default)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l tier -d 'Tier: fast or heavy' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l api-key-ref -d 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l extra -d 'Extra config key=value (repeat)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l force -d 'Update if already exists' + # create secretbackend options complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l type -d 'Backend type (plaintext, openbao)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l description -d 'Description' -x diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 7a86a8b..4f24357 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -184,7 +184,7 @@ async function extractTree(): Promise { // ============================================================ const CANONICAL_RESOURCES = [ - 'servers', 'instances', 'secrets', 'secretbackends', 'templates', 'projects', + 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'proxymodels', 'all', ]; @@ -194,6 +194,7 @@ const ALIAS_ENTRIES: [string, string][] = [ ['instance', 'instances'], ['inst', 'instances'], ['secret', 'secrets'], ['sec', 'secrets'], ['secretbackend', 'secretbackends'], ['sb', 'secretbackends'], + ['llm', 'llms'], ['llms', 'llms'], ['template', 'templates'], ['tpl', 'templates'], ['project', 'projects'], ['proj', 'projects'], ['user', 'users'], diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 64c0f10..65e4b62 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -49,6 +49,20 @@ const SecretBackendSpecSchema = z.object({ config: z.record(z.unknown()).default({}), }); +const LlmSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + type: z.enum(['anthropic', 'openai', 'deepseek', 'vllm', 'ollama', 'gemini-cli']), + model: z.string().min(1), + url: z.string().url().optional(), + tier: z.enum(['fast', 'heavy']).default('fast'), + description: z.string().max(500).default(''), + apiKeyRef: z.object({ + name: z.string().min(1), + key: z.string().min(1), + }).nullable().optional(), + extraConfig: z.record(z.unknown()).default({}), +}); + const TemplateEnvEntrySchema = z.object({ name: z.string().min(1), description: z.string().optional(), @@ -152,6 +166,7 @@ const McpTokenSpecSchema = z.object({ const ApplyConfigSchema = z.object({ secretbackends: z.array(SecretBackendSpecSchema).default([]), secrets: z.array(SecretSpecSchema).default([]), + llms: z.array(LlmSpecSchema).default([]), servers: z.array(ServerSpecSchema).default([]), users: z.array(UserSpecSchema).default([]), groups: z.array(GroupSpecSchema).default([]), @@ -194,6 +209,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { log('Dry run - would apply:'); if (config.secretbackends.length > 0) log(` ${config.secretbackends.length} secretbackend(s)`); if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`); + if (config.llms.length > 0) log(` ${config.llms.length} llm(s)`); if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); if (config.users.length > 0) log(` ${config.users.length} user(s)`); if (config.groups.length > 0) log(` ${config.groups.length} group(s)`); @@ -240,6 +256,7 @@ const KIND_TO_RESOURCE: Record = { serverattachment: 'serverattachments', mcptoken: 'mcptokens', secretbackend: 'secretbackends', + llm: 'llms', }; /** @@ -376,6 +393,25 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } + // Apply LLMs (after secrets — apiKeyRef resolves to an existing Secret) + for (const llm of config.llms) { + try { + const existing = await cachedFindByName('llms', llm.name); + if (existing) { + // Exclude type on update — type is immutable. + const { name: _n, type: _t, ...updateBody } = llm; + await withRetry(() => client.put(`/api/v1/llms/${existing.id}`, updateBody)); + log(`Updated llm: ${llm.name}`); + } else { + await withRetry(() => client.post('/api/v1/llms', llm)); + invalidateCache('llms'); + log(`Created llm: ${llm.name}`); + } + } catch (err) { + log(`Error applying llm '${llm.name}': ${err instanceof Error ? err.message : err}`); + } + } + // Apply servers for (const server of config.servers) { try { diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 5603f7e..9318c63 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -88,7 +88,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const { client, log } = deps; const cmd = new Command('create') - .description('Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)'); + .description('Create a resource (server, secret, secretbackend, llm, project, user, group, rbac, serverattachment, prompt)'); // --- create server --- cmd.command('server') @@ -252,6 +252,61 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } }); + // --- create llm --- + cmd.command('llm') + .description('Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)') + .argument('', 'LLM name (lowercase alphanumeric with hyphens)') + .requiredOption('--type ', 'Provider type (anthropic, openai, deepseek, vllm, ollama, gemini-cli)') + .requiredOption('--model ', 'Model identifier (e.g. claude-3-5-sonnet-20241022)') + .option('--url ', 'Endpoint URL (empty = provider default)') + .option('--tier ', 'Tier: fast or heavy', 'fast') + .option('--description ', 'Description') + .option('--api-key-ref ', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)') + .option('--extra ', 'Extra config key=value (repeat)', collect, []) + .option('--force', 'Update if already exists') + .action(async (name: string, opts) => { + const body: Record = { + name, + type: opts.type, + model: opts.model, + tier: opts.tier, + }; + if (opts.url) body.url = opts.url; + if (opts.description !== undefined) body.description = opts.description; + if (opts.apiKeyRef) { + const slashIdx = (opts.apiKeyRef as string).indexOf('/'); + if (slashIdx < 1) throw new Error(`Invalid --api-key-ref '${opts.apiKeyRef as string}'. Expected SECRET_NAME/KEY_NAME`); + body.apiKeyRef = { + name: (opts.apiKeyRef as string).slice(0, slashIdx), + key: (opts.apiKeyRef as string).slice(slashIdx + 1), + }; + } + if (opts.extra && (opts.extra as string[]).length > 0) { + const extra: Record = {}; + for (const entry of opts.extra as string[]) { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) throw new Error(`Invalid --extra '${entry}'. Expected key=value`); + extra[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1); + } + body.extraConfig = extra; + } + + try { + const row = await client.post<{ id: string; name: string }>('/api/v1/llms', body); + log(`llm '${row.name}' created (id: ${row.id})`); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && opts.force) { + const existing = (await client.get>('/api/v1/llms')).find((l) => l.name === name); + if (!existing) throw err; + const { name: _n, type: _t, ...updateBody } = body; + await client.put(`/api/v1/llms/${existing.id}`, updateBody); + log(`llm '${name}' updated (id: ${existing.id})`); + } else { + throw err; + } + } + }); + // --- create secretbackend --- cmd.command('secretbackend') .alias('sb') diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index e434bb8..814472a 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -218,6 +218,49 @@ function formatSecretDetail(secret: Record, showValues: boolean return lines.join('\n'); } +function formatLlmDetail(llm: Record): string { + const lines: string[] = []; + lines.push(`=== LLM: ${llm.name} ===`); + lines.push(`${pad('Name:')}${llm.name}`); + lines.push(`${pad('Type:')}${llm.type}`); + lines.push(`${pad('Model:')}${llm.model}`); + lines.push(`${pad('Tier:')}${llm.tier ?? 'fast'}`); + if (llm.url) lines.push(`${pad('URL:')}${llm.url}`); + if (llm.description) lines.push(`${pad('Description:')}${llm.description}`); + + const ref = llm.apiKeyRef as { name: string; key: string } | null | undefined; + lines.push(''); + lines.push('API Key:'); + if (ref) { + lines.push(` ${pad('Secret:', 12)}${ref.name}`); + lines.push(` ${pad('Key:', 12)}${ref.key}`); + } else { + lines.push(' (none)'); + } + + const extra = llm.extraConfig as Record | undefined; + if (extra && Object.keys(extra).length > 0) { + lines.push(''); + lines.push('Extra Config:'); + const keyW = Math.max(6, ...Object.keys(extra).map((k) => k.length)) + 2; + for (const [k, v] of Object.entries(extra)) { + let display: string; + if (v === null || v === undefined) display = '-'; + else if (typeof v === 'object') display = JSON.stringify(v); + else display = String(v); + lines.push(` ${k.padEnd(keyW)}${display}`); + } + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${llm.id}`); + if (llm.createdAt) lines.push(` ${pad('Created:', 12)}${llm.createdAt}`); + if (llm.updatedAt) lines.push(` ${pad('Updated:', 12)}${llm.updatedAt}`); + + return lines.join('\n'); +} + function formatSecretBackendDetail(backend: Record): string { const lines: string[] = []; lines.push(`=== SecretBackend: ${backend.name} ===`); @@ -840,6 +883,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { case 'secretbackends': deps.log(formatSecretBackendDetail(item)); break; + case 'llms': + deps.log(formatLlmDetail(item)); + break; case 'projects': { const projectPrompts = await deps.client .get>(`/api/v1/prompts?projectId=${item.id as string}`) diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index ac1d786..b327bab 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -119,6 +119,26 @@ const rbacColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +interface LlmRow { + id: string; + name: string; + type: string; + model: string; + tier: string; + url: string; + description: string; + apiKeyRef: { name: string; key: string } | null; +} + +const llmColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'TYPE', key: 'type', width: 12 }, + { header: 'MODEL', key: 'model', width: 28 }, + { header: 'TIER', key: 'tier', width: 8 }, + { header: 'KEY', key: (r) => r.apiKeyRef ? `secret://${r.apiKeyRef.name}/${r.apiKeyRef.key}` : '-', width: 34 }, + { header: 'ID', key: 'id' }, +]; + interface SecretBackendRow { id: string; name: string; @@ -284,6 +304,8 @@ function getColumnsForResource(resource: string): Column return mcpTokenColumns as unknown as Column>[]; case 'secretbackends': return secretBackendColumns as unknown as Column>[]; + case 'llms': + return llmColumns as unknown as Column>[]; default: return [ { header: 'ID', key: 'id' as keyof Record }, @@ -307,6 +329,7 @@ const RESOURCE_KIND: Record = { serverattachments: 'serverattachment', mcptokens: 'mcptoken', secretbackends: 'secretbackend', + llms: 'llm', }; /** diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index cd5abab..af7929e 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -34,6 +34,8 @@ export const RESOURCE_ALIASES: Record = { secretbackend: 'secretbackends', secretbackends: 'secretbackends', sb: 'secretbackends', + llm: 'llms', + llms: 'llms', all: 'all', }; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index e39bf02..17778b1 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -150,11 +150,42 @@ model Secret { updatedAt DateTime @updatedAt backend SecretBackend @relation(fields: [backendId], references: [id]) + llms Llm[] @@index([name]) @@index([backendId]) } +// ── LLMs ── +// +// Server-managed LLM providers. Clients (agent, HTTP-mode mcplocal) send +// OpenAI-format requests to `mcpd /api/v1/llms/:name/infer` — mcpd attaches the +// provider API key server-side so credentials never leave the cluster. +// Credentials are stored by reference: `apiKeySecret` points at a Secret, and +// `apiKeySecretKey` names the key within that secret's data. + +model Llm { + id String @id @default(cuid()) + name String @unique + type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli + model String // e.g. claude-3-5-sonnet-20241022 + url String @default("") // endpoint (empty for provider default) + tier String @default("fast") // fast | heavy + description String @default("") + apiKeySecretId String? // FK to Secret + apiKeySecretKey String? // key inside the Secret's data + extraConfig Json @default("{}") // per-type extras + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull) + + @@index([name]) + @@index([tier]) + @@index([apiKeySecretId]) +} + // ── Groups ── model Group { diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index bf4d79f..0e3785c 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { 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 { projects: projectRepo, groups: groupRepo, mcptokens: mcpTokenRepo, + llms: llmRepo, }; // Migrate legacy 'admin' role → granular roles @@ -327,6 +333,7 @@ async function main(): Promise { }); 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 { registerSecretRoutes(app, secretService); registerSecretBackendRoutes(app, secretBackendService); registerSecretMigrateRoutes(app, secretMigrateService); + registerLlmRoutes(app, llmService); registerInstanceRoutes(app, instanceService); registerProjectRoutes(app, projectService); registerAuditLogRoutes(app, auditLogService); diff --git a/src/mcpd/src/repositories/llm.repository.ts b/src/mcpd/src/repositories/llm.repository.ts new file mode 100644 index 0000000..92f839a --- /dev/null +++ b/src/mcpd/src/repositories/llm.repository.ts @@ -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; +} + +export interface UpdateLlmInput { + model?: string; + url?: string; + tier?: string; + description?: string; + apiKeySecretId?: string | null; + apiKeySecretKey?: string | null; + extraConfig?: Record; +} + +export interface ILlmRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByTier(tier: string): Promise; + create(data: CreateLlmInput): Promise; + update(id: string, data: UpdateLlmInput): Promise; + delete(id: string): Promise; +} + +export class LlmRepository implements ILlmRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.llm.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.llm.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.llm.findUnique({ where: { name } }); + } + + async findByTier(tier: string): Promise { + return this.prisma.llm.findMany({ where: { tier }, orderBy: { name: 'asc' } }); + } + + async create(data: CreateLlmInput): Promise { + 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 { + 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 { + await this.prisma.llm.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/llms.ts b/src/mcpd/src/routes/llms.ts new file mode 100644 index 0000000..58c26c9 --- /dev/null +++ b/src/mcpd/src/routes/llms.ts @@ -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; + } + }); +} diff --git a/src/mcpd/src/services/llm.service.ts b/src/mcpd/src/services/llm.service.ts new file mode 100644 index 0000000..3a92410 --- /dev/null +++ b/src/mcpd/src/services/llm.service.ts @@ -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; + version: number; + createdAt: Date; + updatedAt: Date; +} + +export class LlmService { + constructor( + private readonly repo: ILlmRepository, + private readonly secrets: SecretService, + ) {} + + async list(): Promise { + const rows = await this.repo.findAll(); + return Promise.all(rows.map((r) => this.toView(r))); + } + + async getById(id: string): Promise { + 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 { + 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 { + 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 { + const data = UpdateLlmSchema.parse(input); + await this.getById(id); + + const updateFields: Parameters[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 { + 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 { + 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 { + 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, + version: row.version, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + // ── Backup/restore helpers ── + + async upsertByName(input: CreateLlmInput): Promise { + 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 { + const row = await this.repo.findByName(name); + if (row === null) return; + await this.delete(row.id); + } +} diff --git a/src/mcpd/src/validation/llm.schema.ts b/src/mcpd/src/validation/llm.schema.ts new file mode 100644 index 0000000..3982a5f --- /dev/null +++ b/src/mcpd/src/validation/llm.schema.ts @@ -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; +export type UpdateLlmInput = z.infer; +export type ApiKeyRef = z.infer; diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index ebe8c36..e079c8f 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -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 = { @@ -16,6 +16,7 @@ const RESOURCE_ALIASES: Record = { promptrequest: 'promptrequests', mcptoken: 'mcptokens', secretbackend: 'secretbackends', + llm: 'llms', }; /** Normalize a resource name to its canonical plural form. */ diff --git a/src/mcpd/tests/llm-routes.test.ts b/src/mcpd/tests/llm-routes.test.ts new file mode 100644 index 0000000..2d06fd7 --- /dev/null +++ b/src/mcpd/tests/llm-routes.test.ts @@ -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 { + 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 { + 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>(); + 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); + }); +}); diff --git a/src/mcpd/tests/llm-service.test.ts b/src/mcpd/tests/llm-service.test.ts new file mode 100644 index 0000000..78af3e3 --- /dev/null +++ b/src/mcpd/tests/llm-service.test.ts @@ -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 { + 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 { + 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(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, resolved: Record = {}): { + getById: ReturnType; + getByName: ReturnType; + resolveData: ReturnType; +} { + 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(); + }); +});