Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
201ecb3
add draft dynamic router impl
ryanhoangt Sep 26, 2025
536d6d7
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Sep 29, 2025
3bcee98
update routing example with persistence
ryanhoangt Sep 29, 2025
4d94f82
implement resolve_diff_from_deserialized for baserouter
ryanhoangt Oct 1, 2025
ebf954a
working persistence for router
ryanhoangt Oct 1, 2025
16792ed
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Oct 8, 2025
3872337
fix pre-commit
ryanhoangt Oct 8, 2025
dad5ac4
store active_llm_identifier str instead of LLM instance
ryanhoangt Oct 8, 2025
50a5d98
working
ryanhoangt Oct 8, 2025
a8843b3
fix tests and cleanup
ryanhoangt Oct 8, 2025
5103237
rename llm_type to kind
ryanhoangt Oct 8, 2025
12acf40
add LLMBase class and refactor LLM to LLMBase
ryanhoangt Oct 9, 2025
12229e2
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Oct 10, 2025
61ecb3b
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Oct 13, 2025
d700240
rename
ryanhoangt Oct 13, 2025
74c4241
cleanup
ryanhoangt Oct 14, 2025
388d1a5
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Oct 16, 2025
6ea9ec3
fix thinking blocks and simplify example
ryanhoangt Oct 16, 2025
19e2cd7
use gpt-5
ryanhoangt Oct 16, 2025
a3d74c9
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Oct 16, 2025
70d1ba7
make llms_for_routing immutable
ryanhoangt Oct 16, 2025
5c8a090
fix router tests
ryanhoangt Oct 16, 2025
49b72aa
fix tests
ryanhoangt Oct 16, 2025
95b9226
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Oct 20, 2025
b594948
Merge branch 'main' into ht/switch-llm-manually
ryanhoangt Oct 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion examples/01_standalone_sdk/19_llm_routing.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import uuid

from pydantic import SecretStr

Expand Down Expand Up @@ -56,8 +57,14 @@ def conversation_callback(event: Event):
llm_messages.append(event.to_llm_message())


conversation_id = uuid.uuid4()

conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=os.getcwd()
agent=agent,
callbacks=[conversation_callback],
conversation_id=conversation_id,
workspace=os.getcwd(),
persistence_dir="./.conversations",
)

conversation.send_message(
Expand All @@ -81,6 +88,24 @@ def conversation_callback(event: Event):
)
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],
persistence_dir="./.conversations",
conversation_id=conversation_id,
)

conversation.send_message(
message=Message(
role="user",
Expand Down
158 changes: 158 additions & 0 deletions examples/01_standalone_sdk/25_llm_manual_switch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import os
import uuid

from pydantic import SecretStr

from openhands.sdk import (
LLM,
Agent,
Conversation,
Event,
LLMConvertibleEvent,
Message,
TextContent,
get_logger,
)
from openhands.sdk.llm.router.impl.dynamic import DynamicRouter
from openhands.tools.preset.default import get_default_tools


logger = get_logger(__name__)

# Configure initial LLM
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."

# Create DynamicRouter with 2 initial LLMs
claude_llm = LLM(
service_id="agent-initial",
model="litellm_proxy/anthropic/claude-sonnet-4-5-20250929",
base_url="https://llm-proxy.eval.all-hands.dev",
api_key=SecretStr(api_key),
)

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),
)

dynamic_router = DynamicRouter(
service_id="dynamic-router",
llms_for_routing={
"primary": claude_llm,
"gpt-5": gpt_5_llm,
}, # primary is the default
)

# Tools
cwd = os.getcwd()
tools = get_default_tools()

# Agent with dynamic router
agent = Agent(llm=dynamic_router, tools=tools)

llm_messages = [] # collect raw LLM messages


def conversation_callback(event: Event):
if isinstance(event, LLMConvertibleEvent):
llm_messages.append(event.to_llm_message())


# Set up conversation with persistence for serialization demo
conversation_id = uuid.uuid4()

conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
conversation_id=conversation_id,
workspace=os.getcwd(),
persistence_dir="./.conversations",
)

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 - primary LLM
conversation.send_message(
message=Message(
role="user",
content=[TextContent(text="Hi there!")],
)
)
conversation.run()

print("=" * 50)
print("Switching to GPT-5...")

# 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-5
conversation.send_message(
message=Message(
role="user",
content=[TextContent(text="Who trained you as an LLM?")],
)
)
conversation.run()


# Show current state before serialization
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

# Recreate conversation from persistence
print("Recreating conversation from persistence...")
conversation = Conversation(
agent=agent,
callbacks=[conversation_callback],
conversation_id=conversation_id,
persistence_dir="./.conversations",
)

print(f"After deserialization - Current LLM: {dynamic_router.active_llm_identifier}")
assert dynamic_router.active_llm_identifier == "gpt-5"
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="What did we talk about earlier?")],
)
)
conversation.run()

# Switch back to primary model for complex task
print("Switching back to claude for complex reasoning...")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how would the user know in advance that they'll need to switch?

Copy link
Collaborator

@xingyaoww xingyaoww Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we were designing the DynamicRouter as a way for the user to "manually select model name from drop down"?

And further down the road, we should by default initialize LLM as DynamicRouter in our GUI/CLI if we have UI for switching model on the fly


dynamic_router.switch_to_llm("primary")
print(f"Switched to 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...")


print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
print(f"Message {i}: {str(message)[:200]}")
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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


Expand Down Expand Up @@ -276,7 +276,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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 AgentExecutionStatus, ConversationState
Expand Down Expand Up @@ -255,7 +255,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.

Expand Down
4 changes: 2 additions & 2 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,7 +176,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"
)

Expand Down
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from openhands.sdk.llm import (
LLM,
ImageContent,
LLMBase,
LLMRegistry,
Message,
RedactedThinkingBlock,
Expand Down Expand Up @@ -56,6 +57,7 @@
__version__ = "0.0.0" # fallback for editable/unbuilt environments

__all__ = [
"LLMBase",
"LLM",
"LLMRegistry",
"ConversationStats",
Expand Down
7 changes: 4 additions & 3 deletions openhands-sdk/openhands/sdk/agent/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,7 +12,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
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
Expand All @@ -37,7 +38,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
arbitrary_types_allowed=True,
)

llm: LLM = Field(
llm: LLMBase = Field(
...,
description="LLM configuration for the agent.",
examples=[
Expand Down Expand Up @@ -377,7 +378,7 @@ def _walk(obj: object) -> Iterable[LLM]:
return model_out

# Built-in containers
if isinstance(obj, dict):
if isinstance(obj, dict) or isinstance(obj, MappingProxyType):
dict_out: list[LLM] = []
for k, v in obj.items():
dict_out.extend(_walk(k))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions openhands-sdk/openhands/sdk/conversation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, content_to_str
from openhands.sdk.security.confirmation_policy import (
ConfirmationPolicyBase,
Expand Down Expand Up @@ -123,7 +123,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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,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 (
Expand Down Expand Up @@ -366,7 +366,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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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,
Expand Down Expand Up @@ -601,7 +601,7 @@ def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None:
self._client, "POST", f"/api/conversations/{self._id}/secrets", json=payload
)

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:
Expand Down
Loading
Loading