From ad615ebc8bee37011f0378bb378170a4783aa153 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 8 Mar 2026 20:42:35 +0000 Subject: [PATCH] settings: use generic sdk settings values in OpenHands Co-authored-by: openhands --- AGENTS.md | 2 + .../settings-service/settings-service.api.ts | 4 +- .../src/hooks/mutation/use-save-settings.ts | 10 +- frontend/src/hooks/query/use-settings.ts | 2 + frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 36 +- frontend/src/routes/llm-settings.tsx | 926 ++++-------------- frontend/src/services/settings.ts | 1 + frontend/src/types/settings.ts | 9 +- .../src/utils/sdk-settings-schema.test.ts | 194 ++++ frontend/src/utils/sdk-settings-schema.ts | 169 ++++ openhands/server/routes/settings.py | 103 +- openhands/storage/data_models/settings.py | 3 + tests/unit/server/routes/test_settings_api.py | 20 +- 14 files changed, 729 insertions(+), 752 deletions(-) create mode 100644 frontend/src/utils/sdk-settings-schema.test.ts create mode 100644 frontend/src/utils/sdk-settings-schema.ts diff --git a/AGENTS.md b/AGENTS.md index be8f6e820c..b9615a0ed8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ This repository contains the code for OpenHands, an automated AI software engine ## Repository Memory - Legacy `/api/settings` responses can bridge to the SDK by returning `sdk_settings_schema` from `openhands.sdk.settings` when that package is available. Use this as the compatibility handoff while V1 settings work moves into the SDK and newer clients. +- The legacy LLM settings screen now renders SDK-backed sections from `sdk_settings_schema` and reads/writes values through the generic `sdk_settings_values` map. Do not mirror SDK-managed fields into the legacy `Settings` model just to render V0 settings. + ## General Setup: To set up the entire repo, including frontend and backend, run `make build`. diff --git a/frontend/src/api/settings-service/settings-service.api.ts b/frontend/src/api/settings-service/settings-service.api.ts index 1b0d1d5e0e..c2d074eda0 100644 --- a/frontend/src/api/settings-service/settings-service.api.ts +++ b/frontend/src/api/settings-service/settings-service.api.ts @@ -17,7 +17,9 @@ class SettingsService { * Save the settings to the server. Only valid settings are saved. * @param settings - the settings to save */ - static async saveSettings(settings: Partial): Promise { + static async saveSettings( + settings: Partial & Record, + ): Promise { const data = await openHands.post("/api/settings", settings); return data.status === 200; } diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index f335fd83ec..aab44bdba9 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -5,8 +5,10 @@ import SettingsService from "#/api/settings-service/settings-service.api"; import { Settings } from "#/types/settings"; import { useSettings } from "../query/use-settings"; -const saveSettingsMutationFn = async (settings: Partial) => { - const settingsToSave: Partial = { +type SettingsUpdate = Partial & Record; + +const saveSettingsMutationFn = async (settings: SettingsUpdate) => { + const settingsToSave: SettingsUpdate = { ...settings, agent: settings.agent || DEFAULT_SETTINGS.agent, language: settings.language || DEFAULT_SETTINGS.language, @@ -32,8 +34,8 @@ export const useSaveSettings = () => { const { data: currentSettings } = useSettings(); return useMutation({ - mutationFn: async (settings: Partial) => { - const newSettings = { ...currentSettings, ...settings }; + mutationFn: async (settings: SettingsUpdate) => { + const newSettings: SettingsUpdate = { ...currentSettings, ...settings }; // Track MCP configuration changes if ( diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index ce01e4f69b..caa7b54881 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -18,6 +18,8 @@ const getSettingsQueryFn = async (): Promise => { git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email, is_new_user: false, v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled, + sdk_settings_values: + settings.sdk_settings_values ?? DEFAULT_SETTINGS.sdk_settings_values, }; }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 44ff4dcc38..b4ede3c31d 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -104,6 +104,7 @@ export enum I18nKey { HOME$RESOLVE_UNRESOLVED_COMMENTS = "HOME$RESOLVE_UNRESOLVED_COMMENTS", HOME$LAUNCH = "HOME$LAUNCH", SETTINGS$ADVANCED = "SETTINGS$ADVANCED", + SETTINGS$BASIC = "SETTINGS$BASIC", SETTINGS$BASE_URL = "SETTINGS$BASE_URL", SETTINGS$AGENT = "SETTINGS$AGENT", SETTINGS$ENABLE_MEMORY_CONDENSATION = "SETTINGS$ENABLE_MEMORY_CONDENSATION", @@ -119,6 +120,7 @@ export enum I18nKey { ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES", SETTINGS$SAVING = "SETTINGS$SAVING", SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES", + SETTINGS$SDK_SCHEMA_UNAVAILABLE = "SETTINGS$SDK_SCHEMA_UNAVAILABLE", SETTINGS$NAV_INTEGRATIONS = "SETTINGS$NAV_INTEGRATIONS", SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION", SETTINGS$NAV_BILLING = "SETTINGS$NAV_BILLING", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index d84602daab..6a74f0ce06 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -1663,6 +1663,22 @@ "de": "Erweitert", "uk": "Розширений" }, + "SETTINGS$BASIC": { + "en": "Basic", + "ja": "Basic", + "zh-CN": "Basic", + "zh-TW": "Basic", + "ko-KR": "Basic", + "no": "Basic", + "it": "Basic", + "pt": "Basic", + "es": "Basic", + "ar": "Basic", + "fr": "Basic", + "tr": "Basic", + "de": "Basic", + "uk": "Basic" + }, "SETTINGS$BASE_URL": { "en": "Base URL", "ja": "ベースURL", @@ -1903,6 +1919,22 @@ "de": "Änderungen speichern", "uk": "Зберегти зміни" }, + "SETTINGS$SDK_SCHEMA_UNAVAILABLE": { + "en": "SDK settings schema unavailable.", + "ja": "SDK settings schema unavailable.", + "zh-CN": "SDK settings schema unavailable.", + "zh-TW": "SDK settings schema unavailable.", + "ko-KR": "SDK settings schema unavailable.", + "no": "SDK settings schema unavailable.", + "it": "SDK settings schema unavailable.", + "pt": "SDK settings schema unavailable.", + "es": "SDK settings schema unavailable.", + "ar": "SDK settings schema unavailable.", + "fr": "SDK settings schema unavailable.", + "tr": "SDK settings schema unavailable.", + "de": "SDK settings schema unavailable.", + "uk": "SDK settings schema unavailable." + }, "SETTINGS$NAV_INTEGRATIONS": { "en": "Integrations", "ja": "統合", @@ -7247,7 +7279,7 @@ "es": "Actualmente no hay un plan para este repositorio", "tr": "Şu anda bu depo için bir plan yok" }, - "SIDEBAR$NAVIGATION_LABEL": { + "SIDEBAR$NAVIGATION_LABEL": { "en": "Sidebar navigation", "zh-CN": "侧边栏导航", "zh-TW": "側邊欄導航", @@ -10385,7 +10417,7 @@ "de": "klicken Sie hier für Anweisungen", "uk": "натисніть тут, щоб отримати інструкції" }, - "BITBUCKET_DATA_CENTER$TOKEN_LABEL": { + "BITBUCKET_DATA_CENTER$TOKEN_LABEL": { "en": "Bitbucket Data Center Token", "ja": "Bitbucket Data Centerトークン", "zh-CN": "Bitbucket Data Center令牌", diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index d28bfa661b..e09fefc1e3 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -1,763 +1,253 @@ import React from "react"; -import { useTranslation } from "react-i18next"; import { AxiosError } from "axios"; -import { useSearchParams } from "react-router"; -import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; -import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers"; -import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options"; -import { useSettings } from "#/hooks/query/use-settings"; -import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set"; -import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; -import { SettingsSwitch } from "#/components/features/settings/settings-switch"; -import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; -import QuestionCircleIcon from "#/icons/question-circle.svg?react"; -import { I18nKey } from "#/i18n/declaration"; -import { SettingsInput } from "#/components/features/settings/settings-input"; -import { HelpLink } from "#/ui/help-link"; +import { useTranslation } from "react-i18next"; + import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SettingsSwitch } from "#/components/features/settings/settings-switch"; +import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton"; +import { I18nKey } from "#/i18n/declaration"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { useSettings } from "#/hooks/query/use-settings"; import { displayErrorToast, displaySuccessToast, } from "#/utils/custom-toast-handlers"; import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; -import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; -import { useConfig } from "#/hooks/query/use-config"; -import { isCustomModel } from "#/utils/is-custom-model"; -import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton"; -import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { getProviderId } from "#/utils/map-provider"; -import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models"; +import { + buildInitialSettingsFormValues, + buildSdkSettingsPayload, + getVisibleSettingsSections, + hasAdvancedSettingsOverrides, + SettingsDirtyState, + SettingsFormValues, +} from "#/utils/sdk-settings-schema"; +import { SettingsFieldSchema } from "#/types/settings"; +import { Typography } from "#/ui/typography"; -interface OpenHandsApiKeyHelpProps { - testId: string; -} - -function OpenHandsApiKeyHelp({ testId }: OpenHandsApiKeyHelpProps) { - const { t } = useTranslation(); +function FieldHelp({ field }: { field: SettingsFieldSchema }) { + if (!field.description && !field.help_text) { + return null; + } return ( - <> - -

