feat(mcpd+cli+mcplocal): /llms/<name>/members + POOL column + --pool-name (v4 Stage 2)
Surfaces the v4 pool model end-to-end: - mcpd: GET /api/v1/llms/:name/members returns the effective pool the named anchor belongs to, plus aggregate stats (size, activeCount, explicit vs implicit pool key). RBAC inherits from `view:llms` — same as the single-Llm route. Members are full LlmView shapes so callers don't need a second roundtrip to render the pool block. - mcpd: VirtualLlmService.register accepts an optional `poolName` on RegisterProviderInput; the route's `coerceProviderInput` validates the same character set as CreateLlmSchema.poolName. Backwards compatible — older mcplocals that don't send the field continue to publish solo Llms. - CLI `get llm` table: new POOL column right after NAME. Solo rows show "-" so the "no pool / pool of 1" case is unambiguous (per user direction "make sure we see it, prominently visible and impossible to mistake"). - CLI `describe llm`: fetches /members and renders a Pool block at the top of the detail view when the row is in an explicit pool OR when its implicit pool has size > 1. Each member line shows kind/status; the anchor row gets "← this row". Block is suppressed for solo rows so describe stays compact in the common case. - CLI `create llm --pool-name <name>` flag and apply schema both accept the new field. Yaml round-trip preserves it: get -o yaml emits `poolName: <name>`, apply -f re-imports it without diff. Verified end-to-end against the live mcpd. - mcplocal: LlmProviderFileEntry gains optional `poolName`; main.ts and registrar.ts thread it through into the register payload. Use case for distributed inference: each user's mcplocal picks a unique `name` (e.g. `vllm-<host>-qwen3`) but a shared `poolName` (e.g. `user-vllm-qwen3-thinking`); agents see one logical pool that auto-grows as workers come online. - Shell completions: regenerated from source via the existing scripts/generate-completions.ts. `--pool-name` now suggests in fish + bash for `mcpctl create llm`. Tests: +3 new mcpd route tests for /members (explicit pool, solo pool of 1, missing-anchor 404). All suites green: mcpd 868/868 (was 865, +3), mcplocal 723/723, cli 437/437. Stage 3 (next): live smoke against 2 publishers sharing a pool name + docs.
This commit is contained in:
@@ -61,6 +61,10 @@ const LlmSpecSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
}).nullable().optional(),
|
||||
extraConfig: z.record(z.unknown()).default({}),
|
||||
// v4: optional pool key. Same validation as on the mcpd side
|
||||
// (CreateLlmSchema). Null means "solo Llm, effective pool key falls
|
||||
// back to the row's own name".
|
||||
poolName: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).nullable().optional(),
|
||||
});
|
||||
|
||||
const AgentChatParamsAppliedSchema = z.object({
|
||||
|
||||
@@ -263,6 +263,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--description <text>', 'Description')
|
||||
.option('--api-key-ref <ref>', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)')
|
||||
.option('--extra <entry>', 'Extra config key=value (repeat)', collect, [])
|
||||
.option('--pool-name <pool>', 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool')
|
||||
.option('--force', 'Update if already exists')
|
||||
.option('--skip-auth-check', 'Skip the upstream auth probe (for offline registration before infra exists)')
|
||||
.action(async (name: string, opts) => {
|
||||
@@ -274,6 +275,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
};
|
||||
if (opts.url) body.url = opts.url;
|
||||
if (opts.description !== undefined) body.description = opts.description;
|
||||
if (opts.poolName !== undefined) body.poolName = opts.poolName;
|
||||
if (opts.apiKeyRef) {
|
||||
const slashIdx = (opts.apiKeyRef as string).indexOf('/');
|
||||
if (slashIdx < 1) throw new Error(`Invalid --api-key-ref '${opts.apiKeyRef as string}'. Expected SECRET_NAME/KEY_NAME`);
|
||||
|
||||
@@ -243,7 +243,15 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatLlmDetail(llm: Record<string, unknown>): string {
|
||||
interface PoolMembersInfo {
|
||||
poolName: string;
|
||||
explicitPoolName: string | null;
|
||||
size: number;
|
||||
activeCount: number;
|
||||
members: Array<{ id?: string; name: string; status?: string; kind?: string; url?: string }>;
|
||||
}
|
||||
|
||||
function formatLlmDetail(llm: Record<string, unknown>, pool?: PoolMembersInfo): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== LLM: ${llm.name} ===`);
|
||||
lines.push(`${pad('Name:')}${llm.name}`);
|
||||
@@ -253,6 +261,29 @@ function formatLlmDetail(llm: Record<string, unknown>): string {
|
||||
if (llm.url) lines.push(`${pad('URL:')}${llm.url}`);
|
||||
if (llm.description) lines.push(`${pad('Description:')}${llm.description}`);
|
||||
|
||||
// v4 Pool block: only render when there's actually pool context to show.
|
||||
// For solo Llms (poolName null AND pool size 1), suppress the section so
|
||||
// describe stays compact for the common case. For explicit-pool members
|
||||
// OR rows whose name is implicitly seeding a pool (size > 1), render up
|
||||
// top so it's the first thing the operator sees — pool routing is a
|
||||
// significant behavioral fact.
|
||||
const poolNameVal = llm.poolName as string | null | undefined;
|
||||
const isExplicitPool = poolNameVal !== null && poolNameVal !== undefined && poolNameVal !== '';
|
||||
const isImplicitPool = pool !== undefined && pool.size > 1;
|
||||
if (isExplicitPool || isImplicitPool) {
|
||||
lines.push('');
|
||||
lines.push('Pool:');
|
||||
const effective = pool?.poolName ?? (poolNameVal ?? llm.name as string);
|
||||
lines.push(` ${pad('Pool name:', 14)}${effective}${isExplicitPool ? '' : ' (implicit, falls back to name)'}`);
|
||||
if (pool !== undefined) {
|
||||
lines.push(` ${pad('Members:', 14)}${String(pool.size)} (${String(pool.activeCount)} active)`);
|
||||
for (const m of pool.members) {
|
||||
const youSuffix = m.name === llm.name ? ' ← this row' : '';
|
||||
lines.push(` - ${m.name} [${m.kind ?? '?'}/${m.status ?? '?'}]${youSuffix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ref = llm.apiKeyRef as { name: string; key: string } | null | undefined;
|
||||
lines.push('');
|
||||
lines.push('API Key:');
|
||||
@@ -982,9 +1013,22 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
case 'secretbackends':
|
||||
deps.log(formatSecretBackendDetail(item));
|
||||
break;
|
||||
case 'llms':
|
||||
deps.log(formatLlmDetail(item));
|
||||
case 'llms': {
|
||||
// v4: also fetch pool membership so the describe Pool block
|
||||
// can show siblings + active counts. Best-effort — older
|
||||
// mcpd versions without the /members route 404 here, in
|
||||
// which case we render the row alone.
|
||||
let poolInfo: PoolMembersInfo | undefined;
|
||||
try {
|
||||
poolInfo = await deps.client.get<PoolMembersInfo>(
|
||||
`/api/v1/llms/${encodeURIComponent(item.name as string)}/members`,
|
||||
);
|
||||
} catch {
|
||||
// Old mcpd without /members, or RBAC denial — fall back silently.
|
||||
}
|
||||
deps.log(formatLlmDetail(item, poolInfo));
|
||||
break;
|
||||
}
|
||||
case 'projects': {
|
||||
const [projectPrompts, llms] = await Promise.all([
|
||||
deps.client
|
||||
|
||||
@@ -136,10 +136,16 @@ interface LlmRow {
|
||||
// mcpd responses that predate the kind/status columns).
|
||||
kind?: 'public' | 'virtual';
|
||||
status?: 'active' | 'inactive' | 'hibernating';
|
||||
// v4: explicit pool key. NULL = solo Llm (effective pool = its own name).
|
||||
poolName?: string | null;
|
||||
}
|
||||
|
||||
// v4: POOL column placed right after NAME so an operator can't miss
|
||||
// which Llms stack into the same dispatcher pool. Solo rows show "-"
|
||||
// to make the "no pool / pool of 1" case unambiguous.
|
||||
const llmColumns: Column<LlmRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'POOL', key: (r) => (r.poolName !== null && r.poolName !== undefined && r.poolName !== '') ? r.poolName : '-', width: 18 },
|
||||
{ header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 },
|
||||
{ header: 'STATUS', key: (r) => r.status ?? 'active', width: 12 },
|
||||
{ header: 'TYPE', key: 'type', width: 12 },
|
||||
|
||||
Reference in New Issue
Block a user