-
Notifications
You must be signed in to change notification settings - Fork 309
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
Open
juanmichelini
wants to merge
9
commits into
main
Choose a base branch
from
jmj/intelligent-model-router
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
1ced902
Add intelligent model router (ClassifyAndSwitchLLM tool + meta-profiles)
openhands-agent 4907cf8
Fall back to first meta-profile when none is active
openhands-agent 87b3830
Add meta-profile CRUD endpoints (/api/meta-profiles)
openhands-agent 6468a93
Address review: wire meta-profile activation, defer load, lock store
openhands-agent 1ca1ebe
Merge branch 'main' into jmj/intelligent-model-router
juanmichelini 3333e48
fix(test): satisfy pyright after main merge in meta-profiles router t…
openhands-agent 2f1f55e
refactor: address code-review feedback on intelligent model router
openhands-agent 716e7dd
fix: account classifier LLM spend + map meta-profile store timeouts t…
openhands-agent 99ec877
fix: keep active_meta_profile a strict facade for routing-capable agents
openhands-agent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
238 changes: 238 additions & 0 deletions
238
openhands-agent-server/openhands/agent_server/meta_profiles_router.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.