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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions examples/01_standalone_sdk/49_switch_llm_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Switch LLM profiles with the built-in switch_llm tool.

This example creates two temporary LLM profiles, starts the conversation on a
GPT profile, asks the agent to call the switch_llm tool, and then verifies that
future model calls use the Claude profile.

Usage:
LLM_API_KEY=... LLM_BASE_URL=https://llm-proxy.app.all-hands.dev \
uv run python examples/01_standalone_sdk/49_switch_llm_tool.py
"""

import os

from pydantic import SecretStr

from openhands.sdk import LLM, Agent, LocalConversation
from openhands.sdk.llm.llm_profile_store import LLMProfileStore


GPT_PROFILE = "example-gpt55"
CLAUDE_PROFILE = "example-claude"
DEFAULT_BASE_URL = "https://llm-proxy.app.all-hands.dev"
GPT_MODEL = "openai/gpt-5.5"
CLAUDE_MODEL = "openai/prod/claude-sonnet-4-5-20250929"

api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."
base_url = os.getenv("LLM_BASE_URL", DEFAULT_BASE_URL)

store = LLMProfileStore()
store.save(
GPT_PROFILE,
LLM(
model=GPT_MODEL,
api_key=SecretStr(api_key),
base_url=base_url,
usage_id="gpt55",
),
include_secrets=True,
)
store.save(
CLAUDE_PROFILE,
LLM(
model=CLAUDE_MODEL,
api_key=SecretStr(api_key),
base_url=base_url,
usage_id="claude",
),
include_secrets=True,
)

try:
initial_llm = store.load(GPT_PROFILE)
agent = Agent(
llm=initial_llm,
tools=[],
include_default_tools=["FinishTool", "SwitchLLMTool"],
)
conversation = LocalConversation(agent=agent, workspace=os.getcwd())

print(f"Starting model: {conversation.agent.llm.model}")
conversation.send_message(
f"Call the switch_llm tool now with profile_name={CLAUDE_PROFILE!r}. "
"After the tool succeeds, answer in one short sentence naming the "
"active model value from the tool observation exactly."
)
conversation.run()

active_model = conversation.agent.llm.model
print(f"Active model after tool switch: {active_model}")
assert active_model == CLAUDE_MODEL

for usage_id, metrics in conversation.state.stats.usage_to_metrics.items():
print(f" [{usage_id}] cost=${metrics.accumulated_cost:.6f}")

combined = conversation.state.stats.get_combined_metrics()
print(f"Total cost: ${combined.accumulated_cost:.6f}")
print(f"EXAMPLE_COST: {combined.accumulated_cost}")
finally:
store.delete(GPT_PROFILE)
store.delete(CLAUDE_PROFILE)
17 changes: 14 additions & 3 deletions openhands-sdk/openhands/sdk/tool/builtins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
InvokeSkillObservation,
InvokeSkillTool,
)
from openhands.sdk.tool.builtins.switch_llm import (
SwitchLLMAction,
SwitchLLMExecutor,
SwitchLLMObservation,
SwitchLLMTool,
)
from openhands.sdk.tool.builtins.think import (
ThinkAction,
ThinkExecutor,
Expand All @@ -30,12 +36,13 @@
# AgentSkills-format skill is loaded (see BUILT_IN_TOOL_CLASSES below).
BUILT_IN_TOOLS = [FinishTool, ThinkTool]

# Map of built-in tool class names to their classes. Includes
# `InvokeSkillTool` so it can be resolved by name from `include_default_tools`
# and the conditional wiring in `Agent._initialize`.
# Map of built-in tool class names to their classes. Includes optional built-ins
# so they can be resolved by name from `include_default_tools` and the
# conditional wiring in `Agent._initialize`.
BUILT_IN_TOOL_CLASSES = {
**{tool.__name__: tool for tool in BUILT_IN_TOOLS},
InvokeSkillTool.__name__: InvokeSkillTool,
SwitchLLMTool.__name__: SwitchLLMTool,
}

__all__ = [
Expand All @@ -49,6 +56,10 @@
"InvokeSkillAction",
"InvokeSkillObservation",
"InvokeSkillExecutor",
"SwitchLLMTool",
"SwitchLLMAction",
"SwitchLLMObservation",
"SwitchLLMExecutor",
"ThinkTool",
"ThinkAction",
"ThinkObservation",
Expand Down
176 changes: 176 additions & 0 deletions openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING, Self

from pydantic import Field
from rich.text import Text

from openhands.sdk.llm.llm_profile_store import LLMProfileStore
from openhands.sdk.tool.tool import (
Action,
Observation,
ToolAnnotations,
ToolDefinition,
ToolExecutor,
)


if TYPE_CHECKING:
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
from openhands.sdk.conversation.state import ConversationState


class SwitchLLMAction(Action):
"""Action for switching this conversation to a saved LLM profile."""

profile_name: str = Field(
description="Name of the saved LLM profile to use for future agent steps."
)
reason: str = Field(
description="Brief reason why this profile is a better fit for the next step."
)

@property
def visualize(self) -> Text:
content = Text()
content.append("Switch LLM profile: ", style="bold magenta")
content.append(self.profile_name)
if self.reason:
content.append("\nReason: ", style="bold")
content.append(self.reason)
return content


class SwitchLLMObservation(Observation):
"""Observation returned after switching this conversation's LLM profile."""

