Skip to content

Commit 65a5f54

Browse files
committed
Resolve Conflicts
2 parents 50e7a10 + b8289c4 commit 65a5f54

19 files changed

Lines changed: 1199 additions & 4 deletions

File tree

Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## v0.0.2-rc142
99

1010
### Changed
11+
- feat/665-allow-user-configure-default-account-settings (2026-01-30)
1112
- feat/707-ticket-md-ralph-loop (2026-01-30)
1213
- feat/694-show-subagent-tool-calls (2026-01-24)
1314
- feat/504-frontend-queue (2026-01-20)

backend/src/agents/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,17 @@ def init_graph(
6666
store: BaseStore | None = None,
6767
middleware: list[Callable] = None,
6868
backend: CompositeBackend = None,
69+
api_key: str | None = None,
6970
) -> CompiledStateGraph:
7071
from langchain.chat_models import init_chat_model
7172

7273
if not model:
7374
model = DEFAULT_CHAT_MODEL
7475

75-
llm = init_chat_model(model=model)
76+
kwargs: dict[str, Any] = {"model": model}
77+
if api_key:
78+
kwargs["api_key"] = api_key
79+
llm = init_chat_model(**kwargs)
7680

7781
deep_agent = create_deep_agent(
7882
model=llm,
@@ -209,6 +213,7 @@ async def construct_agent(
209213
backend: CompositeBackend = None,
210214
checkpointer: BaseCheckpointSaver = None,
211215
service_context: ServiceContext = None,
216+
api_key: str | None = None,
212217
):
213218
try:
214219
if subagents:
@@ -228,6 +233,7 @@ async def construct_agent(
228233
middleware=middleware,
229234
context_schema=ContextSchema,
230235
backend=backend,
236+
api_key=api_key,
231237
)
232238
return agent
233239
except Exception as e:
@@ -248,6 +254,7 @@ def __init__(
248254
middleware: list[Callable] = None,
249255
graph_id: Literal["react", "deepagent"] = "deepagent",
250256
backend: CompositeBackend = None,
257+
api_key: str | None = None,
251258
):
252259
self.tools = tools
253260
self.model = model
@@ -266,6 +273,7 @@ def __init__(
266273
store=self.store,
267274
middleware=middleware,
268275
backend=backend,
276+
api_key=api_key,
269277
)
270278

271279
async def invoke(

backend/src/controllers/llm.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from src.services.db import get_checkpoint_db
1414
from src.utils.stream import stream_generator
1515
from src.agents import Orchestra
16+
from src.repos.user_settings_repo import UserSettingsRepo
17+
from src.utils.llm import resolve_api_key
1618
from src.utils.logger import logger
1719
from src.utils.format import get_time
1820

@@ -68,10 +70,34 @@ async def _update_store(self, agent: Orchestra, config: RunnableConfig) -> None:
6870
)
6971
logger.info(f"checkpoint: {ujson.dumps(configurable)}")
7072

73+
async def _resolve_user_settings(self, model: str) -> tuple[str, str | None]:
74+
"""Resolve user default model and API key.
75+
76+
Returns (model, api_key) where model may be overridden by user default
77+
and api_key is the resolved key for the provider.
78+
"""
79+
if not self.user_id:
80+
return model, None
81+
82+
settings_repo = UserSettingsRepo(self.user_id, self.store)
83+
settings = await settings_repo._get_or_create()
84+
user_keys = settings_repo._decrypt_keys(settings)
85+
86+
# Apply user default model when request has no explicit model
87+
if not model and settings.default_model:
88+
model = settings.default_model
89+
90+
api_key = resolve_api_key(model, user_keys if user_keys else None)
91+
return model, api_key
92+
7193
async def llm_invoke(self, params: LLMRequest):
7294
try:
7395
config = init_config(params, user_id=self.user_id)
7496
params = await self.service_context.llm_service.assistant(params)
97+
98+
# Resolve user-configured API key and default model
99+
params.model, api_key = await self._resolve_user_settings(params.model)
100+
75101
async with get_checkpoint_db() as checkpointer:
76102
backend = self.init_backend(params)
77103
agent: Orchestra = await construct_agent(
@@ -83,6 +109,7 @@ async def llm_invoke(self, params: LLMRequest):
83109
checkpointer=checkpointer,
84110
backend=backend,
85111
service_context=self.service_context,
112+
api_key=api_key,
86113
)
87114
response = await agent.invoke(
88115
params.input,
@@ -100,6 +127,10 @@ async def llm_invoke(self, params: LLMRequest):
100127

101128
async def llm_stream(self, params: LLMRequest):
102129
assistant = await self.service_context.llm_service.assistant(params)
130+
131+
# Resolve user-configured API key and default model
132+
assistant.model, api_key = await self._resolve_user_settings(assistant.model)
133+
103134
return stream_generator(
104135
input=assistant.input,
105136
model=assistant.model,
@@ -109,6 +140,7 @@ async def llm_stream(self, params: LLMRequest):
109140
config=self.service_context.config,
110141
service_context=self.service_context,
111142
instructions=assistant.instructions,
143+
api_key=api_key,
112144
)
113145

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

backend/src/repos/base_repo.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from src.services.db import get_store_in_memory
77
from src.schemas.entities.store import Source, Project, Document
88
from src.schemas.entities.auth import ApiToken
9+
from src.schemas.entities.settings import UserSettings
910
from src.utils.logger import logger
1011

1112

@@ -43,6 +44,8 @@ def _format(self, item: SearchItem) -> Any:
4344
return Project.model_validate(item.value)
4445
elif self.entity_type == "api_tokens":
4546
return ApiToken.model_validate(item.value)
47+
elif self.entity_type == "user_settings":
48+
return UserSettings.model_validate(item.value)
4649
else:
4750
raise ValueError(f"Invalid entity type: {self.entity_type}")
4851

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from typing import Optional
2+
import uuid
3+
from datetime import datetime, timezone
4+
5+
from src.repos.base_repo import BaseRepo
6+
from src.schemas.entities.settings import UserSettings, ProviderKeyStatus
7+
from src.utils.security import encrypt_value, decrypt_value
8+
from src.constants import UserTokenKey
9+
10+
# Single well-known key for the one settings record per user
11+
_SETTINGS_KEY = "default"
12+
13+
14+
class UserSettingsRepo(BaseRepo):
15+
def __init__(self, user_id: str, store):
16+
super().__init__(user_id, store, "user_settings")
17+
18+
# ------------------------------------------------------------------
19+
# Helpers
20+
# ------------------------------------------------------------------
21+
22+
def _validate_provider(self, provider: str) -> None:
23+
"""Raise ValueError if provider is not in UserTokenKey."""
24+
valid = UserTokenKey.values()
25+
if provider not in valid:
26+
raise ValueError(f"Invalid provider '{provider}'. Must be one of: {valid}")
27+
28+
async def _get_or_create(self) -> UserSettings:
29+
"""Return existing settings or create an empty record."""
30+
item = await self._get(_SETTINGS_KEY)
31+
if item:
32+
return UserSettings.model_validate(item.value)
33+
settings = UserSettings(
34+
id=str(uuid.uuid4()),
35+
user_id=self.user_id,
36+
created_at=datetime.now(timezone.utc),
37+
updated_at=datetime.now(timezone.utc),
38+
)
39+
await self._set(_SETTINGS_KEY, settings)
40+
return settings
41+
42+
def _decrypt_keys(self, settings: UserSettings) -> dict[str, str]:
43+
"""Return decrypted key map, or empty dict if nothing stored."""
44+
if not settings.encrypted_keys:
45+
return {}
46+
return decrypt_value(settings.encrypted_keys)
47+
48+
def _encrypt_keys(self, keys: dict[str, str]) -> Optional[str]:
49+
if not keys:
50+
return None
51+
return encrypt_value(keys)
52+
53+
# ------------------------------------------------------------------
54+
# Public API
55+
# ------------------------------------------------------------------
56+
57+
async def get_settings(self) -> tuple[UserSettings, list[ProviderKeyStatus]]:
58+
"""Return settings and provider key statuses (no raw keys)."""
59+
settings = await self._get_or_create()
60+
keys = self._decrypt_keys(settings)
61+
statuses = [
62+
ProviderKeyStatus(provider=p.value, is_set=(p.value in keys))
63+
for p in UserTokenKey
64+
]
65+
return settings, statuses
66+
67+
async def set_default_model(self, model: Optional[str]) -> UserSettings:
68+
settings = await self._get_or_create()
69+
settings.default_model = model
70+
settings.updated_at = datetime.now(timezone.utc)
71+
await self._set(_SETTINGS_KEY, settings)
72+
return settings
73+
74+
async def upsert_provider_key(self, provider: str, api_key: str) -> UserSettings:
75+
self._validate_provider(provider)
76+
settings = await self._get_or_create()
77+
keys = self._decrypt_keys(settings)
78+
keys[provider] = api_key
79+
settings.encrypted_keys = self._encrypt_keys(keys)
80+
settings.updated_at = datetime.now(timezone.utc)
81+
await self._set(_SETTINGS_KEY, settings)
82+
return settings
83+
84+
async def delete_provider_key(self, provider: str) -> UserSettings:
85+
self._validate_provider(provider)
86+
settings = await self._get_or_create()
87+
keys = self._decrypt_keys(settings)
88+
keys.pop(provider, None)
89+
settings.encrypted_keys = self._encrypt_keys(keys)
90+
settings.updated_at = datetime.now(timezone.utc)
91+
await self._set(_SETTINGS_KEY, settings)
92+
return settings
93+
94+
async def get_all_decrypted_keys(self) -> dict[str, str]:
95+
"""Return all decrypted provider keys as a dict."""
96+
settings = await self._get_or_create()
97+
return self._decrypt_keys(settings)
98+
99+
async def get_decrypted_key(self, provider: str) -> Optional[str]:
100+
"""Return the raw decrypted key for a single provider, or None."""
101+
self._validate_provider(provider)
102+
settings = await self._get_or_create()
103+
keys = self._decrypt_keys(settings)
104+
return keys.get(provider)

backend/src/routes/v0/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .project import router as project
1717
from .api_tokens import router as api_tokens
1818
from .share import router as share
19+
from .settings import router as settings
1920

2021

2122
def create_api_router(app: FastAPI, prefix: str = "/api"):
@@ -35,6 +36,7 @@ def create_api_router(app: FastAPI, prefix: str = "/api"):
3536
app.include_router(storage, prefix=prefix)
3637
app.include_router(api_tokens, prefix=prefix)
3738
app.include_router(share, prefix=prefix)
39+
app.include_router(settings, prefix=prefix)
3840
return app
3941

4042

backend/src/routes/v0/settings.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from fastapi import APIRouter, Depends, HTTPException, status
2+
3+
from src.schemas.models import User
4+
from src.schemas.entities.settings import (
5+
UserSettingsResponse,
6+
UpdateDefaultModelRequest,
7+
UpsertProviderKeyRequest,
8+
)
9+
from src.repos.user_settings_repo import UserSettingsRepo
10+
from src.utils.auth import verify_credentials
11+
from src.services.db import get_store
12+
13+
router = APIRouter(tags=["Settings"])
14+
15+
16+
def _get_repo(user: User, store) -> UserSettingsRepo:
17+
return UserSettingsRepo(str(user.id), store)
18+
19+
20+
@router.get("/settings", response_model=UserSettingsResponse)
21+
async def get_settings(
22+
user: User = Depends(verify_credentials), store=Depends(get_store)
23+
):
24+
repo = _get_repo(user, store)
25+
settings, statuses = await repo.get_settings()
26+
return UserSettingsResponse(
27+
default_model=settings.default_model,
28+
provider_keys=statuses,
29+
)
30+
31+
32+
@router.put("/settings/default-model", response_model=UserSettingsResponse)
33+
async def update_default_model(
34+
req: UpdateDefaultModelRequest,
35+
user: User = Depends(verify_credentials),
36+
store=Depends(get_store),
37+
):
38+
repo = _get_repo(user, store)
39+
await repo.set_default_model(req.model)
40+
settings, statuses = await repo.get_settings()
41+
return UserSettingsResponse(
42+
default_model=settings.default_model,
43+
provider_keys=statuses,
44+
)
45+
46+
47+
@router.put("/settings/provider-keys", response_model=UserSettingsResponse)
48+
async def upsert_provider_key(
49+
req: UpsertProviderKeyRequest,
50+
user: User = Depends(verify_credentials),
51+
store=Depends(get_store),
52+
):
53+
repo = _get_repo(user, store)
54+
try:
55+
await repo.upsert_provider_key(req.provider, req.api_key)
56+
except ValueError as e:
57+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
58+
settings, statuses = await repo.get_settings()
59+
return UserSettingsResponse(
60+
default_model=settings.default_model,
61+
provider_keys=statuses,
62+
)
63+
64+
65+
@router.delete(
66+
"/settings/provider-keys/{provider}", response_model=UserSettingsResponse
67+
)
68+
async def delete_provider_key(
69+
provider: str,
70+
user: User = Depends(verify_credentials),
71+
store=Depends(get_store),
72+
):
73+
repo = _get_repo(user, store)
74+
try:
75+
await repo.delete_provider_key(provider)
76+
except ValueError as e:
77+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
78+
settings, statuses = await repo.get_settings()
79+
return UserSettingsResponse(
80+
default_model=settings.default_model,
81+
provider_keys=statuses,
82+
)

backend/src/schemas/entities/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
from src.schemas.entities.llm import *
88
from src.schemas.entities.store import Thread
99
from src.schemas.entities.auth import ApiToken
10+
from src.schemas.entities.settings import (
11+
UserSettings,
12+
UserSettingsResponse,
13+
ProviderKeyStatus,
14+
UpdateDefaultModelRequest,
15+
UpsertProviderKeyRequest,
16+
)
1017
from src.schemas.entities.hitl import (
1118
DecisionType,
1219
HumanDecision,

0 commit comments

Comments
 (0)