Files
Codeman/test/custom-model-injection.test.ts
T
Codeman maintainer 942bf37e48 fix(custom-model): unset injected env on clear, resume on restart, select the model for pi/omp/grok
Custom Model Endpoint Profiles (#393) let a session point its CLI at a
custom OpenAI-compatible endpoint by injecting env vars or a config file
and restarting the CLI in place. Review of the apply path found four
things, two of them destructive. This lands all four plus the smaller
items from the same review.

1. Clearing a selection did not clear it. The injected vars reach the CLI
   via `tmux setenv`, which persists at the tmux-session level and is
   inherited by `respawn-pane` (measured: `setenv FOO bar` survived two
   successive `respawn-pane -k`), so deleting the keys from the session's
   envOverrides relaunched the CLI still pointed at the old endpoint, and
   for the configDir kinds at a HOME/CODEX_HOME/GROK_HOME that had just
   been deleted. `Session.setCustomModel()` now reports the removed keys,
   queues them (`_pendingEnvUnsets`), and `RespawnPaneOptions.unsetEnvKeys`
   carries them into `applyEnvOverrides()`, which `setenv -u`s them before
   re-applying the live overrides, on the same path that already unsets
   the legacy CLAUDE_CODE_EFFORT_LEVEL. Verified on a private tmux socket
   that `setenv -u HOME` hands the next respawn the global HOME back.

2. Applying a model to a local claude session killed the pane. The
   relaunch was `claude --session-id <id>` and Claude refuses an id that
   already has a transcript, and unlike the dead-pane respawn this one
   kills a working pane first. `restartCli()` now pins the live
   conversation id as the resume id for that respawn when the CLI's launch
   declares a `fallback` chain, which renders the same
   `--resume <id> || --session-id <id>` shape the docker and remote pane
   commands use. Gated on the registry shape, not the CLI id: an entry
   whose resume id is minted by the CLI itself never declares that chain.

3. pi, omp and grok wrote their config file and then launched without the
   `--model` that selects it, so the file was ignored. The registry entry
   now declares `customModelInjection.launchModel` (`custom/{modelId}` for
   pi and omp, grok's `[model.codeman-custom]` block name), the builder
   renders it, and `_withCustomModelLaunchModel()` applies it onto the
   respawn options through `legacyConfigField`, leaving the stored
   <Mode>Config untouched so a clear falls back to the user's own model.
   A model id the CLI's `model` token pattern cannot carry is refused
   with a 400 rather than silently dropped by the argv engine.

4. Remote (SSH) and Docker sessions reported `restarted: true` and changed
   nothing: their `restartCli()` reattaches the durable tmux rather than
   relaunching the agent, and the env lands on the local pane. Both are
   refused with a 400 until those paths are plumbed.

Smaller items from the same review:

- The selection survives a Codeman restart as the disk-only `__customModel`
  bookkeeping (endpoint, model, injected key NAMES, config dir, launch
  model; never the values, which carry the API key). Recovery re-derives
  the values from the endpoint store through the same apply path the route
  uses and keeps the bookkeeping even when the endpoint is gone, so a
  later clear still has keys to unset.
- Discovery goes through `webviewFetch()`, so the RESOLVED address is
  judged by the same egress guard the web-tab proxy uses, and `baseUrl`
  reuses `webviewUrlSchema` (http(s) only, no embedded credentials,
  link-local and cloud-metadata addresses refused). undici's `fetch failed`
  wrapper is unwrapped so the user sees the ECONNREFUSED underneath.
- `custom-model-hosts.json` is written 0600 via tmp+rename, the per-session
  config dir 0700/0600 (pi and omp embed the key literally), and that dir
  is removed with the session.
- `PR.md` is gone from the repo root and the design doc moved to
  `docs/custom-model-endpoints-plan.md` with the LAN address and the
  personal name scrubbed; every reference follows. The guide's `authStyle`
  text matches the shipped schema (`bearer | api-key`, default `bearer`)
  and says that `customModelEndpointsEnabled` is read by nothing until
  the picker lands.
- `config/tsconfig.scripts.json` typechecks `scripts/test-local-llm-harnesses.ts`
  (four real type errors fixed). It is not yet wired into `npm run typecheck`
  because that line differs on master; adding `&& tsc -p config/tsconfig.scripts.json`
  there is the one-line follow-up.

Tests: `test/session-custom-model-restart.test.ts` drives a real Session and
fails on the unfixed code for items 1 to 3; the route suite covers item 4
and the pattern refusal; `test/tmux-manager.test.ts` pins that the unsets
run before the overrides and that a shell-metachar key never reaches tmux.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:46:28 +02:00

189 lines
8.8 KiB
TypeScript

/**
* @fileoverview Tests for the Custom Model Endpoint Profiles pure builder.
* Uses the real CLI registry entries (getCli) rather than hand-rolled
* fixtures, so a change to a real entry's customModelInjection declaration
* is exercised here automatically instead of silently diverging.
*
* Port: N/A (no server needed)
*/
import { describe, it, expect } from 'vitest';
import { getCli } from '../src/config/cli-registry/index.js';
import {
buildCustomModelInjection,
withV1Suffix,
GROK_CUSTOM_MODEL_NAME,
type CustomModelEndpoint,
} from '../src/custom-model-injection.js';
const endpoint: CustomModelEndpoint = {
id: 'ep1',
label: 'llama.cpp box',
baseUrl: 'http://192.168.1.50:8080',
apiKey: 'my-key',
};
function entryOrThrow(id: string) {
const entry = getCli(id);
if (!entry) throw new Error(`missing CLI registry entry: ${id}`);
return entry;
}
describe('withV1Suffix', () => {
it('appends /v1 when missing', () => {
expect(withV1Suffix('http://host:8080')).toBe('http://host:8080/v1');
});
it('is idempotent when already present', () => {
expect(withV1Suffix('http://host:8080/v1')).toBe('http://host:8080/v1');
expect(withV1Suffix('http://host:8080/v1/')).toBe('http://host:8080/v1');
});
it('strips a trailing slash with no /v1', () => {
expect(withV1Suffix('http://host:8080/')).toBe('http://host:8080/v1');
});
});
describe('buildCustomModelInjection', () => {
it('claude: env kind sets base URL, api key, and all three tier model vars', () => {
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3');
expect(result.kind).toBe('env');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides).toEqual({
ANTHROPIC_BASE_URL: 'http://192.168.1.50:8080',
ANTHROPIC_API_KEY: 'my-key',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3',
});
});
it('claude: falls back to a dummy key when the endpoint has none', () => {
const result = buildCustomModelInjection(entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides.ANTHROPIC_API_KEY).toBe('local-dummy-key');
});
it('opencode: configContentEnv carries a JSON blob in OPENCODE_CONFIG_CONTENT', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3');
expect(result.kind).toBe('env');
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
expect(parsed.model).toBe('custom/qwen3');
expect(parsed.provider.custom.options.baseURL).toBe('http://192.168.1.50:8080/v1');
expect(parsed.provider.custom.options.apiKey).toBe('my-key');
expect(parsed.provider.custom.models.qwen3).toEqual({});
});
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
expect(result.kind).toBe('configDir');
if (result.kind !== 'configDir') throw new Error('unreachable');
expect(result.dirEnvVar).toBe('CODEX_HOME');
expect(result.files).toHaveLength(1);
expect(result.files[0].relPath).toBe('config.toml');
expect(result.files[0].content).toContain('model = "qwen3"');
expect(result.files[0].content).toContain('base_url = "http://192.168.1.50:8080/v1"');
expect(result.files[0].content).toContain('wire_api = "responses"');
expect(result.files[0].content).not.toContain('api_key ='); // never a literal TOML field
expect(result.files[0].content).toContain('env_key = "CODEMAN_CUSTOM_MODEL_API_KEY"');
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
});
it('codex: escapes a quote in the model id so it cannot break out of the TOML string', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'weird"model');
if (result.kind !== 'configDir') throw new Error('unreachable');
expect(result.files[0].content).toContain('model = "weird\\"model"');
});
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
expect(result.dirEnvVar).toBe('HOME');
expect(result.files[0].relPath).toBe('.pi/agent/models.json');
const parsed = JSON.parse(result.files[0].content);
expect(parsed.providers.custom.baseUrl).toBe('http://192.168.1.50:8080/v1');
expect(parsed.providers.custom.authHeader).toBe(true);
expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id
});
it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => {
const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
expect(result.dirEnvVar).toBe('HOME');
expect(result.files[0].relPath).toBe('.omp/agent/models.yml');
expect(result.files[0].content).toContain('baseUrl: "http://192.168.1.50:8080/v1"');
expect(result.files[0].content).toContain('authHeader: true');
expect(result.files[0].content).toContain('- id: "qwen3"');
});
it('gemini: env kind sets GOOGLE_GEMINI_BASE_URL/GEMINI_API_KEY/GEMINI_MODEL', () => {
const result = buildCustomModelInjection(entryOrThrow('gemini'), endpoint, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides).toEqual({
GOOGLE_GEMINI_BASE_URL: 'http://192.168.1.50:8080',
GEMINI_API_KEY: 'my-key',
GEMINI_MODEL: 'qwen3',
});
});
it('grok: configDir writes a config.toml [model.<name>] block, key rides as extraEnv (XAI_API_KEY)', () => {
const result = buildCustomModelInjection(entryOrThrow('grok'), endpoint, 'qwen3');
expect(result.kind).toBe('configDir');
if (result.kind !== 'configDir') throw new Error('unreachable');
expect(result.dirEnvVar).toBe('GROK_HOME');
expect(result.files).toHaveLength(1);
expect(result.files[0].relPath).toBe('config.toml');
expect(result.files[0].content).toContain('model = "qwen3"');
expect(result.files[0].content).toContain('base_url = "http://192.168.1.50:8080/v1"');
expect(result.files[0].content).toContain('api_backend = "chat_completions"');
expect(result.files[0].content).toContain('env_key = "XAI_API_KEY"');
expect(result.files[0].content).not.toContain('api_key ='); // never a literal TOML field
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
});
it('deepseek: env kind sets base URL/key only, no model var', () => {
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides).toEqual({
DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080',
DEEPSEEK_API_KEY: 'my-key',
});
});
it('antigravity: unsupported', () => {
const result = buildCustomModelInjection(entryOrThrow('antigravity'), endpoint, 'qwen3');
expect(result).toEqual({ kind: 'unsupported' });
});
it('shell: unsupported', () => {
const result = buildCustomModelInjection(entryOrThrow('shell'), endpoint, 'qwen3');
expect(result).toEqual({ kind: 'unsupported' });
});
});
describe('launchModel (the model launch param that selects the injected provider)', () => {
it('pi and omp get --model custom/<modelId>: the config file alone leaves them on their default provider', () => {
for (const id of ['pi', 'omp']) {
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3.5-0.8b');
if (result.kind !== 'configDir') throw new Error('unreachable');
expect(result.launchModel, id).toBe('custom/qwen3.5-0.8b');
}
});
it('grok gets the [model.<name>] block name, pinned to the constant the config template writes', () => {
const result = buildCustomModelInjection(entryOrThrow('grok'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
expect(result.launchModel).toBe(GROK_CUSTOM_MODEL_NAME);
expect(result.files[0].content).toContain(`[model.${GROK_CUSTOM_MODEL_NAME}]`);
});
it('CLIs whose config selects the model on its own declare none', () => {
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
if (result.kind === 'unsupported') throw new Error('unreachable');
expect(result.launchModel, id).toBeUndefined();
}
});
});