- {t(I18nKey.SETTINGS$LLM_BILLING_INFO)}{" "} - + {field.description ? ( + + {field.description} + + ) : null} + {field.help_text ? ( + + {field.help_text} + + ) : null} + + ); +} + +function SchemaField({ + field, + value, + onChange, +}: { + field: SettingsFieldSchema; + value: string | boolean; + onChange: (value: string | boolean) => void; +}) { + if (field.widget === "boolean") { + return ( +

+ ); + } + + if (field.widget === "select") { + return ( +
+ ({ + key: choice.value, + label: choice.label, + }))} + placeholder={field.placeholder ?? undefined} + selectedKey={String(value || "") || undefined} + isClearable={!field.required} + required={field.required} + onSelectionChange={(selectedKey) => + onChange(String(selectedKey ?? "")) + } + /> + +
+ ); + } + + return ( +
+ + +
); } function LlmSettingsScreen() { const { t } = useTranslation(); - const [searchParams, setSearchParams] = useSearchParams(); - const { mutate: saveSettings, isPending } = useSaveSettings(); - - const { data: resources } = useAIConfigOptions(); const { data: settings, isLoading, isFetching } = useSettings(); - const { data: config } = useConfig(); const [view, setView] = React.useState<"basic" | "advanced">("basic"); + const [values, setValues] = React.useState({}); + const [dirty, setDirty] = React.useState({}); - const [dirtyInputs, setDirtyInputs] = React.useState({ - model: false, - apiKey: false, - searchApiKey: false, - baseUrl: false, - agent: false, - confirmationMode: false, - enableDefaultCondenser: false, - securityAnalyzer: false, - condenserMaxSize: false, - }); + const schema = settings?.sdk_settings_schema ?? null; - // Track the currently selected model to show help text - const [currentSelectedModel, setCurrentSelectedModel] = React.useState< - string | null - >(null); + React.useEffect(() => { + if (!settings?.sdk_settings_schema) { + return; + } - // Track confirmation mode state to control security analyzer visibility - const [confirmationModeEnabled, setConfirmationModeEnabled] = React.useState( - settings?.confirmation_mode ?? DEFAULT_SETTINGS.confirmation_mode, + setValues(buildInitialSettingsFormValues(settings)); + setDirty({}); + setView(hasAdvancedSettingsOverrides(settings) ? "advanced" : "basic"); + }, [settings]); + + const visibleSections = React.useMemo(() => { + if (!schema) { + return []; + } + + return getVisibleSettingsSections(schema, values, view === "advanced"); + }, [schema, values, view]); + + const handleFieldChange = React.useCallback( + (fieldKey: string, nextValue: string | boolean) => { + setValues((previousValues) => ({ + ...previousValues, + [fieldKey]: nextValue, + })); + setDirty((previousDirty) => ({ + ...previousDirty, + [fieldKey]: true, + })); + }, + [], ); - // Track selected security analyzer for form submission - const [selectedSecurityAnalyzer, setSelectedSecurityAnalyzer] = - React.useState( - settings?.security_analyzer === null - ? "none" - : (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 isSaasMode = config?.app_mode === "saas"; - - const isOpenHandsProvider = () => { - if (view === "basic") { - return selectedProvider === "openhands"; - } - - if (view === "advanced") { - if (dirtyInputs.model) { - return currentModel?.startsWith("openhands/"); - } - return settings?.llm_model?.startsWith("openhands/"); - } - - return false; - }; - - const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode; - - // Determine if we should hide the agent dropdown when V1 conversation API is enabled - const isV1Enabled = settings?.v1_enabled; - - React.useEffect(() => { - const determineWhetherToToggleAdvancedSettings = () => { - if (resources && settings) { - return ( - isCustomModel(resources.models, settings.llm_model) || - hasAdvancedSettingsSet({ - ...settings, - }) - ); - } - - return false; - }; - - const userSettingsIsAdvanced = determineWhetherToToggleAdvancedSettings(); - - if (userSettingsIsAdvanced) setView("advanced"); - else setView("basic"); - }, [settings, resources]); - - // Initialize currentSelectedModel with the current settings - React.useEffect(() => { - if (settings?.llm_model) { - setCurrentSelectedModel(settings.llm_model); - } - }, [settings?.llm_model]); - - // Update confirmation mode state when settings change - React.useEffect(() => { - if (settings?.confirmation_mode !== undefined) { - setConfirmationModeEnabled(settings.confirmation_mode); - } - }, [settings?.confirmation_mode]); - - // Update selected security analyzer state when settings change - React.useEffect(() => { - if (settings?.security_analyzer !== undefined) { - setSelectedSecurityAnalyzer(settings.security_analyzer || "none"); - } - }, [settings?.security_analyzer]); - - // Handle URL parameters for SaaS subscription redirects - React.useEffect(() => { - const checkout = searchParams.get("checkout"); - - if (checkout === "success") { - displaySuccessToast(t(I18nKey.SUBSCRIPTION$SUCCESS)); - setSearchParams({}); - } else if (checkout === "cancel") { - displayErrorToast(t(I18nKey.SUBSCRIPTION$FAILURE)); - setSearchParams({}); - } - }, [searchParams, setSearchParams, t]); - - const handleSuccessfulMutation = () => { - displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING)); - setDirtyInputs({ - model: false, - apiKey: false, - searchApiKey: false, - baseUrl: false, - agent: false, - confirmationMode: false, - enableDefaultCondenser: false, - securityAnalyzer: false, - condenserMaxSize: false, - }); - }; - - const handleErrorMutation = (error: AxiosError) => { + const handleError = (error: AxiosError) => { const errorMessage = retrieveAxiosErrorMessage(error); displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC)); }; - const basicFormAction = (formData: FormData) => { - const providerDisplay = formData.get("llm-provider-input")?.toString(); - const provider = providerDisplay - ? getProviderId(providerDisplay) - : undefined; - const model = formData.get("llm-model-input")?.toString(); - const apiKey = formData.get("llm-api-key-input")?.toString(); - const searchApiKey = formData.get("search-api-key-input")?.toString(); - const confirmationMode = - formData.get("enable-confirmation-mode-switch")?.toString() === "on"; - const securityAnalyzer = formData - .get("security-analyzer-input") - ?.toString(); + const handleSave = () => { + if (!schema) { + return; + } - const fullLlmModel = provider && model && `${provider}/${model}`; + const payload = buildSdkSettingsPayload(schema, values, dirty); + if (Object.keys(payload).length === 0) { + return; + } - // Use OpenHands-managed key for OpenHands provider in SaaS mode - const finalApiKey = shouldUseOpenHandsKey ? null : apiKey; - - saveSettings( - { - llm_model: fullLlmModel, - llm_api_key: finalApiKey || null, - search_api_key: searchApiKey || "", - confirmation_mode: confirmationMode, - security_analyzer: - securityAnalyzer === "none" - ? null - : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer, - - // reset advanced settings - llm_base_url: DEFAULT_SETTINGS.llm_base_url, - agent: DEFAULT_SETTINGS.agent, - enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser, - }, - { - onSuccess: handleSuccessfulMutation, - onError: handleErrorMutation, + saveSettings(payload, { + onError: handleError, + onSuccess: () => { + displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING)); + setDirty({}); }, + }); + }; + + if (isLoading || isFetching) { + return ; + } + + if (!schema) { + return ( + + {t(I18nKey.SETTINGS$SDK_SCHEMA_UNAVAILABLE)} + ); - }; + } - const advancedFormAction = (formData: FormData) => { - const model = formData.get("llm-custom-model-input")?.toString(); - const baseUrl = formData.get("base-url-input")?.toString(); - const apiKey = formData.get("llm-api-key-input")?.toString(); - const searchApiKey = formData.get("search-api-key-input")?.toString(); - const agent = formData.get("agent-input")?.toString(); - const confirmationMode = - formData.get("enable-confirmation-mode-switch")?.toString() === "on"; - const enableDefaultCondenser = - formData.get("enable-memory-condenser-switch")?.toString() === "on"; - const condenserMaxSizeStr = formData - .get("condenser-max-size-input") - ?.toString(); - const condenserMaxSizeRaw = condenserMaxSizeStr - ? Number.parseInt(condenserMaxSizeStr, 10) - : undefined; - const condenserMaxSize = - condenserMaxSizeRaw !== undefined - ? Math.max(20, condenserMaxSizeRaw) - : undefined; - - const securityAnalyzer = formData - .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: finalApiKey || null, - search_api_key: searchApiKey || "", - agent, - confirmation_mode: confirmationMode, - enable_default_condenser: enableDefaultCondenser, - condenser_max_size: - condenserMaxSize ?? DEFAULT_SETTINGS.condenser_max_size, - security_analyzer: - securityAnalyzer === "none" - ? null - : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer, - }, - { - onSuccess: handleSuccessfulMutation, - onError: handleErrorMutation, - }, - ); - }; - - const handleToggleAdvancedSettings = (isToggled: boolean) => { - setView(isToggled ? "advanced" : "basic"); - setDirtyInputs({ - model: false, - apiKey: false, - searchApiKey: false, - baseUrl: false, - agent: false, - confirmationMode: false, - enableDefaultCondenser: false, - securityAnalyzer: false, - condenserMaxSize: false, - }); - }; - - 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/", ""); - setDirtyInputs((prev) => ({ - ...prev, - model: modelIsDirty, - })); - - // 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) => { - const apiKeyIsDirty = apiKey !== ""; - setDirtyInputs((prev) => ({ - ...prev, - apiKey: apiKeyIsDirty, - })); - }; - - const handleSearchApiKeyIsDirty = (searchApiKey: string) => { - const searchApiKeyIsDirty = searchApiKey !== settings?.search_api_key; - setDirtyInputs((prev) => ({ - ...prev, - searchApiKey: searchApiKeyIsDirty, - })); - }; - - const handleCustomModelIsDirty = (model: string) => { - const modelIsDirty = model !== settings?.llm_model && model !== ""; - setDirtyInputs((prev) => ({ - ...prev, - model: modelIsDirty, - })); - - // Track the currently selected model for help text display - setCurrentSelectedModel(model); - }; - - const handleBaseUrlIsDirty = (baseUrl: string) => { - const baseUrlIsDirty = baseUrl !== settings?.llm_base_url; - setDirtyInputs((prev) => ({ - ...prev, - baseUrl: baseUrlIsDirty, - })); - }; - - const handleAgentIsDirty = (agent: string) => { - const agentIsDirty = agent !== settings?.agent && agent !== ""; - setDirtyInputs((prev) => ({ - ...prev, - agent: agentIsDirty, - })); - }; - - const handleConfirmationModeIsDirty = (isToggled: boolean) => { - const confirmationModeIsDirty = isToggled !== settings?.confirmation_mode; - setDirtyInputs((prev) => ({ - ...prev, - confirmationMode: confirmationModeIsDirty, - })); - setConfirmationModeEnabled(isToggled); - - // When confirmation mode is enabled, set default security analyzer to "llm" if not already set - if (isToggled && !selectedSecurityAnalyzer) { - setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.security_analyzer); - setDirtyInputs((prev) => ({ - ...prev, - securityAnalyzer: true, - })); - } - }; - - const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => { - const enableDefaultCondenserIsDirty = - isToggled !== settings?.enable_default_condenser; - setDirtyInputs((prev) => ({ - ...prev, - enableDefaultCondenser: enableDefaultCondenserIsDirty, - })); - }; - - const handleCondenserMaxSizeIsDirty = (value: string) => { - const parsed = value ? Number.parseInt(value, 10) : undefined; - const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined; - const condenserMaxSizeIsDirty = - (bounded ?? DEFAULT_SETTINGS.condenser_max_size) !== - (settings?.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size); - setDirtyInputs((prev) => ({ - ...prev, - condenserMaxSize: condenserMaxSizeIsDirty, - })); - }; - - const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => { - const securityAnalyzerIsDirty = - securityAnalyzer !== settings?.security_analyzer; - setDirtyInputs((prev) => ({ - ...prev, - securityAnalyzer: securityAnalyzerIsDirty, - })); - }; - - const formIsDirty = Object.values(dirtyInputs).some((isDirty) => isDirty); - - const getSecurityAnalyzerOptions = () => { - const analyzers = resources?.securityAnalyzers || []; - const orderedItems = []; - - // Add LLM analyzer first - if (analyzers.includes("llm")) { - orderedItems.push({ - key: "llm", - label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT), - }); - } - - // Add None option second - orderedItems.push({ - key: "none", - label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_NONE), - }); - - if (isV1Enabled) { - return orderedItems; - } - - // Add Invariant analyzer third - if (analyzers.includes("invariant")) { - orderedItems.push({ - key: "invariant", - label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_INVARIANT), - }); - } - - // Add any other analyzers that might exist - analyzers.forEach((analyzer) => { - if (!["llm", "invariant", "none"].includes(analyzer)) { - // For unknown analyzers, use the analyzer name as fallback - // In the future, add specific i18n keys for new analyzers - orderedItems.push({ - key: analyzer, - label: analyzer, // TODO: Add i18n support for new analyzers - }); - } - }); - - return orderedItems; - }; - - if (!settings || isFetching) return ; - - const formAction = (formData: FormData) => { - if (view === "basic") basicFormAction(formData); - else advancedFormAction(formData); - }; + if (Object.keys(values).length === 0) { + return ; + } return (
-
-
- - {t(I18nKey.SETTINGS$ADVANCED)} - +
+ setView("basic")} + > + {t(I18nKey.SETTINGS$BASIC)} + + setView("advanced")} + > + {t(I18nKey.SETTINGS$ADVANCED)} + +
- {view === "basic" && ( -
- {!isLoading && !isFetching && ( - <> - - {(settings.llm_model?.startsWith("openhands/") || - currentSelectedModel?.startsWith("openhands/")) && ( - - )} - - )} - - {!shouldUseOpenHandsKey && ( - <> - " : ""} - onChange={handleApiKeyIsDirty} - startContent={ - settings.llm_api_key_set && ( - - ) - } - /> - - - - )} -
- )} - - {view === "advanced" && ( -
- - {(settings.llm_model?.startsWith("openhands/") || - currentSelectedModel?.startsWith("openhands/")) && ( - - )} - - - - {!shouldUseOpenHandsKey && ( - <> - " : ""} - onChange={handleApiKeyIsDirty} - startContent={ - settings.llm_api_key_set && ( - - ) - } - /> - - - )} - - {config?.app_mode !== "saas" && ( - <> - - ) - } - /> - - - - {!isV1Enabled && ( - ({ - key: agent, - label: agent, // TODO: Add i18n support for agent names - })) || [] - } - defaultSelectedKey={settings.agent} - isClearable={false} - onInputChange={handleAgentIsDirty} - wrapperClassName="w-full max-w-[680px]" - /> - )} - - )} - -
- handleCondenserMaxSizeIsDirty(value)} - isDisabled={!settings.enable_default_condenser} - className="w-full max-w-[680px] capitalize" +
+ {visibleSections.map((section) => ( +
+ {section.label} +
+ {section.fields.map((field) => ( + + handleFieldChange(field.key, nextValue) + } /> -

- {t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)} -

-
- - - {t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)} - - - {/* Confirmation mode and security analyzer */} -
- - {t(I18nKey.SETTINGS$CONFIRMATION_MODE)} - - - - - - -
- - {confirmationModeEnabled && ( - <> -
- { - const newValue = key?.toString() || ""; - setSelectedSecurityAnalyzer(newValue); - handleSecurityAnalyzerIsDirty(newValue); - }} - onInputChange={(value) => { - // Handle when input is cleared - if (!value) { - setSelectedSecurityAnalyzer(""); - handleSecurityAnalyzerIsDirty(""); - } - }} - wrapperClassName="w-full" - /> - {/* Hidden input to store the actual key value for form submission */} - -
-

- {t(I18nKey.SETTINGS$SECURITY_ANALYZER_DESCRIPTION)} -

- - )} + ))}
- )} -
+ + ))} +
-
- - {!isPending && t("SETTINGS$SAVE_CHANGES")} - {isPending && t("SETTINGS$SAVING")} - -
- +
+ + {isPending ? "Loading..." : t(I18nKey.SETTINGS$SAVE_CHANGES)} + +
); } diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts index 939912ea65..5ac99ccce7 100644 --- a/frontend/src/services/settings.ts +++ b/frontend/src/services/settings.ts @@ -30,6 +30,7 @@ export const DEFAULT_SETTINGS: Settings = { stdio_servers: [], shttp_servers: [], }, + sdk_settings_values: {}, git_user_name: "openhands", git_user_email: "openhands@all-hands.dev", v1_enabled: false, diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 5ff35ac04e..7f2ce28153 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -44,17 +44,22 @@ export type SettingsChoice = { value: string; }; +export type SettingsValue = boolean | number | string | null; + export type SettingsFieldSchema = { key: string; label: string; description?: string | null; - widget: string; + widget: "text" | "password" | "number" | "boolean" | "select"; section: string; section_label: string; order: number; + default?: boolean | number | string | null; + placeholder?: string | null; choices: SettingsChoice[]; depends_on: string[]; advanced: boolean; + help_text?: string | null; secret: boolean; required: boolean; slash_command?: string | null; @@ -63,7 +68,6 @@ export type SettingsFieldSchema = { export type SettingsSectionSchema = { key: string; label: string; - order: number; fields: SettingsFieldSchema[]; }; @@ -101,4 +105,5 @@ export type Settings = { git_user_email?: string; v1_enabled?: boolean; sdk_settings_schema?: SettingsSchema | null; + sdk_settings_values?: Record | null; }; diff --git a/frontend/src/utils/sdk-settings-schema.test.ts b/frontend/src/utils/sdk-settings-schema.test.ts new file mode 100644 index 0000000000..656caf7e80 --- /dev/null +++ b/frontend/src/utils/sdk-settings-schema.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; + +import { + buildInitialSettingsFormValues, + buildSdkSettingsPayload, + getVisibleSettingsSections, + hasAdvancedSettingsOverrides, +} from "./sdk-settings-schema"; +import { Settings } from "#/types/settings"; + +const BASE_SETTINGS: Settings = { + agent: "CodeActAgent", + condenser_max_size: 240, + confirmation_mode: false, + email: "", + email_verified: true, + enable_default_condenser: true, + enable_proactive_conversation_starters: false, + enable_solvability_analysis: false, + enable_sound_notifications: false, + git_user_email: "openhands@all-hands.dev", + git_user_name: "openhands", + language: "en", + llm_api_key: null, + llm_api_key_set: false, + llm_base_url: "", + llm_model: "openai/gpt-4o", + max_budget_per_task: null, + provider_tokens_set: {}, + remote_runtime_resource_factor: 1, + search_api_key: "", + search_api_key_set: false, + sdk_settings_schema: { + model_name: "SDKSettings", + sections: [ + { + key: "llm", + label: "LLM", + fields: [ + { + key: "llm_model", + label: "Model", + widget: "text", + section: "llm", + section_label: "LLM", + order: 10, + default: "claude-sonnet-4-20250514", + choices: [], + depends_on: [], + advanced: false, + secret: false, + required: true, + }, + { + key: "llm_api_key", + label: "API key", + widget: "password", + section: "llm", + section_label: "LLM", + order: 20, + default: null, + choices: [], + depends_on: [], + advanced: false, + secret: true, + required: false, + }, + ], + }, + { + key: "critic", + label: "Critic", + fields: [ + { + key: "enable_critic", + label: "Enable critic", + widget: "boolean", + section: "critic", + section_label: "Critic", + order: 10, + default: false, + choices: [], + depends_on: [], + advanced: false, + secret: false, + required: true, + }, + { + key: "critic_mode", + label: "Critic mode", + widget: "select", + section: "critic", + section_label: "Critic", + order: 20, + default: "finish_and_message", + choices: [ + { label: "finish_and_message", value: "finish_and_message" }, + { label: "all_actions", value: "all_actions" }, + ], + depends_on: ["enable_critic"], + advanced: true, + secret: false, + required: true, + }, + ], + }, + ], + }, + sdk_settings_values: { + critic_mode: "finish_and_message", + enable_critic: false, + llm_model: "openai/gpt-4o", + }, + security_analyzer: null, + user_consents_to_analytics: false, + v1_enabled: false, +}; + +describe("sdk settings schema helpers", () => { + it("builds initial form values from the current settings", () => { + expect(buildInitialSettingsFormValues(BASE_SETTINGS)).toEqual({ + critic_mode: "finish_and_message", + enable_critic: false, + llm_api_key: "", + llm_model: "openai/gpt-4o", + }); + }); + + it("detects advanced overrides from non-default values", () => { + expect(hasAdvancedSettingsOverrides(BASE_SETTINGS)).toBe(false); + + expect( + hasAdvancedSettingsOverrides({ + ...BASE_SETTINGS, + sdk_settings_values: { + ...BASE_SETTINGS.sdk_settings_values, + critic_mode: "all_actions", + }, + }), + ).toBe(true); + }); + + it("filters advanced and dependent fields based on current values", () => { + const values = buildInitialSettingsFormValues(BASE_SETTINGS); + + expect( + getVisibleSettingsSections( + BASE_SETTINGS.sdk_settings_schema!, + values, + false, + ), + ).toEqual([ + { + key: "llm", + label: "LLM", + fields: BASE_SETTINGS.sdk_settings_schema!.sections[0].fields, + }, + { + key: "critic", + label: "Critic", + fields: [BASE_SETTINGS.sdk_settings_schema!.sections[1].fields[0]], + }, + ]); + + expect( + getVisibleSettingsSections( + BASE_SETTINGS.sdk_settings_schema!, + { ...values, enable_critic: true }, + true, + )[1].fields, + ).toHaveLength(2); + }); + + it("builds a typed payload from dirty schema values", () => { + const payload = buildSdkSettingsPayload( + BASE_SETTINGS.sdk_settings_schema!, + { + ...buildInitialSettingsFormValues(BASE_SETTINGS), + enable_critic: true, + llm_api_key: "new-key", + }, + { + enable_critic: true, + llm_api_key: true, + llm_model: false, + }, + ); + + expect(payload).toEqual({ + enable_critic: true, + llm_api_key: "new-key", + }); + }); +}); diff --git a/frontend/src/utils/sdk-settings-schema.ts b/frontend/src/utils/sdk-settings-schema.ts new file mode 100644 index 0000000000..79bc01001c --- /dev/null +++ b/frontend/src/utils/sdk-settings-schema.ts @@ -0,0 +1,169 @@ +import { + Settings, + SettingsFieldSchema, + SettingsSchema, + SettingsSectionSchema, + SettingsValue, +} from "#/types/settings"; + +export type SettingsFormValues = Record; +export type SettingsDirtyState = Record; + +function getSchemaFields(schema: SettingsSchema): SettingsFieldSchema[] { + return schema.sections.flatMap((section) => section.fields); +} + +export type SdkSettingsPayload = Record; + +function getCurrentSettingValue( + settings: Settings, + key: string, +): SettingsValue { + return settings.sdk_settings_values?.[key] ?? null; +} + +function normalizeFieldValue( + field: SettingsFieldSchema, + rawValue: unknown, +): string | boolean { + if (field.widget === "boolean") { + return Boolean(rawValue ?? field.default ?? false); + } + + const resolvedValue = rawValue ?? field.default; + if (resolvedValue === null || resolvedValue === undefined) { + return ""; + } + + return String(resolvedValue); +} + +function normalizeComparableValue( + field: SettingsFieldSchema, + rawValue: unknown, +): boolean | number | string | null { + if (rawValue === undefined) { + return null; + } + + if (field.widget === "boolean") { + return Boolean(rawValue); + } + + if (field.widget === "number") { + if (rawValue === "" || rawValue === null) { + return null; + } + + const parsedValue = + typeof rawValue === "number" ? rawValue : Number(String(rawValue)); + return Number.isNaN(parsedValue) ? null : parsedValue; + } + + if (rawValue === null) { + return null; + } + + return String(rawValue); +} + +export function buildInitialSettingsFormValues( + settings: Settings, +): SettingsFormValues { + const schema = settings.sdk_settings_schema; + if (!schema) { + return {}; + } + + return Object.fromEntries( + getSchemaFields(schema).map((field) => [ + field.key, + normalizeFieldValue(field, getCurrentSettingValue(settings, field.key)), + ]), + ); +} + +export function hasAdvancedSettingsOverrides(settings: Settings): boolean { + const schema = settings.sdk_settings_schema; + if (!schema) { + return false; + } + + return getSchemaFields(schema).some((field) => { + if (!field.advanced) { + return false; + } + + const currentValue = getCurrentSettingValue(settings, field.key); + + return ( + normalizeComparableValue(field, currentValue ?? field.default ?? null) !== + normalizeComparableValue(field, field.default ?? null) + ); + }); +} + +export function isSettingsFieldVisible( + field: SettingsFieldSchema, + values: SettingsFormValues, +): boolean { + return field.depends_on.every((dependency) => values[dependency] === true); +} + +function coerceFieldValue( + field: SettingsFieldSchema, + rawValue: string | boolean, +): boolean | number | string | null { + if (field.widget === "boolean") { + return Boolean(rawValue); + } + + if (field.widget === "number") { + const stringValue = String(rawValue).trim(); + if (!stringValue) { + return null; + } + + return Number(stringValue); + } + + const stringValue = String(rawValue); + if (stringValue === "" && field.widget !== "password") { + return null; + } + + return stringValue; +} + +export function buildSdkSettingsPayload( + schema: SettingsSchema, + values: SettingsFormValues, + dirty: SettingsDirtyState, +): SdkSettingsPayload { + const payload: SdkSettingsPayload = {}; + + for (const field of getSchemaFields(schema)) { + if (dirty[field.key]) { + payload[field.key] = coerceFieldValue(field, values[field.key]); + } + } + + return payload; +} + +export function getVisibleSettingsSections( + schema: SettingsSchema, + values: SettingsFormValues, + showAdvanced: boolean, +): SettingsSectionSchema[] { + return schema.sections + .map((section) => ({ + ...section, + fields: section.fields.filter( + (field) => + (showAdvanced || !field.advanced) && + isSettingsFieldVisible(field, values), + ), + })) + .filter((section) => section.fields.length > 0); +} diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index f6489b75c6..46951ef54e 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -49,6 +49,70 @@ def _get_sdk_settings_schema() -> dict[str, Any] | None: return settings_module.SDKSettings.export_schema().model_dump(mode='json') +def _get_sdk_field_keys(schema: dict[str, Any] | None) -> set[str]: + if not schema: + return set() + + return { + field['key'] + for section in schema.get('sections', []) + for field in section.get('fields', []) + } + + +def _get_sdk_secret_field_keys(schema: dict[str, Any] | None) -> set[str]: + if not schema: + return set() + + return { + field['key'] + for section in schema.get('sections', []) + for field in section.get('fields', []) + if field.get('secret') + } + + +def _extract_sdk_settings_values( + settings: Settings, schema: dict[str, Any] | None +) -> dict[str, bool | float | int | str | None]: + values = dict(settings.sdk_settings_values) + secret_field_keys = _get_sdk_secret_field_keys(schema) + + for field_key in _get_sdk_field_keys(schema): + if field_key in secret_field_keys: + values[field_key] = None + continue + if field_key in values: + continue + if field_key not in Settings.model_fields: + continue + + values[field_key] = getattr(settings, field_key) + + return values + + +def _apply_settings_payload( + payload: dict[str, Any], + existing_settings: Settings | None, + sdk_schema: dict[str, Any] | None, +) -> Settings: + settings = existing_settings.model_copy() if existing_settings else Settings() + + sdk_field_keys = _get_sdk_field_keys(sdk_schema) + secret_field_keys = _get_sdk_secret_field_keys(sdk_schema) + sdk_settings_values = dict(settings.sdk_settings_values) + + for key, value in payload.items(): + if key in Settings.model_fields: + setattr(settings, key, value) + if key in sdk_field_keys and key not in secret_field_keys: + sdk_settings_values[key] = value + + settings.sdk_settings_values = sdk_settings_values + return settings + + app = APIRouter(prefix='/api', dependencies=get_dependencies()) @@ -89,14 +153,18 @@ async def load_settings( if provider_token.token or provider_token.user_id: provider_tokens_set[provider_type] = provider_token.host + sdk_settings_schema = _get_sdk_settings_schema() settings_with_token_data = GETSettingsModel( - **settings.model_dump(exclude={'secrets_store'}), + **settings.model_dump(exclude={'secrets_store', 'sdk_settings_values'}), llm_api_key_set=settings.llm_api_key is not None and bool(settings.llm_api_key), search_api_key_set=settings.search_api_key is not None and bool(settings.search_api_key), provider_tokens_set=provider_tokens_set, - sdk_settings_schema=_get_sdk_settings_schema(), + sdk_settings_schema=sdk_settings_schema, + sdk_settings_values=_extract_sdk_settings_values( + settings, sdk_settings_schema + ), ) # If the base url matches the default for the provider, we don't send it @@ -109,6 +177,10 @@ async def load_settings( ): settings_with_token_data.llm_base_url = None + settings_with_token_data.sdk_settings_values['llm_base_url'] = ( + settings_with_token_data.llm_base_url + ) + settings_with_token_data.llm_api_key = None settings_with_token_data.search_api_key = None settings_with_token_data.sandbox_api_key = None @@ -178,14 +250,17 @@ async def store_llm_settings( }, ) async def store_settings( - settings: Settings, + payload: dict[str, Any], settings_store: SettingsStore = Depends(get_user_settings_store), ) -> JSONResponse: # Check provider tokens are valid try: existing_settings = await settings_store.load() + sdk_settings_schema = _get_sdk_settings_schema() + settings = _apply_settings_payload( + payload, existing_settings, sdk_settings_schema + ) - # Convert to Settings model and merge with existing settings if existing_settings: settings = await store_llm_settings(settings, existing_settings) @@ -217,7 +292,6 @@ async def store_settings( f'Updated global git configuration: name={config.git_user_name}, email={config.git_user_email}' ) - settings = convert_to_settings(settings) await settings_store.store(settings) return JSONResponse( status_code=status.HTTP_200_OK, @@ -229,22 +303,3 @@ async def store_settings( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={'error': 'Something went wrong storing settings'}, ) - - -def convert_to_settings(settings_with_token_data: Settings) -> Settings: - settings_data = settings_with_token_data.model_dump() - - # Filter out additional fields from `SettingsWithTokenData` - filtered_settings_data = { - key: value - for key, value in settings_data.items() - if key in Settings.model_fields # Ensures only `Settings` fields are included - } - - # Convert the API keys to `SecretStr` instances - filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key - filtered_settings_data['search_api_key'] = settings_with_token_data.search_api_key - - # Create a new Settings instance - settings = Settings(**filtered_settings_data) - return settings diff --git a/openhands/storage/data_models/settings.py b/openhands/storage/data_models/settings.py index 1600acd3ad..ce992c39fe 100644 --- a/openhands/storage/data_models/settings.py +++ b/openhands/storage/data_models/settings.py @@ -54,6 +54,9 @@ class Settings(BaseModel): git_user_name: str | None = None git_user_email: str | None = None v1_enabled: bool = True + sdk_settings_values: dict[str, bool | float | int | str | None] = Field( + default_factory=dict + ) model_config = ConfigDict( validate_assignment=True, diff --git a/tests/unit/server/routes/test_settings_api.py b/tests/unit/server/routes/test_settings_api.py index 6ebc426e79..acdccbc65b 100644 --- a/tests/unit/server/routes/test_settings_api.py +++ b/tests/unit/server/routes/test_settings_api.py @@ -92,7 +92,13 @@ async def test_settings_api_endpoints(test_client): 'llm_model': 'test-model', 'llm_api_key': 'test-key', 'llm_base_url': 'https://test.com', + 'llm_timeout': 123, 'remote_runtime_resource_factor': 2, + 'enable_critic': True, + 'critic_mode': 'all_actions', + 'enable_iterative_refinement': True, + 'critic_threshold': 0.7, + 'max_refinement_iterations': 4, } # Make the POST request to store settings @@ -104,7 +110,15 @@ async def test_settings_api_endpoints(test_client): # Test the GET settings endpoint response = test_client.get('/api/settings') assert response.status_code == 200 - assert response.json()['sdk_settings_schema']['model_name'] == 'SDKSettings' + response_data = response.json() + assert response_data['sdk_settings_schema']['model_name'] == 'SDKSettings' + assert response_data['sdk_settings_values']['llm_timeout'] == 123 + assert response_data['sdk_settings_values']['enable_critic'] is True + assert response_data['sdk_settings_values']['critic_mode'] == 'all_actions' + assert response_data['sdk_settings_values']['enable_iterative_refinement'] is True + assert response_data['sdk_settings_values']['critic_threshold'] == 0.7 + assert response_data['sdk_settings_values']['max_refinement_iterations'] == 4 + assert response_data['sdk_settings_values']['llm_api_key'] is None # Test updating with partial settings partial_settings = { @@ -116,6 +130,10 @@ async def test_settings_api_endpoints(test_client): response = test_client.post('/api/settings', json=partial_settings) assert response.status_code == 200 + response = test_client.get('/api/settings') + assert response.status_code == 200 + assert response.json()['sdk_settings_values']['llm_timeout'] == 123 + # Test the unset-provider-tokens endpoint response = test_client.post('/api/unset-provider-tokens') assert response.status_code == 200