From c2207887b3241c0d1c74af842d1822137936250d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 11 Feb 2026 14:00:34 -0600 Subject: [PATCH] feat: skills backend --- backend/open_webui/main.py | 2 + backend/open_webui/models/skills.py | 234 ++++++++++++++++ backend/open_webui/routers/skills.py | 363 +++++++++++++++++++++++++ backend/open_webui/tools/builtin.py | 58 ++++ backend/open_webui/utils/middleware.py | 23 ++ backend/open_webui/utils/tools.py | 5 + 6 files changed, 685 insertions(+) create mode 100644 backend/open_webui/models/skills.py create mode 100644 backend/open_webui/routers/skills.py diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 72cb31fcf..4d838d251 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -90,6 +90,7 @@ from open_webui.routers import ( knowledge, prompts, evaluations, + skills, tools, users, utils, @@ -1467,6 +1468,7 @@ app.include_router(models.router, prefix="/api/v1/models", tags=["models"]) app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"]) app.include_router(prompts.router, prefix="/api/v1/prompts", tags=["prompts"]) app.include_router(tools.router, prefix="/api/v1/tools", tags=["tools"]) +app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"]) app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"]) app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"]) diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py new file mode 100644 index 000000000..d492e07a6 --- /dev/null +++ b/backend/open_webui/models/skills.py @@ -0,0 +1,234 @@ +import logging +import time +from typing import Optional + +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.models.users import Users, UserResponse +from open_webui.models.groups import Groups +from open_webui.models.access_grants import AccessGrantModel, AccessGrants + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import BigInteger, Boolean, Column, String, Text + + +log = logging.getLogger(__name__) + +#################### +# Skills DB Schema +#################### + + +class Skill(Base): + __tablename__ = "skill" + + id = Column(String, primary_key=True, unique=True) + user_id = Column(String) + name = Column(Text, unique=True) + description = Column(Text, nullable=True) + content = Column(Text) + meta = Column(JSONField) + is_active = Column(Boolean, default=True) + + updated_at = Column(BigInteger) + created_at = Column(BigInteger) + + +class SkillMeta(BaseModel): + tags: Optional[list[str]] = [] + + +class SkillModel(BaseModel): + id: str + user_id: str + name: str + description: Optional[str] = None + content: str + meta: SkillMeta + is_active: bool = True + access_grants: list[AccessGrantModel] = Field(default_factory=list) + + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + +#################### +# Forms +#################### + + +class SkillUserModel(SkillModel): + user: Optional[UserResponse] = None + + +class SkillResponse(BaseModel): + id: str + user_id: str + name: str + description: Optional[str] = None + meta: SkillMeta + is_active: bool = True + access_grants: list[AccessGrantModel] = Field(default_factory=list) + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + +class SkillUserResponse(SkillResponse): + user: Optional[UserResponse] = None + + model_config = ConfigDict(extra="allow") + + +class SkillAccessResponse(SkillUserResponse): + write_access: Optional[bool] = False + + +class SkillForm(BaseModel): + id: str + name: str + description: Optional[str] = None + content: str + meta: SkillMeta = SkillMeta() + is_active: bool = True + access_grants: Optional[list[dict]] = None + + +class SkillsTable: + def _get_access_grants( + self, skill_id: str, db: Optional[Session] = None + ) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource("skill", skill_id, db=db) + + def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel: + skill_data = SkillModel.model_validate(skill).model_dump(exclude={"access_grants"}) + skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db) + return SkillModel.model_validate(skill_data) + + def insert_new_skill( + self, + user_id: str, + form_data: SkillForm, + db: Optional[Session] = None, + ) -> Optional[SkillModel]: + with get_db_context(db) as db: + try: + result = Skill( + **{ + **form_data.model_dump(exclude={"access_grants"}), + "user_id": user_id, + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + db.add(result) + db.commit() + db.refresh(result) + AccessGrants.set_access_grants( + "skill", result.id, form_data.access_grants, db=db + ) + if result: + return self._to_skill_model(result, db=db) + else: + return None + except Exception as e: + log.exception(f"Error creating a new skill: {e}") + return None + + def get_skill_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + skill = db.get(Skill, id) + return self._to_skill_model(skill, db=db) if skill else None + except Exception: + return None + + def get_skill_by_name( + self, name: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + skill = db.query(Skill).filter_by(name=name).first() + return self._to_skill_model(skill, db=db) if skill else None + except Exception: + return None + + def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: + with get_db_context(db) as db: + all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all() + + user_ids = list(set(skill.user_id for skill in all_skills)) + + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users_dict = {user.id: user for user in users} + + skills = [] + for skill in all_skills: + user = users_dict.get(skill.user_id) + skills.append( + SkillUserModel.model_validate( + { + **self._to_skill_model(skill, db=db).model_dump(), + "user": user.model_dump() if user else None, + } + ) + ) + return skills + + def get_skills_by_user_id( + self, user_id: str, permission: str = "write", db: Optional[Session] = None + ) -> list[SkillUserModel]: + skills = self.get_skills(db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } + + return [ + skill + for skill in skills + if skill.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type="skill", + resource_id=skill.id, + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + ] + + def update_skill_by_id( + self, id: str, updated: dict, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + access_grants = updated.pop("access_grants", None) + db.query(Skill).filter_by(id=id).update( + {**updated, "updated_at": int(time.time())} + ) + db.commit() + if access_grants is not None: + AccessGrants.set_access_grants("skill", id, access_grants, db=db) + + skill = db.query(Skill).get(id) + db.refresh(skill) + return self._to_skill_model(skill, db=db) + except Exception: + return None + + def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool: + try: + with get_db_context(db) as db: + AccessGrants.revoke_all_access("skill", id, db=db) + db.query(Skill).filter_by(id=id).delete() + db.commit() + + return True + except Exception: + return False + + +Skills = SkillsTable() diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py new file mode 100644 index 000000000..6739c634b --- /dev/null +++ b/backend/open_webui/routers/skills.py @@ -0,0 +1,363 @@ +import logging +from typing import Optional + +from open_webui.models.groups import Groups +from pydantic import BaseModel + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from open_webui.internal.db import get_session +from open_webui.models.skills import ( + SkillForm, + SkillModel, + SkillResponse, + SkillUserResponse, + SkillAccessResponse, + Skills, +) +from open_webui.models.access_grants import AccessGrants +from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_access, has_permission + +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.constants import ERROR_MESSAGES + + +log = logging.getLogger(__name__) + + +router = APIRouter() + + +############################ +# GetSkills +############################ + + +@router.get("/", response_model=list[SkillUserResponse]) +async def get_skills( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + skills = Skills.get_skills(db=db) + else: + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + all_skills = Skills.get_skills(db=db) + skills = [ + skill + for skill in all_skills + if skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="read", + user_group_ids=user_group_ids, + db=db, + ) + ] + + return skills + + +############################ +# GetSkillList +############################ + + +@router.get("/list", response_model=list[SkillAccessResponse]) +async def get_skill_list( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + skills = Skills.get_skills(db=db) + else: + skills = Skills.get_skills_by_user_id(user.id, "read", db=db) + + return [ + SkillAccessResponse( + **skill.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == skill.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ), + ) + for skill in skills + ] + + +############################ +# ExportSkills +############################ + + +@router.get("/export", response_model=list[SkillModel]) +async def export_skills( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role != "admin" and not has_permission( + user.id, + "workspace.skills", + request.app.state.config.USER_PERMISSIONS, + db=db, + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return Skills.get_skills(db=db) + else: + return Skills.get_skills_by_user_id(user.id, "read", db=db) + + +############################ +# CreateNewSkill +############################ + + +@router.post("/create", response_model=Optional[SkillResponse]) +async def create_new_skill( + request: Request, + form_data: SkillForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role != "admin" and not has_permission( + user.id, "workspace.skills", request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + form_data.id = form_data.id.lower().replace(" ", "-") + + existing = Skills.get_skill_by_id(form_data.id, db=db) + if existing is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.ID_TAKEN, + ) + + try: + skill = Skills.insert_new_skill(user.id, form_data, db=db) + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error creating skill"), + ) + except Exception as e: + log.exception(f"Failed to create skill: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + + +############################ +# GetSkillById +############################ + + +@router.get("/id/{id}", response_model=Optional[SkillAccessResponse]) +async def get_skill_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + skill = Skills.get_skill_by_id(id, db=db) + + if skill: + if ( + user.role == "admin" + or skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="read", + db=db, + ) + ): + return SkillAccessResponse( + **skill.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == skill.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# UpdateSkillById +############################ + + +@router.post("/id/{id}/update", response_model=Optional[SkillModel]) +async def update_skill_by_id( + request: Request, + id: str, + form_data: SkillForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + try: + updated = { + **form_data.model_dump(exclude={"id"}), + } + + skill = Skills.update_skill_by_id(id, updated, db=db) + + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error updating skill"), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + + +############################ +# UpdateSkillAccessById +############################ + + +class SkillAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post("/id/{id}/access/update", response_model=Optional[SkillModel]) +async def update_skill_access_by_id( + id: str, + form_data: SkillAccessGrantsForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + AccessGrants.set_access_grants( + "skill", id, form_data.access_grants, db=db + ) + + return Skills.get_skill_by_id(id, db=db) + + +############################ +# DeleteSkillById +############################ + + +@router.delete("/id/{id}/delete", response_model=bool) +async def delete_skill_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + result = Skills.delete_skill_by_id(id, db=db) + return result diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 9dd3ea1bc..6014bd7bd 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -1881,3 +1881,61 @@ async def query_knowledge_bases( except Exception as e: log.exception(f"query_knowledge_bases error: {e}") return json.dumps({"error": str(e)}) + + +# ============================================================================= +# SKILLS TOOLS +# ============================================================================= + + +async def view_skill( + name: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Load the full instructions of a skill by its name from the available skills manifest. + Use this when you need detailed instructions for a skill listed in . + + :param name: The name of the skill to load (as shown in the manifest) + :return: The full skill instructions as markdown content + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.skills import Skills + from open_webui.models.access_grants import AccessGrants + + user_id = __user__.get("id") + + # Direct DB lookup by unique name + skill = Skills.get_skill_by_name(name) + + if not skill or not skill.is_active: + return json.dumps({"error": f"Skill '{name}' not found"}) + + # Check user access + user_role = __user__.get("role", "user") + if user_role != "admin" and skill.user_id != user_id: + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + if not AccessGrants.has_access( + user_id=user_id, + resource_type="skill", + resource_id=skill.id, + permission="read", + user_group_ids=set(user_group_ids), + ): + return json.dumps({"error": "Access denied"}) + + return json.dumps({ + "name": skill.name, + "content": skill.content, + }, ensure_ascii=False) + except Exception as e: + log.exception(f"view_skill error: {e}") + return json.dumps({"error": str(e)}) + diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 58ea3f249..586a26110 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2097,6 +2097,29 @@ async def process_chat_payload(request, form_data, user, metadata, model): tool_ids = form_data.pop("tool_ids", None) files = form_data.pop("files", None) + # Skills: inject manifest only — model uses view_skill tool to load full content on-demand + user_skill_ids = form_data.pop("skill_ids", None) or [] + model_skill_ids = model.get("info", {}).get("meta", {}).get("skills", []) + + all_skill_ids = list(set(user_skill_ids + model_skill_ids)) + if all_skill_ids: + from open_webui.models.skills import Skills as SkillsModel + + accessible_skill_ids = {s.id for s in SkillsModel.get_skills_by_user_id(user.id, "read")} + available_skills = [ + s for sid in all_skill_ids + if sid in accessible_skill_ids and (s := SkillsModel.get_skill_by_id(sid)) and s.is_active + ] + + if available_skills: + manifest = "\n" + for skill in available_skills: + manifest += f"\n{skill.name}\n{skill.description or ''}\n\n" + manifest += "" + form_data["messages"] = add_or_update_system_message( + manifest, form_data["messages"], append=True + ) + prompt = get_last_user_message(form_data["messages"]) # TODO: re-enable URL extraction from prompt # urls = [] diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 88479f40d..bce57dc26 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -77,6 +77,7 @@ from open_webui.tools.builtin import ( search_knowledge_files, query_knowledge_files, view_knowledge_file, + view_skill, ) import copy @@ -506,6 +507,10 @@ def get_builtin_tools( ] ) + # Skills tools - view_skill allows model to load full skill instructions on demand + if is_builtin_tool_enabled("skills"): + builtin_functions.append(view_skill) + for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( func,