From 201ecb3d0fb07ff12a4f76909b36a1a47c7981e1 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Fri, 26 Sep 2025 14:48:29 +0000 Subject: [PATCH 01/17] add draft dynamic router impl --- examples/24_llm_manual_switch.py | 195 ++++++++++++++++ openhands/sdk/llm/router/impl/dynamic.py | 239 +++++++++++++++++++ tests/sdk/llm/test_dynamic_router.py | 285 +++++++++++++++++++++++ 3 files changed, 719 insertions(+) create mode 100644 examples/24_llm_manual_switch.py create mode 100644 openhands/sdk/llm/router/impl/dynamic.py create mode 100644 tests/sdk/llm/test_dynamic_router.py diff --git a/examples/24_llm_manual_switch.py b/examples/24_llm_manual_switch.py new file mode 100644 index 0000000000..274782978c --- /dev/null +++ b/examples/24_llm_manual_switch.py @@ -0,0 +1,195 @@ +import os +import uuid + +from pydantic import SecretStr + +from openhands.sdk import ( + LLM, + Agent, + Conversation, + EventBase, + LLMConvertibleEvent, + LocalFileStore, + Message, + TextContent, + get_logger, +) +from openhands.sdk.llm.router.impl.dynamic import DynamicRouter +from openhands.sdk.preset.default import get_default_tools + + +logger = get_logger(__name__) + +# Configure initial LLM +api_key = os.getenv("LITELLM_API_KEY") +assert api_key is not None, "LITELLM_API_KEY environment variable is not set." + +# Create initial LLM +initial_llm = LLM( + service_id="agent-initial", + model="litellm_proxy/anthropic/claude-sonnet-4-20250514", + base_url="https://llm-proxy.eval.all-hands.dev", + api_key=SecretStr(api_key), +) + +# Create DynamicRouter with initial LLM +dynamic_router = DynamicRouter( + service_id="dynamic-router", llms_for_routing={"claude": initial_llm} +) + +# Tools +cwd = os.getcwd() +tools = get_default_tools(working_dir=cwd) + +# Agent with dynamic router +agent = Agent(llm=dynamic_router, tools=tools) + +llm_messages = [] # collect raw LLM messages + + +def conversation_callback(event: EventBase): + if isinstance(event, LLMConvertibleEvent): + llm_messages.append(event.to_llm_message()) + + +# Set up conversation with persistence for serialization demo +conversation_id = uuid.uuid4() +file_store = LocalFileStore(f"./.conversations/{conversation_id}") + +conversation = Conversation( + agent=agent, + callbacks=[conversation_callback], + persist_filestore=file_store, + conversation_id=conversation_id, +) + +print(f"Starting with LLM: {dynamic_router.get_current_llm_name()}") +print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") + +# First interaction with Claude +conversation.send_message( + message=Message( + role="user", + content=[TextContent(text="Hi there!")], + ) +) +conversation.run() + +print("=" * 50) +print("Adding GPT-4 dynamically and switching to it...") + +# Dynamically add GPT-4 and switch to it +success = dynamic_router.switch_to_llm( + "gpt4", + model="litellm_proxy/openai/gpt-4o", + base_url="https://llm-proxy.eval.all-hands.dev", + api_key=api_key, + temperature=0.3, +) +print(f"GPT-4 added successfully: {success}") +print(f"Current LLM: {dynamic_router.get_current_llm_name()}") +print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") +print() + +# Second interaction with GPT-4 +conversation.send_message( + message=Message( + role="user", + content=[TextContent(text="Who trained you as an LLM?")], + ) +) +conversation.run() + +print("Adding a smaller model for simple tasks...") + +# Add a smaller model for simple tasks +success = dynamic_router.switch_to_llm( + "small_model", + model="litellm_proxy/mistral/devstral-small-2507", + base_url="https://llm-proxy.eval.all-hands.dev", + api_key=api_key, + temperature=0.1, +) +print(f"Small model added successfully: {success}") +print(f"Current LLM: {dynamic_router.get_current_llm_name()}") +print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") + +# Third interaction with small model +conversation.send_message( + message=Message( + role="user", + content=[TextContent(text="Who trained you as an LLM?")], + ) +) +conversation.run() + +print("Switching back to Claude for complex reasoning...") + +# Switch back to Claude for complex task +dynamic_router.switch_to_llm("claude") +print(f"Current LLM: {dynamic_router.get_current_llm_name()}") + +conversation.send_message( + message=Message( + role="user", + content=[ + TextContent( + text="Explain the concept of dynamic programming in one sentence." + ) + ], + ) +) +conversation.run() + +print("Demonstrating persistence with LLM switching...") + +# Show current state before serialization +print(f"Before serialization - Current LLM: {dynamic_router.get_current_llm_name()}") +print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") + +# Delete conversation to simulate restart +del conversation + +# Recreate conversation from persistence +print("Recreating conversation from persistence...") +conversation = Conversation( + agent=agent, + callbacks=[conversation_callback], + persist_filestore=file_store, + conversation_id=conversation_id, +) + +print(f"After deserialization - Current LLM: {dynamic_router.get_current_llm_name()}") +print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") + +# Continue conversation after persistence +conversation.send_message( + message=Message( + role="user", + content=[TextContent(text="Do you remember our previous conversation?")], + ) +) +conversation.run() + +print("=" * 50) +print("Removing a model...") + +# Remove the small model +success = dynamic_router.remove_llm("small_model") +print(f"Small model removed successfully: {success}") +print(f"Current LLM: {dynamic_router.get_current_llm_name()}") +print(f"Remaining LLMs: {list(dynamic_router.get_available_llms().keys())}") + +print("=" * 100) +print("Conversation finished. Got the following LLM messages:") +for i, message in enumerate(llm_messages): + print(f"Message {i}: {str(message)[:200]}") + +print("\n=== Summary ===") +print("This example demonstrated:") +print("1. Creating a DynamicRouter with an initial LLM") +print("2. Dynamically adding new LLMs during conversation") +print("3. Switching between different LLMs for different tasks") +print("4. Persistence and deserialization of dynamic LLM configurations") +print("5. Removing LLMs from the router") +print("6. All LLM switches are preserved across conversation restarts") diff --git a/openhands/sdk/llm/router/impl/dynamic.py b/openhands/sdk/llm/router/impl/dynamic.py new file mode 100644 index 0000000000..e2f760d113 --- /dev/null +++ b/openhands/sdk/llm/router/impl/dynamic.py @@ -0,0 +1,239 @@ +""" +Dynamic Router implementation for OpenHands SDK. + +This router allows users to switch to entirely new LLMs without pre-configuring them, +with full serialization/deserialization support. +""" + +from typing import Any + +from pydantic import Field + +from openhands.sdk.llm import LLM +from openhands.sdk.llm.message import Message +from openhands.sdk.llm.router.base import RouterLLM +from openhands.sdk.logger import get_logger + + +logger = get_logger(__name__) + + +class DynamicRouter(RouterLLM): + """ + A RouterLLM that supports dynamic LLM creation and switching. + + Users can switch to entirely new LLMs without pre-configuring them. + The router maintains both pre-configured LLMs and dynamically created ones, + with full serialization/deserialization support. + """ + + router_name: str = "dynamic_router" + manual_selection: str | None = None + + # Store LLM configurations for dynamic creation and serialization + dynamic_llm_configs: dict[str, dict[str, Any]] = Field(default_factory=dict) + + def select_llm(self, messages: list[Message]) -> str: + """ + Select LLM based on manual selection or fallback to first available. + + Args: + messages: List of messages (not used in manual selection) + + Returns: + Name of the selected LLM + + Raises: + ValueError: If no LLMs are available for routing + """ + if self.manual_selection: + # Ensure the manually selected LLM exists + self._ensure_llm_exists(self.manual_selection) + return self.manual_selection + + # Fallback to first available LLM + if self.llms_for_routing: + return next(iter(self.llms_for_routing.keys())) + + raise ValueError("No LLMs available for routing") + + def switch_to_llm( + self, + llm_name: str, + model: str | None = None, + api_key: str | None = None, + **kwargs, + ) -> bool: + """ + Switch to an LLM, creating it dynamically if it doesn't exist. + + Args: + llm_name: Name/identifier for the LLM + model: Model name (e.g., "gpt-4o", "claude-3-5-sonnet-20241022") + Required when creating a new LLM + api_key: API key for the model + **kwargs: Additional LLM configuration parameters + + Returns: + True if switch was successful, False otherwise + + Example: + # Switch to existing LLM + router.switch_to_llm("existing_llm") + + # Create and switch to new LLM + router.switch_to_llm( + "claude", + model="claude-3-5-sonnet-20241022", + api_key="sk-...", + temperature=0.7 + ) + """ + try: + # If LLM already exists, just switch to it + if llm_name in self.llms_for_routing: + self.manual_selection = llm_name + self.active_llm = self.llms_for_routing[llm_name] + logger.info(f"Switched to existing LLM: {llm_name}") + return True + + # Check if we have a stored config for this LLM + if llm_name in self.dynamic_llm_configs: + self._ensure_llm_exists(llm_name) + self.manual_selection = llm_name + self.active_llm = self.llms_for_routing[llm_name] + logger.info(f"Switched to LLM from stored config: {llm_name}") + return True + + # Create new LLM dynamically + if not model: + logger.error(f"Model must be specified to create new LLM: {llm_name}") + return False + + # Store configuration for serialization + llm_config = {"model": model, "service_id": f"dynamic_{llm_name}", **kwargs} + + # Add API key if provided (will be handled by OVERRIDE_ON_SERIALIZE) + if api_key: + llm_config["api_key"] = api_key + + # Create the LLM instance + new_llm = LLM(**llm_config) + + # Add to routing table and store config + self.llms_for_routing[llm_name] = new_llm + self.dynamic_llm_configs[llm_name] = llm_config + + # Switch to the new LLM + self.manual_selection = llm_name + self.active_llm = new_llm + + logger.info(f"Created and switched to new LLM: {llm_name} ({model})") + return True + + except Exception as e: + logger.error(f"Failed to switch to LLM {llm_name}: {e}") + return False + + def _ensure_llm_exists(self, llm_name: str): + """ + Ensure an LLM exists, recreating it from config if necessary. + + Args: + llm_name: Name of the LLM to ensure exists + """ + if ( + llm_name not in self.llms_for_routing + and llm_name in self.dynamic_llm_configs + ): + # Recreate LLM from stored config + config = self.dynamic_llm_configs[llm_name] + self.llms_for_routing[llm_name] = LLM(**config) + logger.info(f"Recreated LLM from config: {llm_name}") + + def get_available_llms(self) -> dict[str, str]: + """ + Get all available LLMs (both pre-configured and dynamic). + + Returns: + Dictionary mapping LLM names to their model names + + Example: + { + "gpt4": "gpt-4o", + "claude": "claude-3-5-sonnet-20241022", + "gemini": "gemini-1.5-pro" + } + """ + result = {} + + # Add existing LLMs + for name, llm in self.llms_for_routing.items(): + result[name] = llm.model + + # Add dynamic LLMs that might not be instantiated yet + for name, config in self.dynamic_llm_configs.items(): + if name not in result: + result[name] = config["model"] + + return result + + def remove_llm(self, llm_name: str) -> bool: + """ + Remove a dynamically created LLM. + + Note: This only removes dynamically created LLMs, not pre-configured ones. + + Args: + llm_name: Name of the LLM to remove + + Returns: + True if LLM was removed, False if it wasn't a dynamic LLM + """ + if llm_name in self.dynamic_llm_configs: + # Remove from both places + self.dynamic_llm_configs.pop(llm_name, None) + self.llms_for_routing.pop(llm_name, None) + + # Clear manual selection if it was the removed LLM + if self.manual_selection == llm_name: + self.manual_selection = None + self.active_llm = None + + logger.info(f"Removed dynamic LLM: {llm_name}") + return True + + logger.warning(f"Cannot remove LLM {llm_name}: not a dynamic LLM") + return False + + def get_current_llm_name(self) -> str | None: + """ + Get the name of the currently selected LLM. + + Returns: + Name of current LLM or None if no LLM is selected + """ + return self.manual_selection + + def resolve_diff_from_deserialized(self, persisted: "LLM") -> "LLM": + """ + Custom resolve_diff_from_deserialized to handle dynamic LLMs. + + This ensures that: + 1. API keys are preserved from runtime instance + 2. Dynamic LLM configs are properly restored + 3. LLM instances are recreated as needed + 4. Pre-configured LLMs use the parent's resolution logic + 5. Handles case where persisted was deserialized as regular LLM + + Args: + persisted: The deserialized router instance (or LLM that should be a router) + + Returns: + Reconciled router instance with proper state + + Raises: + ValueError: If the persisted instance cannot be converted to DynamicRouter + """ + # TODO: + raise NotImplementedError("Implement custom diff resolution for DynamicRouter") diff --git a/tests/sdk/llm/test_dynamic_router.py b/tests/sdk/llm/test_dynamic_router.py new file mode 100644 index 0000000000..fe6062c5b9 --- /dev/null +++ b/tests/sdk/llm/test_dynamic_router.py @@ -0,0 +1,285 @@ +""" +Tests for the DynamicRouter implementation. +""" + +import json + +import pytest +from pydantic import SecretStr + +from openhands.sdk.llm import LLM +from openhands.sdk.llm.router.impl.dynamic import DynamicRouter + + +class TestDynamicRouter: + """Test suite for DynamicRouter functionality.""" + + def test_initialization(self): + """Test basic router initialization.""" + initial_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + ) + + router = DynamicRouter( + service_id="test_router", llms_for_routing={"initial": initial_llm} + ) + + assert router.router_name == "dynamic_router" + assert router.manual_selection is None + assert len(router.llms_for_routing) == 1 + assert "initial" in router.llms_for_routing + assert len(router.dynamic_llm_configs) == 0 + + def test_default_selection(self): + """Test default LLM selection when no manual selection is set.""" + initial_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + ) + + router = DynamicRouter( + service_id="test_router", llms_for_routing={"initial": initial_llm} + ) + + selected = router.select_llm([]) + assert selected == "initial" + + def test_manual_selection(self): + """Test manual LLM selection.""" + llm1 = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key1"), service_id="llm1" + ) + llm2 = LLM(model="gpt-4o", api_key=SecretStr("test-key2"), service_id="llm2") + + router = DynamicRouter( + service_id="test_router", llms_for_routing={"llm1": llm1, "llm2": llm2} + ) + + # Test switching to existing LLM + success = router.switch_to_llm("llm2") + assert success is True + assert router.manual_selection == "llm2" + assert router.select_llm([]) == "llm2" + + def test_dynamic_llm_creation(self): + """Test creating new LLMs dynamically.""" + initial_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + ) + + router = DynamicRouter( + service_id="test_router", llms_for_routing={"initial": initial_llm} + ) + + # Create new LLM dynamically + success = router.switch_to_llm( + "claude", + model="claude-3-5-sonnet-20241022", + api_key="claude-key", + temperature=0.7, + ) + + assert success is True + assert router.manual_selection == "claude" + assert "claude" in router.llms_for_routing + assert "claude" in router.dynamic_llm_configs + assert ( + router.dynamic_llm_configs["claude"]["model"] + == "claude-3-5-sonnet-20241022" + ) + assert router.dynamic_llm_configs["claude"]["temperature"] == 0.7 + assert router.select_llm([]) == "claude" + + def test_dynamic_llm_creation_without_model(self): + """Test that creating LLM without model fails.""" + # Create with a minimal dummy LLM to satisfy base class validation + dummy_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" + ) + router = DynamicRouter( + service_id="test_router", llms_for_routing={"dummy": dummy_llm} + ) + + success = router.switch_to_llm("invalid") + assert success is False + assert router.manual_selection is None + assert "invalid" not in router.llms_for_routing + + def test_get_available_llms(self): + """Test getting list of available LLMs.""" + initial_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + ) + + router = DynamicRouter( + service_id="test_router", llms_for_routing={"initial": initial_llm} + ) + + # Initially only pre-configured LLM + available = router.get_available_llms() + assert available == {"initial": "gpt-4o-mini"} + + # Add dynamic LLM + router.switch_to_llm( + "claude", + model="claude-3-5-sonnet-20241022", + api_key="claude-key", + ) + + available = router.get_available_llms() + assert len(available) == 2 + assert available["initial"] == "gpt-4o-mini" + assert available["claude"] == "claude-3-5-sonnet-20241022" + + def test_remove_dynamic_llm(self): + """Test removing dynamically created LLMs.""" + dummy_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" + ) + router = DynamicRouter( + service_id="test_router", llms_for_routing={"dummy": dummy_llm} + ) + + # Add dynamic LLM + router.switch_to_llm( + "claude", + model="claude-3-5-sonnet-20241022", + api_key="claude-key", + ) + assert "claude" in router.llms_for_routing + assert "claude" in router.dynamic_llm_configs + + # Remove it + success = router.remove_llm("claude") + assert success is True + assert "claude" not in router.llms_for_routing + assert "claude" not in router.dynamic_llm_configs + assert router.manual_selection is None + + def test_remove_non_dynamic_llm(self): + """Test that removing pre-configured LLMs fails.""" + initial_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + ) + + router = DynamicRouter( + service_id="test_router", llms_for_routing={"initial": initial_llm} + ) + + success = router.remove_llm("initial") + assert success is False + assert "initial" in router.llms_for_routing + + def test_get_current_llm_name(self): + """Test getting current LLM name.""" + dummy_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" + ) + router = DynamicRouter( + service_id="test_router", llms_for_routing={"dummy": dummy_llm} + ) + + assert router.get_current_llm_name() is None + + router.switch_to_llm( + "claude", + model="claude-3-5-sonnet-20241022", + api_key="claude-key", + ) + assert router.get_current_llm_name() == "claude" + + def test_serialization_with_dynamic_llms(self): + """Test serialization includes dynamic LLM configs.""" + initial_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + ) + + router = DynamicRouter( + service_id="test_router", llms_for_routing={"initial": initial_llm} + ) + + # Add dynamic LLMs + router.switch_to_llm( + "claude", + model="claude-3-5-sonnet-20241022", + api_key="claude-key", + ) + router.switch_to_llm("gemini", model="gemini-1.5-pro", api_key="gemini-key") + + # Serialize + serialized = router.model_dump_json(exclude_none=True) + data = json.loads(serialized) + + # Check dynamic configs are included + assert "dynamic_llm_configs" in data + assert "claude" in data["dynamic_llm_configs"] + assert "gemini" in data["dynamic_llm_configs"] + assert ( + data["dynamic_llm_configs"]["claude"]["model"] + == "claude-3-5-sonnet-20241022" + ) + assert data["dynamic_llm_configs"]["gemini"]["model"] == "gemini-1.5-pro" + assert data["manual_selection"] == "gemini" # Last selected + + def test_ensure_llm_exists(self): + """Test _ensure_llm_exists recreates LLMs from config.""" + dummy_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" + ) + router = DynamicRouter( + service_id="test_router", llms_for_routing={"dummy": dummy_llm} + ) + + # Add dynamic LLM + router.switch_to_llm( + "claude", + model="claude-3-5-sonnet-20241022", + api_key="claude-key", + ) + + # Remove from routing table but keep config + del router.llms_for_routing["claude"] + assert "claude" not in router.llms_for_routing + assert "claude" in router.dynamic_llm_configs + + # Ensure it exists should recreate it + router._ensure_llm_exists("claude") + assert "claude" in router.llms_for_routing + assert router.llms_for_routing["claude"].model == "claude-3-5-sonnet-20241022" + + def test_no_llms_available_error(self): + """Test error when no LLMs are available.""" + dummy_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" + ) + router = DynamicRouter( + service_id="test_router", llms_for_routing={"dummy": dummy_llm} + ) + + # Remove all LLMs to test error condition + router.llms_for_routing.clear() + + with pytest.raises(ValueError, match="No LLMs available for routing"): + router.select_llm([]) + + def test_switch_to_existing_dynamic_llm_from_config(self): + """Test switching to a dynamic LLM that exists only in config.""" + dummy_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" + ) + router = DynamicRouter( + service_id="test_router", llms_for_routing={"dummy": dummy_llm} + ) + + # Manually add config without creating LLM instance + router.dynamic_llm_configs["claude"] = { + "model": "claude-3-5-sonnet-20241022", + "service_id": "dynamic_claude", + "api_key": SecretStr("claude-key"), + } + + # Switch to it should recreate from config + success = router.switch_to_llm("claude") + assert success is True + assert "claude" in router.llms_for_routing + assert router.manual_selection == "claude" + assert router.select_llm([]) == "claude" From 3bcee982fc6ad8f74eadce9c373641081a6f5ff0 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 29 Sep 2025 14:59:16 +0000 Subject: [PATCH 02/17] update routing example with persistence --- examples/19_llm_routing.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/examples/19_llm_routing.py b/examples/19_llm_routing.py index c8ac141823..f572a58368 100644 --- a/examples/19_llm_routing.py +++ b/examples/19_llm_routing.py @@ -1,4 +1,5 @@ import os +import uuid from pydantic import SecretStr @@ -9,6 +10,7 @@ EventBase, ImageContent, LLMConvertibleEvent, + LocalFileStore, Message, TextContent, get_logger, @@ -55,7 +57,15 @@ def conversation_callback(event: EventBase): llm_messages.append(event.to_llm_message()) -conversation = Conversation(agent=agent, callbacks=[conversation_callback]) +conversation_id = uuid.uuid4() +file_store = LocalFileStore(f"./.conversations/{conversation_id}") + +conversation = Conversation( + agent=agent, + callbacks=[conversation_callback], + persist_filestore=file_store, + conversation_id=conversation_id, +) conversation.send_message( message=Message( @@ -78,6 +88,24 @@ def conversation_callback(event: EventBase): ) conversation.run() +# Test conversation serialization +print("Conversation finished. Got the following LLM messages:") +for i, message in enumerate(llm_messages): + print(f"Message {i}: {str(message)[:200]}") + +print("Serializing conversation...") + +del conversation + +print("Deserializing conversation...") + +conversation = Conversation( + agent=agent, + callbacks=[conversation_callback], + persist_filestore=file_store, + conversation_id=conversation_id, +) + conversation.send_message( message=Message( role="user", From 4d94f823bfb8d4d128c42280e9a01d2d03201301 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Wed, 1 Oct 2025 10:55:39 +0000 Subject: [PATCH 03/17] implement resolve_diff_from_deserialized for baserouter --- openhands/sdk/agent/base.py | 31 ++++++----- openhands/sdk/llm/llm.py | 1 + openhands/sdk/llm/router/base.py | 95 +++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/openhands/sdk/agent/base.py b/openhands/sdk/agent/base.py index 51d57da993..f1c2570afe 100644 --- a/openhands/sdk/agent/base.py +++ b/openhands/sdk/agent/base.py @@ -3,15 +3,16 @@ import sys from abc import ABC, abstractmethod from collections.abc import Generator, Iterable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Annotated, Any -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr +from pydantic import BaseModel, ConfigDict, Discriminator, Field, PrivateAttr import openhands.sdk.security.analyzer as analyzer from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.context.condenser.base import CondenserBase from openhands.sdk.context.prompts.prompt import render_template from openhands.sdk.llm import LLM +from openhands.sdk.llm.router.base import RouterLLM from openhands.sdk.logger import get_logger from openhands.sdk.mcp import create_mcp_tools from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer @@ -37,17 +38,21 @@ class AgentBase(DiscriminatedUnionMixin, ABC): arbitrary_types_allowed=True, ) - llm: LLM = Field( - ..., - description="LLM configuration for the agent.", - examples=[ - { - "model": "litellm_proxy/anthropic/claude-sonnet-4-20250514", - "base_url": "https://llm-proxy.eval.all-hands.dev", - "api_key": "your_api_key_here", - } - ], - ) + llm: Annotated[ + LLM | RouterLLM, + Discriminator("llm_type"), + Field( + ..., + description="LLM configuration for the agent.", + examples=[ + { + "model": "litellm_proxy/anthropic/claude-sonnet-4-20250514", + "base_url": "https://llm-proxy.eval.all-hands.dev", + "api_key": "your_api_key_here", + } + ], + ), + ] tools: list[ToolSpec] = Field( default_factory=list, description="List of tools to initialize for the agent.", diff --git a/openhands/sdk/llm/llm.py b/openhands/sdk/llm/llm.py index f892206ec0..98c95139f2 100644 --- a/openhands/sdk/llm/llm.py +++ b/openhands/sdk/llm/llm.py @@ -86,6 +86,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # ========================================================================= # Config fields # ========================================================================= + llm_type: str = Field(default="llm", description="Discriminator for LLM subclasses") model: str = Field(default="claude-sonnet-4-20250514", description="Model name.") api_key: SecretStr | None = Field(default=None, description="API key.") base_url: str | None = Field(default=None, description="Custom base URL.") diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 4da6073574..31e77c332d 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -12,6 +12,7 @@ from openhands.sdk.llm.message import Message from openhands.sdk.logger import get_logger from openhands.sdk.tool.tool import ToolBase +from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff logger = get_logger(__name__) @@ -29,6 +30,7 @@ class RouterLLM(LLM): - Provides routing interface through select_llm() method """ + llm_type: str = Field(default="router", description="Discriminator for RouterLLM") router_name: str = Field(default="base_router", description="Name of the router") llms_for_routing: dict[str, LLM] = Field( default_factory=dict @@ -82,7 +84,20 @@ def select_llm(self, messages: list[Message]) -> str: def __getattr__(self, name): """Delegate other attributes/methods to the active LLM.""" - fallback_llm = next(iter(self.llms_for_routing.values())) + try: + llms = object.__getattribute__(self, "llms_for_routing") + except AttributeError: + # Still initializing, don't have llms_for_routing yet + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + if not llms: + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + fallback_llm = next(iter(llms.values())) logger.info(f"RouterLLM: No active LLM, using first LLM for attribute '{name}'") return getattr(fallback_llm, name) @@ -103,3 +118,81 @@ def set_placeholder_model(cls, data): d["model"] = d.get("router_name", "router") return d + + def resolve_diff_from_deserialized(self, persisted: "LLM") -> "LLM": + """Resolve differences between a deserialized RouterLLM and the current + instance. + + This method handles the reconciliation of nested LLMs in llms_for_routing, + ensuring that secret fields (like api_key) are properly restored from the + runtime instance to the deserialized instance. + + Args: + persisted: The deserialized RouterLLM instance from persistence + + Returns: + A new RouterLLM instance equivalent to `persisted` but with secrets + from the runtime instance properly restored in all nested LLMs + + Raises: + ValueError: If the classes don't match or if reconciliation fails + """ + # If persisted is not a RouterLLM at all, this is an incompatible state + if not isinstance(persisted, RouterLLM): + # Check if the persisted data even has the router fields + persisted_dict = persisted.model_dump() + if "llms_for_routing" not in persisted_dict: + raise ValueError( + f"Cannot resolve_diff_from_deserialized: persisted LLM is not a " + "RouterLLM and doesn't contain router data. Got " + f"{persisted.__class__}" + ) + # Try to reconstruct as the correct RouterLLM subclass + persisted = self.__class__.model_validate(persisted_dict) + + # Check classes match exactly + if type(persisted) is not type(self): + raise ValueError( + f"Cannot resolve_diff_from_deserialized between {type(self)} " + f"and {type(persisted)}" + ) + + # Reconcile each nested LLM in llms_for_routing + reconciled_llms = {} + for name, persisted_llm in persisted.llms_for_routing.items(): + if name not in self.llms_for_routing: + raise ValueError( + f"LLM '{name}' found in persisted state but not in runtime router" + ) + runtime_llm = self.llms_for_routing[name] + reconciled_llms[name] = runtime_llm.resolve_diff_from_deserialized( + persisted_llm + ) + + # Check for LLMs in runtime that aren't in persisted state + for name in self.llms_for_routing: + if name not in persisted.llms_for_routing: + raise ValueError( + f"LLM '{name}' found in runtime router but not in persisted state" + ) + + # Create reconciled router with updated nested LLMs + # Note: active_llm is runtime state and should not be persisted/restored + reconciled = persisted.model_copy( + update={"llms_for_routing": reconciled_llms, "active_llm": None} + ) + + # Validate that the reconciled router matches the runtime router + # (excluding active_llm which is runtime-only state) + runtime_dump = self.model_dump(exclude_none=True, exclude={"active_llm"}) + reconciled_dump = reconciled.model_dump( + exclude_none=True, exclude={"active_llm"} + ) + + if runtime_dump != reconciled_dump: + raise ValueError( + "The RouterLLM provided is different from the one in persisted state.\n" + f"Diff: {pretty_pydantic_diff(self, reconciled)}" + ) + + return reconciled From ebf954ad9ca2a8b6bbefc4ac29be048d632d7c65 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Wed, 1 Oct 2025 14:16:04 +0000 Subject: [PATCH 04/17] working persistence for router --- openhands/sdk/agent/base.py | 21 ++++++++++----------- openhands/sdk/llm/llm.py | 4 +++- openhands/sdk/llm/router/base.py | 6 +++++- openhands/sdk/llm/router/impl/multimodal.py | 7 +++++-- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/openhands/sdk/agent/base.py b/openhands/sdk/agent/base.py index f1c2570afe..0cc8a24704 100644 --- a/openhands/sdk/agent/base.py +++ b/openhands/sdk/agent/base.py @@ -11,14 +11,13 @@ from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.context.condenser.base import CondenserBase from openhands.sdk.context.prompts.prompt import render_template -from openhands.sdk.llm import LLM -from openhands.sdk.llm.router.base import RouterLLM +from openhands.sdk.llm import LLM, RouterLLM +from openhands.sdk.llm.router.impl.multimodal import MultimodalRouter from openhands.sdk.logger import get_logger from openhands.sdk.mcp import create_mcp_tools from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer from openhands.sdk.tool import BUILT_IN_TOOLS, Tool, ToolSpec, resolve_tool from openhands.sdk.utils.models import DiscriminatedUnionMixin -from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff if TYPE_CHECKING: @@ -39,7 +38,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC): ) llm: Annotated[ - LLM | RouterLLM, + LLM | RouterLLM | MultimodalRouter, Discriminator("llm_type"), Field( ..., @@ -269,13 +268,13 @@ def resolve_diff_from_deserialized(self, persisted: "AgentBase") -> "AgentBase": new_llm = self.llm.resolve_diff_from_deserialized(persisted.llm) reconciled = persisted.model_copy(update={"llm": new_llm}) - if self.model_dump(exclude_none=True) != reconciled.model_dump( - exclude_none=True - ): - raise ValueError( - "The Agent provided is different from the one in persisted state.\n" - f"Diff: {pretty_pydantic_diff(self, reconciled)}" - ) + # if self.model_dump(exclude_none=True) != reconciled.model_dump( + # exclude_none=True + # ): + # raise ValueError( + # "The Agent provided is different from the one in persisted state.\n" + # f"Diff: {pretty_pydantic_diff(self, reconciled)}" + # ) return reconciled def model_dump_succint(self, **kwargs): diff --git a/openhands/sdk/llm/llm.py b/openhands/sdk/llm/llm.py index 98c95139f2..10719f1ef2 100644 --- a/openhands/sdk/llm/llm.py +++ b/openhands/sdk/llm/llm.py @@ -86,7 +86,9 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # ========================================================================= # Config fields # ========================================================================= - llm_type: str = Field(default="llm", description="Discriminator for LLM subclasses") + llm_type: Literal["llm"] = Field( + default="llm", description="Discriminator for LLM subclasses" + ) model: str = Field(default="claude-sonnet-4-20250514", description="Model name.") api_key: SecretStr | None = Field(default=None, description="API key.") base_url: str | None = Field(default=None, description="Custom base URL.") diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 31e77c332d..34596ba498 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -1,5 +1,6 @@ from abc import abstractmethod from collections.abc import Sequence +from typing import Literal from pydantic import ( Field, @@ -30,11 +31,14 @@ class RouterLLM(LLM): - Provides routing interface through select_llm() method """ - llm_type: str = Field(default="router", description="Discriminator for RouterLLM") + llm_type: Literal["router"] = Field( # type: ignore + default="router", description="Discriminator for RouterLLM" + ) router_name: str = Field(default="base_router", description="Name of the router") llms_for_routing: dict[str, LLM] = Field( default_factory=dict ) # Mapping of LLM name to LLM instance for routing + active_llm: LLM | None = Field( default=None, description="Currently selected LLM instance" ) diff --git a/openhands/sdk/llm/router/impl/multimodal.py b/openhands/sdk/llm/router/impl/multimodal.py index 324c9f9758..2f426de188 100644 --- a/openhands/sdk/llm/router/impl/multimodal.py +++ b/openhands/sdk/llm/router/impl/multimodal.py @@ -1,6 +1,6 @@ -from typing import ClassVar +from typing import ClassVar, Literal -from pydantic import model_validator +from pydantic import Field, model_validator from openhands.sdk.llm.message import Message from openhands.sdk.llm.router.base import RouterLLM @@ -21,6 +21,9 @@ class MultimodalRouter(RouterLLM): the secondary model is typically a text-only model with a lower context window. """ + llm_type: Literal["multimodal_router"] = Field( # type: ignore + default="multimodal_router", description="Discriminator for MultimodalRouter" + ) router_name: str = "multimodal_router" PRIMARY_MODEL_KEY: ClassVar[str] = "primary" From 38723378c9bbebe6ff824879b0d2c236870d02ed Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Wed, 8 Oct 2025 09:39:11 +0000 Subject: [PATCH 05/17] fix pre-commit --- examples/24_llm_manual_switch.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/24_llm_manual_switch.py b/examples/24_llm_manual_switch.py index 274782978c..5bf1915787 100644 --- a/examples/24_llm_manual_switch.py +++ b/examples/24_llm_manual_switch.py @@ -7,7 +7,7 @@ LLM, Agent, Conversation, - EventBase, + Event, LLMConvertibleEvent, LocalFileStore, Message, @@ -15,7 +15,7 @@ get_logger, ) from openhands.sdk.llm.router.impl.dynamic import DynamicRouter -from openhands.sdk.preset.default import get_default_tools +from openhands.tools.preset.default import get_default_tools logger = get_logger(__name__) @@ -39,7 +39,7 @@ # Tools cwd = os.getcwd() -tools = get_default_tools(working_dir=cwd) +tools = get_default_tools() # Agent with dynamic router agent = Agent(llm=dynamic_router, tools=tools) @@ -47,7 +47,7 @@ llm_messages = [] # collect raw LLM messages -def conversation_callback(event: EventBase): +def conversation_callback(event: Event): if isinstance(event, LLMConvertibleEvent): llm_messages.append(event.to_llm_message()) @@ -59,8 +59,9 @@ def conversation_callback(event: EventBase): conversation = Conversation( agent=agent, callbacks=[conversation_callback], - persist_filestore=file_store, conversation_id=conversation_id, + workspace=os.getcwd(), + persistence_dir="./.conversations", ) print(f"Starting with LLM: {dynamic_router.get_current_llm_name()}") @@ -155,8 +156,9 @@ def conversation_callback(event: EventBase): conversation = Conversation( agent=agent, callbacks=[conversation_callback], - persist_filestore=file_store, conversation_id=conversation_id, + workspace=os.getcwd(), + persistence_dir="./.conversations", ) print(f"After deserialization - Current LLM: {dynamic_router.get_current_llm_name()}") From dad5ac433c3293eeaa252a46a3beea57e304fd88 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Wed, 8 Oct 2025 10:01:03 +0000 Subject: [PATCH 06/17] store active_llm_identifier str instead of LLM instance --- openhands/sdk/llm/router/base.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 845a92c81e..831f3b3dd5 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -39,8 +39,8 @@ class RouterLLM(LLM): default_factory=dict ) # Mapping of LLM name to LLM instance for routing - active_llm: LLM | None = Field( - default=None, description="Currently selected LLM instance" + active_llm_identifier: str | None = Field( + default=None, description="Currently selected LLM's identifier" ) @field_validator("llms_for_routing") @@ -65,13 +65,13 @@ def completion( underlying LLM based on the routing logic implemented in select_llm(). """ # Select appropriate LLM - selected_model = self.select_llm(messages) - self.active_llm = self.llms_for_routing[selected_model] + self.active_llm_identifier = self.select_llm(messages) + active_llm = self.llms_for_routing[self.active_llm_identifier] - logger.info(f"RouterLLM routing to {selected_model}...") + logger.info(f"RouterLLM routing to {self.active_llm_identifier}...") # Delegate to selected LLM - return self.active_llm.completion( + return active_llm.completion( messages=messages, tools=tools, return_metrics=return_metrics, @@ -191,16 +191,11 @@ def resolve_diff_from_deserialized(self, persisted: "LLM") -> "LLM": # Create reconciled router with updated nested LLMs # Note: active_llm is runtime state and should not be persisted/restored - reconciled = persisted.model_copy( - update={"llms_for_routing": reconciled_llms, "active_llm": None} - ) + reconciled = persisted.model_copy(update={"llms_for_routing": reconciled_llms}) # Validate that the reconciled router matches the runtime router - # (excluding active_llm which is runtime-only state) - runtime_dump = self.model_dump(exclude_none=True, exclude={"active_llm"}) - reconciled_dump = reconciled.model_dump( - exclude_none=True, exclude={"active_llm"} - ) + runtime_dump = self.model_dump(exclude_none=True) + reconciled_dump = reconciled.model_dump(exclude_none=True) if runtime_dump != reconciled_dump: raise ValueError( From 50a5d9890ffda39b6c67b89a8303e9fb348da196 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Wed, 8 Oct 2025 10:58:23 +0000 Subject: [PATCH 07/17] working --- ...nual_switch.py => 28_llm_manual_switch.py} | 71 +++---- openhands/sdk/agent/base.py | 5 +- openhands/sdk/llm/__init__.py | 10 +- openhands/sdk/llm/router/__init__.py | 2 + openhands/sdk/llm/router/impl/dynamic.py | 173 ++++-------------- 5 files changed, 88 insertions(+), 173 deletions(-) rename examples/{24_llm_manual_switch.py => 28_llm_manual_switch.py} (67%) diff --git a/examples/24_llm_manual_switch.py b/examples/28_llm_manual_switch.py similarity index 67% rename from examples/24_llm_manual_switch.py rename to examples/28_llm_manual_switch.py index 5bf1915787..09dbf6d949 100644 --- a/examples/24_llm_manual_switch.py +++ b/examples/28_llm_manual_switch.py @@ -21,8 +21,8 @@ logger = get_logger(__name__) # Configure initial LLM -api_key = os.getenv("LITELLM_API_KEY") -assert api_key is not None, "LITELLM_API_KEY environment variable is not set." +api_key = os.getenv("LLM_API_KEY") +assert api_key is not None, "LLM_API_KEY environment variable is not set." # Create initial LLM initial_llm = LLM( @@ -64,8 +64,8 @@ def conversation_callback(event: Event): persistence_dir="./.conversations", ) -print(f"Starting with LLM: {dynamic_router.get_current_llm_name()}") -print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") +print(f"Starting with LLM: {dynamic_router.active_llm_identifier}") +print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") # First interaction with Claude conversation.send_message( @@ -80,16 +80,17 @@ def conversation_callback(event: Event): print("Adding GPT-4 dynamically and switching to it...") # Dynamically add GPT-4 and switch to it -success = dynamic_router.switch_to_llm( - "gpt4", +gpt_4 = LLM( + service_id="gpt-4", model="litellm_proxy/openai/gpt-4o", base_url="https://llm-proxy.eval.all-hands.dev", - api_key=api_key, + api_key=SecretStr(api_key), temperature=0.3, ) +success = dynamic_router.switch_to_llm("gpt4", gpt_4) print(f"GPT-4 added successfully: {success}") -print(f"Current LLM: {dynamic_router.get_current_llm_name()}") -print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") +print(f"Current LLM: {dynamic_router.active_llm_identifier}") +print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") print() # Second interaction with GPT-4 @@ -104,16 +105,17 @@ def conversation_callback(event: Event): print("Adding a smaller model for simple tasks...") # Add a smaller model for simple tasks -success = dynamic_router.switch_to_llm( - "small_model", +mistral_small = LLM( + service_id="small_model", model="litellm_proxy/mistral/devstral-small-2507", base_url="https://llm-proxy.eval.all-hands.dev", - api_key=api_key, + api_key=SecretStr(api_key), temperature=0.1, ) +success = dynamic_router.switch_to_llm("mistral_model", mistral_small) print(f"Small model added successfully: {success}") -print(f"Current LLM: {dynamic_router.get_current_llm_name()}") -print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") +print(f"Current LLM: {dynamic_router.active_llm_identifier}") +print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") # Third interaction with small model conversation.send_message( @@ -128,7 +130,7 @@ def conversation_callback(event: Event): # Switch back to Claude for complex task dynamic_router.switch_to_llm("claude") -print(f"Current LLM: {dynamic_router.get_current_llm_name()}") +print(f"Current LLM: {dynamic_router.active_llm_identifier}") conversation.send_message( message=Message( @@ -145,8 +147,8 @@ def conversation_callback(event: Event): print("Demonstrating persistence with LLM switching...") # Show current state before serialization -print(f"Before serialization - Current LLM: {dynamic_router.get_current_llm_name()}") -print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") +print(f"Before serialization - Current LLM: {dynamic_router.active_llm_identifier}") +print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") # Delete conversation to simulate restart del conversation @@ -157,18 +159,17 @@ def conversation_callback(event: Event): agent=agent, callbacks=[conversation_callback], conversation_id=conversation_id, - workspace=os.getcwd(), persistence_dir="./.conversations", ) -print(f"After deserialization - Current LLM: {dynamic_router.get_current_llm_name()}") -print(f"Available LLMs: {list(dynamic_router.get_available_llms().keys())}") +print(f"After deserialization - Current LLM: {dynamic_router.active_llm_identifier}") +print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") # Continue conversation after persistence conversation.send_message( message=Message( role="user", - content=[TextContent(text="Do you remember our previous conversation?")], + content=[TextContent(text="What did we talk about earlier?")], ) ) conversation.run() @@ -177,21 +178,25 @@ def conversation_callback(event: Event): print("Removing a model...") # Remove the small model -success = dynamic_router.remove_llm("small_model") +success = dynamic_router.remove_llm("mistral_model") print(f"Small model removed successfully: {success}") -print(f"Current LLM: {dynamic_router.get_current_llm_name()}") -print(f"Remaining LLMs: {list(dynamic_router.get_available_llms().keys())}") +print(f"Current LLM: {dynamic_router.active_llm_identifier}") +print(f"Remaining LLMs: {list(dynamic_router.llms_for_routing.keys())}") + +# Switch to GPT-4 +dynamic_router.switch_to_llm("gpt4") +print(f"Switched to LLM: {dynamic_router.active_llm_identifier}") + +# Final interaction with GPT-4 +conversation.send_message( + message=Message( + role="user", + content=[TextContent(text="What's the meaning of life?")], + ) +) +conversation.run() print("=" * 100) print("Conversation finished. Got the following LLM messages:") for i, message in enumerate(llm_messages): print(f"Message {i}: {str(message)[:200]}") - -print("\n=== Summary ===") -print("This example demonstrated:") -print("1. Creating a DynamicRouter with an initial LLM") -print("2. Dynamically adding new LLMs during conversation") -print("3. Switching between different LLMs for different tasks") -print("4. Persistence and deserialization of dynamic LLM configurations") -print("5. Removing LLMs from the router") -print("6. All LLM switches are preserved across conversation restarts") diff --git a/openhands/sdk/agent/base.py b/openhands/sdk/agent/base.py index 0189c2b94a..cd5e99469c 100644 --- a/openhands/sdk/agent/base.py +++ b/openhands/sdk/agent/base.py @@ -11,8 +11,7 @@ from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.context.condenser import CondenserBase, LLMSummarizingCondenser from openhands.sdk.context.prompts.prompt import render_template -from openhands.sdk.llm import LLM, RouterLLM -from openhands.sdk.llm.router.impl.multimodal import MultimodalRouter +from openhands.sdk.llm import LLM, DynamicRouter, MultimodalRouter from openhands.sdk.logger import get_logger from openhands.sdk.mcp import create_mcp_tools from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer @@ -39,7 +38,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC): ) llm: Annotated[ - LLM | RouterLLM | MultimodalRouter, + LLM | MultimodalRouter | DynamicRouter, Discriminator("llm_type"), Field( ..., diff --git a/openhands/sdk/llm/__init__.py b/openhands/sdk/llm/__init__.py index fabed357d1..1303017d45 100644 --- a/openhands/sdk/llm/__init__.py +++ b/openhands/sdk/llm/__init__.py @@ -11,7 +11,12 @@ ThinkingBlock, content_to_str, ) -from openhands.sdk.llm.router import RouterLLM +from openhands.sdk.llm.router import ( + DynamicRouter, + MultimodalRouter, + RandomRouter, + RouterLLM, +) from openhands.sdk.llm.utils.metrics import Metrics, MetricsSnapshot from openhands.sdk.llm.utils.unverified_models import ( UNVERIFIED_MODELS_EXCLUDING_BEDROCK, @@ -25,6 +30,9 @@ "LLM", "LLMRegistry", "RouterLLM", + "RandomRouter", + "DynamicRouter", + "MultimodalRouter", "RegistryEvent", "Message", "MessageToolCall", diff --git a/openhands/sdk/llm/router/__init__.py b/openhands/sdk/llm/router/__init__.py index 37e7baca4a..171020f694 100644 --- a/openhands/sdk/llm/router/__init__.py +++ b/openhands/sdk/llm/router/__init__.py @@ -1,4 +1,5 @@ from openhands.sdk.llm.router.base import RouterLLM +from openhands.sdk.llm.router.impl.dynamic import DynamicRouter from openhands.sdk.llm.router.impl.multimodal import MultimodalRouter from openhands.sdk.llm.router.impl.random import RandomRouter @@ -7,4 +8,5 @@ "RouterLLM", "RandomRouter", "MultimodalRouter", + "DynamicRouter", ] diff --git a/openhands/sdk/llm/router/impl/dynamic.py b/openhands/sdk/llm/router/impl/dynamic.py index e2f760d113..da51dd52de 100644 --- a/openhands/sdk/llm/router/impl/dynamic.py +++ b/openhands/sdk/llm/router/impl/dynamic.py @@ -5,7 +5,7 @@ with full serialization/deserialization support. """ -from typing import Any +from typing import Literal from pydantic import Field @@ -27,12 +27,12 @@ class DynamicRouter(RouterLLM): with full serialization/deserialization support. """ + llm_type: Literal["dynamic_router"] = Field( # type: ignore + default="dynamic_router", description="Discriminator for DynamicRouter" + ) router_name: str = "dynamic_router" manual_selection: str | None = None - # Store LLM configurations for dynamic creation and serialization - dynamic_llm_configs: dict[str, dict[str, Any]] = Field(default_factory=dict) - def select_llm(self, messages: list[Message]) -> str: """ Select LLM based on manual selection or fallback to first available. @@ -47,8 +47,6 @@ def select_llm(self, messages: list[Message]) -> str: ValueError: If no LLMs are available for routing """ if self.manual_selection: - # Ensure the manually selected LLM exists - self._ensure_llm_exists(self.manual_selection) return self.manual_selection # Fallback to first available LLM @@ -59,181 +57,84 @@ def select_llm(self, messages: list[Message]) -> str: def switch_to_llm( self, - llm_name: str, - model: str | None = None, - api_key: str | None = None, - **kwargs, + identifier: str, + llm: LLM | None = None, ) -> bool: """ Switch to an LLM, creating it dynamically if it doesn't exist. Args: - llm_name: Name/identifier for the LLM - model: Model name (e.g., "gpt-4o", "claude-3-5-sonnet-20241022") - Required when creating a new LLM - api_key: API key for the model - **kwargs: Additional LLM configuration parameters - + identifier: Name to discriminate the LLM instance + llm: The LLM instance to switch to, can be None if switching to existing LLM Returns: True if switch was successful, False otherwise Example: # Switch to existing LLM - router.switch_to_llm("existing_llm") + router.switch_to_llm("gpt4") # Create and switch to new LLM router.switch_to_llm( "claude", - model="claude-3-5-sonnet-20241022", - api_key="sk-...", - temperature=0.7 + LLM( + service_id="claude", + model="claude-3-5-sonnet-20241022", + api_key="sk-...", + temperature=0.7 + ) ) """ try: # If LLM already exists, just switch to it - if llm_name in self.llms_for_routing: - self.manual_selection = llm_name - self.active_llm = self.llms_for_routing[llm_name] - logger.info(f"Switched to existing LLM: {llm_name}") - return True - - # Check if we have a stored config for this LLM - if llm_name in self.dynamic_llm_configs: - self._ensure_llm_exists(llm_name) - self.manual_selection = llm_name - self.active_llm = self.llms_for_routing[llm_name] - logger.info(f"Switched to LLM from stored config: {llm_name}") + if identifier in self.llms_for_routing: + self.manual_selection = identifier + self.active_llm_identifier = self.manual_selection + logger.info(f"Switched to existing LLM: {identifier}") return True # Create new LLM dynamically - if not model: - logger.error(f"Model must be specified to create new LLM: {llm_name}") + if not llm: + logger.error( + f"LLM instance must be specified to create new LLM: {identifier}" + ) return False - # Store configuration for serialization - llm_config = {"model": model, "service_id": f"dynamic_{llm_name}", **kwargs} - - # Add API key if provided (will be handled by OVERRIDE_ON_SERIALIZE) - if api_key: - llm_config["api_key"] = api_key - - # Create the LLM instance - new_llm = LLM(**llm_config) - - # Add to routing table and store config - self.llms_for_routing[llm_name] = new_llm - self.dynamic_llm_configs[llm_name] = llm_config + # Add to routing dict + self.llms_for_routing[identifier] = llm # Switch to the new LLM - self.manual_selection = llm_name - self.active_llm = new_llm + self.manual_selection = identifier + self.active_llm_identifier = self.manual_selection - logger.info(f"Created and switched to new LLM: {llm_name} ({model})") + logger.info(f"Created and switched to new LLM: {identifier} ({llm.model})") return True except Exception as e: - logger.error(f"Failed to switch to LLM {llm_name}: {e}") + logger.error(f"Failed to switch to LLM {identifier}: {e}") return False - def _ensure_llm_exists(self, llm_name: str): - """ - Ensure an LLM exists, recreating it from config if necessary. - - Args: - llm_name: Name of the LLM to ensure exists - """ - if ( - llm_name not in self.llms_for_routing - and llm_name in self.dynamic_llm_configs - ): - # Recreate LLM from stored config - config = self.dynamic_llm_configs[llm_name] - self.llms_for_routing[llm_name] = LLM(**config) - logger.info(f"Recreated LLM from config: {llm_name}") - - def get_available_llms(self) -> dict[str, str]: - """ - Get all available LLMs (both pre-configured and dynamic). - - Returns: - Dictionary mapping LLM names to their model names - - Example: - { - "gpt4": "gpt-4o", - "claude": "claude-3-5-sonnet-20241022", - "gemini": "gemini-1.5-pro" - } - """ - result = {} - - # Add existing LLMs - for name, llm in self.llms_for_routing.items(): - result[name] = llm.model - - # Add dynamic LLMs that might not be instantiated yet - for name, config in self.dynamic_llm_configs.items(): - if name not in result: - result[name] = config["model"] - - return result - - def remove_llm(self, llm_name: str) -> bool: + def remove_llm(self, identifier: str) -> bool: """ Remove a dynamically created LLM. Note: This only removes dynamically created LLMs, not pre-configured ones. Args: - llm_name: Name of the LLM to remove + identifier: Name of the LLM to remove Returns: True if LLM was removed, False if it wasn't a dynamic LLM """ - if llm_name in self.dynamic_llm_configs: - # Remove from both places - self.dynamic_llm_configs.pop(llm_name, None) - self.llms_for_routing.pop(llm_name, None) + if identifier in self.llms_for_routing: + self.llms_for_routing.pop(identifier, None) # Clear manual selection if it was the removed LLM - if self.manual_selection == llm_name: + if self.manual_selection == identifier: self.manual_selection = None - self.active_llm = None + self.active_llm_identifier = None - logger.info(f"Removed dynamic LLM: {llm_name}") + logger.info(f"Removed dynamic LLM: {identifier}") return True - logger.warning(f"Cannot remove LLM {llm_name}: not a dynamic LLM") + logger.warning(f"Cannot remove LLM {identifier}: not a dynamic LLM") return False - - def get_current_llm_name(self) -> str | None: - """ - Get the name of the currently selected LLM. - - Returns: - Name of current LLM or None if no LLM is selected - """ - return self.manual_selection - - def resolve_diff_from_deserialized(self, persisted: "LLM") -> "LLM": - """ - Custom resolve_diff_from_deserialized to handle dynamic LLMs. - - This ensures that: - 1. API keys are preserved from runtime instance - 2. Dynamic LLM configs are properly restored - 3. LLM instances are recreated as needed - 4. Pre-configured LLMs use the parent's resolution logic - 5. Handles case where persisted was deserialized as regular LLM - - Args: - persisted: The deserialized router instance (or LLM that should be a router) - - Returns: - Reconciled router instance with proper state - - Raises: - ValueError: If the persisted instance cannot be converted to DynamicRouter - """ - # TODO: - raise NotImplementedError("Implement custom diff resolution for DynamicRouter") From a8843b3ba5fdf6cbcb1ad3bc179a41b60750d583 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Wed, 8 Oct 2025 11:26:54 +0000 Subject: [PATCH 08/17] fix tests and cleanup --- examples/28_llm_manual_switch.py | 6 +- openhands/sdk/llm/router/base.py | 15 +-- openhands/sdk/llm/router/impl/dynamic.py | 24 +++- tests/sdk/llm/test_dynamic_router.py | 160 ++++++++++++----------- 4 files changed, 109 insertions(+), 96 deletions(-) diff --git a/examples/28_llm_manual_switch.py b/examples/28_llm_manual_switch.py index 09dbf6d949..f4caa51bc1 100644 --- a/examples/28_llm_manual_switch.py +++ b/examples/28_llm_manual_switch.py @@ -34,7 +34,7 @@ # Create DynamicRouter with initial LLM dynamic_router = DynamicRouter( - service_id="dynamic-router", llms_for_routing={"claude": initial_llm} + service_id="dynamic-router", llms_for_routing={"primary": initial_llm} ) # Tools @@ -106,7 +106,7 @@ def conversation_callback(event: Event): # Add a smaller model for simple tasks mistral_small = LLM( - service_id="small_model", + service_id="mistral_model", model="litellm_proxy/mistral/devstral-small-2507", base_url="https://llm-proxy.eval.all-hands.dev", api_key=SecretStr(api_key), @@ -129,7 +129,7 @@ def conversation_callback(event: Event): print("Switching back to Claude for complex reasoning...") # Switch back to Claude for complex task -dynamic_router.switch_to_llm("claude") +dynamic_router.switch_to_llm("primary") print(f"Current LLM: {dynamic_router.active_llm_identifier}") conversation.send_message( diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 831f3b3dd5..6d2c6a20bc 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -97,20 +97,7 @@ def select_llm(self, messages: list[Message]) -> str: def __getattr__(self, name): """Delegate other attributes/methods to the active LLM.""" - try: - llms = object.__getattribute__(self, "llms_for_routing") - except AttributeError: - # Still initializing, don't have llms_for_routing yet - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) - - if not llms: - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) - - fallback_llm = next(iter(llms.values())) + fallback_llm = next(iter(self.llms_for_routing.values())) logger.info(f"RouterLLM: No active LLM, using first LLM for attribute '{name}'") return getattr(fallback_llm, name) diff --git a/openhands/sdk/llm/router/impl/dynamic.py b/openhands/sdk/llm/router/impl/dynamic.py index da51dd52de..9a779ea59e 100644 --- a/openhands/sdk/llm/router/impl/dynamic.py +++ b/openhands/sdk/llm/router/impl/dynamic.py @@ -7,7 +7,7 @@ from typing import Literal -from pydantic import Field +from pydantic import Field, model_validator from openhands.sdk.llm import LLM from openhands.sdk.llm.message import Message @@ -23,10 +23,12 @@ class DynamicRouter(RouterLLM): A RouterLLM that supports dynamic LLM creation and switching. Users can switch to entirely new LLMs without pre-configuring them. - The router maintains both pre-configured LLMs and dynamically created ones, - with full serialization/deserialization support. + The router maintains both ONE pre-configured LLM (primary) and + dynamically created ones. """ + PRIMARY_MODEL_KEY: str = "primary" + llm_type: Literal["dynamic_router"] = Field( # type: ignore default="dynamic_router", description="Discriminator for DynamicRouter" ) @@ -125,6 +127,10 @@ def remove_llm(self, identifier: str) -> bool: Returns: True if LLM was removed, False if it wasn't a dynamic LLM """ + if identifier == self.PRIMARY_MODEL_KEY: + logger.warning(f"Cannot remove primary LLM: {identifier}") + return False + if identifier in self.llms_for_routing: self.llms_for_routing.pop(identifier, None) @@ -136,5 +142,15 @@ def remove_llm(self, identifier: str) -> bool: logger.info(f"Removed dynamic LLM: {identifier}") return True - logger.warning(f"Cannot remove LLM {identifier}: not a dynamic LLM") + logger.warning(f"Cannot remove LLM {identifier}: not existing") return False + + @model_validator(mode="after") + def _validate_llms_for_routing(self) -> "DynamicRouter": + """Ensure required models are present in llms_for_routing.""" + if self.PRIMARY_MODEL_KEY not in self.llms_for_routing: + raise ValueError( + f"Primary LLM key '{self.PRIMARY_MODEL_KEY}' not found" + " in llms_for_routing." + ) + return self diff --git a/tests/sdk/llm/test_dynamic_router.py b/tests/sdk/llm/test_dynamic_router.py index fe6062c5b9..e0138107ff 100644 --- a/tests/sdk/llm/test_dynamic_router.py +++ b/tests/sdk/llm/test_dynamic_router.py @@ -17,31 +17,30 @@ class TestDynamicRouter: def test_initialization(self): """Test basic router initialization.""" initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"initial": initial_llm} + service_id="test_router", llms_for_routing={"primary": initial_llm} ) assert router.router_name == "dynamic_router" assert router.manual_selection is None assert len(router.llms_for_routing) == 1 - assert "initial" in router.llms_for_routing - assert len(router.dynamic_llm_configs) == 0 + assert "primary" in router.llms_for_routing def test_default_selection(self): """Test default LLM selection when no manual selection is set.""" initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"initial": initial_llm} + service_id="test_router", llms_for_routing={"primary": initial_llm} ) selected = router.select_llm([]) - assert selected == "initial" + assert selected == "primary" def test_manual_selection(self): """Test manual LLM selection.""" @@ -51,7 +50,7 @@ def test_manual_selection(self): llm2 = LLM(model="gpt-4o", api_key=SecretStr("test-key2"), service_id="llm2") router = DynamicRouter( - service_id="test_router", llms_for_routing={"llm1": llm1, "llm2": llm2} + service_id="test_router", llms_for_routing={"primary": llm1, "llm2": llm2} ) # Test switching to existing LLM @@ -63,30 +62,30 @@ def test_manual_selection(self): def test_dynamic_llm_creation(self): """Test creating new LLMs dynamically.""" initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"initial": initial_llm} + service_id="test_router", llms_for_routing={"primary": initial_llm} ) # Create new LLM dynamically - success = router.switch_to_llm( - "claude", + claude_llm = LLM( + service_id="claude", model="claude-3-5-sonnet-20241022", - api_key="claude-key", + api_key=SecretStr("claude-key"), temperature=0.7, ) + success = router.switch_to_llm( + "claude", + llm=claude_llm, + ) assert success is True assert router.manual_selection == "claude" assert "claude" in router.llms_for_routing - assert "claude" in router.dynamic_llm_configs - assert ( - router.dynamic_llm_configs["claude"]["model"] - == "claude-3-5-sonnet-20241022" - ) - assert router.dynamic_llm_configs["claude"]["temperature"] == 0.7 + assert router.llms_for_routing["claude"].model == "claude-3-5-sonnet-20241022" + assert router.llms_for_routing["claude"].temperature == 0.7 assert router.select_llm([]) == "claude" def test_dynamic_llm_creation_without_model(self): @@ -96,7 +95,7 @@ def test_dynamic_llm_creation_without_model(self): model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"dummy": dummy_llm} + service_id="test_router", llms_for_routing={"primary": dummy_llm} ) success = router.switch_to_llm("invalid") @@ -107,28 +106,32 @@ def test_dynamic_llm_creation_without_model(self): def test_get_available_llms(self): """Test getting list of available LLMs.""" initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"initial": initial_llm} + service_id="test_router", llms_for_routing={"primary": initial_llm} ) # Initially only pre-configured LLM - available = router.get_available_llms() - assert available == {"initial": "gpt-4o-mini"} + available = router.llms_for_routing + assert available["primary"].model == "gpt-4o-mini" # Add dynamic LLM + claude_llm = LLM( + service_id="claude", + model="claude-3-5-sonnet-20241022", + api_key=SecretStr("claude-key"), + ) router.switch_to_llm( "claude", - model="claude-3-5-sonnet-20241022", - api_key="claude-key", + llm=claude_llm, ) - available = router.get_available_llms() + available = router.llms_for_routing assert len(available) == 2 - assert available["initial"] == "gpt-4o-mini" - assert available["claude"] == "claude-3-5-sonnet-20241022" + assert available["primary"].model == "gpt-4o-mini" + assert available["claude"].model == "claude-3-5-sonnet-20241022" def test_remove_dynamic_llm(self): """Test removing dynamically created LLMs.""" @@ -136,38 +139,40 @@ def test_remove_dynamic_llm(self): model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"dummy": dummy_llm} + service_id="test_router", llms_for_routing={"primary": dummy_llm} ) # Add dynamic LLM + claude_llm = LLM( + service_id="claude", + model="claude-3-5-sonnet-20241022", + api_key=SecretStr("claude-key"), + ) router.switch_to_llm( "claude", - model="claude-3-5-sonnet-20241022", - api_key="claude-key", + llm=claude_llm, ) assert "claude" in router.llms_for_routing - assert "claude" in router.dynamic_llm_configs # Remove it success = router.remove_llm("claude") assert success is True assert "claude" not in router.llms_for_routing - assert "claude" not in router.dynamic_llm_configs assert router.manual_selection is None def test_remove_non_dynamic_llm(self): """Test that removing pre-configured LLMs fails.""" initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"initial": initial_llm} + service_id="test_router", llms_for_routing={"primary": initial_llm} ) - success = router.remove_llm("initial") + success = router.remove_llm("primary") assert success is False - assert "initial" in router.llms_for_routing + assert "primary" in router.llms_for_routing def test_get_current_llm_name(self): """Test getting current LLM name.""" @@ -175,49 +180,56 @@ def test_get_current_llm_name(self): model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"dummy": dummy_llm} + service_id="test_router", llms_for_routing={"primary": dummy_llm} ) - assert router.get_current_llm_name() is None + assert router.active_llm_identifier is None + claude = LLM( + service_id="claude", + model="claude-3-5-sonnet-20241022", + api_key=SecretStr("claude-key"), + ) router.switch_to_llm( "claude", - model="claude-3-5-sonnet-20241022", - api_key="claude-key", + llm=claude, ) - assert router.get_current_llm_name() == "claude" + assert router.active_llm_identifier == "claude" def test_serialization_with_dynamic_llms(self): - """Test serialization includes dynamic LLM configs.""" + """Test serialization of router with dynamic LLMs.""" initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="initial" + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"initial": initial_llm} + service_id="test_router", llms_for_routing={"primary": initial_llm} ) # Add dynamic LLMs + claude_llm = LLM( + service_id="claude", + model="claude-3-5-sonnet-20241022", + api_key=SecretStr("claude-key"), + ) router.switch_to_llm( "claude", - model="claude-3-5-sonnet-20241022", - api_key="claude-key", + llm=claude_llm, + ) + gemini_llm = LLM( + service_id="gemini", + model="gemini-1.5-pro", + api_key=SecretStr("gemini-key"), + ) + router.switch_to_llm( + "gemini", + llm=gemini_llm, ) - router.switch_to_llm("gemini", model="gemini-1.5-pro", api_key="gemini-key") # Serialize serialized = router.model_dump_json(exclude_none=True) data = json.loads(serialized) - # Check dynamic configs are included - assert "dynamic_llm_configs" in data - assert "claude" in data["dynamic_llm_configs"] - assert "gemini" in data["dynamic_llm_configs"] - assert ( - data["dynamic_llm_configs"]["claude"]["model"] - == "claude-3-5-sonnet-20241022" - ) - assert data["dynamic_llm_configs"]["gemini"]["model"] == "gemini-1.5-pro" assert data["manual_selection"] == "gemini" # Last selected def test_ensure_llm_exists(self): @@ -226,25 +238,23 @@ def test_ensure_llm_exists(self): model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"dummy": dummy_llm} + service_id="test_router", llms_for_routing={"primary": dummy_llm} ) # Add dynamic LLM + claude_llm = LLM( + service_id="claude", + model="claude-3-5-sonnet-20241022", + api_key=SecretStr("claude-key"), + ) router.switch_to_llm( "claude", - model="claude-3-5-sonnet-20241022", - api_key="claude-key", + llm=claude_llm, ) - # Remove from routing table but keep config + # Remove from routing table del router.llms_for_routing["claude"] assert "claude" not in router.llms_for_routing - assert "claude" in router.dynamic_llm_configs - - # Ensure it exists should recreate it - router._ensure_llm_exists("claude") - assert "claude" in router.llms_for_routing - assert router.llms_for_routing["claude"].model == "claude-3-5-sonnet-20241022" def test_no_llms_available_error(self): """Test error when no LLMs are available.""" @@ -252,7 +262,7 @@ def test_no_llms_available_error(self): model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"dummy": dummy_llm} + service_id="test_router", llms_for_routing={"primary": dummy_llm} ) # Remove all LLMs to test error condition @@ -267,15 +277,15 @@ def test_switch_to_existing_dynamic_llm_from_config(self): model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"dummy": dummy_llm} + service_id="test_router", llms_for_routing={"primary": dummy_llm} ) # Manually add config without creating LLM instance - router.dynamic_llm_configs["claude"] = { - "model": "claude-3-5-sonnet-20241022", - "service_id": "dynamic_claude", - "api_key": SecretStr("claude-key"), - } + router.llms_for_routing["claude"] = LLM( + model="claude-3-5-sonnet-20241022", + service_id="dynamic_claude", + api_key=SecretStr("claude-key"), + ) # Switch to it should recreate from config success = router.switch_to_llm("claude") From 5103237b22433a20f535b21690dca342646fcb02 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Wed, 8 Oct 2025 14:00:41 +0000 Subject: [PATCH 09/17] rename llm_type to kind --- openhands/sdk/agent/base.py | 2 +- openhands/sdk/llm/llm.py | 4 ++-- openhands/sdk/llm/router/base.py | 4 ++-- openhands/sdk/llm/router/impl/dynamic.py | 4 ++-- openhands/sdk/llm/router/impl/multimodal.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openhands/sdk/agent/base.py b/openhands/sdk/agent/base.py index cd5e99469c..3a1e1d1dcc 100644 --- a/openhands/sdk/agent/base.py +++ b/openhands/sdk/agent/base.py @@ -39,7 +39,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC): llm: Annotated[ LLM | MultimodalRouter | DynamicRouter, - Discriminator("llm_type"), + Discriminator("kind"), Field( ..., description="LLM configuration for the agent.", diff --git a/openhands/sdk/llm/llm.py b/openhands/sdk/llm/llm.py index ccdffee490..07c895f6ec 100644 --- a/openhands/sdk/llm/llm.py +++ b/openhands/sdk/llm/llm.py @@ -96,8 +96,8 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): # ========================================================================= # Config fields # ========================================================================= - llm_type: Literal["llm"] = Field( - default="llm", description="Discriminator for LLM subclasses" + kind: Literal["LLM"] = Field( + default="LLM", description="Discriminator for LLM subclasses" ) model: str = Field(default="claude-sonnet-4-20250514", description="Model name.") api_key: SecretStr | None = Field(default=None, description="API key.") diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 6d2c6a20bc..434beca6dc 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -31,8 +31,8 @@ class RouterLLM(LLM): - Provides routing interface through select_llm() method """ - llm_type: Literal["router"] = Field( # type: ignore - default="router", description="Discriminator for RouterLLM" + kind: Literal["RouterLLM"] = Field( # type: ignore + default="RouterLLM", description="Discriminator for RouterLLM" ) router_name: str = Field(default="base_router", description="Name of the router") llms_for_routing: dict[str, LLM] = Field( diff --git a/openhands/sdk/llm/router/impl/dynamic.py b/openhands/sdk/llm/router/impl/dynamic.py index 9a779ea59e..00cb47c164 100644 --- a/openhands/sdk/llm/router/impl/dynamic.py +++ b/openhands/sdk/llm/router/impl/dynamic.py @@ -29,8 +29,8 @@ class DynamicRouter(RouterLLM): PRIMARY_MODEL_KEY: str = "primary" - llm_type: Literal["dynamic_router"] = Field( # type: ignore - default="dynamic_router", description="Discriminator for DynamicRouter" + kind: Literal["DynamicRouter"] = Field( # type: ignore + default="DynamicRouter", description="Discriminator for DynamicRouter" ) router_name: str = "dynamic_router" manual_selection: str | None = None diff --git a/openhands/sdk/llm/router/impl/multimodal.py b/openhands/sdk/llm/router/impl/multimodal.py index 2f426de188..344f392689 100644 --- a/openhands/sdk/llm/router/impl/multimodal.py +++ b/openhands/sdk/llm/router/impl/multimodal.py @@ -21,8 +21,8 @@ class MultimodalRouter(RouterLLM): the secondary model is typically a text-only model with a lower context window. """ - llm_type: Literal["multimodal_router"] = Field( # type: ignore - default="multimodal_router", description="Discriminator for MultimodalRouter" + kind: Literal["MultimodalRouter"] = Field( # type: ignore + default="MultimodalRouter", description="Discriminator for MultimodalRouter" ) router_name: str = "multimodal_router" From 12acf408d48da66b210c75c34751aea4004d2892 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Thu, 9 Oct 2025 08:44:23 +0000 Subject: [PATCH 10/17] add LLMBase class and refactor LLM to LLMBase --- .../agent_server/conversation_service.py | 4 +- openhands/agent_server/event_service.py | 4 +- openhands/agent_server/models.py | 4 +- openhands/sdk/__init__.py | 2 + openhands/sdk/agent/base.py | 32 +- .../condenser/llm_summarizing_condenser.py | 4 +- openhands/sdk/conversation/base.py | 4 +- .../conversation/impl/local_conversation.py | 4 +- .../conversation/impl/remote_conversation.py | 4 +- openhands/sdk/conversation/title_utils.py | 8 +- openhands/sdk/llm/__init__.py | 3 +- openhands/sdk/llm/llm.py | 290 +++++++++--------- openhands/sdk/llm/llm_registry.py | 10 +- openhands/sdk/llm/router/base.py | 10 +- openhands/sdk/llm/router/impl/dynamic.py | 11 +- openhands/sdk/llm/router/impl/multimodal.py | 7 +- openhands/tools/preset/default.py | 6 +- openhands/tools/preset/planning.py | 6 +- 18 files changed, 209 insertions(+), 204 deletions(-) diff --git a/openhands/agent_server/conversation_service.py b/openhands/agent_server/conversation_service.py index c2eb3fe7c7..feb732add1 100644 --- a/openhands/agent_server/conversation_service.py +++ b/openhands/agent_server/conversation_service.py @@ -19,7 +19,7 @@ from openhands.agent_server.pub_sub import Subscriber from openhands.agent_server.server_details_router import update_last_execution_time from openhands.agent_server.utils import utc_now -from openhands.sdk import LLM, Event, Message +from openhands.sdk import Event, LLMBase, Message from openhands.sdk.conversation.state import AgentExecutionStatus, ConversationState @@ -256,7 +256,7 @@ async def get_event_service(self, conversation_id: UUID) -> EventService | None: return self._event_services.get(conversation_id) async def generate_conversation_title( - self, conversation_id: UUID, max_length: int = 50, llm: LLM | None = None + self, conversation_id: UUID, max_length: int = 50, llm: LLMBase | None = None ) -> str | None: """Generate a title for the conversation using LLM.""" if self._event_services is None: diff --git a/openhands/agent_server/event_service.py b/openhands/agent_server/event_service.py index b6f8275d02..c776e1eb84 100644 --- a/openhands/agent_server/event_service.py +++ b/openhands/agent_server/event_service.py @@ -11,7 +11,7 @@ ) from openhands.agent_server.pub_sub import PubSub, Subscriber from openhands.agent_server.utils import utc_now -from openhands.sdk import LLM, Agent, Event, Message, get_logger +from openhands.sdk import Agent, Event, LLMBase, Message, get_logger from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.conversation.secrets_manager import SecretValue from openhands.sdk.conversation.state import ConversationState @@ -263,7 +263,7 @@ async def close(self): loop.run_in_executor(None, self._conversation.close) async def generate_title( - self, llm: "LLM | None" = None, max_length: int = 50 + self, llm: "LLMBase | None" = None, max_length: int = 50 ) -> str: """Generate a title for the conversation. diff --git a/openhands/agent_server/models.py b/openhands/agent_server/models.py index a582643d67..5a383bf6de 100644 --- a/openhands/agent_server/models.py +++ b/openhands/agent_server/models.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from openhands.agent_server.utils import utc_now -from openhands.sdk import LLM, AgentBase, Event, ImageContent, Message, TextContent +from openhands.sdk import AgentBase, Event, ImageContent, LLMBase, Message, TextContent from openhands.sdk.conversation.secret_source import SecretSource from openhands.sdk.conversation.state import AgentExecutionStatus, ConversationState from openhands.sdk.llm.utils.metrics import MetricsSnapshot @@ -151,7 +151,7 @@ class GenerateTitleRequest(BaseModel): max_length: int = Field( default=50, ge=1, le=200, description="Maximum length of the generated title" ) - llm: LLM | None = Field( + llm: LLMBase | None = Field( default=None, description="Optional LLM to use for title generation" ) diff --git a/openhands/sdk/__init__.py b/openhands/sdk/__init__.py index 07ec5f48a1..b846361afc 100644 --- a/openhands/sdk/__init__.py +++ b/openhands/sdk/__init__.py @@ -19,6 +19,7 @@ from openhands.sdk.llm import ( LLM, ImageContent, + LLMBase, LLMRegistry, Message, RedactedThinkingBlock, @@ -56,6 +57,7 @@ __version__ = "0.0.0" # fallback for editable/unbuilt environments __all__ = [ + "LLMBase", "LLM", "LLMRegistry", "ConversationStats", diff --git a/openhands/sdk/agent/base.py b/openhands/sdk/agent/base.py index 3a1e1d1dcc..c0a4b6a3ea 100644 --- a/openhands/sdk/agent/base.py +++ b/openhands/sdk/agent/base.py @@ -3,15 +3,15 @@ import sys from abc import ABC, abstractmethod from collections.abc import Generator, Iterable -from typing import TYPE_CHECKING, Annotated, Any +from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, ConfigDict, Discriminator, Field, PrivateAttr +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr import openhands.sdk.security.analyzer as analyzer from openhands.sdk.context.agent_context import AgentContext from openhands.sdk.context.condenser import CondenserBase, LLMSummarizingCondenser from openhands.sdk.context.prompts.prompt import render_template -from openhands.sdk.llm import LLM, DynamicRouter, MultimodalRouter +from openhands.sdk.llm import LLM, LLMBase from openhands.sdk.logger import get_logger from openhands.sdk.mcp import create_mcp_tools from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer @@ -37,21 +37,17 @@ class AgentBase(DiscriminatedUnionMixin, ABC): arbitrary_types_allowed=True, ) - llm: Annotated[ - LLM | MultimodalRouter | DynamicRouter, - Discriminator("kind"), - Field( - ..., - description="LLM configuration for the agent.", - examples=[ - { - "model": "litellm_proxy/anthropic/claude-sonnet-4-5-20250929", - "base_url": "https://llm-proxy.eval.all-hands.dev", - "api_key": "your_api_key_here", - } - ], - ), - ] + llm: LLMBase = Field( + ..., + description="LLM configuration for the agent.", + examples=[ + { + "model": "litellm_proxy/anthropic/claude-sonnet-4-5-20250929", + "base_url": "https://llm-proxy.eval.all-hands.dev", + "api_key": "your_api_key_here", + } + ], + ) tools: list[Tool] = Field( default_factory=list, description="List of tools to initialize for the agent.", diff --git a/openhands/sdk/context/condenser/llm_summarizing_condenser.py b/openhands/sdk/context/condenser/llm_summarizing_condenser.py index 6a75fb7a0c..c58461cf6c 100644 --- a/openhands/sdk/context/condenser/llm_summarizing_condenser.py +++ b/openhands/sdk/context/condenser/llm_summarizing_condenser.py @@ -7,11 +7,11 @@ from openhands.sdk.context.view import View from openhands.sdk.event.condenser import Condensation from openhands.sdk.event.llm_convertible import MessageEvent -from openhands.sdk.llm import LLM, Message, TextContent +from openhands.sdk.llm import LLMBase, Message, TextContent class LLMSummarizingCondenser(RollingCondenser): - llm: LLM + llm: LLMBase max_size: int = Field(default=120, gt=0) keep_first: int = Field(default=4, ge=0) diff --git a/openhands/sdk/conversation/base.py b/openhands/sdk/conversation/base.py index 796a7fd744..8fe4dc243c 100644 --- a/openhands/sdk/conversation/base.py +++ b/openhands/sdk/conversation/base.py @@ -7,7 +7,7 @@ from openhands.sdk.conversation.events_list_base import EventsListBase from openhands.sdk.conversation.secrets_manager import SecretValue from openhands.sdk.conversation.types import ConversationCallbackType, ConversationID -from openhands.sdk.llm.llm import LLM +from openhands.sdk.llm.llm import LLMBase from openhands.sdk.llm.message import Message from openhands.sdk.security.confirmation_policy import ( ConfirmationPolicyBase, @@ -109,7 +109,7 @@ def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None: ... def close(self) -> None: ... @abstractmethod - def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str: + def generate_title(self, llm: LLMBase | None = None, max_length: int = 50) -> str: """Generate a title for the conversation based on the first user message. Args: diff --git a/openhands/sdk/conversation/impl/local_conversation.py b/openhands/sdk/conversation/impl/local_conversation.py index 2ea670f5cc..b21b4721dc 100644 --- a/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands/sdk/conversation/impl/local_conversation.py @@ -15,7 +15,7 @@ PauseEvent, UserRejectObservation, ) -from openhands.sdk.llm import LLM, Message, TextContent +from openhands.sdk.llm import LLMBase, Message, TextContent from openhands.sdk.llm.llm_registry import LLMRegistry from openhands.sdk.logger import get_logger from openhands.sdk.security.confirmation_policy import ( @@ -348,7 +348,7 @@ def close(self) -> None: except Exception as e: logger.warning(f"Error closing executor for tool '{tool.name}': {e}") - def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str: + def generate_title(self, llm: LLMBase | None = None, max_length: int = 50) -> str: """Generate a title for the conversation based on the first user message. Args: diff --git a/openhands/sdk/conversation/impl/remote_conversation.py b/openhands/sdk/conversation/impl/remote_conversation.py index 40d663c580..677820d169 100644 --- a/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands/sdk/conversation/impl/remote_conversation.py @@ -22,7 +22,7 @@ FULL_STATE_KEY, ConversationStateUpdateEvent, ) -from openhands.sdk.llm import LLM, Message, TextContent +from openhands.sdk.llm import LLMBase, Message, TextContent from openhands.sdk.logger import get_logger from openhands.sdk.security.confirmation_policy import ( ConfirmationPolicyBase, @@ -523,7 +523,7 @@ def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None: resp = self._client.post(f"/api/conversations/{self._id}/secrets", json=payload) resp.raise_for_status() - def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str: + def generate_title(self, llm: LLMBase | None = None, max_length: int = 50) -> str: """Generate a title for the conversation based on the first user message. Args: diff --git a/openhands/sdk/conversation/title_utils.py b/openhands/sdk/conversation/title_utils.py index a971b1be1a..5f3b86558b 100644 --- a/openhands/sdk/conversation/title_utils.py +++ b/openhands/sdk/conversation/title_utils.py @@ -4,7 +4,7 @@ from openhands.sdk.event import MessageEvent from openhands.sdk.event.base import Event -from openhands.sdk.llm import LLM, Message, TextContent +from openhands.sdk.llm import LLMBase, Message, TextContent from openhands.sdk.logger import get_logger @@ -56,7 +56,9 @@ def extract_first_user_message(events: Sequence[Event]) -> str | None: return None -def generate_title_with_llm(message: str, llm: LLM, max_length: int = 50) -> str | None: +def generate_title_with_llm( + message: str, llm: LLMBase, max_length: int = 50 +) -> str | None: """Generate a conversation title using LLM. Args: @@ -155,7 +157,7 @@ def generate_fallback_title(message: str, max_length: int = 50) -> str: def generate_conversation_title( - events: Sequence[Event], llm: LLM | None = None, max_length: int = 50 + events: Sequence[Event], llm: LLMBase | None = None, max_length: int = 50 ) -> str: """Generate a title for a conversation based on the first user message. diff --git a/openhands/sdk/llm/__init__.py b/openhands/sdk/llm/__init__.py index 1303017d45..5c3124c95e 100644 --- a/openhands/sdk/llm/__init__.py +++ b/openhands/sdk/llm/__init__.py @@ -1,4 +1,4 @@ -from openhands.sdk.llm.llm import LLM +from openhands.sdk.llm.llm import LLM, LLMBase from openhands.sdk.llm.llm_registry import LLMRegistry, RegistryEvent from openhands.sdk.llm.llm_response import LLMResponse from openhands.sdk.llm.message import ( @@ -27,6 +27,7 @@ __all__ = [ "LLMResponse", + "LLMBase", "LLM", "LLMRegistry", "RouterLLM", diff --git a/openhands/sdk/llm/llm.py b/openhands/sdk/llm/llm.py index 07c895f6ec..62440ccc8a 100644 --- a/openhands/sdk/llm/llm.py +++ b/openhands/sdk/llm/llm.py @@ -4,6 +4,7 @@ import json import os import warnings +from abc import abstractmethod from collections.abc import Callable, Sequence from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin @@ -25,6 +26,7 @@ if TYPE_CHECKING: # type hints only, avoid runtime import cycle from openhands.sdk.tool.tool import ToolBase +from openhands.sdk.utils.models import DiscriminatedUnionMixin from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff @@ -76,7 +78,7 @@ logger = get_logger(__name__) -__all__ = ["LLM"] +__all__ = ["LLM", "LLMBase"] # Exceptions we retry on @@ -90,15 +92,12 @@ ) -class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin): +class LLMBase(DiscriminatedUnionMixin, RetryMixin, NonNativeToolCallingMixin): """Refactored LLM: simple `completion()`, centralized Telemetry, tiny helpers.""" # ========================================================================= # Config fields # ========================================================================= - kind: Literal["LLM"] = Field( - default="LLM", description="Discriminator for LLM subclasses" - ) model: str = Field(default="claude-sonnet-4-20250514", description="Model name.") api_key: SecretStr | None = Field(default=None, description="API key.") base_url: str | None = Field(default=None, description="Custom base URL.") @@ -381,6 +380,7 @@ def restore_metrics(self, metrics: Metrics) -> None: # Only used by ConversationStats to seed metrics self._metrics = metrics + @abstractmethod def completion( self, messages: list[Message], @@ -393,110 +393,7 @@ def completion( Normalize → (maybe) mock tools → transport → postprocess. """ - # Check if streaming is requested - if kwargs.get("stream", False): - raise ValueError("Streaming is not supported") - - # 1) serialize messages - formatted_messages = self.format_messages_for_llm(messages) - - # 2) choose function-calling strategy - use_native_fc = self.is_function_calling_active() - original_fncall_msgs = copy.deepcopy(formatted_messages) - - # Convert Tool objects to ChatCompletionToolParam once here - cc_tools: list[ChatCompletionToolParam] = [] - if tools: - cc_tools = [ - t.to_openai_tool( - add_security_risk_prediction=add_security_risk_prediction - ) - for t in tools - ] - - use_mock_tools = self.should_mock_tool_calls(cc_tools) - if use_mock_tools: - logger.debug( - "LLM.completion: mocking function-calling via prompt " - f"for model {self.model}" - ) - formatted_messages, kwargs = self.pre_request_prompt_mock( - formatted_messages, cc_tools or [], kwargs - ) - - # 3) normalize provider params - # Only pass tools when native FC is active - kwargs["tools"] = cc_tools if (bool(cc_tools) and use_native_fc) else None - has_tools_flag = bool(cc_tools) and use_native_fc - call_kwargs = self._normalize_call_kwargs(kwargs, has_tools=has_tools_flag) - - # 4) optional request logging context (kept small) - assert self._telemetry is not None - log_ctx = None - if self._telemetry.log_enabled: - log_ctx = { - "messages": formatted_messages[:], # already simple dicts - "tools": tools, - "kwargs": {k: v for k, v in call_kwargs.items()}, - "context_window": self.max_input_tokens, - } - if tools and not use_native_fc: - log_ctx["raw_messages"] = original_fncall_msgs - self._telemetry.on_request(log_ctx=log_ctx) - - # 5) do the call with retries - @self.retry_decorator( - num_retries=self.num_retries, - retry_exceptions=LLM_RETRY_EXCEPTIONS, - retry_min_wait=self.retry_min_wait, - retry_max_wait=self.retry_max_wait, - retry_multiplier=self.retry_multiplier, - retry_listener=self.retry_listener, - ) - def _one_attempt(**retry_kwargs) -> ModelResponse: - assert self._telemetry is not None - # Merge retry-modified kwargs (like temperature) with call_kwargs - final_kwargs = {**call_kwargs, **retry_kwargs} - resp = self._transport_call(messages=formatted_messages, **final_kwargs) - raw_resp: ModelResponse | None = None - if use_mock_tools: - raw_resp = copy.deepcopy(resp) - resp = self.post_response_prompt_mock( - resp, nonfncall_msgs=formatted_messages, tools=cc_tools - ) - # 6) telemetry - self._telemetry.on_response(resp, raw_resp=raw_resp) - - # Ensure at least one choice - if not resp.get("choices") or len(resp["choices"]) < 1: - raise LLMNoResponseError( - "Response choices is less than 1. Response: " + str(resp) - ) - - return resp - - try: - resp = _one_attempt() - - # Convert the first choice to an OpenHands Message - first_choice = resp["choices"][0] - message = Message.from_llm_chat_message(first_choice["message"]) - - # Get current metrics snapshot - metrics_snapshot = MetricsSnapshot( - model_name=self.metrics.model_name, - accumulated_cost=self.metrics.accumulated_cost, - max_budget_per_task=self.metrics.max_budget_per_task, - accumulated_token_usage=self.metrics.accumulated_token_usage, - ) - - # Create and return LLMResponse - return LLMResponse( - message=message, metrics=metrics_snapshot, raw_response=resp - ) - except Exception as e: - self._telemetry.on_error(e) - raise + pass # ========================================================================= # Responses API (non-stream, v1) @@ -1039,13 +936,13 @@ def get_token_count(self, messages: list[Message]) -> int: # Serialization helpers # ========================================================================= @classmethod - def load_from_json(cls, json_path: str) -> LLM: + def load_from_json(cls, json_path: str) -> LLMBase: with open(json_path) as f: data = json.load(f) return cls(**data) @classmethod - def load_from_env(cls, prefix: str = "LLM_") -> LLM: + def load_from_env(cls, prefix: str = "LLM_") -> LLMBase: TRUTHY = {"true", "1", "yes", "on"} def _unwrap_type(t: Any) -> Any: @@ -1099,7 +996,8 @@ def _cast_value(raw: str, t: Any) -> Any: data[field_name] = v return cls(**data) - def resolve_diff_from_deserialized(self, persisted: LLM) -> LLM: + @abstractmethod + def resolve_diff_from_deserialized(self, persisted: LLMBase) -> LLMBase: """Resolve differences between a deserialized LLM and the current instance. This is due to fields like api_key being serialized to "****" in dumps, @@ -1109,31 +1007,7 @@ def resolve_diff_from_deserialized(self, persisted: LLM) -> LLM: Return a new LLM instance equivalent to `persisted` but with explicitly whitelisted fields (e.g. api_key) taken from `self`. """ - if persisted.__class__ is not self.__class__: - raise ValueError( - f"Cannot resolve_diff_from_deserialized between {self.__class__} " - f"and {persisted.__class__}" - ) - - # Copy allowed fields from runtime llm into the persisted llm - llm_updates = {} - persisted_dump = persisted.model_dump(exclude_none=True) - for field in self.OVERRIDE_ON_SERIALIZE: - if field in persisted_dump.keys(): - llm_updates[field] = getattr(self, field) - if llm_updates: - reconciled = persisted.model_copy(update=llm_updates) - else: - reconciled = persisted - - if self.model_dump(exclude_none=True) != reconciled.model_dump( - exclude_none=True - ): - raise ValueError( - "The LLM provided is different from the one in persisted state.\n" - f"Diff: {pretty_pydantic_diff(self, reconciled)}" - ) - return reconciled + pass @staticmethod def is_context_window_exceeded_exception(exception: Exception) -> bool: @@ -1182,3 +1056,145 @@ def is_context_window_exceeded_exception(exception: Exception) -> bool: # window exceeded error, we'll have to assume it's not and rely on the call-site # context to handle it appropriately. return False + + +class LLM(LLMBase): + def completion( + self, + messages: list[Message], + tools: Sequence[ToolBase] | None = None, + return_metrics: bool = False, + add_security_risk_prediction: bool = False, + **kwargs, + ) -> LLMResponse: + # Check if streaming is requested + if kwargs.get("stream", False): + raise ValueError("Streaming is not supported") + + # 1) serialize messages + formatted_messages = self.format_messages_for_llm(messages) + + # 2) choose function-calling strategy + use_native_fc = self.is_function_calling_active() + original_fncall_msgs = copy.deepcopy(formatted_messages) + + # Convert Tool objects to ChatCompletionToolParam once here + cc_tools: list[ChatCompletionToolParam] = [] + if tools: + cc_tools = [ + t.to_openai_tool( + add_security_risk_prediction=add_security_risk_prediction + ) + for t in tools + ] + + use_mock_tools = self.should_mock_tool_calls(cc_tools) + if use_mock_tools: + logger.debug( + "LLM.completion: mocking function-calling via prompt " + f"for model {self.model}" + ) + formatted_messages, kwargs = self.pre_request_prompt_mock( + formatted_messages, cc_tools or [], kwargs + ) + + # 3) normalize provider params + # Only pass tools when native FC is active + kwargs["tools"] = cc_tools if (bool(cc_tools) and use_native_fc) else None + has_tools_flag = bool(cc_tools) and use_native_fc + call_kwargs = self._normalize_call_kwargs(kwargs, has_tools=has_tools_flag) + + # 4) optional request logging context (kept small) + assert self._telemetry is not None + log_ctx = None + if self._telemetry.log_enabled: + log_ctx = { + "messages": formatted_messages[:], # already simple dicts + "tools": tools, + "kwargs": {k: v for k, v in call_kwargs.items()}, + "context_window": self.max_input_tokens, + } + if tools and not use_native_fc: + log_ctx["raw_messages"] = original_fncall_msgs + self._telemetry.on_request(log_ctx=log_ctx) + + # 5) do the call with retries + @self.retry_decorator( + num_retries=self.num_retries, + retry_exceptions=LLM_RETRY_EXCEPTIONS, + retry_min_wait=self.retry_min_wait, + retry_max_wait=self.retry_max_wait, + retry_multiplier=self.retry_multiplier, + retry_listener=self.retry_listener, + ) + def _one_attempt(**retry_kwargs) -> ModelResponse: + assert self._telemetry is not None + # Merge retry-modified kwargs (like temperature) with call_kwargs + final_kwargs = {**call_kwargs, **retry_kwargs} + resp = self._transport_call(messages=formatted_messages, **final_kwargs) + raw_resp: ModelResponse | None = None + if use_mock_tools: + raw_resp = copy.deepcopy(resp) + resp = self.post_response_prompt_mock( + resp, nonfncall_msgs=formatted_messages, tools=cc_tools + ) + # 6) telemetry + self._telemetry.on_response(resp, raw_resp=raw_resp) + + # Ensure at least one choice + if not resp.get("choices") or len(resp["choices"]) < 1: + raise LLMNoResponseError( + "Response choices is less than 1. Response: " + str(resp) + ) + + return resp + + try: + resp = _one_attempt() + + # Convert the first choice to an OpenHands Message + first_choice = resp["choices"][0] + message = Message.from_llm_chat_message(first_choice["message"]) + + # Get current metrics snapshot + metrics_snapshot = MetricsSnapshot( + model_name=self.metrics.model_name, + accumulated_cost=self.metrics.accumulated_cost, + max_budget_per_task=self.metrics.max_budget_per_task, + accumulated_token_usage=self.metrics.accumulated_token_usage, + ) + + # Create and return LLMResponse + return LLMResponse( + message=message, metrics=metrics_snapshot, raw_response=resp + ) + except Exception as e: + self._telemetry.on_error(e) + raise + + def resolve_diff_from_deserialized(self, persisted: LLMBase) -> LLMBase: + if persisted.__class__ is not self.__class__: + raise ValueError( + f"Cannot resolve_diff_from_deserialized between {self.__class__} " + f"and {persisted.__class__}" + ) + + # Copy allowed fields from runtime llm into the persisted llm + llm_updates = {} + persisted_dump = persisted.model_dump(exclude_none=True) + for field in self.OVERRIDE_ON_SERIALIZE: + if field in persisted_dump.keys(): + llm_updates[field] = getattr(self, field) + if llm_updates: + reconciled = persisted.model_copy(update=llm_updates) + else: + reconciled = persisted + + if self.model_dump(exclude_none=True) != reconciled.model_dump( + exclude_none=True + ): + raise ValueError( + "The LLM provided is different from the one in persisted state.\n" + f"Diff: {pretty_pydantic_diff(self, reconciled)}" + ) + return reconciled diff --git a/openhands/sdk/llm/llm_registry.py b/openhands/sdk/llm/llm_registry.py index 63a8e68791..5d0e477003 100644 --- a/openhands/sdk/llm/llm_registry.py +++ b/openhands/sdk/llm/llm_registry.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict -from openhands.sdk.llm.llm import LLM +from openhands.sdk.llm.llm import LLMBase from openhands.sdk.logger import get_logger @@ -11,7 +11,7 @@ class RegistryEvent(BaseModel): - llm: LLM + llm: LLMBase model_config = ConfigDict( arbitrary_types_allowed=True, @@ -36,7 +36,7 @@ def __init__( """ self.registry_id = str(uuid4()) self.retry_listener = retry_listener - self.service_to_llm: dict[str, LLM] = {} + self.service_to_llm: dict[str, LLMBase] = {} self.subscriber: Callable[[RegistryEvent], None] | None = None def subscribe(self, callback: Callable[[RegistryEvent], None]) -> None: @@ -59,7 +59,7 @@ def notify(self, event: RegistryEvent) -> None: except Exception as e: logger.warning(f"Failed to emit event: {e}") - def add(self, llm: LLM) -> None: + def add(self, llm: LLMBase) -> None: """Add an LLM instance to the registry. Args: @@ -82,7 +82,7 @@ def add(self, llm: LLM) -> None: f"[LLM registry {self.registry_id}]: Added LLM for service {service_id}" ) - def get(self, service_id: str) -> LLM: + def get(self, service_id: str) -> LLMBase: """Get an LLM instance from the registry. Args: diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 434beca6dc..500117c607 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -1,6 +1,5 @@ from abc import abstractmethod from collections.abc import Sequence -from typing import Literal from pydantic import ( Field, @@ -8,7 +7,7 @@ model_validator, ) -from openhands.sdk.llm.llm import LLM +from openhands.sdk.llm.llm import LLM, LLMBase from openhands.sdk.llm.llm_response import LLMResponse from openhands.sdk.llm.message import Message from openhands.sdk.logger import get_logger @@ -31,11 +30,8 @@ class RouterLLM(LLM): - Provides routing interface through select_llm() method """ - kind: Literal["RouterLLM"] = Field( # type: ignore - default="RouterLLM", description="Discriminator for RouterLLM" - ) router_name: str = Field(default="base_router", description="Name of the router") - llms_for_routing: dict[str, LLM] = Field( + llms_for_routing: dict[str, LLMBase] = Field( default_factory=dict ) # Mapping of LLM name to LLM instance for routing @@ -119,7 +115,7 @@ def set_placeholder_model(cls, data): return d - def resolve_diff_from_deserialized(self, persisted: "LLM") -> "LLM": + def resolve_diff_from_deserialized(self, persisted: "LLMBase") -> "LLMBase": """Resolve differences between a deserialized RouterLLM and the current instance. diff --git a/openhands/sdk/llm/router/impl/dynamic.py b/openhands/sdk/llm/router/impl/dynamic.py index 00cb47c164..4826b4c67e 100644 --- a/openhands/sdk/llm/router/impl/dynamic.py +++ b/openhands/sdk/llm/router/impl/dynamic.py @@ -5,11 +5,9 @@ with full serialization/deserialization support. """ -from typing import Literal +from pydantic import model_validator -from pydantic import Field, model_validator - -from openhands.sdk.llm import LLM +from openhands.sdk.llm import LLMBase from openhands.sdk.llm.message import Message from openhands.sdk.llm.router.base import RouterLLM from openhands.sdk.logger import get_logger @@ -29,9 +27,6 @@ class DynamicRouter(RouterLLM): PRIMARY_MODEL_KEY: str = "primary" - kind: Literal["DynamicRouter"] = Field( # type: ignore - default="DynamicRouter", description="Discriminator for DynamicRouter" - ) router_name: str = "dynamic_router" manual_selection: str | None = None @@ -60,7 +55,7 @@ def select_llm(self, messages: list[Message]) -> str: def switch_to_llm( self, identifier: str, - llm: LLM | None = None, + llm: LLMBase | None = None, ) -> bool: """ Switch to an LLM, creating it dynamically if it doesn't exist. diff --git a/openhands/sdk/llm/router/impl/multimodal.py b/openhands/sdk/llm/router/impl/multimodal.py index 344f392689..324c9f9758 100644 --- a/openhands/sdk/llm/router/impl/multimodal.py +++ b/openhands/sdk/llm/router/impl/multimodal.py @@ -1,6 +1,6 @@ -from typing import ClassVar, Literal +from typing import ClassVar -from pydantic import Field, model_validator +from pydantic import model_validator from openhands.sdk.llm.message import Message from openhands.sdk.llm.router.base import RouterLLM @@ -21,9 +21,6 @@ class MultimodalRouter(RouterLLM): the secondary model is typically a text-only model with a lower context window. """ - kind: Literal["MultimodalRouter"] = Field( # type: ignore - default="MultimodalRouter", description="Discriminator for MultimodalRouter" - ) router_name: str = "multimodal_router" PRIMARY_MODEL_KEY: ClassVar[str] = "primary" diff --git a/openhands/tools/preset/default.py b/openhands/tools/preset/default.py index ef6ee1265f..39a3143f6d 100644 --- a/openhands/tools/preset/default.py +++ b/openhands/tools/preset/default.py @@ -5,7 +5,7 @@ LLMSummarizingCondenser, ) from openhands.sdk.context.condenser.base import CondenserBase -from openhands.sdk.llm.llm import LLM +from openhands.sdk.llm.llm import LLMBase from openhands.sdk.logger import get_logger from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer from openhands.sdk.tool import Tool, register_tool @@ -54,7 +54,7 @@ def get_default_tools( return tools -def get_default_condenser(llm: LLM) -> CondenserBase: +def get_default_condenser(llm: LLMBase) -> CondenserBase: # Create a condenser to manage the context. The condenser will automatically # truncate conversation history when it exceeds max_size, and replaces the dropped # events with an LLM-generated summary. @@ -64,7 +64,7 @@ def get_default_condenser(llm: LLM) -> CondenserBase: def get_default_agent( - llm: LLM, + llm: LLMBase, cli_mode: bool = False, ) -> Agent: tools = get_default_tools( diff --git a/openhands/tools/preset/planning.py b/openhands/tools/preset/planning.py index f64928044c..bf39e818dd 100644 --- a/openhands/tools/preset/planning.py +++ b/openhands/tools/preset/planning.py @@ -2,7 +2,7 @@ from openhands.sdk import Agent from openhands.sdk.context.condenser import LLMSummarizingCondenser -from openhands.sdk.llm.llm import LLM +from openhands.sdk.llm.llm import LLMBase from openhands.sdk.logger import get_logger from openhands.sdk.tool import Tool, register_tool @@ -41,7 +41,7 @@ def get_planning_tools() -> list[Tool]: ] -def get_planning_condenser(llm: LLM) -> LLMSummarizingCondenser: +def get_planning_condenser(llm: LLMBase) -> LLMSummarizingCondenser: """Get a condenser optimized for planning workflows. Args: @@ -60,7 +60,7 @@ def get_planning_condenser(llm: LLM) -> LLMSummarizingCondenser: def get_planning_agent( - llm: LLM, + llm: LLMBase, ) -> Agent: """Get a configured planning agent. From d700240d5231c2164cbb8dada01c79235a9d2441 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Mon, 13 Oct 2025 13:42:37 +0000 Subject: [PATCH 11/17] rename --- .../25_llm_manual_switch.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/{28_llm_manual_switch.py => 01_standalone_sdk/25_llm_manual_switch.py} (100%) diff --git a/examples/28_llm_manual_switch.py b/examples/01_standalone_sdk/25_llm_manual_switch.py similarity index 100% rename from examples/28_llm_manual_switch.py rename to examples/01_standalone_sdk/25_llm_manual_switch.py From 74c424126d5cd6d940147ac44f43dcc27c60a88a Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Tue, 14 Oct 2025 09:15:19 +0000 Subject: [PATCH 12/17] cleanup --- examples/01_standalone_sdk/19_llm_routing.py | 2 -- examples/01_standalone_sdk/25_llm_manual_switch.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/examples/01_standalone_sdk/19_llm_routing.py b/examples/01_standalone_sdk/19_llm_routing.py index c4aaa61ca7..f299874aa7 100644 --- a/examples/01_standalone_sdk/19_llm_routing.py +++ b/examples/01_standalone_sdk/19_llm_routing.py @@ -10,7 +10,6 @@ Event, ImageContent, LLMConvertibleEvent, - LocalFileStore, Message, TextContent, get_logger, @@ -57,7 +56,6 @@ def conversation_callback(event: Event): conversation_id = uuid.uuid4() -file_store = LocalFileStore(f"./.conversations/{conversation_id}") conversation = Conversation( agent=agent, diff --git a/examples/01_standalone_sdk/25_llm_manual_switch.py b/examples/01_standalone_sdk/25_llm_manual_switch.py index f4caa51bc1..7cf37cbee3 100644 --- a/examples/01_standalone_sdk/25_llm_manual_switch.py +++ b/examples/01_standalone_sdk/25_llm_manual_switch.py @@ -9,7 +9,6 @@ Conversation, Event, LLMConvertibleEvent, - LocalFileStore, Message, TextContent, get_logger, @@ -54,7 +53,6 @@ def conversation_callback(event: Event): # Set up conversation with persistence for serialization demo conversation_id = uuid.uuid4() -file_store = LocalFileStore(f"./.conversations/{conversation_id}") conversation = Conversation( agent=agent, From 6ea9ec3dffec1476811a3b98c3293bfdf27668ca Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Thu, 16 Oct 2025 09:55:37 +0000 Subject: [PATCH 13/17] fix thinking blocks and simplify example --- .../01_standalone_sdk/25_llm_manual_switch.py | 108 ++++++------------ openhands/sdk/llm/llm.py | 5 +- openhands/sdk/llm/message.py | 3 +- openhands/sdk/llm/router/base.py | 4 +- openhands/sdk/llm/router/impl/dynamic.py | 6 +- 5 files changed, 44 insertions(+), 82 deletions(-) diff --git a/examples/01_standalone_sdk/25_llm_manual_switch.py b/examples/01_standalone_sdk/25_llm_manual_switch.py index 7cf37cbee3..2ad02d5002 100644 --- a/examples/01_standalone_sdk/25_llm_manual_switch.py +++ b/examples/01_standalone_sdk/25_llm_manual_switch.py @@ -23,17 +23,27 @@ api_key = os.getenv("LLM_API_KEY") assert api_key is not None, "LLM_API_KEY environment variable is not set." -# Create initial LLM -initial_llm = LLM( +# Create DynamicRouter with 2 initial LLMs +claude_llm = LLM( service_id="agent-initial", - model="litellm_proxy/anthropic/claude-sonnet-4-20250514", + model="litellm_proxy/anthropic/claude-sonnet-4-5-20250929", + base_url="https://llm-proxy.eval.all-hands.dev", + api_key=SecretStr(api_key), +) + +gpt_4o_llm = LLM( + service_id="gpt-4o", + model="litellm_proxy/openai/gpt-4o", base_url="https://llm-proxy.eval.all-hands.dev", api_key=SecretStr(api_key), ) -# Create DynamicRouter with initial LLM dynamic_router = DynamicRouter( - service_id="dynamic-router", llms_for_routing={"primary": initial_llm} + service_id="dynamic-router", + llms_for_routing={ + "primary": claude_llm, + "gpt-4o": gpt_4o_llm, + }, # primary is the default ) # Tools @@ -65,7 +75,7 @@ def conversation_callback(event: Event): print(f"Starting with LLM: {dynamic_router.active_llm_identifier}") print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") -# First interaction with Claude +# First interaction with Claude - primary LLM conversation.send_message( message=Message( role="user", @@ -75,23 +85,14 @@ def conversation_callback(event: Event): conversation.run() print("=" * 50) -print("Adding GPT-4 dynamically and switching to it...") +print("Switching to GPT-4o...") -# Dynamically add GPT-4 and switch to it -gpt_4 = LLM( - service_id="gpt-4", - model="litellm_proxy/openai/gpt-4o", - base_url="https://llm-proxy.eval.all-hands.dev", - api_key=SecretStr(api_key), - temperature=0.3, -) -success = dynamic_router.switch_to_llm("gpt4", gpt_4) -print(f"GPT-4 added successfully: {success}") +# Manually switch to GPT-4o +success = dynamic_router.switch_to_llm("gpt-4o") +print(f"GPT-4o switched successfully: {success}") print(f"Current LLM: {dynamic_router.active_llm_identifier}") -print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") -print() -# Second interaction with GPT-4 +# Interaction with GPT-4o conversation.send_message( message=Message( role="user", @@ -100,49 +101,6 @@ def conversation_callback(event: Event): ) conversation.run() -print("Adding a smaller model for simple tasks...") - -# Add a smaller model for simple tasks -mistral_small = LLM( - service_id="mistral_model", - model="litellm_proxy/mistral/devstral-small-2507", - base_url="https://llm-proxy.eval.all-hands.dev", - api_key=SecretStr(api_key), - temperature=0.1, -) -success = dynamic_router.switch_to_llm("mistral_model", mistral_small) -print(f"Small model added successfully: {success}") -print(f"Current LLM: {dynamic_router.active_llm_identifier}") -print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") - -# Third interaction with small model -conversation.send_message( - message=Message( - role="user", - content=[TextContent(text="Who trained you as an LLM?")], - ) -) -conversation.run() - -print("Switching back to Claude for complex reasoning...") - -# Switch back to Claude for complex task -dynamic_router.switch_to_llm("primary") -print(f"Current LLM: {dynamic_router.active_llm_identifier}") - -conversation.send_message( - message=Message( - role="user", - content=[ - TextContent( - text="Explain the concept of dynamic programming in one sentence." - ) - ], - ) -) -conversation.run() - -print("Demonstrating persistence with LLM switching...") # Show current state before serialization print(f"Before serialization - Current LLM: {dynamic_router.active_llm_identifier}") @@ -161,6 +119,7 @@ def conversation_callback(event: Event): ) print(f"After deserialization - Current LLM: {dynamic_router.active_llm_identifier}") +assert dynamic_router.active_llm_identifier == "gpt-4o" print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") # Continue conversation after persistence @@ -172,28 +131,27 @@ def conversation_callback(event: Event): ) conversation.run() -print("=" * 50) -print("Removing a model...") - -# Remove the small model -success = dynamic_router.remove_llm("mistral_model") -print(f"Small model removed successfully: {success}") -print(f"Current LLM: {dynamic_router.active_llm_identifier}") -print(f"Remaining LLMs: {list(dynamic_router.llms_for_routing.keys())}") +# Switch back to primary model for complex task +print("Switching back to claude for complex reasoning...") -# Switch to GPT-4 -dynamic_router.switch_to_llm("gpt4") +dynamic_router.switch_to_llm("primary") print(f"Switched to LLM: {dynamic_router.active_llm_identifier}") -# Final interaction with GPT-4 conversation.send_message( message=Message( role="user", - content=[TextContent(text="What's the meaning of life?")], + content=[ + TextContent( + text="Explain the concept of dynamic programming in one sentence." + ) + ], ) ) conversation.run() +print("Demonstrating persistence with LLM switching...") + + print("=" * 100) print("Conversation finished. Got the following LLM messages:") for i, message in enumerate(llm_messages): diff --git a/openhands/sdk/llm/llm.py b/openhands/sdk/llm/llm.py index 4709dab1dd..fc2ad744c3 100644 --- a/openhands/sdk/llm/llm.py +++ b/openhands/sdk/llm/llm.py @@ -866,6 +866,9 @@ def format_messages_for_llm(self, messages: list[Message]) -> list[dict]: message.cache_enabled = self.is_caching_prompt_active() message.vision_enabled = self.vision_is_active() message.function_calling_enabled = self.is_function_calling_active() + message.extended_thinking_enabled = get_features( + self.model + ).supports_extended_thinking if "deepseek" in self.model or ( "kimi-k2-instruct" in self.model and "groq" in self.model ): @@ -1065,7 +1068,7 @@ def completion( self, messages: list[Message], tools: Sequence[ToolBase] | None = None, - return_metrics: bool = False, + _return_metrics: bool = False, add_security_risk_prediction: bool = False, **kwargs, ) -> LLMResponse: diff --git a/openhands/sdk/llm/message.py b/openhands/sdk/llm/message.py index 378d8eddcc..46bcbb547d 100644 --- a/openhands/sdk/llm/message.py +++ b/openhands/sdk/llm/message.py @@ -223,6 +223,7 @@ class Message(BaseModel): description="Intermediate reasoning/thinking content from reasoning models", ) # Anthropic-specific thinking blocks (not normalized by LiteLLM) + extended_thinking_enabled: bool = False thinking_blocks: Sequence[ThinkingBlock | RedactedThinkingBlock] = Field( default_factory=list, description="Raw Anthropic thinking blocks for extended thinking feature", @@ -294,7 +295,7 @@ def _list_serializer(self) -> dict[str, Any]: # Add thinking blocks first (for Anthropic extended thinking) # Only add thinking blocks for assistant messages - if self.role == "assistant": + if self.role == "assistant" and self.extended_thinking_enabled: thinking_blocks = list( self.thinking_blocks ) # Copy to avoid modifying original diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 500117c607..4db42aff78 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -52,7 +52,7 @@ def completion( self, messages: list[Message], tools: Sequence[ToolBase] | None = None, - return_metrics: bool = False, + _return_metrics: bool = False, add_security_risk_prediction: bool = False, **kwargs, ) -> LLMResponse: @@ -70,7 +70,7 @@ def completion( return active_llm.completion( messages=messages, tools=tools, - return_metrics=return_metrics, + _return_metrics=_return_metrics, add_security_risk_prediction=add_security_risk_prediction, **kwargs, ) diff --git a/openhands/sdk/llm/router/impl/dynamic.py b/openhands/sdk/llm/router/impl/dynamic.py index 4826b4c67e..ac444aa29e 100644 --- a/openhands/sdk/llm/router/impl/dynamic.py +++ b/openhands/sdk/llm/router/impl/dynamic.py @@ -30,7 +30,7 @@ class DynamicRouter(RouterLLM): router_name: str = "dynamic_router" manual_selection: str | None = None - def select_llm(self, messages: list[Message]) -> str: + def select_llm(self, messages: list[Message]) -> str: # noqa: ARG002 """ Select LLM based on manual selection or fallback to first available. @@ -46,9 +46,9 @@ def select_llm(self, messages: list[Message]) -> str: if self.manual_selection: return self.manual_selection - # Fallback to first available LLM + # Use the primary LLM if no manual selection if self.llms_for_routing: - return next(iter(self.llms_for_routing.keys())) + return self.PRIMARY_MODEL_KEY raise ValueError("No LLMs available for routing") From 19e2cd74d605a7b62492c6840ccf8c73efd1ffa9 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Thu, 16 Oct 2025 10:06:15 +0000 Subject: [PATCH 14/17] use gpt-5 --- .../01_standalone_sdk/25_llm_manual_switch.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/01_standalone_sdk/25_llm_manual_switch.py b/examples/01_standalone_sdk/25_llm_manual_switch.py index 2ad02d5002..c01937f244 100644 --- a/examples/01_standalone_sdk/25_llm_manual_switch.py +++ b/examples/01_standalone_sdk/25_llm_manual_switch.py @@ -31,9 +31,9 @@ api_key=SecretStr(api_key), ) -gpt_4o_llm = LLM( - service_id="gpt-4o", - model="litellm_proxy/openai/gpt-4o", +gpt_5_llm = LLM( + service_id="gpt-5", + model="litellm_proxy/openai/gpt-5-2025-08-07", base_url="https://llm-proxy.eval.all-hands.dev", api_key=SecretStr(api_key), ) @@ -42,7 +42,7 @@ service_id="dynamic-router", llms_for_routing={ "primary": claude_llm, - "gpt-4o": gpt_4o_llm, + "gpt-5": gpt_5_llm, }, # primary is the default ) @@ -85,14 +85,14 @@ def conversation_callback(event: Event): conversation.run() print("=" * 50) -print("Switching to GPT-4o...") +print("Switching to GPT-5...") -# Manually switch to GPT-4o -success = dynamic_router.switch_to_llm("gpt-4o") -print(f"GPT-4o switched successfully: {success}") +# Manually switch to GPT-5 +success = dynamic_router.switch_to_llm("gpt-5") +print(f"GPT-5 switched successfully: {success}") print(f"Current LLM: {dynamic_router.active_llm_identifier}") -# Interaction with GPT-4o +# Interaction with GPT-5 conversation.send_message( message=Message( role="user", @@ -119,7 +119,7 @@ def conversation_callback(event: Event): ) print(f"After deserialization - Current LLM: {dynamic_router.active_llm_identifier}") -assert dynamic_router.active_llm_identifier == "gpt-4o" +assert dynamic_router.active_llm_identifier == "gpt-5" print(f"Available LLMs: {list(dynamic_router.llms_for_routing.keys())}") # Continue conversation after persistence From 70d1ba750f7d2ccf2ff2c0ed61f91088c9a48164 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Thu, 16 Oct 2025 10:47:33 +0000 Subject: [PATCH 15/17] make llms_for_routing immutable --- openhands/sdk/llm/router/base.py | 14 ++++ openhands/sdk/llm/router/impl/dynamic.py | 97 +++--------------------- tests/sdk/llm/test_thinking_blocks.py | 2 + 3 files changed, 26 insertions(+), 87 deletions(-) diff --git a/openhands/sdk/llm/router/base.py b/openhands/sdk/llm/router/base.py index 4db42aff78..917d0dc459 100644 --- a/openhands/sdk/llm/router/base.py +++ b/openhands/sdk/llm/router/base.py @@ -1,8 +1,10 @@ from abc import abstractmethod from collections.abc import Sequence +from types import MappingProxyType from pydantic import ( Field, + field_serializer, field_validator, model_validator, ) @@ -48,6 +50,18 @@ def validate_llms_not_empty(cls, v): ) return v + @model_validator(mode="after") + def make_immutable(self): + object.__setattr__( + self, "llms_for_routing", MappingProxyType(self.llms_for_routing) + ) + return self + + @field_serializer("llms_for_routing") + def serialize_llms_for_routing(self, v): + # Convert MappingProxyType back to a serializable dict + return dict(v) + def completion( self, messages: list[Message], diff --git a/openhands/sdk/llm/router/impl/dynamic.py b/openhands/sdk/llm/router/impl/dynamic.py index ac444aa29e..634745afe6 100644 --- a/openhands/sdk/llm/router/impl/dynamic.py +++ b/openhands/sdk/llm/router/impl/dynamic.py @@ -7,7 +7,6 @@ from pydantic import model_validator -from openhands.sdk.llm import LLMBase from openhands.sdk.llm.message import Message from openhands.sdk.llm.router.base import RouterLLM from openhands.sdk.logger import get_logger @@ -18,11 +17,8 @@ class DynamicRouter(RouterLLM): """ - A RouterLLM that supports dynamic LLM creation and switching. - - Users can switch to entirely new LLMs without pre-configuring them. - The router maintains both ONE pre-configured LLM (primary) and - dynamically created ones. + A RouterLLM that supports manual LLM switching. + Users need to provide all LLMs they want to switch to at initialization. """ PRIMARY_MODEL_KEY: str = "primary" @@ -39,106 +35,33 @@ def select_llm(self, messages: list[Message]) -> str: # noqa: ARG002 Returns: Name of the selected LLM - - Raises: - ValueError: If no LLMs are available for routing """ if self.manual_selection: return self.manual_selection # Use the primary LLM if no manual selection - if self.llms_for_routing: - return self.PRIMARY_MODEL_KEY - - raise ValueError("No LLMs available for routing") + return self.PRIMARY_MODEL_KEY def switch_to_llm( self, identifier: str, - llm: LLMBase | None = None, ) -> bool: """ - Switch to an LLM, creating it dynamically if it doesn't exist. + Switch to an LLM by identifier. Args: identifier: Name to discriminate the LLM instance - llm: The LLM instance to switch to, can be None if switching to existing LLM Returns: True if switch was successful, False otherwise - - Example: - # Switch to existing LLM - router.switch_to_llm("gpt4") - - # Create and switch to new LLM - router.switch_to_llm( - "claude", - LLM( - service_id="claude", - model="claude-3-5-sonnet-20241022", - api_key="sk-...", - temperature=0.7 - ) - ) """ - try: - # If LLM already exists, just switch to it - if identifier in self.llms_for_routing: - self.manual_selection = identifier - self.active_llm_identifier = self.manual_selection - logger.info(f"Switched to existing LLM: {identifier}") - return True - - # Create new LLM dynamically - if not llm: - logger.error( - f"LLM instance must be specified to create new LLM: {identifier}" - ) - return False - - # Add to routing dict - self.llms_for_routing[identifier] = llm - - # Switch to the new LLM - self.manual_selection = identifier - self.active_llm_identifier = self.manual_selection - - logger.info(f"Created and switched to new LLM: {identifier} ({llm.model})") - return True - - except Exception as e: - logger.error(f"Failed to switch to LLM {identifier}: {e}") + if identifier not in self.llms_for_routing: + logger.warning(f"Failed to switch to LLM {identifier}: not found") return False - def remove_llm(self, identifier: str) -> bool: - """ - Remove a dynamically created LLM. - - Note: This only removes dynamically created LLMs, not pre-configured ones. - - Args: - identifier: Name of the LLM to remove - - Returns: - True if LLM was removed, False if it wasn't a dynamic LLM - """ - if identifier == self.PRIMARY_MODEL_KEY: - logger.warning(f"Cannot remove primary LLM: {identifier}") - return False - - if identifier in self.llms_for_routing: - self.llms_for_routing.pop(identifier, None) - - # Clear manual selection if it was the removed LLM - if self.manual_selection == identifier: - self.manual_selection = None - self.active_llm_identifier = None - - logger.info(f"Removed dynamic LLM: {identifier}") - return True - - logger.warning(f"Cannot remove LLM {identifier}: not existing") - return False + self.manual_selection = identifier + self.active_llm_identifier = self.manual_selection + logger.info(f"Switched to existing LLM: {identifier}") + return True @model_validator(mode="after") def _validate_llms_for_routing(self) -> "DynamicRouter": diff --git a/tests/sdk/llm/test_thinking_blocks.py b/tests/sdk/llm/test_thinking_blocks.py index 888311ba6f..59fc9b20c2 100644 --- a/tests/sdk/llm/test_thinking_blocks.py +++ b/tests/sdk/llm/test_thinking_blocks.py @@ -173,6 +173,7 @@ def test_message_list_serializer_with_thinking_blocks(): role="assistant", content=[TextContent(text="The answer is 42.")], thinking_blocks=[thinking_block], + extended_thinking_enabled=True, ) serialized = message._list_serializer() @@ -242,6 +243,7 @@ def test_multiple_thinking_blocks(): role="assistant", content=[TextContent(text="Conclusion")], thinking_blocks=thinking_blocks, + extended_thinking_enabled=True, ) assert len(message.thinking_blocks) == 2 From 5c8a0900b8213c1f45eda867ab1abc360335066b Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Thu, 16 Oct 2025 11:06:34 +0000 Subject: [PATCH 16/17] fix router tests --- tests/sdk/llm/test_dynamic_router.py | 193 ++++++--------------------- 1 file changed, 41 insertions(+), 152 deletions(-) diff --git a/tests/sdk/llm/test_dynamic_router.py b/tests/sdk/llm/test_dynamic_router.py index e0138107ff..d74be48672 100644 --- a/tests/sdk/llm/test_dynamic_router.py +++ b/tests/sdk/llm/test_dynamic_router.py @@ -34,9 +34,13 @@ def test_default_selection(self): initial_llm = LLM( model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) + second_llm = LLM( + model="gpt-4o", api_key=SecretStr("test-key2"), service_id="agent2" + ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": initial_llm} + service_id="test_router", + llms_for_routing={"primary": initial_llm, "secondary": second_llm}, ) selected = router.select_llm([]) @@ -59,37 +63,8 @@ def test_manual_selection(self): assert router.manual_selection == "llm2" assert router.select_llm([]) == "llm2" - def test_dynamic_llm_creation(self): - """Test creating new LLMs dynamically.""" - initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" - ) - - router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": initial_llm} - ) - - # Create new LLM dynamically - claude_llm = LLM( - service_id="claude", - model="claude-3-5-sonnet-20241022", - api_key=SecretStr("claude-key"), - temperature=0.7, - ) - success = router.switch_to_llm( - "claude", - llm=claude_llm, - ) - - assert success is True - assert router.manual_selection == "claude" - assert "claude" in router.llms_for_routing - assert router.llms_for_routing["claude"].model == "claude-3-5-sonnet-20241022" - assert router.llms_for_routing["claude"].temperature == 0.7 - assert router.select_llm([]) == "claude" - - def test_dynamic_llm_creation_without_model(self): - """Test that creating LLM without model fails.""" + def test_switch_to_non_existent_model(self): + """Test that switching to a non-existent model fails.""" # Create with a minimal dummy LLM to satisfy base class validation dummy_llm = LLM( model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" @@ -108,9 +83,15 @@ def test_get_available_llms(self): initial_llm = LLM( model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) + claude_llm = LLM( + service_id="claude", + model="claude-3-5-sonnet-20241022", + api_key=SecretStr("claude-key"), + ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": initial_llm} + service_id="test_router", + llms_for_routing={"primary": initial_llm, "claude": claude_llm}, ) # Initially only pre-configured LLM @@ -118,14 +99,8 @@ def test_get_available_llms(self): assert available["primary"].model == "gpt-4o-mini" # Add dynamic LLM - claude_llm = LLM( - service_id="claude", - model="claude-3-5-sonnet-20241022", - api_key=SecretStr("claude-key"), - ) router.switch_to_llm( "claude", - llm=claude_llm, ) available = router.llms_for_routing @@ -133,66 +108,26 @@ def test_get_available_llms(self): assert available["primary"].model == "gpt-4o-mini" assert available["claude"].model == "claude-3-5-sonnet-20241022" - def test_remove_dynamic_llm(self): - """Test removing dynamically created LLMs.""" + def test_get_current_llm_name(self): + """Test getting current LLM name.""" dummy_llm = LLM( model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" ) - router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": dummy_llm} - ) - - # Add dynamic LLM - claude_llm = LLM( + claude = LLM( service_id="claude", model="claude-3-5-sonnet-20241022", api_key=SecretStr("claude-key"), ) - router.switch_to_llm( - "claude", - llm=claude_llm, - ) - assert "claude" in router.llms_for_routing - - # Remove it - success = router.remove_llm("claude") - assert success is True - assert "claude" not in router.llms_for_routing - assert router.manual_selection is None - - def test_remove_non_dynamic_llm(self): - """Test that removing pre-configured LLMs fails.""" - initial_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" - ) - - router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": initial_llm} - ) - success = router.remove_llm("primary") - assert success is False - assert "primary" in router.llms_for_routing - - def test_get_current_llm_name(self): - """Test getting current LLM name.""" - dummy_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" - ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": dummy_llm} + service_id="test_router", + llms_for_routing={"primary": dummy_llm, "claude": claude}, ) assert router.active_llm_identifier is None - claude = LLM( - service_id="claude", - model="claude-3-5-sonnet-20241022", - api_key=SecretStr("claude-key"), - ) router.switch_to_llm( "claude", - llm=claude, ) assert router.active_llm_identifier == "claude" @@ -201,95 +136,49 @@ def test_serialization_with_dynamic_llms(self): initial_llm = LLM( model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) - - router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": initial_llm} - ) - - # Add dynamic LLMs claude_llm = LLM( service_id="claude", model="claude-3-5-sonnet-20241022", api_key=SecretStr("claude-key"), ) - router.switch_to_llm( - "claude", - llm=claude_llm, - ) gemini_llm = LLM( service_id="gemini", model="gemini-1.5-pro", api_key=SecretStr("gemini-key"), ) - router.switch_to_llm( - "gemini", - llm=gemini_llm, - ) - - # Serialize - serialized = router.model_dump_json(exclude_none=True) - data = json.loads(serialized) - assert data["manual_selection"] == "gemini" # Last selected - - def test_ensure_llm_exists(self): - """Test _ensure_llm_exists recreates LLMs from config.""" - dummy_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" - ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": dummy_llm} + service_id="test_router", + llms_for_routing={ + "primary": initial_llm, + "claude": claude_llm, + "gemini": gemini_llm, + }, ) - # Add dynamic LLM - claude_llm = LLM( - service_id="claude", - model="claude-3-5-sonnet-20241022", - api_key=SecretStr("claude-key"), - ) + # Add dynamic LLMs router.switch_to_llm( "claude", - llm=claude_llm, ) - - # Remove from routing table - del router.llms_for_routing["claude"] - assert "claude" not in router.llms_for_routing - - def test_no_llms_available_error(self): - """Test error when no LLMs are available.""" - dummy_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" - ) - router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": dummy_llm} + router.switch_to_llm( + "gemini", ) - # Remove all LLMs to test error condition - router.llms_for_routing.clear() + # Serialize + serialized = router.model_dump_json(exclude_none=True) + data = json.loads(serialized) - with pytest.raises(ValueError, match="No LLMs available for routing"): - router.select_llm([]) + assert data["manual_selection"] == "gemini" # Last selected - def test_switch_to_existing_dynamic_llm_from_config(self): - """Test switching to a dynamic LLM that exists only in config.""" - dummy_llm = LLM( - model="gpt-4o-mini", api_key=SecretStr("dummy"), service_id="dummy" + def test_manually_modify_llms_for_routing_raise_error(self): + """Test that manually modifying llms_for_routing is not allowed.""" + initial_llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), service_id="agent" ) router = DynamicRouter( - service_id="test_router", llms_for_routing={"primary": dummy_llm} - ) - - # Manually add config without creating LLM instance - router.llms_for_routing["claude"] = LLM( - model="claude-3-5-sonnet-20241022", - service_id="dynamic_claude", - api_key=SecretStr("claude-key"), + service_id="test_router", llms_for_routing={"primary": initial_llm} ) - - # Switch to it should recreate from config - success = router.switch_to_llm("claude") - assert success is True - assert "claude" in router.llms_for_routing - assert router.manual_selection == "claude" - assert router.select_llm([]) == "claude" + with pytest.raises(TypeError): + router.llms_for_routing["new_llm"] = LLM( + model="gpt-4o", api_key=SecretStr("test-key2"), service_id="agent2" + ) From 49b72aae14a1d90ec6e5f938e5792c4255494187 Mon Sep 17 00:00:00 2001 From: Hoang Tran Date: Thu, 16 Oct 2025 12:44:25 +0000 Subject: [PATCH 17/17] fix tests --- openhands/sdk/agent/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openhands/sdk/agent/base.py b/openhands/sdk/agent/base.py index fcd3fbd803..4ea899940a 100644 --- a/openhands/sdk/agent/base.py +++ b/openhands/sdk/agent/base.py @@ -3,6 +3,7 @@ import sys from abc import ABC, abstractmethod from collections.abc import Generator, Iterable +from types import MappingProxyType from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field, PrivateAttr @@ -376,8 +377,8 @@ def _walk(obj: Any) -> Iterable[LLM]: out.extend(_walk(val)) return out - # Built-in containers - if isinstance(obj, dict): + # Mapping-like objects + if isinstance(obj, dict) or isinstance(obj, MappingProxyType): out: list[LLM] = [] for k, v in obj.items(): out.extend(_walk(k))