diff --git a/README.md b/README.md index 813bf481..71a1218a 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex 项目支持国内外主流厂商的模型接口,可选模型及配置说明参考:[模型说明](#模型说明)。 -> 注:Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:deepseek-v4-flash、MiniMax-M2.7、glm-5.1、kimi-k2.6、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini +> 注:Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:deepseek-v4-flash、MiniMax-M2.7、glm-5.1、kimi-k2.6、qwen3.5-plus、claude-sonnet-4-6、gemini-3.1-pro-preview、gpt-5.4、gpt-5.4-mini、ernie-4.5-turbo-128k 同时支持使用 **LinkAI 平台** 接口,支持上述全部模型,并支持知识库、工作流、插件等 Agent 技能,参考 [接口文档](https://docs.link-ai.tech/platform/api)。 @@ -597,33 +597,37 @@ API Key 创建:在 [控制台](https://aistudio.google.com/app/apikey?hl=zh-cn
-百度文心 -方式一:官方 SDK 接入,配置如下: +百度千帆 / ERNIE + +方式一:官方接入(推荐),配置如下: ```json { - "model": "wenxin-4", - "baidu_wenxin_api_key": "IajztZ0bDxgnP9bEykU7lBer", - "baidu_wenxin_secret_key": "EDPZn6L24uAS9d8RWFfotK47dPvkjD6G" + "model": "ernie-4.5-turbo-128k", + "qianfan_api_key": "", + "qianfan_api_base": "https://qianfan.baidubce.com/v2" } ``` - - `model`: 可填 `wenxin`和`wenxin-4`,对应模型为 文心-3.5 和 文心-4.0 - - `baidu_wenxin_api_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 API Key - - `baidu_wenxin_secret_key`:参考 [千帆平台-access_token鉴权](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/dlv4pct3s) 文档获取 Secret Key + + - `model`: 推荐填写 `ernie-4.5-turbo-128k`,也可填写 `ernie-4.5-turbo-32k`、`ernie-x1-turbo-32k` + - `qianfan_api_key`: 百度千帆 API Key,通常以 `bce-v3/` 开头,可在百度智能云控制台创建 + - `qianfan_api_base`: 可选,默认为 `https://qianfan.baidubce.com/v2` + +兼容说明:旧配置 `model: "wenxin"`、`model: "wenxin-4"`、`baidu_wenxin_api_key`、`baidu_wenxin_secret_key` 仍可继续使用。 方式二:OpenAI 兼容方式接入,配置如下: ```json { "bot_type": "openai", - "model": "ERNIE-4.0-Turbo-8K", + "model": "ernie-4.5-turbo-128k", "open_ai_api_base": "https://qianfan.baidubce.com/v2", - "open_ai_api_key": "bce-v3/ALTxxxxxxd2b" + "open_ai_api_key": "" } ``` - `bot_type`: OpenAI 兼容方式 -- `model`: 支持官方所有模型,参考[模型列表](https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Wm9cvy6rl) -- `open_ai_api_base`: 百度文心 API 的 BASE URL -- `open_ai_api_key`: 百度文心的 API-KEY,参考 [官方文档](https://cloud.baidu.com/doc/qianfan-api/s/ym9chdsy5) ,在 [控制台](https://console.bce.baidu.com/iam/#/iam/apikey/list) 创建 API Key +- `model`: 支持千帆平台上的 ERNIE 模型 +- `open_ai_api_base`: 百度千帆 OpenAI 兼容 API 的 BASE URL +- `open_ai_api_key`: 百度千帆 API Key
diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index 00e4265f..43cfeb53 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -14,6 +14,7 @@ from bridge.reply import Reply, ReplyType from common import const from common.log import logger from common.utils import expand_path +from config import conf from models.openai_compatible_bot import OpenAICompatibleBot @@ -68,6 +69,7 @@ class AgentLLMModel(LLMModel): _MODEL_BOT_TYPE_MAP = { "wenxin": const.BAIDU, "wenxin-4": const.BAIDU, "xunfei": const.XUNFEI, const.QWEN: const.QWEN_DASHSCOPE, + const.QIANFAN: const.QIANFAN, const.MODELSCOPE: const.MODELSCOPE, } _MODEL_PREFIX_MAP = [ @@ -75,10 +77,10 @@ class AgentLLMModel(LLMModel): ("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI), ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT), ("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK), + ("ernie", const.QIANFAN), ] def __init__(self, bridge: Bridge, bot_type: str = "chat"): - from config import conf super().__init__(model=conf().get("model", const.GPT_41)) self.bridge = bridge self.bot_type = bot_type @@ -87,7 +89,6 @@ class AgentLLMModel(LLMModel): @property def model(self): - from config import conf return conf().get("model", const.GPT_41) @model.setter @@ -96,8 +97,6 @@ class AgentLLMModel(LLMModel): def _resolve_bot_type(self, model_name: str) -> str: """Resolve bot type from model name, matching Bridge.__init__ logic.""" - from config import conf - if conf().get("use_linkai", False) and conf().get("linkai_api_key"): return const.LINKAI # Support custom bot type configuration @@ -117,8 +116,9 @@ class AgentLLMModel(LLMModel): return const.MOONSHOT if conf().get("bot_type") == "modelscope": return const.MODELSCOPE + lowered_model = model_name.lower() for prefix, btype in self._MODEL_PREFIX_MAP: - if model_name.startswith(prefix): + if lowered_model.startswith(prefix): return btype return const.OPENAI @@ -948,4 +948,4 @@ class AgentBridge: agent.tools = [t for t in agent.tools if t.name != "web_search"] logger.info("[AgentBridge] web_search tool removed (API key no longer available)") except Exception as e: - logger.debug(f"[AgentBridge] Failed to refresh conditional tools: {e}") \ No newline at end of file + logger.debug(f"[AgentBridge] Failed to refresh conditional tools: {e}") diff --git a/bridge/bridge.py b/bridge/bridge.py index 3cf55a80..753e394a 100644 --- a/bridge/bridge.py +++ b/bridge/bridge.py @@ -61,6 +61,11 @@ class Bridge(object): if model_type and model_type.startswith("deepseek"): self.btype["chat"] = const.DEEPSEEK + if model_type and isinstance(model_type, str): + lowered_model_type = model_type.lower() + if lowered_model_type == const.QIANFAN or lowered_model_type.startswith("ernie"): + self.btype["chat"] = const.QIANFAN + if model_type in [const.MODELSCOPE]: self.btype["chat"] = const.MODELSCOPE diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index c84431cf..fd456bae 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -772,6 +772,7 @@ class ConfigHandler: _RECOMMENDED_MODELS = [ const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER, + const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K, const.ERNIE_X1_TURBO_32K, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7, const.MINIMAX_M2_5, const.MINIMAX_M2_1, const.MINIMAX_M2_1_LIGHTNING, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE, @@ -788,6 +789,7 @@ class ConfigHandler: # never looks like a real default a user might paste verbatim — and we # never auto-rewrite anything on the server side. _PLACEHOLDER_V1 = "https://...../v1" + _PLACEHOLDER_QIANFAN = "https://...../v2" _PLACEHOLDER_ZHIPU = "https://...../api/paas/v4" _PLACEHOLDER_DOUBAO = "https://...../api/v3" _PLACEHOLDER_GEMINI = "https://....." @@ -801,6 +803,14 @@ class ConfigHandler: "api_base_placeholder": _PLACEHOLDER_V1, "models": [const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER], }), + ("qianfan", { + "label": "百度千帆", + "api_key_field": "qianfan_api_key", + "api_base_key": "qianfan_api_base", + "api_base_default": "https://qianfan.baidubce.com/v2", + "api_base_placeholder": _PLACEHOLDER_QIANFAN, + "models": [const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K, const.ERNIE_X1_TURBO_32K], + }), ("minimax", { "label": "MiniMax", "api_key_field": "minimax_api_key", @@ -893,9 +903,9 @@ class ConfigHandler: EDITABLE_KEYS = { "model", "bot_type", "use_linkai", - "open_ai_api_base", "deepseek_api_base", "claude_api_base", "gemini_api_base", + "open_ai_api_base", "deepseek_api_base", "qianfan_api_base", "claude_api_base", "gemini_api_base", "zhipu_ai_api_base", "moonshot_base_url", "ark_base_url", "custom_api_base", - "open_ai_api_key", "deepseek_api_key", "claude_api_key", "gemini_api_key", + "open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key", "zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key", "ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps", diff --git a/common/const.py b/common/const.py index b10a8eaa..ea8d80e5 100644 --- a/common/const.py +++ b/common/const.py @@ -3,6 +3,7 @@ OPEN_AI = "openAI" OPENAI = "openai" CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility BAIDU = "baidu" +QIANFAN = "qianfan" XUNFEI = "xunfei" CHATGPTONAZURE = "chatGPTOnAzure" LINKAI = "linkai" @@ -85,6 +86,12 @@ DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型 DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思考模式 + 工具调用) DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用) +# Baidu Qianfan / ERNIE +ERNIE_45_TURBO_128K = "ernie-4.5-turbo-128k" +ERNIE_45_TURBO_32K = "ernie-4.5-turbo-32k" +ERNIE_X1_TURBO_32K = "ernie-x1-turbo-32k" +ERNIE_4_TURBO_8K = "ERNIE-4.0-Turbo-8K" + # Qwen (通义千问 - 阿里云 DashScope) QWEN_TURBO = "qwen-turbo" QWEN_PLUS = "qwen-plus" @@ -159,6 +166,9 @@ MODEL_LIST = [ # DeepSeek DEEPSEEK_V4_FLASH, DEEPSEEK_V4_PRO, DEEPSEEK_CHAT, DEEPSEEK_REASONER, + # Baidu Qianfan / ERNIE + QIANFAN, ERNIE_45_TURBO_128K, ERNIE_45_TURBO_32K, ERNIE_X1_TURBO_32K, ERNIE_4_TURBO_8K, + # MiniMax MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5, diff --git a/config-template.json b/config-template.json index 97517ae8..2d4637da 100644 --- a/config-template.json +++ b/config-template.json @@ -3,6 +3,8 @@ "model": "deepseek-v4-flash", "deepseek_api_key": "", "deepseek_api_base": "https://api.deepseek.com/v1", + "qianfan_api_key": "", + "qianfan_api_base": "https://qianfan.baidubce.com/v2", "minimax_api_key": "", "zhipu_ai_api_key": "", "ark_api_key": "", diff --git a/config.py b/config.py index 5515b324..156c26c1 100644 --- a/config.py +++ b/config.py @@ -76,6 +76,9 @@ available_setting = { "baidu_wenxin_api_key": "", # Baidu api key "baidu_wenxin_secret_key": "", # Baidu secret key "baidu_wenxin_prompt_enabled": False, # Enable prompt if you are using ernie character model + # Baidu Qianfan / ERNIE OpenAI-compatible API + "qianfan_api_key": "", # Baidu Qianfan API key in bce-v3 format + "qianfan_api_base": "https://qianfan.baidubce.com/v2", # Qianfan OpenAI-compatible API base # 讯飞星火API "xunfei_app_id": "", # 讯飞应用ID "xunfei_api_key": "", # 讯飞 API key @@ -390,6 +393,8 @@ def load_config(): "minimax_api_base": "MINIMAX_API_BASE", "deepseek_api_key": "DEEPSEEK_API_KEY", "deepseek_api_base": "DEEPSEEK_API_BASE", + "qianfan_api_key": "QIANFAN_API_KEY", + "qianfan_api_base": "QIANFAN_API_BASE", "zhipu_ai_api_key": "ZHIPU_AI_API_KEY", "zhipu_ai_api_base": "ZHIPU_AI_API_BASE", "moonshot_api_key": "MOONSHOT_API_KEY", diff --git a/docs/en/models/index.mdx b/docs/en/models/index.mdx index 3073d96c..07da562e 100644 --- a/docs/en/models/index.mdx +++ b/docs/en/models/index.mdx @@ -6,7 +6,7 @@ description: Supported models and recommended choices for CowAgent CowAgent supports mainstream LLMs from domestic and international providers. Model interfaces are implemented in the project's `models/` directory. - For Agent mode, the following models are recommended based on quality and cost: deepseek-v4-flash, MiniMax-M2.7, claude-sonnet-4-6, gemini-3.1-pro-preview, glm-5.1, qwen3.6-plus, kimi-k2.6 + For Agent mode, the following models are recommended based on quality and cost: deepseek-v4-flash, MiniMax-M2.7, claude-sonnet-4-6, gemini-3.1-pro-preview, glm-5.1, qwen3.6-plus, kimi-k2.6, ernie-4.5-turbo-128k ## Configuration @@ -21,6 +21,9 @@ You can also use the [LinkAI](https://link-ai.tech) platform interface to flexib deepseek-v4-flash, deepseek-v4-pro, and more + + ernie-4.5-turbo-128k, ernie-x1-turbo-32k, and more + MiniMax-M2.7 and other series models diff --git a/docs/en/models/qianfan.mdx b/docs/en/models/qianfan.mdx new file mode 100644 index 00000000..e0612912 --- /dev/null +++ b/docs/en/models/qianfan.mdx @@ -0,0 +1,43 @@ +--- +title: Baidu Qianfan / ERNIE +description: Baidu Qianfan ERNIE model configuration +--- + +Option 1: Native integration (recommended): + +```json +{ + "model": "ernie-4.5-turbo-128k", + "qianfan_api_key": "", + "qianfan_api_base": "https://qianfan.baidubce.com/v2" +} +``` + +| Parameter | Description | +| --- | --- | +| `model` | Recommended: `ernie-4.5-turbo-128k`; also supports `ernie-4.5-turbo-32k` and `ernie-x1-turbo-32k` | +| `qianfan_api_key` | Qianfan API key, usually starting with `bce-v3/` | +| `qianfan_api_base` | Optional, defaults to `https://qianfan.baidubce.com/v2` | + +## Model Selection + +| Model | Use Case | +| --- | --- | +| `ernie-4.5-turbo-128k` | Default recommendation for long-context and general chat | +| `ernie-4.5-turbo-32k` | General chat with a balanced context window and cost | +| `ernie-x1-turbo-32k` | Tasks that need stronger reasoning | + +Option 2: OpenAI-compatible configuration: + +```json +{ + "model": "ernie-4.5-turbo-128k", + "bot_type": "openai", + "open_ai_api_key": "", + "open_ai_api_base": "https://qianfan.baidubce.com/v2" +} +``` + + + Prefer `qianfan_api_key` for new configurations. Existing `wenxin`, `wenxin-4`, `baidu_wenxin_api_key`, and `baidu_wenxin_secret_key` configurations remain supported. + diff --git a/docs/ja/models/index.mdx b/docs/ja/models/index.mdx index 95d865ef..e6d45862 100644 --- a/docs/ja/models/index.mdx +++ b/docs/ja/models/index.mdx @@ -6,7 +6,7 @@ description: CowAgentがサポートするモデルとおすすめの選択肢 CowAgentは国内外の主要なLLMをサポートしています。モデルインターフェースはプロジェクトの`models/`ディレクトリに実装されています。 - Agent モードでは、品質とコストのバランスから以下のモデルをおすすめします: deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.1-pro-preview、glm-5.1、qwen3.6-plus、kimi-k2.6 + Agent モードでは、品質とコストのバランスから以下のモデルをおすすめします: deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.1-pro-preview、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-4.5-turbo-128k ## 設定 @@ -21,6 +21,9 @@ CowAgentは国内外の主要なLLMをサポートしています。モデルイ deepseek-v4-flash、deepseek-v4-pro など + + ernie-4.5-turbo-128k、ernie-x1-turbo-32k など + MiniMax-M2.7およびその他のシリーズモデル diff --git a/docs/ja/models/qianfan.mdx b/docs/ja/models/qianfan.mdx new file mode 100644 index 00000000..cf8d5042 --- /dev/null +++ b/docs/ja/models/qianfan.mdx @@ -0,0 +1,43 @@ +--- +title: Baidu Qianfan / ERNIE +description: Baidu Qianfan ERNIE モデル設定 +--- + +方法 1: 公式接続(推奨): + +```json +{ + "model": "ernie-4.5-turbo-128k", + "qianfan_api_key": "", + "qianfan_api_base": "https://qianfan.baidubce.com/v2" +} +``` + +| パラメータ | 説明 | +| --- | --- | +| `model` | 推奨は `ernie-4.5-turbo-128k`。`ernie-4.5-turbo-32k` と `ernie-x1-turbo-32k` も利用できます | +| `qianfan_api_key` | Qianfan API Key。通常は `bce-v3/` で始まります | +| `qianfan_api_base` | 任意。デフォルトは `https://qianfan.baidubce.com/v2` | + +## モデル選択 + +| モデル | 用途 | +| --- | --- | +| `ernie-4.5-turbo-128k` | 長いコンテキストと一般的なチャット向けの推奨モデル | +| `ernie-4.5-turbo-32k` | コンテキスト長とコストのバランスが良い一般チャット向け | +| `ernie-x1-turbo-32k` | より強い推論が必要なタスク向け | + +方法 2: OpenAI 互換接続: + +```json +{ + "model": "ernie-4.5-turbo-128k", + "bot_type": "openai", + "open_ai_api_key": "", + "open_ai_api_base": "https://qianfan.baidubce.com/v2" +} +``` + + + 新しい設定では `qianfan_api_key` の利用を推奨します。既存の `wenxin`、`wenxin-4`、`baidu_wenxin_api_key`、`baidu_wenxin_secret_key` 設定は引き続き利用できます。 + diff --git a/docs/models/index.mdx b/docs/models/index.mdx index 33a5580f..23862055 100644 --- a/docs/models/index.mdx +++ b/docs/models/index.mdx @@ -6,7 +6,7 @@ description: CowAgent 支持的模型及推荐选择 CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在项目的 `models/` 目录下。 - Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.1-pro-preview、glm-5.1、qwen3.6-plus、kimi-k2.6 + Agent 模式下推荐使用以下模型,可根据效果及成本综合选择:deepseek-v4-flash、MiniMax-M2.7、claude-sonnet-4-6、gemini-3.1-pro-preview、glm-5.1、qwen3.6-plus、kimi-k2.6、ernie-4.5-turbo-128k 同时支持使用 [LinkAI](https://link-ai.tech) 平台接口,可灵活切换多种模型,并支持知识库、工作流、插件等 Agent 能力。 @@ -26,6 +26,9 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在 deepseek-v4-flash、deepseek-v4-pro 等 + + ernie-4.5-turbo-128k、ernie-x1-turbo-32k 等 + MiniMax-M2.7 等系列模型 diff --git a/docs/models/qianfan.mdx b/docs/models/qianfan.mdx new file mode 100644 index 00000000..a013b259 --- /dev/null +++ b/docs/models/qianfan.mdx @@ -0,0 +1,43 @@ +--- +title: 百度千帆 / ERNIE +description: 百度千帆 ERNIE 模型配置 +--- + +方式一:官方接入(推荐): + +```json +{ + "model": "ernie-4.5-turbo-128k", + "qianfan_api_key": "", + "qianfan_api_base": "https://qianfan.baidubce.com/v2" +} +``` + +| 参数 | 说明 | +| --- | --- | +| `model` | 推荐使用 `ernie-4.5-turbo-128k`;也可使用 `ernie-4.5-turbo-32k`、`ernie-x1-turbo-32k` | +| `qianfan_api_key` | 千帆 API Key,格式通常以 `bce-v3/` 开头 | +| `qianfan_api_base` | 可选,默认为 `https://qianfan.baidubce.com/v2` | + +## 模型选择 + +| 模型 | 适用场景 | +| --- | --- | +| `ernie-4.5-turbo-128k` | 默认推荐,适合长上下文和通用对话 | +| `ernie-4.5-turbo-32k` | 通用对话,成本和上下文更均衡 | +| `ernie-x1-turbo-32k` | 需要更强推理能力的任务 | + +方式二:OpenAI 兼容方式接入: + +```json +{ + "model": "ernie-4.5-turbo-128k", + "bot_type": "openai", + "open_ai_api_key": "", + "open_ai_api_base": "https://qianfan.baidubce.com/v2" +} +``` + + + 新配置推荐使用 `qianfan_api_key`。旧的 `wenxin`、`wenxin-4`、`baidu_wenxin_api_key`、`baidu_wenxin_secret_key` 配置仍保持兼容。 + diff --git a/models/bot_factory.py b/models/bot_factory.py index 632a9052..824aed04 100644 --- a/models/bot_factory.py +++ b/models/bot_factory.py @@ -21,6 +21,10 @@ def create_bot(bot_type): from models.deepseek.deepseek_bot import DeepSeekBot return DeepSeekBot() + elif bot_type == const.QIANFAN: + from models.qianfan.qianfan_bot import QianfanBot + return QianfanBot() + elif bot_type in (const.OPENAI, const.CHATGPT, const.CUSTOM): # OpenAI-compatible API from models.chatgpt.chat_gpt_bot import ChatGPTBot return ChatGPTBot() diff --git a/models/qianfan/__init__.py b/models/qianfan/__init__.py new file mode 100644 index 00000000..14668141 --- /dev/null +++ b/models/qianfan/__init__.py @@ -0,0 +1 @@ +# encoding:utf-8 diff --git a/models/qianfan/qianfan_bot.py b/models/qianfan/qianfan_bot.py new file mode 100644 index 00000000..0896611a --- /dev/null +++ b/models/qianfan/qianfan_bot.py @@ -0,0 +1,175 @@ +# encoding:utf-8 + +import time + +import requests +from bridge.context import ContextType +from bridge.reply import Reply, ReplyType +from common import const +from common.log import logger +from config import conf, load_config +from models.bot import Bot +from models.openai_compatible_bot import OpenAICompatibleBot +from models.session_manager import SessionManager +from .qianfan_session import QianfanSession + +DEFAULT_API_BASE = "https://qianfan.baidubce.com/v2" +DEFAULT_MODEL = const.ERNIE_45_TURBO_128K + + +class QianfanBot(Bot, OpenAICompatibleBot): + def __init__(self): + super().__init__() + model = self._resolve_model() + self.sessions = SessionManager(QianfanSession, model=model) + self.args = { + "model": model, + "temperature": conf().get("temperature", 0.7), + "top_p": conf().get("top_p", 1.0), + "frequency_penalty": conf().get("frequency_penalty", 0.0), + "presence_penalty": conf().get("presence_penalty", 0.0), + } + + def _resolve_model(self): + model = conf().get("model") or DEFAULT_MODEL + if model == const.QIANFAN: + return DEFAULT_MODEL + return model + + @property + def api_key(self): + return conf().get("qianfan_api_key") + + @property + def api_base(self): + url = conf().get("qianfan_api_base") or DEFAULT_API_BASE + url = url.rstrip("/") + suffix = "/chat/completions" + if url.endswith(suffix): + url = url[:-len(suffix)] + return url.rstrip("/") + + def get_api_config(self): + return { + "api_key": self.api_key, + "api_base": self.api_base, + "model": self._resolve_model(), + "default_temperature": conf().get("temperature", 0.7), + "default_top_p": conf().get("top_p", 1.0), + "default_frequency_penalty": conf().get("frequency_penalty", 0.0), + "default_presence_penalty": conf().get("presence_penalty", 0.0), + } + + def _build_headers(self): + return { + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(self.api_key), + } + + def reply(self, query, context=None): + if context.type == ContextType.TEXT: + logger.info("[QIANFAN] query={}".format(query)) + + session_id = context["session_id"] + reply = None + clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"]) + if query in clear_memory_commands: + self.sessions.clear_session(session_id) + reply = Reply(ReplyType.INFO, "记忆已清除") + elif query == "#清除所有": + self.sessions.clear_all_session() + reply = Reply(ReplyType.INFO, "所有人记忆已清除") + elif query == "#更新配置": + load_config() + reply = Reply(ReplyType.INFO, "配置已更新") + if reply: + return reply + + session = self.sessions.session_query(query, session_id) + logger.debug("[QIANFAN] session query={}".format(session.messages)) + + reply_content = self.reply_text(session, args=self.args.copy()) + logger.debug( + "[QIANFAN] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format( + session.messages, + session_id, + reply_content["content"], + reply_content["completion_tokens"], + ) + ) + if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0: + reply = Reply(ReplyType.ERROR, reply_content["content"]) + elif reply_content["completion_tokens"] > 0: + self.sessions.session_reply( + reply_content["content"], session_id, reply_content["total_tokens"], + ) + reply = Reply(ReplyType.TEXT, reply_content["content"]) + else: + reply = Reply(ReplyType.ERROR, reply_content["content"]) + logger.debug("[QIANFAN] reply {} used 0 tokens.".format(reply_content)) + return reply + else: + reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type)) + return reply + + def reply_text(self, session, args=None, retry_count=0): + try: + body = dict(args) if args else dict(self.args) + body["messages"] = session.messages + response = requests.post( + "{}/chat/completions".format(self.api_base), + headers=self._build_headers(), + json=body, + timeout=conf().get("request_timeout", 180), + ) + if response.status_code == 200: + data = response.json() + return { + "total_tokens": data["usage"]["total_tokens"], + "completion_tokens": data["usage"]["completion_tokens"], + "content": data["choices"][0]["message"]["content"], + } + return self._error_result(response, session, args, retry_count) + except Exception as e: + logger.exception(e) + if retry_count < 2: + return self.reply_text(session, args, retry_count + 1) + return {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"} + + def _error_result(self, response, session, args=None, retry_count=0): + try: + body = response.json() + except ValueError: + body = {"raw": response.text} + + error = body.get("error") if isinstance(body, dict) else None + if isinstance(error, dict): + message = error.get("message") or str(error) + elif error: + message = str(error) + elif isinstance(body, dict) and body.get("raw") is not None: + message = str(body.get("raw")) + else: + message = str(body) + + logger.error( + "[QIANFAN] chat failed, status_code={}, msg={}".format( + response.status_code, message + ) + ) + + if response.status_code >= 500 and retry_count < 2: + time.sleep(3) + return self.reply_text(session, args, retry_count + 1) + + if response.status_code == 401: + content = "授权失败,请检查 Qianfan API Key 是否正确" + elif response.status_code == 429: + if retry_count < 2: + time.sleep(3) + return self.reply_text(session, args, retry_count + 1) + content = "请求过于频繁,请稍后再试" + else: + content = "请求失败:{}".format(message) + + return {"completion_tokens": 0, "content": content} diff --git a/models/qianfan/qianfan_session.py b/models/qianfan/qianfan_session.py new file mode 100644 index 00000000..b5e19153 --- /dev/null +++ b/models/qianfan/qianfan_session.py @@ -0,0 +1,57 @@ +from models.session_manager import Session +from common.log import logger + + +class QianfanSession(Session): + def __init__(self, session_id, system_prompt=None, model="ernie-4.5-turbo-128k"): + super().__init__(session_id, system_prompt) + self.model = model + self.reset() + + def discard_exceeding(self, max_tokens, cur_tokens=None): + precise = True + try: + cur_tokens = self.calc_tokens() + except Exception as e: + precise = False + if cur_tokens is None: + raise e + logger.debug("Exception when counting tokens precisely for query: {}".format(e)) + while cur_tokens > max_tokens: + if len(self.messages) > 2: + self.messages.pop(1) + elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant": + self.messages.pop(1) + if precise: + cur_tokens = self.calc_tokens() + else: + cur_tokens = cur_tokens - max_tokens + break + elif len(self.messages) == 2 and self.messages[1]["role"] == "user": + logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens)) + break + else: + logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format( + max_tokens, cur_tokens, len(self.messages))) + break + if precise: + cur_tokens = self.calc_tokens() + else: + cur_tokens = cur_tokens - max_tokens + return cur_tokens + + def calc_tokens(self): + return num_tokens_from_messages(self.messages, self.model) + + +def num_tokens_from_messages(messages, model): + tokens = 0 + for msg in messages: + content = msg.get("content", "") + if isinstance(content, str): + tokens += len(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict): + tokens += len(block.get("text", "")) + return tokens diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index 6a449314..42d27330 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -428,11 +428,12 @@ class CowCliPlugin(Plugin): @staticmethod def _resolve_bot_type_for_model(model_name: str) -> str: - """Resolve bot_type from model name, reusing AgentBridge mapping.""" + """Resolve bot_type from model name, matching AgentBridge mapping.""" from common import const _EXACT = { "wenxin": const.BAIDU, "wenxin-4": const.BAIDU, "xunfei": const.XUNFEI, const.QWEN: const.QWEN_DASHSCOPE, + const.QIANFAN: const.QIANFAN, const.MODELSCOPE: const.MODELSCOPE, const.MOONSHOT: const.MOONSHOT, "moonshot-v1-8k": const.MOONSHOT, "moonshot-v1-32k": const.MOONSHOT, @@ -445,6 +446,7 @@ class CowCliPlugin(Plugin): ("claude", const.CLAUDEAPI), ("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT), ("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK), + ("ernie", const.QIANFAN), ] if not model_name: return const.OPENAI @@ -454,8 +456,9 @@ class CowCliPlugin(Plugin): return const.MiniMax if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]: return const.QWEN_DASHSCOPE + lowered_model = model_name.lower() for prefix, btype in _PREFIX: - if model_name.startswith(prefix): + if lowered_model.startswith(prefix): return btype return const.OPENAI diff --git a/plugins/godcmd/godcmd.py b/plugins/godcmd/godcmd.py index 6439411d..efd445f2 100644 --- a/plugins/godcmd/godcmd.py +++ b/plugins/godcmd/godcmd.py @@ -315,7 +315,7 @@ class Godcmd(Plugin): except Exception as e: ok, result = False, "你没有设置私有GPT模型" elif cmd == "reset": - if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.XUNFEI, const.QWEN, const.QWEN_DASHSCOPE, const.GEMINI, const.ZHIPU_AI, const.CLAUDEAPI]: + if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, const.BAIDU, const.QIANFAN, const.XUNFEI, const.QWEN, const.QWEN_DASHSCOPE, const.GEMINI, const.ZHIPU_AI, const.CLAUDEAPI]: bot.sessions.clear_session(session_id) if Bridge().chat_bots.get(bottype): Bridge().chat_bots.get(bottype).sessions.clear_session(session_id) @@ -341,7 +341,7 @@ class Godcmd(Plugin): ok, result = True, "配置已重载" elif cmd == "resetall": if bottype in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.LINKAI, - const.BAIDU, const.XUNFEI, const.QWEN, const.QWEN_DASHSCOPE, const.GEMINI, const.ZHIPU_AI, const.MOONSHOT, + const.BAIDU, const.QIANFAN, const.XUNFEI, const.QWEN, const.QWEN_DASHSCOPE, const.GEMINI, const.ZHIPU_AI, const.MOONSHOT, const.MODELSCOPE]: channel.cancel_all_session() bot.sessions.clear_all_session() diff --git a/plugins/role/role.py b/plugins/role/role.py index adec6c82..e23ff361 100644 --- a/plugins/role/role.py +++ b/plugins/role/role.py @@ -99,7 +99,7 @@ class Role(Plugin): if e_context["context"].type != ContextType.TEXT: return btype = Bridge().get_bot_type("chat") - if btype not in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.QWEN_DASHSCOPE, const.XUNFEI, const.BAIDU, const.ZHIPU_AI, const.MOONSHOT, const.MiniMax, const.LINKAI, const.MODELSCOPE]: + if btype not in [const.OPEN_AI, const.OPENAI, const.CHATGPT, const.CHATGPTONAZURE, const.QWEN_DASHSCOPE, const.XUNFEI, const.BAIDU, const.QIANFAN, const.ZHIPU_AI, const.MOONSHOT, const.MiniMax, const.LINKAI, const.MODELSCOPE]: logger.debug(f'不支持的bot: {btype}') return bot = Bridge().get_bot("chat") diff --git a/tests/test_qianfan_provider.py b/tests/test_qianfan_provider.py new file mode 100644 index 00000000..e2c3d4fe --- /dev/null +++ b/tests/test_qianfan_provider.py @@ -0,0 +1,281 @@ +# encoding:utf-8 +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +class TestQianfanConstantsAndRouting(unittest.TestCase): + def test_qianfan_provider_constant_defined(self): + from common import const + + self.assertEqual(const.QIANFAN, "qianfan") + + def test_ernie_constants_are_in_model_list(self): + from common import const + + self.assertEqual(const.ERNIE_45_TURBO_128K, "ernie-4.5-turbo-128k") + self.assertEqual(const.ERNIE_45_TURBO_32K, "ernie-4.5-turbo-32k") + self.assertEqual(const.ERNIE_X1_TURBO_32K, "ernie-x1-turbo-32k") + self.assertIn(const.QIANFAN, const.MODEL_LIST) + self.assertIn(const.ERNIE_45_TURBO_128K, const.MODEL_LIST) + self.assertIn(const.ERNIE_45_TURBO_32K, const.MODEL_LIST) + self.assertIn(const.ERNIE_X1_TURBO_32K, const.MODEL_LIST) + + def test_qianfan_config_keys_are_available(self): + import config + + self.assertIn("qianfan_api_key", config.available_setting) + self.assertIn("qianfan_api_base", config.available_setting) + + def test_agent_bridge_routes_ernie_models_to_qianfan(self): + from bridge.agent_bridge import AgentLLMModel + from common import const + + model = AgentLLMModel.__new__(AgentLLMModel) + fake_conf = MagicMock() + fake_conf.get.side_effect = lambda key, default=None: { + "use_linkai": False, + "linkai_api_key": "", + "bot_type": "", + }.get(key, default) + + with patch("bridge.agent_bridge.conf", return_value=fake_conf): + self.assertEqual( + AgentLLMModel._resolve_bot_type(model, "ernie-4.5-turbo-128k"), + const.QIANFAN, + ) + self.assertEqual( + AgentLLMModel._resolve_bot_type(model, "qianfan"), + const.QIANFAN, + ) + + def test_cow_cli_routes_ernie_models_to_qianfan(self): + from common import const + import plugins + + old_plugin_path = plugins.instance.current_plugin_path + cow_cli_was_registered = "COW_CLI" in plugins.instance.plugins + old_cow_cli_plugin = plugins.instance.plugins.get("COW_CLI") + parent_had_cow_cli = hasattr(plugins, "cow_cli") + old_parent_cow_cli = getattr(plugins, "cow_cli", None) + module_names = ("plugins.cow_cli", "plugins.cow_cli.cow_cli") + old_modules = { + name: sys.modules[name] + for name in module_names + if name in sys.modules + } + plugins.instance.current_plugin_path = os.path.join( + os.path.dirname(__file__), "..", "plugins", "cow_cli" + ) + try: + import plugins.cow_cli.cow_cli + cow_cli_plugin = plugins.instance.plugins["COW_CLI"] + finally: + plugins.instance.current_plugin_path = old_plugin_path + if cow_cli_was_registered: + plugins.instance.plugins["COW_CLI"] = old_cow_cli_plugin + else: + plugins.instance.plugins.pop("COW_CLI", None) + for name in module_names: + if name in old_modules: + sys.modules[name] = old_modules[name] + else: + sys.modules.pop(name, None) + if parent_had_cow_cli: + plugins.cow_cli = old_parent_cow_cli + elif hasattr(plugins, "cow_cli"): + delattr(plugins, "cow_cli") + + self.assertEqual( + cow_cli_plugin._resolve_bot_type_for_model("ernie-4.5-turbo-128k"), + const.QIANFAN, + ) + self.assertEqual( + cow_cli_plugin._resolve_bot_type_for_model("qianfan"), + const.QIANFAN, + ) + + +class TestQianfanBot(unittest.TestCase): + def _fake_conf(self, values=None): + data = { + "model": "ernie-4.5-turbo-128k", + "qianfan_api_key": "test-qianfan-key", + "qianfan_api_base": "https://qianfan.baidubce.com/v2", + "temperature": 0.7, + "top_p": 1.0, + "frequency_penalty": 0.0, + "presence_penalty": 0.0, + "request_timeout": 180, + "clear_memory_commands": ["#清除记忆"], + "conversation_max_tokens": 1000, + "expires_in_seconds": 3600, + } + if values: + data.update(values) + fake_conf = MagicMock() + fake_conf.get.side_effect = lambda key, default=None: data.get(key, default) + return fake_conf + + def test_bot_factory_returns_qianfan_bot(self): + from common import const + from models.bot_factory import create_bot + + fake_conf = self._fake_conf() + with patch("models.qianfan.qianfan_bot.conf", return_value=fake_conf): + with patch("models.qianfan.qianfan_bot.SessionManager"): + bot = create_bot(const.QIANFAN) + + from models.qianfan.qianfan_bot import QianfanBot + self.assertIsInstance(bot, QianfanBot) + + def test_default_model_uses_ernie_when_model_is_provider_alias(self): + fake_conf = self._fake_conf({"model": "qianfan"}) + with patch("models.qianfan.qianfan_bot.conf", return_value=fake_conf): + with patch("models.qianfan.qianfan_bot.SessionManager"): + from models.qianfan.qianfan_bot import QianfanBot + + bot = QianfanBot() + + self.assertEqual(bot.args["model"], "ernie-4.5-turbo-128k") + + def test_reply_text_posts_openai_compatible_payload(self): + fake_conf = self._fake_conf() + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = { + "choices": [{"message": {"content": "你好,我是文心。"}}], + "usage": {"total_tokens": 12, "completion_tokens": 6}, + } + session = MagicMock() + session.messages = [{"role": "user", "content": "你好"}] + + with patch("models.qianfan.qianfan_bot.conf", return_value=fake_conf): + with patch("models.qianfan.qianfan_bot.SessionManager"): + from models.qianfan.qianfan_bot import QianfanBot + + bot = QianfanBot() + with patch("models.qianfan.qianfan_bot.requests.post", return_value=fake_response) as post: + result = bot.reply_text(session) + + self.assertEqual(result["content"], "你好,我是文心。") + self.assertEqual(result["total_tokens"], 12) + self.assertEqual(result["completion_tokens"], 6) + post.assert_called_once() + url = post.call_args.args[0] + kwargs = post.call_args.kwargs + self.assertEqual(url, "https://qianfan.baidubce.com/v2/chat/completions") + self.assertEqual(kwargs["headers"]["Authorization"], "Bearer test-qianfan-key") + self.assertEqual(kwargs["json"]["model"], "ernie-4.5-turbo-128k") + self.assertEqual(kwargs["json"]["messages"], [{"role": "user", "content": "你好"}]) + + def test_reply_text_returns_auth_error_for_401(self): + fake_conf = self._fake_conf() + fake_response = MagicMock() + fake_response.status_code = 401 + fake_response.json.return_value = {"error": {"message": "invalid api key"}} + fake_response.text = '{"error":{"message":"invalid api key"}}' + session = MagicMock() + session.messages = [{"role": "user", "content": "你好"}] + + with patch("models.qianfan.qianfan_bot.conf", return_value=fake_conf): + with patch("models.qianfan.qianfan_bot.SessionManager"): + from models.qianfan.qianfan_bot import QianfanBot + + bot = QianfanBot() + with patch("models.qianfan.qianfan_bot.requests.post", return_value=fake_response): + result = bot.reply_text(session) + + self.assertEqual(result["completion_tokens"], 0) + self.assertEqual(result["content"], "授权失败,请检查 Qianfan API Key 是否正确") + + def test_reply_text_returns_raw_message_for_non_json_error(self): + fake_conf = self._fake_conf() + fake_response = MagicMock() + fake_response.status_code = 400 + fake_response.json.side_effect = ValueError + fake_response.text = "bad gateway text" + session = MagicMock() + session.messages = [{"role": "user", "content": "你好"}] + + with patch("models.qianfan.qianfan_bot.conf", return_value=fake_conf): + with patch("models.qianfan.qianfan_bot.SessionManager"): + from models.qianfan.qianfan_bot import QianfanBot + + bot = QianfanBot() + with patch("models.qianfan.qianfan_bot.requests.post", return_value=fake_response) as post: + result = bot.reply_text(session) + + self.assertEqual(result["completion_tokens"], 0) + self.assertEqual(result["content"], "请求失败:bad gateway text") + post.assert_called_once() + + +class TestQianfanSurfaces(unittest.TestCase): + def _read(self, relative_path): + root = os.path.join(os.path.dirname(__file__), "..") + with open(os.path.join(root, relative_path), encoding="utf-8") as f: + return f.read() + + def test_web_console_registers_qianfan_provider(self): + source = self._read("channel/web/web_channel.py") + + self.assertIn('("qianfan", {', source) + self.assertIn('"label": "百度千帆"', source) + self.assertIn('"api_key_field": "qianfan_api_key"', source) + self.assertIn('"api_base_key": "qianfan_api_base"', source) + self.assertIn('"api_base_default": "https://qianfan.baidubce.com/v2"', source) + + def test_web_console_allows_qianfan_config_edits(self): + source = self._read("channel/web/web_channel.py") + + self.assertIn('"qianfan_api_base"', source) + self.assertIn('"qianfan_api_key"', source) + + def test_session_plugins_allow_qianfan(self): + role_source = self._read("plugins/role/role.py") + godcmd_source = self._read("plugins/godcmd/godcmd.py") + + self.assertIn("const.QIANFAN", role_source) + self.assertIn("const.QIANFAN", godcmd_source) + + +class TestQianfanDocs(unittest.TestCase): + def _read(self, relative_path): + root = os.path.join(os.path.dirname(__file__), "..") + with open(os.path.join(root, relative_path), encoding="utf-8") as f: + return f.read() + + def test_qianfan_docs_exist_in_all_doc_locales(self): + for path in ( + "docs/models/qianfan.mdx", + "docs/en/models/qianfan.mdx", + "docs/ja/models/qianfan.mdx", + ): + text = self._read(path) + self.assertIn("qianfan_api_key", text) + self.assertIn("https://qianfan.baidubce.com/v2", text) + self.assertIn("ernie-4.5-turbo-128k", text) + + def test_model_indexes_link_qianfan(self): + for path in ( + "docs/models/index.mdx", + "docs/en/models/index.mdx", + "docs/ja/models/index.mdx", + ): + text = self._read(path) + self.assertIn('/models/qianfan', text) + + def test_readme_documents_native_qianfan_provider(self): + text = self._read("README.md") + + self.assertIn('"model": "ernie-4.5-turbo-128k"', text) + self.assertIn('"qianfan_api_key": ""', text) + self.assertIn('"qianfan_api_base": "https://qianfan.baidubce.com/v2"', text) + + +if __name__ == "__main__": + unittest.main()