diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index bf27c4aaa5..6cbcb50802 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -97,6 +97,10 @@ class SaasSettingsStore(SettingsStore): return settings async def store(self, item: Settings): + # Check if provider is OpenHands and generate API key if needed + if item and self._is_openhands_provider(item): + await self._ensure_openhands_api_key(item) + with self.session_maker() as session: existing = None kwargs = {} @@ -368,6 +372,30 @@ class SaasSettingsStore(SettingsStore): def _should_encrypt(self, key: str) -> bool: return key in ('llm_api_key', 'llm_api_key_for_byor', 'search_api_key') + def _is_openhands_provider(self, item: Settings) -> bool: + """Check if the settings use the OpenHands provider.""" + return bool(item.llm_model and item.llm_model.startswith('openhands/')) + + async def _ensure_openhands_api_key(self, item: Settings) -> None: + """Generate and set the OpenHands API key for the given settings. + + First checks if an existing key with the OpenHands alias exists, + and reuses it if found. Otherwise, generates a new key. + """ + # Generate new key if none exists + generated_key = await self._generate_openhands_key() + if generated_key: + item.llm_api_key = SecretStr(generated_key) + logger.info( + 'saas_settings_store:store:generated_openhands_key', + extra={'user_id': self.user_id}, + ) + else: + logger.warning( + 'saas_settings_store:store:failed_to_generate_openhands_key', + extra={'user_id': self.user_id}, + ) + async def _create_user_in_lite_llm( self, client: httpx.AsyncClient, email: str | None, max_budget: int, spend: int ): @@ -390,3 +418,55 @@ class SaasSettingsStore(SettingsStore): }, ) return response + + async def _generate_openhands_key(self) -> str | None: + """Generate a new OpenHands provider key for a user.""" + if not (LITE_LLM_API_KEY and LITE_LLM_API_URL): + logger.warning( + 'saas_settings_store:_generate_openhands_key:litellm_config_not_found', + extra={'user_id': self.user_id}, + ) + return None + + try: + async with httpx.AsyncClient( + verify=httpx_verify_option(), + headers={ + 'x-goog-api-key': LITE_LLM_API_KEY, + }, + ) as client: + response = await client.post( + f'{LITE_LLM_API_URL}/key/generate', + json={ + 'user_id': self.user_id, + 'metadata': {'type': 'openhands'}, + }, + ) + response.raise_for_status() + response_json = response.json() + key = response_json.get('key') + + if key: + logger.info( + 'saas_settings_store:_generate_openhands_key:success', + extra={ + 'user_id': self.user_id, + 'key_length': len(key) if key else 0, + 'key_prefix': ( + key[:10] + '...' if key and len(key) > 10 else key + ), + }, + ) + return key + else: + logger.error( + 'saas_settings_store:_generate_openhands_key:no_key_in_response', + extra={'user_id': self.user_id, 'response_json': response_json}, + ) + return None + except Exception as e: + logger.exception( + 'saas_settings_store:_generate_openhands_key:error', + extra={'user_id': self.user_id, 'error': str(e)}, + ) + return None diff --git a/frontend/src/components/shared/modals/settings/model-selector.tsx b/frontend/src/components/shared/modals/settings/model-selector.tsx index 1fd03d7c8f..b542c4e695 100644 --- a/frontend/src/components/shared/modals/settings/model-selector.tsx +++ b/frontend/src/components/shared/modals/settings/model-selector.tsx @@ -21,7 +21,11 @@ interface ModelSelectorProps { isDisabled?: boolean; models: Record; currentModel?: string; - onChange?: (model: string | null) => void; + onChange?: (provider: string | null, model: string | null) => void; + onDefaultValuesChanged?: ( + provider: string | null, + model: string | null, + ) => void; wrapperClassName?: string; labelClassName?: string; } @@ -31,6 +35,7 @@ export function ModelSelector({ models, currentModel, onChange, + onDefaultValuesChanged, wrapperClassName, labelClassName, }: ModelSelectorProps) { @@ -56,6 +61,7 @@ export function ModelSelector({ setLitellmId(currentModel); setSelectedProvider(provider); setSelectedModel(model); + onDefaultValuesChanged?.(provider, model); } }, [currentModel]); @@ -65,6 +71,7 @@ export function ModelSelector({ const separator = models[provider]?.separator || ""; setLitellmId(provider + separator); + onChange?.(provider, null); }; const handleChangeModel = (model: string) => { @@ -76,7 +83,7 @@ export function ModelSelector({ } setLitellmId(fullModel); setSelectedModel(model); - onChange?.(fullModel); + onChange?.(selectedProvider, model); }; const clear = () => { diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index ed89d03882..ca5163f3dd 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -102,10 +102,22 @@ function LlmSettingsScreen() { : (settings?.SECURITY_ANALYZER ?? DEFAULT_SETTINGS.SECURITY_ANALYZER), ); + const [selectedProvider, setSelectedProvider] = React.useState( + null, + ); + const modelsAndProviders = organizeModelsAndProviders( resources?.models || [], ); + // Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode) + const currentModel = currentSelectedModel || settings?.LLM_MODEL; + const isOpenHandsProvider = + (view === "basic" && selectedProvider === "openhands") || + (view === "advanced" && currentModel?.startsWith("openhands/")); + const isSaasMode = config?.APP_MODE === "saas"; + const shouldUseOpenHandsKey = isOpenHandsProvider && isSaasMode; + React.useEffect(() => { const determineWhetherToToggleAdvancedSettings = () => { if (resources && settings) { @@ -196,10 +208,13 @@ function LlmSettingsScreen() { const fullLlmModel = provider && model && `${provider}/${model}`; + // Use OpenHands-managed key for OpenHands provider in SaaS mode + const finalApiKey = shouldUseOpenHandsKey ? null : apiKey; + saveSettings( { LLM_MODEL: fullLlmModel, - llm_api_key: apiKey || null, + llm_api_key: finalApiKey || null, SEARCH_API_KEY: searchApiKey || "", CONFIRMATION_MODE: confirmationMode, SECURITY_ANALYZER: @@ -244,11 +259,14 @@ function LlmSettingsScreen() { .get("security-analyzer-input") ?.toString(); + // Use OpenHands-managed key for OpenHands provider in SaaS mode + const finalApiKey = shouldUseOpenHandsKey ? null : apiKey; + saveSettings( { LLM_MODEL: model, LLM_BASE_URL: baseUrl, - llm_api_key: apiKey || null, + llm_api_key: finalApiKey || null, SEARCH_API_KEY: searchApiKey || "", AGENT: agent, CONFIRMATION_MODE: confirmationMode, @@ -282,7 +300,10 @@ function LlmSettingsScreen() { }); }; - const handleModelIsDirty = (model: string | null) => { + const handleModelIsDirty = ( + provider: string | null, + model: string | null, + ) => { // openai providers are special case; see ModelSelector // component for details const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", ""); @@ -293,6 +314,15 @@ function LlmSettingsScreen() { // Track the currently selected model for help text display setCurrentSelectedModel(model); + setSelectedProvider(provider); + }; + + const onDefaultValuesChanged = ( + provider: string | null, + model: string | null, + ) => { + setSelectedProvider(provider); + setCurrentSelectedModel(model); }; const handleApiKeyIsDirty = (apiKey: string) => { @@ -463,6 +493,7 @@ function LlmSettingsScreen() { models={modelsAndProviders} currentModel={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL} onChange={handleModelIsDirty} + onDefaultValuesChanged={onDefaultValuesChanged} wrapperClassName="!flex-col !gap-6" /> {(settings.LLM_MODEL?.startsWith("openhands/") || @@ -472,27 +503,31 @@ function LlmSettingsScreen() { )} - " : ""} - onChange={handleApiKeyIsDirty} - startContent={ - settings.LLM_API_KEY_SET && ( - - ) - } - /> + {!shouldUseOpenHandsKey && ( + <> + " : ""} + onChange={handleApiKeyIsDirty} + startContent={ + settings.LLM_API_KEY_SET && ( + + ) + } + /> - + + + )} )} @@ -527,26 +562,30 @@ function LlmSettingsScreen() { onChange={handleBaseUrlIsDirty} /> - " : ""} - onChange={handleApiKeyIsDirty} - startContent={ - settings.LLM_API_KEY_SET && ( - - ) - } - /> - + {!shouldUseOpenHandsKey && ( + <> + " : ""} + onChange={handleApiKeyIsDirty} + startContent={ + settings.LLM_API_KEY_SET && ( + + ) + } + /> + + + )} {config?.APP_MODE !== "saas" && ( <>