Repository navigation
feat: add pre-flight LLM validation endpoint (POST /api/profiles/{name}/validate) #4422
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
Changes from all commits
8a62287
f06f879
b6f03af
281490e
03dab92
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 |
|---|---|---|
|
|
@@ -20,7 +20,13 @@ | |
| get_llm_profile_store, | ||
| get_settings_store, | ||
| ) | ||
| from openhands.sdk.llm import LLM | ||
| from openhands.sdk.llm import LLM, Message, TextContent | ||
| from openhands.sdk.llm.exceptions import ( | ||
| LLMError, | ||
| LLMRateLimitError, | ||
| LLMServiceUnavailableError, | ||
| LLMTimeoutError, | ||
| ) | ||
| from openhands.sdk.llm.llm_profile_store import ( | ||
| PROFILE_NAME_PATTERN, | ||
| ProfileLimitExceeded, | ||
|
|
@@ -211,6 +217,111 @@ async def save_profile( | |
| return ProfileMutationResponse(name=name, message=f"Profile '{name}' saved") | ||
|
|
||
|
|
||
| class ValidateProfileRequest(BaseModel): | ||
| """Request body for LLM pre-flight validation. | ||
|
|
||
| Accepts an LLM config (same shape as ``SaveProfileRequest.llm``) so the | ||
| frontend can validate a draft *before* persisting it. | ||
| """ | ||
|
|
||
| llm: LLM | ||
|
|
||
|
|
||
| class ValidateProfileError(BaseModel): | ||
| """Structured error returned when validation fails.""" | ||
|
|
||
| type: str | ||
| message: str | ||
|
|
||
|
|
||
| class ValidateProfileResponse(BaseModel): | ||
| """Result of an LLM pre-flight check.""" | ||
|
|
||
| valid: bool | ||
| error: ValidateProfileError | None = None | ||
|
|
||
|
|
||
| # Errors that should NOT block saving — they are transient and unrelated to | ||
| # the correctness of the configuration itself. | ||
| _TRANSIENT_ERROR_TYPES = (LLMRateLimitError, LLMTimeoutError) | ||
|
|
||
|
|
||
| @profiles_router.post( | ||
| "/{name}/validate", | ||
| response_model=ValidateProfileResponse, | ||
| ) | ||
| async def validate_profile( | ||
| request: Request, | ||
| name: ProfileName, | ||
| body: ValidateProfileRequest, | ||
| ) -> ValidateProfileResponse: | ||
| """Pre-flight check: fire a minimal LLM completion to catch misconfigurations. | ||
|
|
||
| Instantiates the submitted LLM config and sends a 1-token completion to | ||
| surface errors like invalid model names, missing provider prefixes, bad | ||
| base URLs, and invalid API keys — *before* the profile is saved. | ||
|
|
||
| Transient errors (rate limits, timeouts) are treated as non-blocking: the | ||
| response is ``valid=True`` with no error, since those don't indicate a | ||
| misconfigured profile. | ||
| """ | ||
| cipher = get_cipher(request) | ||
| llm = decrypt_incoming_llm_secrets(body.llm, cipher) if cipher else body.llm | ||
|
|
||
| messages = [ | ||
| Message( | ||
| role="user", | ||
| content=[TextContent(text="ping")], | ||
| ) | ||
| ] | ||
|
|
||
| try: | ||
| # Mirror the runtime dispatch (see ``amake_llm_completion``) and stay | ||
| # async so provider I/O doesn't pin the FastAPI event loop. | ||
| if llm.uses_responses_api(): | ||
| await llm.aresponses(messages=messages, max_tokens=1) | ||
| else: | ||
| await llm.acompletion(messages=messages, max_tokens=1) | ||
|
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. Retry latency can block the endpoint for minutes.
A bad probe = llm.model_copy(update={"num_retries": 0, "timeout": 30})The first-attempt error is all a validation probe needs. |
||
| except _TRANSIENT_ERROR_TYPES as exc: | ||
| # Transient — don't block the save | ||
| logger.info( | ||
| f"Profile '{name}' pre-flight hit a transient error " | ||
| f"({type(exc).__name__}); not blocking save." | ||
| ) | ||
| return ValidateProfileResponse(valid=True) | ||
| except ( | ||
| # LLMServiceUnavailableError (provider 503) is intentionally treated as | ||
| # a blocking config error: at this layer a provider outage is | ||
| # indistinguishable from a wrong base URL, so we prefer a false | ||
| # negative over silently saving a misconfigured profile. | ||
| LLMServiceUnavailableError, | ||
|
neubig marked this conversation as resolved.
|
||
| LLMError, | ||
|
neubig marked this conversation as resolved.
|
||
| ) as exc: | ||
| error_type = type(exc).__name__ | ||
| logger.info(f"Profile '{name}' pre-flight failed: {error_type}: {exc.message}") | ||
| return ValidateProfileResponse( | ||
| valid=False, | ||
| error=ValidateProfileError( | ||
| type=error_type, | ||
| message=exc.message, | ||
| ), | ||
| ) | ||
| except Exception as exc: | ||
| # Unknown errors — block the save and surface the raw message so the | ||
| # user can debug, but classify generically. | ||
| msg = str(exc) or type(exc).__name__ | ||
| logger.info(f"Profile '{name}' pre-flight failed (unknown): {msg}") | ||
| return ValidateProfileResponse( | ||
| valid=False, | ||
| error=ValidateProfileError( | ||
| type=type(exc).__name__, | ||
| message=msg, | ||
| ), | ||
| ) | ||
|
|
||
| return ValidateProfileResponse(valid=True) | ||
|
|
||
|
|
||
| @profiles_router.delete("/{name}", response_model=ProfileMutationResponse) | ||
| async def delete_profile( | ||
| request: Request, name: ProfileName | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 Important: This does not reliably cap the preflight to one output token.
select_responses_options()expectsmax_output_tokens, whileselect_chat_options()expectsmax_completion_tokens; passingmax_tokens=1leaves the SDK's full default budget in place (max_output_tokens/max_completion_tokens) and can also send an unsupported Responses API parameter. Use the canonical kwargs for each path (and assert them in tests) so the endpoint validates the same minimal request it documents.