Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ _Avoid_: Row Permission
_Avoid_: 模型名称(仅显示用,与基础模型无强制关系)

**Default Model / 默认模型**:
系统全局唯一的默认问答模型;工作空间不设各自的默认模型。
_Avoid_: 工作空间默认模型(该概念不存在)
默认模型分为系统默认模型和工作空间默认模型。系统默认模型全局唯一;工作空间默认使用它并允许穿透空间授权,也可显式指定一个已授权模型。工作空间启用指定模型但 ID 留空时,禁止该空间所有问数入口的模型调用。助手选择默认模型时动态跟随空间配置;助手或 MCP 显式指定模型时必须验证空间授权。

**Workspace Model Policy / 工作空间模型策略**:
仅系统管理员配置,由 `sys_workspace.enable_custom_model` 和 `custom_model` 持久化。失效的指定 ID 保留,调用时报错、不回退;有效的显式指定模型可替代失效的空间默认模型。策略与授权在每次新操作时读取,正在执行的操作沿用启动时选定的模型。

### 会话

Expand Down
19 changes: 19 additions & 0 deletions backend/alembic/versions/073_workspace_model_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Add workspace model policy; existing and new workspaces keep system defaults."""

import sqlalchemy as sa
from alembic import op

revision = "c71e75fd6a30"
down_revision = "f898322341a5"
branch_labels = None
depends_on = None


def upgrade():
op.add_column("sys_workspace", sa.Column("enable_custom_model", sa.Boolean(), server_default=sa.false(), nullable=False, comment="是否指定工作空间模型"))
op.add_column("sys_workspace", sa.Column("custom_model", sa.String(255), nullable=True, comment="工作空间默认模型 ID;显式留空禁止模型调用"))


def downgrade():
op.drop_column("sys_workspace", "custom_model")
op.drop_column("sys_workspace", "enable_custom_model")
8 changes: 6 additions & 2 deletions backend/apps/ai_model/model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from apps.ai_model.openai.llm import BaseChatOpenAI
from apps.system.models.system_model import AiModelDetail
from common.core.db import engine
from common.error import SingleMessageError
from common.utils.locale import I18n
from common.utils.crypto import sqlbot_decrypt
from common.utils.utils import prepare_model_arg
from langchain_community.llms import VLLMOpenAI
Expand Down Expand Up @@ -145,12 +147,14 @@ def register_llm(cls, model_type: str, llm_class: Type[BaseLLM]):
return config """


async def get_default_config(custom_model_id: Optional[int] = None) -> LLMConfig:
async def get_default_config(custom_model_id: Optional[int] = None, *, trans=None) -> LLMConfig:
with Session(engine) as session:
db_model: AiModelDetail | None = None
if custom_model_id:
db_model = session.get(AiModelDetail, custom_model_id)
if not db_model:
if db_model is None:
raise SingleMessageError((trans or I18n()())('i18n_llm.model_unavailable'))
else:
db_model = session.exec(
select(AiModelDetail).where(AiModelDetail.default_model == True)
).first()
Expand Down
9 changes: 5 additions & 4 deletions backend/apps/chat/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,11 @@ async def question_answer_inner(session: SessionDep, current_user: CurrentUser,

elif command == QuickCommand.ANALYSIS:
return await analysis_or_predict(session, current_user, rec_id, 'analysis', current_assistant, in_chat,
stream)
stream, custom_model=request_question.custom_model)

