feat(project): Project.llmProvider semantically names an Llm resource
Why: Phases 0-3 built the server-managed Llm registry; this phase pivots the existing Project.llmProvider column from "local provider hint" to "named Llm reference" so operators can pick a centralised Llm per project. No schema change — the column stays a free-form string for backward compat. - `mcpctl create project --llm <name>` (+ `--llm-model <override>`) sets llmProvider/llmModel to a centralised Llm reference, or 'none' to disable. - `mcpctl describe project` fetches the Llm catalogue alongside prompts and flags values that don't resolve with a visible warning. 'none' is treated as an explicit disable, not an orphan. - `apply -f` doc comments updated; --llm-provider still accepted but now documented as naming an Llm resource. - New `resolveProjectLlmReference(mcpdClient, name)` helper in mcplocal's discovery: returns `registered`/`disabled`/`unregistered`/`unreachable`. The HTTP-mode proxy-model pipeline will consume this when it pivots to mcpd's /api/v1/llms/:name/infer proxy. - project-mcp-endpoint.ts cache-namespace path gets a comment explaining the new resolution order — behavior unchanged, just clarified. Tests: 6 resolver unit tests + 3 new describe-warning cases. Full suite 1853/1853 (+9 from Phase 3's 1844). TypeScript clean; completions regenerated for the new create-project flags. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
src/mcplocal/tests/llm-reference-resolver.test.ts
Normal file
45
src/mcplocal/tests/llm-reference-resolver.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { resolveProjectLlmReference } from '../src/discovery.js';
|
||||
import type { McpdClient } from '../src/http/mcpd-client.js';
|
||||
|
||||
function mockClient(get: (path: string) => Promise<unknown>): McpdClient {
|
||||
return { get } as unknown as McpdClient;
|
||||
}
|
||||
|
||||
describe('resolveProjectLlmReference', () => {
|
||||
it('returns "disabled" for the literal string "none"', async () => {
|
||||
const client = mockClient(async () => { throw new Error('should not be called'); });
|
||||
expect(await resolveProjectLlmReference(client, 'none')).toBe('disabled');
|
||||
});
|
||||
|
||||
it('returns "unregistered" when llmProvider is empty or undefined', async () => {
|
||||
const client = mockClient(async () => { throw new Error('should not be called'); });
|
||||
expect(await resolveProjectLlmReference(client, undefined)).toBe('unregistered');
|
||||
expect(await resolveProjectLlmReference(client, '')).toBe('unregistered');
|
||||
});
|
||||
|
||||
it('returns "registered" when mcpd returns 200 for the name', async () => {
|
||||
const get = vi.fn(async () => ({ name: 'claude' }));
|
||||
expect(await resolveProjectLlmReference(mockClient(get), 'claude')).toBe('registered');
|
||||
expect(get).toHaveBeenCalledWith('/api/v1/llms/claude');
|
||||
});
|
||||
|
||||
it('returns "unregistered" on 404', async () => {
|
||||
const client = mockClient(async () => { throw new Error('HTTP 404 not found'); });
|
||||
expect(await resolveProjectLlmReference(client, 'missing')).toBe('unregistered');
|
||||
});
|
||||
|
||||
it('returns "unreachable" on other errors (500, network)', async () => {
|
||||
const client = mockClient(async () => { throw new Error('HTTP 500 internal error'); });
|
||||
expect(await resolveProjectLlmReference(client, 'x')).toBe('unreachable');
|
||||
|
||||
const client2 = mockClient(async () => { throw new Error('ECONNREFUSED'); });
|
||||
expect(await resolveProjectLlmReference(client2, 'x')).toBe('unreachable');
|
||||
});
|
||||
|
||||
it('URL-encodes names with special characters', async () => {
|
||||
const get = vi.fn(async () => ({}));
|
||||
await resolveProjectLlmReference(mockClient(get), 'weird name/with/slashes');
|
||||
expect(get).toHaveBeenCalledWith('/api/v1/llms/weird%20name%2Fwith%2Fslashes');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user