Skip to content
Open
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,32 @@ 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"])
finally:
# Clear conv_merged to minimize plaintext exposure window
if conv_merged is not None:
conv_merged.clear()

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``). ACP agent
settings lack these fields, so guard on their presence.
"""
self.active_meta_profile = name
if "active_meta_profile" in type(self.agent_settings).model_fields:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: This sets the top-level active_meta_profile even when agent_settings is ACPAgentSettings, but ACP agents do not expose active_meta_profile / enable_classify_and_switch_llm_tool and never attach ClassifyAndSwitchLLMTool. In that state POST /api/meta-profiles/{name}/activate returns success and GET /api/meta-profiles reports an active router, while new conversations built from the persisted ACP settings cannot use it; switching agent kinds later can also leave the facade field stale. Please keep this invariant strict: either reject/clear active_meta_profile when the current agent settings variant cannot support the routing tool, or re-apply it when converting back to OpenHandsAgentSettings so persisted state cannot claim routing is active when it is not.

self.agent_settings = self.agent_settings.model_copy(
update={
"active_meta_profile": name,
"enable_classify_and_switch_llm_tool": name is not 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
Loading