diff --git a/Dockerfile b/Dockerfile index c8727b40..728473e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,13 @@ COPY ./backend ${APP_HOME} RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --extra cpu +# Install Amazon Bedrock SDK separately with --no-deps to avoid the langchain-aws +# transitive numpy<2 constraint clashing with the pinned numpy==2.3.5. +# ChatBedrockConverse only needs boto3 + langchain-core (already present) at runtime. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv pip install "boto3>=1.34.0" && \ + uv pip install "langchain-aws>=0.2.0,<0.3.0" --no-deps + # Build g2-ssr FROM registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-base:latest AS ssr-builder diff --git a/backend/apps/ai_model/model_factory.py b/backend/apps/ai_model/model_factory.py index 887005ce..97cdaa17 100644 --- a/backend/apps/ai_model/model_factory.py +++ b/backend/apps/ai_model/model_factory.py @@ -1,5 +1,6 @@ from functools import lru_cache import json +import re from abc import ABC, abstractmethod from typing import Optional, Dict, Any, Type @@ -18,6 +19,36 @@ # from langchain_community.llms import Tongyi, VLLM +# protocol(int) stored on the model record -> internal model_type used by the factory +PROTOCOL_OPENAI = 1 +PROTOCOL_VLLM = 2 +PROTOCOL_BEDROCK = 3 + +_PROTOCOL_MODEL_TYPE: Dict[int, str] = { + PROTOCOL_OPENAI: "openai", + PROTOCOL_VLLM: "vllm", + PROTOCOL_BEDROCK: "bedrock", +} + + +def get_model_type_by_protocol(protocol: Optional[int]) -> str: + """Map the stored protocol value to the factory model_type. + + Falls back to the legacy behaviour (1 -> openai, everything else -> vllm) + for any unknown value so existing records keep working. + """ + if protocol == PROTOCOL_OPENAI: + return "openai" + return _PROTOCOL_MODEL_TYPE.get(protocol, "vllm") + + +def _region_from_bedrock_url(url: Optional[str]) -> Optional[str]: + """Extract the AWS region from a bedrock-runtime / bedrock-mantle endpoint url.""" + if not url: + return None + match = re.search(r"bedrock(?:-runtime|-mantle)?\.([a-z0-9-]+)\.(?:amazonaws\.com|api\.aws)", url) + return match.group(1) if match else None + class LLMConfig(BaseModel): """Base configuration class for large language models""" model_id: Optional[int] = None @@ -109,6 +140,71 @@ def generate(self, prompt: str) -> str: return self.llm.invoke(prompt) +class BedrockLLM(BaseLLM): + """Amazon Bedrock support for both available endpoint families: + + - ``bedrock-runtime`` (default): native Bedrock inference via the Converse + API. Authenticates with SigV4 using the standard AWS credential chain + (IAM role / profile / env vars) or explicit keys passed as advanced args. + - ``bedrock-mantle``: OpenAI-compatible endpoint (Chat Completions). Reuses + the OpenAI client with the mantle base url and a Bedrock API key. + + Control args are read from ``additional_params``: + - ``endpoint_type``: ``runtime`` (default) or ``mantle`` + - ``region_name``: e.g. ``us-east-1`` (inferred from the url when omitted) + - ``aws_access_key_id`` / ``aws_secret_access_key`` / ``aws_session_token`` + - ``provider``: optional explicit Bedrock provider for the model + Any remaining params (``temperature``, ``max_tokens`` ...) are forwarded to + the underlying model. + """ + + def _init_llm(self) -> BaseChatModel: + params = dict(self.config.additional_params or {}) + endpoint_type = str(params.pop("endpoint_type", "") or "runtime").lower() + region_name = params.pop("region_name", None) or _region_from_bedrock_url(self.config.api_base_url) + + if endpoint_type == "mantle": + # OpenAI-compatible endpoint, served by the bedrock-mantle endpoint. + base_url = self.config.api_base_url + if not base_url and region_name: + base_url = f"https://bedrock-mantle.{region_name}.api.aws/v1" + return BaseChatOpenAI( + model=self.config.model_name, + api_key=self.config.api_key or 'Empty', + base_url=base_url, + stream_usage=True, + **params, + ) + + # default: native bedrock-runtime endpoint via the Converse API + from langchain_aws import ChatBedrockConverse + + aws_access_key_id = params.pop("aws_access_key_id", None) + aws_secret_access_key = params.pop("aws_secret_access_key", None) + aws_session_token = params.pop("aws_session_token", None) + provider = params.pop("provider", None) + + kwargs: Dict[str, Any] = {} + if region_name: + kwargs["region_name"] = region_name + # api_domain may hold a custom/private bedrock-runtime endpoint url + if self.config.api_base_url and "bedrock-mantle" not in self.config.api_base_url: + kwargs["endpoint_url"] = self.config.api_base_url + if aws_access_key_id and aws_secret_access_key: + kwargs["aws_access_key_id"] = aws_access_key_id + kwargs["aws_secret_access_key"] = aws_secret_access_key + if aws_session_token: + kwargs["aws_session_token"] = aws_session_token + if provider: + kwargs["provider"] = provider + + return ChatBedrockConverse( + model=self.config.model_name, + **kwargs, + **params, + ) + + class LLMFactory: """Large Language Model Factory Class""" @@ -117,6 +213,7 @@ class LLMFactory: "tongyi": OpenAILLM, "vllm": OpenAIvLLM, "azure": OpenAIAzureLLM, + "bedrock": BedrockLLM, } @classmethod @@ -173,7 +270,7 @@ async def get_default_config(custom_model_id: Optional[int] = None) -> LLMConfig # 构造 LLMConfig return LLMConfig( model_id=db_model.id, - model_type="openai" if db_model.protocol == 1 else "vllm", + model_type=get_model_type_by_protocol(db_model.protocol), model_name=db_model.base_model, api_key=db_model.api_key, api_base_url=db_model.api_domain, diff --git a/backend/apps/system/api/aimodel.py b/backend/apps/system/api/aimodel.py index 620adac7..312b614e 100644 --- a/backend/apps/system/api/aimodel.py +++ b/backend/apps/system/api/aimodel.py @@ -5,11 +5,12 @@ from fastapi.responses import StreamingResponse from sqlmodel import func, select, update, delete -from apps.ai_model.model_factory import LLMConfig, LLMFactory +from apps.ai_model.model_factory import LLMConfig, LLMFactory, get_model_type_by_protocol from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.crud.aimodel_manage import get_ai_model_list_by_workspace 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.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem, \ + BedrockModelListReq from apps.system.schemas.permission import SqlbotPermission, require_permissions from common.core.deps import SessionDep, Trans, CurrentUser from common.utils.crypto import sqlbot_decrypt @@ -29,7 +30,7 @@ async def generate(): additional_params = {item.key: prepare_model_arg(item.val) for item in info.config_list if item.key and item.val} config = LLMConfig( - model_type="openai" if info.protocol == 1 else "vllm", + model_type=get_model_type_by_protocol(info.protocol), model_name=info.base_model, api_key=info.api_key, api_base_url=info.api_domain, @@ -51,6 +52,95 @@ async def generate(): return StreamingResponse(generate(), media_type="application/x-ndjson") +# Cross-region "geo" inference profile prefixes (route across multiple countries) +_BEDROCK_GEO_PREFIXES = {"us", "eu", "apac"} + + +@router.post("/bedrock/models", include_in_schema=False) +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def list_bedrock_models(info: BedrockModelListReq, trans: Trans): + """List invocable Amazon Bedrock models for the given region, grouped by scope. + + Groups: In-Region (single country/region profiles), Cross-Region (Geo) + (us/eu/apac multi-country profiles), Global, and direct on-demand + Foundation Models. Uses the standard AWS credential chain (instance role) + unless explicit keys are provided. + """ + try: + import boto3 + except ImportError: + raise Exception("boto3 is not installed; Amazon Bedrock support is unavailable") + + client_kwargs = {"region_name": info.region_name} + if info.aws_access_key_id and info.aws_secret_access_key: + client_kwargs["aws_access_key_id"] = info.aws_access_key_id + client_kwargs["aws_secret_access_key"] = info.aws_secret_access_key + if info.aws_session_token: + client_kwargs["aws_session_token"] = info.aws_session_token + + try: + client = boto3.client("bedrock", **client_kwargs) + + groups: dict[str, list] = { + "In-Region": [], + "Cross-Region (Geo)": [], + "Global": [], + "Foundation Models (On-demand)": [], + } + + # 1) system-defined inference profiles (the canonical invocable ids) + next_token = None + while True: + kwargs = {"typeEquals": "SYSTEM_DEFINED", "maxResults": 100} + if next_token: + kwargs["nextToken"] = next_token + resp = client.list_inference_profiles(**kwargs) + for p in resp.get("inferenceProfileSummaries", []): + if p.get("status") != "ACTIVE": + continue + pid = p.get("inferenceProfileId") + if not pid: + continue + prefix = pid.split(".")[0] + if prefix == "global": + grp = "Global" + elif prefix in _BEDROCK_GEO_PREFIXES: + grp = "Cross-Region (Geo)" + else: + grp = "In-Region" + name = p.get("inferenceProfileName") or pid + groups[grp].append({"label": f"{name} ({pid})", "value": pid}) + next_token = resp.get("nextToken") + if not next_token: + break + + # 2) directly-invokable on-demand text foundation models + try: + fm = client.list_foundation_models(byOutputModality="TEXT", byInferenceType="ON_DEMAND") + for m in fm.get("modelSummaries", []): + if m.get("modelLifecycle", {}).get("status") != "ACTIVE": + continue + mid = m.get("modelId") + if not mid: + continue + name = m.get("modelName") or mid + groups["Foundation Models (On-demand)"].append({"label": f"{name} ({mid})", "value": mid}) + except Exception as e: + SQLBotLogUtil.warning(f"list_foundation_models failed: {e}") + + except Exception as e: + SQLBotLogUtil.error(f"Error listing Bedrock models: {e}") + raise Exception(str(e)) + + order = ["In-Region", "Cross-Region (Geo)", "Global", "Foundation Models (On-demand)"] + result = [] + for g in order: + opts = sorted(groups[g], key=lambda x: x["value"]) + if opts: + result.append({"label": g, "options": opts}) + return result + + @router.get("/default", include_in_schema=False) async def check_default(session: SessionDep, trans: Trans): db_model = session.exec( diff --git a/backend/apps/system/schemas/ai_model_schema.py b/backend/apps/system/schemas/ai_model_schema.py index 7f523eb9..6437e15a 100644 --- a/backend/apps/system/schemas/ai_model_schema.py +++ b/backend/apps/system/schemas/ai_model_schema.py @@ -27,4 +27,11 @@ class AiModelCreator(AiModelItem): config_list: List[AiModelConfigItem] = Field(description=f"{PLACEHOLDER_PREFIX}config_list") class AiModelEditor(AiModelCreator, BaseCreatorDTO): - pass \ No newline at end of file + pass + +class BedrockModelListReq(BaseModel): + region_name: str = Field(default="us-east-1", description="AWS region, e.g. ap-northeast-1") + endpoint_type: str = Field(default="runtime", description="runtime or mantle") + aws_access_key_id: str | None = Field(default=None, description="optional explicit AK") + aws_secret_access_key: str | None = Field(default=None, description="optional explicit SK") + aws_session_token: str | None = Field(default=None, description="optional session token") \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 414b1a70..449900a5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -78,7 +78,7 @@ explicit = true [[tool.uv.index]] name = "default" -url = "http://mirrors.aliyun.com/pypi/simple" +url = "https://pypi.org/simple" default = true [[tool.uv.index]] diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 4d56fecb..8d8dcb06 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -31,4 +31,5 @@ export const modelApi = { request.post(`/system/platform/org/${id}`, { lazy, pid }), userSync: (data: any) => request.post(`/system/platform/user/sync`, data), list_by_ws: () => request.get(`/system/aimodel/list/by_ws`), + bedrockModels: (data: any) => request.post('/system/aimodel/bedrock/models', data), } diff --git a/frontend/src/entity/supplier.ts b/frontend/src/entity/supplier.ts index ebefea00..8c7f0bc3 100644 --- a/frontend/src/entity/supplier.ts +++ b/frontend/src/entity/supplier.ts @@ -370,6 +370,45 @@ export const supplierList: Array<{ }, }, }, + { + id: 16, + name: 'Amazon Bedrock', + i18nKey: 'supplier.aws_bedrock', + icon: icon_common_openai, + type: 'bedrock', + is_private: true, + model_config: { + 0: { + api_domain: 'https://bedrock-runtime.us-east-1.amazonaws.com', + common_args: [ + { key: 'endpoint_type', val: 'runtime', type: 'string' }, + { key: 'region_name', val: 'us-east-1', type: 'string' }, + { key: 'temperature', val: 0.6, type: 'number', range: '[0, 1]' }, + ], + // model_options are fetched dynamically via the Bedrock list API + model_options: [], + }, + }, + }, + { + id: 17, + name: 'Amazon Bedrock (OpenAI 兼容)', + i18nKey: 'supplier.aws_bedrock_mantle', + icon: icon_common_openai, + type: 'bedrock', + model_config: { + 0: { + api_domain: 'https://bedrock-mantle.us-east-1.api.aws/v1', + common_args: [ + { key: 'endpoint_type', val: 'mantle', type: 'string' }, + { key: 'region_name', val: 'us-east-1', type: 'string' }, + { key: 'temperature', val: 0.6, type: 'number', range: '[0, 1]' }, + ], + // model_options are fetched dynamically via the Bedrock list API + model_options: [], + }, + }, + }, ] export const base_model_options = (supplier_id: number, model_type?: number) => { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index f87d3a58..5a60b923 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -484,6 +484,9 @@ "model_name": "Model name", "custom_model_name": "Custom model name", "enter_to_add": "For models not listed in the list, just enter the model name and press Enter to add", + "refresh_models": "Refresh model list", + "bedrock_no_models": "No invocable Bedrock models found for this region. Check region and model access.", + "bedrock_list_failed": "Failed to list Bedrock models. Falling back to manual input.", "api_domain_name": "API domain name", "advanced_settings": "Advanced settings", "model_parameters": "Model parameters", @@ -947,7 +950,9 @@ "tencent_cloud": "Tencent Cloud", "volcano_engine": "Volcano Engine", "minimax": "MiniMax", - "generic_openai": "Generic OpenAI" + "generic_openai": "Generic OpenAI", + "aws_bedrock": "Amazon Bedrock", + "aws_bedrock_mantle": "Amazon Bedrock (OpenAI-compatible)" }, "modelType": { "llm": "Large Language Model" diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index e5027582..7143de38 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -484,6 +484,9 @@ "model_name": "모델 이름", "custom_model_name": "사용자 정의 모델 이름", "enter_to_add": "목록에 없는 모델은 모델 이름을 직접 입력한 후 Enter 키를 눌러 추가할 수 있습니다", + "refresh_models": "모델 목록 새로 고침", + "bedrock_no_models": "이 리전에서 호출 가능한 Bedrock 모델을 찾을 수 없습니다. 리전과 모델 액세스 권한을 확인하세요.", + "bedrock_list_failed": "Bedrock 모델 목록을 가져오지 못했습니다. 모델 ID를 직접 입력하세요.", "api_domain_name": "API 도메인명", "advanced_settings": "고급 설정", "model_parameters": "모델 매개변수", @@ -947,7 +950,9 @@ "tencent_cloud": "텐센트 클라우드", "volcano_engine": "볼케이노 엔진", "minimax": "MiniMax", - "generic_openai": "범용 OpenAI" + "generic_openai": "범용 OpenAI", + "aws_bedrock": "Amazon Bedrock", + "aws_bedrock_mantle": "Amazon Bedrock (OpenAI 호환)" }, "modelType": { "llm": "대형 언어 모델" diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index 12c2f05f..407ce598 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -484,6 +484,9 @@ "model_name": "模型名称", "custom_model_name": "自定义的模型名称", "enter_to_add": "列表中未列出的模型,直接输入模型名称,回车即可添加", + "refresh_models": "刷新模型列表", + "bedrock_no_models": "该区域未找到可调用的 Bedrock 模型,请检查区域和模型访问权限。", + "bedrock_list_failed": "获取 Bedrock 模型列表失败,可手动输入模型 ID。", "api_domain_name": "API 域名", "advanced_settings": "高级设置", "model_parameters": "模型参数", @@ -947,7 +950,9 @@ "tencent_cloud": "腾讯云", "volcano_engine": "火山引擎", "minimax": "MiniMax", - "generic_openai": "通用OpenAI" + "generic_openai": "通用OpenAI", + "aws_bedrock": "Amazon Bedrock", + "aws_bedrock_mantle": "Amazon Bedrock (OpenAI 兼容)" }, "modelType": { "llm": "大语言模型" diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json index 2eda931a..1a8d7c6c 100644 --- a/frontend/src/i18n/zh-TW.json +++ b/frontend/src/i18n/zh-TW.json @@ -484,6 +484,9 @@ "model_name": "模型名稱", "custom_model_name": "自訂的模型名稱", "enter_to_add": "清單中未列出的模型,直接輸入模型名稱,Enter 即可新增", + "refresh_models": "重新整理模型清單", + "bedrock_no_models": "此區域未找到可呼叫的 Bedrock 模型,請檢查區域與模型存取權限。", + "bedrock_list_failed": "取得 Bedrock 模型清單失敗,可手動輸入模型 ID。", "api_domain_name": "API 網域", "advanced_settings": "進階設定", "model_parameters": "模型參數", @@ -947,7 +950,9 @@ "tencent_cloud": "騰訊雲", "volcano_engine": "火山引擎", "minimax": "MiniMax", - "generic_openai": "通用OpenAI" + "generic_openai": "通用OpenAI", + "aws_bedrock": "Amazon Bedrock", + "aws_bedrock_mantle": "Amazon Bedrock (OpenAI 相容)" }, "modelType": { "llm": "大語言模型" diff --git a/frontend/src/views/system/model/ModelForm.vue b/frontend/src/views/system/model/ModelForm.vue index 0abb792a..f8569f46 100644 --- a/frontend/src/views/system/model/ModelForm.vue +++ b/frontend/src/views/system/model/ModelForm.vue @@ -8,6 +8,7 @@ import icon_add_outlined from '@/assets/svg/icon_add_outlined.svg' import ParamsForm from './ParamsForm.vue' import { modelTypeOptions } from '@/entity/CommonEntity.ts' import { base_model_options, get_supplier } from '@/entity/supplier' +import { modelApi } from '@/api/system' import { useI18n } from 'vue-i18n' withDefaults( @@ -63,6 +64,46 @@ const modelList = computed(() => { } return base_model_options(modelForm.supplier, modelForm.model_type) }) + +// ---- Amazon Bedrock dynamic model listing ---- +const isBedrock = computed(() => currentSupplier.value?.type === 'bedrock') +const bedrockGroups = ref([] as Array<{ label: string; options: Array<{ label: string; value: string }> }>) +const bedrockLoading = ref(false) + +const getBedrockRegion = () => { + const fromArg = advancedSetting.value.find((a: any) => a.key === 'region_name')?.val + if (fromArg) return fromArg + const m = (modelForm.api_domain || '').match( + /bedrock(?:-runtime|-mantle)?\.([a-z0-9-]+)\.(?:amazonaws\.com|api\.aws)/ + ) + return m ? m[1] : 'us-east-1' +} + +const getBedrockEndpointType = () => { + const fromArg = advancedSetting.value.find((a: any) => a.key === 'endpoint_type')?.val + if (fromArg) return fromArg + return (modelForm.api_domain || '').includes('mantle') ? 'mantle' : 'runtime' +} + +const fetchBedrockModels = async () => { + if (!isBedrock.value) return + bedrockLoading.value = true + try { + const data: any = await modelApi.bedrockModels({ + region_name: getBedrockRegion(), + endpoint_type: getBedrockEndpointType(), + }) + bedrockGroups.value = Array.isArray(data) ? data : [] + if (!bedrockGroups.value.length) { + ElMessage.warning(t('model.bedrock_no_models')) + } + } catch (e: any) { + bedrockGroups.value = [] + ElMessage.error(e?.message || t('model.bedrock_list_failed')) + } finally { + bedrockLoading.value = false + } +} const handleParamsEdite = (ele?: any) => { isCreate.value = false paramsFormDrawer.value = true @@ -177,8 +218,18 @@ const supplierChang = (supplier: any) => { const config = supplier.model_config[modelForm.model_type || 0] modelForm.api_domain = config.api_domain modelForm.base_model = '' - modelForm.protocol = supplier.type === 'vllm' ? 2 : 1 + if (supplier.type === 'vllm') { + modelForm.protocol = 2 + } else if (supplier.type === 'bedrock') { + modelForm.protocol = 3 + } else { + modelForm.protocol = 1 + } advancedSetting.value = [] + bedrockGroups.value = [] + if (supplier.type === 'bedrock') { + fetchBedrockModels() + } } let curId = +new Date() const initForm = (item?: any) => { @@ -200,6 +251,9 @@ const initForm = (item?: any) => { } tempConfigMap.set(`${modelForm.supplier}-${modelForm.base_model}`, [...advancedSetting.value]) } + if (isBedrock.value) { + fetchBedrockModels() + } } const formatAdvancedSetting = (list: Array) => { const setting_list = [ @@ -317,7 +371,17 @@ defineExpose({ - + +