settings: use generic sdk settings values in OpenHands

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-03-08 20:42:35 +00:00
parent 424f6b30d1
commit ad615ebc8b
14 changed files with 729 additions and 752 deletions

View File

@@ -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`.

View File

@@ -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;
}

View File

@@ -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 (

View File

@@ -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,
};
};

View File

@@ -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",

View File

@@ -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令牌",

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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;
};

View 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",
});
});
});

View 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);
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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