Skip to content

Commit 17fd9f3

Browse files
ryaneggzclaude
andauthored
feat: Extensible sandbox/backend selection (Issue #769) (#770)
* feat: add PRD for Daytona Sandbox Backend integration (#751) Add product requirements document for integrating DaytonaSandbox as an optional deepagents backend, activated via metadata.sandbox="daytona". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * prd.json, progress.txt reset * feat: US-001 - Add langchain-daytona dependency to pyproject.toml Add langchain-daytona>=0.0.2 and daytona>=0.140.0 to backend dependencies. The explicit daytona pin ensures the SDK version with the correct `daytona` module name is resolved (older versions used `daytona_sdk`). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-002 - Add conditional import and create_daytona_backend() factory Add DAYTONA_API_KEY to constants and UserTokenKey enum. Add conditional try/except import for Daytona and DaytonaSandbox in src/agents/__init__.py. Implement create_daytona_backend() factory that creates a sandbox via the Daytona client and wraps it in DaytonaSandbox, returning (None, None) gracefully on missing packages, missing API key, or creation errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-003 - Wire Daytona backend into LLMController (llm.py) llm_invoke() now checks params.metadata.sandbox for 'daytona' and creates a Daytona sandbox backend via create_daytona_backend(). Falls back to StateBackend with a SystemMessage notification when creation fails. daytona_sandbox.stop() is called in the finally block for cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-004 - Wire Daytona backend into stream_generator (stream.py) stream_generator() now checks config metadata for sandbox='daytona' and creates a Daytona sandbox backend via create_daytona_backend(). Falls back to StateBackend with an SSE system message on failure. daytona_sandbox.stop() is called in the finally block for cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-005 - Wire Daytona backend into worker task (tasks.py) _execute_agent_stream() now checks config metadata for sandbox='daytona' and creates a Daytona sandbox backend via create_daytona_backend(). Falls back to StateBackend with a Redis stream system message on failure. daytona_sandbox.stop() is called in the finally block for cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-006 - Add unit tests for backend factory and wiring Add 8 unit tests for create_daytona_backend() covering: - Returns (None, None) when DaytonaSandbox is None (package missing) - Returns (None, None) when Daytona client is None - Returns (None, None) when API key is None or empty - Returns valid (sandbox, backend) tuple with mocked client - Returns (None, None) and logs error on client.create() exception - Uses DAYTONA_API_KEY constant as fallback - sandbox.stop() cleanup works correctly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * - finshed iteration 1 * feat: US-001 - Create unified resolve_sandbox_backend() helper Add resolve_sandbox_backend() that tries Daytona first and silently falls back to StateBackend. Create daytona.py with validate_daytona_execute_capability(). Add Daytona deps and constants. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-002 - Simplify invoke execution path in llm.py Replace self.init_backend() with resolve_sandbox_backend() in llm_invoke() so the invoke path uses the shared Daytona-first helper. Delete the controller's init_backend() method and update imports accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-003 - Simplify stream execution path in stream.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-004 - Simplify worker execution path in tasks.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-005 - Remove sandbox_backend from backend settings infrastructure All sandbox_backend settings infrastructure was already absent from the codebase. Verified and marked as passing. Includes formatting fix from make format on tasks.py imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-006 - Remove sandbox_backend from frontend All sandbox_backend frontend infrastructure was already absent from the codebase. Verified: no component, type, function, or test file exists. Frontend typecheck and all 180 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-007 - Clean up dead backend code Remove init_backend() from agents/__init__.py (no remaining callers) and inline its logic into resolve_sandbox_backend() fallback path. daytona_fallback_message() was already absent from the codebase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-008 - Update and rewrite unit tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * - iter 1 done * feat: US-009 - Remove StoreBackend routes from resolve_sandbox_backend() and all callers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-010 - Update unit tests for routeless resolve_sandbox_backend() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-010 - Update unit tests for routeless resolve_sandbox_backend() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * docs: add PRD for extensible sandbox/backend selection (#769) Product requirements document for user-configurable sandbox selection using enum + registry dict + factory function pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * Archive previous * chore: add Ralph prd.json for extensible sandbox selection (#769) Converted PRD to Ralph format with 10 user stories ordered by dependency: schema -> repo -> route -> registry -> wiring -> frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-001 - Add SandboxType enum and schema fields - Add SandboxType(str, Enum) with values auto, daytona, state - Add default_sandbox field to UserSettings entity - Add default_sandbox field to UserSettingsResponse model - Add UpdateDefaultSandboxRequest schema - Export new types from entities __init__.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-002 - Add set_default_sandbox repo method with unit tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-003 - Add PUT /settings/default-sandbox endpoint with route tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-004 - Implement registry pattern in resolve_sandbox_backend with dispatch tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-005 - Wire sandbox_type through LLMController Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-007 - Wire sandbox_type through worker tasks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-008 - Frontend service types and API call Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-009 - SandboxSettings frontend component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * feat: US-010 - Wire SandboxSettings into Settings page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * chore: mark US-010 complete and update progress Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: ryaneggz <kre8mymedia@gmail.com> * fix: patch correct function name in worker model resolution tests The tests patched non-existent `src.agents.init_backend` — the actual function is `resolve_sandbox_backend`. Also supply the 2-tuple return value it expects and add `default_sandbox` to FakeSettings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Signed-off-by: ryaneggz <kre8mymedia@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d51cc5 commit 17fd9f3

28 files changed

Lines changed: 2132 additions & 556 deletions

.ralph/prd.json

Lines changed: 124 additions & 51 deletions
Large diffs are not rendered by default.

.ralph/progress.txt

Lines changed: 100 additions & 86 deletions
Large diffs are not rendered by default.

backend/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ dependencies = [
1212
"authlib>=1.6.3",
1313
"bs4>=0.0.2",
1414
"cryptography>=45.0.7",
15+
"daytona>=0.140.0",
1516
"deepagents==0.3.8",
1617
"fastapi>=0.116.1",
18+
"langchain-daytona>=0.0.2",
1719
"langchain-sandbox>=0.0.3", # TODO: Update to 0.0.6 when langchain-core>=1.0 is released
1820
"langchain[aws]>=1.2.0",
1921
"langchain-anthropic>=1.3.0",
2022
# "langchain-arcade>=1.3.1",
2123
"langchain-community>=0.4.1",
24+
"langchain-daytona>=0.0.2",
25+
"daytona>=0.140.0",
2226
"langchain-google-genai>=4.1.2",
2327
"langchain-groq>=1.1.1",
2428
"langchain-mcp-adapters>=0.2.1",

backend/src/agents/__init__.py

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,16 @@
2020
from deepagents.backends.utils import create_file_data
2121

2222

23-
from src.constants import APP_ENV
23+
# Conditional import for Daytona sandbox support
24+
try:
25+
from daytona import Daytona, DaytonaConfig
26+
from langchain_daytona import DaytonaSandbox
27+
except ImportError:
28+
Daytona = None # type: ignore[assignment,misc]
29+
DaytonaConfig = None # type: ignore[assignment,misc]
30+
DaytonaSandbox = None # type: ignore[assignment,misc]
31+
32+
from src.constants import APP_ENV, DAYTONA_API_KEY
2433
from src.contexts.service import ServiceContext
2534
from src.constants.llm import DEFAULT_CHAT_MODEL, DEFAULT_SYSTEM_PROMPT
2635
from src.schemas.entities.llm import Assistant, LLMInput
@@ -232,16 +241,97 @@ def init_config(
232241
)
233242

234243

235-
def init_backend(runtime: ToolRuntime, *, routes):
236-
"""Factory function that creates a CompositeBackend with custom routes."""
237-
built_routes = {}
238-
for prefix, backend_or_factory in routes.items():
239-
if callable(backend_or_factory):
240-
built_routes[prefix] = backend_or_factory(runtime)
241-
else:
242-
built_routes[prefix] = backend_or_factory
244+
def create_daytona_backend():
245+
"""Create a Daytona sandbox and return (sandbox, backend).
246+
247+
Returns ``(None, None)`` when the package is not installed, the API key is
248+
missing, or sandbox creation fails for any reason.
249+
"""
250+
if DaytonaSandbox is None or Daytona is None:
251+
return None, None
252+
253+
key = DAYTONA_API_KEY
254+
if not key:
255+
return None, None
256+
257+
try:
258+
client = Daytona(DaytonaConfig(api_key=key)) # type: ignore[misc]
259+
sandbox = client.create()
260+
backend = DaytonaSandbox(sandbox=sandbox)
261+
return sandbox, backend
262+
except Exception as exc:
263+
logger.error(f"Failed to create Daytona sandbox: {exc}")
264+
return None, None
265+
266+
267+
def _create_daytona_backend_checked(
268+
runtime: ToolRuntime,
269+
) -> tuple[CompositeBackend, Any] | None:
270+
"""Try to create a Daytona-backed CompositeBackend.
271+
272+
Returns ``(backend, sandbox)`` on success, or ``None`` if Daytona is
273+
unavailable or not capable. Cleans up the sandbox on failure.
274+
"""
275+
from src.agents.daytona import validate_daytona_execute_capability
276+
277+
sandbox, daytona_backend = create_daytona_backend()
278+
if daytona_backend is not None:
279+
supported, _reason = validate_daytona_execute_capability(daytona_backend)
280+
if supported:
281+
backend = CompositeBackend(default=daytona_backend, routes={})
282+
return backend, sandbox
283+
284+
# Daytona not capable — clean up sandbox silently
285+
if sandbox is not None:
286+
try:
287+
sandbox.stop()
288+
except Exception:
289+
pass
290+
291+
return None
292+
293+
294+
def _create_state_backend(
295+
runtime: ToolRuntime,
296+
) -> tuple[CompositeBackend, None]:
297+
"""Create a plain StateBackend-backed CompositeBackend."""
243298
default_state = StateBackend(runtime)
244-
return CompositeBackend(default=default_state, routes=built_routes)
299+
backend = CompositeBackend(default=default_state, routes={})
300+
return backend, None
301+
302+
303+
_SANDBOX_FACTORIES: dict[str, Callable] = {
304+
"daytona": _create_daytona_backend_checked,
305+
"state": _create_state_backend,
306+
}
307+
308+
309+
def resolve_sandbox_backend(
310+
runtime: ToolRuntime,
311+
sandbox_type: str | None = None,
312+
) -> tuple[CompositeBackend, Any]:
313+
"""Resolve a sandbox backend based on *sandbox_type*.
314+
315+
Dispatch rules:
316+
* ``None`` / ``"auto"`` — try Daytona first, fall back to State.
317+
* ``"state"`` — use StateBackend directly (never attempts Daytona).
318+
* ``"daytona"`` — try Daytona, fall back to State if unavailable.
319+
* Any unknown value — treated as ``"auto"``.
320+
321+
Returns ``(backend, daytona_sandbox_or_None)``.
322+
"""
323+
effective = sandbox_type if sandbox_type in _SANDBOX_FACTORIES else None
324+
325+
if effective == "state":
326+
return _create_state_backend(runtime)
327+
328+
# "daytona" or auto (None) — try Daytona first
329+
result = _create_daytona_backend_checked(runtime)
330+
if result is not None:
331+
return result
332+
333+
# Fallback: plain StateBackend (silent, no messages)
334+
return _create_state_backend(runtime)
245335

246336

247337
################################################################################

backend/src/agents/daytona.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Daytona sandbox helpers for the agents module."""
2+
3+
from typing import Any
4+
5+
6+
def validate_daytona_execute_capability(backend: Any) -> tuple[bool, str | None]:
7+
"""Check whether a Daytona backend supports the execute() method.
8+
9+
Returns ``(True, None)`` when the backend is capable, or
10+
``(False, reason)`` explaining why it is not.
11+
"""
12+
if backend is None:
13+
return False, "backend is None"
14+
15+
if not callable(getattr(backend, "execute", None)):
16+
return False, "backend does not support execute()"
17+
18+
return True, None

backend/src/constants/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class UserTokenKey(Enum):
107107
EXA_API_KEY = "EXA_API_KEY"
108108
ARCADE_API_KEY = "ARCADE_API_KEY"
109109
LANGCONNECT_SERVER_URL = "LANGCONNECT_SERVER_URL"
110+
DAYTONA_API_KEY = "DAYTONA_API_KEY"
110111

111112
@classmethod
112113
def values(cls) -> list[str]:
@@ -133,6 +134,7 @@ def values(cls) -> list[str]:
133134
TAVILY_API_KEY = os.getenv(UserTokenKey.TAVILY_API_KEY.value)
134135
EXA_API_KEY = os.getenv(UserTokenKey.EXA_API_KEY.value)
135136
LANGCONNECT_SERVER_URL = os.getenv(UserTokenKey.LANGCONNECT_SERVER_URL.value)
137+
DAYTONA_API_KEY = os.getenv(UserTokenKey.DAYTONA_API_KEY.value)
136138

137139
# Storage
138140
MINIO_HOST = os.getenv("MINIO_HOST")

backend/src/controllers/llm.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend
21
from langchain.tools import ToolRuntime
32
import ujson
43

@@ -9,7 +8,12 @@
98
from src.schemas.entities.schedule import ScheduleCreate
109
from src.schemas.entities import LLMRequest
1110
from src.contexts.service import ServiceContext
12-
from src.agents import construct_agent, init_config, prepare_memory_files
11+
from src.agents import (
12+
construct_agent,
13+
init_config,
14+
prepare_memory_files,
15+
resolve_sandbox_backend,
16+
)
1317
from src.services.db import get_checkpoint_db
1418
from src.utils.stream import stream_generator
1519
from src.agents import Orchestra
@@ -41,15 +45,6 @@ def _init_runtime(self, request: LLMRequest) -> ToolRuntime:
4145
config=self.service_context.config,
4246
)
4347

44-
def init_backend(self, request: LLMRequest) -> CompositeBackend:
45-
runtime = self._init_runtime(request)
46-
store_backend = StoreBackend(runtime)
47-
built_routes = {
48-
f"/users/{runtime.context.user_id}/memories/": store_backend,
49-
f"/users/{runtime.context.user_id}/config/": store_backend,
50-
}
51-
return CompositeBackend(default=StateBackend(runtime), routes=built_routes)
52-
5348
async def _update_store(self, agent: Orchestra, config: RunnableConfig) -> None:
5449
final_state = await agent.graph.aget_state(config)
5550
configurable = {
@@ -71,17 +66,20 @@ async def _update_store(self, agent: Orchestra, config: RunnableConfig) -> None:
7166
)
7267
logger.info(f"checkpoint: {ujson.dumps(configurable)}")
7368

74-
async def _resolve_user_settings(self, model: str) -> tuple[str, str | None]:
75-
"""Resolve user default model and API key.
69+
async def _resolve_user_settings(
70+
self, model: str
71+
) -> tuple[str, str | None, str | None]:
72+
"""Resolve user default model, API key, and sandbox preference.
7673
77-
Returns (model, api_key) where model may be overridden by user default
78-
and api_key is the resolved key for the provider.
74+
Returns (model, api_key, default_sandbox) where model may be overridden
75+
by user default, api_key is the resolved key for the provider, and
76+
default_sandbox is the user's sandbox backend preference.
7977
"""
8078
if not self.user_id:
8179
# Unauthenticated users: fall back to system default if no model
8280
if not model:
8381
model = DEFAULT_CHAT_MODEL
84-
return model, None
82+
return model, None, None
8583

8684
settings_repo = UserSettingsRepo(self.user_id, self.store)
8785
settings = await settings_repo._get_or_create()
@@ -95,12 +93,15 @@ async def _resolve_user_settings(self, model: str) -> tuple[str, str | None]:
9593
if not model:
9694
model = DEFAULT_CHAT_MODEL
9795

96+
# Read sandbox preference from settings
97+
default_sandbox = getattr(settings, "default_sandbox", None)
98+
9899
# Guard against None model before resolving API key
99100
if not model:
100-
return model, None
101+
return model, None, default_sandbox
101102

102103
api_key = resolve_api_key(model, user_keys if user_keys else None)
103-
return model, api_key
104+
return model, api_key, default_sandbox
104105

105106
async def llm_invoke(self, params: LLMRequest):
106107
"""Invoke the agent synchronously and return the final response.
@@ -117,8 +118,10 @@ async def llm_invoke(self, params: LLMRequest):
117118
config = init_config(params, user_id=self.user_id)
118119
params = await self.service_context.llm_service.assistant(params)
119120

120-
# Resolve user-configured API key and default model
121-
params.model, api_key = await self._resolve_user_settings(params.model)
121+
# Resolve user-configured API key, default model, and sandbox
122+
params.model, api_key, default_sandbox = await self._resolve_user_settings(
123+
params.model
124+
)
122125

123126
# Load user memories into files_map for MemoryMiddleware
124127
memory_files, memory_sources = await prepare_memory_files(
@@ -129,7 +132,10 @@ async def llm_invoke(self, params: LLMRequest):
129132
params.input.files = {**memory_files, **existing_files}
130133

131134
async with get_checkpoint_db() as checkpointer:
132-
backend = self.init_backend(params)
135+
runtime = self._init_runtime(params)
136+
backend, _sandbox = resolve_sandbox_backend(
137+
runtime, sandbox_type=default_sandbox
138+
)
133139
agent: Orchestra = await construct_agent(
134140
instructions=params.instructions,
135141
system_prompt=params.system_prompt,
@@ -161,8 +167,10 @@ async def llm_invoke(self, params: LLMRequest):
161167
async def llm_stream(self, params: LLMRequest):
162168
assistant = await self.service_context.llm_service.assistant(params)
163169

164-
# Resolve user-configured API key and default model
165-
assistant.model, api_key = await self._resolve_user_settings(assistant.model)
170+
# Resolve user-configured API key, default model, and sandbox
171+
assistant.model, api_key, default_sandbox = await self._resolve_user_settings(
172+
assistant.model
173+
)
166174

167175
return stream_generator(
168176
input=assistant.input,
@@ -174,6 +182,7 @@ async def llm_stream(self, params: LLMRequest):
174182
service_context=self.service_context,
175183
instructions=assistant.instructions,
176184
api_key=api_key,
185+
sandbox_type=default_sandbox,
177186
)
178187

179188
async def llm_task(self, job: ScheduleCreate):

backend/src/repos/user_settings_repo.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from datetime import datetime, timezone
44

55
from src.repos.base_repo import BaseRepo
6-
from src.schemas.entities.settings import UserSettings, ProviderKeyStatus
6+
from src.schemas.entities.settings import UserSettings, ProviderKeyStatus, SandboxType
77
from src.utils.security import encrypt_value, decrypt_value
88
from src.constants import UserTokenKey
99

@@ -71,6 +71,19 @@ async def set_default_model(self, model: Optional[str]) -> UserSettings:
7171
await self._set(_SETTINGS_KEY, settings)
7272
return settings
7373

74+
async def set_default_sandbox(self, sandbox: Optional[str]) -> UserSettings:
75+
if sandbox is not None:
76+
valid = [e.value for e in SandboxType]
77+
if sandbox not in valid:
78+
raise ValueError(
79+
f"Invalid sandbox '{sandbox}'. Must be one of: {valid}"
80+
)
81+
settings = await self._get_or_create()
82+
settings.default_sandbox = sandbox
83+
settings.updated_at = datetime.now(timezone.utc)
84+
await self._set(_SETTINGS_KEY, settings)
85+
return settings
86+
7487
async def upsert_provider_key(self, provider: str, api_key: str) -> UserSettings:
7588
self._validate_provider(provider)
7689
settings = await self._get_or_create()

backend/src/routes/v0/settings.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from src.schemas.entities.settings import (
66
UserSettingsResponse,
77
UpdateDefaultModelRequest,
8+
UpdateDefaultSandboxRequest,
89
UpsertProviderKeyRequest,
910
)
1011
from src.repos.user_settings_repo import UserSettingsRepo
@@ -27,6 +28,7 @@ async def get_settings(
2728
settings, statuses = await repo.get_settings()
2829
return UserSettingsResponse(
2930
default_model=settings.default_model,
31+
default_sandbox=settings.default_sandbox,
3032
provider_keys=statuses,
3133
)
3234

@@ -42,6 +44,26 @@ async def update_default_model(
4244
settings, statuses = await repo.get_settings()
4345
return UserSettingsResponse(
4446
default_model=settings.default_model,
47+
default_sandbox=settings.default_sandbox,
48+
provider_keys=statuses,
49+
)
50+
51+
52+
@router.put("/settings/default-sandbox", response_model=UserSettingsResponse)
53+
async def update_default_sandbox(
54+
req: UpdateDefaultSandboxRequest,
55+
user: User = Depends(verify_credentials),
56+
store: BaseStore = Depends(get_store),
57+
) -> UserSettingsResponse:
58+
repo = _get_repo(user, store)
59+
try:
60+
await repo.set_default_sandbox(req.sandbox)
61+
except ValueError as e:
62+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
63+
settings, statuses = await repo.get_settings()
64+
return UserSettingsResponse(
65+
default_model=settings.default_model,
66+
default_sandbox=settings.default_sandbox,
4567
provider_keys=statuses,
4668
)
4769

@@ -60,6 +82,7 @@ async def upsert_provider_key(
6082
settings, statuses = await repo.get_settings()
6183
return UserSettingsResponse(
6284
default_model=settings.default_model,
85+
default_sandbox=settings.default_sandbox,
6386
provider_keys=statuses,
6487
)
6588

@@ -80,5 +103,6 @@ async def delete_provider_key(
80103
settings, statuses = await repo.get_settings()
81104
return UserSettingsResponse(
82105
default_model=settings.default_model,
106+
default_sandbox=settings.default_sandbox,
83107
provider_keys=statuses,
84108
)

0 commit comments

Comments
 (0)