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
632 lines
24 KiB
JavaScript
632 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Standalone smoke-test for pointing each Codeman-supported harness CLI at a
|
|
* custom OpenAI-compatible endpoint — local (llama.cpp, Ollama, vLLM, ...) or
|
|
* cloud (Azure AI Foundry's OpenAI-compatible endpoint, OpenRouter, a
|
|
* self-hosted gateway, ...). Anything that answers GET /v1/models and POST
|
|
* /v1/chat/completions in the standard shape qualifies; --base-url is not
|
|
* assumed to be a LAN address.
|
|
*
|
|
* This is intentionally OUTSIDE the npm test suite and outside Codeman's own
|
|
* session/tmux machinery: it spawns each real CLI binary directly, one-shot,
|
|
* with the env vars / config files that CLI's own docs say redirect it to a
|
|
* custom endpoint, and checks it can answer "hello world".
|
|
*
|
|
* Cloud endpoints often differ from a bare llama.cpp box in two ways this
|
|
* script accounts for: (1) auth may be an `api-key` header (Azure's
|
|
* convention) rather than `Authorization: Bearer` — the baseline check in
|
|
* Step 0 sends both, since an extra header is harmless to servers that
|
|
* ignore it; each CLI's OWN auth convention (set via its env vars/config,
|
|
* not this script) still needs to match what that endpoint expects. (2) a
|
|
* cloud endpoint's "model" may actually be a deployment name distinct from
|
|
* the model family (Azure AI Foundry deployments) — always pass --model
|
|
* explicitly for those rather than relying on GET /v1/models discovery.
|
|
*
|
|
* IMPORTANT CONFIDENCE NOTE: only claude/opencode/codex recipes are verified
|
|
* (Devvyn confirmed them by hand). gemini/pi/grok/deepseek/omp are best
|
|
* guesses from public docs, not verified against this repo or against real
|
|
* binaries. antigravity has no known CLI/env mechanism at all and is always
|
|
* skipped. Read a harness's UNCONFIRMED/FAIL output before trusting it — use
|
|
* --probe-help to read that binary's real --help and fix the guessed flag.
|
|
*
|
|
* Usage:
|
|
* node scripts/test-local-llm-harnesses.mjs --base-url http://192.168.1.50:8080 [options]
|
|
* node scripts/test-local-llm-harnesses.mjs --base-url https://<resource>.services.ai.azure.com/openai/v1 --model <deployment-name> --api-key $AZURE_AI_KEY
|
|
*
|
|
* Options:
|
|
* --base-url <url> Required. Root URL of the OpenAI-compatible endpoint (local or cloud).
|
|
* --model <name> Model/deployment id to request. Default: first from GET /v1/models.
|
|
* --api-key <key> API key to send. Default: local-dummy-key (fine for llama.cpp; required for most cloud endpoints).
|
|
* --auth-style <style> "bearer" (default, Authorization: Bearer) or "api-key" (the
|
|
* `api-key` header some cloud gateways, e.g. Azure, want).
|
|
* NEVER send both — live-tested against a real server, doing
|
|
* so reliably HANGS the request indefinitely.
|
|
* --prompt <text> Prompt to send. Default: "Reply with exactly: hello world".
|
|
* --only <id,id,...> Restrict to these harness ids (comma-separated).
|
|
* --timeout <ms> Per-harness spawn timeout. Default: 30000.
|
|
* --probe-help Instead of testing, resolve each installed binary and print --help.
|
|
* --keep-temp Don't delete generated per-harness config dirs afterward.
|
|
* --list Dry run: print the resolved plan per harness, execute nothing.
|
|
* -h, --help Show this help.
|
|
*/
|
|
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
import { tmpdir, homedir } from 'node:os';
|
|
import { join, delimiter, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const TAG = '[test-local-llm-harnesses]';
|
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
const CONFIG_PATH = join(SCRIPT_DIR, 'local-llm-test.config.json');
|
|
const CONFIG_EXAMPLE_PATH = join(SCRIPT_DIR, 'local-llm-test.config.example.json');
|
|
|
|
/**
|
|
* Loads scripts/local-llm-test.config.json (gitignored — real IP/model/key,
|
|
* per-machine) if present, so you don't have to retype --base-url every run.
|
|
* See local-llm-test.config.example.json (tracked) for the shape. CLI flags
|
|
* always override whatever this file sets; this only supplies defaults.
|
|
*/
|
|
function loadConfigFile() {
|
|
if (!existsSync(CONFIG_PATH)) return {};
|
|
try {
|
|
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
return {
|
|
baseUrl: raw.baseUrl ?? null,
|
|
model: raw.model ?? null,
|
|
apiKey: raw.apiKey || undefined, // empty string counts as "not set", not a real key
|
|
authStyle: raw.authStyle === 'api-key' ? 'api-key' : undefined, // never 'both'
|
|
prompt: raw.prompt ?? undefined,
|
|
only: Array.isArray(raw.only) && raw.only.length ? raw.only : null,
|
|
timeout: typeof raw.timeout === 'number' ? raw.timeout : undefined,
|
|
};
|
|
} catch (err) {
|
|
console.error(`${TAG} failed to parse ${CONFIG_PATH}: ${err.message} (ignoring it)`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv, configDefaults) {
|
|
const opts = {
|
|
baseUrl: configDefaults.baseUrl ?? null,
|
|
model: configDefaults.model ?? null,
|
|
apiKey: configDefaults.apiKey ?? 'local-dummy-key',
|
|
authStyle: configDefaults.authStyle ?? 'bearer',
|
|
prompt: configDefaults.prompt ?? 'Reply with exactly: hello world',
|
|
only: configDefaults.only ?? null,
|
|
timeout: configDefaults.timeout ?? 30000,
|
|
probeHelp: false,
|
|
keepTemp: false,
|
|
list: false,
|
|
help: false,
|
|
};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
switch (a) {
|
|
case '--base-url':
|
|
opts.baseUrl = argv[++i];
|
|
break;
|
|
case '--model':
|
|
opts.model = argv[++i];
|
|
break;
|
|
case '--api-key':
|
|
opts.apiKey = argv[++i];
|
|
break;
|
|
case '--auth-style':
|
|
opts.authStyle = argv[++i];
|
|
if (opts.authStyle !== 'bearer' && opts.authStyle !== 'api-key') {
|
|
console.error(`${TAG} --auth-style must be "bearer" or "api-key"`);
|
|
opts.help = true;
|
|
}
|
|
break;
|
|
case '--prompt':
|
|
opts.prompt = argv[++i];
|
|
break;
|
|
case '--only':
|
|
opts.only = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
|
|
break;
|
|
case '--timeout':
|
|
opts.timeout = Number(argv[++i]);
|
|
break;
|
|
case '--probe-help':
|
|
opts.probeHelp = true;
|
|
break;
|
|
case '--keep-temp':
|
|
opts.keepTemp = true;
|
|
break;
|
|
case '--list':
|
|
opts.list = true;
|
|
break;
|
|
case '-h':
|
|
case '--help':
|
|
opts.help = true;
|
|
break;
|
|
default:
|
|
console.error(`${TAG} unknown argument: ${a}`);
|
|
opts.help = true;
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`Usage: node scripts/test-local-llm-harnesses.mjs [--base-url <url>] [options]
|
|
|
|
Reads defaults from scripts/local-llm-test.config.json if it exists (copy
|
|
scripts/local-llm-test.config.example.json to create it — gitignored, since
|
|
it holds a real IP/model/key). CLI flags always override the config file.
|
|
--base-url becomes optional once that file supplies one.
|
|
|
|
Works against any custom OpenAI-compatible endpoint, local or cloud
|
|
(llama.cpp, Ollama, vLLM, Azure AI Foundry, OpenRouter, a self-hosted
|
|
gateway, ...) — anything answering GET /v1/models and POST
|
|
/v1/chat/completions in the standard shape.
|
|
|
|
Options:
|
|
--base-url <url> Required. Root URL of the OpenAI-compatible endpoint.
|
|
--model <name> Model/deployment id to request. Default: first from GET /v1/models.
|
|
--api-key <key> API key to send. Default: local-dummy-key (required for most cloud endpoints).
|
|
--auth-style <style> "bearer" (default) or "api-key" (Azure-style). Never both — sending
|
|
both headers together reliably hangs some real servers.
|
|
--prompt <text> Prompt to send. Default: "Reply with exactly: hello world".
|
|
--only <id,id,...> Restrict to these harness ids.
|
|
--timeout <ms> Per-harness spawn timeout. Default: 30000.
|
|
--probe-help Print each installed binary's --help instead of testing.
|
|
--keep-temp Keep generated per-harness config dirs afterward.
|
|
--list Dry run: print the resolved plan, execute nothing.
|
|
-h, --help Show this help.
|
|
|
|
Harness ids: claude, opencode, codex, gemini, pi, grok, deepseek, omp, antigravity
|
|
|
|
Examples:
|
|
node scripts/test-local-llm-harnesses.mjs --base-url http://192.168.1.50:8080
|
|
node scripts/test-local-llm-harnesses.mjs --base-url https://<resource>.services.ai.azure.com/openai/v1 --model <deployment-name> --api-key $AZURE_AI_KEY`);
|
|
}
|
|
|
|
const HOME = homedir();
|
|
const EXTRA_SEARCH_DIRS = [
|
|
join(HOME, '.local', 'bin'),
|
|
join(HOME, '.opencode', 'bin'),
|
|
join(HOME, '.codex', 'bin'),
|
|
join(HOME, '.gemini', 'bin'),
|
|
join(HOME, '.antigravity', 'bin'),
|
|
join(HOME, '.grok', 'bin'),
|
|
join(HOME, '.omp', 'bin'),
|
|
join(HOME, '.bun', 'bin'),
|
|
join(HOME, '.npm-global', 'bin'),
|
|
join(HOME, 'bin'),
|
|
'/usr/local/bin',
|
|
];
|
|
|
|
function pathWithExtraDirs() {
|
|
return [...EXTRA_SEARCH_DIRS, process.env.PATH ?? ''].join(delimiter);
|
|
}
|
|
|
|
/** Resolve a binary by trying `<bin> --version` with extra search dirs prefixed onto PATH. */
|
|
function resolveBinary(bin) {
|
|
try {
|
|
execFileSync(bin, ['--version'], {
|
|
timeout: 5000,
|
|
stdio: 'pipe',
|
|
env: { ...process.env, PATH: pathWithExtraDirs() },
|
|
});
|
|
return bin;
|
|
} catch (err) {
|
|
// Some CLIs (e.g. dsh) don't support --version cleanly for identity but
|
|
// still exist on PATH; a non-ENOENT failure still counts as "found".
|
|
if (err && err.code === 'ENOENT') return null;
|
|
return bin;
|
|
}
|
|
}
|
|
|
|
function printHelp(bin) {
|
|
try {
|
|
const out = execFileSync(bin, ['--help'], {
|
|
timeout: 5000,
|
|
stdio: 'pipe',
|
|
env: { ...process.env, PATH: pathWithExtraDirs() },
|
|
});
|
|
console.log(out.toString());
|
|
} catch (err) {
|
|
console.log((err.stdout ?? err.message ?? String(err)).toString());
|
|
}
|
|
}
|
|
|
|
// --- per-harness definitions -----------------------------------------------
|
|
|
|
/** kind: 'env' | 'configContentEnv' | 'configDir' | 'unsupported' */
|
|
const HARNESSES = {
|
|
claude: {
|
|
binary: 'claude',
|
|
confidence: 'verified',
|
|
buildEnv: (baseUrl, apiKey, model) => ({
|
|
ANTHROPIC_BASE_URL: baseUrl,
|
|
ANTHROPIC_API_KEY: apiKey,
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
}),
|
|
// Claude Code's async session-title-generation call also uses
|
|
// ANTHROPIC_DEFAULT_HAIKU_MODEL and validates it against Claude's OWN internal
|
|
// recognized-model list, printing [claude-code:unrecognized_model] to stderr for
|
|
// a local model name. Confirmed live: `--settings '{"autoTitle":false}'` does NOT
|
|
// stop it (still hung the whole run); `--bare` does — the warning still prints,
|
|
// but the actual prompt now runs and returns the real answer. Confirmed against
|
|
// a real llama-swap server.
|
|
buildArgv: (prompt) => ['--dangerously-skip-permissions', '--bare', '-p', prompt],
|
|
},
|
|
opencode: {
|
|
binary: 'opencode',
|
|
confidence: 'verified',
|
|
buildEnv: (baseUrl, apiKey, model) => ({
|
|
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
$schema: 'https://opencode.ai/config.json',
|
|
provider: {
|
|
local: {
|
|
options: { baseURL: `${baseUrl}/v1`, apiKey },
|
|
models: { [model]: {} },
|
|
},
|
|
},
|
|
model: `local/${model}`,
|
|
}),
|
|
}),
|
|
buildArgv: (prompt) => ['run', prompt],
|
|
},
|
|
codex: {
|
|
binary: 'codex',
|
|
confidence: 'verified',
|
|
configDir: {
|
|
dirEnvVar: 'CODEX_HOME',
|
|
fileName: 'config.toml',
|
|
// Verified against a real codex binary: `model` must be a top-level STRING
|
|
// (an earlier `[model].default` table was rejected with "invalid type: map,
|
|
// expected a string"). The API key is NEVER a literal TOML field — codex only
|
|
// supports `env_key`, the NAME of an env var it reads the value from, so the
|
|
// real key rides as an extra env var (see extraEnv below), never in the file.
|
|
// ⚠️ `wire_api = "responses"` is the only value codex still accepts (support
|
|
// for "chat" was dropped Feb 2026) — a plain OpenAI Chat-Completions server
|
|
// (llama.cpp, llama-swap) does NOT implement the Responses API, so this may
|
|
// still fail at the PROTOCOL level even with a correctly-shaped file.
|
|
content: (baseUrl, _apiKey, model) =>
|
|
`model = "${model}"\nmodel_provider = "custom"\n\n[model_providers.custom]\nname = "Custom Endpoint"\nbase_url = "${baseUrl}/v1"\nenv_key = "CODEMAN_CUSTOM_MODEL_API_KEY"\nwire_api = "responses"\n`,
|
|
extraEnv: (_baseUrl, apiKey) => ({ CODEMAN_CUSTOM_MODEL_API_KEY: apiKey }),
|
|
},
|
|
buildArgv: (prompt) => ['exec', '--dangerously-bypass-approvals-and-sandbox', prompt],
|
|
},
|
|
gemini: {
|
|
binary: 'gemini',
|
|
confidence: 'researched',
|
|
buildEnv: (baseUrl, apiKey, model) => ({
|
|
GOOGLE_GEMINI_BASE_URL: baseUrl,
|
|
GEMINI_API_KEY: apiKey,
|
|
GEMINI_MODEL: model,
|
|
}),
|
|
buildArgv: (prompt) => ['-p', prompt, '--approval-mode', 'yolo'],
|
|
},
|
|
pi: {
|
|
binary: 'pi',
|
|
confidence: 'researched',
|
|
configDir: {
|
|
dirEnvVar: 'PI_CONFIG_DIR',
|
|
fileName: join('agent', 'models.json'),
|
|
content: (baseUrl, apiKey, model) =>
|
|
JSON.stringify(
|
|
{
|
|
providers: {
|
|
local: {
|
|
baseUrl: `${baseUrl}/v1`,
|
|
apiKey,
|
|
api: 'openai-completions',
|
|
models: { [model]: {} },
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
},
|
|
buildArgv: (prompt) => ['--approve', '-p', prompt],
|
|
},
|
|
grok: {
|
|
binary: 'grok',
|
|
confidence: 'researched',
|
|
buildEnv: (baseUrl, apiKey, model) => ({
|
|
GROK_BASE_URL: baseUrl,
|
|
XAI_API_KEY: apiKey,
|
|
GROK_MODEL: model,
|
|
}),
|
|
buildArgv: (prompt) => ['--always-approve', '-p', prompt],
|
|
},
|
|
deepseek: {
|
|
binary: 'dsh',
|
|
confidence: 'unknown',
|
|
note: 'dsh is a profile launcher, not a documented one-shot prompt flag. Best-effort only.',
|
|
buildEnv: (baseUrl, apiKey) => ({
|
|
DEEPSEEK_BASE_URL: baseUrl,
|
|
DEEPSEEK_API_KEY: apiKey,
|
|
DSH_PERMISSION_MODE: 'danger-full-access',
|
|
}),
|
|
buildArgv: (prompt) => ['--profile', 'headless', prompt],
|
|
},
|
|
omp: {
|
|
binary: 'omp',
|
|
confidence: 'researched',
|
|
configDir: {
|
|
dirEnvVar: 'PI_CONFIG_DIR', // omp's ~/.omp tree is relocatable via PI_CONFIG_DIR per CLAUDE.md
|
|
fileName: join('agent', 'models.yml'),
|
|
content: (baseUrl, apiKey, model) =>
|
|
`providers:\n local:\n baseUrl: ${baseUrl}/v1\n apiKey: ${apiKey}\n models:\n - ${model}\n`,
|
|
},
|
|
buildArgv: (prompt) => ['-p', prompt],
|
|
},
|
|
antigravity: {
|
|
binary: 'agy',
|
|
confidence: 'unsupported',
|
|
note: 'No known CLI/env/config mechanism for a custom endpoint (GUI-only per public docs). Always skipped.',
|
|
buildEnv: null,
|
|
buildArgv: null,
|
|
},
|
|
};
|
|
|
|
// --- baseline server check ---------------------------------------------------
|
|
|
|
async function baselineCheck(baseUrl, apiKey, authStyle, model, prompt, timeoutMs) {
|
|
console.log(`\n=== Step 0: baseline check against ${baseUrl} (auth: ${authStyle}) ===`);
|
|
|
|
// Exactly ONE header, never both. An earlier version sent both auth conventions
|
|
// (Bearer + api-key) on the theory that an unused header is harmless — live-
|
|
// tested against a real llama-swap server, sending both reliably HUNG the
|
|
// request indefinitely (reproduced 3x: Bearer alone ~500ms, api-key alone
|
|
// ~600ms, both together no response inside a 15s timeout). Use --auth-style
|
|
// api-key for endpoints that specifically want that header (e.g. Azure AI
|
|
// Foundry); default 'bearer' covers everything else.
|
|
const authHeaders = authStyle === 'api-key' ? { 'api-key': apiKey } : { Authorization: `Bearer ${apiKey}` };
|
|
|
|
let discoveredModel = model;
|
|
try {
|
|
const res = await fetch(`${baseUrl}/v1/models`, {
|
|
headers: authHeaders,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const body = await res.json();
|
|
const ids = (body.data ?? []).map((m) => m.id);
|
|
console.log(`GET /v1/models -> ${ids.length ? ids.join(', ') : '(empty list)'}`);
|
|
if (!discoveredModel && ids.length) discoveredModel = ids[0];
|
|
} catch (err) {
|
|
console.error(`${TAG} GET /v1/models failed: ${err.message}`);
|
|
console.error(`${TAG} Is the server actually running at ${baseUrl}? Aborting.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!discoveredModel) {
|
|
console.error(`${TAG} No --model given and none discovered from /v1/models. Aborting.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Live-tested against a real llama-swap server: a POST issued right after a GET on
|
|
// the same Node process reliably HANGS indefinitely (reproduced repeatedly — GET
|
|
// alone ~30ms, POST alone ~1-2s, GET-then-immediate-POST times out completely; a
|
|
// 2s pause between them fixed it every time). This looks like Node's fetch (undici)
|
|
// reusing a pooled keep-alive connection the server doesn't handle cleanly for a
|
|
// second request right behind a first. A short pause is the simplest portable fix
|
|
// (no extra deps, no need for undici's Agent/dispatcher API).
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
|
|
try {
|
|
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
|
body: JSON.stringify({
|
|
model: discoveredModel,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
}),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
const body = await res.json();
|
|
const reply = body.choices?.[0]?.message?.content ?? '';
|
|
if (!reply.trim()) throw new Error('empty reply');
|
|
console.log(`POST /v1/chat/completions -> "${reply.trim().slice(0, 200)}"`);
|
|
console.log('Server baseline: PASS\n');
|
|
} catch (err) {
|
|
console.error(`${TAG} POST /v1/chat/completions failed: ${err.message}`);
|
|
console.error(`${TAG} Server responded to /v1/models but not to a chat request. Aborting.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
return discoveredModel;
|
|
}
|
|
|
|
// --- per-harness run ----------------------------------------------------------
|
|
|
|
function makeTempConfigDir(id) {
|
|
const dir = mkdtempSync(join(tmpdir(), `codeman-local-llm-test-${id}-`));
|
|
return dir;
|
|
}
|
|
|
|
function runChild(bin, argv, env, timeoutMs) {
|
|
return new Promise((resolve) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let settled = false;
|
|
const child = spawn(bin, argv, {
|
|
env: { ...process.env, ...env, PATH: pathWithExtraDirs() },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
child.kill('SIGKILL');
|
|
resolve({ code: null, stdout, stderr, timedOut: true });
|
|
}, timeoutMs);
|
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
child.on('error', (err) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({ code: null, stdout, stderr: `${stderr}\n${err.message}`, timedOut: false });
|
|
});
|
|
child.on('close', (code) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({ code, stdout, stderr, timedOut: false });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runHarness(id, def, opts, model) {
|
|
const result = { id, confidence: def.confidence, status: 'SKIP', detail: '' };
|
|
|
|
if (def.confidence === 'unsupported') {
|
|
result.status = 'SKIP';
|
|
result.detail = def.note ?? 'no known mechanism';
|
|
return result;
|
|
}
|
|
|
|
const resolved = resolveBinary(def.binary);
|
|
if (!resolved) {
|
|
result.status = 'SKIP';
|
|
result.detail = `binary "${def.binary}" not found on PATH or search dirs`;
|
|
return result;
|
|
}
|
|
|
|
let env = def.buildEnv ? def.buildEnv(opts.baseUrl, opts.apiKey, model) : {};
|
|
let tempDir = null;
|
|
|
|
if (def.configDir) {
|
|
tempDir = makeTempConfigDir(id);
|
|
const filePath = join(tempDir, def.configDir.fileName);
|
|
mkdirSync(join(filePath, '..'), { recursive: true });
|
|
writeFileSync(filePath, def.configDir.content(opts.baseUrl, opts.apiKey, model), 'utf8');
|
|
const extraEnv = def.configDir.extraEnv ? def.configDir.extraEnv(opts.baseUrl, opts.apiKey, model) : {};
|
|
env = { ...env, [def.configDir.dirEnvVar]: tempDir, ...extraEnv };
|
|
}
|
|
|
|
const argv = def.buildArgv(opts.prompt);
|
|
|
|
if (opts.list) {
|
|
result.status = 'LIST';
|
|
result.detail = `${def.binary} ${argv.join(' ')} | env: ${Object.keys(env).join(', ')}${
|
|
tempDir ? ` | configDir: ${tempDir}` : ''
|
|
}`;
|
|
if (tempDir && !opts.keepTemp) rmSync(tempDir, { recursive: true, force: true });
|
|
return result;
|
|
}
|
|
|
|
const { code, stdout, stderr, timedOut } = await runChild(def.binary, argv, env, opts.timeout);
|
|
|
|
if (tempDir && !opts.keepTemp) rmSync(tempDir, { recursive: true, force: true });
|
|
else if (tempDir) result.detail += ` [config kept at ${tempDir}]`;
|
|
|
|
if (timedOut) {
|
|
result.status = 'FAIL';
|
|
result.detail = `timed out after ${opts.timeout}ms. stderr: ${stderr.slice(-300)}`;
|
|
return result;
|
|
}
|
|
|
|
const reply = stdout.trim();
|
|
const matched = /hello/i.test(reply) && /world/i.test(reply);
|
|
|
|
if (code !== 0) {
|
|
result.status = def.confidence === 'verified' ? 'FAIL' : 'UNCONFIRMED';
|
|
result.detail = `exit ${code}. stderr: ${stderr.trim().slice(-300) || '(empty)'}`;
|
|
return result;
|
|
}
|
|
|
|
if (!reply) {
|
|
result.status = def.confidence === 'verified' ? 'FAIL' : 'UNCONFIRMED';
|
|
result.detail = 'exit 0 but empty stdout';
|
|
return result;
|
|
}
|
|
|
|
if (matched) {
|
|
result.status = 'PASS';
|
|
result.detail = reply.slice(0, 200);
|
|
} else {
|
|
result.status = 'UNCONFIRMED';
|
|
result.detail = `reply didn't match heuristic, judge by eye: "${reply.slice(0, 300)}"`;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// --- main ---------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
const configDefaults = loadConfigFile();
|
|
const opts = parseArgs(process.argv.slice(2), configDefaults);
|
|
if (opts.help) {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
|
|
const ids = opts.only ?? Object.keys(HARNESSES);
|
|
const unknownIds = ids.filter((id) => !HARNESSES[id]);
|
|
if (unknownIds.length) {
|
|
console.error(`${TAG} unknown harness id(s): ${unknownIds.join(', ')}`);
|
|
console.error(`${TAG} known ids: ${Object.keys(HARNESSES).join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// --probe-help never touches the network — no --base-url needed for it.
|
|
if (opts.probeHelp) {
|
|
for (const id of ids) {
|
|
const def = HARNESSES[id];
|
|
const resolved = resolveBinary(def.binary);
|
|
console.log(`\n=== ${id} (${def.binary}) ===`);
|
|
if (!resolved) {
|
|
console.log('(not found on PATH or search dirs)');
|
|
continue;
|
|
}
|
|
printHelp(def.binary);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!opts.baseUrl) {
|
|
console.error(`${TAG} --base-url is required (pass it, or set "baseUrl" in ${CONFIG_PATH}).`);
|
|
console.error(`${TAG} See ${CONFIG_EXAMPLE_PATH} for the config file shape.\n`);
|
|
printUsage();
|
|
process.exit(1);
|
|
}
|
|
opts.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
|
|
// --list is a pure dry run: never touch the network, even if --model was given.
|
|
let model = opts.model;
|
|
if (opts.list) {
|
|
model = opts.model ?? 'local-model';
|
|
console.log(`\n=== Step 0 skipped (--list never hits the network; using placeholder "${model}") ===\n`);
|
|
} else {
|
|
model = await baselineCheck(opts.baseUrl, opts.apiKey, opts.authStyle, opts.model, opts.prompt, opts.timeout);
|
|
}
|
|
|
|
console.log(`=== Testing ${ids.length} harness(es) ===`);
|
|
const results = [];
|
|
for (const id of ids) {
|
|
process.stdout.write(`\n--- ${id} ---\n`);
|
|
const result = await runHarness(id, HARNESSES[id], opts, model);
|
|
results.push(result);
|
|
console.log(`${result.status}: ${result.detail}`);
|
|
}
|
|
|
|
console.log('\n=== Summary ===');
|
|
const width = Math.max(...results.map((r) => r.id.length)) + 2;
|
|
for (const r of results) {
|
|
console.log(`${r.id.padEnd(width)} [${r.confidence.padEnd(11)}] ${r.status.padEnd(11)} ${r.detail.slice(0, 100)}`);
|
|
}
|
|
|
|
const hardFail = results.some((r) => r.status === 'FAIL' && r.confidence === 'verified');
|
|
if (hardFail) {
|
|
console.error(`\n${TAG} at least one VERIFIED harness FAILed — that's a real regression, not just an unconfirmed guess.`);
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`${TAG} unexpected error:`, err);
|
|
process.exit(1);
|
|
});
|