Skip to content
Open
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
2 changes: 2 additions & 0 deletions openhands-agent-server/openhands/agent_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from openhands.agent_server.hooks_router import hooks_router
from openhands.agent_server.llm_router import llm_router
from openhands.agent_server.mcp_router import mcp_router
from openhands.agent_server.meta_profiles_router import meta_profiles_router
from openhands.agent_server.middleware import CORSDispatcher
from openhands.agent_server.openai.router import (
create_openai_api_key_dependency,
Expand Down Expand Up @@ -316,6 +317,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
api_router.include_router(settings_router)
api_router.include_router(workspaces_router)
api_router.include_router(profiles_router)
api_router.include_router(meta_profiles_router)
# /api/auth/* mints workspace cookies and requires the header to bootstrap,
# so it lives under the header-only auth group.
api_router.include_router(auth_router)
Expand Down
238 changes: 238 additions & 0 deletions openhands-agent-server/openhands/agent_server/meta_profiles_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
"""HTTP CRUD + activate endpoints for meta-profiles (mirrors profiles_router).

Unlike LLM profiles, meta-profiles hold no secrets — they are plain JSON
documents persisted via :class:`MetaProfileStore`.
"""

from collections.abc import Iterator
from contextlib import contextmanager
from typing import Annotated

from fastapi import APIRouter, HTTPException, Path, Request, status
from pydantic import BaseModel

from openhands.agent_server._secrets_exposure import get_config
from openhands.agent_server.persistence import (
PersistedSettings,
get_settings_store,
)
from openhands.sdk.llm.llm_profile_store import PROFILE_NAME_PATTERN
from openhands.sdk.llm.meta_profile_store import (
MetaProfile,
MetaProfileLimitExceeded,
MetaProfileStore,
)
from openhands.sdk.logger import get_logger


logger = get_logger(__name__)

meta_profiles_router = APIRouter(prefix="/meta-profiles", tags=["Meta-profiles"])

MAX_META_PROFILES = 50

MetaProfileName = Annotated[
str,
Path(min_length=1, max_length=64, pattern=PROFILE_NAME_PATTERN),
]


class MetaProfileInfo(BaseModel):
name: str
classifier_model: str | None = None
default_model: str | None = None
num_classes: int = 0


class MetaProfileListResponse(BaseModel):
meta_profiles: list[MetaProfileInfo]
active_meta_profile: str | None = None


class MetaProfileDetailResponse(BaseModel):
name: str
config: MetaProfile


class MetaProfileMutationResponse(BaseModel):
name: str
message: str


class ActivateMetaProfileResponse(BaseModel):
name: str
message: str


@contextmanager
def _store_errors() -> Iterator[None]:
"""Map ``MetaProfileStore`` errors to HTTP responses."""
try:
yield
except TimeoutError:
# save()/delete() can raise TimeoutError from the file lock under
# contention; surface a retryable 503 instead of a generic 500
# (mirrors profiles_router._store_errors()).
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Meta-profile store is busy. Please retry.",
)
except ValueError as e:
Comment thread
juanmichelini marked this conversation as resolved.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)


def _set_active_meta_profile_if_matches(
request: Request, old_name: str, new_name: str | None
) -> bool:
config = get_config(request)
settings_store = get_settings_store(config)
settings = settings_store.load() or PersistedSettings()
if settings.active_meta_profile != old_name:
return False

def update_active(settings: PersistedSettings) -> PersistedSettings:
# Route through PersistedSettings.update() so the change also
# propagates into agent_settings (active_meta_profile +
# enable_classify_and_switch_llm_tool); a direct field assignment
# would leave that nested state stale.
settings.update({"active_meta_profile": new_name})
return settings

settings_store.update(update_active)
return True


@meta_profiles_router.get("", response_model=MetaProfileListResponse)
async def list_meta_profiles(request: Request) -> MetaProfileListResponse:
"""List all saved meta-profiles and the currently active one."""
config = get_config(request)
settings_store = get_settings_store(config)
settings = settings_store.load() or PersistedSettings()

store = MetaProfileStore()
with _store_errors():
summaries = store.list_summaries()

return MetaProfileListResponse(
meta_profiles=[MetaProfileInfo(**s) for s in summaries],
active_meta_profile=settings.active_meta_profile,
)


