From 25fae9ad101defd867e84a968b852ee91706059c Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:12:31 +0800 Subject: [PATCH 01/22] feat(custom-model): generate Run-menu entries from saved endpoint profiles Follow-up to #393, picking up the work Ark0N invited in his merge comment: "generate those entries from the saved profiles rather than a fixed duplicate per harness, and put it in a follow-up PR so this one stays the backend... The Run-menu picker is yours if you want it." Adds the frontend surface the backend has been waiting on: - Run menu: a "Custom Endpoints" section lists one entry per (harness that supports customModelInjection, saved endpoint) pair, e.g. "Claude Code (llama.cpp)". The harness list comes from window.__codemanCustomModelClis, injected at page render straight off the CLI registry's own capabilities (never a hardcoded id list in the frontend), so a CLI whose injection recipe lands later appears with no frontend change. Picking an entry runs that harness's own existing run*() function unmodified (case creation, env overrides, everything, forced to a single instance) and then applies the endpoint's default model to the session it creates via the existing POST /api/sessions/:id/custom-model route. Entries are hidden for a remote/docker active case, since that route already refuses both. - Settings: App Settings -> Models gets a "Custom model endpoints" group wiring up the customModelEndpointsEnabled toggle (declared since #393, read by nothing until now) plus CRUD against the existing /api/model-endpoints routes: list, add/edit (inline form), delete, discover models. - Backend: CustomModelHost gains an optional defaultModelId, the model the picker applies with no further choice per endpoint (one generated menu entry per CLI+endpoint pair, not per CLI+endpoint+model). The route refuses a value that isn't one of the endpoint's own discovered models, and a fresh discovery drops a default that no longer appears rather than carrying an invalid one forward. Docs: docs/custom-model-endpoints.md describes the new picker and settings panel; CLAUDE.md's Custom Model Endpoint Profiles entry drops the "backend-only" status note and documents the picker's generation mechanism. Tests: four new route tests cover defaultModelId validation, acceptance, and the drop/keep behaviour across a re-discovery; a new render-index-html test pins the __codemanCustomModelClis injection (present, agent CLIs supporting the capability, antigravity and shell excluded) and its solo-window skip. No browser test was added for the Run-menu picker itself or the settings CRUD panel (this box has no tmux, so the live server used by test:browser/test:mobile could not be exercised here) -- worth a Playwright pass before merge, same as any other frontend PR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG --- CLAUDE.md | 2 +- docs/custom-model-endpoints.md | 54 ++++++-- src/custom-model-hosts.ts | 10 ++ src/web/public/index.html | 58 +++++++++ src/web/public/session-ui.js | 119 +++++++++++++++++ src/web/public/settings-ui.js | 164 ++++++++++++++++++++++++ src/web/public/styles.css | 20 +++ src/web/routes/custom-model-routes.ts | 26 +++- src/web/schemas.ts | 6 + src/web/server.ts | 14 +- test/render-index-html.test.ts | 23 ++++ test/routes/custom-model-routes.test.ts | 101 +++++++++++++++ 12 files changed, 583 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ef118e5..2583c903 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,7 +227,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab. ⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: **exactly one** server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), **restarted when the browser authority changes** (`--trusted-host` fences dsh's `/api` against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), **killed on shutdown** (`stopDeepSeekWeb()` in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and **failures returned to the caller**, since with no tab there is nowhere for a stack trace to land. ⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own `dsh web`, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success. ⚠️ Both `POST` and `DELETE` sit at the **same privilege bar as the profile installer** (`canUsernameRunPrivilegedCommands`) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs. -**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; backend + HTTP API only until the Run-menu picker lands, and the setting is read by nothing yet): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET /v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ Applying a selection **restarts the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` kills a WORKING pane, so a CLI whose launch declares a `fallback` chain (claude) gets the live conversation id pinned as `resumeSessionId` for that one respawn: `--session-id ` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume || --session-id ` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. **Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct but codex only speaks the Responses API since Feb 2026, which llama.cpp/llama-swap don't implement — a confirmed protocol gap, not a bug; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek reaches the server but gets a consistent `HTTP_404` (root cause not identified); antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. +**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET /v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ Applying a selection **restarts the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` kills a WORKING pane, so a CLI whose launch declares a `fallback` chain (claude) gets the live conversation id pinned as `resumeSessionId` for that one respawn: `--session-id ` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume || --session-id ` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. **Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct but codex only speaks the Responses API since Feb 2026, which llama.cpp/llama-swap don't implement — a confirmed protocol gap, not a bug; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek reaches the server but gets a consistent `HTTP_404` (root cause not identified); antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. ⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces, extended to the one frontend surface that needs to know which CLIs support this. One entry per (capable CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)"; picking one runs that CLI's own existing `run*()` function unmodified (case creation, env overrides, the works — forced to a single instance) and then calls `POST /api/sessions/:id/custom-model` on the session it selects, reusing the fact every `run*()` ends by selecting its new session rather than a parallel create path. `CustomModelHost.defaultModelId` is what the picker applies with no further choice — settings-ui.js's Edit form is a select populated from that endpoint's own discovered `models`, the route rejects a value that isn't a member, and re-discovery drops a stale one rather than carrying it forward invalid. Entries are hidden for a remote/docker active case (the apply route refuses both) and for an endpoint with no discovered models at all (nothing to default to). **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index 975965ef..24acc171 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -11,20 +11,21 @@ company gateway) — anything answering `GET /v1/models` and recipe confidence table, and security reasoning: [`custom-model-endpoints-plan.md`](custom-model-endpoints-plan.md). -> **Status**: backend is implemented and tested (registry capability, the -> injection engine, the endpoint store + discovery route, the session -> restart route). The toolbar picker / settings UI described below as the -> intended surface is **not yet built** — until it lands, use the HTTP API -> directly (examples below). Antigravity has no known custom-endpoint -> mechanism and is not supported. +> **Status**: fully wired end to end — registry capability, the injection +> engine, the endpoint store + discovery route, the session restart route, +> a settings-panel CRUD surface, and the Run-menu picker described below. +> Antigravity has no known custom-endpoint mechanism and is not supported. +> The HTTP API (examples below) still works directly and is what the picker +> itself calls under the hood. ## Turning it on -App Settings → Agents & CLIs → **Custom Model Endpoints** (synced setting -`customModelEndpointsEnabled`, default **OFF**). Until the toolbar picker -lands, nothing reads this setting: the HTTP routes below work whether it is -on or off, and it exists now only so the picker has a switch to hang off -when it ships. The API equivalent: +App Settings → Models → **Custom model endpoints** (synced setting +`customModelEndpointsEnabled`, default **OFF**). Turning it on does two +things: it reveals the endpoint list/add/edit/discover panel in that same +settings section, and it makes the Run menu offer a generated entry per +(harness, endpoint) pair — see "The Run-menu picker" below. The API +equivalent: ```bash curl -sk -X PUT https://localhost:3000/api/settings \ @@ -34,6 +35,9 @@ curl -sk -X PUT https://localhost:3000/api/settings \ ## Adding an endpoint +Via App Settings → Models → Custom model endpoints → **+ Add endpoint**, or +directly: + ```bash curl -sk -X POST https://localhost:3000/api/model-endpoints \ -H 'Content-Type: application/json' \ @@ -62,6 +66,34 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one. Endpoint management is admin-only in multi-user mode, same as remote/docker hosts — these are machine-level infra, not per-user settings. +`defaultModelId` names which discovered model the Run-menu picker applies +for that endpoint with no further choice — the settings panel's Edit form +exposes it as a select populated from the endpoint's own discovered +`models`, and the route refuses a value that isn't one of them. Leaving it +unset falls back to the first discovered model; re-discovering drops a +default that no longer appears in the fresh list rather than carrying an +invalid one forward. + +## The Run-menu picker + +With the setting on and at least one endpoint carrying a usable default +model (either an explicit `defaultModelId` or just one discovered model), +the toolbar's Run dropdown grows a **Custom Endpoints** section: one entry +per (harness that can redirect to a custom endpoint, saved endpoint) pair, +e.g. "Claude Code (llama.cpp)". The harness list is read off the CLI +registry's own `capabilities.customModelInjection` at page render +(`window.__codemanCustomModelClis`, `server.ts`) — never a hardcoded id list +in the frontend — so a CLI whose injection recipe lands later shows up with +no frontend change, and Antigravity (`unsupported`) never does. + +Picking an entry runs a single session on that harness exactly the way its +own Run-menu entry would (same case creation, env overrides, everything), +then immediately applies the endpoint's default model to it via the route +below. It is a one-off "try this endpoint" action, not a sticky mode: the +plain Run button still means "this harness, native cloud" afterward. +Entries are hidden entirely for a remote or Docker active case, since the +apply route refuses both (see the next section). + ## Applying a model to a session ```bash diff --git a/src/custom-model-hosts.ts b/src/custom-model-hosts.ts index 0cde1039..0130eee7 100644 --- a/src/custom-model-hosts.ts +++ b/src/custom-model-hosts.ts @@ -40,6 +40,16 @@ export interface CustomModelHost { authStyle?: CustomModelAuthStyle; models?: string[]; lastDiscoveredAt?: string; + /** + * The model the Run-menu picker (docs/custom-model-endpoints-plan.md) applies when + * this endpoint is picked with no further choice — one generated menu entry per + * (CLI, endpoint) pair, not per (CLI, endpoint, model), so it needs a single answer. + * Must be a member of `models` when set; the picker falls back to `models[0]` when + * this is unset, and disables the entry entirely when `models` is empty (nothing to + * default to). Never auto-set on discovery — the previous default staying valid + * after a re-discover is a property worth keeping even if the model list changes. + */ + defaultModelId?: string; } export function customModelHostsPath(configDir: string): string { diff --git a/src/web/public/index.html b/src/web/public/index.html index 2ba80243..2e6fded1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -650,6 +650,14 @@

Resume Conversation

