-
Notifications
You must be signed in to change notification settings - Fork 307
Intelligent model router: classify_and_switch_llm tool + meta-profiles #3744
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
1ced902
4907cf8
87b3830
6468a93
1ca1ebe
3333e48
2f1f55e
716e7dd
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,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: | ||
| 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 |
|---|---|---|
|
|
@@ -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( | ||
|
|
@@ -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=( | ||
|
|
@@ -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: | ||
|
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: This sets the top-level |
||
| 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.