-
Notifications
You must be signed in to change notification settings - Fork 152
feat(llm): add LLM profiles #1843
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
base: main
Are you sure you want to change the base?
Changes from all commits
3590c43
9b1e3db
21efefe
46ca1b7
5efdaee
9cbf67f
dfab517
441eb25
e7cd039
269610a
d0ab952
f74d050
df308fb
4d293db
7d1a525
ec45ed5
1566df4
8f8b5b9
a3efa6e
17617aa
9134aa1
36ab580
cea6a0d
12eec55
03b4600
acf67e3
218728e
75e8ecd
8511524
1f3adab
96ba8e9
142faee
82138dd
b6511a9
f5404b6
85bc698
ba4bd50
99a422c
5c52fa5
b69db09
5dc94c1
69d3a7d
61f5b77
2381da7
b2f80d3
8a95dac
0aa1164
744f171
24d59bd
a4d6cd4
075c9b2
ab3a265
82549cc
f400d7d
a112ddc
60bfbb2
0d01065
bc94774
ad07b05
2464633
db10002
ce31e79
1fe3929
8625ff2
95d94c3
67ab2c0
fab1d57
926fb90
5676592
b2ea371
2aa320d
b5a01ad
90257c5
7a83b34
9530155
69e259b
c6f5db7
9ecab27
cd3ab89
9859f21
23cb159
dcc83f5
6807c99
498fd80
1383003
4b34b5f
5e3caab
66411b3
897392c
1c1be88
affa51c
f496755
8e10e46
7f19ae3
ffaeb7b
f8b113a
bd7c2ce
f754097
813645a
a742c61
63b30c5
a625b95
86ae7cc
46b36ee
449e790
eacd4e3
e0dc785
f3b58c1
572e719
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| """Create and use an LLM profile with :class:`LLMRegistry`. | ||
|
|
||
| Run with:: | ||
|
|
||
| uv run python examples/01_standalone_sdk/36_llm_profiles.py | ||
|
|
||
| Profiles are stored under ``$LLM_PROFILES_DIR/<name>.json`` when the env var is | ||
| set, otherwise ``~/.openhands/llm-profiles/<name>.json``. | ||
|
|
||
| Set ``LLM_PROFILE_NAME`` to choose which profile file to load. | ||
|
|
||
| Notes on credentials: | ||
| - New profiles include API keys by default when saved | ||
| - To omit secrets on disk, pass include_secrets=False to LLMRegistry.save_profile | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
| from pydantic import SecretStr | ||
|
|
||
| from openhands.sdk import ( | ||
| LLM, | ||
| Agent, | ||
| Conversation, | ||
| LLMRegistry, | ||
| Tool, | ||
| ) | ||
| from openhands.tools.terminal import TerminalTool | ||
|
|
||
|
|
||
| PROFILE_NAME = os.getenv("LLM_PROFILE_NAME", "gpt-5-mini") | ||
|
|
||
|
|
||
| def ensure_profile_exists(registry: LLMRegistry, name: str) -> None: | ||
| """Create a starter profile in the default directory when missing.""" | ||
|
|
||
| if name in registry.list_profiles(): | ||
| return | ||
|
|
||
| model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") | ||
| base_url = os.getenv("LLM_BASE_URL") | ||
| api_key = os.getenv("LLM_API_KEY") | ||
|
|
||
| profile_defaults = LLM( | ||
| usage_id="agent", | ||
| model=model, | ||
| base_url=base_url, | ||
| api_key=SecretStr(api_key) if api_key else None, | ||
| temperature=0.2, | ||
| max_output_tokens=4096, | ||
| ) | ||
| path = registry.save_profile(name, profile_defaults) | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| print(f"Created profile '{name}' at {path}") | ||
|
|
||
|
|
||
| def load_profile(registry: LLMRegistry, name: str) -> LLM: | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """Load profile and merge credentials from environment if needed. | ||
|
|
||
| Note: Profiles should be saved without secrets (include_secrets=False) | ||
| and credentials provided via environment variables for better security. | ||
| """ | ||
| llm = registry.load_profile(name) | ||
| # If profile was saved without secrets, allow providing API key via env var | ||
| if llm.api_key is None: | ||
| api_key = os.getenv("LLM_API_KEY") | ||
| if api_key: | ||
| llm = llm.model_copy(update={"api_key": SecretStr(api_key)}) | ||
| return llm | ||
|
|
||
|
|
||
| registry = LLMRegistry() | ||
| ensure_profile_exists(registry, PROFILE_NAME) | ||
|
|
||
| llm = load_profile(registry, PROFILE_NAME) | ||
|
|
||
| tools = [Tool(name=TerminalTool.name)] | ||
| agent = Agent(llm=llm, tools=tools) | ||
|
|
||
| workspace_dir = Path(os.getcwd()) | ||
| summary_path = workspace_dir / "summary_readme.md" | ||
| if summary_path.exists(): | ||
| summary_path.unlink() | ||
|
|
||
| persistence_root = workspace_dir / ".conversations_llm_profiles" | ||
| conversation = Conversation( | ||
| agent=agent, | ||
| workspace=str(workspace_dir), | ||
| persistence_dir=str(persistence_root), | ||
| visualizer=None, | ||
| ) | ||
|
|
||
| conversation.send_message( | ||
| "Read README.md in this workspace, create a concise summary in " | ||
| "summary_readme.md (overwrite it if it exists), and respond with " | ||
| "SUMMARY_READY when the file is written." | ||
| ) | ||
| conversation.run() | ||
|
|
||
| if summary_path.exists(): | ||
| print(f"summary_readme.md written to {summary_path}") | ||
| else: | ||
| print("summary_readme.md not found after first run") | ||
|
|
||
| conversation.send_message( | ||
| "Thanks! Delete summary_readme.md from the workspace and respond with " | ||
| "SUMMARY_REMOVED once it is gone." | ||
| ) | ||
| conversation.run() | ||
|
|
||
| if summary_path.exists(): | ||
| print("summary_readme.md still present after deletion request") | ||
| else: | ||
| print("summary_readme.md removed") | ||
|
|
||
| persistence_dir = conversation.state.persistence_dir | ||
| if persistence_dir is None: | ||
| raise RuntimeError("Conversation did not persist base state to disk") | ||
|
|
||
| base_state_path = Path(persistence_dir) / "base_state.json" | ||
| state_payload = json.loads(base_state_path.read_text()) | ||
| llm_entry = state_payload.get("agent", {}).get("llm", {}) | ||
| profile_in_state = llm_entry.get("profile_id") | ||
| kind_in_state = llm_entry.get("kind") | ||
| print(f"Profile recorded in base_state.json: {kind_in_state} / {profile_in_state}") | ||
| if kind_in_state != "profile_ref" or profile_in_state != PROFILE_NAME: | ||
| print( | ||
| "Warning: base_state.json did not persist the expected profile_ref payload." | ||
| " This likely means your runtime LLM did not have profile_id set," | ||
| " or persistence was configured differently." | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "model": "litellm_proxy/openai/gpt-5-mini", | ||
| "base_url": "https://llm-proxy.eval.all-hands.dev", | ||
| "temperature": 0.2, | ||
| "max_output_tokens": 4096, | ||
| "usage_id": "agent" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |
| from collections.abc import Sequence | ||
| from enum import Enum | ||
| from pathlib import Path | ||
| from typing import Any, Self | ||
| from typing import TYPE_CHECKING, Any, Self | ||
|
|
||
| from pydantic import Field, PrivateAttr, model_validator | ||
|
|
||
|
|
@@ -18,6 +18,12 @@ | |
| from openhands.sdk.event.base import Event | ||
| from openhands.sdk.io import FileStore, InMemoryFileStore, LocalFileStore | ||
| from openhands.sdk.logger import get_logger | ||
|
|
||
|
|
||
| if TYPE_CHECKING: | ||
| from openhands.sdk.llm.llm_registry import LLMRegistry | ||
|
|
||
|
|
||
| from openhands.sdk.security.analyzer import SecurityAnalyzerBase | ||
| from openhands.sdk.security.confirmation_policy import ( | ||
| ConfirmationPolicyBase, | ||
|
|
@@ -181,8 +187,15 @@ def _save_base_state(self, fs: FileStore) -> None: | |
| "redacted and lost on restore. Consider providing a cipher to " | ||
| "preserve secrets." | ||
| ) | ||
| payload = self.model_dump_json(exclude_none=True, context=context) | ||
| fs.write(BASE_STATE, payload) | ||
| payload = self.model_dump( | ||
| mode="json", | ||
| exclude_none=True, | ||
| context={**(context or {}), "persist_profile_ref": True}, | ||
| ) | ||
| if self.agent.llm.profile_id and self.agent.llm.profile_ref: | ||
| payload["agent"]["llm"] = self.agent.llm.to_profile_ref() | ||
|
|
||
| fs.write(BASE_STATE, json.dumps(payload)) | ||
|
|
||
| # ===== Factory: open-or-create (no load/save methods needed) ===== | ||
| @classmethod | ||
|
|
@@ -194,6 +207,7 @@ def create( | |
| persistence_dir: str | None = None, | ||
| max_iterations: int = 500, | ||
| stuck_detection: bool = True, | ||
| llm_registry: "LLMRegistry | None" = None, | ||
| cipher: Cipher | None = None, | ||
| ) -> "ConversationState": | ||
| """Create a new conversation state or resume from persistence. | ||
|
|
@@ -211,13 +225,19 @@ def create( | |
| history), but all other configuration can be freely changed: LLM, | ||
| agent_context, condenser, system prompts, etc. | ||
|
|
||
| When conversation state is persisted with LLM profile references (instead | ||
| of inlined credentials), pass an ``llm_registry`` so profile IDs can be | ||
| expanded during restore. | ||
|
|
||
| Args: | ||
| id: Unique conversation identifier | ||
| agent: The Agent to use (tools must match persisted on restore) | ||
| workspace: Working directory for agent operations | ||
| persistence_dir: Directory for persisting state and events | ||
| max_iterations: Maximum iterations per run | ||
| stuck_detection: Whether to enable stuck detection | ||
| llm_registry: Optional registry used to expand profile references when | ||
| conversations persist profile IDs instead of inline credentials. | ||
| cipher: Optional cipher for encrypting/decrypting secrets in | ||
| persisted state. If provided, secrets are encrypted when | ||
| saving and decrypted when loading. If not provided, secrets | ||
|
|
@@ -241,35 +261,69 @@ def create( | |
| except FileNotFoundError: | ||
| base_text = None | ||
|
|
||
| context: dict[str, object] = {} | ||
| registry = llm_registry | ||
| if registry is None: | ||
| from openhands.sdk.llm.llm_registry import LLMRegistry | ||
|
|
||
| registry = LLMRegistry() | ||
| context["llm_registry"] = registry | ||
|
|
||
| # Ensure we have a registry available during both dump and validate. | ||
| # | ||
| # We do NOT implicitly write profile files here. Instead, persistence will | ||
| # store a profile reference only when the runtime LLM already has an | ||
| # explicit ``profile_id``. | ||
|
|
||
| # ---- Resume path ---- | ||
| if base_text: | ||
| # Use cipher context for decrypting secrets if provided | ||
| context = {"cipher": cipher} if cipher else None | ||
| state = cls.model_validate(json.loads(base_text), context=context) | ||
| base_payload = json.loads(base_text) | ||
| # Add cipher context for decrypting secrets if provided | ||
| if cipher: | ||
| context["cipher"] = cipher | ||
|
|
||
| # Restore the conversation with the same id | ||
| if state.id != id: | ||
| persisted_id = ConversationID(base_payload.get("id")) | ||
| if persisted_id != id: | ||
| raise ValueError( | ||
| f"Conversation ID mismatch: provided {id}, " | ||
| f"but persisted state has {state.id}" | ||
| f"but persisted state has {persisted_id}" | ||
| ) | ||
|
|
||
| persisted_agent_payload = base_payload.get("agent") | ||
| if persisted_agent_payload is None: | ||
| raise ValueError("Persisted conversation is missing agent state") | ||
|
|
||
| # Attach event log early so we can read history for tool verification | ||
| event_log = EventLog(file_store, dir_path=EVENTS_DIR) | ||
|
|
||
| persisted_agent = AgentBase.model_validate( | ||
| persisted_agent_payload, | ||
| context={"llm_registry": registry}, | ||
| ) | ||
| agent.verify(persisted_agent, events=event_log) | ||
|
|
||
| # Use runtime-provided Agent directly (PR #1542 / issue #1451) | ||
| # | ||
| # Persist LLMs as profile references only when an explicit profile_id is | ||
| # set on the runtime LLM. | ||
enyst marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| agent_payload = agent.model_dump( | ||
| mode="json", | ||
| exclude_none=True, | ||
| context={"expose_secrets": True, "persist_profile_ref": True}, | ||
| ) | ||
| if agent.llm.profile_id and agent.llm.profile_ref: | ||
| agent_payload["llm"] = agent.llm.to_profile_ref() | ||
|
|
||
| base_payload["agent"] = agent_payload | ||
| base_payload["workspace"] = workspace.model_dump(mode="json") | ||
| base_payload["max_iterations"] = max_iterations | ||
|
Comment on lines
261
to
319
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 Important: The The multiple payload mutations (expanding profile refs, injecting runtime agent, converting back to profile refs) make this hard to follow and maintain. |
||
|
|
||
| state = cls.model_validate(base_payload, context=context) | ||
| state._fs = file_store | ||
| state._events = EventLog(file_store, dir_path=EVENTS_DIR) | ||
| state._events = event_log | ||
| state._cipher = cipher | ||
|
|
||
| # Verify compatibility (agent class + tools) | ||
| agent.verify(state.agent, events=state._events) | ||
|
|
||
| # Commit runtime-provided values (may autosave) | ||
| state._autosave_enabled = True | ||
| state.agent = agent | ||
| state.workspace = workspace | ||
| state.max_iterations = max_iterations | ||
|
|
||
| # Note: stats are already deserialized from base_state.json above. | ||
| # Do NOT reset stats here - this would lose accumulated metrics. | ||
|
|
||
| logger.info( | ||
| f"Resumed conversation {state.id} from persistent storage.\n" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Critical: This documentation is misleading and dangerous. The current default behavior (
include_secrets=True) could lead users to accidentally commit API keys.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as below