-
Notifications
You must be signed in to change notification settings - Fork 260
Add LLM profile switch tool #3188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
d18c898
Add LLM profile switch tool
openhands-agent 8fe34a8
Merge branch 'main' into agent-switch-llm-tool
neubig 177edd2
docs(sdk): add switch llm tool example
openhands-agent f7ced90
Merge branch 'main' into agent-switch-llm-tool
neubig 944e4c9
Address switch LLM review feedback
openhands-agent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
176 changes: 176 additions & 0 deletions
176
openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| 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: | ||
|
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, | ||
| ), | ||
| ) | ||
| ] | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.