From 97499d702eee5d98c1f763678ee4084a37a6d328 Mon Sep 17 00:00:00 2001 From: cucurigoo <241698038+cucurigoo@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:32:08 +0000 Subject: [PATCH] fix(compression): harden startup route scoping --- agent/agent_init.py | 174 +++++++- .../test_invalid_context_length_warning.py | 4 +- tests/run_agent/test_switch_model_context.py | 378 ++++++++++++++++++ 3 files changed, 540 insertions(+), 16 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 6055b8c0ccbc5..3ae396e59db91 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -28,7 +28,7 @@ import time import uuid from datetime import datetime from typing import Any, Callable, Dict, List, Optional -from urllib.parse import urlparse, parse_qs, urlunparse +from urllib.parse import parse_qs, urlparse, urlsplit, urlunparse, urlunsplit from agent.context_compressor import ContextCompressor from agent.iteration_budget import IterationBudget @@ -68,6 +68,112 @@ def _ra(): return run_agent +def _normalize_route_base_url(base_url: Any) -> str: + """Canonicalize an endpoint URL for model-route identity comparisons.""" + raw = str(base_url or "") + if not raw: + return "" + if any(ord(char) <= 0x20 for char in raw): + return raw + had_query_delimiter = "?" in raw.split("#", 1)[0] + try: + parsed = urlsplit(raw) + hostname = parsed.hostname + if not parsed.scheme or not hostname: + return raw + scheme = parsed.scheme.lower() + if "%" in hostname: + address, zone = hostname.split("%", 1) + host = f"{address.lower()}%{zone}" + else: + host = hostname.lower() + port = parsed.port + except (TypeError, ValueError): + return raw + + route_host = parsed.netloc.rsplit("@", 1)[-1] + if route_host.startswith("[") or ":" in host: + host = f"[{host}]" + if port is not None and (scheme, port) not in {("http", 80), ("https", 443)}: + host = f"{host}:{port}" + if "@" in parsed.netloc: + host = f"{parsed.netloc.rsplit('@', 1)[0]}@{host}" + + path = parsed.path + if path.endswith("/") and not had_query_delimiter: + path = path[:-1] + + normalized = urlunsplit( + ( + scheme, + host, + path, + parsed.query, + "", + ) + ) + if had_query_delimiter and not parsed.query: + normalized += "?" + return normalized + + +def _provider_default_routes(provider: str) -> set[str]: + """Return known exact default routes for a canonical provider id.""" + routes: set[str] = set() + try: + from hermes_cli.providers import HERMES_OVERLAYS, get_provider + + overlay = HERMES_OVERLAYS.get(provider) + provider_def = get_provider(provider) + for value in ( + getattr(overlay, "base_url_override", ""), + getattr(provider_def, "base_url", ""), + ): + route = _normalize_route_base_url(value) + if route: + routes.add(route) + except Exception: + pass + + try: + from providers import get_provider_profile + + profile = get_provider_profile(provider) + route = _normalize_route_base_url( + getattr(profile, "base_url", "") + ) + if route: + routes.add(route) + except Exception: + pass + + try: + from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.models import normalize_provider as normalize_model_provider + from hermes_cli.providers import normalize_provider as normalize_registry_provider + + for provider_id, config in PROVIDER_REGISTRY.items(): + canonical_id = normalize_registry_provider( + normalize_model_provider(provider_id) + ) + if canonical_id != provider: + continue + route = _normalize_route_base_url( + getattr(config, "inference_base_url", "") + ) + if route: + routes.add(route) + except Exception: + pass + + if provider == "gemini": + routes.update( + f"{route.rstrip('/')}/openai" + for route in list(routes) + ) + return routes + + def _build_codex_gpt5_autoraise_notice( autoraise: Dict[str, Any], context_length: Optional[int] = None ) -> str: @@ -1792,7 +1898,9 @@ def init_agent( except Exception: pass _configured_provider = str(_model_cfg.get("provider") or "").strip() - _configured_base_url = str(_model_cfg.get("base_url") or "").rstrip("/") + _configured_base_url = _normalize_route_base_url( + _model_cfg.get("base_url") + ) if not _configured_base_url and _configured_provider.lower().startswith("custom:"): _configured_custom_name = _configured_provider.split(":", 1)[1].lower() for _provider_entry in _custom_providers: @@ -1800,11 +1908,25 @@ def init_agent( continue if str(_provider_entry.get("name") or "").strip().lower() != _configured_custom_name: continue - _configured_base_url = str( - _provider_entry.get("base_url") or "" - ).rstrip("/") + _configured_base_url = _normalize_route_base_url( + _provider_entry.get("base_url") + ) break - _active_base_url = str(agent.base_url or "").rstrip("/") + _active_route_url = str(agent.base_url or "") + _requested_route_url = str(base_url or "") + if "?" in _requested_route_url.split("#", 1)[0]: + try: + _requested_parts = urlparse(_requested_route_url) + _requested_without_query = urlunparse( + _requested_parts._replace(query="") + ) + if _normalize_route_base_url( + _requested_without_query + ) == _normalize_route_base_url(_active_route_url): + _active_route_url = _requested_route_url + except (TypeError, ValueError): + pass + _active_base_url = _normalize_route_base_url(_active_route_url) _route_mismatch = bool( _configured_base_url and _active_base_url @@ -1812,19 +1934,43 @@ def init_agent( ) if not _configured_base_url: _active_provider = str(agent.provider or "").strip() + _normalize_provider_fn = None + _normalize_registry_provider_fn = None try: - from hermes_cli.models import normalize_provider + from hermes_cli.models import normalize_provider as _normalize_provider_fn - _configured_provider = normalize_provider(_configured_provider) - _active_provider = normalize_provider(_active_provider) + _configured_provider = _normalize_provider_fn(_configured_provider) + _active_provider = _normalize_provider_fn(_active_provider) except Exception: _configured_provider = _configured_provider.lower() _active_provider = _active_provider.lower() - _route_mismatch = bool( - _configured_provider - and _active_provider - and _configured_provider != _active_provider - ) + try: + from hermes_cli.providers import ( + normalize_provider as _normalize_registry_provider_fn, + ) + + _configured_provider = _normalize_registry_provider_fn( + _configured_provider + ) + _active_provider = _normalize_registry_provider_fn( + _active_provider + ) + except Exception: + pass + if _active_base_url: + _configured_routes = _provider_default_routes( + _configured_provider + ) + _route_mismatch = bool( + not _configured_routes + or _active_base_url not in _configured_routes + ) + else: + _route_mismatch = bool( + _configured_provider + and _active_provider + and _configured_provider != _active_provider + ) _model_mismatch = bool( _configured_default_runtime_model and _configured_default_runtime_model != _active_runtime_model diff --git a/tests/run_agent/test_invalid_context_length_warning.py b/tests/run_agent/test_invalid_context_length_warning.py index a38980e97745a..4c771949010fa 100644 --- a/tests/run_agent/test_invalid_context_length_warning.py +++ b/tests/run_agent/test_invalid_context_length_warning.py @@ -3,7 +3,7 @@ from unittest.mock import patch -def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus-4.6"): +def _build_agent(model_cfg, custom_providers=None, model=None): """Build an AIAgent with the given model config.""" cfg = {"model": model_cfg} if custom_providers is not None: @@ -21,7 +21,7 @@ def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus- from run_agent import AIAgent agent = AIAgent( - model=model, + model=model or model_cfg.get("default") or "anthropic/claude-opus-4.6", api_key="test-key-1234567890", base_url=base_url, quiet_mode=True, diff --git a/tests/run_agent/test_switch_model_context.py b/tests/run_agent/test_switch_model_context.py index 33893c68fdffe..48208eae77c5f 100644 --- a/tests/run_agent/test_switch_model_context.py +++ b/tests/run_agent/test_switch_model_context.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch from run_agent import AIAgent +from agent.agent_init import _normalize_route_base_url from agent.context_compressor import ContextCompressor @@ -20,6 +21,34 @@ class _StubStartupCompressor: return None +def test_route_url_normalization_preserves_path_slash_before_query(): + """A path slash before a query changes OpenAI SDK URL joining.""" + assert _normalize_route_base_url( + "https://example.com/v1/?tenant=large" + ) != _normalize_route_base_url("https://example.com/v1?tenant=large") + + +def test_route_url_normalization_preserves_trailing_whitespace(): + """Whitespace can alter the request target and must not collapse routes.""" + assert _normalize_route_base_url( + "https://example.com/v1 " + ) != _normalize_route_base_url("https://example.com/v1") + + +def test_route_url_normalization_preserves_bracketed_host_syntax(): + """Invalid bracketed host syntax must not collapse onto a valid DNS host.""" + assert _normalize_route_base_url( + "http://[v1.Foo]/v1" + ) != _normalize_route_base_url("http://v1.foo/v1") + + +def test_route_url_normalization_preserves_malformed_trailing_slash(): + """Malformed URLs are kept byte-exact rather than partially normalized.""" + assert _normalize_route_base_url( + "http://[bad/v1/" + ) != _normalize_route_base_url("http://[bad/v1") + + def _make_direct_start_agent( cfg: dict, *, model: str, provider: str, base_url: str ) -> AIAgent: @@ -202,6 +231,295 @@ def test_direct_start_preserves_context_for_bare_aggregator_model(): assert agent.context_compressor.config_context_length == 1_000_000 +def test_direct_start_drops_context_for_same_provider_custom_base_url(): + """An explicit endpoint override changes the route even if provider matches.""" + cfg = { + "model": { + "default": "gpt-5.4", + "provider": "openrouter", + "context_length": 1_000_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="gpt-5.4", + provider="openrouter", + base_url="https://small.example/v1", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_drops_context_for_provider_name_lookalike_host(): + """A hostname containing a provider domain is not that provider's route.""" + cfg = { + "model": { + "default": "gpt-5.4", + "provider": "openrouter", + "context_length": 1_000_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="gpt-5.4", + provider="openrouter", + base_url="https://evil-openrouter.ai/v1", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_preserves_context_for_codex_default_endpoint(): + """ChatGPT's Codex endpoint belongs to the openai-codex route.""" + cfg = { + "model": { + "default": "gpt-5.6-sol", + "provider": "openai-codex", + "context_length": 272_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="gpt-5.6-sol", + provider="openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + ) + + assert agent.context_compressor.config_context_length == 272_000 + + +def test_direct_start_drops_context_for_codex_wrong_path(): + """A known host with a different route path is not the Codex endpoint.""" + cfg = { + "model": { + "default": "gpt-5.6-sol", + "provider": "openai-codex", + "context_length": 272_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="gpt-5.6-sol", + provider="openai-codex", + base_url="https://chatgpt.com/unrelated", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_drops_context_for_overridden_provider_wrong_path(): + """Providers with an explicit default route require that complete route.""" + cfg = { + "model": { + "default": "grok-4", + "provider": "xai", + "context_length": 256_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="grok-4", + provider="xai", + base_url="https://api.x.ai/not-v1", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_preserves_context_for_equivalent_base_url_spellings(): + """Route identity ignores URL casing, default ports, and trailing slashes.""" + cfg = { + "model": { + "default": "gpt-5.4", + "provider": "openrouter", + "base_url": "HTTPS://OPENROUTER.AI:443/api/v1/", + "context_length": 1_000_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="gpt-5.4", + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + ) + + assert agent.context_compressor.config_context_length == 1_000_000 + + +def test_direct_start_drops_context_when_path_parameter_segment_changes(): + """Trailing-slash normalization must not move params to another segment.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://example.com/v1/;tenant=large", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="https://example.com/v1;tenant=large", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_drops_context_when_empty_path_parameter_changes(): + """An explicit empty path-parameter delimiter is not discarded.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://example.com/v1;", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="https://example.com/v1", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_drops_context_when_empty_query_delimiter_changes(): + """An explicit empty query changes OpenAI SDK base-URL joining semantics.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://example.com/v1?", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="https://example.com/v1", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_drops_context_when_active_query_changes(): + """Query parameters remain part of the effective route identity.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://example.com/v1", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="https://example.com/v1?tenant=small", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_preserves_context_for_matching_query_route(): + """SDK query extraction must not hide an otherwise matching route.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://example.com/v1?tenant=large", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="https://example.com/v1?tenant=large", + ) + + assert agent.context_compressor.config_context_length == 1_048_576 + + +def test_direct_start_drops_context_when_extra_trailing_segment_changes(): + """Only one conventional trailing slash is ignored for route identity.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://example.com/v1//", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="https://example.com/v1", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_drops_context_when_url_userinfo_changes(): + """Credentials embedded in a URL remain part of route identity.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "https://large-tenant:secret@example.com/v1", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="https://small-tenant:secret@example.com/v1", + ) + + assert agent.context_compressor.config_context_length is None + + +def test_direct_start_drops_context_when_ipv6_zone_case_changes(): + """IPv6 address hex is case-insensitive, but its zone identifier is not.""" + cfg = { + "model": { + "default": "shared-model", + "provider": "custom", + "base_url": "http://[FE80::1%25ETH0]/v1", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="shared-model", + provider="custom", + base_url="http://[fe80::1%25eth0]/v1", + ) + + assert agent.context_compressor.config_context_length is None + + def test_direct_start_preserves_context_for_provider_alias(): """Canonical provider aliases identify the same route when no URL is pinned.""" cfg = { @@ -222,6 +540,66 @@ def test_direct_start_preserves_context_for_provider_alias(): assert agent.context_compressor.config_context_length == 1_000_000 +def test_direct_start_preserves_context_for_registry_provider_alias(): + """Legacy and models.dev provider IDs may identify the same route.""" + cfg = { + "model": { + "default": "kimi-k3", + "provider": "kimi-for-coding", + "context_length": 1_048_576, + } + } + + agent = _make_direct_start_agent( + cfg, + model="kimi-k3", + provider="kimi-coding", + base_url="https://api.kimi.com/coding", + ) + + assert agent.context_compressor.config_context_length == 1_048_576 + + +def test_direct_start_preserves_context_for_profile_route_on_shared_host(): + """Exact provider-profile routes disambiguate providers sharing a hostname.""" + cfg = { + "model": { + "default": "gpt-5.4", + "provider": "opencode-zen", + "context_length": 1_000_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="gpt-5.4", + provider="opencode", + base_url="https://opencode.ai/zen/v1", + ) + + assert agent.context_compressor.config_context_length == 1_000_000 + + +def test_direct_start_drops_context_for_profile_wrong_path(): + """A shared hostname cannot substitute for a profile's complete route.""" + cfg = { + "model": { + "default": "gpt-5.4", + "provider": "opencode-go", + "context_length": 1_000_000, + } + } + + agent = _make_direct_start_agent( + cfg, + model="gpt-5.4", + provider="opencode-go", + base_url="https://opencode.ai/unrelated", + ) + + assert agent.context_compressor.config_context_length is None + + def test_direct_start_named_custom_route_resolves_configured_base_url(): """Named custom providers must not collapse to one generic custom route.""" cfg = {