Why: when mcpd's inference proxy is unreachable, clients with a local vllm-managed provider should be able to substitute — but only if they still have view permission on the centralized Llm. Otherwise revoking an Llm wouldn't actually stop a misbehaving client. Infrastructure (the agent + mcplocal HTTP-mode wire-up will land separately when those clients pivot to mcpd's proxy): - LlmProviderFileEntry gains optional `failoverFor: <central llm name>`. The entry is otherwise the same local provider it always was; the new field just declares which central Llm it can substitute for. - ProviderRegistry tracks a failover map (registerFailover / getFailoverFor / listFailovers). Unregister removes any failover entry pointing at the removed provider so we don't end up with dangling references. - New FailoverRouter wraps a primary inference call. On primary failure: if a local provider is registered for the Llm, HEAD-probe `mcpd /api/v1/llms/ :name` with the caller's bearer to verify view permission, then either invoke the local provider (allowed) or re-throw the primary error (403, 401, network unreachable, anything else — all fail-closed). - Server: GET /api/v1/llms/:idOrName accepts both CUID and human name. Lets FailoverRouter probe by name without a separate id-resolution call. HEAD derives automatically from GET in Fastify, which runs the same RBAC hook and drops the body — exactly what the probe needs. Tests: 11 failover unit tests (registry map, decision flow, fail-closed for forbidden + unreachable, checkAuth status mapping) + 4 new route tests (name lookup, HEAD existing/missing). Full suite 1844/1844 (+14 from Phase 2's 1830). TypeScript clean across mcpd + mcplocal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
6.2 KiB
TypeScript
171 lines
6.2 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { ProviderRegistry } from '../src/providers/registry.js';
|
|
import { FailoverRouter } from '../src/providers/failover-router.js';
|
|
import type { LlmProvider, CompleteResponse } from '../src/providers/types.js';
|
|
|
|
function fakeProvider(name: string): LlmProvider {
|
|
const completeFn = vi.fn(async (): Promise<CompleteResponse> => ({
|
|
content: 'local response',
|
|
finishReason: 'stop',
|
|
}));
|
|
return {
|
|
name,
|
|
complete: completeFn,
|
|
listModels: vi.fn(async () => [name]),
|
|
isAvailable: vi.fn(async () => true),
|
|
};
|
|
}
|
|
|
|
function makeFetch(behaviour: { method: string; status?: number; throw?: boolean }): ReturnType<typeof vi.fn> {
|
|
return vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
if (behaviour.throw === true) throw new Error('connection refused');
|
|
expect(init?.method).toBe(behaviour.method);
|
|
expect(String(url)).toMatch(/\/api\/v1\/llms\//);
|
|
return new Response(null, { status: behaviour.status ?? 200 });
|
|
});
|
|
}
|
|
|
|
describe('ProviderRegistry — failover map', () => {
|
|
it('registerFailover maps a central name → local provider name', () => {
|
|
const reg = new ProviderRegistry();
|
|
const local = fakeProvider('vllm-local');
|
|
reg.register(local);
|
|
reg.registerFailover('claude', 'vllm-local');
|
|
|
|
const found = reg.getFailoverFor('claude');
|
|
expect(found?.name).toBe('vllm-local');
|
|
});
|
|
|
|
it('getFailoverFor returns null when no map entry exists', () => {
|
|
const reg = new ProviderRegistry();
|
|
reg.register(fakeProvider('vllm-local'));
|
|
expect(reg.getFailoverFor('claude')).toBeNull();
|
|
});
|
|
|
|
it('registerFailover throws when local provider is not registered', () => {
|
|
const reg = new ProviderRegistry();
|
|
expect(() => reg.registerFailover('claude', 'missing')).toThrow(/not registered/);
|
|
});
|
|
|
|
it('unregister removes failover entries that pointed at the removed provider', () => {
|
|
const reg = new ProviderRegistry();
|
|
reg.register(fakeProvider('vllm-local'));
|
|
reg.registerFailover('claude', 'vllm-local');
|
|
reg.unregister('vllm-local');
|
|
expect(reg.getFailoverFor('claude')).toBeNull();
|
|
expect(reg.listFailovers()).toEqual([]);
|
|
});
|
|
|
|
it('listFailovers reports the current map', () => {
|
|
const reg = new ProviderRegistry();
|
|
reg.register(fakeProvider('vllm-local'));
|
|
reg.registerFailover('claude', 'vllm-local');
|
|
reg.registerFailover('opus', 'vllm-local');
|
|
expect(reg.listFailovers()).toEqual([
|
|
{ centralLlmName: 'claude', localProviderName: 'vllm-local' },
|
|
{ centralLlmName: 'opus', localProviderName: 'vllm-local' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('FailoverRouter', () => {
|
|
it('returns primary result when primary succeeds', async () => {
|
|
const reg = new ProviderRegistry();
|
|
reg.register(fakeProvider('vllm-local'));
|
|
reg.registerFailover('claude', 'vllm-local');
|
|
|
|
const router = new FailoverRouter(reg, {
|
|
mcpdUrl: 'http://mcpd',
|
|
fetch: vi.fn() as unknown as typeof fetch,
|
|
});
|
|
const out = await router.run('claude', async () => 'central', async () => 'local');
|
|
expect(out.failover).toBe(false);
|
|
expect(out.result).toBe('central');
|
|
});
|
|
|
|
it('falls back to local when primary fails AND mcpd auth-checks 200', async () => {
|
|
const reg = new ProviderRegistry();
|
|
reg.register(fakeProvider('vllm-local'));
|
|
reg.registerFailover('claude', 'vllm-local');
|
|
|
|
const fetchFn = makeFetch({ method: 'HEAD', status: 200 });
|
|
const router = new FailoverRouter(reg, {
|
|
mcpdUrl: 'http://mcpd',
|
|
fetch: fetchFn as unknown as typeof fetch,
|
|
bearerToken: 'bearer-x',
|
|
});
|
|
const out = await router.run(
|
|
'claude',
|
|
async () => { throw new Error('upstream down'); },
|
|
async (provider) => `via:${provider.name}`,
|
|
);
|
|
expect(out.failover).toBe(true);
|
|
expect(out.via).toBe('vllm-local');
|
|
expect(out.result).toBe('via:vllm-local');
|
|
|
|
// Bearer was attached
|
|
const [, init] = fetchFn.mock.calls[0] as [string, RequestInit];
|
|
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer bearer-x');
|
|
});
|
|
|
|
it('re-throws primary error when no local failover is registered', async () => {
|
|
const reg = new ProviderRegistry();
|
|
const router = new FailoverRouter(reg, {
|
|
mcpdUrl: 'http://mcpd',
|
|
fetch: vi.fn() as unknown as typeof fetch,
|
|
});
|
|
await expect(router.run(
|
|
'claude',
|
|
async () => { throw new Error('boom'); },
|
|
async () => 'never',
|
|
)).rejects.toThrow('boom');
|
|
});
|
|
|
|
it('re-throws (fail-closed) when mcpd returns 403 to the auth check', async () => {
|
|
const reg = new ProviderRegistry();
|
|
reg.register(fakeProvider('vllm-local'));
|
|
reg.registerFailover('claude', 'vllm-local');
|
|
|
|
const router = new FailoverRouter(reg, {
|
|
mcpdUrl: 'http://mcpd',
|
|
fetch: makeFetch({ method: 'HEAD', status: 403 }) as unknown as typeof fetch,
|
|
});
|
|
await expect(router.run(
|
|
'claude',
|
|
async () => { throw new Error('upstream down'); },
|
|
async () => 'never',
|
|
)).rejects.toThrow('upstream down');
|
|
});
|
|
|
|
it('re-throws (fail-closed) when mcpd itself is unreachable for the auth check', async () => {
|
|
const reg = new ProviderRegistry();
|
|
reg.register(fakeProvider('vllm-local'));
|
|
reg.registerFailover('claude', 'vllm-local');
|
|
|
|
const router = new FailoverRouter(reg, {
|
|
mcpdUrl: 'http://mcpd',
|
|
fetch: makeFetch({ method: 'HEAD', throw: true }) as unknown as typeof fetch,
|
|
});
|
|
await expect(router.run(
|
|
'claude',
|
|
async () => { throw new Error('upstream down'); },
|
|
async () => 'never',
|
|
)).rejects.toThrow('upstream down');
|
|
});
|
|
|
|
it('checkAuth maps responses correctly', async () => {
|
|
const reg = new ProviderRegistry();
|
|
const make = (status: number) => new FailoverRouter(reg, {
|
|
mcpdUrl: 'http://mcpd',
|
|
fetch: (async () => new Response(null, { status })) as unknown as typeof fetch,
|
|
});
|
|
|
|
expect(await make(200).checkAuth('claude')).toBe('allowed');
|
|
expect(await make(204).checkAuth('claude')).toBe('allowed');
|
|
expect(await make(401).checkAuth('claude')).toBe('forbidden');
|
|
expect(await make(403).checkAuth('claude')).toBe('forbidden');
|
|
expect(await make(404).checkAuth('claude')).toBe('unreachable');
|
|
expect(await make(500).checkAuth('claude')).toBe('unreachable');
|
|
});
|
|
});
|