diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 33cda04e26..581d134bd2 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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) diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 4fe3b0619f..c965910168 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -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', () => { diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index baed8f7d81..580b881ec9 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -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(() => { - 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( + () => 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={ {isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`} + {!isAuto && current.base_url && ( + · {current.base_url} + )} } title={ diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index f5fb2ee7bd..5042af6e47 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -44,6 +44,7 @@ export type { AutomationBlueprint, AutomationBlueprintField, AuxiliaryModelsResponse, + AuxiliaryTaskAssignment, BackendUpdateCheckResponse, ComputerUseCheck, ComputerUsePermissionSource, diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 8d5cd0570d..b4f178e24f 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 diff --git a/hermes_cli/web_routers/models.py b/hermes_cli/web_routers/models.py index d2bdfe572d..77aa766808 100644 --- a/hermes_cli/web_routers/models.py +++ b/hermes_cli/web_routers/models.py @@ -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", {})) diff --git a/hermes_cli/web_server_config.py b/hermes_cli/web_server_config.py index d8fc58ed9d..eac185d975 100644 --- a/hermes_cli/web_server_config.py +++ b/hermes_cli/web_server_config.py @@ -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 ""), }) diff --git a/tests/hermes_cli/test_stale_aux_local_endpoint.py b/tests/hermes_cli/test_stale_aux_local_endpoint.py new file mode 100644 index 0000000000..d3d3e0f82b --- /dev/null +++ b/tests/hermes_cli/test_stale_aux_local_endpoint.py @@ -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