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:
@@ -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={
|
||||
|
||||
@@ -44,6 +44,7 @@ export type {
|
||||
AutomationBlueprint,
|
||||
AutomationBlueprintField,
|
||||
AuxiliaryModelsResponse,
|
||||
AuxiliaryTaskAssignment,
|
||||
BackendUpdateCheckResponse,
|
||||
ComputerUseCheck,
|
||||
ComputerUsePermissionSource,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
Reference in New Issue
Block a user