diff --git a/api/oauth.py b/api/oauth.py index b9f6495b..13d9a693 100644 --- a/api/oauth.py +++ b/api/oauth.py @@ -218,6 +218,23 @@ def _now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") +def _invalidate_provider_state_caches(provider: str) -> None: + """Invalidate caches derived from provider credential state.""" + try: + from api.config import invalidate_credential_pool_cache + + invalidate_credential_pool_cache(provider) + except Exception: + logger.debug("Failed to invalidate %s credential cache", provider, exc_info=True) + + try: + from api.providers import invalidate_providers_cache + + invalidate_providers_cache() + except Exception: + logger.debug("Failed to invalidate providers cache after %s credential change", provider, exc_info=True) + + def _persist_codex_credentials(hermes_home: Path, token_data: dict[str, Any]) -> Path: """Persist Codex OAuth credentials to active-profile auth.json.""" access_token = str(token_data.get("access_token") or "").strip() @@ -277,12 +294,7 @@ def _persist_codex_credentials(hermes_home: Path, token_data: dict[str, Any]) -> auth["updated_at"] = now path = _write_auth_json(auth, auth_path) - try: - from api.config import invalidate_credential_pool_cache - - invalidate_credential_pool_cache("openai-codex") - except Exception: - logger.debug("Failed to invalidate openai-codex credential cache", exc_info=True) + _invalidate_provider_state_caches("openai-codex") return path @@ -389,12 +401,7 @@ def _link_anthropic_credentials(hermes_home: Path) -> None: }) auth["updated_at"] = now _write_auth_json(auth, auth_path) - - try: - from api.config import invalidate_credential_pool_cache - invalidate_credential_pool_cache("anthropic") - except Exception: - logger.debug("Failed to invalidate anthropic credential cache", exc_info=True) + _invalidate_provider_state_caches("anthropic") def _anthropic_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]: @@ -515,11 +522,7 @@ def _remove_anthropic_link_marker(hermes_home: Path) -> None: pool.pop("anthropic", None) auth["updated_at"] = _now_iso() _write_auth_json(auth, auth_path) - try: - from api.config import invalidate_credential_pool_cache - invalidate_credential_pool_cache("anthropic") - except Exception: - logger.debug("Failed to invalidate anthropic credential cache", exc_info=True) + _invalidate_provider_state_caches("anthropic") # ── Codex protocol ────────────────────────────────────────────────────────── diff --git a/api/providers.py b/api/providers.py index fb42b82b..98bb2960 100644 --- a/api/providers.py +++ b/api/providers.py @@ -1025,6 +1025,9 @@ def _get_cached_providers(cache_key: tuple[Any, ...]) -> dict[str, Any] | None: def _store_cached_providers(cache_key: tuple[Any, ...], payload: dict[str, Any]) -> dict[str, Any]: with _providers_cache_lock: + # Single-entry by design: /api/providers is cacheable only for the + # active profile/config snapshot, so clear older snapshots to avoid + # retaining unbounded provider metadata across profile switches. _providers_cache.clear() _providers_cache[cache_key] = (time.monotonic(), copy.deepcopy(payload)) return payload diff --git a/tests/test_provider_management.py b/tests/test_provider_management.py index f51bacd6..013a8aad 100644 --- a/tests/test_provider_management.py +++ b/tests/test_provider_management.py @@ -170,6 +170,24 @@ class TestGetProviders: if hasattr(prov, "invalidate_providers_cache"): prov.invalidate_providers_cache() + def test_oauth_credential_updates_invalidate_providers_cache(self, monkeypatch, tmp_path): + """OAuth credential updates should invalidate cached Providers responses (#6010).""" + from api import oauth + from api import providers as prov + + invalidated_credentials = [] + providers_invalidated = [] + monkeypatch.setattr(config, "invalidate_credential_pool_cache", invalidated_credentials.append) + monkeypatch.setattr(prov, "invalidate_providers_cache", lambda: providers_invalidated.append(True)) + + oauth._persist_codex_credentials( + tmp_path, + {"access_token": "access-token", "refresh_token": "refresh-token"}, + ) + + assert invalidated_credentials == ["openai-codex"] + assert providers_invalidated == [True] + def test_returns_list_of_known_providers(self, monkeypatch, tmp_path): """GET /api/providers should return a list of all known providers.""" _install_fake_hermes_cli(monkeypatch)