Skip to content

Commit d53235d

Browse files
authored
Merge branch 'main' into feat/tools-j2-descriptions
2 parents f4875cc + e88ea68 commit d53235d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+3908
-611
lines changed

.github/run-eval/resolve_model_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@
5252
"litellm_extra_body": {"enable_thinking": True},
5353
},
5454
},
55+
"qwen3.5-flash": {
56+
"id": "qwen3.5-flash",
57+
"display_name": "Qwen3.5 Flash",
58+
"llm_config": {
59+
"model": "litellm_proxy/dashscope/qwen3.5-flash-2026-02-23",
60+
"temperature": 0.0,
61+
},
62+
},
5563
"claude-4.5-opus": {
5664
"id": "claude-4.5-opus",
5765
"display_name": "Claude 4.5 Opus",

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ When reviewing code, provide constructive feedback:
120120

121121
When adding non-Python files (JS, templates, etc.) loaded at runtime, add them to `openhands-agent-server/openhands/agent_server/agent-server.spec` using `collect_data_files`.
122122

123+
# Bedrock + LiteLLM note
124+
125+
- LiteLLM interprets the `api_key` parameter for Bedrock models as an **AWS bearer token**.
126+
When using IAM/SigV4 auth (AWS credentials / profiles), do **not** forward `LLM.api_key`
127+
to LiteLLM for Bedrock models, or Bedrock may return:
128+
`Invalid API Key format: Must start with pre-defined prefix`.
129+
- If you need Bedrock bearer-token auth, set `AWS_BEARER_TOKEN_BEDROCK` in the environment
130+
(instead of using `LLM_API_KEY`).
131+
132+
123133
</DEV_SETUP>
124134

125135
<PR_ARTIFACTS>

examples/01_standalone_sdk/25_agent_delegation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
get_logger,
2121
)
2222
from openhands.sdk.context import Skill
23+
from openhands.sdk.subagent import register_agent
2324
from openhands.sdk.tool import register_tool
2425
from openhands.tools.delegate import (
2526
DelegateTool,
2627
DelegationVisualizer,
27-
register_agent,
2828
)
2929
from openhands.tools.preset.default import get_default_tools
3030

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Animal Quiz with Task Tool Set
3+
4+
Demonstrates the TaskToolSet with a main agent delegating to an
5+
animal-expert sub-agent. The flow is:
6+
7+
1. User names an animal.
8+
2. Main agent delegates to the "animal_expert" sub-agent to generate
9+
a multiple-choice question about that animal.
10+
3. Main agent shows the question to the user.
11+
4. User picks an answer.
12+
5. Main agent delegates again to the same sub-agent type to check
13+
whether the answer is correct and explain why.
14+
"""
15+
16+
import os
17+
18+
from openhands.sdk import LLM, Agent, AgentContext, Conversation, Tool
19+
from openhands.sdk.context import Skill
20+
from openhands.sdk.subagent import register_agent
21+
from openhands.tools.delegate import DelegationVisualizer
22+
from openhands.tools.task import TaskToolSet
23+
24+
25+
llm = LLM(
26+
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
27+
api_key=os.getenv("LLM_API_KEY"),
28+
base_url=os.getenv("LLM_BASE_URL", None),
29+
)
30+
# ── Register the animal expert sub-agent ─────────────────────────────
31+
32+
33+
def create_animal_expert(llm: LLM) -> Agent:
34+
"""Factory for the animal-expert sub-agent."""
35+
return Agent(
36+
llm=llm,
37+
tools=[], # no tools needed – pure knowledge
38+
agent_context=AgentContext(
39+
skills=[
40+
Skill(
41+
name="animal_expertise",
42+
content=(
43+
"You are a world-class zoologist. "
44+
"When asked to generate a quiz question, respond with "
45+
"EXACTLY this format and nothing else:\n\n"
46+
"Question: <question text>\n"
47+
"A) <option>\n"
48+
"B) <option>\n"
49+
"C) <option>\n"
50+
"D) <option>\n\n"
51+
"When asked to verify an answer, state whether it is "
52+
"correct or incorrect, reveal the right answer, and "
53+
"give a short fun-fact explanation."
54+
),
55+
trigger=None, # always active
56+
)
57+
],
58+
system_message_suffix="Keep every response concise.",
59+
),
60+
)
61+
62+
63+
register_agent(
64+
name="animal_expert",
65+
factory_func=create_animal_expert,
66+
description="Zoologist that creates and verifies animal quiz questions.",
67+
)
68+
69+
# ── Main agent ───────────────────────────────────────────────────────
70+
71+
main_agent = Agent(
72+
llm=llm,
73+
tools=[Tool(name=TaskToolSet.name)],
74+
)
75+
76+
conversation = Conversation(
77+
agent=main_agent,
78+
workspace=os.getcwd(),
79+
visualizer=DelegationVisualizer(name="QuizHost"),
80+
)
81+
82+
# ── Round 1: generate the question ──────────────────────────────────
83+
84+
animal = input("Pick an animal: ")
85+
86+
conversation.send_message(
87+
f"The user chose the animal: {animal}. "
88+
"Use the task tool to delegate to the 'animal_expert' sub-agent "
89+
"and ask it to generate a single multiple-choice question (A-D) "
90+
f"about {animal}. "
91+
"Once you get the question back, display it to the user exactly "
92+
"as the sub-agent returned it and ask the user to pick A, B, C, or D."
93+
)
94+
conversation.run()
95+
96+
# ── Round 2: verify the answer ──────────────────────────────────────
97+
98+
answer = input("Your answer (A/B/C/D): ")
99+
100+
conversation.send_message(
101+
f"The user answered: {answer}. "
102+
"Use the task tool to delegate to the 'animal_expert' sub-agent again "
103+
f"and ask it whether '{answer}' is the correct answer to the question "
104+
"it generated earlier. Don't include the question; instead, use the "
105+
"'resume' parameter to continue the previous conversation."
106+
)
107+
conversation.run()
108+
109+
# ── Done ────────────────────────────────────────────────────────────
110+
111+
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
112+
print(f"\nEXAMPLE_COST: {cost}")
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Example: Defining a sub-agent inline with AgentDefinition.
2+
3+
Defines a grammar-checker sub-agent using AgentDefinition, registers it,
4+
and delegates work to it from an orchestrator agent. The orchestrator then
5+
asks the builtin default agent to judge the results.
6+
"""
7+
8+
import os
9+
from pathlib import Path
10+
11+
from openhands.sdk import (
12+
LLM,
13+
Agent,
14+
Conversation,
15+
Tool,
16+
agent_definition_to_factory,
17+
register_agent,
18+
)
19+
from openhands.sdk.subagent import AgentDefinition
20+
from openhands.sdk.tool import register_tool
21+
from openhands.tools.delegate import DelegateTool, DelegationVisualizer
22+
23+
24+
# 1. Define a sub-agent using AgentDefinition
25+
grammar_checker = AgentDefinition(
26+
name="grammar-checker",
27+
description="Checks documents for grammatical errors.",
28+
tools=["file_editor"],
29+
system_prompt="You are a grammar expert. Find and list grammatical errors.",
30+
)
31+
32+
# 2. Register it in the delegate registry
33+
register_agent(
34+
name=grammar_checker.name,
35+
factory_func=agent_definition_to_factory(grammar_checker),
36+
description=grammar_checker.description,
37+
)
38+
39+
# 3. Set up the orchestrator agent with the DelegateTool
40+
llm = LLM(
41+
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
42+
api_key=os.getenv("LLM_API_KEY"),
43+
base_url=os.getenv("LLM_BASE_URL"),
44+
usage_id="file-agents-demo",
45+
)
46+
47+
register_tool("DelegateTool", DelegateTool)
48+
main_agent = Agent(
49+
llm=llm,
50+
tools=[Tool(name="DelegateTool")],
51+
)
52+
conversation = Conversation(
53+
agent=main_agent,
54+
workspace=Path.cwd(),
55+
visualizer=DelegationVisualizer(name="Orchestrator"),
56+
)
57+
58+
# 4. Ask the orchestrator to delegate to our agent
59+
task = (
60+
"Please delegate to the grammar-checker agent and ask it to review "
61+
"the README.md file in search of grammatical errors.\n"
62+
"Then ask the default agent to judge the errors."
63+
)
64+
conversation.send_message(task)
65+
conversation.run()
66+
67+
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
68+
print(f"\nTotal cost: ${cost:.4f}")
69+
print(f"EXAMPLE_COST: {cost:.4f}")

