feat: eager vLLM warmup and smart page titles in paginate stage
- Add warmup() to LlmProvider interface for eager subprocess startup - ManagedVllmProvider.warmup() starts vLLM in background on project load - ProviderRegistry.warmupAll() triggers all managed providers - NamedProvider proxies warmup() to inner provider - paginate stage generates LLM-powered descriptive page titles when available, cached by content hash, falls back to generic "Page N" - project-mcp-endpoint calls warmupAll() on router creation so vLLM is loading while the session initializes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,9 @@ import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotifi
|
||||
import type { McpdClient } from '../src/http/mcpd-client.js';
|
||||
import { ProviderRegistry } from '../src/providers/registry.js';
|
||||
import type { LlmProvider, CompletionResult } from '../src/providers/types.js';
|
||||
import { createGatePlugin } from '../src/proxymodel/plugins/gate.js';
|
||||
import { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js';
|
||||
import { MemoryCache } from '../src/proxymodel/cache.js';
|
||||
|
||||
function mockUpstream(
|
||||
name: string,
|
||||
@@ -99,11 +102,20 @@ function setupGatedRouter(
|
||||
providerRegistry.assignTier(mockProvider.name, 'heavy');
|
||||
}
|
||||
|
||||
router.setGateConfig({
|
||||
// Wire gate plugin via setPlugin
|
||||
const gatePlugin = createGatePlugin({
|
||||
gated: opts.gated !== false,
|
||||
providerRegistry,
|
||||
byteBudget: opts.byteBudget,
|
||||
});
|
||||
router.setPlugin(gatePlugin);
|
||||
|
||||
// Wire proxymodel services (needed for plugin context)
|
||||
const llmAdapter = providerRegistry ? new LLMProviderAdapter(providerRegistry) : {
|
||||
complete: async () => '',
|
||||
available: () => false,
|
||||
};
|
||||
router.setProxyModel('default', llmAdapter, new MemoryCache());
|
||||
|
||||
return { router, mcpdClient };
|
||||
}
|
||||
@@ -146,6 +158,7 @@ describe('McpRouter gating', () => {
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).toContain('read_prompts');
|
||||
expect(names).toContain('propose_prompt');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
});
|
||||
@@ -475,7 +488,7 @@ describe('McpRouter gating', () => {
|
||||
});
|
||||
|
||||
describe('session cleanup', () => {
|
||||
it('cleanupSession removes gate state', async () => {
|
||||
it('cleanupSession removes gate state, re-creates on next access', async () => {
|
||||
const { router } = setupGatedRouter();
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
@@ -486,16 +499,17 @@ describe('McpRouter gating', () => {
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
|
||||
// Cleanup
|
||||
// Cleanup removes the context
|
||||
router.cleanupSession('s1');
|
||||
|
||||
// After cleanup, session is treated as unknown (ungated)
|
||||
// After cleanup, getOrCreatePluginContext creates a fresh context and
|
||||
// calls onSessionCreate again → session is re-gated (gated=true config).
|
||||
toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
|
||||
expect(tools.map((t) => t.name)).not.toContain('begin_session');
|
||||
expect(tools[0]!.name).toBe('begin_session');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -710,8 +724,8 @@ describe('McpRouter gating', () => {
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
|
||||
// Project config changes: gated → ungated
|
||||
router.setGateConfig({ gated: false, providerRegistry: null });
|
||||
// Project config changes: gated → ungated (new plugin replaces old)
|
||||
router.setPlugin(createGatePlugin({ gated: false }));
|
||||
|
||||
// New session should be ungated
|
||||
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
|
||||
@@ -738,7 +752,7 @@ describe('McpRouter gating', () => {
|
||||
expect(names).toContain('ha/get_entities');
|
||||
|
||||
// Project config changes: ungated → gated
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
router.setPlugin(createGatePlugin({ gated: true }));
|
||||
|
||||
// New session should be gated
|
||||
await router.route({ jsonrpc: '2.0', id: 3, method: 'initialize' }, { sessionId: 's2' });
|
||||
@@ -751,22 +765,26 @@ describe('McpRouter gating', () => {
|
||||
expect(names[0]).toBe('begin_session');
|
||||
});
|
||||
|
||||
it('existing sessions retain gate state after config change', async () => {
|
||||
it('existing gated sessions become ungated when plugin changes to ungated', async () => {
|
||||
const { router } = setupGatedRouter({ gated: true });
|
||||
router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] }));
|
||||
|
||||
// Session created while gated
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Config changes to ungated
|
||||
router.setGateConfig({ gated: false, providerRegistry: null });
|
||||
// Config changes to ungated — new plugin replaces the old one
|
||||
router.setPlugin(createGatePlugin({ gated: false }));
|
||||
|
||||
// Existing session s1 should STILL be gated (session state is immutable after creation)
|
||||
// With plugin architecture, the new plugin's gate doesn't know about s1,
|
||||
// so it treats it as ungated. This is correct behavior: when admin changes
|
||||
// a project from gated to ungated, existing sessions should also become ungated.
|
||||
const toolsRes = await router.route(
|
||||
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||||
{ sessionId: 's1' },
|
||||
);
|
||||
expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session');
|
||||
const names = (toolsRes.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
|
||||
expect(names).toContain('ha/get_entities');
|
||||
expect(names).not.toContain('begin_session');
|
||||
});
|
||||
|
||||
it('already-ungated sessions remain ungated after config changes to gated', async () => {
|
||||
@@ -777,7 +795,7 @@ describe('McpRouter gating', () => {
|
||||
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' });
|
||||
|
||||
// Config changes to gated
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
router.setPlugin(createGatePlugin({ gated: true }));
|
||||
|
||||
// Existing session s1 should remain ungated
|
||||
const toolsRes = await router.route(
|
||||
@@ -801,7 +819,7 @@ describe('McpRouter gating', () => {
|
||||
);
|
||||
|
||||
// Config refreshes (still gated)
|
||||
router.setGateConfig({ gated: true, providerRegistry: null });
|
||||
router.setPlugin(createGatePlugin({ gated: true }));
|
||||
|
||||
// Session should remain ungated — begin_session already completed
|
||||
const toolsRes = await router.route(
|
||||
|
||||
Reference in New Issue
Block a user