@meta_profiles_router.get("/{name}", response_model=MetaProfileDetailResponse)
async def get_meta_profile(name: MetaProfileName) -> MetaProfileDetailResponse:
"""Get a meta-profile's full configuration."""
store = MetaProfileStore()
try:
with _store_errors():
meta_profile = store.load(name)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Meta-profile '{name}' not found",
)

return MetaProfileDetailResponse(name=name, config=meta_profile)


@meta_profiles_router.post(
"/{name}",
response_model=MetaProfileMutationResponse,
status_code=status.HTTP_201_CREATED,
)
async def save_meta_profile(
name: MetaProfileName,
body: MetaProfile,
) -> MetaProfileMutationResponse:
"""Save (create or overwrite) a meta-profile.

Returns 409 if creating a new meta-profile would exceed
``MAX_META_PROFILES``.
"""
store = MetaProfileStore()
try:
with _store_errors():
store.save(name, body, max_profiles=MAX_META_PROFILES)
except MetaProfileLimitExceeded:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Meta-profile limit reached ({MAX_META_PROFILES}). "
"Delete a meta-profile before saving a new one."
),
)

logger.info(f"Saved meta-profile '{name}'")
return MetaProfileMutationResponse(
name=name, message=f"Meta-profile '{name}' saved"
)


@meta_profiles_router.delete("/{name}", response_model=MetaProfileMutationResponse)
async def delete_meta_profile(
request: Request, name: MetaProfileName
) -> MetaProfileMutationResponse:
"""Delete a meta-profile (idempotent).

If the deleted meta-profile is the active one, ``active_meta_profile`` is
cleared.
"""
store = MetaProfileStore()
with _store_errors():
store.delete(name)
if _set_active_meta_profile_if_matches(request, name, None):
logger.info(f"Cleared active_meta_profile for deleted meta-profile '{name}'")
logger.info(f"Deleted meta-profile '{name}'")
return MetaProfileMutationResponse(
name=name, message=f"Meta-profile '{name}' deleted"
)


@meta_profiles_router.post(
"/{name}/activate", response_model=ActivateMetaProfileResponse
)
async def activate_meta_profile(
request: Request, name: MetaProfileName
) -> ActivateMetaProfileResponse:
"""Activate a meta-profile by recording it as ``active_meta_profile``.

Unlike LLM profiles, activating a meta-profile does not mutate the agent's
LLM config — it only records which meta-profile the
``classify_and_switch_llm`` tool should route with. Returns 404 if the
meta-profile does not exist.
"""
# Verify the meta-profile exists (and is valid) before activating.
store = MetaProfileStore()
try:
with _store_errors():
store.load(name)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Meta-profile '{name}' not found",
)

config = get_config(request)
settings_store = get_settings_store(config)

def apply_active(settings: PersistedSettings) -> PersistedSettings:
# Route through PersistedSettings.update() so activation also wires
# agent_settings (active_meta_profile + enable_classify_and_switch_llm_tool),
# which is what actually attaches the routing tool. A direct field
# assignment would record the active name but never enable the tool.
settings.update({"active_meta_profile": name})
return settings

try:
settings_store.update(apply_active)
except (OSError, PermissionError):
logger.error("Failed to activate meta-profile - file I/O error")
raise HTTPException(status_code=500, detail="Failed to activate meta-profile")

logger.info(f"Activated meta-profile '{name}'")
return ActivateMetaProfileResponse(
name=name, message=f"Meta-profile '{name}' activated"
)
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class SettingsUpdatePayload(TypedDict, total=False):
conversation_settings_diff: dict[str, Any]
misc_settings_diff: dict[str, Any]
active_profile: str | None
active_meta_profile: str | None


