feat(db): Llm.kind discriminator + virtual-provider lifecycle (v1 Stage 1)

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) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-27 13:59:44 +01:00
parent e65a396d3e
commit 1acd8b58bc
3 changed files with 190 additions and 13 deletions

View File

@@ -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");

View File

@@ -182,21 +182,44 @@ model Secret {
// provider API key server-side so credentials never leave the cluster. // provider API key server-side so credentials never leave the cluster.
// Credentials are stored by reference: `apiKeySecret` points at a Secret, and // Credentials are stored by reference: `apiKeySecret` points at a Secret, and
// `apiKeySecretKey` names the key within that secret's data. // `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 { model Llm {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli
model String // e.g. claude-3-5-sonnet-20241022 model String // e.g. claude-3-5-sonnet-20241022
url String @default("") // endpoint (empty for provider default) url String @default("") // endpoint (empty for provider default)
tier String @default("fast") // fast | heavy tier String @default("fast") // fast | heavy
description String @default("") description String @default("")
apiKeySecretId String? // FK to Secret apiKeySecretId String? // FK to Secret
apiKeySecretKey String? // key inside the Secret's data apiKeySecretKey String? // key inside the Secret's data
extraConfig Json @default("{}") // per-type extras extraConfig Json @default("{}") // per-type extras
version Int @default(1) // ── Virtual-provider lifecycle (NULL/default for kind=public) ──
createdAt DateTime @default(now()) kind LlmKind @default(public)
updatedAt DateTime @updatedAt 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) apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
agents Agent[] agents Agent[]
@@ -204,6 +227,8 @@ model Llm {
@@index([name]) @@index([name])
@@index([tier]) @@index([tier])
@@index([apiKeySecretId]) @@index([apiKeySecretId])
@@index([kind, status])
@@index([providerSessionId])
} }
// ── Groups ── // ── Groups ──

View File

@@ -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<typeof prisma.llm.create>[0]['data'],
}),
).rejects.toThrow();
await expect(
prisma.llm.create({
data: ({ name: 'bad2', type: 'openai', model: 'm', status: 'unknown' } as unknown) as Parameters<typeof prisma.llm.create>[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']);
});
});