mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
feat(custom-model): generate Run-menu entries from saved endpoint profiles
Follow-up to #393, picking up the work Ark0N invited in his merge comment: "generate those entries from the saved profiles rather than a fixed duplicate per harness, and put it in a follow-up PR so this one stays the backend... The Run-menu picker is yours if you want it." Adds the frontend surface the backend has been waiting on: - Run menu: a "Custom Endpoints" section lists one entry per (harness that supports customModelInjection, saved endpoint) pair, e.g. "Claude Code (llama.cpp)". The harness list comes from window.__codemanCustomModelClis, injected at page render straight off the CLI registry's own capabilities (never a hardcoded id list in the frontend), so a CLI whose injection recipe lands later appears with no frontend change. Picking an entry runs that harness's own existing run*() function unmodified (case creation, env overrides, everything, forced to a single instance) and then applies the endpoint's default model to the session it creates via the existing POST /api/sessions/:id/custom-model route. Entries are hidden for a remote/docker active case, since that route already refuses both. - Settings: App Settings -> Models gets a "Custom model endpoints" group wiring up the customModelEndpointsEnabled toggle (declared since #393, read by nothing until now) plus CRUD against the existing /api/model-endpoints routes: list, add/edit (inline form), delete, discover models. - Backend: CustomModelHost gains an optional defaultModelId, the model the picker applies with no further choice per endpoint (one generated menu entry per CLI+endpoint pair, not per CLI+endpoint+model). The route refuses a value that isn't one of the endpoint's own discovered models, and a fresh discovery drops a default that no longer appears rather than carrying an invalid one forward. Docs: docs/custom-model-endpoints.md describes the new picker and settings panel; CLAUDE.md's Custom Model Endpoint Profiles entry drops the "backend-only" status note and documents the picker's generation mechanism. Tests: four new route tests cover defaultModelId validation, acceptance, and the drop/keep behaviour across a re-discovery; a new render-index-html test pins the __codemanCustomModelClis injection (present, agent CLIs supporting the capability, antigravity and shell excluded) and its solo-window skip. No browser test was added for the Run-menu picker itself or the settings CRUD panel (this box has no tmux, so the live server used by test:browser/test:mobile could not be exercised here) -- worth a Playwright pass before merge, same as any other frontend PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bd286bf502
commit
25fae9ad10
@@ -187,6 +187,28 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports which run modes the custom-model Run-menu picker may generate an entry for', async () => {
|
||||
// Read generically off the CLI registry's own capabilities, not a hardcoded id
|
||||
// list — antigravity (`unsupported`) and shell (`kind !== 'agent'`) must be
|
||||
// absent, and any enabled agent CLI with a real injection recipe must be
|
||||
// present, with no mock needed since this reads the real stock registry.
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server);
|
||||
expect(html).toContain('window.__codemanCustomModelClis=');
|
||||
const clis = JSON.parse(html.match(/window\.__codemanCustomModelClis=(\[.*?\]);/)![1]) as Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
}>;
|
||||
const ids = clis.map((c) => c.id);
|
||||
expect(ids).toContain('claude');
|
||||
expect(ids).not.toContain('antigravity');
|
||||
expect(ids).not.toContain('shell');
|
||||
for (const cli of clis) {
|
||||
expect(typeof cli.id).toBe('string');
|
||||
expect(typeof cli.label).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('still emits the object when nothing at all is installed', async () => {
|
||||
// The all-false case is the one that matters most and the easiest to get
|
||||
// wrong by only injecting when something resolves.
|
||||
@@ -218,6 +240,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
const { server } = makeServer({});
|
||||
const html = await render(server, 'sess-123');
|
||||
expect(html).not.toContain('__codemanCliAvailable');
|
||||
expect(html).not.toContain('__codemanCustomModelClis');
|
||||
});
|
||||
|
||||
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
|
||||
|
||||
@@ -194,3 +194,104 @@ describe('custom model endpoint CRUD', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultModelId — the Run-menu picker’s per-endpoint default', () => {
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('rejects a defaultModelId that is not one of the endpoint’s discovered models, on both create and update', async () => {
|
||||
const { app } = await setup();
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: {
|
||||
id: 'ep-default-reject',
|
||||
label: 'A',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
models: ['qwen3'],
|
||||
defaultModelId: 'ghost',
|
||||
},
|
||||
});
|
||||
expect(create.json().success).toBe(false);
|
||||
expect(create.json().errorCode).toBe('INVALID_INPUT');
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: { id: 'ep-default-reject', label: 'A', baseUrl: 'http://localhost:8080', models: ['qwen3'] },
|
||||
});
|
||||
const update = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/model-endpoints/ep-default-reject',
|
||||
payload: { label: 'A', baseUrl: 'http://localhost:8080', models: ['qwen3'], defaultModelId: 'ghost' },
|
||||
});
|
||||
expect(update.json().success).toBe(false);
|
||||
expect(update.json().errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('accepts a defaultModelId that IS one of the discovered models', async () => {
|
||||
const { app } = await setup();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: {
|
||||
id: 'ep-default-accept',
|
||||
label: 'A',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
models: ['qwen3', 'llama3'],
|
||||
defaultModelId: 'llama3',
|
||||
},
|
||||
});
|
||||
expect(res.json().success).toBe(true);
|
||||
expect(res.json().data.host.defaultModelId).toBe('llama3');
|
||||
});
|
||||
|
||||
it('drops a stale default that no longer appears in a fresh discovery, rather than carrying it forward invalid', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: {
|
||||
id: 'ep-default-drop',
|
||||
label: 'A',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
models: ['qwen3'],
|
||||
defaultModelId: 'qwen3',
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'llama3' }] }), { status: 200 }));
|
||||
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-default-drop/discover-models' });
|
||||
|
||||
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||
const stored = (list.json() as Array<{ id: string; defaultModelId?: string }>).find(
|
||||
(h) => h.id === 'ep-default-drop'
|
||||
);
|
||||
expect(stored?.defaultModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a default that IS still present after a fresh discovery', async () => {
|
||||
const { app } = await setup();
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/model-endpoints',
|
||||
payload: {
|
||||
id: 'ep-default-keep',
|
||||
label: 'A',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
models: ['qwen3'],
|
||||
defaultModelId: 'qwen3',
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: [{ id: 'qwen3' }, { id: 'llama3' }] }), { status: 200 })
|
||||
);
|
||||
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-default-keep/discover-models' });
|
||||
|
||||
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
|
||||
const stored = (list.json() as Array<{ id: string; defaultModelId?: string }>).find(
|
||||
(h) => h.id === 'ep-default-keep'
|
||||
);
|
||||
expect(stored?.defaultModelId).toBe('qwen3');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user