def _deep_merge(
Expand Down Expand Up @@ -140,6 +141,11 @@ class PersistedSettings(BaseModel):
default=None,
description="Name of the currently active LLM profile.",
)
active_meta_profile: str | None = Field(
default=None,
description="Name of the currently active meta-profile used for "
"intelligent model routing.",
)
misc_settings: dict[str, Any] = Field(
default_factory=dict,
description=(
Expand Down Expand Up @@ -248,11 +254,63 @@ def update(self, payload: SettingsUpdatePayload) -> None:
# Update active_profile if explicitly provided (including None to clear)
if "active_profile" in payload:
self.active_profile = payload["active_profile"]

# Update active_meta_profile if explicitly provided (incl. None)
if "active_meta_profile" in payload:
self._apply_active_meta_profile(payload["active_meta_profile"])

# Enforce the invariant even when only ``agent_settings`` changed:
# switching to an agent variant that cannot attach the routing tool
# (e.g. ACP) must not leave a stale top-level ``active_meta_profile``
# claiming routing is active. (No-op when active already matches.)
self._clear_active_meta_profile_if_unsupported()
finally:
# Clear conv_merged to minimize plaintext exposure window
if conv_merged is not None:
conv_merged.clear()

def _agent_supports_routing(self) -> bool:
"""Whether the current agent variant can attach the routing tool.

OpenHands agent settings expose ``active_meta_profile`` /
``enable_classify_and_switch_llm_tool``; ACP (and any other) variants do
not and never attach :class:`ClassifyAndSwitchLLMTool`.
"""
return "active_meta_profile" in type(self.agent_settings).model_fields

def _apply_active_meta_profile(self, name: str | None) -> None:
"""Set ``active_meta_profile`` and propagate it into agent_settings.

Propagating into the nested ``agent_settings`` is what enables/uses the
routing tool on the agent built from these settings (mirrors how
activating a profile bakes the LLM into ``agent_settings``).

The top-level field is a strict facade for the nested state, not a
best-effort hint: if the current agent variant cannot attach the routing
tool (e.g. ACP), the request is dropped and the facade cleared so
persisted state never claims an active router no conversation can use.
"""
if not self._agent_supports_routing():
self.active_meta_profile = None
return
self.active_meta_profile = name
self.agent_settings = self.agent_settings.model_copy(
update={
"active_meta_profile": name,
"enable_classify_and_switch_llm_tool": name is not None,
}
)

def _clear_active_meta_profile_if_unsupported(self) -> None:
"""Clear the facade when the agent variant cannot support routing.

Guards the agent-kind-switch path: changing ``agent_settings`` to a
non-routing variant (e.g. ACP) while a meta-profile is active would
otherwise leave the top-level ``active_meta_profile`` stale.
"""
if not self._agent_supports_routing() and self.active_meta_profile is not None:
self.active_meta_profile = None

@classmethod
def from_persisted(
cls, data: Any, *, context: dict[str, Any] | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ async def get_settings(request: Request) -> SettingsResponse:
),
llm_api_key_is_set=settings.llm_api_key_is_set,
active_profile=settings.active_profile,
active_meta_profile=settings.active_meta_profile,
misc_settings=settings.misc_settings,
)

Expand Down Expand Up @@ -206,14 +207,16 @@ async def update_settings(
update_data = payload.model_dump(exclude_none=True)
if "active_profile" in payload.model_fields_set:
update_data["active_profile"] = payload.active_profile
if "active_meta_profile" in payload.model_fields_set:
update_data["active_meta_profile"] = payload.active_meta_profile
if not update_data:
# No updates provided - this is a client error
raise HTTPException(
status_code=400,
detail=(
"At least one of agent_settings_diff, "
"conversation_settings_diff, misc_settings_diff, "
"or active_profile must be provided"
"active_profile, or active_meta_profile must be provided"
),
)

Expand Down Expand Up @@ -269,6 +272,7 @@ def apply_update(settings: PersistedSettings) -> PersistedSettings:
conversation_settings=settings.conversation_settings.model_dump(mode="json"),
llm_api_key_is_set=settings.llm_api_key_is_set,
active_profile=settings.active_profile,
active_meta_profile=settings.active_meta_profile,
misc_settings=settings.misc_settings,
)

Expand Down
10 changes: 10 additions & 0 deletions openhands-sdk/openhands/sdk/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
ThinkingBlock,
content_to_str,
)
from openhands.sdk.llm.meta_profile_store import (
MetaProfile,
MetaProfileClass,
MetaProfileLimitExceeded,
MetaProfileStore,
)
from openhands.sdk.llm.router import RouterLLM
from openhands.sdk.llm.streaming import (
AsyncTokenCallbackType,
Expand Down Expand Up @@ -46,6 +52,10 @@
"LLM_PROFILE_SCHEMA_VERSION",
"LLMRegistry",
"LLMProfileStore",
"MetaProfile",
"MetaProfileClass",
"MetaProfileLimitExceeded",
"MetaProfileStore",
"RouterLLM",
"RegistryEvent",
# Messages
Expand Down
Loading