feat: virtual LLMs v1 (registration skeleton) #63
@@ -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