fix(desktop): stop flagging local/LAN auxiliary pins as stale

An aux task pinned to a private endpoint via `base_url` (a home Ollama
box at `byron.local`, a LAN IP, localhost) is the intended per-task
endpoint feature and can never bill a provider. The Settings → Model
banner still counted it as "still run on openai" forever and offered
"Reset all to main", which would wipe the working local setup; the
post-switch `stale_aux` report had the same blind spot; and the aux row
never showed the `base_url` the backend already sends, so the pin was
indistinguishable from a paid-provider pin.

- `GET /api/model/auxiliary` now stamps each task with `local_endpoint`,
  the verdict of the one canonical classifier
  (`agent/model_metadata.py::is_local_endpoint`) — no TS mirror of the
  private-range rules, so frontend and runtime cannot drift.
- Desktop: the persistent banner filter is the pure
  `staleAuxAssignments()` and skips `local_endpoint` pins; the pinned row
  appends ` · <base_url>` when one is set.
- `_stale_aux_pins` (post-switch report) skips local pins the same way.
- `is_local_endpoint`: `*.local` (RFC 6762 mDNS) now counts as local, and
  IPv6 literals no longer ride the "no dots ⇒ unqualified host" rule, so
  a global-scope address (`2607:f8b0::1`) is not local while `::1`,
  ULA and link-local still are via the `ipaddress` scope checks.

Slim redo of #106236 (@webtecnica) and #106234 (@huklaa), which fixed the
same symptom with a client-side classifier copy; the bug class, row
display and mDNS/IPv6 classifier corrections are theirs.

Refs #106228