profile_name: str = Field(
description="Name of the profile that the tool attempted to activate."
)
reason: str | None = Field(
default=None,
description="Reason the agent gave for attempting this LLM profile switch.",
)
active_model: str | None = Field(
default=None,
description="Model configured by the activated profile, when available.",
)

@property
def visualize(self) -> Text:
content = Text()
if self.is_error:
content.append("Failed to switch LLM profile", style="bold red")
else:
content.append("Switched LLM profile", style="bold green")
Comment thread
neubig marked this conversation as resolved.
content.append(f": {self.profile_name}")
if self.active_model:
content.append(f" ({self.active_model})")
if self.reason:
content.append("\nReason: ", style="bold")
content.append(self.reason)
return content


_DESCRIPTION_TEMPLATE = (
"Switch this conversation to a saved LLM profile.\n\n"
"Use this when another available profile is better suited for the next step. "
"The current tool call is still executed by the current model; the switch "
"takes effect on the next LLM call.\n\n"
"Available LLM profiles:\n"
"{profiles}\n\n"
"Provide the profile_name exactly as listed and include a concise reason "
"for the switch."
)


def _format_profiles(profile_names: Sequence[str]) -> str:
if not profile_names:
return "- No saved LLM profiles are currently available."
return "\n".join(f"- {name}" for name in sorted(profile_names))


class SwitchLLMExecutor(ToolExecutor):
def __call__(
self,
action: SwitchLLMAction,
conversation: "LocalConversation | None" = None,
) -> SwitchLLMObservation:
if conversation is None:
return SwitchLLMObservation.from_text(
text="Cannot switch LLM profile without an active conversation.",
is_error=True,
profile_name=action.profile_name,
reason=action.reason,
)

try:
Comment thread
neubig marked this conversation as resolved.
conversation.switch_profile(action.profile_name)
except FileNotFoundError:
return SwitchLLMObservation.from_text(
text=f"LLM profile '{action.profile_name}' was not found.",
is_error=True,
profile_name=action.profile_name,
reason=action.reason,
)
except ValueError as exc:
return SwitchLLMObservation.from_text(
text=str(exc),
is_error=True,
profile_name=action.profile_name,
reason=action.reason,
)
except Exception as exc:
return SwitchLLMObservation.from_text(
text=(
f"Failed to switch LLM profile '{action.profile_name}': "
f"{type(exc).__name__}: {exc}"
),
is_error=True,
profile_name=action.profile_name,
reason=action.reason,
)

active_model = conversation.agent.llm.model
return SwitchLLMObservation.from_text(
text=(
f"Switched LLM profile to '{action.profile_name}' "
f"with active model '{active_model}'. Reason: {action.reason} "
"Future agent steps will use this profile."
),
profile_name=action.profile_name,
reason=action.reason,
active_model=active_model,
)


class SwitchLLMTool(ToolDefinition[SwitchLLMAction, SwitchLLMObservation]):
"""Tool for switching a conversation to a saved LLM profile."""

@classmethod
def create(
cls,
conv_state: "ConversationState | None" = None, # noqa: ARG003
**params,
) -> Sequence[Self]:
if params:
raise ValueError("SwitchLLMTool doesn't accept parameters")

profile_names = [
name.removesuffix(".json") for name in LLMProfileStore().list()
]
return [
cls(
description=_DESCRIPTION_TEMPLATE.format(
profiles=_format_profiles(profile_names)
),
action_type=SwitchLLMAction,
observation_type=SwitchLLMObservation,
executor=SwitchLLMExecutor(),
annotations=ToolAnnotations(
readOnlyHint=False,
destructiveHint=False,
idempotentHint=False,
openWorldHint=False,
),
)
]
Loading
Loading