From 0befb81ca5e895c16b105834aa29e9dfe27535be Mon Sep 17 00:00:00 2001 From: Sehlani042 <257166922+Sehlani042@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:00:12 +0800 Subject: [PATCH] fix(sdk): decrypt encrypted fallback profiles Co-authored-by: openhands --- .../conversation/impl/local_conversation.py | 6 ++- .../openhands/sdk/llm/fallback_strategy.py | 12 ++++- .../openhands/sdk/llm/llm_profile_store.py | 11 ++++- tests/sdk/llm/test_llm_fallback.py | 49 ++++++++++++++++++- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index fb4316046d..0775ff2945 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -406,6 +406,8 @@ def __init__( register_client_tools(recovered_specs) self.agent = agent + self._cipher = cipher + self._profile_store = LLMProfileStore(cipher=cipher) self._bind_conversation_context(self.agent.llm) # Default callback: persist every event to state @@ -484,8 +486,6 @@ def _default_callback(e): # Agent initialization is deferred to _ensure_agent_ready() for lazy loading # This ensures plugins are loaded before agent initialization self.llm_registry = LLMRegistry() - self._profile_store = LLMProfileStore() - self._cipher = cipher # Seed agent_context.secrets into the registry for every agent (regular # and ACP), covering callers that skip create_request() — canvas / @@ -1602,6 +1602,8 @@ def _bind_conversation_context(self, llm: LLM) -> None: See #3443 for background. """ llm._call_context = self.get_llm_call_context() + if llm.fallback_strategy is not None: + llm.fallback_strategy._bind_cipher(self._cipher) def _condenser_for_switched_llm( self, diff --git a/openhands-sdk/openhands/sdk/llm/fallback_strategy.py b/openhands-sdk/openhands/sdk/llm/fallback_strategy.py index e1793e767a..9e805ef492 100644 --- a/openhands-sdk/openhands/sdk/llm/fallback_strategy.py +++ b/openhands-sdk/openhands/sdk/llm/fallback_strategy.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from openhands.sdk.llm.llm_response import LLMResponse from openhands.sdk.llm.utils.metrics import Metrics + from openhands.sdk.utils.cipher import Cipher logger = get_logger(__name__) @@ -55,6 +56,15 @@ class FallbackStrategy(BaseModel): # Private: lazily resolved LLM instances _resolved: list[Any] | None = PrivateAttr(default=None) + _cipher: Cipher | None = PrivateAttr(default=None) + + def _bind_cipher(self, cipher: Cipher | None) -> None: + """Bind the conversation cipher used for lazy profile resolution.""" + if self._cipher is cipher: + return + self._cipher = cipher + self._resolved = None + self.__dict__.pop("_profile_store", None) def should_fallback(self, error: Exception) -> bool: """Whether this error type is eligible for fallback.""" @@ -119,7 +129,7 @@ def try_fallback( @cached_property def _profile_store(self) -> LLMProfileStore: - return LLMProfileStore(self.profile_store_dir) + return LLMProfileStore(self.profile_store_dir, cipher=self._cipher) def _iter_fallbacks(self) -> Generator[Any]: """Yield fallback LLM instances, resolving lazily from profiles. diff --git a/openhands-sdk/openhands/sdk/llm/llm_profile_store.py b/openhands-sdk/openhands/sdk/llm/llm_profile_store.py index 5001e4b9c4..b6381ee21e 100644 --- a/openhands-sdk/openhands/sdk/llm/llm_profile_store.py +++ b/openhands-sdk/openhands/sdk/llm/llm_profile_store.py @@ -86,6 +86,7 @@ def __init__( base_dir: Path | str | None = None, *, provider_store: ProviderConnectionStore | None = None, + cipher: Cipher | None = None, ) -> None: """Initialize the profile store. @@ -102,8 +103,11 @@ def __init__( connections from the same location it reads profiles from. Pass an explicit store to use an unrelated directory (e.g. the agent-server's config-scoped directory) or a test double. + cipher: Default cipher for profile loads. An explicit cipher passed + to :meth:`load` takes precedence. """ self.base_dir = Path(base_dir) if base_dir is not None else _DEFAULT_PROFILE_DIR + self._cipher = cipher # ensure directory existence self.base_dir.mkdir(parents=True, exist_ok=True) self._file_lock = FileLock(self.base_dir / ".profiles.lock") @@ -280,6 +284,7 @@ def load( TimeoutError: If the lock cannot be acquired. """ profile_path = self._get_profile_path(name) + effective_cipher = cipher if cipher is not None else self._cipher with self._acquire_lock(): if not profile_path.exists(): @@ -292,7 +297,9 @@ def load( try: from openhands.sdk.llm.llm import LLM - context: dict[str, Any] | None = {"cipher": cipher} if cipher else None + context: dict[str, Any] | None = ( + {"cipher": effective_cipher} if effective_cipher else None + ) llm_instance = LLM.load_from_json(str(profile_path), context=context) except Exception as e: @@ -303,7 +310,7 @@ def load( if resolve_provider: llm_instance = self._resolve_provider_connection( - name, llm_instance, cipher=cipher + name, llm_instance, cipher=effective_cipher ) return llm_instance diff --git a/tests/sdk/llm/test_llm_fallback.py b/tests/sdk/llm/test_llm_fallback.py index f48fb4b732..88991535f2 100644 --- a/tests/sdk/llm/test_llm_fallback.py +++ b/tests/sdk/llm/test_llm_fallback.py @@ -15,12 +15,16 @@ ) from pydantic import SecretStr +from openhands.sdk.agent import Agent +from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.llm import LLM, FallbackStrategy, Message, TextContent from openhands.sdk.llm.exceptions import ( LLMContextWindowExceedError, LLMServiceUnavailableError, ) from openhands.sdk.llm.llm import LLMCallContext +from openhands.sdk.llm.llm_profile_store import LLMProfileStore +from openhands.sdk.utils.cipher import Cipher def _get_mock_response(content: str = "ok", model: str = "gpt-4o") -> ModelResponse: @@ -284,8 +288,6 @@ def test_responses_non_transient_skips_fallback(mock_resp): @patch("openhands.sdk.llm.llm.litellm_completion") def test_fallback_profiles_resolved_via_store(mock_comp, tmp_path): """Verify that fallback profile names are resolved through LLMProfileStore.""" - from openhands.sdk.llm.llm_profile_store import LLMProfileStore - primary_error = APIConnectionError( message="down", llm_provider="openai", model="gpt-4o" ) @@ -313,6 +315,49 @@ def side_effect(**kwargs): assert content.text == "from store" +def test_conversation_cipher_decrypts_fallback_profile(tmp_path): + """A conversation's cipher must reach lazy fallback profile loading.""" + cipher = Cipher("server-secret-key") + profile_dir = tmp_path / "profiles" + store = LLMProfileStore(base_dir=profile_dir) + store.save( + "encrypted-fallback", + LLM( + model="fallback-model", + api_key=SecretStr("sk-fallback"), + usage_id="fallback-model", + ), + include_secrets=True, + cipher=cipher, + ) + + strategy = FallbackStrategy( + fallback_llms=["encrypted-fallback"], + profile_store_dir=profile_dir, + ) + unresolved = next(strategy._iter_fallbacks()) + assert unresolved.api_key is not None + assert unresolved.api_key.get_secret_value().startswith("gAAAAA") + + conversation = LocalConversation( + agent=Agent( + llm=_get_llm("primary-model", fallback_strategy=strategy), + tools=[], + ), + workspace=tmp_path / "workspace", + cipher=cipher, + ) + + try: + resolved_strategy = conversation.agent.llm.fallback_strategy + assert resolved_strategy is not None + fallback = next(resolved_strategy._iter_fallbacks()) + assert fallback.api_key is not None + assert fallback.api_key.get_secret_value() == "sk-fallback" + finally: + conversation.close() + + # ========================================================================= # Async error-handling parity tests (acompletion / aresponses) # =========================================================================