Add max_budget_per_task to settings (#8812)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Rohit Malhotra 2025-06-18 20:25:01 -04:00 committed by GitHub
parent 54af9ff3fe
commit b7a6190133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 323 additions and 4 deletions

View File

@ -23,6 +23,7 @@ describe("ConversationPanel", () => {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
},
@ -273,6 +274,7 @@ describe("ConversationPanel", () => {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
},

View File

@ -0,0 +1,34 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BudgetProgressBar } from "./budget-progress-bar";
import { BudgetUsageText } from "./budget-usage-text";
interface BudgetDisplayProps {
cost: number | null;
maxBudgetPerTask: number | null;
}
export function BudgetDisplay({ cost, maxBudgetPerTask }: BudgetDisplayProps) {
const { t } = useTranslation();
// Don't render anything if cost is not available
if (cost === null) {
return null;
}
return (
<div className="border-b border-neutral-700">
{maxBudgetPerTask !== null && maxBudgetPerTask > 0 ? (
<>
<BudgetProgressBar currentCost={cost} maxBudget={maxBudgetPerTask} />
<BudgetUsageText currentCost={cost} maxBudget={maxBudgetPerTask} />
</>
) : (
<span className="text-xs text-neutral-400">
{t(I18nKey.CONVERSATION$NO_BUDGET_LIMIT)}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,27 @@
import React from "react";
interface BudgetProgressBarProps {
currentCost: number;
maxBudget: number;
}
export function BudgetProgressBar({
currentCost,
maxBudget,
}: BudgetProgressBarProps) {
const usagePercentage = (currentCost / maxBudget) * 100;
const isNearLimit = usagePercentage > 80;
return (
<div className="w-full h-1.5 bg-neutral-700 rounded-full overflow-hidden mt-1">
<div
className={`h-full transition-all duration-300 ${
isNearLimit ? "bg-red-500" : "bg-blue-500"
}`}
style={{
width: `${Math.min(100, usagePercentage)}%`,
}}
/>
</div>
);
}

View File

@ -0,0 +1,25 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface BudgetUsageTextProps {
currentCost: number;
maxBudget: number;
}
export function BudgetUsageText({
currentCost,
maxBudget,
}: BudgetUsageTextProps) {
const { t } = useTranslation();
const usagePercentage = (currentCost / maxBudget) * 100;
return (
<div className="flex justify-end">
<span className="text-xs text-neutral-400">
${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} (
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
</span>
</div>
);
}

View File

@ -9,6 +9,7 @@ import { EllipsisButton } from "./ellipsis-button";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import { SystemMessageModal } from "./system-message-modal";
import { MicroagentsModal } from "./microagents-modal";
import { BudgetDisplay } from "./budget-display";
import { cn } from "#/utils/utils";
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
import { RootState } from "#/store";
@ -285,7 +286,7 @@ export function ConversationCard({
<div className="rounded-md p-3">
<div className="grid gap-3">
{metrics?.cost !== null && (
<div className="flex justify-between items-center border-b border-neutral-700 pb-2">
<div className="flex justify-between items-center pb-2">
<span className="text-lg font-semibold">
{t(I18nKey.CONVERSATION$TOTAL_COST)}
</span>
@ -294,6 +295,10 @@ export function ConversationCard({
</span>
</div>
)}
<BudgetDisplay
cost={metrics?.cost ?? null}
maxBudgetPerTask={metrics?.max_budget_per_task ?? null}
/>
{metrics?.usage !== null && (
<>

View File

@ -26,6 +26,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
enable_proactive_conversation_starters:
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
};
await OpenHands.saveSettings(apiSettings);

View File

@ -27,6 +27,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
apiSettings.enable_proactive_conversation_starters,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
SEARCH_API_KEY: apiSettings.search_api_key || "",
MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task,
EMAIL: apiSettings.email || "",
EMAIL_VERIFIED: apiSettings.email_verified,
MCP_CONFIG: apiSettings.mcp_config,

View File

@ -121,6 +121,8 @@ export enum I18nKey {
SETTINGS$LLM_SETTINGS = "SETTINGS$LLM_SETTINGS",
SETTINGS$GIT_SETTINGS = "SETTINGS$GIT_SETTINGS",
SETTINGS$SOUND_NOTIFICATIONS = "SETTINGS$SOUND_NOTIFICATIONS",
SETTINGS$MAX_BUDGET_PER_TASK = "SETTINGS$MAX_BUDGET_PER_TASK",
SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION",
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY",
SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL",
@ -494,6 +496,9 @@ export enum I18nKey {
CONVERSATION$DOWNLOAD_ERROR = "CONVERSATION$DOWNLOAD_ERROR",
CONVERSATION$UPDATED = "CONVERSATION$UPDATED",
CONVERSATION$TOTAL_COST = "CONVERSATION$TOTAL_COST",
CONVERSATION$BUDGET = "CONVERSATION$BUDGET",
CONVERSATION$BUDGET_USAGE = "CONVERSATION$BUDGET_USAGE",
CONVERSATION$NO_BUDGET_LIMIT = "CONVERSATION$NO_BUDGET_LIMIT",
CONVERSATION$INPUT = "CONVERSATION$INPUT",
CONVERSATION$OUTPUT = "CONVERSATION$OUTPUT",
CONVERSATION$TOTAL = "CONVERSATION$TOTAL",

View File

@ -1935,6 +1935,38 @@
"tr": "Ses Bildirimleri",
"uk": "Звукові сповіщення"
},
"SETTINGS$MAX_BUDGET_PER_TASK": {
"en": "Maximum Budget Per Task",
"ja": "タスクごとの最大予算",
"zh-CN": "每个任务的最大预算",
"zh-TW": "每個任務的最大預算",
"ko-KR": "작업당 최대 예산",
"de": "Maximales Budget pro Aufgabe",
"no": "Maksimalt budsjett per oppgave",
"it": "Budget massimo per attività",
"pt": "Orçamento máximo por tarefa",
"es": "Presupuesto máximo por tarea",
"ar": "الميزانية القصوى لكل مهمة",
"fr": "Budget maximum par tâche",
"tr": "Görev Başına Maksimum Bütçe",
"uk": "Максимальний бюджет на завдання"
},
"SETTINGS$MAX_BUDGET_PER_CONVERSATION": {
"en": "Maximum Budget Per Conversation",
"ja": "会話ごとの最大予算",
"zh-CN": "每次对话的最大预算",
"zh-TW": "每次對話的最大預算",
"ko-KR": "대화당 최대 예산",
"de": "Maximales Budget pro Konversation",
"no": "Maksimalt budsjett per samtale",
"it": "Budget massimo per conversazione",
"pt": "Orçamento máximo por conversa",
"es": "Presupuesto máximo por conversación",
"ar": "الميزانية القصوى لكل محادثة",
"fr": "Budget maximum par conversation",
"tr": "Konuşma Başına Maksimum Bütçe",
"uk": "Максимальний бюджет на розмову"
},
"SETTINGS$PROACTIVE_CONVERSATION_STARTERS": {
"en": "Suggest Tasks on GitHub",
"ja": "GitHubでタスクを提案",
@ -7903,6 +7935,54 @@
"tr": "Toplam Maliyet",
"uk": "Загальна вартість"
},
"CONVERSATION$BUDGET": {
"en": "Budget",
"ja": "予算",
"zh-CN": "预算",
"zh-TW": "預算",
"ko-KR": "예산",
"de": "Budget",
"no": "Budsjett",
"it": "Budget",
"pt": "Orçamento",
"es": "Presupuesto",
"ar": "الميزانية",
"fr": "Budget",
"tr": "Bütçe",
"uk": "Бюджет"
},
"CONVERSATION$BUDGET_USAGE": {
"en": "% used",
"ja": "% 使用済み",
"zh-CN": "% 已使用",
"zh-TW": "% 已使用",
"ko-KR": "% 사용됨",
"de": "% verwendet",
"no": "% brukt",
"it": "% utilizzato",
"pt": "% utilizado",
"es": "% utilizado",
"ar": "% مستخدم",
"fr": "% utilisé",
"tr": "% kullanıldı",
"uk": "% використано"
},
"CONVERSATION$NO_BUDGET_LIMIT": {
"en": "No budget limit",
"ja": "予算制限なし",
"zh-CN": "无预算限制",
"zh-TW": "無預算限制",
"ko-KR": "예산 제한 없음",
"de": "Kein Budgetlimit",
"no": "Ingen budsjettgrense",
"it": "Nessun limite di budget",
"pt": "Sem limite de orçamento",
"es": "Sin límite de presupuesto",
"ar": "لا حد للميزانية",
"fr": "Pas de limite de budget",
"tr": "Bütçe limiti yok",
"uk": "Без обмеження бюджету"
},
"CONVERSATION$INPUT": {
"en": "- Input:",
"ja": "- 入力:",

View File

@ -30,6 +30,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
enable_proactive_conversation_starters:
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK,
};
const MOCK_USER_PREFERENCES: {

View File

@ -6,6 +6,7 @@ import { AvailableLanguages } from "#/i18n";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { I18nKey } from "#/i18n/declaration";
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
@ -16,6 +17,7 @@ import {
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
import { useConfig } from "#/hooks/query/use-config";
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
function AppSettingsScreen() {
const { t } = useTranslation();
@ -36,6 +38,8 @@ function AppSettingsScreen() {
proactiveConversationsSwitchHasChanged,
setProactiveConversationsSwitchHasChanged,
] = React.useState(false);
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
React.useState(false);
const formAction = (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
@ -53,12 +57,18 @@ function AppSettingsScreen() {
formData.get("enable-proactive-conversations-switch")?.toString() ===
"on";
const maxBudgetPerTaskValue = formData
.get("max-budget-per-task-input")
?.toString();
const maxBudgetPerTask = parseMaxBudgetPerTask(maxBudgetPerTaskValue || "");
saveSettings(
{
LANGUAGE: language,
user_consents_to_analytics: enableAnalytics,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
MAX_BUDGET_PER_TASK: maxBudgetPerTask,
},
{
onSuccess: () => {
@ -74,6 +84,7 @@ function AppSettingsScreen() {
setAnalyticsSwitchHasChanged(false);
setSoundNotificationsSwitchHasChanged(false);
setProactiveConversationsSwitchHasChanged(false);
setMaxBudgetPerTaskHasChanged(false);
},
},
);
@ -110,11 +121,18 @@ function AppSettingsScreen() {
);
};
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
const newValue = parseMaxBudgetPerTask(value);
const currentValue = settings?.MAX_BUDGET_PER_TASK;
setMaxBudgetPerTaskHasChanged(newValue !== currentValue);
};
const formIsClean =
!languageInputHasChanged &&
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged &&
!proactiveConversationsSwitchHasChanged;
!proactiveConversationsSwitchHasChanged &&
!maxBudgetPerTaskHasChanged;
const shouldBeLoading = !settings || isLoading || isPending;
@ -163,6 +181,19 @@ function AppSettingsScreen() {
{t(I18nKey.SETTINGS$PROACTIVE_CONVERSATION_STARTERS)}
</SettingsSwitch>
)}
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
type="number"
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
onChange={checkIfMaxBudgetPerTaskHasChanged}
placeholder="Maximum budget per conversation in USD"
min={1}
step={1}
className="w-[680px]" // Match the width of the language field
/>
</div>
)}

View File

@ -22,6 +22,7 @@ export function handleActionMessage(message: ActionMessage) {
if (message.llm_metrics) {
const metrics = {
cost: message.llm_metrics?.accumulated_cost ?? null,
max_budget_per_task: message.llm_metrics?.max_budget_per_task ?? null,
usage: message.llm_metrics?.accumulated_token_usage ?? null,
};
store.dispatch(setMetrics(metrics));

View File

@ -19,6 +19,7 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
MAX_BUDGET_PER_TASK: null,
EMAIL: "",
EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
MCP_CONFIG: {

View File

@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface MetricsState {
cost: number | null;
max_budget_per_task: number | null;
usage: {
prompt_tokens: number;
completion_tokens: number;
@ -14,6 +15,7 @@ interface MetricsState {
const initialState: MetricsState = {
cost: null,
max_budget_per_task: null,
usage: null,
};
@ -23,6 +25,7 @@ const metricsSlice = createSlice({
reducers: {
setMetrics: (state, action: PayloadAction<MetricsState>) => {
state.cost = action.payload.cost;
state.max_budget_per_task = action.payload.max_budget_per_task;
state.usage = action.payload.usage;
},
},

View File

@ -23,6 +23,7 @@ export interface ActionMessage {
// LLM metrics information
llm_metrics?: {
accumulated_cost: number;
max_budget_per_task: number | null;
accumulated_token_usage: {
prompt_tokens: number;
completion_tokens: number;

View File

@ -46,6 +46,7 @@ export type Settings = {
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
MAX_BUDGET_PER_TASK: number | null;
EMAIL?: string;
EMAIL_VERIFIED?: boolean;
};
@ -67,6 +68,7 @@ export type ApiSettings = {
user_consents_to_analytics: boolean | null;
search_api_key?: string;
provider_tokens_set: Partial<Record<Provider, string | null>>;
max_budget_per_task: number | null;
mcp_config?: {
sse_servers: (string | MCPSSEServer)[];
stdio_servers: MCPStdioServer[];

View File

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { parseMaxBudgetPerTask } from "../settings-utils";
describe("parseMaxBudgetPerTask", () => {
it("should return null for empty string", () => {
expect(parseMaxBudgetPerTask("")).toBeNull();
});
it("should return null for whitespace-only string", () => {
expect(parseMaxBudgetPerTask(" ")).toBeNull();
});
it("should return null for non-numeric string", () => {
expect(parseMaxBudgetPerTask("abc")).toBeNull();
});
it("should return null for values less than 1", () => {
expect(parseMaxBudgetPerTask("0")).toBeNull();
expect(parseMaxBudgetPerTask("0.5")).toBeNull();
expect(parseMaxBudgetPerTask("-1")).toBeNull();
expect(parseMaxBudgetPerTask("-10.5")).toBeNull();
});
it("should return the parsed value for valid numbers >= 1", () => {
expect(parseMaxBudgetPerTask("1")).toBe(1);
expect(parseMaxBudgetPerTask("1.0")).toBe(1);
expect(parseMaxBudgetPerTask("1.5")).toBe(1.5);
expect(parseMaxBudgetPerTask("10")).toBe(10);
expect(parseMaxBudgetPerTask("100.99")).toBe(100.99);
});
it("should handle string numbers with leading/trailing whitespace", () => {
expect(parseMaxBudgetPerTask(" 1 ")).toBe(1);
expect(parseMaxBudgetPerTask(" 10.5 ")).toBe(10.5);
});
it("should return null for edge cases", () => {
expect(parseMaxBudgetPerTask("0.999")).toBeNull();
expect(parseMaxBudgetPerTask("NaN")).toBeNull();
expect(parseMaxBudgetPerTask("Infinity")).toBeNull();
expect(parseMaxBudgetPerTask("-Infinity")).toBeNull();
});
it("should handle scientific notation", () => {
expect(parseMaxBudgetPerTask("1e0")).toBe(1);
expect(parseMaxBudgetPerTask("1.5e1")).toBe(15);
expect(parseMaxBudgetPerTask("5e-1")).toBeNull(); // 0.5, which is < 1
});
});

View File

@ -47,6 +47,24 @@ const extractAdvancedFormData = (formData: FormData) => {
};
};
/**
* Parses and validates a max budget per task value.
* Ensures the value is at least 1 dollar.
* @param value - The string value to parse
* @returns The parsed number if valid (>= 1), null otherwise
*/
export const parseMaxBudgetPerTask = (value: string): number | null => {
if (!value) {
return null;
}
const parsedValue = parseFloat(value);
// Ensure the value is at least 1 dollar and is a finite number
return parsedValue && parsedValue >= 1 && Number.isFinite(parsedValue)
? parsedValue
: null;
};
export const extractSettings = (
formData: FormData,
): Partial<Settings> & { llm_api_key?: string | null } => {

View File

@ -1136,6 +1136,7 @@ class AgentController:
To avoid performance issues with long conversations, we only keep:
- accumulated_cost: The current total cost
- accumulated_token_usage: Accumulated token statistics across all API calls
- max_budget_per_task: The maximum budget allowed for the task
This includes metrics from both the agent's LLM and the condenser's LLM if it exists.
@ -1158,6 +1159,10 @@ class AgentController:
if condenser_metrics:
metrics.accumulated_cost += condenser_metrics.accumulated_cost
# Add max_budget_per_task to metrics
if self.state.budget_flag:
metrics.max_budget_per_task = self.state.budget_flag.max_value
# Set accumulated token usage (sum of agent and condenser token usage)
# Use a deep copy to ensure we don't modify the original object
metrics._accumulated_token_usage = (
@ -1180,7 +1185,7 @@ class AgentController:
accumulated_usage = self.state.metrics.accumulated_token_usage
self.log(
'debug',
f'Action metrics - accumulated_cost: {metrics.accumulated_cost}, '
f'Action metrics - accumulated_cost: {metrics.accumulated_cost}, max_budget: {metrics.max_budget_per_task}, '
f'latest tokens (prompt/completion/cache_read/cache_write): '
f'{latest_usage.prompt_tokens if latest_usage else 0}/'
f'{latest_usage.completion_tokens if latest_usage else 0}/'

View File

@ -70,6 +70,8 @@ def event_from_dict(data: dict[str, Any]) -> 'Event':
metrics = Metrics()
if isinstance(value, dict):
metrics.accumulated_cost = value.get('accumulated_cost', 0.0)
# Set max_budget_per_task if available
metrics.max_budget_per_task = value.get('max_budget_per_task')
for cost in value.get('costs', []):
metrics._costs.append(Cost(**cost))
metrics.response_latencies = [

View File

@ -48,12 +48,14 @@ class Metrics:
"""Metrics class can record various metrics during running and evaluation.
We track:
- accumulated_cost and costs
- max_budget_per_task (budget limit)
- A list of ResponseLatency
- A list of TokenUsage (one per call).
"""
def __init__(self, model_name: str = 'default') -> None:
self._accumulated_cost: float = 0.0
self._max_budget_per_task: float | None = None
self._costs: list[Cost] = []
self._response_latencies: list[ResponseLatency] = []
self.model_name = model_name
@ -78,6 +80,14 @@ class Metrics:
raise ValueError('Total cost cannot be negative.')
self._accumulated_cost = value
@property
def max_budget_per_task(self) -> float | None:
return self._max_budget_per_task
@max_budget_per_task.setter
def max_budget_per_task(self, value: float | None) -> None:
self._max_budget_per_task = value
@property
def costs(self) -> list[Cost]:
return self._costs
@ -171,6 +181,11 @@ class Metrics:
def merge(self, other: 'Metrics') -> None:
"""Merge 'other' metrics into this one."""
self._accumulated_cost += other.accumulated_cost
# Keep the max_budget_per_task from other if it's set and this one isn't
if self._max_budget_per_task is None and other.max_budget_per_task is not None:
self._max_budget_per_task = other.max_budget_per_task
self._costs += other._costs
# use the property so older picked objects that lack the field won't crash
self.token_usages += other.token_usages
@ -185,6 +200,7 @@ class Metrics:
"""Return the metrics in a dictionary."""
return {
'accumulated_cost': self._accumulated_cost,
'max_budget_per_task': self._max_budget_per_task,
'accumulated_token_usage': self.accumulated_token_usage.model_dump(),
'costs': [cost.model_dump() for cost in self._costs],
'response_latencies': [

View File

@ -118,6 +118,13 @@ class Session:
)
max_iterations = settings.max_iterations or self.config.max_iterations
# Prioritize settings over config for max_budget_per_task
max_budget_per_task = (
settings.max_budget_per_task
if settings.max_budget_per_task is not None
else self.config.max_budget_per_task
)
# This is a shallow copy of the default LLM config, so changes here will
# persist if we retrieve the default LLM config again when constructing
# the agent
@ -189,7 +196,7 @@ class Session:
config=self.config,
agent=agent,
max_iterations=max_iterations,
max_budget_per_task=self.config.max_budget_per_task,
max_budget_per_task=max_budget_per_task,
agent_to_llm_config=self.config.get_agent_to_llm_config_map(),
agent_configs=self.config.get_agent_configs(),
git_provider_tokens=git_provider_tokens,

View File

@ -40,6 +40,7 @@ class Settings(BaseModel):
sandbox_runtime_container_image: str | None = None
mcp_config: MCPConfig | None = None
search_api_key: SecretStr | None = None
max_budget_per_task: float | None = None
email: str | None = None
email_verified: bool | None = None
@ -131,5 +132,6 @@ class Settings(BaseModel):
remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
mcp_config=mcp_config,
search_api_key=app_config.search_api_key,
max_budget_per_task=app_config.max_budget_per_task,
)
return settings