diff --git a/backend/functions/letta_agents.py b/backend/functions/letta_agents.py index a3f7cba65..32aeb1ead 100644 --- a/backend/functions/letta_agents.py +++ b/backend/functions/letta_agents.py @@ -2,15 +2,16 @@ title: Letta Agents author: Letta author_url: https://letta.com -version: 0.1.0 +version: 0.3.0 license: Apache 2.0 description: Connect your Letta agents to Open WebUI. Each agent appears as a model you can chat with. requirements: letta-client """ from pydantic import BaseModel, Field -from typing import Generator, Iterator, Union +from typing import Generator, Iterator, Union, Optional, List import logging +import requests log = logging.getLogger(__name__) @@ -18,12 +19,16 @@ log = logging.getLogger(__name__) class Pipe: class Valves(BaseModel): LETTA_BASE_URL: str = Field( - default="https://app.letta.com", + default="https://api.letta.com", description="Base URL for the Letta server (use http://localhost:8283 for local)" ) LETTA_API_KEY: str = Field( default="", - description="Your Letta API key (get one at app.letta.com)" + description="Your Letta API key (fallback if not using OAuth login)" + ) + LETTA_PROJECT_ID: str = Field( + default="", + description="Filter agents to a specific project ID (optional - leave empty to show agents from all projects)" ) LETTA_AGENT_ID: str = Field( default="", @@ -34,29 +39,116 @@ class Pipe: description="Display name for the agent (used when LETTA_AGENT_ID is set)" ) + class UserValves(BaseModel): + SELECTED_PROJECT: str = Field( + default="", + description="Filter agents to a specific project (enter project name or ID, leave empty to show all projects)" + ) + def __init__(self): self.valves = self.Valves() self.name = "" - def _get_client(self): - """Get a Letta client instance.""" + def _get_client(self, oauth_token: Optional[dict] = None): + """Get a Letta client instance. + + Args: + oauth_token: OAuth token dict with 'access_token' key (from Letta OAuth login) + """ from letta_client import Letta - if not self.valves.LETTA_API_KEY: - raise ValueError("LETTA_API_KEY is required. Get one at app.letta.com") + # Prefer OAuth token if available + if oauth_token and oauth_token.get("access_token"): + log.debug("Using OAuth token for Letta client") + return Letta( + base_url=self.valves.LETTA_BASE_URL, + api_key=oauth_token["access_token"], # OAuth access token works as API key + default_headers={"X-Letta-Source": "chat-letta-com"}, + ) + + # Fallback to API key from valves + if self.valves.LETTA_API_KEY: + log.debug("Using API key for Letta client") + return Letta( + base_url=self.valves.LETTA_BASE_URL, + api_key=self.valves.LETTA_API_KEY, + default_headers={"X-Letta-Source": "chat-letta-com"}, + ) + + raise ValueError("No authentication available. Login with Letta or configure API key.") + + def _has_auth(self, oauth_token: Optional[dict] = None) -> bool: + """Check if we have authentication (OAuth token or API key).""" + if oauth_token and oauth_token.get("access_token"): + return True + if self.valves.LETTA_API_KEY: + return True + return False + + def _get_api_key(self, oauth_token: Optional[dict] = None) -> str: + """Get the API key/token for direct HTTP calls.""" + if oauth_token and oauth_token.get("access_token"): + return oauth_token["access_token"] + return self.valves.LETTA_API_KEY + + def _list_projects(self, oauth_token: Optional[dict] = None) -> List[dict]: + """List all projects via direct HTTP call with pagination.""" + api_key = self._get_api_key(oauth_token) + headers = { + "Authorization": f"Bearer {api_key}", + "X-Letta-Source": "chat-letta-com" + } + base_url = f"{self.valves.LETTA_BASE_URL}/v1/projects" + all_projects = [] + offset = 0 + limit = 20 # Max allowed by API - return Letta( - base_url=self.valves.LETTA_BASE_URL, - api_key=self.valves.LETTA_API_KEY, - default_headers={"X-Letta-Source": "chat-letta-com"}, - ) + try: + while True: + url = f"{base_url}?limit={limit}&offset={offset}" + log.info(f"Letta: fetching projects from {url}") + resp = requests.get(url, headers=headers, timeout=30) + + if resp.status_code != 200: + log.info(f"Letta: projects API failed with {resp.status_code}: {resp.text[:200]}") + break + + data = resp.json() + if isinstance(data, dict) and "projects" in data: + projects = data["projects"] + all_projects.extend(projects) + log.info(f"Letta: fetched {len(projects)} projects (total: {len(all_projects)})") + + # Check if there are more pages + if not data.get("hasNextPage", False): + break + offset += limit + elif isinstance(data, list): + all_projects.extend(data) + break + else: + break + + log.info(f"Letta: found {len(all_projects)} total projects") + return all_projects + except Exception as e: + log.info(f"Letta: error listing projects: {e}") + return all_projects if all_projects else [] + + def pipes(self, __oauth_token__: Optional[dict] = None, __user__: Optional[dict] = None) -> list[dict]: + """Return list of Letta agents as available models. - def pipes(self) -> list[dict]: - """Return list of Letta agents as available models.""" - if not self.valves.LETTA_API_KEY: + Only loads agents from ONE project at a time. User must select a project first, + or the default (first) project is used. + + Args: + __oauth_token__: OAuth token passed by Open WebUI if user logged in via OAuth + __user__: User info dict with 'valves' key containing UserValves instance + """ + if not self._has_auth(__oauth_token__): return [{ "id": "setup", - "name": "Letta (configure API key in Valves)" + "name": "Letta (login with Letta or configure API key)" }] # If a specific agent ID is configured, just return that one @@ -66,24 +158,86 @@ class Pipe: "name": self.valves.LETTA_AGENT_NAME or self.valves.LETTA_AGENT_ID }] + # Get user's selected project from UserValves + selected_project_id = "" + if __user__ and __user__.get("valves"): + user_valves = __user__["valves"] + if hasattr(user_valves, "SELECTED_PROJECT"): + selected_project_id = user_valves.SELECTED_PROJECT or "" + elif isinstance(user_valves, dict): + selected_project_id = user_valves.get("SELECTED_PROJECT", "") + try: - client = self._get_client() - agents = [] + client = self._get_client(__oauth_token__) - page = client.agents.list() - for agent in page.items: - agents.append({ - "id": agent.id, - "name": agent.name or agent.id - }) + # Admin override: if LETTA_PROJECT_ID is set, use only that project + if self.valves.LETTA_PROJECT_ID: + project_id = self.valves.LETTA_PROJECT_ID + project_name = project_id + all_projects = [{"id": project_id, "name": project_name}] + else: + # List all available projects + all_projects = self._list_projects(__oauth_token__) + + if not all_projects: + return [{ + "id": "no-projects", + "name": "No projects found" + }] + + # Find the selected project or use the first one as default + project_id = None + project_name = None + + if selected_project_id: + # Try to find the selected project by ID or name + for p in all_projects: + if (p.get("id", "") == selected_project_id or + p.get("name", "").lower() == selected_project_id.lower()): + project_id = p.get("id") + project_name = p.get("name") + break + + # Use first project as default if no selection or not found + if not project_id: + project_id = all_projects[0].get("id") + project_name = all_projects[0].get("name") + + log.info(f"Letta: using project '{project_name}' ({project_id})") + + # Build projects list for frontend dropdown + projects_for_frontend = [ + {"id": p.get("id"), "name": p.get("name", p.get("id"))} + for p in all_projects + ] + + # Fetch agents only from the selected project + agents = [] + list_params = {"limit": 100, "project_id": project_id} + + try: + page = client.agents.list(**list_params) + # Handle both list and page object responses + agent_list = page.items if hasattr(page, 'items') else page + for agent in agent_list: + agents.append({ + "id": agent.id, + "name": agent.name or agent.id, + "letta_projects": projects_for_frontend, + "letta_current_project": {"id": project_id, "name": project_name}, + }) + except Exception as e: + log.warning(f"Error fetching agents for project {project_id}: {e}") if not agents: return [{ "id": "no-agents", - "name": "No agents found - create one at app.letta.com" + "name": f"No agents in project '{project_name}'", + "letta_projects": projects_for_frontend, + "letta_current_project": {"id": project_id, "name": project_name}, }] - log.info(f"Letta: found {len(agents)} agents") + log.info(f"Letta: found {len(agents)} agents in project '{project_name}'") return agents except Exception as e: @@ -93,8 +247,13 @@ class Pipe: "name": f"Letta error: {str(e)[:50]}" }] - def pipe(self, body: dict) -> Union[str, Generator, Iterator]: - """Process a chat request and forward to Letta agent.""" + def pipe(self, body: dict, __oauth_token__: Optional[dict] = None) -> Union[str, Generator, Iterator]: + """Process a chat request and forward to Letta agent. + + Args: + body: Request body with messages and model info + __oauth_token__: OAuth token passed by Open WebUI if user logged in via OAuth + """ model = body.get("model", "") if "." in model: agent_id = model.split(".", 1)[1] @@ -102,8 +261,8 @@ class Pipe: agent_id = model # Handle placeholder states - if agent_id in ["setup", "no-agents", "error"]: - return "Please configure your Letta API key in the function settings (Admin > Functions > letta_agents > Valves)" + if agent_id in ["setup", "no-agents", "no-projects", "error"]: + return "Please login with Letta or configure an API key in Admin > Functions > letta_agents > Valves" messages = body.get("messages", []) @@ -123,7 +282,7 @@ class Pipe: return "Error: No user message found" try: - client = self._get_client() + client = self._get_client(__oauth_token__) if body.get("stream", False): return self._stream_response(client, agent_id, [last_user_msg]) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 29b503318..db0de88a0 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -568,10 +568,19 @@ LETTA_OAUTH_SCOPE = PersistentConfig( os.environ.get("LETTA_OAUTH_SCOPE", "openid email profile"), ) +def _get_default_letta_redirect_uri(): + """Get default Letta OAuth redirect URI based on environment.""" + if os.environ.get("LETTA_REDIRECT_URI"): + return os.environ.get("LETTA_REDIRECT_URI") + webui_url = os.environ.get("WEBUI_URL", "") + if webui_url: + return f"{webui_url.rstrip('/')}/oauth/letta/callback" + return "https://chatlettacom-production.up.railway.app/oauth/letta/callback" + LETTA_REDIRECT_URI = PersistentConfig( "LETTA_REDIRECT_URI", "oauth.letta.redirect_uri", - os.environ.get("LETTA_REDIRECT_URI", "https://chatlettacom-production.up.railway.app/oauth/letta/callback"), + _get_default_letta_redirect_uri(), ) ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig( @@ -1083,7 +1092,7 @@ OLLAMA_API_CONFIGS = PersistentConfig( ENABLE_OPENAI_API = PersistentConfig( "ENABLE_OPENAI_API", "openai.enable", - os.environ.get("ENABLE_OPENAI_API", "True").lower() == "true", + os.environ.get("ENABLE_OPENAI_API", "False").lower() == "true", ) diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 316efe18e..e9b7bc29c 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -78,10 +78,22 @@ def get_function_module_by_id(request: Request, pipe_id: str): return function_module -async def get_function_models(request): +async def get_function_models(request, user: UserModel = None): pipes = Functions.get_functions_by_type("pipe", active_only=True) pipe_models = [] + # Get OAuth token if user is available + oauth_token = None + if user: + try: + if request.cookies.get("oauth_session_id", None): + oauth_token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + except Exception as e: + log.error(f"Error getting OAuth token for pipes: {e}") + for pipe in pipes: try: function_module = get_function_module_by_id(request, pipe.id) @@ -94,13 +106,35 @@ async def get_function_models(request): if hasattr(function_module, "pipes"): sub_pipes = [] + # Build extra params for pipes() call + extra_params = { + "__oauth_token__": oauth_token, + "__user__": user.model_dump() if user else {}, + "__request__": request, + } + + # Load user valves if the function has UserValves and user is available + if has_user_valves and user: + user_valves = Functions.get_user_valves_by_id_and_user_id(pipe.id, user.id) + try: + extra_params["__user__"]["valves"] = function_module.UserValves(**user_valves) if user_valves else function_module.UserValves() + except Exception as e: + log.exception(f"Error loading user valves for pipes() in {pipe.id}: {e}") + extra_params["__user__"]["valves"] = function_module.UserValves() + # Handle pipes being a list, sync function, or async function try: if callable(function_module.pipes): + # Inspect signature and pass relevant params + sig = inspect.signature(function_module.pipes) + params = { + k: v for k, v in extra_params.items() if k in sig.parameters + } + if asyncio.iscoroutinefunction(function_module.pipes): - sub_pipes = await function_module.pipes() + sub_pipes = await function_module.pipes(**params) else: - sub_pipes = function_module.pipes() + sub_pipes = function_module.pipes(**params) else: sub_pipes = function_module.pipes except Exception as e: @@ -120,17 +154,23 @@ async def get_function_models(request): pipe_flag = {"type": pipe.type} - pipe_models.append( - { - "id": sub_pipe_id, - "name": sub_pipe_name, - "object": "model", - "created": pipe.created_at, - "owned_by": "openai", - "pipe": pipe_flag, - "has_user_valves": has_user_valves, - } - ) + model_entry = { + "id": sub_pipe_id, + "name": sub_pipe_name, + "object": "model", + "created": pipe.created_at, + "owned_by": "openai", + "pipe": pipe_flag, + "has_user_valves": has_user_valves, + } + + # Pass through Letta-specific metadata if present + if "letta_projects" in p: + model_entry["letta_projects"] = p["letta_projects"] + if "letta_current_project" in p: + model_entry["letta_current_project"] = p["letta_current_project"] + + pipe_models.append(model_entry) else: pipe_flag = {"type": "pipe"} diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 8b53ce519..a87a7487e 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -69,7 +69,7 @@ async def get_all_base_models(request: Request, user: UserModel = None): if request.app.state.config.ENABLE_OLLAMA_API else asyncio.sleep(0, result=[]) ) - function_task = get_function_models(request) + function_task = get_function_models(request, user) openai_models, ollama_models, function_models = await asyncio.gather( openai_task, ollama_task, function_task diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 65ebd762f..2c4f69fa0 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -929,6 +929,10 @@ class OAuthManager: log.error(f"No OAuth client found for provider {provider}") return None + # Special handling for Letta - no OpenID discovery endpoint + if provider == "letta": + return await self._letta_token_refresh(token_data, client) + server_metadata_url = self.get_server_metadata_url(provider) token_endpoint = None async with aiohttp.ClientSession(trust_env=True) as session_http: @@ -997,6 +1001,63 @@ class OAuthManager: log.error(f"Exception during token refresh for provider {provider}: {e}") return None + async def _letta_token_refresh(self, token_data: dict, client) -> dict: + """ + Refresh token for Letta OAuth (no OpenID discovery endpoint). + + Args: + token_data: Current token data with refresh_token + client: The OAuth client + + Returns: + dict: New token data, or None if refresh failed + """ + from open_webui.config import LETTA_BASE_URL + + token_url = f"{LETTA_BASE_URL.value}/api/oauth/token" + + refresh_data = { + "grant_type": "refresh_token", + "refresh_token": token_data["refresh_token"], + "client_id": client.client_id, + } + + try: + async with aiohttp.ClientSession(trust_env=True) as session_http: + async with session_http.post( + token_url, + data=refresh_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + if r.status == 200: + new_token_data = await r.json() + + # Fix non-standard token_type if needed + if new_token_data.get("token_type") == "access_token": + new_token_data["token_type"] = "Bearer" + + # Preserve refresh_token if not returned + if "refresh_token" not in new_token_data: + new_token_data["refresh_token"] = token_data["refresh_token"] + + # Add timestamps + new_token_data["issued_at"] = datetime.now().timestamp() + if "expires_in" in new_token_data and "expires_at" not in new_token_data: + new_token_data["expires_at"] = int( + datetime.now().timestamp() + new_token_data["expires_in"] + ) + + log.debug("Letta token refresh successful") + return new_token_data + else: + error_text = await r.text() + log.error(f"Letta token refresh failed: {r.status} - {error_text}") + return None + except Exception as e: + log.error(f"Exception during Letta token refresh: {e}") + return None + def get_user_role(self, user, user_data): user_count = Users.get_num_users() if user and user_count == 1: diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 4c442568f..dcf26f885 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -13,6 +13,7 @@ import { goto } from '$app/navigation'; import { deleteModel, getOllamaVersion, pullModel, unloadModel } from '$lib/apis/ollama'; + import { updateUserValvesById } from '$lib/apis/functions'; import { user, @@ -71,6 +72,38 @@ let selectedTag = ''; let selectedConnectionType = ''; + // Extract Letta projects from model metadata (if available) + $: lettaProjects = (() => { + const firstLettaModel = items.find((item) => item.model?.letta_projects); + return firstLettaModel?.model?.letta_projects ?? []; + })(); + + $: lettaCurrentProject = (() => { + const firstLettaModel = items.find((item) => item.model?.letta_current_project); + return firstLettaModel?.model?.letta_current_project ?? null; + })(); + + // Handler for changing the selected Letta project + const selectLettaProject = async (projectId: string) => { + try { + // Update the user's valves with the new project selection + await updateUserValvesById(localStorage.token, 'letta_agents', { + SELECTED_PROJECT: projectId + }); + + // Refresh the models list to get agents from the new project + models.set( + await getModels( + localStorage.token, + $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) + ) + ); + } catch (error) { + console.error('Error updating project selection:', error); + toast.error('Failed to update project selection'); + } + }; + let ollamaVersion = null; let selectedModelIdx = 0; @@ -448,6 +481,37 @@ {/if}