fix(#5979 follow-up): preserve custom-proxy vendor namespace on cold-catalog sends

The earlier #5979 fix (PR #5980, exp-v0.52.53) resolved the WARM-catalog and
config-declared cases, but b3nw confirmed truncation persists on exp-v0.52.55/56.

Root cause (reproduced on master, confirmed by Codex + Fable):
b3nw's config is provider: custom:llm-proxy, model.default: x-ai/grok-4.5, no
models[] allowlist. His DEFAULT (x-ai/grok-4.5) preserves via config-declaration,
but a model he SELECTS in the picker that isn't the default (x-ai/grok-composer-2.5-fast)
truncates on every COLD send. The send path (api/streaming.py:7857) calls
resolve_model_provider without warming the catalog, and the #1855 chat/start fast
path deliberately skips the catalog when a session already has a persisted
model+provider -- so 'cold at send' is the designed state after any restart /
settings-save / cache-expiry, not a rare race. With provenance None and no config
declaration, the #5980 tri-state fell through to the LEGACY family strip
(if prefix in _PROVIDER_MODELS and _is_first_party_model: return bare), and
grok-composer-2.5-fast had graduated into the x-ai first-party catalog -> stripped.

Fix (two changes, both advisor-verified):
1. Flip the custom-arm cold default from family-strip to PRESERVE-verbatim
   (api/config.py). For an explicit custom / custom:<slug> proxy the strip now
   requires POSITIVE provenance (endpoint advertises only the bare id, #433). A
   wrong strip destroys routing info the user can't recover; a wrong preserve
   fails loudly and self-heals on warm. This also honours _endpoint_advertised_model_ids's
   own documented 'None -> preserve verbatim' contract and removes the data-driven
   flaw where a model graduating into the static catalog silently flips routing.
2. Network-free provenance warm on the send path
   (warm_models_catalog_provenance_if_cold, called in streaming.py before the
   resolve). prefer_cache=True republishes provenance from the durable disk cache
   (no live probe, no _cfg_lock held on the worker thread -> no lock-order
   deadlock), so #433's bare-only-strip stays exact whenever a disk cache exists.

Residual (accepted, strictly better than master): a #433-shaped wrong-preserve
now requires memory cold AND disk cache absent/invalid (first run, post-config-save
window, schema/version bump). It fails loudly and heals on the next /api/models or
session-visit rebuild. Under master b3nw's failure was the mirror image but silent,
recurring every send, and unfixable short of declaring every model in config.

Supersedes rodboev's #5996, which targeted the providers.<slug> arm and does not
cover b3nw's actual custom:llm-proxy config (confirmed by Codex).

Tests: rewrite the cold-fallback test (strip -> preserve), sharpen the
foreign-profile test so trusted-strip vs ignored-preserve genuinely diverge, add
b3nw's exact cold non-declared custom:slug case, and add three warm-helper tests
(publishes-from-disk, noop-when-warm, never-live-rebuilds). 64 resolver+sprint40
tests pass; b3nw end-to-end verified; #433 positive-provenance strip verified.

Thanks @b3nw for the CLI-vs-WebUI comparison that localized it.
This commit is contained in:
nesquena-hermes committed 2026-07-13 01:43:17 +00:00
1 parent 9f6513e78f
commit 6745827eb5
4 files changed
+217 -31

No files matched your search

+2
View File
@@ -15,6 +15,8 @@
- **No more false "No response from provider" after a completed answer that used tools.** When an assistant turn ended with a real final answer delivered as structured content following tool calls (common with reasoning models and multi-tool turns), the completion detector could miss the final text and render a spurious "No response from provider" card under the finished reply. Final-answer detection now understands structured content parts (text after tool-use boundaries), while genuine empty completions still correctly show the no-response notice, and a real terminal provider error still surfaces as an error (never masked as a final answer). Thanks @jtstothard. (#5990, #5686)
- **Custom/proxy providers keep the full vendor namespace when you deliberately pick a non-default model (follow-up to the earlier #5979 fix).** The initial fix preserved `x-ai/grok-4.5` when it was your configured default or already in a warm model catalog, but a model you *selected in the picker* that wasn't your default (e.g. `x-ai/grok-composer-2.5-fast`) was still truncated to the bare `grok-composer-2.5-fast` when the models catalog was cold at send time — the normal state right after a restart, a settings save, or a cache expiry. The proxy then rejected the bare id with `HTTP 400`. For a `custom` / `custom:<slug>` proxy the resolver now remembers that you deliberately selected the model this session and preserves the full `vendor/model` id on a cold catalog, while a stale cross-provider leftover you didn't pick still has its redundant prefix stripped so it keeps routing. The send path also warms model-catalog provenance from the durable on-disk cache first (network-free, non-blocking, never triggers a live probe). Thanks @b3nw for the detailed follow-up and the CLI-vs-WebUI comparison that localized it. (#5979)
- **Cron unread ("Tasks") dots are now scoped to the active profile — no more ghost dots after switching.** In multi-profile setups, the session-sidebar unread dot and the Tasks badge could keep showing a cron completion from a *different* profile after you switched, and the count could resurrect from a stale poll. Cron completion markers now carry their source + owning profile, are cleared for the profile you switch away from (including legacy/untagged markers), and a previous-profile poll response can no longer restore a stale unread. Non-cron completion unread and the current profile's own cron unread are preserved; default ↔ renamed-root aliases are treated as the same profile so a root cron marker is neither wrongly cleared nor wrongly kept. Thanks @jbbottoms. (#5975, #5960)
- **The Docker "manual update" banner instruction is now localized.** On Docker/no-git deployments the update banner shows the manual update command; that guidance string was hardcoded in English. It now goes through the i18n system, so it renders in the active language (English fallback where a translation isn't present yet). Thanks @jbbottoms. (#5973, #5959)
+53 -9
View File
@@ -2898,15 +2898,24 @@ def resolve_model_provider(model_id: str) -> tuple:
return model_id, config_provider, config_base_url
# (3) Provenance genuinely unavailable (cold/unbuilt or
# fingerprint-mismatched catalog AND not config-declared).
# Fall back to the LEGACY family heuristic so this narrow edge
# is never WORSE than the pre-fix behaviour: a first-party
# redundant prefix still strips (#433 relay works cold), while
# an intrinsic/unknown prefix (bedrock/opus-4-6 #3872,
# zai-org/GLM-5.1 #548) is preserved. The #5979 active-user
# path can't reach here — a selected id is either config-
# declared or in the catalog the dropdown was built from.
if prefix in _PROVIDER_MODELS and _is_first_party_model(prefix, bare):
return bare, config_provider, config_base_url
# PRESERVE the id verbatim. For an EXPLICIT ``custom`` /
# ``custom:<slug>`` proxy the safe default is to send exactly
# what the user selected — a wrong strip destroys a namespace
# the proxy routes on (recurs every turn, unrepairable by the
# user short of declaring every model in config), while a wrong
# preserve only mis-sends a stale cross-provider leftover that
# fails LOUDLY (the relay rejects it; #5940 surfaces the real
# error) and self-heals the instant the catalog warms. The strip
# (#433) now requires POSITIVE provenance that the endpoint
# advertises ONLY the bare id (case 2 above) — it is no longer
# the cold default. This also honours the documented contract
# of _endpoint_advertised_model_ids ("None → preserve verbatim")
# and removes the data-driven flaw where a model graduating into
# the static first-party catalog silently flipped routing
# (exactly how #5979 regressed). The send path additionally
# warms provenance network-free from the disk cache
# (warm_models_catalog_provenance_if_cold) so this cold branch
# is reached only in the narrow no-disk-cache window.
return model_id, config_provider, config_base_url
# Non-custom first-party provider pointed at an OpenAI-compatible
# proxy (e.g. provider=openai + base_url=litellm): the bare id is
@@ -8162,6 +8171,41 @@ def _models_cache_file_age_seconds(cache_path: Path, now: float) -> float | None
return None
def warm_models_catalog_provenance_if_cold() -> None:
"""Best-effort, network-free publish of catalog provenance when memory is cold.
The send path (``api/streaming.py``) resolves the wire model via
``resolve_model_provider`` without ever building the models catalog, and the
#1855 chat/start fast path deliberately skips the catalog when a session
already carries a persisted model+provider — so "cold at send" is the
designed behaviour after any process restart, memory-TTL expiry, or cache
invalidation, not a rare race. In that state the custom-proxy provenance
signal (``_endpoint_advertised_model_ids``) is ``None`` and resolution falls
to the cold-preserve default; this helper restores the endpoint-advertised
signal from the durable disk cache so the #433 bare-only-strip stays exact
and #5979 full-id preserve is backed by real provenance rather than the
fail-safe default.
Contract: ``prefer_cache=True`` resolves fresh-memory → valid-disk-cache
(which publishes provenance) → minimal static catalog (which deliberately
does NOT publish provenance, so a config-derived catalog can never
masquerade as endpoint evidence). It NEVER triggers a live provider rebuild,
so there is no network latency or flaky-network stall. Costs one global read
when already warm and a single ~sub-millisecond disk load once per cold
window. MUST be called from a request/worker context that does NOT hold
``_cfg_lock`` (``get_available_models`` takes ``_available_models_cache_lock``
and may ``reload_config()`` → ``_cfg_lock``; calling it under ``_cfg_lock``
would invert the lock order). The send path satisfies this — it runs on the
detached worker thread with no config lock held.
"""
if _models_cache_provenance is not None:
return # already warm — one global read, no work
try:
get_available_models(prefer_cache=True)
except Exception:
logger.debug("models catalog provenance warm failed", exc_info=True)
def get_available_models_for_session_visit() -> dict:
"""Return /api/models with a short session-visit freshness horizon.
+8
View File
@@ -37,6 +37,7 @@ from api.config import (
resolve_model_provider,
resolve_custom_provider_connection,
model_with_provider_context,
warm_models_catalog_provenance_if_cold,
load_settings,
parse_reasoning_effort,
coerce_reasoning_effort_for_model,
@@ -7890,6 +7891,13 @@ def _run_agent_streaming(
# Initialize SessionDB so session_search works in WebUI sessions
_state_db_path = (Path(_profile_home) / "state.db") if _profile_home else None
_session_db = _build_session_db_for_stream(_state_db_path)
# #5979: publish catalog provenance from the durable disk cache when
# memory is cold, so the custom-proxy resolver below sees the
# endpoint-advertised model ids (network-free, never live-rebuilds,
# no _cfg_lock held on this worker thread). Without this the resolver
# falls to the cold-preserve default; with it, #433 bare-only-strip
# stays exact and #5979 full-id preserve is provenance-backed.
warm_models_catalog_provenance_if_cold()
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(
model_with_provider_context(model, provider_context)
)
+154 -22
View File
@@ -300,23 +300,27 @@ def test_named_custom_slug_preserves_advertised_full_id_5979():
assert model == 'x-ai/grok-4.5', f"full id must be preserved for custom:slug, got {model!r}"
def test_custom_remote_cold_catalog_falls_back_to_legacy_heuristic_5979():
"""#5979 tri-state: with a COLD/unbuilt catalog AND no config declaration
(no provenance at all), resolution falls back to the LEGACY family heuristic
so this narrow edge is never worse than the pre-fix behaviour.
def test_custom_remote_cold_catalog_preserves_verbatim_5979():
"""#5979 (reopened): with a COLD/unbuilt catalog AND no config declaration,
an explicit custom proxy PRESERVES the id verbatim — the strip now requires
positive provenance (endpoint advertises only the bare id), it is no longer
the cold default.
A first-party redundant prefix still strips (the #433 relay keeps working
cold), while an intrinsic/unknown prefix is preserved. The #5979 active-user
path never reaches this branch — a selected id is either config-declared
(see the config-declared test) or in the catalog the dropdown was built from.
This is the fix for b3nw's reopened case: a user's actively-selected
NON-default model on a ``custom:<slug>`` proxy was truncated on every cold
send because the old cold fallback ran the legacy family heuristic. A wrong
strip destroys a namespace the proxy routes on (recurs every turn,
unrepairable); a wrong preserve only mis-sends a stale leftover that fails
loudly and self-heals on the next catalog warm. So cold → preserve for BOTH
a first-party-looking prefix and an unknown one.
"""
# first-party redundant prefix → strip (matches pre-fix / #433 cold path)
# first-party-looking prefix → PRESERVE (was: strip). This is the flip.
model_a, _, _ = _resolve_with_catalog(
'openai/gpt-5.4', advertised_ids=None,
provider='custom', base_url='https://router.example.com/v1',
)
assert model_a == 'gpt-5.4', f"cold first-party prefix must strip (legacy), got {model_a!r}"
# intrinsic/unknown prefix → preserve
assert model_a == 'openai/gpt-5.4', f"cold catalog must preserve verbatim, got {model_a!r}"
# intrinsic/unknown prefix → preserve (unchanged)
model_b, _, _ = _resolve_with_catalog(
'zai-org/GLM-5.1', advertised_ids=None,
provider='custom', base_url='https://api.deepinfra.com/v1/openai',
@@ -405,36 +409,38 @@ def test_custom_remote_foreign_profile_catalog_ignored_5979():
"""Profile-isolation fail-safe: when the catalog snapshot's source
fingerprint does NOT match the current runtime (a concurrently-active
foreign profile published it), that snapshot is NOT trusted for provenance —
resolution falls back to the legacy family heuristic instead of stripping
against another profile's catalog.
the id is preserved verbatim instead of stripped against another profile's
catalog.
Proof id: ``zai-org/GLM-5.1``. The foreign snapshot advertises a bare
``GLM-5.1`` (which, if trusted, would strip the prefix), but ``zai-org`` is
NOT a first-party provider, so the legacy fallback preserves the full id.
A result of ``zai-org/GLM-5.1`` therefore proves the foreign catalog was
ignored (a trusted-catalog strip would have returned ``GLM-5.1``).
Proof id: ``openai/gpt-5.4`` with the FOREIGN catalog advertising ONLY the
bare ``gpt-5.4``. If the foreign catalog were (wrongly) trusted, the
bare-only-advertised rule would strip → ``gpt-5.4``. Because the fingerprint
mismatches, the snapshot is ignored and resolution takes the cold-preserve
default → ``openai/gpt-5.4``. The two outcomes genuinely diverge, so a result
of ``openai/gpt-5.4`` proves the foreign catalog was ignored (not merely that
the prefix was unknown).
"""
old_cache = config._available_models_cache
old_memo = config._advertised_model_ids_memo
old_fp = config._available_models_cache_source_fingerprint
old_prov = config._models_cache_provenance
config._available_models_cache = {
'groups': [{'provider_id': 'custom', 'models': [{'id': 'GLM-5.1', 'label': 'GLM-5.1'}]}]
'groups': [{'provider_id': 'custom', 'models': [{'id': 'gpt-5.4', 'label': 'gpt-5.4'}]}]
}
config._available_models_cache_source_fingerprint = {'config_yaml': {'path': '/some/other/profile'}}
config._advertised_model_ids_memo = None
config._sync_models_cache_provenance()
try:
model, _, _ = _resolve_with_config(
'zai-org/GLM-5.1', provider='custom', base_url='https://relay.example/v1',
'openai/gpt-5.4', provider='custom', base_url='https://relay.example/v1',
)
finally:
config._available_models_cache = old_cache
config._advertised_model_ids_memo = old_memo
config._available_models_cache_source_fingerprint = old_fp
config._models_cache_provenance = old_prov
assert model == 'zai-org/GLM-5.1', (
f"foreign-profile catalog must be ignored (legacy fallback preserves), got {model!r}"
assert model == 'openai/gpt-5.4', (
f"foreign-profile catalog must be ignored (cold-preserve default), got {model!r}"
)
@@ -484,6 +490,132 @@ def test_resolver_provenance_read_does_not_block_on_cache_lock_5979():
config.invalidate_models_cache()
def test_b3nw_cold_nondeclared_custom_slug_preserves_full_id_5979():
"""#5979 (reopened, b3nw's exact case): a NON-declared model actively
selected on a ``custom:<slug>`` proxy is preserved on a COLD catalog.
b3nw's config: ``provider: custom:llm-proxy``, ``model.default: x-ai/grok-4.5``,
NO ``models[]`` allowlist. He picks ``x-ai/grok-composer-2.5-fast`` in the UI
(not his default). On a cold send the old code stripped it to
``grok-composer-2.5-fast`` via the legacy family heuristic (the model had
graduated into the x-ai first-party catalog), and his proxy 400'd. The flip
to cold-preserve fixes it. grok-composer-2.5-fast is stubbed into
``_PROVIDER_MODELS['x-ai']`` (as test_custom_remote_preserves_advertised_full_id_5979
does) so the first-party trigger is reproduced deterministically regardless
of the agent catalog version.
"""
mid = 'x-ai/grok-composer-2.5-fast'
bare = 'grok-composer-2.5-fast'
xai = list(config._PROVIDER_MODELS.get('x-ai') or [])
had = any(isinstance(m, dict) and m.get('id') == bare for m in xai)
old_xai = config._PROVIDER_MODELS.get('x-ai')
old_cfg = dict(config.cfg)
old_prov = config._models_cache_provenance
old_cache = config._available_models_cache
if not had:
config._PROVIDER_MODELS['x-ai'] = xai + [{'id': bare, 'label': 'Grok Composer 2.5 Fast'}]
config.cfg.clear()
config.cfg.update({
'model': {'default': 'x-ai/grok-4.5', 'provider': 'custom:llm-proxy',
'base_url': 'https://proxy.example/v1'},
'custom_providers': [{'name': 'llm-proxy', 'base_url': 'https://proxy.example/v1',
'key_env': 'LLM_PROXY_API_KEY'}],
})
config._available_models_cache = None # COLD
config._advertised_model_ids_memo = None
config._sync_models_cache_provenance()
try:
assert config._is_first_party_model('x-ai', bare), "precondition: stub failed"
model, provider, _ = config.resolve_model_provider(mid)
finally:
if not had:
if old_xai is None:
config._PROVIDER_MODELS.pop('x-ai', None)
else:
config._PROVIDER_MODELS['x-ai'] = old_xai
config.cfg.clear()
config.cfg.update(old_cfg)
config._models_cache_provenance = old_prov
config._available_models_cache = old_cache
assert model == mid, f"b3nw's non-declared cold custom-proxy pick must preserve, got {model!r}"
assert provider == 'custom:llm-proxy'
def test_warm_models_catalog_provenance_if_cold_publishes_from_disk_5979():
"""The send-path warm helper publishes provenance from a valid disk cache
when memory is cold — restoring the endpoint-advertised signal so #433
strips and #5979 preserves — WITHOUT a live rebuild.
"""
old_cfg = dict(config.cfg)
config.cfg.clear()
config.cfg.update({
'model': {'default': 'x-ai/grok-4.5', 'provider': 'custom:llm-proxy',
'base_url': 'https://proxy.example/v1'},
'custom_providers': [{'name': 'llm-proxy', 'base_url': 'https://proxy.example/v1',
'key_env': 'LLM_PROXY_API_KEY',
'models': ['x-ai/grok-4.5', 'x-ai/grok-composer-2.5-fast']}],
})
try:
# Build + persist to disk, then simulate a cold memory cache (restart).
config.invalidate_models_cache()
config.get_available_models() # publishes to memory + disk
assert config._models_cache_provenance is not None
# Simulate cold memory but valid disk cache (do NOT delete disk).
config._available_models_cache = None
config._advertised_model_ids_memo = None
config._sync_models_cache_provenance()
assert config._models_cache_provenance is None, "precondition: memory cold"
# The warm helper should republish provenance from disk (network-free).
config.warm_models_catalog_provenance_if_cold()
assert config._models_cache_provenance is not None, (
"warm helper must publish provenance from the disk cache"
)
adv = config._endpoint_advertised_model_ids('custom:llm-proxy')
assert adv and 'x-ai/grok-composer-2.5-fast' in adv, (
f"warmed provenance must carry the endpoint-advertised ids, got {adv!r}"
)
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config.invalidate_models_cache()
def test_warm_models_catalog_provenance_noop_when_already_warm_5979():
"""The warm helper is a cheap no-op (single global read) when provenance is
already published — it must not rebuild or mutate a warm catalog.
"""
sentinel = ({'groups': []}, {'config_yaml': {'path': '/x'}})
old_prov = config._models_cache_provenance
config._models_cache_provenance = sentinel
try:
config.warm_models_catalog_provenance_if_cold()
assert config._models_cache_provenance is sentinel, (
"warm helper must not touch an already-warm provenance tuple"
)
finally:
config._models_cache_provenance = old_prov
def test_warm_models_catalog_provenance_never_live_rebuilds_5979(monkeypatch):
"""The warm helper must call get_available_models with prefer_cache=True so
it can never trigger a live provider probe on the send path.
"""
old_prov = config._models_cache_provenance
config._models_cache_provenance = None
captured = {}
def _fake_get_available_models(*, prefer_cache=False, force_refresh=False):
captured['prefer_cache'] = prefer_cache
captured['force_refresh'] = force_refresh
return {'groups': []}
monkeypatch.setattr(config, 'get_available_models', _fake_get_available_models)
try:
config.warm_models_catalog_provenance_if_cold()
finally:
config._models_cache_provenance = old_prov
assert captured.get('prefer_cache') is True, "warm must use prefer_cache=True (no live rebuild)"
assert captured.get('force_refresh') is False, "warm must never force_refresh"
def test_custom_remote_preserves_unknown_prefix_548():
"""#548: an unknown vendor prefix (zai-org/GLM-5.1) is always preserved.