feat: self-updating local-model backends (backend, steps 1–3)#293
feat: self-updating local-model backends (backend, steps 1–3)#293mriechers wants to merge 8 commits into
Conversation
Session-in-flight checkpoint committed by /start drift reconciliation so the work is pushed and recoverable. Not review-ready: eval pipeline + house-style normalizer POC scripts, llm/secrets service changes, oMLX backend config, and planning doc are all mid-stream. [Agent: Claude Code] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0179oo9iE6vYCLry74udAp5X
[Agent: Main Assistant] Scopes a self-contained "add a local model and it just appears" feature out of draft #291: multi-source model roster (query each enabled local backend's /v1/models), route on the (backend, model) pair, and a backend-definition CRUD + Settings panel. Local models label themselves by serving software (provider from owned_by -> oMLX) and host (studio.riechers.co:8000) straight from discovery -- onboarding a new model is data, never code. Branched off the oMLX backend commit (40bf6c1) so it excludes the deterministic-pipeline docs and merges against main on its own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR
[Agent: Main Assistant] Step 1 of the self-updating local-model backends design (planning/2026-07-10-self-updating-local-model-backends.md). Previously a local backend set force_model, which made its single configured model win over any per-phase assignment -- so only one local model was ever usable. Remove the force_model short-circuit in LLMClient.chat(): a phase now routes on the pair, taking the per-phase assigned model id and sending it to the backend's endpoint. The backend's own model (honoring the LOCAL_LLM_MODEL env override) becomes the FALLBACK, used only when no explicit or per-phase model is assigned. This lets any model a local server offers be selected per phase -- the precondition for the discovery/roster work in later steps. - api/services/llm.py: drop the force_model branch; fallback via _resolve_model (still honors model_env) then fallback_model. - config/llm-config.json + test fixture: remove the now-dead force_model flag. - tests: assignment wins for local and cloud backends; model_env is the fallback when unassigned; assignment beats model_env (regression guard against reintroducing a single-model override). Verified: full suite (850 passed, 5 xfailed) + a live call against oMLX (studio.riechers.co:8000) confirming an assigned model id (Qwen2.5-7B-Instruct- 4bit) is what reaches the server, at $0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR
[Agent: Main Assistant] Step 2 of the self-updating local-model backends design (planning/2026-07-10-self-updating-local-model-backends.md). get_available_models() now also queries each enabled, discover-flagged local (openai-type) backend's /v1/models and merges the results UNFILTERED alongside the OpenRouter roster -- so a brand-new model on the server appears after a refresh with no code or config-file edit. The model_families pattern filter is bypassed for local models (it would otherwise drop unknown ids). Each discovered entry is tagged by serving software (provider from the server's owned_by, e.g. "omlx" -> "oMLX"), host, and backend routing key, at $0, with context length from max_model_len. A backend that errors contributes nothing (logged) -- discovery is never fatal. The combined roster is cached only when the cloud fetch succeeded (unchanged), so a fallback still retries next call. - api/services/model_roster.py: fetch_local_models() + merge; provider-label map; /v1/models URL + host derivation. - config/llm-config.json: local-llm opts in with discover: true. - tests: tagging, unfiltered, non-fatal on unreachable, URL+auth derivation, cloud+local merge. Verified: full suite (853 passed, 5 xfailed). The real oMLX /v1/models catalog was confirmed live earlier this session; the end-to-end discovery run against the box was blocked only by a 1Password CLI session re-lock (auth, not code). Endpoint response, URL derivation, and payload tagging are covered deterministically by the unit tests using the real oMLX payload shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR
[Agent: Main Assistant] Step 3 of the self-updating local-model backends design (planning/2026-07-10-self-updating-local-model-backends.md). Adds GET/POST/PATCH/DELETE /config/backends so a user can register a local OpenAI-compatible endpoint as a first-class, discoverable, routable backend without hand-editing llm-config.json: - POST derives the backend key from the endpoint HOST (self-identifying, no invented names like local-llm-2), forces type=openai, defaults discover + enabled on and cost_per_project 0.0; 409 on duplicate host, 400 on a non-URL endpoint. - GET lists all backends; PATCH toggles/edits fields (404 if missing); DELETE removes (404 if missing, 409 if still referenced by primary_backend, fallback_backend, or phase_backends -- routing can't be orphaned). - Every mutation persists via _save_config (which reloads the live client) and invalidates the roster cache so a new server's models surface on next refresh. The generic OpenAI client (step 1) and multi-source roster (step 2) already consume whatever is written, so no client/roster changes were needed -- a new server is onboarded as data, not code. Verified: 8 TestClient integration tests (real ASGI app -> handler -> file persist -> client reload) + full suite (861 passed, 5 xfailed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR
|
@claude please review |
|
Claude finished @mriechers's task in 3m 36s —— View job Code Review — PR #293
Overall this is a well-scoped PR with a clear design goal. The host-keyed backend identity, the (backend, model) routing pair, and the non-fatal discovery design are all solid choices. I found one bug that will cause a 500 in production as soon as a local backend is active, one edge-case crash, and a few lower-severity items. Bug —
|
|
Code review Two confirmed issues found: one security vulnerability and one logic bug. Issue 1 — SSRF + server-secret exfiltration (api/routers/config.py) The only URL validation in create_backend is urlparse(body.endpoint).netloc being non-empty — no scheme restriction, no private-IP blocklist. api_key_env accepts any string with no allowlist. Chained exploit path:
The homelab deployment is unauthenticated (tailnet-only), so no API key is needed. Suggested fixes: restrict endpoint scheme to http/https and block private/link-local ranges (RFC 1918, 169.254.0.0/16, ::1) in both create_backend and update_backend; restrict api_key_env to an explicit allowlist and reject values containing / or ... Issue 2 — _resolve_model empty-string env fallback bug (api/services/llm.py L342) Lines 340 to 344 in cba9107 os.getenv(env_var, config.get('model')) returns '' (not the default) when the env var is set but empty. docker-compose.prod.yml sets LOCAL_LLM_MODEL=${LOCAL_LLM_MODEL:-}, injecting an empty string when the host env var is unset. _resolve_model() returns '', which or backend_config.get('fallback_model') discards (falsy) — since local-llm has no fallback_model, model_id = None is sent to the backend in the default deploy state. Fix: change line 342 from os.getenv(env_var, config.get('model')) to os.getenv(env_var) or config.get('model'). |
…ep 4) (#296) * feat(config): local models in /config/models + route-on-pair on assignment [Agent: Main Assistant] Step 4 backend of the self-updating local-model backends design. - Fix a latent 500: AvailableModel required tier:int, but locally-discovered models carry tier:null -- GET /config/models would have errored the moment a local model was discovered (introduced by step 2's roster merge, uncaught because no test exercised /config/models with a discover backend). tier is now Optional and the response passes through backend/host/context_len so the per-phase dropdown can group models by serving server. - Route-on-pair on assignment: PATCH /config/models now also updates phase_backends to match the assigned model. Assigning a local model points the phase at its serving backend; assigning a cloud model resets the phase off a local backend (to the primary cloud backend) but leaves an existing cloud tier (e.g. openrouter-cheapskate) untouched. This makes a model assignment actually route end to end (the write-side counterpart to step 1's chat() resolution). Verified: full suite (865 passed, 5 xfailed); 4 new tests (schema pass-through + 3 pair-writing cases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR * feat(web): Models tab for local endpoints + grouped model picker [Agent: Main Assistant] Step 4 frontend of the self-updating local-model backends design. - New "Models" Settings tab: lists local OpenAI-compatible endpoints (from GET /config/backends), with an add-endpoint form (base URL + optional API-key env var name), enable/disable, remove, and rediscover. Adding an endpoint creates it, refreshes the roster, and shows its discovered model count. The key is never stored client-side -- it resolves from the named env var on the server. - Agents tab: the per-phase model picker now groups options with <optgroup> -- "Cloud" for OpenRouter models and "provider - host" (e.g. "oMLX - studio.riechers.co:8000") for each local server. Selecting a local model writes the (backend, model) pair server-side so it routes correctly. - Grouping logic extracted to a pure util (groupAvailableModels) with vitest coverage. Verified: `tsc --noEmit` clean; vite production build clean; grouping unit tests pass (3). Live/visual check (real oMLX models appearing in the picker) still pending -- needs the app running + 1Password unlocked for the oMLX key. (Pre-existing, unrelated failure in useWebSocket.test.tsx StrictMode case -- not touched by this change.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR * fix(web): exclude non-discover openai backends from the Models tab list [Agent: Main Assistant] Found during a live local run: the built-in openai / openai-mini presets are also type "openai", so the "Local endpoints" list matched them. Filter to discover-flagged openai backends -- the set of actual local model sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR * fix(config): address #296 review — memoize grouping, WCAG labels, cold-roster guard Review response for the claude-review findings on #296: - perf: hoist groupAvailableModels above AGENT_INFO.map via useMemo — was recomputed once per agent, every render (M1) - a11y: aria-label the endpoint Enable/Disable + Remove buttons so screen readers announce which endpoint they act on — WCAG AA is mandatory (M2) - surface POST /models/refresh failures instead of silently continuing (L3) - guard update_phase_models against an empty roster: 503 instead of silently saving a model_id with a stale phase_backends pairing (L4) - add a fallback_backend referential-integrity delete test (L5) - assert group order directly (drop .sort()) so the alphabetical-by-label contract is under test (L6) - copy-before-sort in modelGroups to avoid mutating the map's array (nit) Deferred: the pre-existing update_phase_backends double llm.reload_config() is a follow-on per the reviewer (not introduced here), left out of scope. [Agent: Main Assistant] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRCornpfVYYWYhuXWf7Ei9 * fix(web): accessible red for the Remove-endpoint button (WCAG AA) Round-2 review follow-up for #296. The inline comment on the Remove button flagged a WCAG AA contrast failure: status-failed (#d64545) on surface-900 (#161a2a) is ~3.94:1 even at full opacity — below the 4.5:1 minimum for 12px text — and text-status-failed/80 made it worse. The palette has no lighter warm-red token, so use Tailwind red-400 (#f87171, ~6.3:1) with a red-300 hover, per the reviewer's suggestion. [Agent: Main Assistant] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XRCornpfVYYWYhuXWf7Ei9 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…overy # Conflicts: # prompts/seo.md # scripts/eval_pipeline.py # scripts/poc_house_style_normalizer.py
|
@claude please review Merged |
|
Claude finished @mriechers's task in 3m 40s —— View job Code Review — PR #293 (post-main-merge)
Merge resolution — cleanThe merge is clean. Conflicts were only in the eval/house-style artifacts ( The core PR changes (backend CRUD, roster discovery, What got fixed ✅
Remaining issues from prior reviewsBug —
|
Self-updating local-model backends — backend (steps 1–3)
Makes local LLMs first-class, self-onboarding agent options. North star:
Full design:
planning/2026-07-10-self-updating-local-model-backends.md(in this PR).This PR delivers the entire backend; the Settings UI (step 4) follows as a separate PR.
What's here
1e395d2) —chat()sends a phase's assigned model to its backend; the backend's own model (honoringLOCAL_LLM_MODEL) is the fallback. Dropped theforce_modelsingle-model lock so any served model is selectable per phase. Verified live against oMLX at $0.174dbb2) —get_available_models()merges eachdiscover-flagged local backend's/v1/modelsunfiltered (a new model is never dropped), tagged by serving software (providerfromowned_by→oMLX), host,$0, and context length. A down endpoint is non-fatal.cba9107) —GET/POST/PATCH/DELETE /config/backends, host-keyed (self-identifying, nolocal-llm-2), forcestype: openai, defaults discover+enabled and$0; guards duplicate host (409), invalid URL (400), and refuses to delete a backend still routing traffic (409).Follow-up (separate PR)
CloudvsoMLX · host.Verification
studio.riechers.co:8000): an assigned model id reaches the server at $0. Roster URL-derivation/tagging + the CRUD are covered by unit + ASGI-integration tests. (A fresh live discovery run was blocked only by a 1Password CLI session re-lock — auth, not code.)Relationship to #291
Branched off #291's oMLX-backend commit (
40bf6c1), excluding its deterministic-pipeline docs. This PR is the de-scoped "generic local backend" feature; #291 is repurposed for the separate deterministic-pipeline epic (which keeps those docs).🤖 Generated with Claude Code
https://claude.ai/code/session_01FSw8KPi4k2MKgZ1AKjb5jR