feat: Custom Model Endpoint Profiles (local or cloud, all harnesses) - #393
Open
opticon454 wants to merge 4 commits into
Open
feat: Custom Model Endpoint Profiles (local or cloud, all harnesses)#393opticon454 wants to merge 4 commits into
opticon454 wants to merge 4 commits into
Conversation
…ll harnesses) 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
… CLIs end-to-end
Rewrites scripts/test-local-llm-harnesses.mjs -> .ts to read the live CLI
registry (enabledClis()) and call the real production
buildCustomModelInjection()/applyConfigDirInjection() instead of keeping a
second hand-maintained copy of every CLI's env/config shape. A future
registry change (new CLI, edited env var, fixed config template) is now
picked up automatically with zero edits to this script; only the one-shot
invocation flags (info the registry genuinely doesn't model) stay in a
small hand-maintained ONE_SHOT table, and a registry CLI with no entry
there reports UNKNOWN rather than being silently skipped.
Extracted src/custom-model-injection-apply.ts (applyConfigDirInjection/
removeConfigDir) so the production route and this script share one
implementation instead of two.
Full end-to-end run against a real llama-swap server, inside a
codeman/agent:llm-test Docker image with all 9 CLI binaries:
- claude, opencode, pi, grok, omp: PASS, real "hello world" replies
- codex: confirmed FAIL for a real protocol reason, not a bug — it only
speaks the Responses API since Feb 2026, which llama.cpp/llama-swap
don't implement
- gemini: confirmed FAIL, unresolved after real investigation — an
undocumented GATEWAY AuthType gemini-cli selects once
GOOGLE_GEMINI_BASE_URL is set rejects every auth-key format/override
tried
- deepseek: reaches the server (env vars are read) but gets a consistent
HTTP_404; root cause not identified, documented as best-effort/unknown
- antigravity: SKIP, no known mechanism (unchanged)
Two real bugs found and fixed along the way (grok, pi/omp registry
entries in stock.ts): grok's original recipe (env vars) was flat-out
wrong, not just unverified — the real mechanism is a config.toml
[model.<name>] block redirected via GROK_HOME. pi/omp's PI_CONFIG_DIR
does nothing for either (grepped pi's entire bundled source — the string
appears nowhere); the real redirect is the child process's own HOME, and
both need `models` as an array of {id} objects, not an object keyed by
id (silently loaded zero models otherwise).
deployment_plan.md, PR.md, docs/custom-model-endpoints.md, and CLAUDE.md
updated with the final confidence table reflecting all of the above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HqNWfmtBU2KN29SvSVWB3
Contributor
Author
|
I'm currently away ATM, I'll rejig this more next week when I'm back. But I'm extremely keen to know your thoughts/comments on how you'd like this to work |
opticon454
marked this pull request as ready for review
September 8, 2026 09:50
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HqNWfmtBU2KN29SvSVWB3
CI caught a real regression: DEEPSEEK_API_KEY was added to deepseek's privilegedEnvKeys alongside DEEPSEEK_BASE_URL on the theory that "the pair travels together," but that contradicts the documented and tested design (clampEnvOverridesForOwner()'s own docstring in session-routes.ts) — a non-granted owner supplying their OWN DeepSeek key removes privilege rather than granting it, since the exfiltration vector is the BASE URL (which redirects the server's own forwarded key to a foreign host), not the key itself. Removed it from the list; test/deepseek-mode.test.ts's existing two clamp tests now pass again. Also swapped that test's "unrelated override" example off CODEX_HOME, which the earlier commit in this same PR legitimately made privileged (closing a real pre-existing gap, documented in PR.md) — so it stopped being a valid "unrelated" example the moment that fix landed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HqNWfmtBU2KN29SvSVWB3
Ark0N
pushed a commit
that referenced
this pull request
Sep 8, 2026
Claude Code answers an exhausted model budget INSIDE the turn ("You've
reached your Fable limit. Run /usage-credits to continue or switch models
with /model.") and then sits there with nothing to write. The reviewer never
produces a report, so `runTurn` waited out its full 40-minute deadline and
reported a bare "timed out after 40 min without a report", which reads as a
hung reviewer rather than an account that needs attention.
Measured on 2026-09-08: #388, #393, #394 and #377 each lost 40 minutes this
way, and because every attempt counted, all four reached MAX_AUTO_RETRIES and
would NOT have been picked up again once the budget returned. One spent
afternoon quietly took the whole queue out of service.
`findModelLimitNotice()` reads the notice off the pane and `runTurn` returns
a new `limit` outcome instead of waiting. It is consulted in exactly two
places, both of which mean "the turn produced nothing": on a stop where
`isDone()` is still false, and on each timed-out wait slice. A review that
merely discusses usage limits in its own findings therefore cannot be
mistaken for one that hit the wall, and the pattern matches neither the model
name nor a straight apostrophe, since the pane renders a typographic one and
every model prints the same sentence.
A spent budget is an account condition, not a bad PR, so it no longer spends
the per-head retry budget: the queue resumes by itself when the budget does.
Telegram now names the cause and the file to change.
Tests use the pane captured verbatim off the run that lost the 40 minutes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 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:
purpose-built on-prem boxes like NVIDIA DGX Spark, AMD Strix Halo
(Ryzen AI Max) mini-PCs, or the Qwen5090
setup above.
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 toolbarpicker 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
(or vLLM/LiteLLM/Ollama serving several loaded/loadable models behind one
/v1/modelslist) already advertises every model it can hot-swap to, sothe 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.How
src/config/cli-registry/{types,schema,stock}.ts— newcapabilities.customModelInjectionfield per CLI entry, one of fourkinds:
env(Claude, Gemini, Grok, DeepSeek),configContentEnv(opencode, reusing its existing
OPENCODE_CONFIG_CONTENTmechanism),configDir(Codex/Pi/OMP — writes an isolated config file, never touchesthe 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
configDirfiles 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) andGET/POST/PUT/DELETE /api/model-endpoints+POST /:id/discover-models, admin-gated inmulti-user mode, SSRF-guarded via the same
isBlockedWebviewUrl()checkweb tabs use.
src/web/schemas.ts—customModelEndpointsEnabled(synced, defaultOFF) + the endpoint payload schema.
scripts/test-local-llm-harnesses.ts— standalone smoke-test scriptthat 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 thecommitted
.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
envOverridesAPI 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
envOverridesfield. This PR adds all ofthem to their CLI's
privilegedEnvKeys(the existing clamp mechanismDEEPSEEK_BASE_URLalready used), closing that gap rather than widening it.CODEX_HOMEandPI_CONFIG_DIRare flagged as extra-sensitive: aredirected config dir can restate approval/sandbox policy or, for Pi,
redirect to a dir Pi will execute
.pi/extensionsTypeScript from.Claude is the deliberate exception:
ANTHROPIC_*is not added toClaude'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.
customModelInjectioncapability shapecustom-model-injection.ts+ 15 unit testscustom-model-hosts.ts,custom-model-routes.ts, discovery + SSRF guard, 7 route testscustomModelEndpointsEnabledflag,
privilegedEnvKeysadditions across 7 CLI entriessession.customModelstate field,session.setCustomModel()/session.restartCli()(a generalized,de-restricted
reattachRemote()reusing the existingrespawn-pane -kprimitive),
POST /api/sessions/:id/custom-modelrestart route. 5 newroute tests; the existing
session.test.ts/session-cleanup.test.tssuites can't run at all on this Windows dev box (no local
tmux—confirmed identical on unmodified
master, not a regression), which isexactly why the container test below matters.
test/fixtures/mock-openai-server.tsand
test/custom-model-injection-contract.test.ts, 10 tests replayingevery CLI's real injected values through an HTTP call shaped the way that
CLI sends it, against an in-process fake server
docs/custom-model-endpoints.md(user guide, HTTP-API-onlyuntil chunk 6 lands) + a CLAUDE.md pointer bullet
Also done outside the chunk list: the standalone
scripts/test-local-llm-harnesses.tssmoke-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), acodeman/agent:llm-testDocker image (all 9 CLI binaries, built fromdocker/agent.Dockerfile), and a completed real end-to-end run of all 9harnesses against a live llama-swap server — see Testing below.
Testing performed so far
npm run typecheck— clean after every chunknpm run lint/npx prettier --check— cleannpm 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
privilegedEnvKeysadditions)scripts/test-local-llm-harnesses.ts(nownpx tsx-run, wasplain
.mjs) to importenabledClis()andbuildCustomModelInjection()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.tsso both placescall exactly one implementation.
(
http://10.10.11.241:8080), insidecodeman/agent:llm-test(all 9 CLIbinaries, built via
docker/agent.Dockerfile), against the smallestavailable model (
qwen3.5-0.8b-ud-q8_k_xl, 1.1GB — picked by parsing theserver'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 registryrather than a hand-maintained harness list. Real findings, not
simulated:
through the real endpoint.
PI_CONFIG_DIRdoesnothing 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.jsonwith no dedicatedoverride. Separately, pi's
modelsfield must be an array of{id}objects, not an object keyed by id (confirmed against pi's ownbundled
docs/models.md) — the object shape silently loaded zeromodels. Also needs an explicit
--model custom/<id>on invocation.wrong, not just unverified — the env-var recipe in this table's first
draft (
GROK_BASE_URL/XAI_API_KEY/GROK_MODEL) produced "Not signedin" against a real binary. Researched xAI's actual docs and corrected
to a
config.tomlwith a[model.<name>]block redirected viaGROK_HOME, with the key riding as anenv_key-named env var — thenconfirmed working end-to-end.
models,HOME-redirect instead ofPI_CONFIG_DIR) plus--model custom/<id>.Unverified against omp's own official docs (none are bundled in the
install), but empirically confirmed working live.
Setting
GOOGLE_GEMINI_BASE_URLmakes gemini-cli internally select anundocumented
AuthType.GATEWAYpath with validation requirements alive run never satisfies (
Invalid auth method selected, regardless ofkey format). Tried and ruled out: a Google-format dummy key,
GOOGLE_GENAI_USE_VERTEXAI=false, aGEMINI_DEFAULT_AUTH_TYPEoverride, and a hand-written
settings.json.--skip-trustis a real,separate fix for a different symptom (an untrusted-folder check
silently overriding
--approval-mode yolo) and is kept, but does nottouch this auth failure. Left as an open, documented gap rather than
claimed as working.
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,
dshmay 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.
(
[model].default) was rejected by a real codex binary ("invalidtype: map, expected a string") — codex wants a top-level
modelstring plus
[model_providers.custom], and the API key rides as anenv_key-named env var, never a literal TOML field. Fixed incustom-model-injection.ts, the standalone script, and both testsuites. Then a second, deeper finding: codex only speaks the
Responses API now (
wire_api = "responses", the only value it acceptssince dropping
"chat"support in Feb 2026) — a real run against thenow-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 protocolincompatibility, not a bug in this PR's code — documented prominently
in
deployment_plan.md's confidence table.Code's async session-title-generation call also uses
ANTHROPIC_DEFAULT_HAIKU_MODELand validates it against Claude's owninternal recognized-model list, printing
[claude-code:unrecognized_model]and, in
-pmode, hanging the whole invocation rather than justwarning.
--settings '{"autoTitle":false}'does NOTstop it (confirmed);
--baredoes — the warning still prints, but thereal prompt now runs and returns the real answer.
--bareis onlysafe 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
POSTissued rightafter a
GETin the same Node process reliably HUNG indefinitelyagainst 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.
Authorization: Bearerandapi-keyauth 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 fromCustomModelAuthStyle(was'bearer' | 'api-key' | 'both', now justthe first two, default
'bearer') — in the schema, the store type, thediscovery route, and the standalone script (
--auth-styleflag 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
still entirely unbuilt; the feature is currently HTTP-API-only (see
docs/custom-model-endpoints.md).Invalid auth method selected, traced to an undocumentedGATEWAYAuthType gemini-cliselects once
GOOGLE_GEMINI_BASE_URLis set) — needs upstreaminvestigation before it can be called supported. Documented in full in
deployment_plan.md's confidence table rather than silently shipped asworking.
consistent
HTTP_404, root cause not identified — documented asbest-effort/unknown, same as its pre-existing lowest confidence tag.
(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.
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 aharmless background warning) is untested and should be checked before
calling claude's chunk 5 support done —
--bareitself must never beapplied to a real interactive session, since it disables hooks Codeman
depends on.
🤖 Generated with Claude Code