+ + + +
+ + + diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 67cbfad4..de783959 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -461,6 +461,7 @@ Object.assign(CodemanApp.prototype, { if (menu.classList.contains('active')) { this._loadRunModeHistory(); this._refreshRunModeAvailability(menu); + this._refreshCustomModelRunOptions(menu); const close = (ev) => { if (!menu.contains(ev.target)) { menu.classList.remove('active'); @@ -534,6 +535,124 @@ Object.assign(CodemanApp.prototype, { if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none'; }, + /** + * Generates the Run menu's Custom Model Endpoint entries + * (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved + * endpoint) pair, e.g. "Claude Code (llama.cpp)". Hidden entirely when the + * feature is off, no endpoint has a usable default model, or the active case is + * remote/docker (the apply route refuses both — see session-routes.ts). + * + * `window.__codemanCustomModelClis` is server-injected at render time from the + * CLI registry's own `capabilities.customModelInjection` (never a hardcoded id + * list here), so a CLI gaining or losing the capability shows up with no + * frontend change. + */ + async _refreshCustomModelRunOptions(menu) { + const sep = menu.querySelector('#runModeCustomModelSep'); + const header = menu.querySelector('#runModeCustomModelHeader'); + const container = menu.querySelector('#runModeCustomModels'); + if (!container) return; + const hide = () => { + if (sep) sep.style.display = 'none'; + if (header) header.style.display = 'none'; + container.innerHTML = ''; + }; + + const settings = this.loadAppSettingsFromStorage(); + const capableClis = window.__codemanCustomModelClis || []; + if (!settings.customModelEndpointsEnabled || capableClis.length === 0) return hide(); + + const caseName = document.getElementById('quickStartCase')?.value; + const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; + if (activeCase?.location === 'remote' || activeCase?.location === 'docker') return hide(); + + let hosts; + try { + const res = await fetch('/api/model-endpoints'); + hosts = await res.json(); + } catch { + return hide(); + } + if (!Array.isArray(hosts) || hosts.length === 0) return hide(); + + const rows = []; + for (const host of hosts) { + const modelId = host.defaultModelId || (host.models || [])[0]; + if (!modelId) continue; // nothing discovered yet — the settings panel explains why + for (const cli of capableClis) { + rows.push(` + `); + } + } + if (rows.length === 0) return hide(); + if (sep) sep.style.display = ''; + if (header) header.style.display = ''; + container.innerHTML = rows.join(''); + }, + + /** + * Runs a session on `mode` and immediately applies `endpointId`/`modelId` to it + * via POST /api/sessions/:id/custom-model (see session-routes.ts) — the same + * restart-in-place apply path the (not-yet-built) endpoint-management surface + * would use for an already-running session. Reuses the existing per-mode run*() + * functions wholesale (case creation, env overrides, the works) rather than a + * parallel create path, forcing a single instance: a custom-model run is a + * one-off "try this endpoint" action, not a batch spawn. + */ + async runCustomModelEntry(mode, endpointId, modelId) { + document.getElementById('runModeMenu')?.classList.remove('active'); + const runners = { + claude: () => this.runClaude(), + opencode: () => this.runOpenCode(), + codex: () => this.runCodex(), + gemini: () => this.runGemini(), + pi: () => this.runPi(), + grok: () => this.runGrok(), + deepseek: () => this.runDeepSeek(), + omp: () => this.runOmp(), + }; + const runner = runners[mode]; + if (!runner) { + this.showToast(`No run function for mode ${mode}`, 'error'); + return; + } + + const tabCountEl = document.getElementById('tabCount'); + const prevTabCount = tabCountEl?.value; + if (tabCountEl) tabCountEl.value = '1'; + try { + await runner(); + } finally { + if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount; + } + + // Every run*() ends by selecting the session it just created, so the active + // session at this point IS the new one — see runClaude/runShell's own comments + // on why selectSession must run before this reads activeSessionId. + const sessionId = this.activeSessionId; + if (!sessionId) return; // run() already reported its own error via toast + + try { + const res = await fetch(`/api/sessions/${sessionId}/custom-model`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpointId, modelId }), + }); + const data = await res.json(); + if (!data.success) { + this.showToast(`Session started on the native backend — could not apply the custom endpoint: ${data.error}`, 'warning'); + return; + } + this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info'); + } catch (err) { + this.showToast(`Session started, but applying the custom endpoint failed: ${err.message}`, 'warning'); + } + }, + /** * Start the DeepSeek Harness browser UI and open it as a Codeman web tab. * diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 79bee3c0..19a2309b 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -395,6 +395,10 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; // Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables). document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; + // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both + // the Run-menu picker's generated entries and this settings panel's visibility; + // the endpoint list itself is server state, loaded separately below. + document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true; // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = @@ -509,6 +513,10 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10; // Model configuration (loaded from server) this.loadModelConfigForSettings(); + // Custom Model Endpoint Profiles: server state, own load path (mirrors the + // model-config pair above) rather than the settings payload — endpoints are + // infra records (CRUD'd via /api/model-endpoints), not user preferences. + this.loadCustomModelEndpointsForSettings(); // Notification settings const notifPrefs = this.notificationManager?.preferences || {}; document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true; @@ -2106,6 +2114,7 @@ Object.assign(CodemanApp.prototype, { showSubagents: document.getElementById('appSettingsShowSubagents').checked, showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked, approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked, + customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked, readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, @@ -2487,6 +2496,161 @@ Object.assign(CodemanApp.prototype, { } }, + // ═══════════════════════════════════════════════════════════════ + // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) + // + // CRUD against /api/model-endpoints, rendered into the Models settings section. + // Deliberately its own load/save pair rather than folded into openAppSettings/ + // saveAppSettings: these are server-side infra records (like remote/docker + // hosts), not a settings-payload field, so the app-settings-structure guard's + // by-id contract does not apply to them — only the `customModelEndpointsEnabled` + // toggle itself goes through that path. + // ═══════════════════════════════════════════════════════════════ + + async loadCustomModelEndpointsForSettings() { + try { + const res = await fetch('/api/model-endpoints'); + const hosts = await res.json(); + this._customModelHosts = Array.isArray(hosts) ? hosts : []; + } catch (err) { + console.warn('Failed to load model endpoints:', err); + this._customModelHosts = this._customModelHosts || []; + } + this.renderCustomModelHostsList(); + }, + + renderCustomModelHostsList() { + const list = document.getElementById('customModelHostsList'); + if (!list) return; + const hosts = this._customModelHosts || []; + if (hosts.length === 0) { + list.innerHTML = '

No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.

'; + return; + } + list.innerHTML = hosts + .map((h) => { + const modelCount = (h.models || []).length; + const modelSummary = modelCount === 0 + ? 'No models discovered yet' + : `${modelCount} model${modelCount === 1 ? '' : 's'}${h.defaultModelId ? ` · default: ${escapeHtml(h.defaultModelId)}` : ' · no default set'}`; + return ` +
+
+ ${escapeHtml(h.label)} + ${escapeHtml(h.baseUrl)} — ${modelSummary} +
+
+ + + +
+
`; + }) + .join(''); + }, + + /** Opens the inline add/edit form. Pass no id to add a new endpoint. */ + openCustomModelHostEditor(hostId) { + const host = hostId ? (this._customModelHosts || []).find((h) => h.id === hostId) : null; + this._editingCustomModelHostId = host ? host.id : null; + document.getElementById('customModelHostEditorTitle').textContent = host ? `Edit ${host.label}` : 'Add endpoint'; + document.getElementById('customModelHostId').value = host?.id || ''; + document.getElementById('customModelHostId').disabled = !!host; // id is immutable once created + document.getElementById('customModelHostLabel').value = host?.label || ''; + document.getElementById('customModelHostBaseUrl').value = host?.baseUrl || ''; + document.getElementById('customModelHostApiKey').value = ''; // never round-tripped back into the field + document.getElementById('customModelHostApiKey').placeholder = host?.apiKey ? '•••••••• (unchanged if left blank)' : ''; + document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer'; + this._populateCustomModelDefaultSelect(host); + document.getElementById('customModelHostEditor').style.display = ''; + }, + + closeCustomModelHostEditor() { + document.getElementById('customModelHostEditor').style.display = 'none'; + this._editingCustomModelHostId = null; + }, + + _populateCustomModelDefaultSelect(host) { + const select = document.getElementById('customModelHostDefaultModel'); + const models = host?.models || []; + select.innerHTML = + '' + + models.map((m) => ``).join(''); + select.value = host?.defaultModelId || ''; + select.disabled = models.length === 0; + }, + + async saveCustomModelHostFromEditor() { + const id = document.getElementById('customModelHostId').value.trim(); + const label = document.getElementById('customModelHostLabel').value.trim(); + const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim(); + const apiKeyInput = document.getElementById('customModelHostApiKey').value; + const authStyle = document.getElementById('customModelHostAuthStyle').value; + const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined; + if (!id || !label || !baseUrl) { + this.showToast('Id, label and base URL are all required', 'warning'); + return; + } + const editing = this._editingCustomModelHostId; + const existing = editing ? (this._customModelHosts || []).find((h) => h.id === editing) : null; + const body = { + id, + label, + baseUrl, + authStyle, + defaultModelId, + // A blank key on EDIT means "leave it alone", never "clear it" — the field + // is never pre-filled with the real value (see openCustomModelHostEditor), + // so an unedited save must not silently wipe a working credential. + apiKey: apiKeyInput ? apiKeyInput : existing?.apiKey, + models: existing?.models, + lastDiscoveredAt: existing?.lastDiscoveredAt, + }; + try { + const res = await fetch(editing ? `/api/model-endpoints/${encodeURIComponent(editing)}` : '/api/model-endpoints', { + method: editing ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!data.success) { + this.showToast(data.error || 'Failed to save endpoint', 'error'); + return; + } + this.showToast(editing ? 'Endpoint updated' : 'Endpoint added', 'success'); + this.closeCustomModelHostEditor(); + await this.loadCustomModelEndpointsForSettings(); + } catch (err) { + this.showToast(`Failed to save endpoint: ${err.message}`, 'error'); + } + }, + + async discoverCustomModelHostModels(hostId) { + this.showToast('Discovering models…', 'info'); + try { + const res = await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}/discover-models`, { method: 'POST' }); + const data = await res.json(); + if (!data.success) { + this.showToast(data.error || 'Discovery failed', 'error'); + return; + } + this.showToast(`Found ${data.data.models.length} model${data.data.models.length === 1 ? '' : 's'}`, 'success'); + await this.loadCustomModelEndpointsForSettings(); + } catch (err) { + this.showToast(`Discovery failed: ${err.message}`, 'error'); + } + }, + + async deleteCustomModelHost(hostId) { + const host = (this._customModelHosts || []).find((h) => h.id === hostId); + if (!confirm(`Delete endpoint "${host?.label || hostId}"? Any session currently pointed at it keeps running until cleared.`)) return; + try { + await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}`, { method: 'DELETE' }); + await this.loadCustomModelEndpointsForSettings(); + } catch (err) { + this.showToast(`Failed to delete endpoint: ${err.message}`, 'error'); + } + }, // ═══════════════════════════════════════════════════════════════ // Visibility Settings & Device-Specific Defaults diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 398eb6ce..2fe9fb65 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16207,6 +16207,26 @@ html[data-tab-orientation='vertical'] .home-sessions { gap: 3px; } +/* Custom Model Endpoint Profiles' inline add/edit form: a nested panel rather + than a modal, so it needs its own border to read as a distinct sub-section + inside .set-group-body's flat row stack. */ +:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 6px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(0, 0, 0, 0.12); +} + +:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form h5 { + margin: 0 0 4px; + font-size: 0.72rem; + color: var(--text-muted); +} + /* ── rows ─────────────────────────────────────────────────────────────── */ :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row { display: flex; diff --git a/src/web/routes/custom-model-routes.ts b/src/web/routes/custom-model-routes.ts index d5b1b1e8..0fc1b1f2 100644 --- a/src/web/routes/custom-model-routes.ts +++ b/src/web/routes/custom-model-routes.ts @@ -33,6 +33,21 @@ function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown }) return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); } +/** + * `defaultModelId` names the model the Run-menu picker applies for this endpoint with + * no further choice, so it must actually be one of the discovered `models` — a schema + * `.refine()` can't see across the two fields the way this can, and would also run on + * every unrelated field edit rather than only when either of these two changes. + */ +function invalidDefaultModel(host: Pick): ApiResponse | null { + if (host.defaultModelId === undefined) return null; + if ((host.models ?? []).includes(host.defaultModelId)) return null; + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'defaultModelId must be one of the endpoint’s discovered models' + ); +} + async function discoverModels(host: Pick): Promise { const headers: Record = {}; const apiKey = host.apiKey?.trim(); @@ -78,6 +93,8 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { if (isBlockedWebviewUrl(host.baseUrl)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed'); } + const badDefault = invalidDefaultModel(host); + if (badDefault) return badDefault; const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); if (hosts.some((item) => item.id === host.id)) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists'); @@ -94,6 +111,8 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { if (isBlockedWebviewUrl(host.baseUrl)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed'); } + const badDefault = invalidDefaultModel(host); + if (badDefault) return badDefault; const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); const index = hosts.findIndex((item) => item.id === id); if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); @@ -131,7 +150,12 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { try { const models = await discoverModels(host); const next = [...hosts]; - next[index] = { ...host, models, lastDiscoveredAt: new Date().toISOString() }; + // A default that no longer appears in the fresh list would leave the Run-menu + // picker applying a model id the endpoint just told us it doesn't serve; drop + // it rather than carry it forward silently invalid. + const defaultModelId = + host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined; + next[index] = { ...host, models, defaultModelId, lastDiscoveredAt: new Date().toISOString() }; await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next); return { success: true, data: { models } }; } catch (err) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index b5dabdd0..f1f68151 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1918,6 +1918,12 @@ export const CustomModelHostSchema = z.object({ authStyle: z.enum(['bearer', 'api-key']).optional(), models: z.array(z.string().max(200)).max(200).optional(), lastDiscoveredAt: z.string().max(64).optional(), + // The Run-menu picker's per-endpoint default; validated against `models` at the + // route layer (schema-level cross-field checks can't see the array narrowed the + // same way a `.refine()` closure could, and the route already re-reads the stored + // host to apply it, so the check belongs there once, not duplicated into a refine + // that would run on every unrelated field edit too). + defaultModelId: z.string().max(200).optional(), }); /** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */ diff --git a/src/web/server.ts b/src/web/server.ts index cf5927c0..54c6b4ab 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -67,7 +67,7 @@ import { import { imageWatcher } from '../image-watcher.js'; import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js'; import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js'; -import { getCli } from '../config/cli-registry/registry.js'; +import { getCli, enabledClis } from '../config/cli-registry/registry.js'; import { readCustomModelHosts } from '../custom-model-hosts.js'; import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js'; import type { CustomModelBookkeeping } from '../types/session.js'; @@ -1596,6 +1596,18 @@ export class WebServer extends EventEmitter { '', `\n` ); + // Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may + // generate an entry for: read generically off the registry's `capabilities` + // (never an id list here) so a CLI whose customModelInjection lands later shows + // up in the picker with no frontend change, and one that ships `unsupported` + // (antigravity, and `shell`'s `kind !== 'agent'`) never does. + const customModelClis = enabledClis() + .filter((entry) => entry.kind === 'agent' && entry.capabilities.customModelInjection.kind !== 'unsupported') + .map((entry) => ({ id: entry.id, label: entry.label })); + html = html.replace( + '', + `\n` + ); } if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('', `\n`); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 0cb6b334..df5fb417 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -187,6 +187,28 @@ describe('WebServer.renderIndexHtml', () => { }); }); + it('reports which run modes the custom-model Run-menu picker may generate an entry for', async () => { + // Read generically off the CLI registry's own capabilities, not a hardcoded id + // list — antigravity (`unsupported`) and shell (`kind !== 'agent'`) must be + // absent, and any enabled agent CLI with a real injection recipe must be + // present, with no mock needed since this reads the real stock registry. + const { server } = makeServer({}); + const html = await render(server); + expect(html).toContain('window.__codemanCustomModelClis='); + const clis = JSON.parse(html.match(/window\.__codemanCustomModelClis=(\[.*?\]);/)![1]) as Array<{ + id: string; + label: string; + }>; + const ids = clis.map((c) => c.id); + expect(ids).toContain('claude'); + expect(ids).not.toContain('antigravity'); + expect(ids).not.toContain('shell'); + for (const cli of clis) { + expect(typeof cli.id).toBe('string'); + expect(typeof cli.label).toBe('string'); + } + }); + it('still emits the object when nothing at all is installed', async () => { // The all-false case is the one that matters most and the easiest to get // wrong by only injecting when something resolves. @@ -218,6 +240,7 @@ describe('WebServer.renderIndexHtml', () => { const { server } = makeServer({}); const html = await render(server, 'sess-123'); expect(html).not.toContain('__codemanCliAvailable'); + expect(html).not.toContain('__codemanCustomModelClis'); }); it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => { diff --git a/test/routes/custom-model-routes.test.ts b/test/routes/custom-model-routes.test.ts index 7c864be5..059f210d 100644 --- a/test/routes/custom-model-routes.test.ts +++ b/test/routes/custom-model-routes.test.ts @@ -194,3 +194,104 @@ describe('custom model endpoint CRUD', () => { } }); }); + +describe('defaultModelId — the Run-menu picker’s per-endpoint default', () => { + afterEach(() => { + fetchMock.mockReset(); + }); + + it('rejects a defaultModelId that is not one of the endpoint’s discovered models, on both create and update', async () => { + const { app } = await setup(); + const create = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { + id: 'ep-default-reject', + label: 'A', + baseUrl: 'http://localhost:8080', + models: ['qwen3'], + defaultModelId: 'ghost', + }, + }); + expect(create.json().success).toBe(false); + expect(create.json().errorCode).toBe('INVALID_INPUT'); + + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'ep-default-reject', label: 'A', baseUrl: 'http://localhost:8080', models: ['qwen3'] }, + }); + const update = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/ep-default-reject', + payload: { label: 'A', baseUrl: 'http://localhost:8080', models: ['qwen3'], defaultModelId: 'ghost' }, + }); + expect(update.json().success).toBe(false); + expect(update.json().errorCode).toBe('INVALID_INPUT'); + }); + + it('accepts a defaultModelId that IS one of the discovered models', async () => { + const { app } = await setup(); + const res = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { + id: 'ep-default-accept', + label: 'A', + baseUrl: 'http://localhost:8080', + models: ['qwen3', 'llama3'], + defaultModelId: 'llama3', + }, + }); + expect(res.json().success).toBe(true); + expect(res.json().data.host.defaultModelId).toBe('llama3'); + }); + + it('drops a stale default that no longer appears in a fresh discovery, rather than carrying it forward invalid', async () => { + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { + id: 'ep-default-drop', + label: 'A', + baseUrl: 'http://localhost:8080', + models: ['qwen3'], + defaultModelId: 'qwen3', + }, + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'llama3' }] }), { status: 200 })); + await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-default-drop/discover-models' }); + + const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' }); + const stored = (list.json() as Array<{ id: string; defaultModelId?: string }>).find( + (h) => h.id === 'ep-default-drop' + ); + expect(stored?.defaultModelId).toBeUndefined(); + }); + + it('keeps a default that IS still present after a fresh discovery', async () => { + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { + id: 'ep-default-keep', + label: 'A', + baseUrl: 'http://localhost:8080', + models: ['qwen3'], + defaultModelId: 'qwen3', + }, + }); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: 'qwen3' }, { id: 'llama3' }] }), { status: 200 }) + ); + await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-default-keep/discover-models' }); + + const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' }); + const stored = (list.json() as Array<{ id: string; defaultModelId?: string }>).find( + (h) => h.id === 'ep-default-keep' + ); + expect(stored?.defaultModelId).toBe('qwen3'); + }); +}); From 98d26e14d9305decf31afbfdbc5db48f1ee2a6c8 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:58:23 +0800 Subject: [PATCH 02/22] docs(wiki): document Custom Model Endpoints and the Run-menu picker New docs/wiki/Custom-Model-Endpoints.md (auto-synced to the live GitHub wiki on push to master, per docs/wiki/Contributing.md) covers turning the feature on, adding an endpoint, the Run-menu picker's one-off-run behaviour, the per-harness confidence table, and what it deliberately does not do yet (remote/Docker sessions, live hot-swap). Linked from the sidebar, from Agent-CLIs.md's "Read next" list plus a short pointer section, and from Settings-Reference.md's Models section. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG --- docs/wiki/Agent-CLIs.md | 7 +++ docs/wiki/Custom-Model-Endpoints.md | 75 +++++++++++++++++++++++++++++ docs/wiki/Settings-Reference.md | 4 ++ docs/wiki/_Sidebar.md | 1 + 4 files changed, 87 insertions(+) create mode 100644 docs/wiki/Custom-Model-Endpoints.md diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index eb013b29..d7ad4b22 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -273,9 +273,16 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working. - **Shell** for the times you want a terminal on your phone with no agent at all. It is a genuinely useful mode, not a fallback. +## Pointing one at your own server + +Most of these harnesses can also run against a custom OpenAI-compatible endpoint instead of +their native cloud backend, for one session at a time, an opt-in feature covered in full on +[Custom Model Endpoints](Custom-Model-Endpoints). + ## Read next - [Core Concepts](Core-Concepts) - run modes versus location overlays. +- [Custom Model Endpoints](Custom-Model-Endpoints) - run a harness against your own server. - [Settings Reference](Settings-Reference) - model, effort, and permission-mode settings. - [Keeping Agents Running](Keeping-Agents-Running) - what idle detection does per mode. - [Security](Security) - what skipping permission prompts actually means. diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md new file mode 100644 index 00000000..f7e2ed28 --- /dev/null +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -0,0 +1,75 @@ +# Custom Model Endpoints + +Point a harness at your own OpenAI-compatible server instead of its native cloud backend, for +one session at a time. "Custom endpoint" covers **local** hardware (llama.cpp, Ollama, vLLM, +a home GPU rig, DGX Spark, Strix Halo) and **cloud** services (Azure AI Foundry's +OpenAI-compatible endpoint, OpenRouter, a company gateway) alike, anything answering +`GET /v1/models` and `POST /v1/chat/completions` in the standard shape. + +**Off by default.** Turn it on in App Settings → Models → **Custom model endpoints**. + +## Adding an endpoint + +Still in App Settings → Models → Custom model endpoints: + +1. **+ Add endpoint** — give it an id, a label, and the base URL (`http://192.168.1.50:8080`, + say). An API key is optional; most local servers don't check one. +2. **Discover** — fetches the endpoint's own model list over `GET /v1/models` and stores it. +3. Pick a **default model** from what was discovered. This is the model the Run-menu entry + below applies with no further choice, so set it once you know which one you want. + +Endpoint management is admin-only in multi-user mode, the same as remote hosts and Docker +hosts — these are machine-level infra, not a per-user setting. + +## Running a session against one + +With the setting on and at least one endpoint carrying a usable default model, the **Run** +dropdown grows a **Custom Endpoints** section: one entry per harness that can redirect to a +custom endpoint, per saved endpoint, e.g. "Claude Code (llama.cpp)". Picking one starts a +session on that harness exactly the way its own entry would, then points it at the +endpoint's default model. It is a one-off "try this endpoint" action, not a sticky mode — the +plain **Run** button still means "this harness, native cloud" afterward, and a fresh session +never inherits whatever the last one was pointed at. + +Applying a selection **restarts the harness's process in place** — same tab, same +conversation where the harness supports resuming one, fresh environment. That restart is +necessary, not incidental: every supported harness reads its endpoint config at process +start, never per turn, so there is no live hot-swap while a turn is running. + +Entries are hidden entirely for a session in a **remote (SSH) or Docker case** — support for +redirecting those hasn't landed yet, see below. + +## Which harnesses actually work + +| Harness | Status | +| ------- | ------ | +| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. | +| **Codex** | Config is correct, but Codex only speaks the Responses API, which llama.cpp-style servers don't implement. A protocol gap, not a Codeman bug. | +| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. | +| **DeepSeek** | Reaches the server but gets a consistent 404. Root cause not identified. | +| **Antigravity** | No known custom-endpoint mechanism at all. Not offered. | + +Which harnesses show up in the Run-menu picker is read live off Codeman's own CLI registry, +not a fixed list here, so this table can go stale before this page does — a greyed-out or +missing entry is the more current answer. + +## What it does not do + +- **No remote or Docker sessions yet.** Both restart their agent differently under the hood + (reattaching a durable tmux session rather than relaunching the process), so redirecting + them needs its own plumbing that hasn't been built. +- **No live hot-swap mid-conversation.** Applying a selection always restarts the process. +- **Nothing is shared with your real cloud credentials.** The endpoint's own key, if any, + never touches your Anthropic/OpenAI/Google login — a custom endpoint is a separate, + explicit choice per session. + +## Security + +An endpoint's base URL can't point at a link-local or cloud-metadata address (both at save +time and against the address it actually resolves to), the same guard Web Tabs uses for +saved dashboards. Endpoint records and any per-session config files a harness needs are +written with owner-only permissions. See +[custom-model-endpoints-plan.md](https://github.com/Ark0N/Codeman/blob/master/docs/custom-model-endpoints-plan.md) +in the repository for the full design reasoning, including why this feature closed a +pre-existing gap in how session environment overrides were guarded rather than opening a new +one. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 3255f56f..14b04056 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -92,6 +92,10 @@ Model and effort are both **soft defaults**: the model is written into the case' `.claude/settings.local.json` and effort is passed at start, so `/model` and `/effort` inside a session override them at any time. +**Custom model endpoints** (off by default) adds a saved-endpoint list plus a matching +section to the Run dropdown, for pointing a harness at your own OpenAI-compatible server +instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-Endpoints). + ### Agents & CLIs | Setting | Notes | diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index af84a756..2e6d54b5 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -12,6 +12,7 @@ - [The Dashboard](The-Dashboard) - [Agent CLIs](Agent-CLIs) +- [Custom Model Endpoints](Custom-Model-Endpoints) - [Working With Files](Working-With-Files) - [Input And Voice](Input-And-Voice) - [Mobile Guide](Mobile-Guide) From fed6582d3e22a9ba94bc4ead0a5331357aecd510 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:30:53 +0800 Subject: [PATCH 03/22] fix(test): strip the custom-model Run-menu picker's injected script too CI on PR #430 failed test/server-index-title.test.ts's byte-identity check: renderIndexHtml now injects a second unconditional (CliEntry.label is user-clis.json-settable, unlike __codemanCliAvailable's booleans-only payload) via a new exported escapeScriptJson(), pure and unit-tested without needing a WebServer. - Added defaultModelId + the new /v1/model-endpoints routes to docs/api-reference.md; left the "no zh-CN for the new Models-section group" minor unaddressed only insofar as the wider Models section (task routing, thinking effort, etc.) has never had zh-CN coverage either — everything this PR itself introduces (labels, hints, button text, the Run-menu's "Custom Endpoints" header) IS translated in i18n.js. Regression caught while fixing #4: the admin-gate's codeman:me listener is a module-level document.addEventListener() call, which threw in run-mode-ui.test.ts's minimal vm-context fake document and failed all 10 of that file's tests. Fixed with optional chaining before it ever reached the branch this commit lands on; full targeted suite (route tests, structural guards, every settings-ui.js-loading frontend test) reverified green afterward. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG --- docs/api-reference.md | 47 +++++ docs/custom-model-endpoints-plan.md | 8 + docs/wiki/Custom-Model-Endpoints.md | 7 +- src/web/public/i18n.js | 25 +++ src/web/public/index.html | 78 ++++---- src/web/public/session-ui.js | 102 +++++----- src/web/public/settings-ui.js | 106 ++++++++--- src/web/public/styles.css | 13 +- src/web/routes/custom-model-routes.ts | 49 ++++- src/web/server.ts | 21 +- test/custom-model-run-menu-ui.test.ts | 243 ++++++++++++++++++++++++ test/render-index-html.test.ts | 15 +- test/routes/custom-model-routes.test.ts | 94 +++++++++ 13 files changed, 687 insertions(+), 121 deletions(-) create mode 100644 test/custom-model-run-menu-ui.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index dcd0d631..3a5dab66 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -516,6 +516,53 @@ All four enforce session ownership in multi-user mode; a foreign session id answers `404 NOT_FOUND` (no existence leak), and profiles of two owners of the same directory are distinct by construction. +## Custom Model Endpoints + +Points a session's harness at a user-configured OpenAI-compatible endpoint — +local (llama.cpp, vLLM, DGX Spark) or cloud (Azure AI Foundry, OpenRouter) — +instead of its native cloud backend, gated by the opt-in +`customModelEndpointsEnabled` setting (default OFF). Endpoints are +machine-level infra, like remote/docker hosts: writes are admin-only in +multi-user mode. Design: [`custom-model-endpoints-plan.md`](custom-model-endpoints-plan.md); +user guide: [`custom-model-endpoints.md`](custom-model-endpoints.md). + +- `GET /api/v1/model-endpoints` -> `CustomModelHost[]`, an unwrapped bare + array like every other list route (still riding the standard `{success, + data}` envelope on the wire — unwrap it the same way). Answers `[]` for a + non-admin in multi-user mode. `apiKey` is never returned; `apiKeySet: + boolean` reports whether one is stored, so a client can render "unchanged + if left blank" without ever holding the real value. +- `POST /api/v1/model-endpoints` with `{ id, label, baseUrl, apiKey?, + authStyle?, defaultModelId? }` creates one. `id` must match + `^[a-zA-Z0-9_-]+$`; `authStyle` is `bearer` (default) or `api-key`, never + both (a real server hung indefinitely when sent both headers on one + request); `baseUrl` must be `http(s)`, carry no embedded credentials, and + is refused if it points at (or resolves to) a link-local or + cloud-metadata address. `409 ALREADY_EXISTS` on a duplicate id. +- `PUT /api/v1/model-endpoints/:id` updates one. An **absent** `apiKey` + keeps the stored one rather than clearing it — the client never receives + the real value to resend deliberately unchanged, so omission is the only + way to say "leave it alone"; there is no way to clear a key back to unset + this way. `defaultModelId`, when set, must be one of that endpoint's own + `models` (`400 INVALID_INPUT` otherwise). +- `DELETE /api/v1/model-endpoints/:id` removes one. +- `POST /api/v1/model-endpoints/:id/discover-models` fetches the endpoint's + own `GET /v1/models` and stores the result as `models`, updating + `lastDiscoveredAt`. A `defaultModelId` that no longer appears in the fresh + list is dropped rather than carried forward invalid. Failures answer + `502 OPERATION_FAILED` with the underlying connection error, or a named + egress refusal if the resolved address turned out to be blocked. +- `POST /api/v1/sessions/:id/custom-model` with `{ endpointId, modelId } | + { clear: true }` applies (or clears) the session's selection and + **restarts the session's CLI process in place** — every supported harness + reads its endpoint config at process start, never per turn, so there is + no live hot-swap. A Claude session resumes its existing conversation + across the restart; pi/omp/grok additionally get a forced `--model`/`-m` + value, since for those three the config file alone does not select it. + `400 INVALID_INPUT` for a remote (SSH) or Docker session — both restart + their agent differently under the hood, and applying to one would report + success while changing nothing. + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the diff --git a/docs/custom-model-endpoints-plan.md b/docs/custom-model-endpoints-plan.md index 85407adf..2bf5db57 100644 --- a/docs/custom-model-endpoints-plan.md +++ b/docs/custom-model-endpoints-plan.md @@ -208,6 +208,14 @@ extra per-model configuration on Codeman's side at all. ### 4. Toolbar UI +> **Superseded.** This section describes the toolbar-button design as originally +> planned. What actually shipped is a Run-menu picker instead: one generated entry +> per (capable harness, saved endpoint) pair directly in the existing `#runModeMenu` +> dropdown, rather than a separate `#customModelBtn`/`#customModelMenu` surface. See +> [`docs/custom-model-endpoints.md`](custom-model-endpoints.md#the-run-menu-picker) +> for the current design; the sections below (session-restart mechanics, security) +> remain accurate regardless of which UI calls the underlying route. + - New header/toolbar button (e.g. `#customModelBtn`, `btn-toolbar btn-custom-model`), marker-hidden by default (`btn-custom-model--hidden`) and revealed by `applyHeaderVisibilitySettings()` only when diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index f7e2ed28..a989a858 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -37,7 +37,9 @@ necessary, not incidental: every supported harness reads its endpoint config at start, never per turn, so there is no live hot-swap while a turn is running. Entries are hidden entirely for a session in a **remote (SSH) or Docker case** — support for -redirecting those hasn't landed yet, see below. +redirecting those hasn't landed yet, see below. The picker also only appears in the desktop +**Run** dropdown; the phone home screen builds its own run picker separately and does not +currently offer these entries. ## Which harnesses actually work @@ -59,6 +61,9 @@ missing entry is the more current answer. (reattaching a durable tmux session rather than relaunching the process), so redirecting them needs its own plumbing that hasn't been built. - **No live hot-swap mid-conversation.** Applying a selection always restarts the process. +- **No button to un-point a session from the UI yet.** Clearing back to native cloud is an + HTTP call (`POST .../custom-model {"clear": true}`) or deleting the session; the settings + panel manages saved endpoints, not what a running session is currently pointed at. - **Nothing is shared with your real cloud credentials.** The endpoint's own key, if any, never touches your Anthropic/OpenAI/Google login — a custom endpoint is a separate, explicit choice per session. diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index bee8fcab..d72cc33a 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -286,6 +286,31 @@ 'Prompt sent': '提示已发送', 'Inserted, press Enter in the terminal to send': '已插入,在终端中按 Enter 发送', 'Could not reach the session': '无法连接到会话', + 'Custom model endpoints': '自定义模型端点', + 'Point a harness at your own OpenAI-compatible server (llama.cpp, vLLM, DGX Spark, Azure AI Foundry, OpenRouter) instead of its native cloud backend. When on, the Run menu offers an extra entry per harness that supports it, per saved endpoint.': + '让工具指向您自己的兼容 OpenAI 服务器(llama.cpp、vLLM、DGX Spark、Azure AI Foundry、OpenRouter),而非其原生云端后端。开启后,"运行"菜单会为每个支持此功能的工具、每个已保存的端点新增一个条目。', + 'Enable custom model endpoints': '启用自定义模型端点', + 'Adds a per-endpoint entry to the Run menu for every harness that can redirect to one.': + '为每个可重定向到端点的工具,在"运行"菜单中添加对应条目。', + 'No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.': + '暂无端点。请在下方添加一个,以便将工具指向本地或云端的兼容 OpenAI 服务器。', + Discover: '发现模型', + '+ Add endpoint': '+ 添加端点', + 'Add endpoint': '添加端点', + Id: 'ID', + 'Short, stable — used in URLs, never shown to the CLI.': '简短且固定 — 用于 URL,不会展示给 CLI。', + Label: '标签', + 'Base URL': '基础 URL', + 'API key': 'API 密钥', + 'Optional. Left blank on edit keeps the existing key.': '可选。编辑时留空将保留现有密钥。', + 'Auth header': '认证请求头', + 'Never send both — some servers hang indefinitely.': '切勿同时发送两者 — 部分服务器会因此无限期挂起。', + 'Authorization: Bearer (default)': 'Authorization: Bearer(默认)', + 'api-key header (Azure)': 'api-key 请求头(Azure)', + 'Default model': '默认模型', + 'What the Run-menu picker applies for this endpoint. Discover models first.': + '运行菜单选择器会为此端点应用该模型。请先发现可用模型。', + 'Custom Endpoints': '自定义端点', 'Subagent Options': '子智能体选项', 'Enable Tracking': '启用跟踪', 'Active Tab Only': '仅活动标签页', diff --git a/src/web/public/index.html b/src/web/public/index.html index 2e6fded1..65007ffc 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -652,7 +652,7 @@

Resume Conversation

@@ -2216,42 +2216,46 @@

Task routing

Enable custom model endpoints Adds a per-endpoint entry to the Run menu for every harness that can redirect to one. - - -
- - diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index de783959..c14dcb3d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -559,20 +559,22 @@ Object.assign(CodemanApp.prototype, { }; const settings = this.loadAppSettingsFromStorage(); - const capableClis = window.__codemanCustomModelClis || []; + // Matches _refreshRunModeAvailability's own gate: a stock entry for an + // uninstalled CLI is hidden, so a generated one must be too, or a box with + // no codex still offers "Codex (llama.cpp)" and fails at launch. + const capableClis = (window.__codemanCustomModelClis || []).filter((cli) => this.isCliAvailable(cli.id)); if (!settings.customModelEndpointsEnabled || capableClis.length === 0) return hide(); const caseName = document.getElementById('quickStartCase')?.value; const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; if (activeCase?.location === 'remote' || activeCase?.location === 'docker') return hide(); - let hosts; - try { - const res = await fetch('/api/model-endpoints'); - hosts = await res.json(); - } catch { - return hide(); - } + // GET /api/model-endpoints wraps its body in the { success, data } envelope + // like every other /api route (server.ts's preSerialization hook applies to + // arrays too) — _apiJson() unwraps it. A raw fetch().json() here would + // silently see the envelope object instead of the array and hide this + // section unconditionally. + const hosts = await this._apiJson('/api/model-endpoints'); if (!Array.isArray(hosts) || hosts.length === 0) return hide(); const rows = []; @@ -580,9 +582,17 @@ Object.assign(CodemanApp.prototype, { const modelId = host.defaultModelId || (host.models || [])[0]; if (!modelId) continue; // nothing discovered yet — the settings panel explains why for (const cli of capableClis) { + // escapeHtml(JSON.stringify(...)) on EVERY arg, not just the untrusted + // one: JSON.stringify's own double quotes would otherwise terminate this + // double-quoted attribute at the first one, and everything after parses + // as raw tag content rather than a quoted string — which is what turns + // modelId (server-controlled, from the endpoint's own /v1/models reply, + // not this box's) into markup instead of inert data. Same idiom as + // deleteCase's onclick a few hundred lines down. + const args = [cli.id, host.id, modelId].map((v) => escapeHtml(JSON.stringify(v))).join(', '); rows.push(` `); @@ -598,59 +608,57 @@ Object.assign(CodemanApp.prototype, { * Runs a session on `mode` and immediately applies `endpointId`/`modelId` to it * via POST /api/sessions/:id/custom-model (see session-routes.ts) — the same * restart-in-place apply path the (not-yet-built) endpoint-management surface - * would use for an already-running session. Reuses the existing per-mode run*() - * functions wholesale (case creation, env overrides, the works) rather than a - * parallel create path, forcing a single instance: a custom-model run is a - * one-off "try this endpoint" action, not a batch spawn. + * would use for an already-running session. A custom-model run is a one-off + * "try this endpoint" action, not a sticky mode. + * + * Routes through run() itself, via a temporary `_runMode` swap, rather than a + * parallel dispatch table: that is what gives this the same in-flight lock + * every other Run click gets (CLAUDE.md, Run launch synchronization — the lock + * exists so a double click cannot create duplicate sessions with the same + * `w-` name, and it guards the OTHER direction too: without it, the + * main Run button could start a second concurrent launch while this one was + * still resolving), and it means a CLI whose customModelInjection recipe + * lands later needs no update here, only in run()'s own dispatch. The swap + * never persists — setRunMode() would sync it to the server as the user's new + * default, which a one-off endpoint run must not do — and is restored in + * `finally` even if run() throws. */ async runCustomModelEntry(mode, endpointId, modelId) { document.getElementById('runModeMenu')?.classList.remove('active'); - const runners = { - claude: () => this.runClaude(), - opencode: () => this.runOpenCode(), - codex: () => this.runCodex(), - gemini: () => this.runGemini(), - pi: () => this.runPi(), - grok: () => this.runGrok(), - deepseek: () => this.runDeepSeek(), - omp: () => this.runOmp(), - }; - const runner = runners[mode]; - if (!runner) { - this.showToast(`No run function for mode ${mode}`, 'error'); - return; - } + const previousRunMode = this._runMode; + const before = this.activeSessionId; const tabCountEl = document.getElementById('tabCount'); const prevTabCount = tabCountEl?.value; + this._runMode = mode; if (tabCountEl) tabCountEl.value = '1'; try { - await runner(); + await this.run(); } finally { + this._runMode = previousRunMode; if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount; } - // Every run*() ends by selecting the session it just created, so the active - // session at this point IS the new one — see runClaude/runShell's own comments - // on why selectSession must run before this reads activeSessionId. + // run() reports its own errors via toast. Every run*() function handles its + // own failure internally and returns normally rather than throwing or + // leaving activeSessionId null, so a declined/failed launch (missing CLI, a + // caught exception, isBusy on the session the launch would have targeted) + // falls through to here with the PREVIOUSLY active session still active. + // Requiring the id to have actually changed — not just to be non-null — is + // what stops that case from silently re-pointing and restarting whatever + // session the user was already looking at. const sessionId = this.activeSessionId; - if (!sessionId) return; // run() already reported its own error via toast + if (!sessionId || sessionId === before) return; - try { - const res = await fetch(`/api/sessions/${sessionId}/custom-model`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ endpointId, modelId }), - }); - const data = await res.json(); - if (!data.success) { - this.showToast(`Session started on the native backend — could not apply the custom endpoint: ${data.error}`, 'warning'); - return; - } - this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info'); - } catch (err) { - this.showToast(`Session started, but applying the custom endpoint failed: ${err.message}`, 'warning'); + const data = await this._apiJson(`/api/sessions/${sessionId}/custom-model`, { + method: 'POST', + body: { endpointId, modelId }, + }); + if (!data) { + this.showToast(`Session started on the native backend — could not apply the custom endpoint`, 'warning'); + return; } + this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info'); }, /** diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 19a2309b..1ade54a4 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -397,8 +397,11 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; - // the endpoint list itself is server state, loaded separately below. + // the endpoint list itself is server state, loaded on demand below. document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true; + // Assigning .checked above does not fire onchange, so the body's visibility + // (and its lazy load) needs an explicit sync on every open, not just a save. + this.applyCustomModelEndpointsVisibility(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = @@ -513,10 +516,9 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10; // Model configuration (loaded from server) this.loadModelConfigForSettings(); - // Custom Model Endpoint Profiles: server state, own load path (mirrors the - // model-config pair above) rather than the settings payload — endpoints are - // infra records (CRUD'd via /api/model-endpoints), not user preferences. - this.loadCustomModelEndpointsForSettings(); + // Custom Model Endpoint Profiles' own load is gated on the toggle above (see + // applyCustomModelEndpointsVisibility) — unlike model config, this GET is + // pointless work with the feature off, so it is not fired unconditionally. // Notification settings const notifPrefs = this.notificationManager?.preferences || {}; document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true; @@ -2507,15 +2509,51 @@ Object.assign(CodemanApp.prototype, { // toggle itself goes through that path. // ═══════════════════════════════════════════════════════════════ + /** + * Toggles the endpoint-management body's visibility to match the setting and, + * turning it on, lazily loads the endpoint list. Assigning `.checked` (as the + * settings load path does) fires no `change` event, so this must be called + * explicitly on open as well as wired to the checkbox's own onchange — a + * gate that only worked one of those two ways would show a stale "off" + * body right after opening, or a stale "on" one right after saving it off. + * With the feature off the body is a list of controls that do nothing, so it + * is hidden entirely rather than shown disabled. + */ + applyCustomModelEndpointsVisibility() { + const enabled = document.getElementById('appSettingsCustomModelEndpoints').checked; + const body = document.getElementById('customModelEndpointsBody'); + if (body) body.style.display = enabled ? '' : 'none'; + if (enabled) this.loadCustomModelEndpointsForSettings(); + else this.closeCustomModelHostEditor(); + this._applyCustomModelAdminGate(); + }, + + /** + * Endpoint writes are admin-only in multi-user mode (custom-model-routes.ts), + * and GET already answers a non-admin with an empty list, which hides every + * per-row Edit/Discover/Delete button on its own. The "+ Add endpoint" button + * has no row to hide behind, so it needs its own gate — otherwise a non-admin + * can open the form, fill it in, and get a 403 toast on Save. Wired to the + * `codeman:me` event (admin-ui.js) as well as called from + * applyCustomModelEndpointsVisibility(), because `window.__codemanUser`'s + * real role can resolve AFTER settings have already been opened once. + */ + _applyCustomModelAdminGate() { + const addBtn = document.getElementById('customModelHostAddBtn'); + if (!addBtn) return; + const me = window.__codemanUser || {}; + const blocked = me.multiUser && me.role !== 'admin'; + addBtn.style.display = blocked ? 'none' : ''; + }, + async loadCustomModelEndpointsForSettings() { - try { - const res = await fetch('/api/model-endpoints'); - const hosts = await res.json(); - this._customModelHosts = Array.isArray(hosts) ? hosts : []; - } catch (err) { - console.warn('Failed to load model endpoints:', err); - this._customModelHosts = this._customModelHosts || []; - } + // GET /api/model-endpoints wraps its body in the { success, data } envelope + // like every other /api route (server.ts's preSerialization hook applies to + // arrays too) — _apiJson() unwraps it. A raw fetch().json() here would + // silently see the envelope object instead of the array and this panel + // would read as "No endpoints yet" forever, even with endpoints saved. + const hosts = await this._apiJson('/api/model-endpoints'); + this._customModelHosts = Array.isArray(hosts) ? hosts : []; this.renderCustomModelHostsList(); }, @@ -2533,6 +2571,14 @@ Object.assign(CodemanApp.prototype, { const modelSummary = modelCount === 0 ? 'No models discovered yet' : `${modelCount} model${modelCount === 1 ? '' : 's'}${h.defaultModelId ? ` · default: ${escapeHtml(h.defaultModelId)}` : ' · no default set'}`; + // escapeHtml(JSON.stringify(h.id)) — not JSON.stringify(h.id) alone — + // because JSON.stringify's own double quotes would otherwise terminate + // this double-quoted attribute at the first one, and everything after + // parses as raw tag content rather than the rest of the quoted string. + // Same idiom as deleteCase's onclick in session-ui.js. h.id is + // regex-constrained server-side (safe either way) but the pattern must + // match everywhere it is used, including where the argument is not. + const idArg = escapeHtml(JSON.stringify(h.id)); return `
@@ -2540,9 +2586,9 @@ Object.assign(CodemanApp.prototype, { ${escapeHtml(h.baseUrl)} — ${modelSummary}
- - - + + +
`; }) @@ -2558,8 +2604,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('customModelHostId').disabled = !!host; // id is immutable once created document.getElementById('customModelHostLabel').value = host?.label || ''; document.getElementById('customModelHostBaseUrl').value = host?.baseUrl || ''; - document.getElementById('customModelHostApiKey').value = ''; // never round-tripped back into the field - document.getElementById('customModelHostApiKey').placeholder = host?.apiKey ? '•••••••• (unchanged if left blank)' : ''; + document.getElementById('customModelHostApiKey').value = ''; // the server never returns the real value (apiKeySet is a bool) + document.getElementById('customModelHostApiKey').placeholder = host?.apiKeySet ? '•••••••• (unchanged if left blank)' : ''; document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer'; this._populateCustomModelDefaultSelect(host); document.getElementById('customModelHostEditor').style.display = ''; @@ -2592,6 +2638,13 @@ Object.assign(CodemanApp.prototype, { return; } const editing = this._editingCustomModelHostId; + // PUT (server-side) treats an absent apiKey as "keep the stored one" — the + // browser never holds the real value to resend deliberately unchanged (see + // openCustomModelHostEditor and custom-model-routes.ts's applyStoredApiKey), + // so a blank field here means omitting the key entirely, not resending + // something we do not have. models/lastDiscoveredAt DO still need + // re-sending: PUT replaces the whole record, and this cached copy still + // carries both (only apiKey is redacted from what GET hands back). const existing = editing ? (this._customModelHosts || []).find((h) => h.id === editing) : null; const body = { id, @@ -2599,10 +2652,7 @@ Object.assign(CodemanApp.prototype, { baseUrl, authStyle, defaultModelId, - // A blank key on EDIT means "leave it alone", never "clear it" — the field - // is never pre-filled with the real value (see openCustomModelHostEditor), - // so an unedited save must not silently wipe a working credential. - apiKey: apiKeyInput ? apiKeyInput : existing?.apiKey, + apiKey: apiKeyInput || undefined, models: existing?.models, lastDiscoveredAt: existing?.lastDiscoveredAt, }; @@ -3707,3 +3757,15 @@ Object.assign(CodemanApp.prototype, { this.subagentPanelVisible = false; }, }); + +// window.__codemanUser's real role can resolve after settings have already been +// opened once (admin-ui.js fetches /api/me asynchronously and dispatches this on +// arrival), so the Custom Model Endpoints admin gate needs to be re-applied when +// it does, not just when the modal opens. Optional chaining on addEventListener +// itself: several frontend tests (run-mode-ui.test.ts) load this file into a vm +// context with a minimal fake `document` that has no event-target methods at +// all, and a module-level statement that throws there fails the whole file's +// evaluation, not just this feature. +document.addEventListener?.('codeman:me', () => { + window.app?._applyCustomModelAdminGate?.(); +}); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 2fe9fb65..7c5b1cca 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -15134,6 +15134,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .run-mode-dot.web { background: #38bdf8; } .run-mode-webviews { max-height: 180px; overflow-y: auto; } +/* Custom Model Endpoint Profiles' generated entries: `.run-mode-menu.active`'s + own `gap: 2px` only spaces its DIRECT children, and this container (like + `.run-mode-webviews` above) is one such child holding several buttons of + its own, so it needs the same gap repeated one level down or its rows sit + flush against each other. */ +.run-mode-custom-models { display: flex; flex-direction: column; gap: 2px; } /* A saved URL is a ROW: open on the left, edit + delete on the right, so a URL can be changed or removed without first opening it as a tab. The side buttons stay @@ -16209,7 +16215,10 @@ html[data-tab-orientation='vertical'] .home-sessions { /* Custom Model Endpoint Profiles' inline add/edit form: a nested panel rather than a modal, so it needs its own border to read as a distinct sub-section - inside .set-group-body's flat row stack. */ + inside .set-group-body's flat row stack. `--control-bg` rather than a + hardcoded black alpha — CLAUDE.md records that literal fill turning the + settings live preview into a grey slab on the light skins, and this panel + sits in the very same modal. */ :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form { display: flex; flex-direction: column; @@ -16218,7 +16227,7 @@ html[data-tab-orientation='vertical'] .home-sessions { padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; - background: rgba(0, 0, 0, 0.12); + background: var(--control-bg); } :is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form h5 { diff --git a/src/web/routes/custom-model-routes.ts b/src/web/routes/custom-model-routes.ts index 0fc1b1f2..5519b99e 100644 --- a/src/web/routes/custom-model-routes.ts +++ b/src/web/routes/custom-model-routes.ts @@ -48,6 +48,30 @@ function invalidDefaultModel(host: Pick & { apiKeySet: boolean } { + const { apiKey, ...rest } = host; + return { ...rest, apiKeySet: !!apiKey }; +} + +/** + * A PUT body with no `apiKey` (or a blank one) means "leave it alone", never + * "clear it": the editor never receives the real value to resend deliberately + * unchanged (see redactApiKey), so the only way it can tell the two apart is + * by omission. There is deliberately no way to CLEAR a key back to unset this + * way — a pre-existing limitation, not something this changes. + */ +function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost { + return incoming.apiKey ? incoming : { ...incoming, apiKey: existing.apiKey }; +} + async function discoverModels(host: Pick): Promise { const headers: Record = {}; const apiKey = host.apiKey?.trim(); @@ -81,12 +105,16 @@ function describeFetchError(err: unknown): string { return message; } +type RedactedHost = ReturnType; + export function registerCustomModelRoutes(app: FastifyInstance): void { - app.get('/api/model-endpoints', async (req) => - isMultiUserMode() && !isAdmin(req) ? [] : readCustomModelHosts(CODEMAN_CONFIG_DIR) - ); + app.get('/api/model-endpoints', async (req): Promise => { + if (isMultiUserMode() && !isAdmin(req)) return []; + const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); + return hosts.map(redactApiKey); + }); - app.post('/api/model-endpoints', async (req, reply): Promise> => { + app.post('/api/model-endpoints', async (req, reply): Promise> => { const denied = adminOnly(req, reply); if (denied) return denied; const host = parseBody(CustomModelHostSchema, req.body); @@ -100,26 +128,27 @@ export function registerCustomModelRoutes(app: FastifyInstance): void { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists'); } await writeCustomModelHosts(CODEMAN_CONFIG_DIR, [...hosts, host]); - return { success: true, data: { host } }; + return { success: true, data: { host: redactApiKey(host) } }; }); - app.put('/api/model-endpoints/:id', async (req, reply): Promise> => { + app.put('/api/model-endpoints/:id', async (req, reply): Promise> => { const denied = adminOnly(req, reply); if (denied) return denied; const { id } = req.params as { id: string }; - const host = parseBody(CustomModelHostSchema, { ...(req.body as object), id }); - if (isBlockedWebviewUrl(host.baseUrl)) { + const incoming = parseBody(CustomModelHostSchema, { ...(req.body as object), id }); + if (isBlockedWebviewUrl(incoming.baseUrl)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed'); } - const badDefault = invalidDefaultModel(host); + const badDefault = invalidDefaultModel(incoming); if (badDefault) return badDefault; const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR); const index = hosts.findIndex((item) => item.id === id); if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); + const host = applyStoredApiKey(incoming, hosts[index]); const next = [...hosts]; next[index] = host; await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next); - return { success: true, data: { host } }; + return { success: true, data: { host: redactApiKey(host) } }; }); app.delete('/api/model-endpoints/:id', async (req, reply): Promise> => { diff --git a/src/web/server.ts b/src/web/server.ts index 54c6b4ab..0852b7f6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -207,6 +207,20 @@ function escapeHtmlText(value: string): string { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); } +/** + * Escapes a JSON string for safe embedding as the body of an inline `` would otherwise close the tag early + * and turn the rest of the document into inert script-body text. Exported so + * it unit-tests without constructing a WebServer (which needs a real tmux). + */ +export function escapeScriptJson(json: string): string { + return json.replace(/ entry.kind === 'agent' && entry.capabilities.customModelInjection.kind !== 'unsupported') .map((entry) => ({ id: entry.id, label: entry.label })); + // Unlike the boolean-only __codemanCliAvailable above, this payload carries + // `label`, a string a user's own clis.json can set (CliEntry.label, up to 60 + // chars) — see escapeScriptJson's own doc comment for why that needs escaping + // and __codemanCliAvailable's booleans never did. + const customModelClisJson = escapeScriptJson(JSON.stringify(customModelClis)); html = html.replace( '', - `\n` + `\n` ); } if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { diff --git a/test/custom-model-run-menu-ui.test.ts b/test/custom-model-run-menu-ui.test.ts new file mode 100644 index 00000000..0994e00b --- /dev/null +++ b/test/custom-model-run-menu-ui.test.ts @@ -0,0 +1,243 @@ +/** + * @fileoverview Frontend tests for the Custom Model Endpoint Profiles Run-menu + * picker (docs/custom-model-endpoints-plan.md): the generated entries in + * session-ui.js's `_refreshCustomModelRunOptions()` / `runCustomModelEntry()`. + * + * These are DOM-level facts that need no Playwright and no tmux — `runScripts: + * "dangerously"` is used deliberately (this JSDOM only ever parses markup this + * module itself generated, never live user input) so that a broken inline + * `onclick` attribute shows up as a genuinely uncallable handler, the same way + * it would in a real browser, rather than merely as a string this test parses + * by eye. `test/admin-ui.test.ts` and `test/home-sessions.test.ts` are the + * precedent for driving a real frontend module against a JSDOM window rather + * than a live server. + * + * Port: none. + */ +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { describe, expect, it } from 'vitest'; + +const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8'); +const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8'); + +function resp(body: unknown, ok = true) { + return { ok, json: async () => body }; +} + +/** + * Boots a minimal CodemanApp instance with constants.js + session-ui.js + * evaluated against a real JSDOM window, so escapeHtml and the picker's own + * innerHTML-building code run exactly as they do in the browser. + */ +function bootApp( + options: { + customModelClis?: Array<{ id: string; label: string }>; + hosts?: unknown; + cliAvailable?: (id: string) => boolean; + activeCase?: { location?: string } | null; + settingsEnabled?: boolean; + } = {} +) { + const dom = new JSDOM( + ` + + + +
+ + +
+
+ `, + { url: 'http://localhost/', runScripts: 'dangerously' } + ); + const win = dom.window as unknown as Window & + typeof globalThis & { + CodemanApp: new () => any; + __codemanCustomModelClis?: Array<{ id: string; label: string }>; + }; + (win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};'); + (win as unknown as { eval: (s: string) => void }).eval(CONSTANTS_JS); + (win as unknown as { eval: (s: string) => void }).eval(SESSION_UI_JS); + + win.__codemanCustomModelClis = options.customModelClis ?? [{ id: 'claude', label: 'Claude Code' }]; + + const app = new win.CodemanApp(); + app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }]; + app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true }); + app.isCliAvailable = options.cliAvailable ?? (() => true); + app.showToast = () => {}; + // _apiJson unwraps the {success,data} envelope for real against a live + // server; here it stands in for that, driven from a fixed `hosts` fixture + // so these tests exercise the picker's OWN code, not the envelope helper. + app._apiJson = async (path: string) => { + if (path === '/api/model-endpoints') return options.hosts ?? []; + return null; + }; + return { dom, win, app }; +} + +describe('Custom Model Endpoint Profiles: Run-menu picker generation', () => { + it('generates a real, clickable button per (capable CLI, endpoint) pair', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }], + }); + const menu = win.document.getElementById('runModeMenu')!; + await app._refreshCustomModelRunOptions(menu); + + const container = win.document.getElementById('runModeCustomModels')!; + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(1); + + const btn = buttons[0] as unknown as HTMLButtonElement & { onclick: unknown }; + // The real bug: JSON.stringify's own double quotes terminate the + // double-quoted onclick attribute at the first one, so btn.onclick comes + // back null and the parsed attribute is garbage. With escapeHtml wrapping + // each stringified argument, jsdom (which compiles inline handlers under + // runScripts:"dangerously" exactly like a real browser) parses it as a + // real, callable function. + expect(typeof btn.onclick).toBe('function'); + + win.app = app; + expect(() => btn.onclick!(new (win as any).Event('click'))).not.toThrow(); + }); + + it('escapes a model id containing HTML-significant characters instead of letting it break out of the tag', async () => { + // modelId comes from the endpoint's OWN /v1/models reply, which this box + // does not control — a live-HTML-injection vector if it ever reaches the + // markup unescaped, distinct from (and on top of) the quoting bug above. + const dangerousModel = '">'; + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: [dangerousModel] }], + }); + const menu = win.document.getElementById('runModeMenu')!; + await app._refreshCustomModelRunOptions(menu); + + const container = win.document.getElementById('runModeCustomModels')!; + // The injected markup must never have produced a live element: if it + // did, the attacker-controlled tag closed the button early and escaped + // into sibling markup instead of staying inert string data. + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelectorAll('button').length).toBe(1); + }); + + it('is hidden when the feature setting is off, even with capable CLIs and endpoints present', async () => { + const { win, app } = bootApp({ + settingsEnabled: false, + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }], + }); + const menu = win.document.getElementById('runModeMenu')!; + await app._refreshCustomModelRunOptions(menu); + expect(win.document.getElementById('runModeCustomModels')!.innerHTML).toBe(''); + expect((win.document.getElementById('runModeCustomModelSep') as HTMLElement).style.display).toBe('none'); + }); + + it('is hidden for a remote or Docker active case, since the apply route refuses both', async () => { + for (const location of ['remote', 'docker']) { + const { win, app } = bootApp({ + activeCase: { location }, + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }], + }); + const menu = win.document.getElementById('runModeMenu')!; + await app._refreshCustomModelRunOptions(menu); + expect(win.document.getElementById('runModeCustomModels')!.innerHTML, location).toBe(''); + } + }); + + it('skips an endpoint with no discovered model and no default, rather than generating a dead entry', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'undiscovered', label: 'Not discovered yet', baseUrl: 'http://localhost:8080', models: [] }], + }); + const menu = win.document.getElementById('runModeMenu')!; + await app._refreshCustomModelRunOptions(menu); + expect(win.document.getElementById('runModeCustomModels')!.innerHTML).toBe(''); + }); + + it('omits a CLI the host does not have installed, matching the stock entries’ own gating', async () => { + const { win, app } = bootApp({ + customModelClis: [ + { id: 'claude', label: 'Claude Code' }, + { id: 'codex', label: 'Codex' }, + ], + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }], + cliAvailable: (id: string) => id === 'claude', + }); + const menu = win.document.getElementById('runModeMenu')!; + await app._refreshCustomModelRunOptions(menu); + const container = win.document.getElementById('runModeCustomModels')!; + expect(container.querySelectorAll('button').length).toBe(1); + expect(container.textContent).toContain('Claude Code'); + expect(container.textContent).not.toContain('Codex'); + }); +}); + +describe('Custom Model Endpoint Profiles: applying a picked entry', () => { + it('does not apply the endpoint to a session that was already open when the launch fails', async () => { + const { app } = bootApp({}); + app.activeSessionId = 'already-open-session'; + // Simulate every run*() function's own documented behaviour: a declined or + // failed launch handles its own error and returns normally without ever + // changing activeSessionId — it does NOT throw and does NOT leave it null. + app.run = async () => {}; + app._runInFlight = false; + let applyCalled = false; + const realApiJson = app._apiJson.bind(app); + app._apiJson = async (path: string, opts?: unknown) => { + if (path.includes('/custom-model')) applyCalled = true; + return realApiJson(path, opts as never); + }; + + await app.runCustomModelEntry('claude', 'llama-box', 'qwen3'); + + expect(applyCalled).toBe(false); + expect(app.activeSessionId).toBe('already-open-session'); + }); + + it('applies the endpoint once run() actually produces a NEW active session', async () => { + const { app } = bootApp({}); + app.activeSessionId = 'old-session'; + app.run = async () => { + app.activeSessionId = 'new-session'; + }; + const calls: Array<{ path: string; body: unknown }> = []; + app._apiJson = async (path: string, opts?: { body?: unknown }) => { + calls.push({ path, body: opts?.body }); + return { customModel: { endpointId: 'llama-box' }, restarted: true }; + }; + + await app.runCustomModelEntry('claude', 'llama-box', 'qwen3'); + + expect(calls).toHaveLength(1); + expect(calls[0].path).toBe('/api/sessions/new-session/custom-model'); + expect(calls[0].body).toEqual({ endpointId: 'llama-box', modelId: 'qwen3' }); + }); + + it('routes through run() itself, so the Run in-flight lock actually engages', async () => { + // CLAUDE.md, Run launch synchronization: the lock exists so a double click + // cannot create duplicate sessions. A hardcoded dispatch table bypassing + // run() would never set _runInFlight, which is what this pins. + const { app } = bootApp({}); + let sawInFlight = false; + app.run = async function (this: typeof app) { + if (this._runInFlight) return; + this._runInFlight = true; + sawInFlight = true; + this._runInFlight = false; + }; + await app.runCustomModelEntry('claude', 'llama-box', 'qwen3'); + expect(sawInFlight).toBe(true); + }); + + it('restores the previous _runMode after a one-off custom-model launch, never persisting it', async () => { + const { app } = bootApp({}); + app._runMode = 'opencode'; + let modeDuringRun: string | undefined; + app.run = async function (this: typeof app) { + modeDuringRun = this._runMode; + }; + await app.runCustomModelEntry('claude', 'llama-box', 'qwen3'); + expect(modeDuringRun).toBe('claude'); + expect(app._runMode).toBe('opencode'); + }); +}); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index df5fb417..bc3de380 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -11,7 +11,7 @@ * Port: N/A (no server start). */ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { WebServer } from '../src/web/server.js'; +import { WebServer, escapeScriptJson } from '../src/web/server.js'; import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js'; import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js'; import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js'; @@ -209,6 +209,19 @@ describe('WebServer.renderIndexHtml', () => { } }); + it('escapeScriptJson neutralizes a literal , and still round-trips as a JS literal', () => { + // CliEntry.label is a plain string a user's own clis.json can set (up to 60 + // chars), unlike __codemanCliAvailable's booleans-only payload, so this is + // the one injection that needs it. Exported so this tests the pure + // function directly rather than needing a real WebServer (which needs tmux). + const dangerous = JSON.stringify([{ id: 'x', label: '' }]); + const escaped = escapeScriptJson(dangerous); + expect(escaped).not.toContain('". + expect(eval(escaped)[0].label).toBe(''); + }); + it('still emits the object when nothing at all is installed', async () => { // The all-false case is the one that matters most and the easiest to get // wrong by only injecting when something resolves. diff --git a/test/routes/custom-model-routes.test.ts b/test/routes/custom-model-routes.test.ts index 059f210d..16b7011f 100644 --- a/test/routes/custom-model-routes.test.ts +++ b/test/routes/custom-model-routes.test.ts @@ -295,3 +295,97 @@ describe('defaultModelId — the Run-menu picker’s per-endpoint default', () = expect(stored?.defaultModelId).toBe('qwen3'); }); }); + +describe('apiKey is never handed back to the browser', () => { + afterEach(() => { + fetchMock.mockReset(); + }); + + it('POST, GET and PUT responses all carry apiKeySet instead of the real key', async () => { + const { app } = await setup(); + const create = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'ep-secret', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'super-secret' }, + }); + expect(create.json().data.host.apiKey).toBeUndefined(); + expect(create.json().data.host.apiKeySet).toBe(true); + + const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' }); + const listed = (list.json() as Array<{ id: string; apiKey?: string; apiKeySet?: boolean }>).find( + (h) => h.id === 'ep-secret' + ); + expect(listed?.apiKey).toBeUndefined(); + expect(listed?.apiKeySet).toBe(true); + expect(JSON.stringify(list.json())).not.toContain('super-secret'); + + const update = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/ep-secret', + payload: { label: 'Renamed', baseUrl: 'http://localhost:8080' }, + }); + expect(update.json().data.host.apiKey).toBeUndefined(); + expect(update.json().data.host.apiKeySet).toBe(true); + expect(JSON.stringify(update.json())).not.toContain('super-secret'); + }); + + it('a host with no key set at all reports apiKeySet: false', async () => { + const { app } = await setup(); + const create = await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'ep-nokey', label: 'A', baseUrl: 'http://localhost:8080' }, + }); + expect(create.json().data.host.apiKeySet).toBe(false); + }); + + it('PUT with no apiKey keeps the stored one, rather than clearing it', async () => { + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'ep-keep-key', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'original-key' }, + }); + // Edit without touching the API key field — the real bug this guards: a + // browser round-trip that only ever sees apiKeySet, never the real value, + // must not accidentally send an empty string and wipe a working credential. + const update = await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/ep-keep-key', + payload: { label: 'Renamed', baseUrl: 'http://localhost:8080' }, + }); + expect(update.json().data.host.apiKeySet).toBe(true); + + // Prove it by observing the auth header discovery actually sends. + fetchMock.mockImplementation(async (_url: URL, init?: RequestInit) => { + const headers = init?.headers as Record; + expect(headers.Authorization).toBe('Bearer original-key'); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }); + const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-keep-key/discover-models' }); + expect(discover.json().success).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('PUT with a new apiKey replaces the stored one', async () => { + const { app } = await setup(); + await app.inject({ + method: 'POST', + url: '/api/model-endpoints', + payload: { id: 'ep-replace-key', label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'old-key' }, + }); + await app.inject({ + method: 'PUT', + url: '/api/model-endpoints/ep-replace-key', + payload: { label: 'A', baseUrl: 'http://localhost:8080', apiKey: 'new-key' }, + }); + + fetchMock.mockImplementation(async (_url: URL, init?: RequestInit) => { + const headers = init?.headers as Record; + expect(headers.Authorization).toBe('Bearer new-key'); + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }); + await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-replace-key/discover-models' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); From 5a9ff07f5787c60ec57675910d080e6ca418b48d Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:50:18 +0800 Subject: [PATCH 05/22] feat(custom-model): ask which model on launch when an endpoint has more than one, and re-discover models every 5 minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two enhancements requested after live-validating PR #430 against a real llama.cpp server: 1. Model picker dialog. Picking a Run-menu Custom Endpoints entry used to apply the endpoint's defaultModelId (or the first discovered model) silently. Now, via the new selectCustomModelEntry() (session-ui.js): - exactly one discovered model launches straight away, same as before - two or more open a new #customModelPickModal listing every discovered model; defaultModelId (if set) is marked but never auto-chosen, since the point of asking is letting ONE launch deliberately differ from the saved default, not just confirming it The endpoint is re-fetched at click time rather than trusting anything cached from the dropdown's own render, since the model list can have changed (the sweep below, or a settings-panel edit) since it opened. runCustomModelEntry() itself — the actual launch, routed through run() for the in-flight lock, snapshot-guarded against applying to the wrong session — is unchanged; it now just always receives an explicit model id from one of these two paths instead of computing one itself. 2. Periodic re-discovery. Every saved endpoint's models now refresh automatically every 5 minutes in the background (CUSTOM_MODEL_REDISCOVER_INTERVAL_MS, server.ts, registered the same way as the Codex plan-usage poll it sits beside — this.cleanup.setInterval, off under testMode), so a model the server starts or stops serving shows up without another manual "Discover" click. The manual POST .../discover-models route and the new refreshAllCustomModelHosts() sweep (custom-model-routes.ts) now share one pure merge step (applyDiscoveredModels: stamps lastDiscoveredAt, drops a defaultModelId that no longer appears) rather than two copies that could drift. The sweep is best-effort per host — one endpoint being unreachable on a cycle never blocks the others — and re-reads the store before each host's write, keyed by id, so a concurrent edit or delete from the settings panel always wins over a sweep that started before it. Tests: test/custom-model-endpoint-rediscovery.test.ts is a new, dedicated file for the sweep (kept separate from custom-model-routes.test.ts because that file's data dir is shared across every test in it — one temp HOME per FILE, not per test — which would make a sweep-touches-every-host assertion meaningless there). test/custom-model-run-menu-ui.test.ts gained a new describe block driving the real picker modal through JSDOM: single-model bypass, multi-model dialog with the default marked-not-chosen, picking a row closes the modal and launches with that exact model, the endpoint re-fetch, and the two "vanished by click time" toast paths. Docs: docs/custom-model-endpoints.md, docs/wiki/Custom-Model-Endpoints.md, docs/api-reference.md and CLAUDE.md's dense feature paragraph all updated — the last of these also caught up two sentences that had gone stale after the draft-review fixes landed (the picker routes through run() now, not a raw run*() call). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG --- CLAUDE.md | 2 +- docs/api-reference.md | 6 +- docs/custom-model-endpoints.md | 47 ++++--- docs/wiki/Custom-Model-Endpoints.md | 24 +++- src/web/public/i18n.js | 3 + src/web/public/index.html | 17 +++ src/web/public/session-ui.js | 81 ++++++++++- src/web/routes/custom-model-routes.ts | 52 +++++++- src/web/routes/index.ts | 2 +- src/web/server.ts | 21 +++ .../custom-model-endpoint-rediscovery.test.ts | 126 ++++++++++++++++++ test/custom-model-run-menu-ui.test.ts | 125 +++++++++++++++++ 12 files changed, 471 insertions(+), 35 deletions(-) create mode 100644 test/custom-model-endpoint-rediscovery.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2583c903..37653f21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,7 +227,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. The session version worked and was still wrong in use: it put a terminal tab on screen next to the web tab the user actually asked for, every single time, and nothing about a long-lived HTTP server needs to be a tab. ⚠️ What a session gave for free now has to be paid for explicitly, and every piece is load-bearing: **exactly one** server (a second click REUSES it rather than racing it for a port, which two sessions structurally could not do), **restarted when the browser authority changes** (`--trusted-host` fences dsh's `/api` against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two, so whoever asks last wins: the asker is by definition the origin about to load the page), **killed on shutdown** (`stopDeepSeekWeb()` in the server teardown, because the child is detached so its whole plugin tree can be signalled at once, which also means it would OUTLIVE Codeman and hold its port against the next start), and **failures returned to the caller**, since with no tab there is nowhere for a stack trace to land. ⚠️ The port search starts at dsh's own default 3080 and walks 40, never fixed: that default is precisely the port most likely to be taken already by the user's own `dsh web`, and hardcoding it killed this feature with EADDRINUSE once. Free-port detection BINDS rather than connects (a connect probe cannot tell "free" from "listening but not answering yet"), so it is racy by nature and the caller still waits for the server to really answer before reporting success. ⚠️ Both `POST` and `DELETE` sit at the **same privilege bar as the profile installer** (`canUsernameRunPrivilegedCommands`) even though the action reads as "open a page": booting a dsh profile executes the plugin code in it, and the server is a single shared instance, so stopping it in multi-user mode takes it out from under other users' tabs. -**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET /v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ Applying a selection **restarts the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` kills a WORKING pane, so a CLI whose launch declares a `fallback` chain (claude) gets the live conversation id pinned as `resumeSessionId` for that one respawn: `--session-id ` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume || --session-id ` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. **Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct but codex only speaks the Responses API since Feb 2026, which llama.cpp/llama-swap don't implement — a confirmed protocol gap, not a bug; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek reaches the server but gets a consistent `HTTP_404` (root cause not identified); antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. ⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces, extended to the one frontend surface that needs to know which CLIs support this. One entry per (capable CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)"; picking one runs that CLI's own existing `run*()` function unmodified (case creation, env overrides, the works — forced to a single instance) and then calls `POST /api/sessions/:id/custom-model` on the session it selects, reusing the fact every `run*()` ends by selecting its new session rather than a parallel create path. `CustomModelHost.defaultModelId` is what the picker applies with no further choice — settings-ui.js's Edit form is a select populated from that endpoint's own discovered `models`, the route rejects a value that isn't a member, and re-discovery drops a stale one rather than carrying it forward invalid. Entries are hidden for a remote/docker active case (the apply route refuses both) and for an endpoint with no discovered models at all (nothing to default to). +**Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`, design doc `docs/custom-model-endpoints-plan.md`; full stack — settings-panel CRUD + the Run-menu picker, on top of the backend below): points a session at a user-configured custom OpenAI-compatible endpoint — local (llama.cpp, DGX Spark, Strix Halo) or cloud (Azure AI Foundry, OpenRouter) — instead of its harness's native cloud backend. Endpoints are a read/write-array store (`custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`) discovered via `GET /v1/models`; `CustomModelHost.authStyle` is `'bearer'` (default, `Authorization: Bearer`) or `'api-key'` (Azure's convention) — **never both**, live-tested against a real server: sending both headers on one request reliably hangs it indefinitely, reproduced 3×. ⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/deepseek, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp — writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config — and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source — the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.]` block redirected via `GROK_HOME` — its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified. ⚠️ Applying a selection **restarts the session's CLI process in place** via `Session.restartCli()` — a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share — because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` kills a WORKING pane, so a CLI whose launch declares a `fallback` chain (claude) gets the live conversation id pinned as `resumeSessionId` for that one respawn: `--session-id ` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume || --session-id ` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/` for pi/omp, grok's `[model.codeman-custom]` block name): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model; never the values, which carry the API key and are re-derived from the endpoint store on recovery), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600. ⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`. **Confidence, verified end-to-end against a real llama-swap server via the DYNAMIC `scripts/test-local-llm-harnesses.ts`** (reads the live CLI registry, so a registry change needs zero script edits): claude/opencode/pi/grok/omp **PASS**; codex config structure is correct but codex only speaks the Responses API since Feb 2026, which llama.cpp/llama-swap don't implement — a confirmed protocol gap, not a bug; gemini fails with `Invalid auth method selected` (an undocumented `GATEWAY` AuthType gemini-cli selects once `GOOGLE_GEMINI_BASE_URL` is set — unresolved after real investigation); deepseek reaches the server but gets a consistent `HTTP_404` (root cause not identified); antigravity has no known mechanism at all. See the confidence table in `docs/custom-model-endpoints-plan.md` for the full detail on each. ⚠️ **The Run-menu picker generates entries from `window.__codemanCustomModelClis`** (`server.ts`, injected at page render from `enabledClis().filter(kind==='agent' && customModelInjection.kind!=='unsupported')`, JSON-escaped against a literal `` via the exported `escapeScriptJson()` since `label` is a user-`clis.json`-settable string unlike the neighbouring booleans-only `__codemanCliAvailable`), never a hardcoded per-CLI id list in the frontend — the same "no branching on CLI id outside stock.ts" discipline the registry itself enforces. One entry per (capable, INSTALLED CLI, saved endpoint) pair, e.g. "Claude Code (llama.cpp)", filtered through `isCliAvailable()` like the stock entries. Clicking one calls `selectCustomModelEntry(mode, endpointId)` (`session-ui.js`), which re-fetches the endpoint (never trusts anything cached from the dropdown's render — the 5-minute sweep below or a settings edit may have changed it since) and decides the model: exactly one discovered model launches straight away, two or more open `#customModelPickModal` to ask, with `defaultModelId` marked but never auto-chosen (asking exists so ONE launch can deliberately differ from the saved default). Either way the actual launch (`runCustomModelEntry`) routes through `run()` itself via a temporary `_runMode` swap — never `setRunMode()`, which would persist it as the user's new default — rather than a parallel dispatch table, which is what gives a custom-model launch the same `_runInFlight` lock every other Run click gets and means a CLI whose injection recipe lands later needs no update here. It then calls `POST /api/sessions/:id/custom-model` on the session `run()` produced, guarded by snapshotting `activeSessionId` before the call and requiring it to have actually changed after — every `run*()` handles its own failure internally and returns normally rather than throwing, so a declined/failed launch must not silently re-point and restart whatever session was already open. Entries are hidden for a remote/docker active case (the apply route refuses both) and for an endpoint with no discovered models at all (nothing to launch with). ⚠️ **Every saved endpoint's models also re-discover themselves automatically**, a `this.cleanup.setInterval` in `server.ts` (`CUSTOM_MODEL_REDISCOVER_INTERVAL_MS`, 5 minutes, off under `testMode` like the Codex plan-usage poll beside it) calling the exported `refreshAllCustomModelHosts()` (`custom-model-routes.ts`) — one endpoint unreachable on a cycle never blocks the others, and a read-modify-write PER HOST (re-reading the store before each splice, keyed by id) means an admin's concurrent edit or delete wins over a sweep that started before it, never the reverse. **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/api-reference.md b/docs/api-reference.md index 3a5dab66..9e274cb6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -551,7 +551,11 @@ user guide: [`custom-model-endpoints.md`](custom-model-endpoints.md). `lastDiscoveredAt`. A `defaultModelId` that no longer appears in the fresh list is dropped rather than carried forward invalid. Failures answer `502 OPERATION_FAILED` with the underlying connection error, or a named - egress refusal if the resolved address turned out to be blocked. + egress refusal if the resolved address turned out to be blocked. The same + refresh also runs automatically for every saved endpoint every 5 minutes + in the background (`refreshAllCustomModelHosts()`, `custom-model-routes.ts`, + started from `server.ts`), so there is no route for triggering "refresh + all" — one endpoint being unreachable on a cycle never blocks the others. - `POST /api/v1/sessions/:id/custom-model` with `{ endpointId, modelId } | { clear: true }` applies (or clears) the session's selection and **restarts the session's CLI process in place** — every supported harness diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index 24acc171..140f10dd 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -66,18 +66,26 @@ configured, `PUT`/`DELETE /api/model-endpoints/:id` update or remove one. Endpoint management is admin-only in multi-user mode, same as remote/docker hosts — these are machine-level infra, not per-user settings. -`defaultModelId` names which discovered model the Run-menu picker applies -for that endpoint with no further choice — the settings panel's Edit form -exposes it as a select populated from the endpoint's own discovered -`models`, and the route refuses a value that isn't one of them. Leaving it -unset falls back to the first discovered model; re-discovering drops a -default that no longer appears in the fresh list rather than carrying an -invalid one forward. +`defaultModelId` names which discovered model the picker pre-marks for that +endpoint — the settings panel's Edit form exposes it as a select populated +from the endpoint's own discovered `models`, and the route refuses a value +that isn't one of them. It is applied automatically only when the endpoint +has exactly one discovered model (nothing to choose); with two or more it +is a pre-selection in the model-picker dialog below, never a silent default. +Re-discovering drops a default that no longer appears in the fresh list +rather than carrying an invalid one forward. + +**Model lists refresh themselves.** A background sweep (`server.ts`, +`CUSTOM_MODEL_REDISCOVER_INTERVAL_MS`, every 5 minutes) re-discovers every +saved endpoint the same way the manual `POST .../discover-models` route +does, best-effort per endpoint — one being unreachable on a given cycle +never blocks the others. Off under `npm test`, same reasoning as the Codex +plan-usage poll it sits beside: no real network to hit, no server instance +to keep the timer alive for. ## The Run-menu picker -With the setting on and at least one endpoint carrying a usable default -model (either an explicit `defaultModelId` or just one discovered model), +With the setting on and at least one endpoint carrying a discovered model, the toolbar's Run dropdown grows a **Custom Endpoints** section: one entry per (harness that can redirect to a custom endpoint, saved endpoint) pair, e.g. "Claude Code (llama.cpp)". The harness list is read off the CLI @@ -86,13 +94,22 @@ registry's own `capabilities.customModelInjection` at page render in the frontend — so a CLI whose injection recipe lands later shows up with no frontend change, and Antigravity (`unsupported`) never does. -Picking an entry runs a single session on that harness exactly the way its +Picking an entry re-fetches the endpoint (`selectCustomModelEntry()`, +`session-ui.js`) rather than trusting anything cached from the dropdown's +own render — the model list can have changed via the 5-minute sweep above +or a settings-panel edit since the menu opened. With exactly one discovered +model it runs straight away; with two or more, a small modal +(`#customModelPickModal`) lists them and asks which one to use for this +launch, with the endpoint's `defaultModelId` marked but not auto-chosen — +the point of asking is letting one launch deliberately differ from the +saved default, not just confirming it. Whichever way the model was decided, +the launch itself runs a single session on that harness exactly the way its own Run-menu entry would (same case creation, env overrides, everything), -then immediately applies the endpoint's default model to it via the route -below. It is a one-off "try this endpoint" action, not a sticky mode: the -plain Run button still means "this harness, native cloud" afterward. -Entries are hidden entirely for a remote or Docker active case, since the -apply route refuses both (see the next section). +then immediately applies the endpoint and model to it via the route below. +It is a one-off "try this endpoint" action, not a sticky mode: the plain +Run button still means "this harness, native cloud" afterward. Entries are +hidden entirely for a remote or Docker active case, since the apply route +refuses both (see the next section). ## Applying a model to a session diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index a989a858..8fccaab3 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -16,20 +16,32 @@ Still in App Settings → Models → Custom model endpoints: say). An API key is optional; most local servers don't check one. 2. **Discover** — fetches the endpoint's own model list over `GET /v1/models` and stores it. 3. Pick a **default model** from what was discovered. This is the model the Run-menu entry - below applies with no further choice, so set it once you know which one you want. + applies directly when only one model is discovered; with two or more, it's just the one + pre-marked in the picker dialog described below, not a silent default. Endpoint management is admin-only in multi-user mode, the same as remote hosts and Docker hosts — these are machine-level infra, not a per-user setting. +**Model lists refresh themselves.** Every saved endpoint is re-discovered automatically every +5 minutes in the background, so a model the server starts serving later — or stops serving — +shows up without another manual click of **Discover**. One endpoint being unreachable on a +given cycle (powered off, wrong network) never blocks the others from refreshing. + ## Running a session against one -With the setting on and at least one endpoint carrying a usable default model, the **Run** +With the setting on and at least one endpoint carrying a discovered model, the **Run** dropdown grows a **Custom Endpoints** section: one entry per harness that can redirect to a custom endpoint, per saved endpoint, e.g. "Claude Code (llama.cpp)". Picking one starts a -session on that harness exactly the way its own entry would, then points it at the -endpoint's default model. It is a one-off "try this endpoint" action, not a sticky mode — the -plain **Run** button still means "this harness, native cloud" afterward, and a fresh session -never inherits whatever the last one was pointed at. +session on that harness exactly the way its own entry would. It is a one-off "try this +endpoint" action, not a sticky mode — the plain **Run** button still means "this harness, +native cloud" afterward, and a fresh session never inherits whatever the last one was +pointed at. + +**Which model it uses depends on how many the endpoint has discovered.** With exactly one, +the session launches straight away on that model — nothing to choose. With two or more, a +small dialog asks which one to use for this launch before starting the session; the +endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately +use a different one without changing the saved default. Applying a selection **restarts the harness's process in place** — same tab, same conversation where the harness supports resuming one, fresh environment. That restart is diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index d72cc33a..3a0fbc5b 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -311,6 +311,9 @@ 'What the Run-menu picker applies for this endpoint. Discover models first.': '运行菜单选择器会为此端点应用该模型。请先发现可用模型。', 'Custom Endpoints': '自定义端点', + 'Choose a model': '选择模型', + 'That endpoint no longer exists': '该端点已不存在', + 'No models discovered for this endpoint yet': '此端点尚未发现任何模型', 'Subagent Options': '子智能体选项', 'Enable Tracking': '启用跟踪', 'Active Tab Only': '仅活动标签页', diff --git a/src/web/public/index.html b/src/web/public/index.html index 65007ffc..244ae124 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -910,6 +910,23 @@

Add URL

+ + + + `, { url: 'http://localhost/', runScripts: 'dangerously' } ); @@ -68,6 +73,10 @@ function bootApp( app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true }); app.isCliAvailable = options.cliAvailable ?? (() => true); app.showToast = () => {}; + // Default no-op so a button's onclick (selectCustomModelEntry -> possibly + // straight to runCustomModelEntry for a single-model host) never rejects + // with "this.run is not a function"; tests of the launch itself override it. + app.run = async () => {}; // _apiJson unwraps the {success,data} envelope for real against a live // server; here it stands in for that, driven from a fixed `hosts` fixture // so these tests exercise the picker's OWN code, not the envelope helper. @@ -172,6 +181,122 @@ describe('Custom Model Endpoint Profiles: Run-menu picker generation', () => { }); }); +describe('Custom Model Endpoint Profiles: the "which model" picker', () => { + it('launches straight away for a host with exactly one discovered model, no dialog', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3'] }], + }); + let launched: unknown[] | null = null; + app.runCustomModelEntry = async (...args: unknown[]) => { + launched = args; + }; + + await app.selectCustomModelEntry('claude', 'llama-box'); + + expect(launched).toEqual(['claude', 'llama-box', 'qwen3']); + expect(win.document.getElementById('customModelPickModal')!.classList.contains('active')).toBe(false); + }); + + it('opens the picker for a host with more than one discovered model, rather than launching directly', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3', 'llama3'] }], + }); + let launched = false; + app.runCustomModelEntry = async () => { + launched = true; + }; + + await app.selectCustomModelEntry('claude', 'llama-box'); + + expect(launched).toBe(false); + const modal = win.document.getElementById('customModelPickModal')!; + expect(modal.classList.contains('active')).toBe(true); + const list = win.document.getElementById('customModelPickList')!; + expect(list.querySelectorAll('button').length).toBe(2); + expect(list.textContent).toContain('qwen3'); + expect(list.textContent).toContain('llama3'); + }); + + it('always asks with 2+ models, even when a defaultModelId is set — the point is letting this launch differ', async () => { + const { win, app } = bootApp({ + hosts: [ + { + id: 'llama-box', + label: 'llama.cpp', + baseUrl: 'http://localhost:8080', + models: ['qwen3', 'llama3'], + defaultModelId: 'qwen3', + }, + ], + }); + await app.selectCustomModelEntry('claude', 'llama-box'); + const modal = win.document.getElementById('customModelPickModal')!; + expect(modal.classList.contains('active')).toBe(true); + // The default is marked, not auto-chosen. + expect(win.document.getElementById('customModelPickList')!.textContent).toContain('Default'); + }); + + it('picking a row in the modal closes it and launches with that exact model', async () => { + const { win, app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3', 'llama3'] }], + }); + let launched: unknown[] | null = null; + app.runCustomModelEntry = async (...args: unknown[]) => { + launched = args; + }; + win.app = app; + + await app.selectCustomModelEntry('claude', 'llama-box'); + const buttons = win.document.getElementById('customModelPickList')!.querySelectorAll('button'); + const llama3Btn = [...buttons].find((b) => b.textContent?.includes('llama3')) as unknown as HTMLButtonElement & { + onclick: (e: unknown) => void; + }; + expect(typeof llama3Btn.onclick).toBe('function'); + llama3Btn.onclick(new (win as any).Event('click')); + + expect(launched).toEqual(['claude', 'llama-box', 'llama3']); + expect(win.document.getElementById('customModelPickModal')!.classList.contains('active')).toBe(false); + }); + + it('re-fetches the endpoint at click time rather than trusting anything cached from the menu render', async () => { + // The background re-discovery sweep (server-side, every 5 minutes) or a + // settings-panel edit can change the model list between opening the + // dropdown and clicking a row — the picker must reflect what is current. + let fetchCount = 0; + const { win, app } = bootApp({}); + app._apiJson = async (path: string) => { + if (path !== '/api/model-endpoints') return null; + fetchCount += 1; + return [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }]; + }; + await app.selectCustomModelEntry('claude', 'llama-box'); + expect(fetchCount).toBe(1); + expect(win.document.getElementById('customModelPickList')!.querySelectorAll('button').length).toBe(3); + }); + + it('toasts and does nothing when the endpoint has vanished by click time', async () => { + const { app } = bootApp({ hosts: [] }); + let toastMessage: string | null = null; + app.showToast = (msg: string) => { + toastMessage = msg; + }; + await app.selectCustomModelEntry('claude', 'ghost-endpoint'); + expect(toastMessage).toMatch(/no longer exists/i); + }); + + it('toasts and does nothing when the endpoint has zero discovered models by click time', async () => { + const { app } = bootApp({ + hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: [] }], + }); + let toastMessage: string | null = null; + app.showToast = (msg: string) => { + toastMessage = msg; + }; + await app.selectCustomModelEntry('claude', 'llama-box'); + expect(toastMessage).toMatch(/no models discovered/i); + }); +}); + describe('Custom Model Endpoint Profiles: applying a picked entry', () => { it('does not apply the endpoint to a session that was already open when the launch fails', async () => { const { app } = bootApp({}); From 9a9e542a7dad5d077c7b1c30f03b9807921ceb8e Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:23:14 +0800 Subject: [PATCH 06/22] fix(custom-model): bound the model-picker dialog's height and make its list scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog had no max-height at all, so an endpoint with many discovered models grew it past the viewport with nothing to scroll — reported live as both "takes up the full page" and "the list is truncated", which turn out to be the same bug. Gives #customModelPickModal .modal-content the same bounded-height + scrollable-body shape cronModal's .modal-lg already uses (max-height + flex column on the content, overflow-y:auto + flex:1 on the body), scoped by id rather than folded into the shared .modal-sm class three other modals already use for short, fixed content. max-height: min(70vh, 520px) scales with the viewport (a phone gets 70% of its height; a 4K display never gets a needlessly tall dialog) rather than committing to one fixed pixel value that would be wrong at either end. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG --- src/web/public/index.html | 2 +- src/web/public/styles.css | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index 244ae124..b962b55d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -915,7 +915,7 @@

Add URL

selectCustomModelEntry() in session-ui.js, which skips straight to launch otherwise. -->