From 792adc653a3985da8ad8b46a673308860524dc3b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 15 Jun 2026 21:06:08 -0700 Subject: [PATCH] Release PK (v0.51.450): preserve custom provider namespace prefixes in model normalization (#4278) (#4294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Release PK (v0.51.450): preserve custom provider namespace prefixes in model normalization (#4278) Token-boundary check in _normalize_provider_id (exact token or prefix followed strictly by ':' or '/'); explicit aliases for hyphenated first-party names (google-gemini, google-ai-studio, claude-code); bare-prefix family detection no longer fires on namespaced model IDs. Closes #4278. Supersedes #4280 (rodboev) whose '-' boundary re-collapsed claude-relay->anthropic. Co-authored-by: b3nw * gate fix (Codex CORE): add openai-api to _PROVIDER_ALIASES so the :/ token boundary doesn't drop the registered openai-api provider id to '' (would silently repair OpenAI API sessions to the profile default) + 2 regression tests * gate fix (Opus): gate the streaming send-path bare-prefix loop on '/' not in model so custom namespaces (gemini_cli/, gpt_proxy/, claude-relay/) survive the send path — same #4278 collision Opus found in _apply_profile_provider_context_to_streaming_model; + regression test + CHANGELOG --------- Co-authored-by: nesquena-hermes --- CHANGELOG.md | 6 + api/routes.py | 24 ++- api/streaming.py | 16 +- .../test_custom_provider_prefix_collisions.py | 154 ++++++++++++++++++ 4 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 tests/test_custom_provider_prefix_collisions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 033e0822..79de4625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259, #4263, #4266, #4274) +## [v0.51.450] — 2026-06-16 — Release PK (preserve custom provider namespace prefixes in model normalization) + +### Fixed + +- **Custom provider model namespaces are no longer silently rewritten to a first-party family.** `_normalize_provider_id` matched provider prefixes with a broad `startswith()`, so a custom namespace that merely *began* with a first-party token — `gemini_cli/...`, `gpt_proxy/...`, `claude-relay/...` — collapsed into the first-party family (`google`/`openai`/`anthropic`). On session reload this triggered a spurious "stale model repair" that swapped the model to the profile default and cleared the displayed turn metrics (`last_prompt_tokens`, `threshold_tokens`), making the composer context indicator ring disappear for custom models. Prefix matching is now restricted to an exact token or a prefix followed strictly by an accepted namespace delimiter (`:` or `/`), and genuine hyphenated first-party aliases (`openai-api`, `openai-codex`, `google-gemini`, `google-ai-studio`, `claude-code`) are mapped explicitly so they still resolve. The same bare-prefix collision was also fixed on the chat-send path (the streaming profile-context resolver had its own ungated loop), and the bare-prefix family detection no longer fires on namespaced model IDs. (#4278) + ## [v0.51.449] — 2026-06-16 — Release PJ (stop recovered turns replaying into later context) ### Fixed diff --git a/api/routes.py b/api/routes.py index 7df7c43a..4e41e115 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1282,6 +1282,10 @@ _PROVIDER_ALIASES = { "gpt": "openai", "gemini": "google", "openai-codex": "openai", + "openai-api": "openai", + "google-gemini": "google", + "google-ai-studio": "google", + "claude-code": "anthropic", } # OpenAI-compatible /v1/models endpoints for live model discovery. @@ -2908,6 +2912,13 @@ def _handle_client_event_log(handler, body: dict) -> bool: return j(handler, {"ok": True, "event": payload.get("event")}) or True +def _starts_token(raw: str, prefix: str) -> bool: + if not raw.startswith(prefix): + return False + rest = raw[len(prefix):] + return rest == "" or rest[0] in ":/" + + def _normalize_provider_id(value: str | None) -> str: raw = str(value or "").strip().lower() if not raw: @@ -2924,7 +2935,7 @@ def _normalize_provider_id(value: str | None) -> str: ("openrouter", "openrouter"), ("custom", "custom"), ): - if raw.startswith(prefix): + if _starts_token(raw, prefix): return normalized # Unknown prefix — return empty so callers treat it as "no match" and pass # the model through unchanged rather than incorrectly stripping it. @@ -3479,11 +3490,12 @@ def _resolve_compatible_session_model_state( model_provider_from_name = _normalize_provider_id(model_prefix) if "/" in model else "" model_family = "" - model_lower = model.lower() - for bare_prefix in ("gpt", "claude", "gemini"): - if model_lower.startswith(bare_prefix): - model_family = _normalize_provider_id(bare_prefix) - break + if "/" not in model: + model_lower = model.lower() + for bare_prefix in ("gpt", "claude", "gemini"): + if model_lower.startswith(bare_prefix): + model_family = _normalize_provider_id(bare_prefix) + break if model_family and model_family != _profile_provider_normalized: if explicit_model_pick: diff --git a/api/streaming.py b/api/streaming.py index d06c17fa..6dcd8819 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -166,11 +166,17 @@ def _apply_profile_provider_context_to_streaming_model( profile_provider_normalized = _normalize_provider_id(profile_provider) model_lower = (model or "").lower() - for prefix in ("gpt", "claude", "gemini"): - if model_lower.startswith(prefix): - if _normalize_provider_id(prefix) != profile_provider_normalized: - return profile_default_model, provider_context, True - return model, provider_context, False + # Only run the bare-prefix family match on un-namespaced model ids. A custom + # namespace like "gemini_cli/..." or "claude-relay/..." merely *starts with* a + # first-party token; matching it here would clobber the model to the profile + # default on the send path (the #4278 collision — the slash-qualified branch + # below routes through the fixed _normalize_provider_id instead). + if "/" not in model_lower: + for prefix in ("gpt", "claude", "gemini"): + if model_lower.startswith(prefix): + if _normalize_provider_id(prefix) != profile_provider_normalized: + return profile_default_model, provider_context, True + return model, provider_context, False if "/" in model_lower: slash_prefix = model_lower.split("/", 1)[0] diff --git a/tests/test_custom_provider_prefix_collisions.py b/tests/test_custom_provider_prefix_collisions.py new file mode 100644 index 00000000..45e0492a --- /dev/null +++ b/tests/test_custom_provider_prefix_collisions.py @@ -0,0 +1,154 @@ +import sys +import types +from unittest.mock import patch + +# ── Mocking context-length/metadata and setup requirements for routes testing ── +def fake_get_model_context_length(model, base_url="", **kwargs): + return 1048576 + +# Ensure a mock of 'agent' exists in sys.modules to prevent ModuleNotFoundError in routes imports. +fake_agent = types.ModuleType("agent") +fake_agent.__path__ = [] +metadata = types.ModuleType("agent.model_metadata") +fake_agent.model_metadata = metadata # type: ignore[attr-defined] +sys.modules["agent"] = fake_agent +sys.modules["agent.model_metadata"] = metadata +metadata.get_model_context_length = fake_get_model_context_length + +from api.routes import _normalize_provider_id, _resolve_compatible_session_model_state + +def test_normalize_provider_id_custom_prefix_collision(): + """Verify that _normalize_provider_id does NOT mis-normalize custom CLI/proxy prefixes. + + 'gemini_cli' starts with 'gemini', but because it is a custom provider prefix with an + underscore suffix, it should not collapse into the first-party 'google' family. It + must return '' (unknown) so that it is passed through untouched. + """ + # Colliding custom prefixes (must return "") + assert _normalize_provider_id("gemini_cli") == "" + assert _normalize_provider_id("gemini-cli") == "" + assert _normalize_provider_id("gpt_proxy") == "" + assert _normalize_provider_id("claude_gateway") == "" + assert _normalize_provider_id("openai_compat") == "" + + # Supported first-party exact match and aliases (must be normalized correctly) + assert _normalize_provider_id("gemini") == "google" + assert _normalize_provider_id("google-gemini") == "google" + assert _normalize_provider_id("openai-codex") == "openai" + assert _normalize_provider_id("claude-code") == "anthropic" + assert _normalize_provider_id("custom:newapi") == "custom" + + +def test_resolve_session_model_state_custom_prefix_survives(): + """Verify that _resolve_compatible_session_model_state preserves custom prefix model strings. + + We pass model_provider=None and profile_provider="custom:newapi" to force the state + resolver down the 'slow path' (family repair checks) where our _normalize_provider_id() + bug actually resides, verifying it survives with changed=False. + """ + model_id = "gemini_cli/gemini-3-flash-preview" + model_provider = None + profile_provider = "custom:newapi" + profile_default = "x-ai/grok-composer" + + with patch("api.routes.get_available_models") as mock_gam: + # Stub catalog return directly (mock_gam.return_value) + mock_gam.return_value = { + "active_provider": "custom:newapi", + "default_model": "x-ai/grok-composer", + "groups": [ + { + "provider": "custom:newapi", + "provider_id": "custom:newapi", + "models": [{"id": "gemini_cli/gemini-3-flash-preview", "label": "Gemini 3 Flash"}] + } + ] + } + + resolved_model, resolved_provider, changed = _resolve_compatible_session_model_state( + model_id, + model_provider, + profile_provider=profile_provider, + profile_default_model=profile_default, + prefer_cached_catalog=True, + ) + + assert resolved_model == "gemini_cli/gemini-3-flash-preview" + assert resolved_provider == "custom:newapi" + assert changed is False + + +def test_hyphenated_first_party_provider_ids_still_normalize(): + """Registered first-party provider IDs that use a hyphen separator (not ':' or '/') + must still normalize to their family via the explicit alias map — the ':/'-only token + boundary alone would drop them to '' and silently repair the session model to the + profile default (the #4278 regression class, caught on the openai-api provider id). + """ + # openai-api is a real registered provider id (_PROVIDER_DISPLAY "OpenAI API"). + assert _normalize_provider_id("openai-api") == "openai" + # The other hyphenated first-party ids already covered, kept here as a class guard. + assert _normalize_provider_id("openai-codex") == "openai" + assert _normalize_provider_id("google-gemini") == "google" + assert _normalize_provider_id("google-ai-studio") == "google" + assert _normalize_provider_id("claude-code") == "anthropic" + + +def test_openai_api_session_model_not_repaired_to_default(): + """A bare non-default GPT model under an openai-api active/profile provider must be + preserved, not silently repaired to the profile default (regression of the openai-api + alias gap).""" + with patch("api.routes.get_available_models") as mock_gam: + mock_gam.return_value = { + "active_provider": "openai-api", + "default_model": "gpt-5.5", + "groups": [ + { + "provider": "OpenAI API", + "provider_id": "openai-api", + "models": [ + {"id": "gpt-5.5", "label": "GPT-5.5"}, + {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"}, + ], + } + ], + } + + resolved_model, resolved_provider, changed = _resolve_compatible_session_model_state( + "gpt-5.5-mini", + None, + profile_provider="openai-api", + profile_default_model="gpt-5.5", + prefer_cached_catalog=True, + ) + + assert resolved_model == "gpt-5.5-mini" + assert changed is False + + +def test_streaming_send_path_preserves_custom_namespace_models(): + """The chat-SEND path (_apply_profile_provider_context_to_streaming_model in + api/streaming.py) had its own ungated bare-prefix loop — a custom namespace like + 'gemini_cli/...' starts with 'gemini' and was clobbered to the profile default on + send (the #4278 collision on the send path, distinct from the session-load path). + The loop is now gated to un-namespaced model ids.""" + from api.streaming import _apply_profile_provider_context_to_streaming_model as apply_ctx + + # Custom-namespace models must survive the send path untouched. + for model in ( + "gemini_cli/gemini-3-flash-preview", + "gpt_proxy/some-model", + "claude-relay/m", + ): + resolved, ctx, changed = apply_ctx(model, None, "custom:newapi", "x-ai/grok-composer") + assert resolved == model, model + assert changed is False, model + + # A genuine bare cross-family model must STILL be repaired to the profile default. + resolved, ctx, changed = apply_ctx("gpt-5.5", None, "google", "gemini-3-pro") + assert resolved == "gemini-3-pro" + assert changed is True + + # A slash-qualified cross-family model must STILL be repaired. + resolved, ctx, changed = apply_ctx("gemini/gemini-3-pro", None, "openai-api", "gpt-5.5") + assert resolved == "gpt-5.5" + assert changed is True