Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 /
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion openhands-sdk/openhands/sdk/llm/fallback_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions openhands-sdk/openhands/sdk/llm/llm_profile_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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")
Expand Down Expand Up @@ -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():
Expand All @@ -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:
Expand All @@ -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

Expand Down
49 changes: 47 additions & 2 deletions tests/sdk/llm/test_llm_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
# =========================================================================
Expand Down
Loading