mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Point any Codeman-supported harness (Claude, opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP) at a custom OpenAI-compatible endpoint instead of its native cloud backend, for a given session. Covers local hardware (llama.cpp, Ollama, vLLM, DGX Spark, Strix Halo) and cloud (Azure AI Foundry, OpenRouter). Off by default (customModelEndpointsEnabled, synced, default OFF). - Registry: capabilities.customModelInjection per CLI entry (env / configContentEnv / configDir / unsupported kinds) - Pure injection builder (custom-model-injection.ts) turning an endpoint + model id into the real env vars / config content per CLI - Endpoint store + CRUD routes (custom-model-hosts.ts, custom-model-routes.ts), discovery via GET /v1/models, SSRF-guarded - Session integration: Session.setCustomModel()/restartCli() (POST /api/sessions/:id/custom-model), reusing the existing respawn-pane -k primitive to restart the CLI process with new env - Multi-user hardening: every new redirect-capable env var added to its CLI's privilegedEnvKeys, closing a pre-existing gap where several were already reachable via the generic envOverrides field's prefix allowlist - Standalone scripts/test-local-llm-harnesses.mjs: spawns real CLI binaries against a real endpoint outside the web UI, independent of tmux/sessions - Mock-server contract tests (test/fixtures/mock-openai-server.ts) replaying every CLI's injected values through a real HTTP shape Real end-to-end validation against a live llama-swap server (inside a codeman/agent:llm-test Docker image with all 9 CLI binaries) found and fixed three real bugs before they shipped: - Codex's config.toml schema was wrong ([model].default table instead of a top-level model string + [model_providers.custom]); fixing it then surfaced a genuine, documented protocol incompatibility (Codex only speaks the Responses API since Feb 2026, which llama.cpp/llama-swap don't implement) - Claude Code's async session-title-generation call validates ANTHROPIC_DEFAULT_HAIKU_MODEL against its own internal model list and hangs the whole -p invocation on an unrecognized name; documented for chunk 6, worked around in the standalone script only (--bare is NOT safe for a real interactive session, which needs hooks) - The discovery route's authStyle: 'both' option (send both Authorization and api-key headers) reliably hung a real server; removed the option entirely rather than just changing the default Status: draft. Chunk 6 (frontend toolbar/settings UI) not yet built — see PR.md and deployment_plan.md for the full chunk breakdown and confidence table. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HqNWfmtBU2KN29SvSVWB3
122 lines
4.2 KiB
TypeScript
122 lines
4.2 KiB
TypeScript
/**
|
|
* @fileoverview Tests for POST /api/sessions/:id/custom-model (deployment_plan.md
|
|
* chunk 5 — applying/clearing a session's custom model endpoint + CLI restart).
|
|
* Port: N/A (app.inject, no real port needed)
|
|
*/
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
|
import { createRouteTestHarness } from './_route-test-utils.js';
|
|
import { getDataDir } from '../../src/config/instance.js';
|
|
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
|
|
|
|
const CLAUDE_ENDPOINT: CustomModelHost = {
|
|
id: 'ep1',
|
|
label: 'llama.cpp box',
|
|
baseUrl: 'http://192.168.1.50:8080',
|
|
apiKey: 'k',
|
|
};
|
|
|
|
async function setup() {
|
|
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT]);
|
|
return createRouteTestHarness(registerSessionRoutes);
|
|
}
|
|
|
|
describe('POST /api/sessions/:id/custom-model', () => {
|
|
beforeEach(async () => {
|
|
await writeCustomModelHosts(getDataDir(), []);
|
|
});
|
|
|
|
it('applies an endpoint/model to a claude-mode session and restarts the CLI', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.customModel).toEqual({ endpointId: 'ep1', modelId: 'qwen3', label: 'llama.cpp box' });
|
|
expect(body.restarted).toBe(true);
|
|
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
|
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify the actual injected env vars via setCustomModel's captured call args.
|
|
const [next, envOverrides] = session.setCustomModel.mock.calls[0];
|
|
expect(next.envKeys).toEqual([
|
|
'ANTHROPIC_BASE_URL',
|
|
'ANTHROPIC_API_KEY',
|
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
]);
|
|
expect(envOverrides.ANTHROPIC_BASE_URL).toBe('http://192.168.1.50:8080');
|
|
expect(envOverrides.ANTHROPIC_API_KEY).toBe('k');
|
|
});
|
|
|
|
it('clears back to the native default', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { clear: true },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().customModel).toBeUndefined();
|
|
expect(session.setCustomModel).toHaveBeenCalledWith(undefined);
|
|
expect(session.restartCli).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('404s for an unknown endpoint id', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'claude';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ghost', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('NOT_FOUND');
|
|
});
|
|
|
|
it('refuses a mode with no known custom-model mechanism (antigravity)', async () => {
|
|
const { app, ctx } = await setup();
|
|
ctx.sessions.get('test-session-1')!.mode = 'antigravity';
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('OPERATION_FAILED');
|
|
});
|
|
|
|
it('refuses to touch a busy session', async () => {
|
|
const { app, ctx } = await setup();
|
|
const session = ctx.sessions.get('test-session-1')!;
|
|
session.mode = 'claude';
|
|
session.isBusy = () => true;
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/test-session-1/custom-model',
|
|
payload: { endpointId: 'ep1', modelId: 'qwen3' },
|
|
});
|
|
|
|
expect(res.json().success).toBe(false);
|
|
expect(res.json().errorCode).toBe('SESSION_BUSY');
|
|
expect(session.setCustomModel).not.toHaveBeenCalled();
|
|
});
|
|
});
|