Skip to content

feat: self-updating local-model backends (backend, steps 1–3)#293

Open
mriechers wants to merge 8 commits into
mainfrom
feat/local-model-discovery
Open

feat: self-updating local-model backends (backend, steps 1–3)#293
mriechers wants to merge 8 commits into
mainfrom
feat/local-model-discovery

Conversation

@mriechers

@mriechers mriechers commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Self-updating local-model backends — backend (steps 1–3)

Makes local LLMs first-class, self-onboarding agent options. North star:

Pull a model onto local hardware → the server serves it → a "Refresh models" lists it → assign it to a phase. No code, no llm-config.json hand-edits, no redeploy.

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

  1. Route on the (backend, model) pair (1e395d2) — chat() sends a phase's assigned model to its backend; the backend's own model (honoring LOCAL_LLM_MODEL) is the fallback. Dropped the force_model single-model lock so any served model is selectable per phase. Verified live against oMLX at $0.
  2. Roster multi-source (174dbb2) — get_available_models() merges each discover-flagged local backend's /v1/models unfiltered (a new model is never dropped), tagged by serving software (provider from owned_byoMLX), host, $0, and context length. A down endpoint is non-fatal.
  3. Backend-CRUD API (cba9107) — GET/POST/PATCH/DELETE /config/backends, host-keyed (self-identifying, no local-llm-2), forces type: 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)

  1. Settings panel — add-endpoint form + Discover button; local models appear in the per-phase dropdown grouped Cloud vs oMLX · host.

