fix(config): custom/proxy providers keep the full vendor namespace on the model id (#5979)
This commit is contained in:
commit
286277ab3d
4 changed files with 565 additions and 22 deletions
|
|
@ -17,6 +17,8 @@
|
|||
|
||||
- **The reasoning-effort chip is correct right after an empty startup.** On a normal boot with no active session restored, the top-bar reasoning chip wasn't refreshed for the hydrated default model, so it could show a stale value until you changed the model. It now refreshes on empty boot too. Thanks @rodboev. (#5967, #5954)
|
||||
|
||||
- **Custom/proxy providers keep the full vendor namespace on the model id (P0 routing fix).** When you run multiple upstream vendors behind one OpenAI-compatible proxy (`provider: custom` / `custom:<name>`) and pick a vendor-namespaced model such as `x-ai/grok-4.5`, the WebUI was stripping the `x-ai/` namespace before sending, so the proxy received a bare `grok-4.5` it couldn't route and rejected the turn with `HTTP 400: Invalid model format`. The strip was a catalog-*family* guess that couldn't tell a namespace the proxy actually routes on (`x-ai/grok-4.5`, keep) apart from a stale cross-provider leftover on a relay that only serves bare ids (`openai/gpt-5.4` → `gpt-5.4`, strip). Resolution is now based on **what the endpoint actually advertised**: if the proxy's own catalog lists the full `vendor/model`, it is sent whole; if it lists only the bare id, the redundant prefix is stripped; and on a cold/unbuilt catalog the id is preserved verbatim (fail-safe toward the id you selected). First-party providers pointed at an OpenAI-compatible proxy still send the bare id as before. Regression was latent — it surfaced only once a model graduated into the first-party catalog. Thanks @b3nw for the report and root-cause analysis. (#5979)
|
||||
|
||||
- **Transparent Stream no longer bloats memory (or freezes the tab) on long tool-heavy chats.** With Transparent Stream enabled, opening a conversation with many prompts built a DOM node for every activity event of every past turn at load and eagerly rendered each tool row's full detail body — a long reasoning-heavy history could balloon to multiple GB and lock up the tab. Settled tool rows now render header-only and build their detail on first expand (identical to the old view once opened), and a turn with a very large number of steps shows its most recent 30 behind a "Show N earlier steps" control. Live turns are unchanged. Thanks @curtisszmania-cytocync for the report. (#5974, #5966)
|
||||
|
||||
- **Dictation no longer wipes text you already typed.** When you typed part of a prompt and then used the microphone (server-side transcription / MediaRecorder path), the recognized text replaced your typed prefix instead of being appended to it. Dictation now appends to whatever is already in the composer, so you can type a partial sentence and dictate the rest. A Settings toggle lets you opt back into the old replace behavior. Thanks @Kopamed. (#5895)
|
||||
|
|
|
|||
231
api/config.py
231
api/config.py
|
|
@ -2354,6 +2354,57 @@ def _is_local_server_provider(provider_id: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _model_id_declared_in_config(model_id: str, config_provider: str | None) -> bool:
|
||||
"""True when the user's own config declares ``model_id`` verbatim (full form).
|
||||
|
||||
This is the COLD-catalog provenance signal for #5979: when the live
|
||||
``/v1/models`` catalog is unbuilt (fresh process, headless client), a
|
||||
vendor-namespaced id the user configured — ``model.default``, the
|
||||
``model.models`` allowlist, or the matching ``custom_providers[].models`` /
|
||||
``.model`` for a named ``custom:<slug>`` — is still authoritative provenance
|
||||
that the full id is intentional and must be preserved. Config is the one
|
||||
source available with zero network and no catalog dependency, so it survives
|
||||
a cold restart (b3nw's ``model.default: x-ai/grok-4.5``). Checked ONLY for
|
||||
custom providers; returns False for anything not verbatim-declared so the
|
||||
caller falls through to the legacy family heuristic.
|
||||
"""
|
||||
model = str(model_id or "").strip()
|
||||
if not model:
|
||||
return False
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
if str(model_cfg.get("default") or "").strip() == model:
|
||||
return True
|
||||
_declared = model_cfg.get("models")
|
||||
if isinstance(_declared, dict) and model in _declared:
|
||||
return True
|
||||
if isinstance(_declared, list) and any(
|
||||
(str(m.get("id") or "").strip() if isinstance(m, dict) else str(m or "").strip()) == model
|
||||
for m in _declared
|
||||
):
|
||||
return True
|
||||
# Named custom:<slug> — scan its custom_providers[] entry for a verbatim id.
|
||||
prov = str(config_provider or "").strip().lower()
|
||||
if prov.startswith("custom:"):
|
||||
raw_suffix = prov.removeprefix("custom:")
|
||||
for entry in _custom_provider_entries():
|
||||
slug = _custom_provider_slug_from_name(entry.get("name"))
|
||||
entry_name = str(entry.get("name") or "").strip().lower()
|
||||
if not (prov in {entry_name, slug} or (slug and raw_suffix == slug.removeprefix("custom:"))):
|
||||
continue
|
||||
if str(entry.get("model") or "").strip() == model:
|
||||
return True
|
||||
_em = entry.get("models")
|
||||
if isinstance(_em, dict) and model in _em:
|
||||
return True
|
||||
if isinstance(_em, list) and any(
|
||||
(str(m.get("id") or "").strip() if isinstance(m, dict) else str(m or "").strip()) == model
|
||||
for m in _em
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_first_party_model(provider_id: str, model_id: str) -> bool:
|
||||
"""True when ``model_id`` is listed in ``provider_id``'s own static catalog.
|
||||
|
||||
|
|
@ -2808,9 +2859,59 @@ def resolve_model_provider(model_id: str) -> tuple:
|
|||
# earlier by the ``prefix == config_provider`` branch.
|
||||
_cp_lower = (config_provider or "").strip().lower()
|
||||
_is_custom = _cp_lower == "custom" or _cp_lower.startswith("custom:")
|
||||
if prefix in _PROVIDER_MODELS and (
|
||||
not _is_custom or _is_first_party_model(prefix, bare)
|
||||
):
|
||||
if _is_custom:
|
||||
# Vendor-routing proxy: the reliable signal for whether the
|
||||
# endpoint wants the full ``vendor/model`` id or the bare id is
|
||||
# what its own catalog actually advertised (the ids the user
|
||||
# picked from the dropdown, populated by the endpoint's live
|
||||
# ``/v1/models`` probe or a ``custom_providers[].models``
|
||||
# allowlist). The catalog-FAMILY heuristic (_is_first_party_model)
|
||||
# is the wrong question: it answered "is this bare id a first-
|
||||
# party model of the prefix's home vendor?" which is True for BOTH
|
||||
# ``x-ai/grok-4.5`` (proxy advertised it whole — must preserve,
|
||||
# #5979) and ``openai/gpt-5.4`` (a stale leftover on a relay that
|
||||
# only serves bare ``gpt-5.4`` — must strip, #433). Those two are
|
||||
# structurally identical to the family heuristic, so a model
|
||||
# graduating into a first-party catalog (agent commit 62ada5175
|
||||
# adding grok-4.5) silently flipped a working custom-proxy id from
|
||||
# preserved to stripped. Tri-state provenance tells them apart:
|
||||
#
|
||||
# (1) Config declares the full id verbatim (model.default /
|
||||
# model.models / custom_providers[].models). Authoritative and
|
||||
# network-free, so #5979 survives a cold restart — preserve.
|
||||
if _model_id_declared_in_config(model_id, config_provider):
|
||||
return model_id, config_provider, config_base_url
|
||||
# (2) The endpoint's live/cached catalog advertised it.
|
||||
_advertised = _endpoint_advertised_model_ids(config_provider)
|
||||
if _advertised:
|
||||
# Full id advertised → route on it verbatim (#5979/#3872/#548).
|
||||
if model_id in _advertised:
|
||||
return model_id, config_provider, config_base_url
|
||||
# ONLY the bare id advertised → the prefix is a redundant
|
||||
# leftover the relay rejects; strip it (#433). Keep the
|
||||
# ``prefix in _PROVIDER_MODELS`` belt so an adversarial catalog
|
||||
# advertising a bare id can't strip an unknown-vendor prefix.
|
||||
if bare in _advertised and prefix in _PROVIDER_MODELS:
|
||||
return bare, config_provider, config_base_url
|
||||
# Advertised but neither exact shape matched → intrinsic /
|
||||
# unknown prefix the proxy routes on; preserve it whole.
|
||||
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
|
||||
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
|
||||
# what it expects — "openai/gpt-5.4" → "gpt-5.4" (#433).
|
||||
if prefix in _PROVIDER_MODELS:
|
||||
return bare, config_provider, config_base_url
|
||||
# Intrinsic / unknown prefix — pass the full model_id through unchanged.
|
||||
return model_id, config_provider, config_base_url
|
||||
|
|
@ -4511,6 +4612,121 @@ _available_models_cache_lock = threading.RLock() # must be RLock: cold path ref
|
|||
_cache_build_cv = threading.Condition(_available_models_cache_lock) # shares underlying RLock so notify_all() is safe inside with _available_models_cache_lock
|
||||
_cache_build_in_progress = False # True while a cold path is actively building
|
||||
|
||||
# Memoized (snapshot_ref, {provider_slug: frozenset(model_ids)}) derived from
|
||||
# the published models-catalog snapshot. Used by _endpoint_advertised_model_ids
|
||||
# to answer "did this endpoint actually advertise this exact id?" in O(1) per
|
||||
# send without rebuilding. Keyed on the snapshot object identity so it is
|
||||
# recomputed exactly once per catalog publish (the cache is replaced wholesale,
|
||||
# never mutated) and can never serve stale ids from a superseded catalog.
|
||||
_advertised_model_ids_memo: tuple | None = None
|
||||
|
||||
# Atomic provenance pair: an immutable (snapshot, publisher_fingerprint) tuple
|
||||
# published together at every catalog publish/invalidate site via
|
||||
# _sync_models_cache_provenance(). The resolver reads THIS single global with one
|
||||
# lock-free load so it can never observe a torn snapshot/fingerprint pair (the
|
||||
# two underlying globals are assigned as separate statements). Reading a tuple is
|
||||
# atomic under the GIL and, crucially, acquires NO lock — so the per-send
|
||||
# provenance check introduces no lock-ordering edge (avoids the _cfg_lock ↔
|
||||
# _available_models_cache_lock deadlock) and never waits behind a catalog rebuild.
|
||||
_models_cache_provenance: tuple | None = None
|
||||
|
||||
|
||||
def _sync_models_cache_provenance() -> None:
|
||||
"""Republish the atomic (snapshot, fingerprint) provenance pair.
|
||||
|
||||
MUST be called at every site that assigns ``_available_models_cache`` and
|
||||
``_available_models_cache_source_fingerprint`` (publish and invalidate),
|
||||
AFTER both have been set. It snapshots the current pair into one immutable
|
||||
tuple so ``_endpoint_advertised_model_ids`` reads both consistently with a
|
||||
single lock-free load. A reader that races between an underlying assignment
|
||||
and this call sees the PREVIOUS consistent tuple (never a torn pair); once
|
||||
this runs, readers see the new consistent pair.
|
||||
"""
|
||||
global _models_cache_provenance
|
||||
snap = _available_models_cache
|
||||
_models_cache_provenance = (
|
||||
(snap, _available_models_cache_source_fingerprint) if snap is not None else None
|
||||
)
|
||||
|
||||
|
||||
def _endpoint_advertised_model_ids(provider_id: str | None) -> frozenset | None:
|
||||
"""Model ids the given provider's group advertised in the current catalog.
|
||||
|
||||
Reads ONLY the already-published in-memory catalog snapshot
|
||||
(``_available_models_cache``) — it never builds, live-probes, or touches
|
||||
disk, so it is safe to call on the per-turn send hot path. Returns:
|
||||
|
||||
* a ``frozenset`` of the ids advertised by ``provider_id``'s own group
|
||||
(bare ids for the active provider, e.g. ``x-ai/grok-4.5``), or
|
||||
* ``None`` when the catalog is cold/unbuilt OR the provider has no group.
|
||||
|
||||
``None`` means "no provenance signal available" — callers MUST treat that as
|
||||
"preserve the model id verbatim" so a cache miss never silently strips a
|
||||
vendor namespace off an id the user actively selected (#5979). Scoping to
|
||||
the provider's OWN group prevents a same-named id in a sibling group (e.g.
|
||||
an ``openai/gpt-5.4`` sitting in the OpenRouter group) from masquerading as
|
||||
something this custom endpoint advertised.
|
||||
"""
|
||||
global _advertised_model_ids_memo
|
||||
# Single lock-free atomic read of the immutable (snapshot, fingerprint) pair
|
||||
# published by _sync_models_cache_provenance(). Reading one tuple can never
|
||||
# tear, and acquiring no lock means this per-send check adds no lock-ordering
|
||||
# edge (no _cfg_lock ↔ _available_models_cache_lock deadlock) and never waits
|
||||
# behind a catalog rebuild.
|
||||
provenance = _models_cache_provenance
|
||||
if provenance is None:
|
||||
return None
|
||||
snapshot, published_fp = provenance
|
||||
if snapshot is None:
|
||||
return None
|
||||
# Profile-isolation fail-safe (profiles are islands): the catalog cache is a
|
||||
# process global, so a concurrently-active profile could have published the
|
||||
# snapshot we're now reading. Only trust it for provenance when the
|
||||
# fingerprint captured AT PUBLISH TIME still matches the current runtime
|
||||
# fingerprint — the ``config_yaml`` axis of that fingerprint is the
|
||||
# PROFILE-SPECIFIC config path (_get_config_path -> get_active_hermes_home),
|
||||
# so a match guarantees the snapshot belongs to the profile asking. Any
|
||||
# mismatch (foreign profile, config edit, stale) returns None so the caller
|
||||
# preserves the id verbatim rather than stripping against another profile's
|
||||
# catalog.
|
||||
try:
|
||||
if published_fp != _models_cache_source_fingerprint():
|
||||
return None
|
||||
except Exception:
|
||||
return None # fingerprint unavailable → no trustworthy provenance
|
||||
memo = _advertised_model_ids_memo
|
||||
# Identity check (``is``), not id(): holding the snapshot reference in the
|
||||
# memo keeps it alive, so a freed-then-reused id() can't cause a false hit.
|
||||
if memo is None or memo[0] is not snapshot:
|
||||
by_slug: dict[str, frozenset] = {}
|
||||
try:
|
||||
groups = snapshot.get("groups", []) or []
|
||||
except AttributeError:
|
||||
return None
|
||||
for group in groups:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
slug = str(group.get("provider_id") or "").strip().lower()
|
||||
if not slug:
|
||||
continue
|
||||
# Union BOTH catalog buckets: a provider's models can be split across
|
||||
# ``models`` (visible) and ``extra_models`` (overflow) by the picker,
|
||||
# so an id the endpoint genuinely advertised may live in either. Only
|
||||
# reading ``models`` would miss it and mis-resolve (e.g. leave the
|
||||
# #433 bare id unstripped because it sits in extra_models).
|
||||
ids = frozenset(
|
||||
str(m.get("id"))
|
||||
for bucket in ("models", "extra_models")
|
||||
for m in (group.get(bucket) or [])
|
||||
if isinstance(m, dict) and m.get("id")
|
||||
)
|
||||
by_slug[slug] = by_slug.get(slug, frozenset()) | ids
|
||||
memo = (snapshot, by_slug)
|
||||
_advertised_model_ids_memo = memo
|
||||
slug = str(provider_id or "").strip().lower()
|
||||
return memo[1].get(slug)
|
||||
|
||||
|
||||
# Hard wall-clock budget for a COLD live provider-catalog rebuild when it is
|
||||
# run from a foreground request path. The live rebuild does one network probe
|
||||
# per detected provider (Copilot token-exchange HTTPS, OpenRouter /v1/models,
|
||||
|
|
@ -5817,6 +6033,7 @@ def _get_fresh_memory_models_cache(now: float) -> dict | None:
|
|||
_available_models_cache_ts = 0.0
|
||||
_available_models_live_rebuild_ts = 0.0
|
||||
_available_models_cache_source_fingerprint = None
|
||||
_sync_models_cache_provenance()
|
||||
return None
|
||||
if _is_valid_models_cache(_available_models_cache):
|
||||
return _annotate_fast_tier_model_groups(copy.deepcopy(_available_models_cache))
|
||||
|
|
@ -5824,6 +6041,7 @@ def _get_fresh_memory_models_cache(now: float) -> dict | None:
|
|||
_available_models_cache_ts = 0.0
|
||||
_available_models_live_rebuild_ts = 0.0
|
||||
_available_models_cache_source_fingerprint = None
|
||||
_sync_models_cache_provenance()
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -5848,6 +6066,7 @@ def invalidate_models_cache():
|
|||
_available_models_cache_ts = 0.0
|
||||
_available_models_live_rebuild_ts = 0.0
|
||||
_available_models_cache_source_fingerprint = None
|
||||
_sync_models_cache_provenance()
|
||||
_cache_build_in_progress = False
|
||||
_cache_build_cv.notify_all()
|
||||
# Clear the credential pool cache too (all profiles). Without this,
|
||||
|
|
@ -5907,6 +6126,7 @@ def invalidate_provider_models_cache(provider_id: str):
|
|||
_available_models_cache_ts = 0.0
|
||||
_available_models_live_rebuild_ts = 0.0
|
||||
_available_models_cache_source_fingerprint = None
|
||||
_sync_models_cache_provenance()
|
||||
_provider_models_invalidated_ts[provider_id] = time.time()
|
||||
# Also evict the credential pool so the next cold path re-loads it.
|
||||
# Must evict both the original key and its canonical form (load_pool
|
||||
|
|
@ -7654,6 +7874,7 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
|
|||
_available_models_cache_ts = 0.0
|
||||
_available_models_live_rebuild_ts = 0.0
|
||||
_available_models_cache_source_fingerprint = None
|
||||
_sync_models_cache_provenance()
|
||||
disk_groups = None
|
||||
stale_disk_groups = None
|
||||
|
||||
|
|
@ -7704,6 +7925,7 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
|
|||
_available_models_cache = disk_groups
|
||||
_available_models_cache_ts = now
|
||||
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
|
||||
_sync_models_cache_provenance()
|
||||
return copy.deepcopy(disk_groups)
|
||||
|
||||
# ── prefer_cache: NEVER run the live provider rebuild ────────────────
|
||||
|
|
@ -7780,6 +8002,7 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
|
|||
_available_models_cache_ts = published_at
|
||||
_available_models_live_rebuild_ts = published_at
|
||||
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
|
||||
_sync_models_cache_provenance()
|
||||
try:
|
||||
_save_models_cache_to_disk(result)
|
||||
finally:
|
||||
|
|
@ -7829,6 +8052,7 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa
|
|||
_available_models_cache_source_fingerprint = (
|
||||
_models_cache_source_fingerprint()
|
||||
)
|
||||
_sync_models_cache_provenance()
|
||||
try:
|
||||
_save_models_cache_to_disk(result)
|
||||
except Exception:
|
||||
|
|
@ -7993,6 +8217,7 @@ def get_available_models_for_session_visit() -> dict:
|
|||
_available_models_cache = copy.deepcopy(disk_cached)
|
||||
_available_models_cache_ts = time.monotonic()
|
||||
_available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
|
||||
_sync_models_cache_provenance()
|
||||
_mark("disk_cache_returned")
|
||||
_maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
|
||||
return copy.deepcopy(disk_cached)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,44 @@ def _resolve_with_config(model_id, provider=None, base_url=None, default=None, c
|
|||
config.cfg.update(old_cfg)
|
||||
|
||||
|
||||
def _resolve_with_catalog(model_id, advertised_ids, *, provider=None, base_url=None,
|
||||
provider_id='custom', default=None):
|
||||
"""Resolve with a seeded models-catalog snapshot (#5979 provenance).
|
||||
|
||||
``advertised_ids`` is the list of model ids the endpoint's own group
|
||||
advertised (what the user could have picked from the dropdown). Pass ``None``
|
||||
to simulate a COLD/unbuilt catalog. Seeds ``config._available_models_cache``
|
||||
(the snapshot ``_endpoint_advertised_model_ids`` reads) for the duration of
|
||||
the call, resets the derivation memo, and restores both afterwards so tests
|
||||
don't leak catalog state into each other.
|
||||
"""
|
||||
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
|
||||
if advertised_ids is None:
|
||||
config._available_models_cache = None
|
||||
else:
|
||||
config._available_models_cache = {
|
||||
'groups': [{
|
||||
'provider_id': provider_id,
|
||||
'models': [{'id': mid, 'label': mid} for mid in advertised_ids],
|
||||
}]
|
||||
}
|
||||
# Stamp the source fingerprint exactly as the real publish sites do, so
|
||||
# the accessor's profile-isolation guard trusts this seeded snapshot.
|
||||
config._available_models_cache_source_fingerprint = config._models_cache_source_fingerprint()
|
||||
config._advertised_model_ids_memo = None # force recompute against the seeded snapshot
|
||||
config._sync_models_cache_provenance() # publish the atomic (snapshot, fingerprint) pair
|
||||
try:
|
||||
return _resolve_with_config(model_id, provider=provider, base_url=base_url, default=default)
|
||||
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
|
||||
|
||||
|
||||
# ── OpenRouter prefix handling ────────────────────────────────────────────
|
||||
|
||||
def test_openrouter_free_keeps_full_path():
|
||||
|
|
@ -170,10 +208,12 @@ def test_custom_remote_preserves_intrinsic_vendor_prefix_3872():
|
|||
A bare ``custom`` provider with a remote base_url is a vendor-routing proxy
|
||||
(LiteLLM, Bedrock gateway). ``bedrock/`` is an intrinsic routing segment the
|
||||
proxy needs whole; stripping it to ``opus-4-6`` makes the proxy return 403
|
||||
"model not allowed for your group".
|
||||
"model not allowed for your group". The proxy advertised the full id (the
|
||||
user picked it from the dropdown), so provenance preserves it.
|
||||
"""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
model, provider, base_url = _resolve_with_catalog(
|
||||
'bedrock/opus-4-6',
|
||||
advertised_ids=['bedrock/opus-4-6'],
|
||||
provider='custom',
|
||||
base_url='https://router.example.com/v1',
|
||||
)
|
||||
|
|
@ -183,15 +223,20 @@ def test_custom_remote_preserves_intrinsic_vendor_prefix_3872():
|
|||
|
||||
|
||||
def test_custom_remote_strips_redundant_first_party_prefix_433():
|
||||
"""#433: bare-custom remote proxy still strips a REDUNDANT first-party prefix.
|
||||
"""#433: bare-custom remote proxy strips a prefix ONLY when the endpoint
|
||||
advertised just the BARE id (not the full ``vendor/model``).
|
||||
|
||||
``gpt-5.4`` IS a first-party OpenAI model, so ``openai/`` is a redundant
|
||||
leftover and the proxy expects the bare id. This is the behaviour pinned by
|
||||
test_sprint40_ui_polish.py::test_prefixed_model_stripped_for_custom_endpoint;
|
||||
the #3872 fix must keep it while preserving intrinsic vendor prefixes.
|
||||
#433 is a verified real relay whose ``/v1/models`` returned bare ``gpt-5.4``
|
||||
and rejected ``openai/gpt-5.4`` (a stale cross-provider leftover). The strip
|
||||
is now justified by PROVENANCE — the catalog advertises exactly ``gpt-5.4``
|
||||
and NOT ``openai/gpt-5.4`` — instead of the old catalog-family guess that
|
||||
couldn't tell this stale-leftover case apart from #5979's advertised-full-id
|
||||
case. Behaviour is also pinned by
|
||||
test_sprint40_ui_polish.py::test_prefixed_model_stripped_for_custom_endpoint.
|
||||
"""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
model, provider, base_url = _resolve_with_catalog(
|
||||
'openai/gpt-5.4',
|
||||
advertised_ids=['gpt-5.4'], # relay advertises ONLY the bare id
|
||||
provider='custom',
|
||||
base_url='https://router.example.com/v1',
|
||||
)
|
||||
|
|
@ -199,10 +244,255 @@ def test_custom_remote_strips_redundant_first_party_prefix_433():
|
|||
assert provider == 'custom'
|
||||
|
||||
|
||||
def test_custom_remote_preserves_advertised_full_id_5979():
|
||||
"""#5979 (the P0 regression): a custom proxy that advertises the FULL
|
||||
``x-ai/grok-4.5`` must receive it whole — never the bare ``grok-4.5``.
|
||||
|
||||
The old code stripped it because ``grok-4.5`` had graduated into the x-ai
|
||||
first-party catalog (agent commit 62ada5175), flipping ``_is_first_party_model``
|
||||
to True for a model the proxy routes on by its full ``x-ai/`` namespace. This
|
||||
is the exact HTTP 400 b3nw hit ("Invalid model format ... grok-4.5"). The
|
||||
provenance rule preserves it because the endpoint advertised the full id.
|
||||
"""
|
||||
# Deterministically reproduce the data-driven trigger regardless of the
|
||||
# hermes-agent catalog version CI happens to run against: ensure grok-4.5 is
|
||||
# first-party of x-ai so _is_first_party_model('x-ai','grok-4.5') is True (the
|
||||
# condition under which the OLD code stripped). We stub it into the catalog
|
||||
# rather than asserting the live catalog already contains it.
|
||||
xai_catalog = list(config._PROVIDER_MODELS.get('x-ai') or [])
|
||||
had_grok = any(isinstance(m, dict) and m.get('id') == 'grok-4.5' for m in xai_catalog)
|
||||
old_xai = config._PROVIDER_MODELS.get('x-ai')
|
||||
if not had_grok:
|
||||
config._PROVIDER_MODELS['x-ai'] = xai_catalog + [{'id': 'grok-4.5', 'label': 'Grok 4.5'}]
|
||||
try:
|
||||
assert config._is_first_party_model('x-ai', 'grok-4.5'), (
|
||||
"precondition: grok-4.5 must be first-party of x-ai for this regression"
|
||||
)
|
||||
model, provider, base_url = _resolve_with_catalog(
|
||||
'x-ai/grok-4.5',
|
||||
advertised_ids=['x-ai/grok-4.5'], # proxy advertises the FULL namespaced id
|
||||
provider='custom',
|
||||
base_url='https://proxy.example.com/v1',
|
||||
)
|
||||
finally:
|
||||
if not had_grok:
|
||||
if old_xai is None:
|
||||
config._PROVIDER_MODELS.pop('x-ai', None)
|
||||
else:
|
||||
config._PROVIDER_MODELS['x-ai'] = old_xai
|
||||
assert model == 'x-ai/grok-4.5', (
|
||||
f"advertised full id must be preserved for routing, got {model!r}"
|
||||
)
|
||||
assert provider == 'custom'
|
||||
assert base_url == 'https://proxy.example.com/v1'
|
||||
|
||||
|
||||
def test_named_custom_slug_preserves_advertised_full_id_5979():
|
||||
"""#5979 (named-custom variant): provider=custom:<slug> proxy advertising the
|
||||
full ``x-ai/grok-4.5`` also preserves it."""
|
||||
model, provider, base_url = _resolve_with_catalog(
|
||||
'x-ai/grok-4.5',
|
||||
advertised_ids=['x-ai/grok-4.5'],
|
||||
provider='custom:my-gateway',
|
||||
provider_id='custom:my-gateway',
|
||||
base_url='https://proxy.example.com/v1',
|
||||
)
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
# first-party redundant prefix → strip (matches pre-fix / #433 cold path)
|
||||
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
|
||||
model_b, _, _ = _resolve_with_catalog(
|
||||
'zai-org/GLM-5.1', advertised_ids=None,
|
||||
provider='custom', base_url='https://api.deepinfra.com/v1/openai',
|
||||
)
|
||||
assert model_b == 'zai-org/GLM-5.1', f"cold unknown prefix must preserve, got {model_b!r}"
|
||||
|
||||
|
||||
def test_custom_remote_config_declared_full_id_preserved_cold_5979():
|
||||
"""#5979 cold-restart survival: even with a COLD catalog, a full vendor id
|
||||
the user DECLARED in config (model.default) is preserved — config is
|
||||
network-free provenance that outlives a process restart.
|
||||
"""
|
||||
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
|
||||
old_cfg = dict(config.cfg)
|
||||
config._available_models_cache = None # cold
|
||||
config._advertised_model_ids_memo = None
|
||||
config._sync_models_cache_provenance() # publish the cold (None) provenance
|
||||
config.cfg['model'] = {
|
||||
'provider': 'custom',
|
||||
'default': 'x-ai/grok-4.5', # user-declared full id
|
||||
'base_url': 'https://proxy.example.com/v1',
|
||||
}
|
||||
try:
|
||||
model, provider, _ = config.resolve_model_provider('x-ai/grok-4.5')
|
||||
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
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
assert model == 'x-ai/grok-4.5', (
|
||||
f"config-declared full id must survive a cold catalog, got {model!r}"
|
||||
)
|
||||
assert provider == 'custom'
|
||||
|
||||
|
||||
def test_custom_remote_prefers_full_id_when_both_advertised_5979():
|
||||
"""When a proxy advertises BOTH the full ``x-ai/grok-4.5`` and a bare
|
||||
``grok-4.5``, the exact full selection wins (preserve)."""
|
||||
model, _, _ = _resolve_with_catalog(
|
||||
'x-ai/grok-4.5',
|
||||
advertised_ids=['x-ai/grok-4.5', 'grok-4.5'],
|
||||
provider='custom',
|
||||
base_url='https://proxy.example.com/v1',
|
||||
)
|
||||
assert model == 'x-ai/grok-4.5', f"exact full selection must win, got {model!r}"
|
||||
|
||||
|
||||
def test_custom_remote_extra_models_bucket_counts_as_advertised_5979():
|
||||
"""Provenance must read BOTH catalog buckets. A relay's bare id sitting in
|
||||
``extra_models`` (picker overflow) still counts as advertised, so the stale
|
||||
``openai/gpt-5.4`` prefix is stripped (#433) even when ``models`` is full of
|
||||
OTHER ids and the bare id overflowed into ``extra_models``.
|
||||
"""
|
||||
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': f'filler-{i}', 'label': f'filler-{i}'} for i in range(30)],
|
||||
'extra_models': [{'id': 'gpt-5.4', 'label': 'gpt-5.4'}], # bare id overflowed here
|
||||
}]
|
||||
}
|
||||
config._available_models_cache_source_fingerprint = config._models_cache_source_fingerprint()
|
||||
config._advertised_model_ids_memo = None
|
||||
config._sync_models_cache_provenance()
|
||||
try:
|
||||
model, _, _ = _resolve_with_config(
|
||||
'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 == 'gpt-5.4', f"bare id in extra_models must count as advertised, got {model!r}"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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``).
|
||||
"""
|
||||
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'}]}]
|
||||
}
|
||||
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',
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
def test_resolver_provenance_read_does_not_block_on_cache_lock_5979():
|
||||
"""Regression: the resolver's per-send provenance read must be LOCK-FREE
|
||||
with respect to ``_available_models_cache_lock``.
|
||||
|
||||
Codex found a deadlock in an earlier cut where the accessor acquired that
|
||||
lock: config-save (``_cfg_lock`` → cache lock) opposed catalog-refresh (cache
|
||||
lock → ``_cfg_lock``). The fix publishes an atomic ``(snapshot, fingerprint)``
|
||||
tuple the resolver reads with one lock-free load. Proof: hold the cache lock
|
||||
on one thread while another thread resolves — it must complete promptly, not
|
||||
block behind the held lock.
|
||||
"""
|
||||
import threading
|
||||
import time as _time
|
||||
old_cfg = dict(config.cfg)
|
||||
config.cfg.clear()
|
||||
config.cfg.update({'model': {
|
||||
'provider': 'custom', 'default': 'x-ai/grok-4.5',
|
||||
'base_url': 'https://proxy.example/v1', 'models': {'x-ai/grok-4.5': {}},
|
||||
}})
|
||||
config.invalidate_models_cache()
|
||||
config.get_available_models() # warm the catalog + publish provenance
|
||||
try:
|
||||
got = config._available_models_cache_lock.acquire(blocking=False)
|
||||
assert got, "precondition: could not take cache lock non-blocking"
|
||||
result = {}
|
||||
def _worker():
|
||||
t0 = _time.time()
|
||||
result['model'] = config.resolve_model_provider(
|
||||
config.model_with_provider_context('x-ai/grok-4.5', 'custom')
|
||||
)[0]
|
||||
result['elapsed'] = _time.time() - t0
|
||||
th = threading.Thread(target=_worker)
|
||||
th.start()
|
||||
th.join(timeout=5)
|
||||
blocked = th.is_alive()
|
||||
config._available_models_cache_lock.release()
|
||||
if blocked:
|
||||
th.join(timeout=5)
|
||||
assert not blocked, "DEADLOCK: resolver blocked on _available_models_cache_lock"
|
||||
assert result.get('model') == 'x-ai/grok-4.5', f"got {result.get('model')!r}"
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config.invalidate_models_cache()
|
||||
|
||||
|
||||
def test_custom_remote_preserves_unknown_prefix_548():
|
||||
"""#548: an unknown vendor prefix (zai-org/GLM-5.1) is always preserved."""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
"""#548: an unknown vendor prefix (zai-org/GLM-5.1) is always preserved.
|
||||
|
||||
The proxy advertised the full id; ``zai-org`` isn't in _PROVIDER_MODELS so
|
||||
even the bare-advertised belt could never strip it.
|
||||
"""
|
||||
model, provider, base_url = _resolve_with_catalog(
|
||||
'zai-org/GLM-5.1',
|
||||
advertised_ids=['zai-org/GLM-5.1'],
|
||||
provider='custom',
|
||||
base_url='https://api.deepinfra.com/v1/openai',
|
||||
)
|
||||
|
|
@ -212,15 +502,13 @@ def test_custom_remote_preserves_unknown_prefix_548():
|
|||
|
||||
def test_named_custom_slug_preserves_intrinsic_vendor_prefix_3872():
|
||||
"""#3872 (named-custom variant): provider=custom:<slug> + remote base_url also
|
||||
preserves an intrinsic vendor prefix when the typed model isn't in the entry.
|
||||
|
||||
A model typed/selected that is not listed in the custom_providers[] entry
|
||||
falls through to the base_url branch; a bare id NOT first-party of the prefix
|
||||
(bedrock/opus-4-6) must still be kept whole, same as bare ``custom``.
|
||||
preserves an intrinsic vendor prefix the endpoint advertised whole.
|
||||
"""
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
model, provider, base_url = _resolve_with_catalog(
|
||||
'bedrock/opus-4-6',
|
||||
advertised_ids=['bedrock/opus-4-6'],
|
||||
provider='custom:my-gateway',
|
||||
provider_id='custom:my-gateway',
|
||||
base_url='https://router.example.com/v1',
|
||||
)
|
||||
assert model == 'bedrock/opus-4-6', f"intrinsic prefix must be preserved for custom:slug, got {model!r}"
|
||||
|
|
|
|||
|
|
@ -146,27 +146,55 @@ if __name__ == "__main__":
|
|||
class TestCustomEndpointModelStripping:
|
||||
"""Tests for fix #433: strip provider prefix when custom base_url is set."""
|
||||
|
||||
def _resolve(self, model_id, provider=None, base_url=None):
|
||||
"""Helper: set cfg directly (same pattern as test_model_resolver.py)."""
|
||||
def _resolve(self, model_id, provider=None, base_url=None, advertised_ids=None):
|
||||
"""Helper: set cfg directly (same pattern as test_model_resolver.py).
|
||||
|
||||
``advertised_ids`` seeds the models-catalog snapshot that provenance
|
||||
resolution reads (#5979). Pass the ids the endpoint's own group
|
||||
advertised; None leaves the catalog cold (preserve-verbatim default).
|
||||
"""
|
||||
old_cfg = dict(_api_config.cfg)
|
||||
old_cache = _api_config._available_models_cache
|
||||
old_memo = _api_config._advertised_model_ids_memo
|
||||
old_fp = _api_config._available_models_cache_source_fingerprint
|
||||
old_prov = _api_config._models_cache_provenance
|
||||
model_cfg = {}
|
||||
if provider:
|
||||
model_cfg['provider'] = provider
|
||||
if base_url:
|
||||
model_cfg['base_url'] = base_url
|
||||
_api_config.cfg['model'] = model_cfg
|
||||
if advertised_ids is None:
|
||||
_api_config._available_models_cache = None
|
||||
else:
|
||||
_api_config._available_models_cache = {
|
||||
'groups': [{
|
||||
'provider_id': provider or 'custom',
|
||||
'models': [{'id': mid, 'label': mid} for mid in advertised_ids],
|
||||
}]
|
||||
}
|
||||
_api_config._available_models_cache_source_fingerprint = _api_config._models_cache_source_fingerprint()
|
||||
_api_config._advertised_model_ids_memo = None
|
||||
_api_config._sync_models_cache_provenance()
|
||||
try:
|
||||
return _api_config.resolve_model_provider(model_id)
|
||||
finally:
|
||||
_api_config.cfg.clear()
|
||||
_api_config.cfg.update(old_cfg)
|
||||
_api_config._available_models_cache = old_cache
|
||||
_api_config._advertised_model_ids_memo = old_memo
|
||||
_api_config._available_models_cache_source_fingerprint = old_fp
|
||||
_api_config._models_cache_provenance = old_prov
|
||||
|
||||
def test_prefixed_model_stripped_for_custom_endpoint(self):
|
||||
"""Issue #433: 'openai/gpt-5.4' with custom base_url returns bare 'gpt-5.4'."""
|
||||
"""Issue #433: 'openai/gpt-5.4' with custom base_url returns bare 'gpt-5.4'
|
||||
when the endpoint advertised ONLY the bare id (provenance strip).
|
||||
"""
|
||||
model, provider, base_url = self._resolve(
|
||||
'openai/gpt-5.4',
|
||||
provider='custom',
|
||||
base_url='http://my-proxy.local:8080/v1',
|
||||
advertised_ids=['gpt-5.4'], # relay serves the bare id only
|
||||
)
|
||||
assert model == 'gpt-5.4', (
|
||||
"Expected bare 'gpt-5.4' for custom endpoint, got '{}'."
|
||||
|
|
|
|||
Loading…
Reference in a new issue