diff --git a/agent/component/agent_with_tools.py b/agent/component/agent_with_tools.py index d59d8eb80..859064046 100644 --- a/agent/component/agent_with_tools.py +++ b/agent/component/agent_with_tools.py @@ -277,10 +277,13 @@ class Agent(LLM, ToolBase): return if delta.find("**ERROR**") >= 0: if self.get_exception_default_value(): - self.set_output("content", self.get_exception_default_value()) - yield self.get_exception_default_value() + fallback = self.get_exception_default_value() + self.set_output("content", fallback) + yield fallback else: self.set_output("_ERROR", delta) + self.set_output("content", delta) + yield delta return if not need2cite or cited: yield delta diff --git a/api/apps/llm_app.py b/api/apps/llm_app.py index 1b520ec29..eaf56628f 100644 --- a/api/apps/llm_app.py +++ b/api/apps/llm_app.py @@ -29,6 +29,23 @@ from rag.utils.base64_image import test_image from rag.llm import EmbeddingModel, ChatModel, RerankModel, CvModel, TTSModel, OcrModel, Seq2txtModel +def _resolve_my_llm_is_tools(o_dict: dict) -> bool: + decode_api_key_config = getattr(TenantLLMService, "_decode_api_key_config", None) + if callable(decode_api_key_config): + _, is_tools, _ = decode_api_key_config(o_dict.get("api_key", "")) + if is_tools is not None: + return bool(is_tools) + + try: + base_name, fid = TenantLLMService.split_model_name_and_factory(o_dict["llm_name"]) + llm_cfg = LLMService.query(llm_name=base_name, fid=fid) if fid else LLMService.query(llm_name=base_name) + if not llm_cfg and fid: + llm_cfg = LLMService.query(llm_name=base_name) + return bool(llm_cfg[0].is_tools) if llm_cfg else False + except Exception: + return False + + @manager.route("/factories", methods=["GET"]) # noqa: F821 @login_required def factories(): @@ -229,6 +246,19 @@ async def add_llm(): elif factory == "OpenDataLoader": api_key = apikey_json(["api_key", "provider_order"]) + existing_llm = None + existing_api_key = None + if req.get("api_key") is None: + existing_llms = TenantLLMService.query(tenant_id=current_user.id, llm_factory=factory, llm_name=llm_name) + if existing_llms: + existing_llm = existing_llms[0] + existing_api_key, _, existing_api_key_payload = TenantLLMService._decode_api_key_config(existing_llm.api_key) + if existing_api_key_payload is not None: + existing_api_key = existing_api_key_payload + + if req.get("api_key") is None: + api_key = existing_api_key if existing_api_key is not None else "x" + llm = { "tenant_id": current_user.id, "llm_factory": factory, @@ -353,6 +383,9 @@ async def add_llm(): if msg: return get_data_error_result(message=msg) + if "is_tools" in req: + llm["api_key"] = TenantLLMService._encode_api_key_config(llm["api_key"], bool(req["is_tools"])) + if not TenantLLMService.filter_update([TenantLLM.tenant_id == current_user.id, TenantLLM.llm_factory == factory, TenantLLM.llm_name == llm["llm_name"]], llm): TenantLLMService.save(**llm) @@ -421,6 +454,7 @@ def my_llms(): "api_base": o_dict["api_base"] or "", "max_tokens": o_dict["max_tokens"] or 8192, "status": o_dict["status"] or "1", + "is_tools": _resolve_my_llm_is_tools(o_dict), } ) else: diff --git a/api/db/joint_services/tenant_model_service.py b/api/db/joint_services/tenant_model_service.py index 8e745d8e0..9f9487286 100644 --- a/api/db/joint_services/tenant_model_service.py +++ b/api/db/joint_services/tenant_model_service.py @@ -26,8 +26,14 @@ def get_model_config_by_id(tenant_model_id: int) -> dict: if not found: raise LookupError(f"Tenant Model with id {tenant_model_id} not found") config_dict = model_config.to_dict() + api_key, is_tools, api_key_payload = TenantLLMService._decode_api_key_config(config_dict.get("api_key", "")) + config_dict["api_key"] = api_key + if api_key_payload is not None: + config_dict["api_key_payload"] = api_key_payload + if is_tools is not None: + config_dict["is_tools"] = is_tools llm = LLMService.query(llm_name=config_dict["llm_name"]) - if llm: + if "is_tools" not in config_dict and llm: config_dict["is_tools"] = llm[0].is_tools return config_dict @@ -73,6 +79,12 @@ def get_model_config_by_type_and_name(tenant_id: str, model_type: str, model_nam else: # model_name without @factory config_dict = model_config.to_dict() + api_key, is_tools, api_key_payload = TenantLLMService._decode_api_key_config(config_dict.get("api_key", "")) + config_dict["api_key"] = api_key + if api_key_payload is not None: + config_dict["api_key_payload"] = api_key_payload + if is_tools is not None: + config_dict["is_tools"] = is_tools config_model_type = config_dict.get("model_type") config_model_type = config_model_type.value if hasattr(config_model_type, "value") else config_model_type if config_model_type != model_type_val and not ( @@ -83,7 +95,7 @@ def get_model_config_by_type_and_name(tenant_id: str, model_type: str, model_nam f"Tenant Model with name {model_name} has type {config_model_type}, expected {model_type_val}" ) llm = LLMService.query(llm_name=config_dict["llm_name"]) - if llm: + if "is_tools" not in config_dict and llm: config_dict["is_tools"] = llm[0].is_tools return config_dict diff --git a/api/db/services/tenant_llm_service.py b/api/db/services/tenant_llm_service.py index fe99aee49..5bf0c17d5 100644 --- a/api/db/services/tenant_llm_service.py +++ b/api/db/services/tenant_llm_service.py @@ -34,6 +34,42 @@ class LLMFactoriesService(CommonService): class TenantLLMService(CommonService): model = TenantLLM + @staticmethod + def _decode_api_key_config(raw_api_key: str) -> tuple[str, bool | None, str | None]: + if not raw_api_key: + return raw_api_key, None, None + + try: + parsed = json.loads(raw_api_key) + except Exception: + return raw_api_key, None, None + + if not isinstance(parsed, dict): + return raw_api_key, None, None + + is_tools = bool(parsed["is_tools"]) if "is_tools" in parsed else None + if set(parsed.keys()) <= {"api_key", "is_tools"}: + return parsed.get("api_key", ""), is_tools, None + + return parsed.get("api_key", raw_api_key), is_tools, raw_api_key + + @staticmethod + def _encode_api_key_config(raw_api_key: str, is_tools: bool | None) -> str: + if is_tools is None: + return raw_api_key + + try: + parsed = json.loads(raw_api_key or "{}") + except Exception: + parsed = None + + if isinstance(parsed, dict): + payload = dict(parsed) + payload["is_tools"] = bool(is_tools) + return json.dumps(payload) + + return json.dumps({"api_key": raw_api_key or "", "is_tools": bool(is_tools)}) + @classmethod @DB.connection_context() def get_api_key(cls, tenant_id, model_name, model_type=None): @@ -123,6 +159,12 @@ class TenantLLMService(CommonService): model_config = cls.get_api_key(tenant_id, mdlnm, llm_type) if model_config: model_config = model_config.to_dict() + api_key, is_tools, api_key_payload = cls._decode_api_key_config(model_config.get("api_key", "")) + model_config["api_key"] = api_key + if api_key_payload is not None: + model_config["api_key_payload"] = api_key_payload + if is_tools is not None: + model_config["is_tools"] = is_tools elif llm_type == LLMType.EMBEDDING and fid == "Builtin" and "tei-" in os.getenv("COMPOSE_PROFILES", "") and mdlnm == os.getenv("TEI_MODEL", ""): embedding_cfg = settings.EMBEDDING_CFG model_config = {"llm_factory": "Builtin", "api_key": embedding_cfg["api_key"], "llm_name": mdlnm, "api_base": embedding_cfg["base_url"]} @@ -132,7 +174,7 @@ class TenantLLMService(CommonService): llm = LLMService.query(llm_name=mdlnm) if not fid else LLMService.query(llm_name=mdlnm, fid=fid) if not llm and fid: # for some cases seems fid mismatch llm = LLMService.query(llm_name=mdlnm) - if llm: + if "is_tools" not in model_config and llm: model_config["is_tools"] = llm[0].is_tools return model_config @@ -142,35 +184,36 @@ class TenantLLMService(CommonService): if not model_config: raise LookupError("Model config is required") kwargs.update({"provider": model_config["llm_factory"]}) + api_key = model_config.get("api_key_payload", model_config["api_key"]) if model_config["model_type"] == LLMType.EMBEDDING.value: if model_config["llm_factory"] not in EmbeddingModel: return None - return EmbeddingModel[model_config["llm_factory"]](model_config["api_key"], model_config["llm_name"], base_url=model_config["api_base"]) + return EmbeddingModel[model_config["llm_factory"]](api_key, model_config["llm_name"], base_url=model_config["api_base"]) elif model_config["model_type"] == LLMType.RERANK: if model_config["llm_factory"] not in RerankModel: return None - return RerankModel[model_config["llm_factory"]](model_config["api_key"], model_config["llm_name"], base_url=model_config["api_base"]) + return RerankModel[model_config["llm_factory"]](api_key, model_config["llm_name"], base_url=model_config["api_base"]) elif model_config["model_type"] == LLMType.IMAGE2TEXT.value: if model_config["llm_factory"] not in CvModel: return None - return CvModel[model_config["llm_factory"]](model_config["api_key"], model_config["llm_name"], lang, base_url=model_config["api_base"], **kwargs) + return CvModel[model_config["llm_factory"]](api_key, model_config["llm_name"], lang, base_url=model_config["api_base"], **kwargs) elif model_config["model_type"] == LLMType.CHAT.value: if model_config["llm_factory"] not in ChatModel: return None - return ChatModel[model_config["llm_factory"]](model_config["api_key"], model_config["llm_name"], base_url=model_config["api_base"], **kwargs) + return ChatModel[model_config["llm_factory"]](api_key, model_config["llm_name"], base_url=model_config["api_base"], **kwargs) elif model_config["model_type"] == LLMType.SPEECH2TEXT: if model_config["llm_factory"] not in Seq2txtModel: return None - return Seq2txtModel[model_config["llm_factory"]](key=model_config["api_key"], model_name=model_config["llm_name"], lang=lang, base_url=model_config["api_base"]) + return Seq2txtModel[model_config["llm_factory"]](key=api_key, model_name=model_config["llm_name"], lang=lang, base_url=model_config["api_base"]) elif model_config["model_type"] == LLMType.TTS: if model_config["llm_factory"] not in TTSModel: return None return TTSModel[model_config["llm_factory"]]( - model_config["api_key"], + api_key, model_config["llm_name"], base_url=model_config["api_base"], ) @@ -179,7 +222,7 @@ class TenantLLMService(CommonService): if model_config["llm_factory"] not in OcrModel: return None return OcrModel[model_config["llm_factory"]]( - key=model_config["api_key"], + key=api_key, model_name=model_config["llm_name"], base_url=model_config.get("api_base", ""), **kwargs, diff --git a/web/src/interfaces/request/llm.ts b/web/src/interfaces/request/llm.ts index 687d13aca..f8690784e 100644 --- a/web/src/interfaces/request/llm.ts +++ b/web/src/interfaces/request/llm.ts @@ -5,6 +5,7 @@ export interface IAddLlmRequestBody { api_base?: string; // chat|embedding|speech2text|image2text api_key?: string | Record; max_tokens: number; + is_tools?: boolean; } export interface IDeleteLlmRequestBody { diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index ee4da4d14..9078dc749 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1126,6 +1126,9 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s Verify: 'Verify', keyValid: 'Your API key is valid.', keyInvalid: 'Your API key is invalid.', + enableToolCall: 'Enable tool call', + enableToolCallTip: + 'Allow this model to call tools when the selected model type supports tool calling.', deleteModel: 'Delete model', bedrockCredentialsHint: 'Tip: Leave Access Key / Secret Key blank to use AWS IAM authentication.', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 8bbcc6a7e..97ebb5d7c 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1036,6 +1036,8 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 Verify: '验证', keyValid: '你的 API 密钥有效。', keyInvalid: '你的 API 密钥无效。', + enableToolCall: '启用工具调用', + enableToolCallTip: '当所选模型类型支持工具调用时,允许该模型调用工具。', deleteModel: '删除模型', modelEmptyTip: '暂无可用模型,
请先在右侧面板添加模型。', sourceEmptyTip: '暂未添加任何数据源,请从下方选择一个进行连接。', diff --git a/web/src/pages/user-setting/setting-model/hooks.tsx b/web/src/pages/user-setting/setting-model/hooks.tsx index 47cfaa37c..1ddf3ac77 100644 --- a/web/src/pages/user-setting/setting-model/hooks.tsx +++ b/web/src/pages/user-setting/setting-model/hooks.tsx @@ -228,6 +228,7 @@ export const useSubmitOllama = () => { api_base: detailedData.api_base || '', max_tokens: detailedData.max_tokens || 8192, api_key: '', + is_tools: detailedData.is_tools || false, }; setInitialValues(initialVals); } else { diff --git a/web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx b/web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx index a1c00e5aa..1b59ecb42 100644 --- a/web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx +++ b/web/src/pages/user-setting/setting-model/modal/ollama-modal/index.tsx @@ -115,6 +115,7 @@ const OllamaModal = ({ const getOptions = (factory: string) => { return optionsMap[factory as LLMFactory] || optionsMap.Default; }; + const defaultToolCallEnabled = initialValues?.is_tools ?? false; const baseFields: FormFieldConfig[] = [ { @@ -177,6 +178,20 @@ const OllamaModal = ({ }, ]; + baseFields.push({ + name: 'is_tools', + label: t('enableToolCall'), + type: FormFieldType.Switch, + required: false, + dependencies: ['model_type'], + shouldRender: (formValues: any) => { + const modelType = formValues?.model_type; + return modelType === 'chat' || modelType === 'image2text'; + }, + tooltip: t('enableToolCallTip'), + defaultValue: defaultToolCallEnabled, + }); + // Add provider_order field only for OpenRouter if (llmFactory === 'OpenRouter') { baseFields.push({ @@ -214,14 +229,18 @@ const OllamaModal = ({ api_key: '', vision: initialValues.model_type === 'image2text', provider_order: initialValues.provider_order || '', + is_tools: initialValues.is_tools || false, }; } return { model_type: - llmFactory in optionsMap - ? optionsMap[llmFactory as LLMFactory]?.at(0)?.value - : 'embedding', + llmFactory === LLMFactory.Ollama || llmFactory === LLMFactory.VLLM + ? 'chat' + : llmFactory in optionsMap + ? optionsMap[llmFactory as LLMFactory]?.at(0)?.value + : 'embedding', vision: false, + is_tools: false, }; }, [editMode, initialValues, llmFactory]); @@ -232,6 +251,7 @@ const OllamaModal = ({ values.model_type === 'chat' && values.vision ? 'image2text' : values.model_type; + const supportsToolCall = modelType === 'chat' || modelType === 'image2text'; const data: IAddLlmRequestBody & { provider_order?: string } = { llm_factory: llmFactory, @@ -241,6 +261,9 @@ const OllamaModal = ({ api_key: values.api_key as string, max_tokens: values.max_tokens as number, }; + if (supportsToolCall) { + data.is_tools = Boolean(values.is_tools); + } // Add provider_order only if it exists (for OpenRouter) if (values.provider_order) {