From 1acd8b58bc40a9c0cfae838496cdd291bac93000 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 27 Apr 2026 13:59:44 +0100 Subject: [PATCH] feat(db): Llm.kind discriminator + virtual-provider lifecycle (v1 Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the virtual-LLM feature. A virtual Llm row is one that gets *registered by an mcplocal client* rather than created via \`mcpctl create llm\`. Its inference is relayed back through an SSE control channel to the publishing session (mcpd routes added in Stage 3). The lifecycle fields below let mcpd reap stale rows when the publisher goes away. Schema additions: - enum LlmKind (public | virtual). Default public. - enum LlmStatus (active | inactive | hibernating). Default active. hibernating is reserved for v2 wake-on-demand. - Llm.kind, providerSessionId, lastHeartbeatAt, status, inactiveSince. - @@index([kind, status]) for the GC sweep. - @@index([providerSessionId]) for the reconnect lookup. All existing rows backfill with kind=public/status=active so v1 is purely additive — public LLMs ignore the lifecycle columns entirely. 7 new prisma-level assertions in tests/llm-virtual-schema.test.ts cover: defaults, persisting kind=virtual + lifecycle together, the active→inactive flip, hibernating value, enum rejection, the (kind,status) GC index, the providerSessionId reconnect index. mcpd suite still 801/801 (regenerated client) and typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 16 +++ src/db/prisma/schema.prisma | 51 +++++-- src/db/tests/llm-virtual-schema.test.ts | 136 ++++++++++++++++++ 3 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 src/db/prisma/migrations/20260427125811_add_virtual_llm_lifecycle/migration.sql create mode 100644 src/db/tests/llm-virtual-schema.test.ts diff --git a/src/db/prisma/migrations/20260427125811_add_virtual_llm_lifecycle/migration.sql b/src/db/prisma/migrations/20260427125811_add_virtual_llm_lifecycle/migration.sql new file mode 100644 index 0000000..826398d --- /dev/null +++ b/src/db/prisma/migrations/20260427125811_add_virtual_llm_lifecycle/migration.sql @@ -0,0 +1,16 @@ +-- Add Llm.kind/status discriminators and virtual-provider lifecycle fields. +-- Existing rows backfill with kind='public' / status='active' so v1 is purely +-- additive — public LLMs ignore the lifecycle columns entirely. + +CREATE TYPE "LlmKind" AS ENUM ('public', 'virtual'); +CREATE TYPE "LlmStatus" AS ENUM ('active', 'inactive', 'hibernating'); + +ALTER TABLE "Llm" + ADD COLUMN "kind" "LlmKind" NOT NULL DEFAULT 'public', + ADD COLUMN "providerSessionId" TEXT, + ADD COLUMN "lastHeartbeatAt" TIMESTAMP(3), + ADD COLUMN "status" "LlmStatus" NOT NULL DEFAULT 'active', + ADD COLUMN "inactiveSince" TIMESTAMP(3); + +CREATE INDEX "Llm_kind_status_idx" ON "Llm"("kind", "status"); +CREATE INDEX "Llm_providerSessionId_idx" ON "Llm"("providerSessionId"); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index a52f1ae..dfc83e5 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -182,21 +182,44 @@ model Secret { // 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. +// +// `kind=virtual` rows are *registered by an mcplocal client* (rather than a +// human via `mcpctl create llm`). Their inference is relayed back through +// the SSE control channel to the publishing mcplocal session. The lifecycle +// fields (lastHeartbeatAt, status, inactiveSince) belong to virtual rows; +// public rows ignore them. + +enum LlmKind { + public // upstream-URL row, mcpd calls directly + virtual // mcplocal-registered, inference relayed via SSE control channel +} + +enum LlmStatus { + active // healthy, accepting requests + inactive // publisher went away; row pending 4-h GC + hibernating // publisher present but backend asleep — wakes on demand (v2) +} 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 + 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 + // ── Virtual-provider lifecycle (NULL/default for kind=public) ── + kind LlmKind @default(public) + providerSessionId String? // mcplocal session that owns this row when virtual + lastHeartbeatAt DateTime? // bumped on every publisher heartbeat + status LlmStatus @default(active) + inactiveSince DateTime? // when status flipped from active; used for 4-h GC + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull) agents Agent[] @@ -204,6 +227,8 @@ model Llm { @@index([name]) @@index([tier]) @@index([apiKeySecretId]) + @@index([kind, status]) + @@index([providerSessionId]) } // ── Groups ── diff --git a/src/db/tests/llm-virtual-schema.test.ts b/src/db/tests/llm-virtual-schema.test.ts new file mode 100644 index 0000000..12fdfde --- /dev/null +++ b/src/db/tests/llm-virtual-schema.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +describe('llm virtual-provider schema', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + it('defaults a freshly inserted Llm to kind=public, status=active', async () => { + const llm = await prisma.llm.create({ + data: { name: 'plain', type: 'openai', model: 'gpt-4o' }, + }); + expect(llm.kind).toBe('public'); + expect(llm.status).toBe('active'); + expect(llm.providerSessionId).toBeNull(); + expect(llm.lastHeartbeatAt).toBeNull(); + expect(llm.inactiveSince).toBeNull(); + }); + + it('persists kind=virtual + lifecycle fields together', async () => { + const now = new Date(); + const llm = await prisma.llm.create({ + data: { + name: 'vllm-local', + type: 'openai', + model: 'Qwen/Qwen2.5-7B-Instruct-AWQ', + kind: 'virtual', + providerSessionId: 'sess-abc', + lastHeartbeatAt: now, + status: 'active', + }, + }); + expect(llm.kind).toBe('virtual'); + expect(llm.providerSessionId).toBe('sess-abc'); + expect(llm.lastHeartbeatAt?.getTime()).toBe(now.getTime()); + expect(llm.status).toBe('active'); + }); + + it('flips status active → inactive and records inactiveSince', async () => { + const llm = await prisma.llm.create({ + data: { + name: 'goingaway', + type: 'openai', + model: 'm', + kind: 'virtual', + providerSessionId: 's1', + }, + }); + const flippedAt = new Date(); + await prisma.llm.update({ + where: { id: llm.id }, + data: { status: 'inactive', inactiveSince: flippedAt }, + }); + const reloaded = await prisma.llm.findUnique({ where: { id: llm.id } }); + expect(reloaded?.status).toBe('inactive'); + expect(reloaded?.inactiveSince?.getTime()).toBe(flippedAt.getTime()); + }); + + it('hibernating is a valid LlmStatus value (reserved for v2 wake path)', async () => { + const llm = await prisma.llm.create({ + data: { + name: 'sleepy', + type: 'openai', + model: 'm', + kind: 'virtual', + providerSessionId: 's-sleep', + status: 'hibernating', + }, + }); + expect(llm.status).toBe('hibernating'); + }); + + it('rejects unknown enum values for kind / status', async () => { + await expect( + prisma.llm.create({ + // Cast through unknown — runtime test of the enum constraint, not TS. + data: ({ name: 'bad', type: 'openai', model: 'm', kind: 'made-up' } as unknown) as Parameters[0]['data'], + }), + ).rejects.toThrow(); + + await expect( + prisma.llm.create({ + data: ({ name: 'bad2', type: 'openai', model: 'm', status: 'unknown' } as unknown) as Parameters[0]['data'], + }), + ).rejects.toThrow(); + }); + + it('finds virtual rows by (kind, status) cheaply', async () => { + // Mix of public + virtual + assorted statuses — confirms the + // @@index([kind, status]) covers the GC sweep query. + await prisma.llm.create({ data: { name: 'pub-1', type: 'openai', model: 'm' } }); + await prisma.llm.create({ data: { name: 'pub-2', type: 'openai', model: 'm' } }); + await prisma.llm.create({ + data: { name: 'v-1', type: 'openai', model: 'm', kind: 'virtual', providerSessionId: 's1', status: 'active' }, + }); + await prisma.llm.create({ + data: { name: 'v-2', type: 'openai', model: 'm', kind: 'virtual', providerSessionId: 's2', status: 'inactive', inactiveSince: new Date() }, + }); + + const stale = await prisma.llm.findMany({ + where: { kind: 'virtual', status: 'inactive' }, + select: { name: true }, + }); + expect(stale.map((l) => l.name)).toEqual(['v-2']); + }); + + it('finds rows by providerSessionId (used on mcplocal reconnect)', async () => { + await prisma.llm.create({ + data: { name: 'a', type: 'openai', model: 'm', kind: 'virtual', providerSessionId: 'shared' }, + }); + await prisma.llm.create({ + data: { name: 'b', type: 'openai', model: 'm', kind: 'virtual', providerSessionId: 'shared' }, + }); + await prisma.llm.create({ + data: { name: 'c', type: 'openai', model: 'm', kind: 'virtual', providerSessionId: 'other' }, + }); + + const owned = await prisma.llm.findMany({ + where: { providerSessionId: 'shared' }, + select: { name: true }, + orderBy: { name: 'asc' }, + }); + expect(owned.map((l) => l.name)).toEqual(['a', 'b']); + }); +});