Files
Codeman/PR.md
T

297 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# feat: Custom Model Endpoint Profiles (local or cloud, all harnesses)
> **⭐ Shout-out up front:** this feature was partly inspired by — and is a
> great fit for — **[Ark0N/Qwen5090](https://github.com/Ark0N/Qwen5090)**,
> the maintainer's other project: a one-click Windows / one-command Linux
> installer that stands up Qwen3.8-27B locally on an RTX 5090 behind an
> OpenAI-compatible API (vLLM / NInfer / llama.cpp). Once this feature lands,
> pointing Codeman at a Qwen5090 box is just adding one endpoint entry — no
> extra code, no special-casing. Qwen5090 already wires up DeepSeek Harness
> and Claude Code as local coding agents itself, which is basically this
> feature's idea in miniature. 🙂
**Status: open for review.** The backend/CLI-injection side (chunks 1-5,
7-8) is built, tested, and verified end-to-end against a real server;
chunk 6 (frontend toolbar UI) is not yet built, and gemini/deepseek remain
documented, unresolved gaps rather than working paths — see
[Status](#status) below for exactly what's done and what's still open.
---
## What
Adds a settings-gated (default **OFF**) way to point any Codeman-supported
harness — Claude, opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP, or
Antigravity — at a **custom OpenAI-compatible endpoint** instead of its
native cloud backend, for a given session. "Custom endpoint" covers both:
- **Local hardware**: llama.cpp, Ollama, vLLM, a home GPU rig, or
purpose-built on-prem boxes like NVIDIA DGX Spark, AMD Strix Halo
(Ryzen AI Max) mini-PCs, or the [Qwen5090](https://github.com/Ark0N/Qwen5090)
setup above.
- **Cloud**: Azure AI Foundry's OpenAI-compatible endpoint, OpenRouter, a
company's self-hosted gateway.
The user adds an endpoint by base URL (+ optional API key), Codeman
discovers its available models via `GET /v1/models`, and a new toolbar
picker lets them apply one of those models to a session — which then
restarts that session's CLI process pointed at the endpoint.
**Why discovery instead of asking the user to type a model name:** it turns
"go read your inference server's docs to find the exact model identifier it
expects" into "pick from a list Codeman already fetched" — one less place
for a user to get a name/casing wrong and have a harness fail with an
opaque "model not found." It also means this feature works unmodified
against **multi-model hosting setups**, not just a single-model server: a
gateway like **[llama-swap](https://github.com/mostlygeek/llama-swap)**
(or vLLM/LiteLLM/Ollama serving several loaded/loadable models behind one
`/v1/models` list) already advertises every model it can hot-swap to, so
the toolbar picker becomes a live menu of everything that endpoint can
serve — no per-model endpoint entries, no separate configuration step,
just "add the gateway once, everything behind it shows up."
## Why
The maintainer pays for a Claude Code subscription but also runs a capable
local model. Every harness Codeman drives already _has_ its own mechanism
for pointing at a custom endpoint (env vars for Claude, a JSON config blob
for opencode, a TOML file for Codex, etc.) — Codeman just never exposed a
UI for it. Full motivation, the per-CLI recipe table, and the on-prem
hardware use cases are written up in **[`deployment_plan.md`](deployment_plan.md)**.
## How
- **`src/config/cli-registry/{types,schema,stock}.ts`** — new
`capabilities.customModelInjection` field per CLI entry, one of four
kinds: `env` (Claude, Gemini, Grok, DeepSeek), `configContentEnv`
(opencode, reusing its existing `OPENCODE_CONFIG_CONTENT` mechanism),
`configDir` (Codex/Pi/OMP — writes an isolated config file, never touches
the user's real one), or `unsupported` (Antigravity — no known mechanism,
toolbar entry stays disabled). Declared data-driven per the repo's
existing "never branch on CLI id" rule.
- **`src/custom-model-injection.ts`** — pure function turning
`(CliEntry, endpoint, modelId)` into the real env vars / config content.
No IO; a caller writes `configDir` files to disk.
- **`src/custom-model-hosts.ts`** + **`src/web/routes/custom-model-routes.ts`** —
read/write-array endpoint store (`~/.codeman/custom-model-hosts.json`,
same shape as `remote-hosts.ts`) and `GET/POST/PUT/DELETE
/api/model-endpoints` + `POST /:id/discover-models`, admin-gated in
multi-user mode, SSRF-guarded via the same `isBlockedWebviewUrl()` check
web tabs use.
- **`src/web/schemas.ts`** — `customModelEndpointsEnabled` (synced, default
OFF) + the endpoint payload schema.
- **`scripts/test-local-llm-harnesses.ts`** — standalone smoke-test script
that spawns each real CLI binary one-shot against a real endpoint and
checks it can answer "hello world," independent of the web UI. Reads
defaults from a gitignored `scripts/local-llm-test.config.json` (see the
committed `.example.json`) so real IPs/keys never land in git.
### A finding along the way: multi-user privilege hardening
Building this surfaced that several env vars (`GOOGLE_GEMINI_BASE_URL`,
`GROK_BASE_URL`, `CODEX_HOME`, `PI_CONFIG_DIR`, `OPENCODE_CONFIG_CONTENT`,
etc.) were **already** reachable via the generic `envOverrides` API today,
pre-existing this PR, because Codeman's env allowlist is prefix-based and
global. A non-granted multi-user owner could already redirect a session's
endpoint/credentials via a plain `envOverrides` field. This PR adds all of
them to their CLI's `privilegedEnvKeys` (the existing clamp mechanism
`DEEPSEEK_BASE_URL` already used), closing that gap rather than widening it.
`CODEX_HOME` and `PI_CONFIG_DIR` are flagged as extra-sensitive: a
redirected config dir can restate approval/sandbox policy or, for Pi,
redirect to a dir Pi will execute `.pi/extensions` TypeScript from.
Claude is the deliberate exception: `ANTHROPIC_*` is **not** added to
Claude's allowed env prefixes at all, so it stays reachable only through
the dedicated, admin-configured, SSRF-guarded custom-model route — never
through a plain client-supplied `envOverrides`.
## Status
Built in reviewable chunks; ✅ = done and verified (typecheck + lint +
format + tests green), ⬜ = not started.
- ✅ **1. Registry types** — `customModelInjection` capability shape
- ✅ **2. Pure injection builder** — `custom-model-injection.ts` + 15 unit tests
- ✅ **3. Endpoint store + CRUD routes** — `custom-model-hosts.ts`,
`custom-model-routes.ts`, discovery + SSRF guard, 7 route tests
- ✅ **4. Settings + security hardening** — `customModelEndpointsEnabled`
flag, `privilegedEnvKeys` additions across 7 CLI entries
- ✅ **5. Session integration** — `session.customModel` state field,
`session.setCustomModel()`/`session.restartCli()` (a generalized,
de-restricted `reattachRemote()` reusing the existing `respawn-pane -k`
primitive), `POST /api/sessions/:id/custom-model` restart route. 5 new
route tests; the existing `session.test.ts`/`session-cleanup.test.ts`
suites can't run at all on this Windows dev box (no local `tmux` —
confirmed identical on unmodified `master`, not a regression), which is
exactly why the container test below matters.
- ⬜ **6. Frontend** — settings group, toolbar picker, tab badge
- ✅ **7. Mock-server contract tests** — `test/fixtures/mock-openai-server.ts`
and `test/custom-model-injection-contract.test.ts`, 10 tests replaying
every CLI's real injected values through an HTTP call shaped the way that
CLI sends it, against an in-process fake server
- ✅ **8. Docs** — `docs/custom-model-endpoints.md` (user guide, HTTP-API-only
until chunk 6 lands) + a CLAUDE.md pointer bullet
Also done outside the chunk list: the standalone
`scripts/test-local-llm-harnesses.ts` smoke-test script (now dynamic —
reads the live CLI registry rather than a hand-maintained harness list) +
its gitignored config file, the on-prem-hardware use-case writeup in
`deployment_plan.md` (DGX Spark, Strix Halo, Qwen5090), a
`codeman/agent:llm-test` Docker image (all 9 CLI binaries, built from
`docker/agent.Dockerfile`), and a **completed real end-to-end run of all 9
harnesses** against a live llama-swap server — see Testing below.
## Testing performed so far
- `npm run typecheck` — clean after every chunk
- `npm run lint` / `npx prettier --check` — clean
- `npm test -- test/cli-registry test/custom-model-injection.test.ts
test/custom-model-injection-contract.test.ts test/routes/custom-model-routes.test.ts
test/routes/session-custom-model.test.ts test/routes/external-cli-bypass-clamp.test.ts` —
245+ tests passing, including the existing multi-user clamp suite (no
regressions from the `privilegedEnvKeys` additions)
- Refactored `scripts/test-local-llm-harnesses.ts` (now `npx tsx`-run, was
plain `.mjs`) to import `enabledClis()` and `buildCustomModelInjection()`
directly from source instead of keeping a second hand-maintained copy of
every CLI's env/config shape — a registry change now needs zero edits to
the test script. Extracted the config-dir-write logic shared with the
production route into `custom-model-injection-apply.ts` so both places
call exactly one implementation.
- **Real end-to-end run against the maintainer's live llama-swap server**
(`http://10.10.11.241:8080`), inside `codeman/agent:llm-test` (all 9 CLI
binaries, built via `docker/agent.Dockerfile`), against the smallest
available model (`qwen3.5-0.8b-ud-q8_k_xl`, 1.1GB — picked by parsing the
server's own reported model sizes). **Full 9-harness result: claude,
opencode, pi, grok, omp all PASS with a genuine "hello world" reply
round-tripped through the real endpoint; codex FAILs for a confirmed
protocol reason (not a bug — see below); gemini and deepseek reach the
server but fail for reasons not yet root-caused; antigravity SKIPs (no
known mechanism); all correctly classified by the now-dynamic
`scripts/test-local-llm-harnesses.ts`, which reads the live CLI registry
rather than a hand-maintained harness list.** Real findings, not
simulated:
- **opencode: PASS.** Genuinely round-tripped a "hello world" reply
through the real endpoint.
- **pi: PASS, after two real bugs found and fixed.** `PI_CONFIG_DIR` does
nothing for pi at all (grepped pi's entire bundled JS source — the
string appears nowhere); the real redirect is the child process's own
`HOME`, since pi hardcodes `~/.pi/agent/models.json` with no dedicated
override. Separately, pi's `models` field must be an **array** of
`{id}` objects, not an object keyed by id (confirmed against pi's own
bundled `docs/models.md`) — the object shape silently loaded zero
models. Also needs an explicit `--model custom/<id>` on invocation.
- **grok: PASS, after the original recipe turned out to be flat-out
wrong**, not just unverified — the env-var recipe in this table's first
draft (`GROK_BASE_URL`/`XAI_API_KEY`/`GROK_MODEL`) produced "Not signed
in" against a real binary. Researched xAI's actual docs and corrected
to a `config.toml` with a `[model.<name>]` block redirected via
`GROK_HOME`, with the key riding as an `env_key`-named env var — then
confirmed working end-to-end.
- **omp: PASS**, after the same two fixes as pi (array-shaped `models`,
`HOME`-redirect instead of `PI_CONFIG_DIR`) plus `--model custom/<id>`.
Unverified against omp's own official docs (none are bundled in the
install), but empirically confirmed working live.
- **gemini: confirmed broken, unresolved after real investigation.**
Setting `GOOGLE_GEMINI_BASE_URL` makes gemini-cli internally select an
undocumented `AuthType.GATEWAY` path with validation requirements a
live run never satisfies (`Invalid auth method selected`, regardless of
key format). Tried and ruled out: a Google-format dummy key,
`GOOGLE_GENAI_USE_VERTEXAI=false`, a `GEMINI_DEFAULT_AUTH_TYPE`
override, and a hand-written `settings.json`. `--skip-trust` is a real,
separate fix for a different symptom (an untrusted-folder check
silently overriding `--approval-mode yolo`) and is kept, but does not
touch this auth failure. Left as an open, documented gap rather than
claimed as working.
- **deepseek: confirmed reaching the server, still failing, unresolved.**
A real run returns `dsh: HTTP_404: DeepSeek API error (HTTP 404)`
consistently — the env vars are read (the request reaches the network
rather than failing locally), but the root cause was not identified in
the time available. By analogy with codex's Responses-API gap, `dsh`
may expect DeepSeek's own API response shape rather than a generic
OpenAI-compatible one, but this was not confirmed by reading dsh's own
bundled source the way the pi/grok questions were resolved. Documented
as best-effort/unknown, matching its pre-existing lowest confidence tag.
- **codex: real bug found and fixed.** The recipe's TOML shape
(`[model].default`) was rejected by a real codex binary ("invalid
type: map, expected a string") — codex wants a top-level `model`
string plus `[model_providers.custom]`, and the API key rides as an
`env_key`-named env var, never a literal TOML field. Fixed in
`custom-model-injection.ts`, the standalone script, and both test
suites. **Then a second, deeper finding**: codex only speaks the
Responses API now (`wire_api = "responses"`, the only value it accepts
since dropping `"chat"` support in Feb 2026) — a real run against the
now-correctly-shaped config still failed (`Reconnecting...` × 5, then
"high demand" errors) because llama-swap doesn't implement
`/v1/responses`. This is a genuine, currently-unresolved protocol
incompatibility, not a bug in this PR's code — documented prominently
in `deployment_plan.md`'s confidence table.
- **claude: PASS, after two real bugs found and fixed.** (1) 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]`
and, in `-p` mode, hanging the whole invocation rather than just
warning. `--settings '{"autoTitle":false}'` does NOT
stop it (confirmed); `--bare` does — the warning still prints, but the
real prompt now runs and returns the real answer. ⚠️ `--bare` is only
safe for this standalone one-shot test script — it also disables hooks,
LSP, plugin sync, and CLAUDE.md auto-discovery, so it must NEVER be
applied to a real interactive Codeman session (which depends on hooks
for idle detection, trust-dialog auto-accept, etc.). Whether an
INTERACTIVE session with a custom model hits the same hang (vs. just a
background warning, which would be harmless) is untested — flagged as
an open item for chunk 5/6, not assumed either way. (2) A separate,
genuinely nasty bug in the test script itself: a `POST` issued right
after a `GET` in the same Node process reliably HUNG indefinitely
against this real server (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) — 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. Fixed with a 2s
pause between the script's discovery GET and its baseline POST. This
is a tooling-correctness fix (affects the script's own baseline check),
not a claim about how any CLI's own HTTP client behaves.
- **Also found and fixed**: an earlier design sent BOTH `Authorization:
Bearer` and `api-key` auth header conventions on every discovery/
baseline request, on the theory that an unused header is harmless.
Live-tested against the real server, sending both reliably HUNG the
request (reproduced 3×: either header alone ~500-600ms, both together
no response inside 15s). Removed the `'both'` option entirely from
`CustomModelAuthStyle` (was `'bearer' | 'api-key' | 'both'`, now just
the first two, default `'bearer'`) — in the schema, the store type, the
discovery route, and the standalone script (`--auth-style` flag added).
This was a real, currently-shipped-in-this-PR bug fixed before it ever
reached anyone, not a pre-existing one.
## Not yet done / open questions for review
- **Chunk 6 (frontend)** — settings group, toolbar picker, tab badge — is
still entirely unbuilt; the feature is currently HTTP-API-only (see
`docs/custom-model-endpoints.md`).
- **Gemini is confirmed broken end-to-end** (`Invalid auth method
selected`, traced to an undocumented `GATEWAY` AuthType gemini-cli
selects once `GOOGLE_GEMINI_BASE_URL` is set) — needs upstream
investigation before it can be called supported. Documented in full in
`deployment_plan.md`'s confidence table rather than silently shipped as
working.
- **DeepSeek is confirmed reaching the server but failing** with a
consistent `HTTP_404`, root cause not identified — documented as
best-effort/unknown, same as its pre-existing lowest confidence tag.
- **Codex cannot work against a plain OpenAI-Chat-Completions server**
(llama.cpp/llama-swap/Ollama/vLLM's default) — it only speaks the
Responses API since Feb 2026. This is an external protocol
incompatibility, not something this PR can fix; codex support is real
only against a Responses-API-compatible endpoint.
- Antigravity has no known mechanism at all and stays unsupported.
- Chunk 5's session-restart design needs a careful look before merge:
switching a session's endpoint restarts its CLI process in place
(confirmed acceptable with the maintainer — these harnesses read
endpoint config at process start, not per-turn). Whether an INTERACTIVE
claude session with a custom model hits the same async-title-generation
hang the standalone script worked around with `--bare` (vs. just a
harmless background warning) is untested and should be checked before
calling claude's chunk 5 support done — `--bare` itself must never be
applied to a real interactive session, since it disables hooks Codeman
depends on.
🤖 Generated with [Claude Code](https://claude.com/claude-code)