diff --git a/api/routes.py b/api/routes.py index 428adcb29..23994f04a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1763,10 +1763,20 @@ def _model_matches_configured_default( - bare: ``claude-opus-4.8`` - slash-prefixed: ``anthropic/claude-opus-4.8`` (OpenRouter-style) - @provider:model: ``@anthropic:claude-opus-4.8`` - Treat any of these as a match so a provider-prefixed default config still - receives its cap (and a bare default still matches a prefixed session model). - Empty default → no match (caller falls back to applying the cap broadly only - when no default is configured at all). + + Matching rule (correct in both directions): + 1. Identical strings → match. + 2. Otherwise compare BARE model ids — BUT only after a provider-compatibility + check: if BOTH sides carry an identifiable provider (from a ``provider/`` + prefix, an ``@provider:`` qualifier, or the explicit ``provider`` arg for + the session side) and those providers DIFFER, it is NOT a match. This + stops a non-default model on a different provider that happens to share a + bare name (``openai/gpt-4o`` vs default ``openrouter/gpt-4o``) from being + treated as the default and wrongly receiving its cap. + 3. When a provider can't be identified on one side, fall through to the bare + comparison (lenient-when-unknown — a bare default config still matches a + bare/prefixed session model). + Empty default → no match. """ sess = str(session_model or "").strip() default = str(cfg_default or "").strip() @@ -1774,23 +1784,33 @@ def _model_matches_configured_default( return False if sess == default: return True - # Normalize both to their bare model name, stripping a leading - # ``@provider:`` qualifier or a single ``provider/`` slash prefix. - def _bare(value: str, prov: str | None = None) -> set[str]: - forms = {value} - unq, _q = _split_provider_qualified_model(value) - if unq: - forms.add(unq) - # Strip a single leading "provider/" segment (OpenRouter-style). - if "/" in unq: - forms.add(unq.split("/", 1)[1].strip()) - # Also consider a provider-prefixed form when an explicit provider is known. - if prov: - p = str(prov).strip().lower() - if p and "/" not in unq and not unq.startswith("@"): - forms.add(f"{p}/{unq}") - return {f for f in forms if f} - return bool(_bare(sess, provider) & _bare(default)) + + def _split(value: str) -> tuple[str, str | None]: + """Return (bare_model, provider_or_None) for any of the 3 shapes.""" + value = str(value or "").strip() + # @provider:model + unq, q_prov = _split_provider_qualified_model(value) + if q_prov: + return unq.strip(), str(q_prov).strip().lower() + # provider/model (single leading slash segment) + if "/" in value: + prefix, rest = value.split("/", 1) + return rest.strip(), prefix.strip().lower() + return value, None + + sess_bare, sess_prov = _split(sess) + default_bare, default_prov = _split(default) + # The explicit provider arg is the session side's provider when the model + # string itself didn't carry one. + if not sess_prov and provider: + sess_prov = str(provider).strip().lower() or None + + if not sess_bare or not default_bare or sess_bare != default_bare: + return False + # Bare ids match. Reject only when both sides name DIFFERENT providers. + if sess_prov and default_prov and sess_prov != default_prov: + return False + return True diff --git a/tests/test_issue3256_context_length_default_only_guard.py b/tests/test_issue3256_context_length_default_only_guard.py index 8d403fcac..fab27abad 100644 --- a/tests/test_issue3256_context_length_default_only_guard.py +++ b/tests/test_issue3256_context_length_default_only_guard.py @@ -130,6 +130,17 @@ def test_default_match_distinct_models_do_not_match(): assert _matcher()("gpt-5.5", "claude-opus-4.8", "openai") is False +def test_default_match_same_bare_different_provider_does_NOT_match(): + """Same bare model id on a DIFFERENT provider is NOT the configured default + and must not receive its cap (Codex final-gate over-match finding).""" + assert _matcher()("openai/gpt-4o", "openrouter/gpt-4o", "openai") is False + assert _matcher()("@openai:gpt-4o", "@openrouter:gpt-4o") is False + assert _matcher()("gpt-4o", "openrouter/gpt-4o", "openai") is False + # but the SAME provider (or unknown session provider) still matches: + assert _matcher()("openrouter/gpt-4o", "openrouter/gpt-4o") is True + assert _matcher()("gpt-4o", "openrouter/gpt-4o", "openrouter") is True + + def test_default_match_empty_inputs_are_false(): assert _matcher()("claude-opus-4.8", "") is False assert _matcher()("", "claude-opus-4.8") is False