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']); + }); +});