Co-authored-by: Hukla <129692708+huklaa@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
This commit is contained in:
teknium1
2026-09-09 05:03:06 -07:00
committed by Teknium
parent 113304199e
commit 8d93081971
8 changed files with 101 additions and 21 deletions
+4 -2
View File
@@ -600,8 +600,10 @@ def is_local_endpoint(base_url: str) -> bool:
return False
if host is None:
return False
# Unqualified hostnames (no dots) are local by definition — Docker Compose service names, /etc/hosts entries, mDNS.
if host in _LOCAL_HOSTS or host.endswith(_CONTAINER_LOCAL_SUFFIXES) or (host and "." not in host):
# Unqualified hostnames (no dots) are local by definition — Docker Compose service names, /etc/hosts
# entries, mDNS — as is `*.local` (RFC 6762 mDNS, LAN-only). IPv6 literals have no dots either, so
# they are excluded here and classified by scope below (a global address is not local).
if host in _LOCAL_HOSTS or host.endswith(_CONTAINER_LOCAL_SUFFIXES) or host.endswith(".local") or (host and "." not in host and ":" not in host):
return True
try:
addr = ipaddress.ip_address(host)
@@ -409,6 +409,30 @@ describe('ModelSettings', () => {
// Banner present on load, no switch required.
expect(await screen.findByText(/still run on/)).toBeTruthy()
})
it('does not flag an aux slot pinned to a local/LAN endpoint and shows its base_url', async () => {
getAuxiliaryModels.mockResolvedValueOnce({
main: { provider: 'ollama-cloud', model: 'glm-5.3-flash' },
tasks: [
{
task: 'title_generation',
provider: 'openai',
model: 'llama3.2:3b',
base_url: 'http://byron.local:11434/v1',
local_endpoint: true
},
{ task: 'vision', provider: 'openai', model: 'gpt-4o-mini', base_url: 'https://api.example.com/v1', local_endpoint: false }
]
})
await renderModelSettings()
// The public custom endpoint still bills a provider, so the banner stays —
// but it names only that one task, not the free LAN pin.
expect(await screen.findByText(/1 auxiliary task \(/)).toBeTruthy()
// The row shows where the pinned task actually points.
expect(screen.getByText(/http:\/\/byron\.local:11434\/v1/)).toBeTruthy()
})
})
describe('ModelSettings MoA preset editor', () => {
@@ -18,6 +18,7 @@ import {
} from '@/hermes'
import type {
AuxiliaryModelsResponse,
AuxiliaryTaskAssignment,
MoaConfigResponse,
MoaModelSlot,
ModelOptionProvider,
@@ -145,6 +146,30 @@ export const moaConfigComplete = (config: MoaConfigResponse): boolean =>
moaSlotComplete(preset.aggregator)
)
// Persistent mismatch: any aux slot pinned to a provider different from the
// current main, regardless of whether the user just switched. Catches the
// "I pinned aux months ago and forgot, now it bills a dead provider" case.
// A pin on a private/LAN endpoint (per-task base_url, e.g. a home Ollama box)
// never bills a provider, so the backend's `local_endpoint` verdict exempts it.
export function staleAuxAssignments(
tasks: readonly AuxiliaryTaskAssignment[],
mainProvider: string
): StaleAuxAssignment[] {
const main = mainProvider.toLowerCase()
if (!main) {
return []
}
return tasks
.filter(entry => {
const p = (entry.provider ?? '').toLowerCase()
return p && p !== 'auto' && p !== main && !entry.local_endpoint
})
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}
interface StaleAuxWarningProps {
applying: boolean
onReset: () => void
@@ -500,24 +525,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
const auxiliaryTaskLabel = useCallback((key: string) => m.tasks[key]?.label ?? key, [m.tasks])
// Persistent mismatch: any aux slot pinned to a provider different from the
// current main, regardless of whether the user just switched. Catches the
// "I pinned aux months ago and forgot, now it bills a dead provider" case.
const persistentStaleAux = useMemo<StaleAuxAssignment[]>(() => {
const mainProvider = (mainModel?.provider ?? '').toLowerCase()
if (!mainProvider || !auxiliary) {
return []
}
return auxiliary.tasks
.filter(entry => {
const p = (entry.provider ?? '').toLowerCase()
return p && p !== 'auto' && p !== mainProvider
})
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
const persistentStaleAux = useMemo<StaleAuxAssignment[]>(
() => staleAuxAssignments(auxiliary?.tasks ?? [], mainModel?.provider ?? ''),
[auxiliary, mainModel]
)
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
@@ -1069,6 +1080,9 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
description={
<span className="font-mono text-[0.68rem]">
{isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`}
{!isAuto && current.base_url && (
<span className="text-muted-foreground"> · {current.base_url}</span>
)}
</span>
}
title={
+1
View File
@@ -44,6 +44,7 @@ export type {
AutomationBlueprint,
AutomationBlueprintField,
AuxiliaryModelsResponse,
AuxiliaryTaskAssignment,
BackendUpdateCheckResponse,
ComputerUseCheck,
ComputerUsePermissionSource,
+3
View File
@@ -1413,6 +1413,9 @@ export interface BackendUpdateCheckResponse {
export interface AuxiliaryTaskAssignment {
base_url: string
/** Backend verdict (`agent/model_metadata.py::is_local_endpoint`) that `base_url`
* is a loopback/LAN/mDNS endpoint. Absent on older backends. */
local_endpoint?: boolean
model: string
provider: string
task: string
+5 -1
View File
@@ -14,6 +14,7 @@ from hermes_cli.web_deps import LateState, late
from hermes_cli.web_server_config import (
_AUX_TASK_SLOTS, _apply_model_assignment_sync, _dashboard_code_skew_guard,
)
from agent.model_metadata import is_local_endpoint
from starlette.concurrency import run_in_threadpool
from hermes_cli.web_models import ModelAssignment, MoaConfigPayload, MoaModelSlot
from hermes_cli.web_routers._common import http_failure
@@ -206,9 +207,12 @@ def get_auxiliary_models(profile: Optional[str] = None):
tasks = []
for slot in _AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {}
base_url = str(slot_cfg.get("base_url", "") or "")
tasks.append({
"task": slot, "provider": str(slot_cfg.get("provider", "auto") or "auto"),
"model": str(slot_cfg.get("model", "") or ""), "base_url": str(slot_cfg.get("base_url", "") or ""),
"model": str(slot_cfg.get("model", "") or ""), "base_url": base_url,
# Lets the UI tell a free local/LAN pin from a forgotten paid-provider pin.
"local_endpoint": is_local_endpoint(base_url),
})
model, provider = _main_model_fields(cfg.get("model", {}))
+5
View File
@@ -5,6 +5,7 @@ import logging
import os
from fastapi import HTTPException
from typing import Any, Dict, List, Optional, Tuple
from agent.model_metadata import is_local_endpoint
from hermes_cli.config import (
DEFAULT_CONFIG,
build_cron_model_impact,
@@ -616,6 +617,10 @@ def _stale_aux_pins(cfg: dict, new_provider: str) -> list:
continue
slot_provider = str(slot_cfg.get("provider", "") or "").strip()
if slot_provider and slot_provider.lower() not in {"auto", ""} and slot_provider.lower() != new_provider:
# A pin on a private/LAN endpoint (per-task base_url, e.g. a home Ollama box) never bills
# a provider, so a main switch does not orphan it.
if is_local_endpoint(str(slot_cfg.get("base_url", "") or "")):
continue
stale_aux.append({
"task": slot, "provider": slot_provider, "model": str(slot_cfg.get("model", "") or ""),
})
@@ -0,0 +1,27 @@
"""Aux pins on local/LAN endpoints are not "stale" — they never bill a provider (#106228)."""
import pytest
from agent.model_metadata import is_local_endpoint
from hermes_cli.web_server_config import _stale_aux_pins
def test_local_endpoint_pins_are_excluded_from_stale_aux_report():
cfg = {"auxiliary": {
"title_generation": {"provider": "openai", "model": "llama3.2:3b", "base_url": "http://byron.local:11434/v1"},
"vision": {"provider": "openai", "model": "llama3.2-vision:11b", "base_url": "http://192.168.1.10:11434/v1"},
"compression": {"provider": "openai", "model": "gpt-4o-mini", "base_url": "https://api.example.com/v1"},
"curator": {"provider": "openai", "model": "gpt-4o-mini"},
}}
stale = _stale_aux_pins(cfg, "ollama-cloud")
# Only the pins that can still bill a provider survive: public custom URL, no base_url.
assert sorted(entry["task"] for entry in stale) == ["compression", "curator"]
@pytest.mark.parametrize(("url", "local"), [
("http://byron.local:11434/v1", True), # RFC 6762 mDNS, LAN-only
("http://[fd00::1]:11434", True), # IPv6 ULA
("http://[2607:f8b0::1]:11434", False), # global IPv6 must not ride the "no dots" rule
])
def test_is_local_endpoint_mdns_and_ipv6_scope(url, local):
assert is_local_endpoint(url) is local