mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(custom-model): merge-time fixes for the promoted-model picker (#459)
The maintainer's promised follow-ups to opticon454's picker promotion, applied on the landing branch after the merge (ecb95b5d): - session-ui.js: the promotion tag ("Currently loaded" / "Last used") and the "Default" pill are two separate spans, so a promoted row that is also the endpoint's defaultModelId shows both instead of silently losing its Default marking; two tests pin it (both fail on the old exclusive-slot rendering). - styles.css: a dedicated #customModelPickModal .set-scope rule, since the pill was only styled inside the three settings modals and rendered as plain body text here; same skin tokens, modal layout untouched. - docs/wiki/Custom-Model-Endpoints.md: describe the promotion (currently loaded, else last used per device), the separate Default pill, and that nothing is ever auto-chosen. - CLAUDE.md + docs/custom-model-endpoints.md: credit the real "Last used" writers (_runCustomModelEntryViaRestart and _quickStartWithCustomModelConfirm; runCustomModelEntry only dispatches since88e5b7b2) and drop the now-wrong "both defer to Default" sentence. - Not done: moving the one-shot "last used" write into _runCustomModelEntryOneShot, because the existing one-shot tests assert that _quickStartWithCustomModelConfirm writes the key itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 0cb0f911adc14a852ba5c2951768a4aa87c25657)
This commit is contained in:
1 parent
6ef71ec3b9
commit
d3f2ec0220
6 files changed
+111
-12
No files matched your search
@@ -229,7 +229,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.
|
**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 <baseUrl>/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.<name>]` 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. ⚠️ **Two launch paths, chosen by mechanism, not preference — see the second paragraph below for why**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting 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 <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <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/<id>` 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 `privileLine truncated
|
**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 <baseUrl>/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.<name>]` 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. ⚠️ **Two launch paths, chosen by mechanism, not preference — see the second paragraph below for why**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting 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 <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <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/<id>` 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 `privileLine truncated
|
||||||
|
|
||||||
**Everything below landed after the initial backend + picker cut, each confirmed live against a real llama-swap deployment.** ⚠️ **llama-swap runs one model at a time, and switching can disrupt ANOTHER live session** — before applying, both apply routes call llama-swap's own `GET /running` (feature-detected via `getLlamaSwapStatus()`, `custom-model-routes.ts`; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is simply never checked). If a different model is loaded and ready AND another live session's own selection is using it, the apply returns `{requiresConfirmation, currentlyLoadedModel, affectedSessions}` instead of switching silently; retrying with `confirmedSwap: true` skips the check, and switching with nothing else affected proceeds immediately. ⚠️ **The swap question and the context-floor question below have SEPARATE flags** (`confirmedSwap`, `confirmedContext`), because the context check runs first and while both shared one `confirmed` a user who clicked past a too-small context silently consented to evicting another session's model too; the legacy `confirmed` still means both, since it shipped in the HTTP-API-only cut. llama-swap also has no dedicated "switch model" endpoint — the only thing that actually starts a swap is a real inference request naming the model (confirmed live: applying a selection alone never reached llama-swap's own logs, since nothing had asked it to load anything) — so both routes also fire `triggerLlamaSwapLoad()`, a fire-and-forget `POST <baseUrl>/v1/chat/completions` with `max_tokens: 1`, whenever the target model isn't already loaded and ready. ⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one. ⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. ⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. ⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasLine truncated
|
**Everything below landed after the initial backend + picker cut, each confirmed live against a real llama-swap deployment.** ⚠️ **llama-swap runs one model at a time, and switching can disrupt ANOTHER live session** — before applying, both apply routes call llama-swap's own `GET /running` (feature-detected via `getLlamaSwapStatus()`, `custom-model-routes.ts`; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is simply never checked). If a different model is loaded and ready AND another live session's own selection is using it, the apply returns `{requiresConfirmation, currentlyLoadedModel, affectedSessions}` instead of switching silently; retrying with `confirmedSwap: true` skips the check, and switching with nothing else affected proceeds immediately. ⚠️ **The swap question and the context-floor question below have SEPARATE flags** (`confirmedSwap`, `confirmedContext`), because the context check runs first and while both shared one `confirmed` a user who clicked past a too-small context silently consented to evicting another session's model too; the legacy `confirmed` still means both, since it shipped in the HTTP-API-only cut. llama-swap also has no dedicated "switch model" endpoint — the only thing that actually starts a swap is a real inference request naming the model (confirmed live: applying a selection alone never reached llama-swap's own logs, since nothing had asked it to load anything) — so both routes also fire `triggerLlamaSwapLoad()`, a fire-and-forget `POST <baseUrl>/v1/chat/completions` with `max_tokens: 1`, whenever the target model isn't already loaded and ready. ⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one. ⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. ⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. ⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasLine truncated
|
||||||
|
|
||||||
|
|||||||
@@ -196,14 +196,20 @@ under your thumb:
|
|||||||
- **"Last used"** — shown only when nothing is currently loaded: the model
|
- **"Last used"** — shown only when nothing is currently loaded: the model
|
||||||
actually launched last for this exact (harness, endpoint) pair, read
|
actually launched last for this exact (harness, endpoint) pair, read
|
||||||
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
|
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
|
||||||
localStorage key. Written by `runCustomModelEntry` /
|
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
|
||||||
`_quickStartWithCustomModelConfirm` only once the model is actually
|
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
|
||||||
applied, never on the mere click — declining the context-window warning
|
`runCustomModelEntry` entry point itself only dispatches between the
|
||||||
means this exact model cannot work with this CLI at all, so promoting it
|
two) only once the model is actually applied, never on the mere click —
|
||||||
next time would be actively wrong, not just premature.
|
declining the context-window warning means this exact model cannot work
|
||||||
|
with this CLI at all, so promoting it next time would be actively wrong,
|
||||||
|
not just premature.
|
||||||
|
|
||||||
Neither tag reorders anything past that one promoted row, and both defer
|
Neither tag reorders anything past that one promoted row. The "Default"
|
||||||
to "Default" when neither applies.
|
pill is a separate span, not a third value of the same slot: a promoted
|
||||||
|
row that is also the endpoint's `defaultModelId` shows both tags (on a
|
||||||
|
single-purpose GPU box that is the common case, and an exclusive slot
|
||||||
|
silently dropped the Default marking for exactly that row), and a row
|
||||||
|
with neither promotion nor default shows no tag at all.
|
||||||
|
|
||||||
**How the launch itself applies the endpoint depends on the harness.** For
|
**How the launch itself applies the endpoint depends on the harness.** For
|
||||||
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
|
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ pointed at.
|
|||||||
the session launches straight away on that model — nothing to choose. With two or more, a
|
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
|
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
|
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.
|
use a different one without changing the saved default. The list is not raw discovery order
|
||||||
|
either: the model llama-swap reports loaded and ready is moved to the top and tagged
|
||||||
|
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
|
||||||
|
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
|
||||||
|
another device starts from its own history). The default model keeps its own **Default** pill
|
||||||
|
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||||
|
thumb.
|
||||||
|
|
||||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
|
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
|
||||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||||
|
|||||||
@@ -825,12 +825,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
||||||
list.innerHTML = models
|
list.innerHTML = models
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const isDefault = m === host.defaultModelId;
|
// Two independent tags, never one exclusive slot: the promotion tag says what
|
||||||
const tag = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : isDefault ? 'Default' : null;
|
// llama-swap (or this device's history) knows about the model, the Default pill
|
||||||
|
// says what the saved endpoint says about it, and on a single-purpose GPU box the
|
||||||
|
// promoted model IS the default more often than not. One slot holding whichever
|
||||||
|
// applied first silently dropped the Default marking for exactly that row.
|
||||||
|
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||||
|
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||||
const arg = escapeHtml(JSON.stringify(m));
|
const arg = escapeHtml(JSON.stringify(m));
|
||||||
return `
|
return `
|
||||||
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tag ? ` <span class="set-scope">${escapeHtml(tag)}</span>` : ''}
|
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
|
||||||
</button>`;
|
</button>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|||||||
@@ -6869,6 +6869,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The picker's row tags ("Currently loaded", "Last used", "Default") reuse the
|
||||||
|
settings surface's .set-scope pill, but that pill is styled only inside
|
||||||
|
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) (see the
|
||||||
|
settings-surface block), so in here it rendered as plain body text and
|
||||||
|
"qwen3 Currently loaded" read as one model name. Same pill, same skin
|
||||||
|
tokens (never a hardcoded colour: --text-muted / --border are what each
|
||||||
|
html[data-skin] block redefines), and nothing about the modal's layout or
|
||||||
|
z-index. The row is a flex container, so the pills sit after the name
|
||||||
|
with the row's own gap and the trailing whitespace collapses. */
|
||||||
|
#customModelPickModal .set-scope {
|
||||||
|
font-size: 0.52rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||||
|
|||||||
@@ -335,6 +335,67 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
|||||||
expect(buttons[0].textContent).toContain('Currently loaded');
|
expect(buttons[0].textContent).toContain('Currently loaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a currently-loaded row that is also the endpoint default shows BOTH tags, as two separate spans', async () => {
|
||||||
|
// The common case on a single-purpose GPU box: the one model that is loaded is the
|
||||||
|
// saved default too. An exclusive tag slot (promotion, else Default) silently dropped
|
||||||
|
// the Default marking for exactly that row.
|
||||||
|
const { win, app } = bootApp({
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
id: 'llama-box',
|
||||||
|
label: 'llama.cpp',
|
||||||
|
baseUrl: 'http://x',
|
||||||
|
models: ['qwen3', 'llama3', 'phi4'],
|
||||||
|
defaultModelId: 'phi4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const origApiJson = app._apiJson;
|
||||||
|
app._apiJson = async (path: string) => {
|
||||||
|
if (path === '/api/model-endpoints/llama-box/running-status') {
|
||||||
|
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
|
||||||
|
}
|
||||||
|
return origApiJson(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||||
|
|
||||||
|
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||||
|
expect(buttons[0].textContent).toContain('phi4');
|
||||||
|
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
|
||||||
|
expect(tags).toEqual(['Currently loaded', 'Default']);
|
||||||
|
// Rows with neither a promotion nor the default carry no tag at all.
|
||||||
|
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||||
|
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a "last used" row that is also the endpoint default shows both tags too', async () => {
|
||||||
|
const { win, app } = bootApp({
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
id: 'llama-box',
|
||||||
|
label: 'llama.cpp',
|
||||||
|
baseUrl: 'http://x',
|
||||||
|
models: ['qwen3', 'llama3', 'phi4'],
|
||||||
|
defaultModelId: 'llama3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const origApiJson = app._apiJson;
|
||||||
|
app._apiJson = async (path: string) => {
|
||||||
|
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
|
||||||
|
return origApiJson(path);
|
||||||
|
};
|
||||||
|
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
|
||||||
|
|
||||||
|
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||||
|
|
||||||
|
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||||
|
expect(buttons[0].textContent).toContain('llama3');
|
||||||
|
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
|
||||||
|
expect(tags).toEqual(['Last used', 'Default']);
|
||||||
|
});
|
||||||
|
|
||||||
it('is not fooled by a model llama-swap reports loaded but not yet ready, or one this host no longer lists', async () => {
|
it('is not fooled by a model llama-swap reports loaded but not yet ready, or one this host no longer lists', async () => {
|
||||||
const { win, app } = bootApp({
|
const { win, app } = bootApp({
|
||||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||||
|
|||||||
Reference in new issue
Block a user