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,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 ──
|
||||
|
||||
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