Verification

  • Full suite 861 passed, 5 xfailed.
  • Step 1 confirmed live against oMLX (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

mriechers and others added 5 commits July 3, 2026 18:27
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
@mriechers mriechers changed the title feat: self-updating local-model backends feat: self-updating local-model backends (backend, steps 1–3) Jul 10, 2026
@mriechers mriechers marked this pull request as ready for review July 10, 2026 21:09
@mriechers

Copy link
Copy Markdown
Owner Author

@claude please review

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Claude finished @mriechers's task in 3m 36s —— View job


Code Review — PR #293

  • Understand the request (PR review)
  • Read key changed files (config.py, llm.py, model_roster.py)
  • Review backend CRUD API
  • Review routing changes in llm.py
  • Review multi-source roster in model_roster.py
  • Review tests
  • Review config/env changes
  • Write up findings

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 — AvailableModel.tier: int rejects None (will 500 in production)

api/routers/config.py:135

tier: int = Field(..., ge=0, le=2, description="Cost tier (0=economy, 1=standard, 2=premium)")

Local roster entries (from fetch_local_models) always set tier: None. With Pydantic v2 in use (pydantic>=2.13.4), AvailableModel(**m) will raise a ValidationError when m["tier"] is None — it cannot be coerced to int. This means the GET /config/models and PATCH /config/models endpoints will return HTTP 500 for any user who has a local backend registered and discover: true.

The test suite passes because test_get_available_models_merges_cloud_and_local mocks at the roster layer and never exercises the Pydantic coercion path.

Fix: change the field type in AvailableModel to Optional[int]:

tier: Optional[int] = Field(None, ge=0, le=2, description="Cost tier (0=economy, 1=standard, 2=premium)")

[Fix this →](https://claude.ai/code?q=In%20api%2Frouters%2Fconfig.py%20on%20branch%20feat%2Flocal-model-discovery%2C%20change%20the%20AvailableModel%20class%20field%20%60tier%3A%20int%20%3D%20Field(...%2C%20ge%3D0%2C%20le%3D2%2C%20...%29%60%20to%20%60tier%3A%20Optional%5Bint%5D%20%3D%20Field(None%2C%20ge%3D0%2C%20le%3D2%2C%20...%29%60.%20Local%20roster%20entries%20from%20fetch_local_models%20set%20tier%3DNone%20and%20Pydantic%20v2%20rejects%20None%20for%20int%20fields.&repo=mriechers%2Fcardigan)


Bug (edge case) — None model_id + non-empty allowlist → AttributeError

api/services/llm.py:457

If a backend has no configured model, no phase assignment, and no fallback_model, model_id remains None after the resolution chain. It then reaches:

self.check_model_allowed(model_id)  # model_id is None

Inside check_model_allowed, if self.model_allowlist is non-empty:

if model == allowed or model.startswith(allowed + ":"):

...calls None.startswith(...)AttributeError → unhandled 500.

In practice this only fires if both a non-empty model_allowlist AND a misconfigured backend (no model anywhere in the chain). But the safeguard should be defensive. Fix: add an early return or guard:

if not model:
    return  # no model resolved; let the backend call fail with a descriptive error

Similarly, it's worth adding an explicit check after the resolution chain (before the API call) to surface a clean ValueError instead of a cryptic backend 400.

Fix this →


Design concern — fallback/local roster not cached (latency under OpenRouter outage)

api/services/model_roster.py:239–246

The double-checked lock only caches a result when _classify_models returns non-empty cloud models. On every fallback path (no model_families configured, OpenRouter unreachable, or zero pattern matches), the function returns _static_fallback(config) + local uncached. This means fetch_local_models — which opens N HTTP connections to local servers — runs on every call to get_available_models() when OpenRouter is down. Under API load this could be significant.

The comment "uncached, so a later config change is picked up" applies only to the no-families path and is intentional there. But the "OpenRouter fetch failed" path (line 245) has no such reason to skip caching. Consider caching the static+local result for a shorter TTL (e.g. 60s) on failure paths.


Low — Double reload_config() in PATCH endpoints

api/routers/config.py:68 and :120–121 (also :220–222)

_save_config already calls llm.reload_config() at the end (line 67–68). Then update_phase_backends and update_phase_models each call it again explicitly after _save_config. The second call is redundant. No functional impact, but worth cleaning up.


Low — POC scripts committed to scripts/

scripts/eval_pipeline.py and scripts/poc_house_style_normalizer.py are added to the main scripts/ directory. The poc_ prefix signals these are experiments. If they're not intended to be part of the production scripts directory, consider moving them to planning/ or another scratch location (or adding to .gitignore) to keep the scripts directory reserved for operational tooling.


What looks good

  • Host-keyed backend identity is the right call — avoids invented names and makes the key self-describing. The 409 duplicate guard is correct.
  • Non-fatal discovery (continue on error, logged) — a down local server never blocks the cloud roster. Good.
  • discover-flag gate in fetch_local_models is clean: enabled + discover + openai-type only.
  • force_model removal is clean — no dangling references found.
  • strip_reasoning / Qwen3 <think> handling with both well-formed and dangling-close cases covered by regex.
  • _resolve_endpoint and _resolve_model env-var override pattern is consistent with the existing deploy model.
  • Delete guard (refuses to remove a backend in use by primary_backend, fallback_backend, or phase_backends) is the right safety rail.
  • Test coverage for the CRUD happy paths and error paths is solid.

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

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:

  1. An attacker registers a backend with endpoint: 'http://attacker.example/v1' and api_key_env: 'OPENROUTER_API_KEY'.
  2. The next GET /config/models triggers fetch_local_models() (
    if not (cfg.get("enabled") and cfg.get("discover") and cfg.get("type") == "openai"):
    continue
    endpoint = _resolve_backend_endpoint(cfg)
    url = _models_url(endpoint)
    host = urlparse(endpoint).netloc or endpoint
    api_key = get_secret(cfg["api_key_env"]) if cfg.get("api_key_env") else None
    headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
    try:
    async with httpx.AsyncClient(timeout=15.0) as client:
    resp = await client.get(url, headers=headers)
    resp.raise_for_status()
    ), which resolves the named secret via get_secret() and sends it as Authorization: Bearer to the attacker URL.
  3. get_secret() (
    """
    # 1. Docker secret file
    secret_file = DOCKER_SECRETS_DIR / key.lower()
    if secret_file.is_file():
    try:
    return secret_file.read_text().strip()
    ) does DOCKER_SECRETS_DIR / key.lower() with no path sanitization — a name like ../../etc/passwd reads arbitrary files.
  4. update_backend (
    entry = backends[name]
    for field in ("endpoint", "api_key_env", "model", "enabled", "discover"):
    value = getattr(body, field)
    if value is not None:
    entry[field] = value
    ) has zero endpoint validation.

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)

env_var = config.get("model_env")
if env_var:
return os.getenv(env_var, config.get("model"))
return config.get("model")

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').

mriechers and others added 2 commits July 13, 2026 18:04
…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
@mriechers

Copy link
Copy Markdown
Owner Author

@claude please review

Merged origin/main in to resolve the conflict from #295's merge. Conflicts were only in house-style/eval artifacts inherited from the shared oMLX exploration base — resolved by taking main's authoritative versions (prompts/seo.md tokenized copy rules + 80/90/350 limits; scripts/eval_pipeline.py and scripts/poc_house_style_normalizer.py main versions). #293's core (backend CRUD, roster discovery, (backend,model) routing, secrets) was conflict-free. Full suite 1632 passing, ruff + black clean.

@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown

Claude finished @mriechers's task in 3m 40s —— View job


Code Review — PR #293 (post-main-merge)

  • Understand the request
  • Read key changed files after merge
  • Check if previous review issues were addressed
  • Review conflict resolution (eval scripts, prompts)
  • Write up findings

Merge resolution — clean

The merge is clean. Conflicts were only in the eval/house-style artifacts (scripts/eval_pipeline.py, scripts/poc_house_style_normalizer.py, prompts/seo.md) inherited from the shared oMLX exploration base; taking main's authoritative versions was the right call. The prior concern about POC scripts living in scripts/ no longer applies — they're in main now, not unique to this PR.

The core PR changes (backend CRUD, roster discovery, (backend, model) routing, secrets bootstrap) were conflict-free throughout.


What got fixed ✅

AvailableModel.tier: Optional[int] — confirmed fixed. config.py:136 now correctly uses Optional[int] = Field(None, ...). The GET /config/models 500-on-local-backend bug is resolved.


Remaining issues from prior reviews

Bug — _resolve_model returns "" in the default Docker deploy

api/services/llm.py:342

docker-compose.prod.yml:27/62 injects LOCAL_LLM_MODEL=${LOCAL_LLM_MODEL:-} — an empty string when the host env var is unset. os.getenv(env_var, config.get("model")) returns "" (not the default) when the var exists but is empty. The call chain then produces model_id = None:

_resolve_model → "" → "" or fallback_model → None

None is forwarded to _call_openai as payload["model"] = None, which gets a backend 400 instead of a readable local error. Still unfixed. One-line fix:

# line 342
return os.getenv(env_var) or config.get("model")

Fix this →

Bug (edge case) — check_model_allowed(None) AttributeError

api/services/llm.py:457

If model_id resolves to None (e.g. via the empty-string bug above or a fully unconfigured backend) and LLM_MODEL_ALLOWLIST is set, model.startswith(allowed + ":") raises AttributeError on None. The default deploy has an empty allowlist so this is latent, but it will surface if allowlist enforcement is ever turned on. Still unfixed. One-line guard:

def check_model_allowed(self, model: str) -> None:
    if not self.enforce_guards:
        return
    if not self.model_allowlist:
        return
+   if not model:
+       return  # no resolved model; the backend call will fail with a clearer error
    for allowed in self.model_allowlist:

Fix this →

Security — update_backend still has zero endpoint validation

api/routers/config.py:480-496

create_backend at least checks urlparse(body.endpoint).netloc is non-empty (though it still lacks scheme/RFC-1918 restrictions). update_backend has no validation at all on the incoming endpoint value — it writes it straight to config. An attacker with access to the tailnet could register a legitimate-looking backend then patch it to point at http://169.254.169.254/ or any internal host. The api_key_env path-traversal issue in get_secret (DOCKER_SECRETS_DIR / key.lower() has no sanitization against ../../ components) noted in the earlier security review also remains.

Given the tailnet-only threat model these are lower risk than they would be on a public service, but update_backend validation is a one-line fix to keep parity with create_backend:

# api/routers/config.py, update_backend()
if body.endpoint is not None:
    if not urlparse(body.endpoint).netloc:
        raise HTTPException(status_code=400, detail="endpoint must be an absolute URL")
    entry["endpoint"] = body.endpoint

Fix this →

Low — Double reload_config() in PATCH handlers

_save_config already calls llm.reload_config() (line 68). update_phase_backends (line 120) and update_phase_models (line 255) each call it again after _save_config returns. No functional impact, but both explicit calls are dead.


What looks good

  • Merge resolution is correct — no behavioral regressions in the core PR from the conflict resolution.
  • AvailableModel schema fix is confirmed in place.
  • Settings.tsx handleAddEndpoint/handleToggleEndpoint correctly use encodeURIComponent(name) on the backend name path param.
  • useMemo for groupAvailableModels is the right call (one computation per roster change, not per-agent-row render).
  • refreshRosterAndLists combining POST /config/models/refresh + parallel fetchBackends + fetchConfig is the right sequencing.
  • The core design choices from the prior review — host-keyed identity, non-fatal discovery, (backend, model) routing pair — remain solid.

Summary: Merge is clean. The tier bug is fixed. The two medium-severity bugs (_resolve_model empty string, check_model_allowed(None)) and the update_backend endpoint validation gap are still outstanding. All three are small fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant