diff --git a/backend/open_webui/models/user_model_credentials.py b/backend/open_webui/models/user_model_credentials.py new file mode 100644 index 0000000000..8e57b40ddd --- /dev/null +++ b/backend/open_webui/models/user_model_credentials.py @@ -0,0 +1,331 @@ +""" +用户私有模型凭据管理 - 数据模型层 + +功能: +1. 允许用户保存自己的 LLM API 凭据(OpenAI/Claude/自建等) +2. 提供凭据的增删改查操作 +3. 自动掩码 API Key 防止泄露 +4. 支持多 Provider(OpenAI/Ollama/兼容接口等) + +核心设计: +- 每个用户可以添加多个私有模型配置 +- 存储 base_url、api_key 等信息 +- 返回给前端时自动掩码 API Key(仅显示前 4 位和后 4 位) +- 所有操作都强制用户隔离(通过 user_id 校验) + +建表参考(SQLite 默认路径 backend/data/webui.db): +cd /home/gaofeng/open-webui-next/backend + source .venv/bin/activate + sqlite3 data/webui.db " + CREATE TABLE IF NOT EXISTS user_model_credential ( + id TEXT PRIMARY KEY, + user_id TEXT, + name TEXT, + model_id TEXT NOT NULL, + base_url TEXT, + api_key TEXT NOT NULL, + config TEXT, + created_at INTEGER, + updated_at INTEGER + ); + CREATE INDEX IF NOT EXISTS ix_user_model_credential_user_id ON user_model_credential (user_id); + " +""" + +import time +from typing import Optional + +from open_webui.internal.db import Base, JSONField, get_db +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, String, Text + +#################### +# DB Schema - 数据库表定义 +#################### + + +class UserModelCredential(Base): + """ + 用户私有模型凭据表 - 存储每个用户自己配置的 LLM API 凭据 + + 字段说明: + - id: 凭据唯一标识(纳秒时间戳) + - user_id: 所属用户 ID(外键,索引) + - name: 用户可读名称(如 "我的 GPT-4") + - model_id: 上游模型名称/ID(如 "gpt-4", "claude-3-opus") + - base_url: API 基础地址(如 "https://api.openai.com/v1") + - api_key: API 密钥(明文存储,返回时掩码) + - config: 扩展配置(JSON,可存 organization、headers 等) + - created_at/updated_at: 创建/更新时间戳 + """ + __tablename__ = "user_model_credential" + + id = Column(String, primary_key=True) + user_id = Column(String, index=True) + + name = Column(String, nullable=True) # 用户可读名称 + model_id = Column(String, nullable=False) # 上游模型名/ID + base_url = Column(Text, nullable=True) + api_key = Column(Text, nullable=False) + + config = Column(JSONField, nullable=True) # 预留自定义字段,如org、headers等 + + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class UserModelCredentialModel(BaseModel): + """ + 用户私有模型凭据数据模型 - 内部使用的完整数据模型 + + 用途:数据库操作和内部逻辑使用的完整模型(包含明文 api_key) + 注意:不应直接返回给前端,应使用 UserModelCredentialResponse + """ + id: str + user_id: str + name: Optional[str] = None + model_id: str + base_url: Optional[str] = None + api_key: str # 明文 API Key,仅内部使用 + config: Optional[dict] = None + created_at: int + updated_at: int + + model_config = ConfigDict(from_attributes=True) + + +class UserModelCredentialResponse(BaseModel): + """ + 用户私有模型凭据响应模型 - 返回给前端的安全模型 + + 用途:API 响应时使用,将 api_key 替换为掩码后的 api_key_masked + 安全设计:前端永远看不到完整的 API Key,只能看到前 4 位和后 4 位 + """ + id: str + user_id: str + name: Optional[str] = None + model_id: str + base_url: Optional[str] = None + api_key_masked: str # 掩码后的 API Key(如 "sk-1***5678") + config: Optional[dict] = None + created_at: int + updated_at: int + + model_config = ConfigDict(from_attributes=True) + + +class UserModelCredentialForm(BaseModel): + """ + 用户私有模型凭据表单模型 - 前端提交的数据格式 + + 用途:创建/更新凭据时前端提交的数据 + 字段:不包含 id、user_id、时间戳(由后端自动生成) + """ + name: Optional[str] = None + model_id: str + base_url: Optional[str] = None + api_key: str # 前端提交时是明文,后端直接存储 + config: Optional[dict] = None + + +def mask_api_key(key: str) -> str: + """ + 掩码 API Key - 保护敏感信息 + + 规则: + - 空字符串:返回空 + - 长度 ≤ 8:全部替换为 * + - 长度 > 8:保留前 4 位和后 4 位,中间用 * 填充 + + 示例: + - "sk-1234567890abcdef" → "sk-1**********cdef" + - "short" → "*****" + - "" → "" + """ + if not key: + return "" + if len(key) <= 8: + return "*" * len(key) + return f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}" + + +class UserModelCredentialsTable: + """ + 用户私有模型凭据数据访问层 - CRUD 操作 + + 功能: + 1. 增:insert_new_credential - 创建新凭据 + 2. 删:delete_credential_by_id_and_user_id - 删除凭据(用户隔离) + 3. 改:update_credential_by_id_and_user_id - 更新凭据(用户隔离) + 4. 查:get_credentials_by_user_id - 获取用户的所有凭据 + get_credential_by_id_and_user_id - 获取单个凭据(用户隔离) + + 安全设计: + - 所有操作都强制校验 user_id,防止跨用户访问 + - 更新/删除/查询单条时,必须同时匹配 id 和 user_id + """ + def insert_new_credential( + self, user_id: str, form: UserModelCredentialForm + ) -> Optional[UserModelCredentialModel]: + """ + 创建新的用户私有模型凭据 + + 流程: + 1. 生成唯一 ID(纳秒时间戳,确保唯一性) + 2. 关联当前用户 ID + 3. 保存表单数据(model_id、base_url、api_key 等) + 4. 设置创建时间和更新时间 + 5. 持久化到数据库 + + Args: + user_id: 当前用户 ID + form: 前端提交的凭据表单数据 + + Returns: + UserModelCredentialModel: 创建成功的凭据对象 + """ + with get_db() as db: + now = int(time.time()) + # 使用纳秒时间戳生成唯一 ID + cred = UserModelCredential( + **{ + "id": str(time.time_ns()), + "user_id": user_id, + "name": form.name, + "model_id": form.model_id, + "base_url": form.base_url, + "api_key": form.api_key, # 明文存储 + "config": form.config, + "created_at": now, + "updated_at": now, + } + ) + db.add(cred) + db.commit() + db.refresh(cred) + return UserModelCredentialModel.model_validate(cred) + + def update_credential_by_id_and_user_id( + self, cred_id: str, user_id: str, form: UserModelCredentialForm + ) -> Optional[UserModelCredentialModel]: + """ + 更新用户私有模型凭据 + + 安全校验: + - 凭据必须存在 + - 凭据必须属于当前用户(user_id 匹配) + - 不满足条件返回 None + + 更新策略: + - 仅更新前端提交的字段(exclude_none=True) + - 自动更新 updated_at 时间戳 + - created_at 不会被修改 + + Args: + cred_id: 凭据 ID + user_id: 当前用户 ID + form: 更新的数据 + + Returns: + UserModelCredentialModel: 更新后的凭据对象,权限不足返回 None + """ + with get_db() as db: + # === 1. 权限校验:凭据必须存在且属于当前用户 === + cred = db.get(UserModelCredential, cred_id) + if not cred or cred.user_id != user_id: + return None + + # === 2. 更新字段:仅更新提交的非空字段 === + for field, value in form.model_dump(exclude_none=True).items(): + setattr(cred, field, value) + cred.updated_at = int(time.time()) + + # === 3. 持久化 === + db.commit() + db.refresh(cred) + return UserModelCredentialModel.model_validate(cred) + + def delete_credential_by_id_and_user_id( + self, cred_id: str, user_id: str + ) -> bool: + """ + 删除用户私有模型凭据 + + 安全校验: + - 凭据必须存在 + - 凭据必须属于当前用户(user_id 匹配) + - 不满足条件返回 False + + Args: + cred_id: 凭据 ID + user_id: 当前用户 ID + + Returns: + bool: 删除成功返回 True,权限不足或不存在返回 False + """ + with get_db() as db: + # === 1. 权限校验:凭据必须存在且属于当前用户 === + cred = db.get(UserModelCredential, cred_id) + if not cred or cred.user_id != user_id: + return False + + # === 2. 删除凭据 === + db.delete(cred) + db.commit() + return True + + def get_credentials_by_user_id( + self, user_id: str + ) -> list[UserModelCredentialModel]: + """ + 获取用户的所有私有模型凭据 + + 用途:前端模型选择器展示用户的所有私有模型 + 返回:用户创建的所有凭据列表(包含明文 api_key) + + Args: + user_id: 用户 ID + + Returns: + list[UserModelCredentialModel]: 用户的所有凭据列表,无数据返回空列表 + """ + with get_db() as db: + # 查询该用户的所有凭据 + creds = ( + db.query(UserModelCredential).filter_by(user_id=user_id).all() + ) + return ( + [UserModelCredentialModel.model_validate(c) for c in creds] + if creds + else [] + ) + + def get_credential_by_id_and_user_id( + self, cred_id: str, user_id: str + ) -> Optional[UserModelCredentialModel]: + """ + 获取单个用户私有模型凭据 + + 安全校验: + - 凭据必须存在 + - 凭据必须属于当前用户(user_id 匹配) + - 不满足条件返回 None + + 用途:查看/编辑凭据详情时使用 + + Args: + cred_id: 凭据 ID + user_id: 当前用户 ID + + Returns: + UserModelCredentialModel: 凭据对象,权限不足返回 None + """ + with get_db() as db: + # === 1. 权限校验:凭据必须存在且属于当前用户 === + cred = db.get(UserModelCredential, cred_id) + if not cred or cred.user_id != user_id: + return None + return UserModelCredentialModel.model_validate(cred) + + +UserModelCredentials = UserModelCredentialsTable() diff --git a/backend/open_webui/routers/user_models.py b/backend/open_webui/routers/user_models.py new file mode 100644 index 0000000000..ee62e4f604 --- /dev/null +++ b/backend/open_webui/routers/user_models.py @@ -0,0 +1,187 @@ +""" +用户私有模型凭据管理 - API 路由层 + +提供的接口: +- GET /api/user/models/credentials - 获取当前用户的所有私有模型凭据 +- POST /api/user/models/credentials - 创建新的私有模型凭据 +- PUT /api/user/models/credentials/{id} - 更新指定的私有模型凭据 +- DELETE /api/user/models/credentials/{id} - 删除指定的私有模型凭据 + +安全设计: +- 所有接口都需要用户认证(get_verified_user) +- 返回数据时自动掩码 API Key(仅显示前 4 位和后 4 位) +- 更新/删除操作强制用户隔离(仅能操作自己的凭据) +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from open_webui.constants import ERROR_MESSAGES +from open_webui.utils.auth import get_verified_user +from open_webui.models.user_model_credentials import ( + UserModelCredentialForm, + UserModelCredentialResponse, + UserModelCredentials, + mask_api_key, +) + +log = logging.getLogger(__name__) + +router = APIRouter() + + +def _mask_response(model): + """ + 响应数据掩码处理 - 保护 API Key 不被前端完整获取 + + 转换流程: + 1. 将内部模型(包含明文 api_key)转换为响应模型 + 2. 不回传明文 api_key,只返回掩码字段 api_key_masked + + Args: + model: UserModelCredentialModel(包含明文 api_key) + + Returns: + UserModelCredentialResponse: 掩码后的响应对象 + """ + return UserModelCredentialResponse( + **{ + **{ + k: v + for k, v in model.model_dump().items() + if k not in ["api_key"] + }, + "api_key_masked": mask_api_key(model.api_key), # 掩码 API Key + } + ) + + +@router.get("/", response_model=list[UserModelCredentialResponse]) +async def list_user_credentials(user=Depends(get_verified_user)): + """ + 获取当前用户的所有私有模型凭据 + + 用途:前端模型选择器初始化时调用,获取用户保存的所有私有 API 配置 + 返回:掩码后的凭据列表(api_key_masked 字段,不暴露明文) + + 权限:仅返回当前用户的凭据 + + Returns: + list[UserModelCredentialResponse]: 用户的所有私有模型凭据列表 + """ + # 查询当前用户的所有凭据 + creds = UserModelCredentials.get_credentials_by_user_id(user.id) + # 掩码 API Key 后返回 + return [_mask_response(c) for c in creds] + + +@router.post("/", response_model=UserModelCredentialResponse) +async def create_user_credential( + form_data: UserModelCredentialForm, user=Depends(get_verified_user) +): + """ + 创建新的私有模型凭据 + + 用途:用户在前端添加自己的 LLM API(如自己的 OpenAI Key、Claude Key 等) + 流程: + 1. 接收前端提交的表单数据(model_id、base_url、api_key 等) + 2. 自动关联当前用户 ID + 3. 生成唯一凭据 ID + 4. 保存到数据库 + 5. 返回掩码后的凭据对象 + + Args: + form_data: 前端提交的凭据表单(不包含 id 和 user_id) + user: 当前认证用户(由依赖注入自动获取) + + Returns: + UserModelCredentialResponse: 创建成功的凭据对象(API Key 已掩码) + + Raises: + HTTPException(400): 创建失败时抛出 + """ + # 创建新凭据,自动关联当前用户 ID + cred = UserModelCredentials.insert_new_credential(user.id, form_data) + if not cred: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(), + ) + # 掩码 API Key 后返回 + return _mask_response(cred) + + +@router.put("/{cred_id}", response_model=UserModelCredentialResponse) +async def update_user_credential( + cred_id: str, form_data: UserModelCredentialForm, user=Depends(get_verified_user) +): + """ + 更新指定的私有模型凭据 + + 用途:用户编辑自己保存的 API 凭据(修改 base_url、api_key、model_id 等) + 安全校验: + - 凭据必须存在 + - 凭据必须属于当前用户(user_id 匹配) + - 不满足条件返回 404 + + 更新策略:仅更新前端提交的字段(部分更新) + + Args: + cred_id: 凭据 ID(路径参数) + form_data: 更新的数据 + user: 当前认证用户(由依赖注入自动获取) + + Returns: + UserModelCredentialResponse: 更新后的凭据对象(API Key 已掩码) + + Raises: + HTTPException(404): 凭据不存在或不属于当前用户 + """ + # 更新凭据,自动校验用户权限 + cred = UserModelCredentials.update_credential_by_id_and_user_id( + cred_id, user.id, form_data + ) + if not cred: + # 凭据不存在或权限不足 + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + # 掩码 API Key 后返回 + return _mask_response(cred) + + +@router.delete("/{cred_id}", response_model=bool) +async def delete_user_credential(cred_id: str, user=Depends(get_verified_user)): + """ + 删除指定的私有模型凭据 + + 用途:用户删除自己保存的 API 凭据 + 安全校验: + - 凭据必须存在 + - 凭据必须属于当前用户(user_id 匹配) + - 不满足条件返回 404 + + Args: + cred_id: 凭据 ID(路径参数) + user: 当前认证用户(由依赖注入自动获取) + + Returns: + bool: 删除成功返回 True + + Raises: + HTTPException(404): 凭据不存在或不属于当前用户 + """ + # 删除凭据,自动校验用户权限 + result = UserModelCredentials.delete_credential_by_id_and_user_id( + cred_id, user.id + ) + if not result: + # 凭据不存在或权限不足 + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + return True