openhands-agent-server/openhands/agent_server/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from openhands.agent_server.file_router import file_router
2525
from openhands.agent_server.git_router import git_router
2626
from openhands.agent_server.hooks_router import hooks_router
27+
from openhands.agent_server.llm_router import llm_router
2728
from openhands.agent_server.middleware import LocalhostCORSMiddleware
2829
from openhands.agent_server.server_details_router import (
2930
get_server_info,
@@ -196,6 +197,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
196197
api_router.include_router(desktop_router)
197198
api_router.include_router(skills_router)
198199
api_router.include_router(hooks_router)
200+
api_router.include_router(llm_router)
199201
app.include_router(api_router)
200202
app.include_router(sockets_router)
201203

@@ -342,6 +344,8 @@ def create_app(config: Config | None = None) -> FastAPI:
342344
if config is None:
343345
config = get_default_config()
344346
app = _create_fastapi_instance()
347+
app.state.config = config
348+
345349
_add_api_routes(app, config)
346350
_setup_static_files(app, config)
347351
app.add_middleware(LocalhostCORSMiddleware, allow_origins=config.allow_cors_origins)

openhands-agent-server/openhands/agent_server/dependencies.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from uuid import UUID
22

3-
from fastapi import Depends, HTTPException, Query, Request, status
3+
from fastapi import Depends, HTTPException, Request, status
44
from fastapi.security import APIKeyHeader
55

66
from openhands.agent_server.config import Config
@@ -26,23 +26,6 @@ def check_session_api_key(
2626
return check_session_api_key
2727

2828

29-
def create_websocket_session_api_key_dependency(config: Config):
30-
"""Create a WebSocket session API key dependency with the given config.
31-
32-
WebSocket connections cannot send custom headers directly from browsers,
33-
so we use query parameters instead.
34-
"""
35-
36-
def check_websocket_session_api_key(
37-
session_api_key: str | None = Query(None, alias="session_api_key"),
38-
):
39-
"""Check the session API key from query parameter for WebSocket connections."""
40-
if config.session_api_keys and session_api_key not in config.session_api_keys:
41-
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
42-
43-
return check_websocket_session_api_key
44-
45-
4629
def get_conversation_service(request: Request):
4730
"""Get the conversation service from app state.
4831

openhands-agent-server/openhands/agent_server/docker/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ COPY --chown=${USERNAME}:${USERNAME} openhands-tools ./openhands-tools
3030
COPY --chown=${USERNAME}:${USERNAME} openhands-workspace ./openhands-workspace
3131
COPY --chown=${USERNAME}:${USERNAME} openhands-agent-server ./openhands-agent-server
3232
RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
33-
uv python install 3.12 && uv venv --python 3.12 .venv && uv sync --frozen --no-editable --managed-python
33+
uv python install 3.12 && uv venv --python 3.12 .venv && uv sync --frozen --no-editable --managed-python --extra boto3
3434

3535
####################################################################################
3636
# Binary Builder (binary mode)
@@ -41,7 +41,7 @@ ARG USERNAME UID GID
4141

4242
# We need --dev for pyinstaller
4343
RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
44-
uv sync --frozen --dev --no-editable
44+
uv sync --frozen --dev --no-editable --extra boto3
4545

4646
RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
4747
uv run pyinstaller openhands-agent-server/openhands/agent_server/agent-server.spec
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Router for LLM model and provider information endpoints."""
2+
3+
from fastapi import APIRouter, Query
4+
from pydantic import BaseModel
5+
6+
from openhands.sdk.llm.utils.unverified_models import (
7+
_extract_model_and_provider,
8+
_get_litellm_provider_names,
9+
get_supported_llm_models,
10+
)
11+
from openhands.sdk.llm.utils.verified_models import VERIFIED_MODELS
12+
13+
14+
llm_router = APIRouter(prefix="/llm", tags=["LLM"])
15+
16+
17+
class ProvidersResponse(BaseModel):
18+
"""Response containing the list of available LLM providers."""
19+
20+
providers: list[str]
21+
22+
23+
class ModelsResponse(BaseModel):
24+
"""Response containing the list of available LLM models."""
25+
26+
models: list[str]
27+
28+
29+
class VerifiedModelsResponse(BaseModel):
30+
"""Response containing verified models organized by provider."""
31+
32+
models: dict[str, list[str]]
33+
34+
35+
@llm_router.get("/providers", response_model=ProvidersResponse)
36+
async def list_providers() -> ProvidersResponse:
37+
"""List all available LLM providers supported by LiteLLM."""
38+
providers = sorted(_get_litellm_provider_names())
39+
return ProvidersResponse(providers=providers)
40+
41+
42+
@llm_router.get("/models", response_model=ModelsResponse)
43+
async def list_models(
44+
provider: str | None = Query(
45+
default=None,
46+
description="Filter models by provider (e.g., 'openai', 'anthropic')",
47+
),
48+
) -> ModelsResponse:
49+
"""List all available LLM models supported by LiteLLM.
50+
51+
Args:
52+
provider: Optional provider name to filter models by.
53+
54+
Note: Bedrock models are excluded unless AWS credentials are configured.
55+
"""
56+
all_models = get_supported_llm_models()
57+
58+
if provider is None:
59+
models = sorted(set(all_models))
60+
else:
61+
filtered_models = []
62+
for model in all_models:
63+
model_provider, model_id, separator = _extract_model_and_provider(model)
64+
if model_provider == provider:
65+
filtered_models.append(model)
66+
models = sorted(set(filtered_models))
67+
68+
return ModelsResponse(models=models)
69+
70+
71+
@llm_router.get("/models/verified", response_model=VerifiedModelsResponse)
72+
async def list_verified_models() -> VerifiedModelsResponse:
73+
"""List all verified LLM models organized by provider.
74+
75+
Verified models are those that have been tested and confirmed to work well
76+
with OpenHands.
77+
"""
78+
return VerifiedModelsResponse(models=VERIFIED_MODELS)

0 commit comments

Comments
 (0)