mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
settings: use generic sdk settings values in OpenHands
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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<Settings>): Promise<boolean> {
|
||||
static async saveSettings(
|
||||
settings: Partial<Settings> & Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
@@ -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<Settings>) => {
|
||||
const settingsToSave: Partial<Settings> = {
|
||||
type SettingsUpdate = Partial<Settings> & Record<string, unknown>;
|
||||
|
||||
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<Settings>) => {
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
mutationFn: async (settings: SettingsUpdate) => {
|
||||
const newSettings: SettingsUpdate = { ...currentSettings, ...settings };
|
||||
|
||||
// Track MCP configuration changes
|
||||
if (
|
||||
|
||||
@@ -18,6 +18,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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令牌",
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<HelpLink
|
||||
testId={testId}
|
||||
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
|
||||
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
|
||||
href="https://app.all-hands.dev/settings/api-keys"
|
||||
suffix={` ${t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}`}
|
||||
/>
|
||||
<p className="text-xs">
|
||||
{t(I18nKey.SETTINGS$LLM_BILLING_INFO)}{" "}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/llms/openhands-llms"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
<div className="flex flex-col gap-1">
|
||||
{field.description ? (
|
||||
<Typography.Paragraph className="text-tertiary-alt text-xs leading-5">
|
||||
{field.description}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{field.help_text ? (
|
||||
<Typography.Paragraph className="text-tertiary-alt text-xs leading-5">
|
||||
{field.help_text}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: SettingsFieldSchema;
|
||||
value: string | boolean;
|
||||
onChange: (value: string | boolean) => void;
|
||||
}) {
|
||||
if (field.widget === "boolean") {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SettingsSwitch
|
||||
testId={`sdk-settings-${field.key}`}
|
||||
isToggled={Boolean(value)}
|
||||
onToggle={onChange}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SEE_PRICING_DETAILS)}
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
{field.label}
|
||||
</SettingsSwitch>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.widget === "select") {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SettingsDropdownInput
|
||||
testId={`sdk-settings-${field.key}`}
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
items={field.choices.map((choice) => ({
|
||||
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 ?? ""))
|
||||
}
|
||||
/>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SettingsInput
|
||||
testId={`sdk-settings-${field.key}`}
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
type={field.widget === "number" ? "number" : field.widget}
|
||||
value={String(value ?? "")}
|
||||
placeholder={field.placeholder ?? undefined}
|
||||
required={field.required}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
/>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<SettingsFormValues>({});
|
||||
const [dirty, setDirty] = React.useState<SettingsDirtyState>({});
|
||||
|
||||
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<string | null>(
|
||||
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 <LlmSettingsInputsSkeleton />;
|
||||
}
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<Typography.Paragraph className="text-tertiary-alt">
|
||||
{t(I18nKey.SETTINGS$SDK_SCHEMA_UNAVAILABLE)}
|
||||
</Typography.Paragraph>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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 <LlmSettingsInputsSkeleton />;
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
if (view === "basic") basicFormAction(formData);
|
||||
else advancedFormAction(formData);
|
||||
};
|
||||
if (Object.keys(values).length === 0) {
|
||||
return <LlmSettingsInputsSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="llm-settings-screen" className="h-full relative">
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={view === "advanced"}
|
||||
onToggle={handleToggleAdvancedSettings}
|
||||
isToggled={view === "advanced"}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<BrandButton
|
||||
testId="llm-settings-basic-toggle"
|
||||
variant={view === "basic" ? "primary" : "secondary"}
|
||||
type="button"
|
||||
onClick={() => setView("basic")}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$BASIC)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="llm-settings-advanced-toggle"
|
||||
variant={view === "advanced" ? "primary" : "secondary"}
|
||||
type="button"
|
||||
onClick={() => setView("advanced")}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
{view === "basic" && (
|
||||
<div
|
||||
data-testid="llm-settings-form-basic"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{!isLoading && !isFetching && (
|
||||
<>
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.llm_model || DEFAULT_OPENHANDS_MODEL}
|
||||
onChange={handleModelIsDirty}
|
||||
onDefaultValuesChanged={onDefaultValuesChanged}
|
||||
wrapperClassName="!flex-col !gap-6"
|
||||
/>
|
||||
{(settings.llm_model?.startsWith("openhands/") ||
|
||||
currentSelectedModel?.startsWith("openhands/")) && (
|
||||
<OpenHandsApiKeyHelp testId="openhands-api-key-help" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!shouldUseOpenHandsKey && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder={settings.llm_api_key_set ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
startContent={
|
||||
settings.llm_api_key_set && (
|
||||
<KeyStatusIcon isSet={settings.llm_api_key_set} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "advanced" && (
|
||||
<div
|
||||
data-testid="llm-settings-form-advanced"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
|
||||
defaultValue={settings.llm_model || DEFAULT_OPENHANDS_MODEL}
|
||||
placeholder={DEFAULT_OPENHANDS_MODEL}
|
||||
type="text"
|
||||
className="w-full max-w-[680px]"
|
||||
onChange={handleCustomModelIsDirty}
|
||||
/>
|
||||
{(settings.llm_model?.startsWith("openhands/") ||
|
||||
currentSelectedModel?.startsWith("openhands/")) && (
|
||||
<OpenHandsApiKeyHelp testId="openhands-api-key-help-2" />
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label={t(I18nKey.SETTINGS$BASE_URL)}
|
||||
defaultValue={settings.llm_base_url}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-full max-w-[680px]"
|
||||
onChange={handleBaseUrlIsDirty}
|
||||
/>
|
||||
|
||||
{!shouldUseOpenHandsKey && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder={settings.llm_api_key_set ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
startContent={
|
||||
settings.llm_api_key_set && (
|
||||
<KeyStatusIcon isSet={settings.llm_api_key_set} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor-advanced"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/usage/local-setup#getting-an-api-key"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config?.app_mode !== "saas" && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="search-api-key-input"
|
||||
name="search-api-key-input"
|
||||
label={t(I18nKey.SETTINGS$SEARCH_API_KEY)}
|
||||
type="password"
|
||||
className="w-full max-w-[680px]"
|
||||
defaultValue={settings.search_api_key || ""}
|
||||
onChange={handleSearchApiKeyIsDirty}
|
||||
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
|
||||
startContent={
|
||||
settings.search_api_key_set && (
|
||||
<KeyStatusIcon isSet={settings.search_api_key_set} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="search-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
|
||||
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
|
||||
href="https://tavily.com/"
|
||||
/>
|
||||
|
||||
{!isV1Enabled && (
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label={t(I18nKey.SETTINGS$AGENT)}
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent, // TODO: Add i18n support for agent names
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.agent}
|
||||
isClearable={false}
|
||||
onInputChange={handleAgentIsDirty}
|
||||
wrapperClassName="w-full max-w-[680px]"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-[680px]">
|
||||
<SettingsInput
|
||||
testId="condenser-max-size-input"
|
||||
name="condenser-max-size-input"
|
||||
type="number"
|
||||
min={20}
|
||||
step={1}
|
||||
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
|
||||
defaultValue={(
|
||||
settings.condenser_max_size ??
|
||||
DEFAULT_SETTINGS.condenser_max_size
|
||||
)?.toString()}
|
||||
onChange={(value) => handleCondenserMaxSizeIsDirty(value)}
|
||||
isDisabled={!settings.enable_default_condenser}
|
||||
className="w-full max-w-[680px] capitalize"
|
||||
<div className="flex flex-col gap-8 pb-20">
|
||||
{visibleSections.map((section) => (
|
||||
<section key={section.key} className="flex flex-col gap-4">
|
||||
<Typography.H3>{section.label}</Typography.H3>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{section.fields.map((field) => (
|
||||
<SchemaField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(nextValue) =>
|
||||
handleFieldChange(field.key, nextValue)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-tertiary-alt mt-6">
|
||||
{t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-memory-condenser-switch"
|
||||
name="enable-memory-condenser-switch"
|
||||
defaultIsToggled={settings.enable_default_condenser}
|
||||
onToggle={handleEnableDefaultCondenserIsDirty}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
|
||||
</SettingsSwitch>
|
||||
|
||||
{/* Confirmation mode and security analyzer */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
name="enable-confirmation-mode-switch"
|
||||
onToggle={handleConfirmationModeIsDirty}
|
||||
defaultIsToggled={settings.confirmation_mode}
|
||||
isBeta
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
<StyledTooltip
|
||||
content={t(I18nKey.SETTINGS$CONFIRMATION_MODE_TOOLTIP)}
|
||||
>
|
||||
<span className="text-[#9099AC] hover:text-white cursor-help">
|
||||
<QuestionCircleIcon width={16} height={16} />
|
||||
</span>
|
||||
</StyledTooltip>
|
||||
</div>
|
||||
|
||||
{confirmationModeEnabled && (
|
||||
<>
|
||||
<div className="w-full max-w-[680px]">
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-display"
|
||||
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
|
||||
items={getSecurityAnalyzerOptions()}
|
||||
placeholder={t(
|
||||
I18nKey.SETTINGS$SECURITY_ANALYZER_PLACEHOLDER,
|
||||
)}
|
||||
selectedKey={selectedSecurityAnalyzer || "none"}
|
||||
isClearable={false}
|
||||
onSelectionChange={(key) => {
|
||||
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 */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="security-analyzer-input"
|
||||
value={selectedSecurityAnalyzer || ""}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-tertiary-alt max-w-[680px]">
|
||||
{t(I18nKey.SETTINGS$SECURITY_ANALYZER_DESCRIPTION)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!formIsDirty || isPending}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
<div className="sticky bottom-0 bg-base py-4">
|
||||
<BrandButton
|
||||
testId="save-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
isDisabled={isPending || Object.keys(dirty).length === 0}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isPending ? "Loading..." : t(I18nKey.SETTINGS$SAVE_CHANGES)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, SettingsValue> | null;
|
||||
};
|
||||
|
||||
194
frontend/src/utils/sdk-settings-schema.test.ts
Normal file
194
frontend/src/utils/sdk-settings-schema.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
169
frontend/src/utils/sdk-settings-schema.ts
Normal file
169
frontend/src/utils/sdk-settings-schema.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
Settings,
|
||||
SettingsFieldSchema,
|
||||
SettingsSchema,
|
||||
SettingsSectionSchema,
|
||||
SettingsValue,
|
||||
} from "#/types/settings";
|
||||
|
||||
export type SettingsFormValues = Record<string, string | boolean>;
|
||||
export type SettingsDirtyState = Record<string, boolean>;
|
||||
|
||||
function getSchemaFields(schema: SettingsSchema): SettingsFieldSchema[] {
|
||||
return schema.sections.flatMap((section) => section.fields);
|
||||
}
|
||||
|
||||
export type SdkSettingsPayload = Record<string, SettingsValue>;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user