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:
@@ -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");
|
||||||
@@ -182,6 +182,23 @@ 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())
|
||||||
@@ -194,6 +211,12 @@ model Llm {
|
|||||||
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
|
||||||
|
// ── 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)
|
version Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -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 ──
|
||||||
|
|||||||
136
src/db/tests/llm-virtual-schema.test.ts
Normal file
136
src/db/tests/llm-virtual-schema.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user