elif command == QuickCommand.PREDICT_DATA:
return await analysis_or_predict(session, current_user, rec_id, 'predict', current_assistant, in_chat,
stream)
stream, custom_model=request_question.custom_model)
else:
raise Exception(f'Unknown command: {command.value}')
else:
Expand Down Expand Up @@ -397,7 +397,8 @@ async def analysis_or_predict_question(session: SessionDep, current_user: Curren


async def analysis_or_predict(session: SessionDep, current_user: CurrentUser, chat_record_id: int, action_type: str,
current_assistant: CurrentAssistant, in_chat: bool = True, stream: bool = True):
current_assistant: CurrentAssistant, in_chat: bool = True, stream: bool = True,
custom_model: str | int | None = None):
try:
if action_type != 'analysis' and action_type != 'predict':
raise Exception(f"Type {action_type} Not Found")
Expand Down Expand Up @@ -429,7 +430,7 @@ async def analysis_or_predict(session: SessionDep, current_user: CurrentUser, ch
raise Exception(
f"Chat record with id {chat_record_id} has not generated chart, do not support to analyze it")

request_question = ChatQuestion(chat_id=record.chat_id, question=record.question)
request_question = ChatQuestion(chat_id=record.chat_id, question=record.question, custom_model=custom_model)

llm_service = await LLMService.create(session, current_user, request_question, current_assistant)
llm_service.run_analysis_or_predict_task_async(session, action_type, record, in_chat, stream)
Expand Down
29 changes: 8 additions & 21 deletions backend/apps/chat/task/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from apps.datasource.embedding.ds_embedding import get_ds_embedding
from apps.datasource.models.datasource import CoreDatasource
from apps.db.db import exec_sql, get_version, check_connection, get_sqlglot_dialect
from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace
from apps.system.crud.workspace_model import resolve_model_id
from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_ds
from apps.system.crud.parameter_manage import get_groups
from apps.system.crud.user import user_ws_list
Expand Down Expand Up @@ -223,26 +223,13 @@ def __init__(self, session: Session, current_user: CurrentUser, chat_question: C

@classmethod
async def create(cls, *args, **kwargs):
specialized_model_id = None
_ai_model_list = []
if args[3]:
if args[1]:
ws_id = args[1].oid
_ai_model_list = get_ai_model_list_by_workspace(args[0], ws_id)
if args[3].enable_custom_model:
if args[3].custom_model:
if any(str(model.id) == str(args[3].custom_model) for model in _ai_model_list):
specialized_model_id = args[3].custom_model
print("use custom model: id[" + specialized_model_id + "]")
# mcp call, custom model id
if args[2] and args[2].custom_model:
if args[1]:
ws_id = args[1].oid
_ai_model_list = get_ai_model_list_by_workspace(args[0], ws_id)
if any(str(model.id) == str(args[2].custom_model) for model in _ai_model_list):
specialized_model_id = args[2].custom_model
print("use custom model: id[" + specialized_model_id + "]")
config: LLMConfig = await get_default_config(specialized_model_id)
session, user, question, assistant = args[:4]
chat = session.get(Chat, question.chat_id)
if chat is None:
raise SingleMessageError(f"Chat with id {question.chat_id} not found")
trans = i18n(lang=user.language)
model_id = resolve_model_id(session, chat.oid, trans, assistant, question.custom_model)
config: LLMConfig = await get_default_config(model_id, trans=trans)
# 构造函数可能同步调用宿主 API(type==1 高级应用动态数据源,
# LLMService.__init__ -> AssistantOutDsFactory.get_instance -> requests.get),
# 必须在工作线程执行,避免阻塞事件循环(Issue #1288)
Expand Down
54 changes: 41 additions & 13 deletions backend/apps/system/api/aimodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from apps.ai_model.model_factory import LLMConfig, LLMFactory
from apps.swagger.i18n import PLACEHOLDER_PREFIX
from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace
from apps.system.crud.workspace_model import resolve_model_id, get_model_impacts, apply_model_impact_resolutions
from apps.system.schemas.workspace_model import ModelMappingUpdate, ModelImpactResolution, ModelImpact
from apps.system.models.system_model import AiModelDetail, AiModelWorkspaceMapping, AiModelBrief
from apps.system.schemas.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem
from apps.system.schemas.permission import SqlbotPermission, require_permissions
from common.core.deps import SessionDep, Trans, CurrentUser
from common.core.deps import SessionDep, Trans, CurrentUser, CurrentAssistant
from common.utils.crypto import sqlbot_decrypt
from common.utils.time import get_timestamp
from common.utils.utils import SQLBotLogUtil, prepare_model_arg
Expand Down Expand Up @@ -52,12 +54,8 @@ async def generate():


@router.get("/default", include_in_schema=False)
async def check_default(session: SessionDep, trans: Trans):
db_model = session.exec(
select(AiModelDetail).where(AiModelDetail.default_model == True)
).first()
if not db_model:
raise Exception(trans('i18n_llm.miss_default'))
async def check_default(session: SessionDep, trans: Trans, current_user: CurrentUser, current_assistant: CurrentAssistant):
resolve_model_id(session, current_user.oid, trans, current_assistant)


@router.put("/default/{id}", summary=f"{PLACEHOLDER_PREFIX}system_model_default",
Expand Down Expand Up @@ -198,15 +196,31 @@ async def update_model(
async def delete_model(
session: SessionDep,
trans: Trans,
id: int = Path(description="ID")
current_user: CurrentUser,
id: int = Path(description="ID"),
resolutions: list[ModelImpactResolution] = Body(default=[]),
):
item = session.get(AiModelDetail, id)
item = session.exec(select(AiModelDetail).where(AiModelDetail.id == id).with_for_update()).first()
if item is None:
raise ValueError(trans('i18n_llm.model_unavailable'))
if item.default_model:
raise Exception(trans('i18n_llm.delete_default_error', key=item.name))
apply_model_impact_resolutions(session, id, None, resolutions, trans, actor=current_user)
session.exec(delete(AiModelWorkspaceMapping).where(AiModelWorkspaceMapping.ai_model_id == id))
session.delete(item)
session.commit()


@router.post("/{id}/workspace_impact", response_model=list[ModelImpact], summary=f"{PLACEHOLDER_PREFIX}system_model_workspace_impact")
@require_permissions(permission=SqlbotPermission(role=['admin']))
async def model_workspace_impact(session: SessionDep, trans: Trans, id: int, ws_ids: list[str] | None = Body(default=None)):
impacts = get_model_impacts(session, id, None if ws_ids is None else [int(value) for value in ws_ids])
for impact in impacts:
if impact.workspace_name.startswith('i18n'):
impact.workspace_name = trans(impact.workspace_name)
return impacts


@router.get("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping",
description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping")
@require_permissions(permission=SqlbotPermission(role=['admin']))
Expand All @@ -232,20 +246,28 @@ async def get_model_ws_mapping_by_id(
@router.put("/{id}/ws_mapping", response_model=List[str], summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update",
description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_update")
@require_permissions(permission=SqlbotPermission(role=['admin']))
@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="id", extract_params=False))
async def update_model_ws_mapping_by_id(
session: SessionDep,
trans: Trans,
current_user: CurrentUser,
id: int = Path(description="ID"),
ws_ids: List[str] = Body(description="workspace id list"),
ws_ids: List[str] | ModelMappingUpdate = Body(description="workspace id list and impact resolutions"),
):
resolutions = ws_ids.resolutions if isinstance(ws_ids, ModelMappingUpdate) else []
ws_ids = ws_ids.ws_ids if isinstance(ws_ids, ModelMappingUpdate) else ws_ids
if ws_ids is None:
ws_ids = []
# 提前去重
ws_ids = list({int(ws_id) for ws_id in ws_ids})

db_model = session.get(AiModelDetail, id)
db_model = session.exec(select(AiModelDetail).where(AiModelDetail.id == id).with_for_update()).first()
if not db_model:
raise ValueError(f"AiModelDetail with id {id} not found")

existing_ids = session.exec(select(AiModelWorkspaceMapping.workspace_id).where(AiModelWorkspaceMapping.ai_model_id == id)).all()
removed_ids = list(set(existing_ids) - set(ws_ids))
apply_model_impact_resolutions(session, id, removed_ids, resolutions, trans, actor=current_user)
# 根据 ai_model_id 更新关联的 workspace_id 列表
# 1. 批量删除旧映射
session.execute(
Expand Down Expand Up @@ -316,19 +338,25 @@ async def add_model_ws_mapping_by_id(
summary=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete",
description=f"{PLACEHOLDER_PREFIX}system_model_ws_mapping_delete")
@require_permissions(permission=SqlbotPermission(role=['admin']))
@system_log(LogConfig(operation_type=OperationType.UPDATE, module=OperationModules.AI_MODEL, resource_id_expr="id", extract_params=False))
async def delete_model_ws_mapping_by_id(
session: SessionDep,
trans: Trans,
current_user: CurrentUser,
id: int = Path(description="ID"),
ws_ids: List[str] = Body(description="workspace id list"),
ws_ids: List[str] | ModelMappingUpdate = Body(description="workspace id list and impact resolutions"),
):
resolutions = ws_ids.resolutions if isinstance(ws_ids, ModelMappingUpdate) else []
ws_ids = ws_ids.ws_ids if isinstance(ws_ids, ModelMappingUpdate) else ws_ids
if ws_ids is None:
ws_ids = []
ws_ids = list({int(ws_id) for ws_id in ws_ids})

db_model = session.get(AiModelDetail, id)
db_model = session.exec(select(AiModelDetail).where(AiModelDetail.id == id).with_for_update()).first()
if not db_model:
raise ValueError(f"AiModelDetail with id {id} not found")

apply_model_impact_resolutions(session, id, ws_ids, resolutions, trans, actor=current_user)
# 只删除指定的映射
if ws_ids:
session.execute(
Expand Down
7 changes: 6 additions & 1 deletion backend/apps/system/api/assistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from apps.swagger.i18n import PLACEHOLDER_PREFIX
from apps.system.crud.assistant import AssistantOutDs, AssistantOutDsFactory, get_assistant_info
from apps.system.crud.assistant_manage import dynamic_upgrade_cors, get_ws_assistant, save
from apps.system.crud.workspace_model import require_authorized_model
from apps.system.models.system_model import AssistantModel
from apps.system.schemas.auth import CacheName, CacheNamespace
from apps.system.schemas.permission import SqlbotPermission, require_permissions
Expand Down Expand Up @@ -336,8 +337,10 @@ async def query_advanced_application(session: SessionDep, current_user: CurrentU
@router.post("", summary=f"{PLACEHOLDER_PREFIX}assistant_create_api", description=f"{PLACEHOLDER_PREFIX}assistant_create_api")
@require_permissions(permission=SqlbotPermission(role=['ws_admin']))
@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.APPLICATION, result_id_expr="id"))
async def add(request: Request, session: SessionDep, current_user: CurrentUser, creator: AssistantBase):
async def add(request: Request, session: SessionDep, current_user: CurrentUser, trans: Trans, creator: AssistantBase):
oid = current_user.oid if creator.type != 4 else 1
if creator.enable_custom_model:
require_authorized_model(session, oid, creator.custom_model, trans)
return await save(request, session, creator, oid)


Expand All @@ -354,6 +357,8 @@ async def update(request: Request, session: SessionDep, current_user: CurrentUse
update_data.oid = db_model.oid
# create_time 不在请求体中,model_validate 会填默认值 0,更新时保留原值
update_data.create_time = db_model.create_time
if update_data.enable_custom_model:
require_authorized_model(session, update_data.oid, update_data.custom_model, trans)
db_model.sqlmodel_update(update_data)
session.add(db_model)
session.commit()
Expand Down
20 changes: 20 additions & 0 deletions backend/apps/system/api/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from apps.swagger.i18n import PLACEHOLDER_PREFIX
from apps.system.crud.user import clean_user_cache
from apps.system.crud.workspace import reset_single_user_oid, reset_user_oid
from apps.system.crud.workspace_model import get_workspace_model_settings, save_workspace_model_settings
from apps.system.schemas.workspace_model import WorkspaceModelSettings, WorkspaceModelSettingsOut
from apps.system.models.system_model import UserWsModel, WorkspaceBase, WorkspaceEditor, WorkspaceModel
from apps.system.models.user import UserModel
from apps.system.schemas.permission import SqlbotPermission, require_permissions
Expand Down Expand Up @@ -217,6 +219,24 @@ async def update(session: SessionDep, editor: WorkspaceEditor):
db_model.name = editor.name
session.add(db_model)

@router.get("/{id}/model", response_model=WorkspaceModelSettingsOut, summary=f"{PLACEHOLDER_PREFIX}ws_model_settings")
@require_permissions(permission=SqlbotPermission(role=['admin']))
async def model_settings(session: SessionDep, trans: Trans, id: int):
workspace = session.get(WorkspaceModel, id)
if workspace is None:
raise HTTPException(status_code=404, detail=trans('i18n_llm.workspace_unavailable'))
return get_workspace_model_settings(session, workspace)


@router.put("/{id}/model", summary=f"{PLACEHOLDER_PREFIX}ws_model_settings")
@require_permissions(permission=SqlbotPermission(role=['admin']))
async def update_model_settings(session: SessionDep, current_user: CurrentUser, trans: Trans, id: int, editor: WorkspaceModelSettings):
workspace = session.exec(select(WorkspaceModel).where(WorkspaceModel.id == id).with_for_update()).first()
if workspace is None:
raise HTTPException(status_code=404, detail=trans('i18n_llm.workspace_unavailable'))
return save_workspace_model_settings(session, workspace, editor, trans, actor=current_user)


@router.get("/{id}", response_model=WorkspaceModel, summary=f"{PLACEHOLDER_PREFIX}ws_query_api", description=f"{PLACEHOLDER_PREFIX}ws_query_api")
@require_permissions(permission=SqlbotPermission(role=['admin']))
async def get_one(session: SessionDep, trans: Trans, id: int = Path(description=f"{PLACEHOLDER_PREFIX}oid")):
Expand Down
7 changes: 4 additions & 3 deletions backend/apps/system/crud/aimodel_manage.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from sqlmodel import Session, select, or_

from apps.system.models.system_model import AiModelDetail, AiModelBrief, AiModelWorkspaceMapping
from common.core.db import engine
from common.utils.crypto import sqlbot_encrypt
from common.utils.utils import SQLBotLogUtil


async def async_model_info():
from common.core.db import engine
from common.utils.crypto import sqlbot_encrypt
from common.utils.utils import SQLBotLogUtil

with Session(engine) as session:
model_list = session.exec(select(AiModelDetail)).all()
any_model_change = False
Expand Down
Loading
Loading