From 28fd12d660dffaddc0ca992113d843f769db29bc Mon Sep 17 00:00:00 2001 From: wxg0103 <727495428@qq.com> Date: Fri, 13 Nov 2026 15:57:23 +0800 Subject: [PATCH] refactor: update Xunfei model interfaces to align with OpenAI compatibility and remove deprecated components --- .../xf_model_provider/credential/embedding.py | 29 +- .../xf_model_provider/credential/image.py | 73 ++- .../impl/xf_model_provider/credential/llm.py | 51 +- .../impl/xf_model_provider/model/embedding.py | 98 +--- .../impl/xf_model_provider/model/image.py | 115 +--- .../impl/xf_model_provider/model/llm.py | 83 +-- .../xf_model_provider/xf_model_provider.py | 27 +- .../langchain_compat/__init__.py | 13 - .../langchain_compat/sparkllm.py | 513 ------------------ .../tests/test_xunfei_openai_embedding.py | 47 ++ .../tests/test_xunfei_openai_image.py | 52 ++ .../tests/test_xunfei_openai_llm.py | 58 ++ 12 files changed, 329 insertions(+), 830 deletions(-) delete mode 100644 apps/models_provider/langchain_compat/__init__.py delete mode 100644 apps/models_provider/langchain_compat/sparkllm.py create mode 100644 apps/models_provider/tests/test_xunfei_openai_embedding.py create mode 100644 apps/models_provider/tests/test_xunfei_openai_image.py create mode 100644 apps/models_provider/tests/test_xunfei_openai_llm.py diff --git a/apps/models_provider/impl/xf_model_provider/credential/embedding.py b/apps/models_provider/impl/xf_model_provider/credential/embedding.py index 65a533d3d73..faeceb394ff 100644 --- a/apps/models_provider/impl/xf_model_provider/credential/embedding.py +++ b/apps/models_provider/impl/xf_model_provider/credential/embedding.py @@ -3,13 +3,13 @@ @project: MaxKB @Author:虎 @file: embedding.py -@date:2024/10/17 15:40 -@desc: +@date:2026/11/13 +@desc: Xunfei Spark 向量模型 OpenAI 兼容接口凭证 """ from typing import Dict -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _, gettext from common import forms from common.exception.app_exception import AppApiException @@ -18,7 +18,7 @@ from common.utils.logger import maxkb_logger -class XFEmbeddingCredential(BaseForm, BaseModelCredential): +class XunFeiOpenAIEmbeddingCredential(BaseForm, BaseModelCredential): def is_valid( self, model_type: str, @@ -26,14 +26,20 @@ def is_valid( model_credential: Dict[str, object], model_params, provider, - raise_exception=False, + raise_exception=True, ): model_type_list = provider.get_model_type_list() if not any(list(filter(lambda mt: mt.get("value") == model_type, model_type_list))): raise AppApiException( ValidCode.valid_error.value, _("{model_type} Model type is not supported").format(model_type=model_type) ) - self.valid_form(model_credential) + + for key in ["api_base", "api_key", "model_id"]: + if key not in model_credential: + if raise_exception: + raise AppApiException(ValidCode.valid_error.value, gettext("{key} is required").format(key=key)) + else: + return False try: model = provider.get_model(model_type, model_name, model_credential) model.embed_query(_("Hello")) @@ -51,9 +57,10 @@ def is_valid( return True def encryption_dict(self, model: Dict[str, object]): - return {**model, "spark_api_secret": super().encryption(model.get("spark_api_secret", ""))} + return {**model, "api_key": super().encryption(model.get("api_key", ""))} - base_url = forms.TextInputField("API URL", required=True, default_value="https://emb-cn-huabei-1.xf-yun.com/") - spark_app_id = forms.TextInputField("APP ID", required=True) - spark_api_key = forms.PasswordInputField("API Key", required=True) - spark_api_secret = forms.PasswordInputField("API Secret", required=True) + api_base = forms.TextInputField( + "API URL", required=True, default_value="https://maas-api.cn-huabei-1.xf-yun.com/v2" + ) + api_key = forms.PasswordInputField("API Key", required=True) + model_id = forms.TextInputField("Model ID", required=True) diff --git a/apps/models_provider/impl/xf_model_provider/credential/image.py b/apps/models_provider/impl/xf_model_provider/credential/image.py index d5fc7e4b472..1f0ee1236a8 100644 --- a/apps/models_provider/impl/xf_model_provider/credential/image.py +++ b/apps/models_provider/impl/xf_model_provider/credential/image.py @@ -1,26 +1,56 @@ # coding=utf-8 -import base64 -import os +""" +@project: MaxKB +@Author:虎 +@file: image.py +@date:2026/11/13 +@desc: 讯飞星火视觉模型 OpenAI 兼容接口凭证 +""" + from typing import Dict -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _, gettext from langchain_core.messages import HumanMessage from common import forms from common.exception.app_exception import AppApiException -from common.forms import BaseForm -from models_provider.base_model_provider import BaseModelCredential, ValidCode -from models_provider.impl.xf_model_provider.model.image import ImageMessage +from common.forms import BaseForm, TooltipLabel from common.utils.logger import maxkb_logger +from models_provider.base_model_provider import BaseModelCredential, ValidCode + + +class XunFeiImageModelParams(BaseForm): + temperature = forms.SliderField( + TooltipLabel( + _("Temperature"), + _("Higher values make the output more random, while lower values make it more focused and deterministic"), + ), + required=True, + default_value=0.5, + _min=0.1, + _max=1.0, + _step=0.01, + precision=2, + ) + + max_tokens = forms.SliderField( + TooltipLabel( + _("Output the maximum Tokens"), _("Specify the maximum number of tokens that the model can generate") + ), + required=True, + default_value=4096, + _min=1, + _max=100000, + _step=1, + precision=0, + ) class XunFeiImageModelCredential(BaseForm, BaseModelCredential): - spark_api_url = forms.TextInputField( - "API URL", required=True, default_value="wss://spark-api.cn-huabei-1.xf-yun.com/v2.1/image" + api_base = forms.TextInputField( + "API URL", required=True, default_value="https://maas-api.cn-huabei-1.xf-yun.com/v2" ) - spark_app_id = forms.TextInputField("APP ID", required=True) - spark_api_key = forms.PasswordInputField("API Key", required=True) - spark_api_secret = forms.PasswordInputField("API Secret", required=True) + api_key = forms.PasswordInputField("API Key", required=True) def is_valid( self, @@ -34,24 +64,21 @@ def is_valid( model_type_list = provider.get_model_type_list() if not any(list(filter(lambda mt: mt.get("value") == model_type, model_type_list))): raise AppApiException( - ValidCode.valid_error.value, _("{model_type} Model type is not supported").format(model_type=model_type) + ValidCode.valid_error.value, + gettext("{model_type} Model type is not supported").format(model_type=model_type), ) - for key in ["spark_api_url", "spark_app_id", "spark_api_key", "spark_api_secret"]: + for key in ["api_base", "api_key"]: if key not in model_credential: if raise_exception: - raise AppApiException(ValidCode.valid_error.value, _("{key} is required").format(key=key)) + raise AppApiException(ValidCode.valid_error.value, gettext("{key} is required").format(key=key)) else: return False try: model = provider.get_model(model_type, model_name, model_credential, **model_params) - cwd = os.path.dirname(os.path.abspath(__file__)) - with open(f"{cwd}/img_1.png", "rb") as f: - message_list = [ - ImageMessage(str(base64.b64encode(f.read()), "utf-8")), - HumanMessage(_("Please outline this picture")), - ] - model.stream(message_list) + res = model.stream([HumanMessage(content=[{"type": "text", "text": gettext("Hello")}])]) + for chunk in res: + maxkb_logger.info(chunk) except Exception as e: maxkb_logger.error(f"Exception: {e}", exc_info=True) if isinstance(e, AppApiException): @@ -66,7 +93,7 @@ def is_valid( return True def encryption_dict(self, model: Dict[str, object]): - return {**model, "spark_api_secret": super().encryption(model.get("spark_api_secret", ""))} + return {**model, "api_key": super().encryption(model.get("api_key", ""))} def get_model_params_setting_form(self, model_name): - pass + return XunFeiImageModelParams() diff --git a/apps/models_provider/impl/xf_model_provider/credential/llm.py b/apps/models_provider/impl/xf_model_provider/credential/llm.py index 79da1310fb5..5c540740afa 100644 --- a/apps/models_provider/impl/xf_model_provider/credential/llm.py +++ b/apps/models_provider/impl/xf_model_provider/credential/llm.py @@ -3,8 +3,8 @@ @project: MaxKB @Author:虎 @file: llm.py -@date:2024/7/12 10:29 -@desc: +@date:2026/11/13 +@desc: Xunfei Spark OpenAI 兼容接口凭证(X2.x 模型走 OpenAI Chat 协议) """ from typing import Dict @@ -19,7 +19,7 @@ from common.utils.logger import maxkb_logger -class XunFeiLLMModelGeneralParams(BaseForm): +class XunFeiOpenAILLMModelParams(BaseForm): temperature = forms.SliderField( TooltipLabel( _("Temperature"), @@ -46,34 +46,7 @@ class XunFeiLLMModelGeneralParams(BaseForm): ) -class XunFeiLLMModelProParams(BaseForm): - temperature = forms.SliderField( - TooltipLabel( - _("Temperature"), - _("Higher values make the output more random, while lower values make it more focused and deterministic"), - ), - required=True, - default_value=0.5, - _min=0.1, - _max=1.0, - _step=0.01, - precision=2, - ) - - max_tokens = forms.SliderField( - TooltipLabel( - _("Output the maximum Tokens"), _("Specify the maximum number of tokens that the model can generate") - ), - required=True, - default_value=4096, - _min=1, - _max=100000, - _step=1, - precision=0, - ) - - -class XunFeiLLMModelCredential(BaseForm, BaseModelCredential): +class XunFeiOpenAILLMModelCredential(BaseForm, BaseModelCredential): def is_valid( self, model_type: str, @@ -90,7 +63,7 @@ def is_valid( gettext("{model_type} Model type is not supported").format(model_type=model_type), ) - for key in ["spark_api_url", "spark_app_id", "spark_api_key", "spark_api_secret"]: + for key in ["api_base", "api_key"]: if key not in model_credential: if raise_exception: raise AppApiException(ValidCode.valid_error.value, gettext("{key} is required").format(key=key)) @@ -113,14 +86,12 @@ def is_valid( return True def encryption_dict(self, model: Dict[str, object]): - return {**model, "spark_api_secret": super().encryption(model.get("spark_api_secret", ""))} + return {**model, "api_key": super().encryption(model.get("api_key", ""))} - spark_api_url = forms.TextInputField("API URL", required=True, default_value="wss://spark-api.xf-yun.com/v3.5/chat") - spark_app_id = forms.TextInputField("APP ID", required=True) - spark_api_key = forms.PasswordInputField("API Key", required=True) - spark_api_secret = forms.PasswordInputField("API Secret", required=True) + api_base = forms.TextInputField( + "API URL", required=True, default_value="https://maas-api.cn-huabei-1.xf-yun.com/v2" + ) + api_key = forms.PasswordInputField("API Key", required=True) def get_model_params_setting_form(self, model_name): - if model_name == "general" or model_name == "pro-128k": - return XunFeiLLMModelGeneralParams() - return XunFeiLLMModelProParams() + return XunFeiOpenAILLMModelParams() diff --git a/apps/models_provider/impl/xf_model_provider/model/embedding.py b/apps/models_provider/impl/xf_model_provider/model/embedding.py index fd9893ea67b..55e77a43383 100644 --- a/apps/models_provider/impl/xf_model_provider/model/embedding.py +++ b/apps/models_provider/impl/xf_model_provider/model/embedding.py @@ -3,92 +3,46 @@ @project: MaxKB @Author:虎 @file: embedding.py -@date:2024/10/17 15:29 -@desc: +@date:2026/11/13 +@desc: Xunfei Spark 向量模型 OpenAI 兼容接口模型 """ -import queue -import threading -import time -from typing import Dict, Optional +from typing import Dict, List -import base64 -import json -import numpy as np -from numpy import ndarray +import openai from models_provider.base_model_provider import MaxKBBaseEmbeddingModel -from models_provider.langchain_compat.sparkllm import SparkLLMTextEmbeddings -_task_queue = queue.Queue() - - -def _worker(): - while True: - message, future = _task_queue.get() - - for i in range(3): - try: - data = json.loads(message) - code = data["header"]["code"] - - if code != 0: - raise Exception(f"Request error: {code}, {data}") - - text_base = data["payload"]["feature"]["text"] - text_data = base64.b64decode(text_base) - - dt = np.dtype(np.float32) - dt = dt.newbyteorder("<") - - text = np.frombuffer(text_data, dtype=dt) - - if len(text) > 2560: - array = text[:2560] - else: - array = text - - future["result"] = array - future["event"].set() - break - - except Exception as e: - if i == 2: - future["error"] = e - future["event"].set() - else: - time.sleep(0.5) - - time.sleep(0.5) # QPS=2 +class XunFeiOpenAIEmbeddingModel(MaxKBBaseEmbeddingModel): + def supports_image_embedding(self) -> bool: + return False -threading.Thread(target=_worker, daemon=True).start() + model_name: str + optional_params: dict + def __init__(self, api_key, base_url, model_name: str, optional_params: dict): + self.client = openai.OpenAI(api_key=api_key, base_url=base_url).embeddings + self.model_name = model_name + self.optional_params = optional_params -class XFEmbedding(MaxKBBaseEmbeddingModel, SparkLLMTextEmbeddings): - def supports_image_embedding(self) -> bool: + def is_cache_model(self): return False @staticmethod def new_instance(model_type, model_name, model_credential: Dict[str, object], **model_kwargs): - return XFEmbedding( - base_url=model_credential.get("base_url"), - spark_app_id=model_credential.get("spark_app_id"), - spark_api_key=model_credential.get("spark_api_key"), - spark_api_secret=model_credential.get("spark_api_secret"), + optional_params = MaxKBBaseEmbeddingModel.filter_optional_params(model_kwargs) + return XunFeiOpenAIEmbeddingModel( + api_key=model_credential.get("api_key"), + base_url=model_credential.get("api_base"), + model_name=model_credential.get("model_id") or model_name, + optional_params=optional_params, ) - @staticmethod - def _parser_message( - message: str, - ) -> Optional[ndarray]: - future = {"event": threading.Event(), "result": None, "error": None} - - _task_queue.put((message, future)) - - future["event"].wait() - - if future["error"]: - raise future["error"] + def embed_query(self, text: str): + res = self.embed_documents([text]) + return res[0] - return future["result"] + def embed_documents(self, texts: List[str], chunk_size: int | None = None) -> List[List[float]]: + res = self.client.create(input=texts, model=self.model_name, encoding_format="float") + return [e.embedding for e in res.data] diff --git a/apps/models_provider/impl/xf_model_provider/model/image.py b/apps/models_provider/impl/xf_model_provider/model/image.py index 2e14a55715c..beb77b29c3b 100644 --- a/apps/models_provider/impl/xf_model_provider/model/image.py +++ b/apps/models_provider/impl/xf_model_provider/model/image.py @@ -1,100 +1,45 @@ # coding=utf-8 -import base64 -import os -from typing import Dict, Any, List, Optional, Iterator +""" +@project: MaxKB +@Author:虎 +@file: image.py +@date:2026/11/13 +@desc: 讯飞星火视觉模型 OpenAI 兼容接入(基于 Chat Completions 多模态协议) +""" -# from docutils.utils import SystemMessage -from models_provider.langchain_compat.sparkllm import ChatSparkLLM, _convert_delta_to_message_chunk -from langchain_core.callbacks import CallbackManagerForLLMRun -from langchain_core.messages import BaseMessage, ChatMessage, HumanMessage, AIMessage, AIMessageChunk -from langchain_core.outputs import ChatGenerationChunk +from typing import Any, Dict +from langchain_core.language_models import LanguageModelInput from models_provider.base_model_provider import MaxKBBaseModel +from models_provider.impl.base_chat_open_ai import BaseChatOpenAI -class ImageMessage(HumanMessage): - content: str - - -def convert_message_to_dict(message: BaseMessage) -> dict: - message_dict: Dict[str, Any] - if isinstance(message, ChatMessage): - message_dict = {"role": "user", "content": message.content} - elif isinstance(message, ImageMessage): - message_dict = {"role": "user", "content": message.content, "content_type": "image"} - elif isinstance(message, HumanMessage): - message_dict = {"role": "user", "content": message.content} - elif isinstance(message, AIMessage): - message_dict = {"role": "assistant", "content": message.content} - if "function_call" in message.additional_kwargs: - message_dict["function_call"] = message.additional_kwargs["function_call"] - # If function call only, content is None not empty string - if message_dict["content"] == "": - message_dict["content"] = None - if "tool_calls" in message.additional_kwargs: - message_dict["tool_calls"] = message.additional_kwargs["tool_calls"] - # If tool calls only, content is None not empty string - if message_dict["content"] == "": - message_dict["content"] = None - # elif isinstance(message, SystemMessage): - # message_dict = {"role": "system", "content": message.content} - else: - raise ValueError(f"Got unknown type {message}") - - return message_dict - - -class XFSparkImage(MaxKBBaseModel, ChatSparkLLM): - spark_app_id: str - spark_api_key: str - spark_api_secret: str - spark_api_url: str +class XunFeiOpenAIImage(MaxKBBaseModel, BaseChatOpenAI): + @staticmethod + def is_cache_model(): + return False @staticmethod def new_instance(model_type, model_name, model_credential: Dict[str, object], **model_kwargs): optional_params = MaxKBBaseModel.filter_optional_params(model_kwargs) - return XFSparkImage( - spark_app_id=model_credential.get("spark_app_id"), - spark_api_key=model_credential.get("spark_api_key"), - spark_api_secret=model_credential.get("spark_api_secret"), - spark_api_url=model_credential.get("spark_api_url"), + return XunFeiOpenAIImage( + model=model_name, + openai_api_base=model_credential.get("api_base") or "https://maas-api.cn-huabei-1.xf-yun.com/v2", + openai_api_key=model_credential.get("api_key"), + streaming=True, + stream_usage=True, **optional_params, ) - @staticmethod - def generate_message(prompt: str, image) -> list[BaseMessage]: - if image is None: - cwd = os.path.dirname(os.path.abspath(__file__)) - with open(f"{cwd}/img_1.png", "rb") as f: - base64_image = base64.b64encode(f.read()).decode("utf-8") - return [ImageMessage(f"data:image/jpeg;base64,{base64_image}"), HumanMessage(prompt)] - return [HumanMessage(prompt)] - - def _stream( + def _get_request_payload( self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[CallbackManagerForLLMRun] = None, + input_: LanguageModelInput, + *, + stop: list[str] | None = None, **kwargs: Any, - ) -> Iterator[ChatGenerationChunk]: - default_chunk_class = AIMessageChunk - - self.client.arun( - [convert_message_to_dict(m) for m in messages], - self.spark_user_id, - self.model_kwargs, - streaming=True, - ) - for content in self.client.subscribe(timeout=self.request_timeout): - if "data" not in content: - continue - delta = content["data"] - chunk = _convert_delta_to_message_chunk(delta, default_chunk_class) - cg_chunk = ChatGenerationChunk(message=chunk) - if run_manager: - run_manager.on_llm_new_token(str(chunk.content), chunk=cg_chunk) - yield cg_chunk - - @staticmethod - def is_cache_model(): - return False + ) -> dict: + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + # 星火 OpenAI 兼容接口使用 max_tokens 字段,而不是 OpenAI 新版的 max_completion_tokens。 + if "max_completion_tokens" in payload and "max_tokens" not in payload: + payload["max_tokens"] = payload.pop("max_completion_tokens") + return payload diff --git a/apps/models_provider/impl/xf_model_provider/model/llm.py b/apps/models_provider/impl/xf_model_provider/model/llm.py index 6eb6c463a52..7788934de7e 100644 --- a/apps/models_provider/impl/xf_model_provider/model/llm.py +++ b/apps/models_provider/impl/xf_model_provider/model/llm.py @@ -1,27 +1,20 @@ # coding=utf-8 """ -@project: maxkb +@project: MaxKB @Author:虎 -@file: __init__.py.py -@date:2024/04/19 15:55 -@desc: +@file: llm.py +@date:2026/11/13 +@desc: Xunfei Spark OpenAI 兼容模型(X2.x 模型走 OpenAI Chat 协议) """ -from typing import List, Optional, Any, Iterator, Dict - -from models_provider.langchain_compat.sparkllm import ( - ChatSparkLLM, - _convert_delta_to_message_chunk, - convert_message_to_dict, -) -from langchain_core.callbacks import CallbackManagerForLLMRun -from langchain_core.messages import BaseMessage, AIMessageChunk -from langchain_core.outputs import ChatGenerationChunk +from typing import Any, Dict +from langchain_core.language_models import LanguageModelInput from models_provider.base_model_provider import MaxKBBaseModel +from models_provider.impl.base_chat_open_ai import BaseChatOpenAI -class XFChatSparkLLM(MaxKBBaseModel, ChatSparkLLM): +class XunFeiOpenAIChatModel(MaxKBBaseModel, BaseChatOpenAI): @staticmethod def is_cache_model(): return False @@ -29,54 +22,22 @@ def is_cache_model(): @staticmethod def new_instance(model_type, model_name, model_credential: Dict[str, object], **model_kwargs): optional_params = MaxKBBaseModel.filter_optional_params(model_kwargs) - return XFChatSparkLLM( - spark_app_id=model_credential.get("spark_app_id"), - spark_api_key=model_credential.get("spark_api_key"), - spark_api_secret=model_credential.get("spark_api_secret"), - spark_api_url=model_credential.get("spark_api_url"), - spark_llm_domain=model_name, - streaming=model_kwargs.get("streaming", False), + return XunFeiOpenAIChatModel( + model=model_name, + openai_api_base=model_credential.get("api_base") or "https://maas-api.cn-huabei-1.xf-yun.com/v2", + openai_api_key=model_credential.get("api_key"), **optional_params, ) - usage_metadata: dict = {} - - def get_last_generation_info(self) -> Optional[Dict[str, Any]]: - return self.usage_metadata - - def get_num_tokens_from_messages(self, messages: List[BaseMessage]) -> int: - return self.usage_metadata.get("prompt_tokens", 0) - - def get_num_tokens(self, text: str) -> int: - return self.usage_metadata.get("completion_tokens", 0) - - def _stream( + def _get_request_payload( self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[CallbackManagerForLLMRun] = None, + input_: LanguageModelInput, + *, + stop: list[str] | None = None, **kwargs: Any, - ) -> Iterator[ChatGenerationChunk]: - default_chunk_class = AIMessageChunk - - self.client.arun( - [convert_message_to_dict(m) for m in messages], - self.spark_user_id, - self.model_kwargs, - True, - ) - for content in self.client.subscribe(timeout=self.request_timeout): - if "data" in content: - delta = content["data"] - chunk = _convert_delta_to_message_chunk(delta, default_chunk_class) - cg_chunk = ChatGenerationChunk(message=chunk) - elif "usage" in content: - generation_info = content["usage"] - self.usage_metadata = generation_info - continue - else: - continue - if cg_chunk is not None: - if run_manager: - run_manager.on_llm_new_token(str(cg_chunk.message.content), chunk=cg_chunk) - yield cg_chunk + ) -> dict: + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + # 星火 OpenAI 兼容接口使用 max_tokens 字段,而不是 OpenAI 新版的 max_completion_tokens。 + if "max_completion_tokens" in payload and "max_tokens" not in payload: + payload["max_tokens"] = payload.pop("max_completion_tokens") + return payload diff --git a/apps/models_provider/impl/xf_model_provider/xf_model_provider.py b/apps/models_provider/impl/xf_model_provider/xf_model_provider.py index 45c0f84238c..509612f1f26 100644 --- a/apps/models_provider/impl/xf_model_provider/xf_model_provider.py +++ b/apps/models_provider/impl/xf_model_provider/xf_model_provider.py @@ -18,9 +18,9 @@ IModelProvider, ModelInfoManage, ) -from models_provider.impl.xf_model_provider.credential.embedding import XFEmbeddingCredential from models_provider.impl.xf_model_provider.credential.image import XunFeiImageModelCredential -from models_provider.impl.xf_model_provider.credential.llm import XunFeiLLMModelCredential +from models_provider.impl.xf_model_provider.credential.embedding import XunFeiOpenAIEmbeddingCredential +from models_provider.impl.xf_model_provider.credential.llm import XunFeiOpenAILLMModelCredential from models_provider.impl.xf_model_provider.credential.stt import XunFeiSTTModelCredential from models_provider.impl.xf_model_provider.credential.tts import XunFeiTTSModelCredential from models_provider.impl.xf_model_provider.credential.tts.super_humanoid_tts import ( @@ -28,8 +28,9 @@ ) from models_provider.impl.xf_model_provider.credential.tts.default_tts import XunFeiDefaultTTSModelCredential from models_provider.impl.xf_model_provider.credential.zh_en_stt import ZhEnXunFeiSTTModelCredential -from models_provider.impl.xf_model_provider.model.embedding import XFEmbedding -from models_provider.impl.xf_model_provider.model.llm import XFChatSparkLLM +from models_provider.impl.xf_model_provider.model.embedding import XunFeiOpenAIEmbeddingModel +from models_provider.impl.xf_model_provider.model.image import XunFeiOpenAIImage +from models_provider.impl.xf_model_provider.model.llm import XunFeiOpenAIChatModel from models_provider.impl.xf_model_provider.model.stt import XFSparkSpeechToText from models_provider.impl.xf_model_provider.model.tts import XFSparkTextToSpeech from models_provider.impl.xf_model_provider.model.tts.super_humanoid_tts import XFSparkSuperHumanoidTextToSpeech @@ -41,7 +42,7 @@ ssl._create_default_https_context = ssl.create_default_context -xunfei_model_credential = XunFeiLLMModelCredential() +xunfei_openai_model_credential = XunFeiOpenAILLMModelCredential() stt_model_credential = XunFeiSTTModelCredential() zh_en_stt_credential = ZhEnXunFeiSTTModelCredential() image_model_credential = XunFeiImageModelCredential() @@ -49,18 +50,17 @@ tts_model_credential = XunFeiTTSModelCredential() super_humanoid_tts_credential = XunFeiSuperHumanoidTTSModelCredential() default_tts_credential = XunFeiDefaultTTSModelCredential() -embedding_model_credential = XFEmbeddingCredential() +embedding_model_credential = XunFeiOpenAIEmbeddingCredential() model_info_list = [ - ModelInfo("generalv3.5", "", ModelTypeConst.LLM, xunfei_model_credential, XFChatSparkLLM), - ModelInfo("generalv3", "", ModelTypeConst.LLM, xunfei_model_credential, XFChatSparkLLM), - ModelInfo("generalv2", "", ModelTypeConst.LLM, xunfei_model_credential, XFChatSparkLLM), + ModelInfo("spark-x2.6", "", ModelTypeConst.LLM, xunfei_openai_model_credential, XunFeiOpenAIChatModel), ModelInfo( "iat", _("Chinese and English recognition"), ModelTypeConst.STT, stt_model_credential, XFSparkSpeechToText ), ModelInfo( "slm", _("Chinese and English recognition"), ModelTypeConst.STT, zh_en_stt_credential, XFZhEnSparkSpeechToText ), + ModelInfo("spark-x2.6", _("Vision Model"), ModelTypeConst.IMAGE, image_model_credential, XunFeiOpenAIImage), # 具体 TTS 模型 ModelInfo("tts", _("Online TTS"), ModelTypeConst.TTS, tts_model_credential, XFSparkTextToSpeech), ModelInfo( @@ -70,26 +70,29 @@ super_humanoid_tts_credential, XFSparkSuperHumanoidTextToSpeech, ), - ModelInfo("embedding", "", ModelTypeConst.EMBEDDING, embedding_model_credential, XFEmbedding), + ModelInfo("embedding", "", ModelTypeConst.EMBEDDING, embedding_model_credential, XunFeiOpenAIEmbeddingModel), ] model_info_manage = ( ModelInfoManage.builder() .append_model_info_list(model_info_list) .append_default_model_info( - ModelInfo("generalv3.5", "", ModelTypeConst.LLM, xunfei_model_credential, XFChatSparkLLM) + ModelInfo("spark-x2.6", "", ModelTypeConst.LLM, xunfei_openai_model_credential, XunFeiOpenAIChatModel) ) .append_default_model_info( ModelInfo( "iat", _("Chinese and English recognition"), ModelTypeConst.STT, stt_model_credential, XFSparkSpeechToText ), ) + .append_default_model_info( + ModelInfo("spark-x2.6", _("Vision Model"), ModelTypeConst.IMAGE, image_model_credential, XunFeiOpenAIImage) + ) # default TTS 工厂入口 .append_default_model_info( ModelInfo("default", _("default"), ModelTypeConst.TTS, default_tts_credential, XFSparkDefaultTextToSpeech) ) .append_default_model_info( - ModelInfo("embedding", "", ModelTypeConst.EMBEDDING, embedding_model_credential, XFEmbedding) + ModelInfo("embedding", "", ModelTypeConst.EMBEDDING, embedding_model_credential, XunFeiOpenAIEmbeddingModel) ) .build() ) diff --git a/apps/models_provider/langchain_compat/__init__.py b/apps/models_provider/langchain_compat/__init__.py deleted file mode 100644 index a2b80dea0f2..00000000000 --- a/apps/models_provider/langchain_compat/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .sparkllm import ( - ChatSparkLLM, - SparkLLMTextEmbeddings, - _convert_delta_to_message_chunk, - convert_message_to_dict, -) - -__all__ = [ - "ChatSparkLLM", - "SparkLLMTextEmbeddings", - "_convert_delta_to_message_chunk", - "convert_message_to_dict", -] diff --git a/apps/models_provider/langchain_compat/sparkllm.py b/apps/models_provider/langchain_compat/sparkllm.py deleted file mode 100644 index 4e93f773d00..00000000000 --- a/apps/models_provider/langchain_compat/sparkllm.py +++ /dev/null @@ -1,513 +0,0 @@ -import base64 -import hashlib -import hmac -import json -import logging -import queue -import threading -from datetime import datetime -from queue import Queue -from time import mktime -from typing import Any, Dict, Generator, Iterator, List, Mapping, Optional, Type, cast -from urllib.parse import urlencode, urlparse, urlunparse -from wsgiref.handlers import format_date_time - -import numpy as np -import requests -from langchain_core.callbacks import CallbackManagerForLLMRun -from langchain_core.embeddings import Embeddings -from langchain_core.language_models.chat_models import BaseChatModel, generate_from_stream -from langchain_core.messages import ( - AIMessage, - AIMessageChunk, - BaseMessage, - BaseMessageChunk, - ChatMessage, - ChatMessageChunk, - FunctionMessageChunk, - HumanMessage, - HumanMessageChunk, - SystemMessage, - ToolMessageChunk, -) -from langchain_core.output_parsers.openai_tools import make_invalid_tool_call, parse_tool_call -from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from langchain_core.utils import get_from_dict_or_env, get_pydantic_field_names -from langchain_core.utils.pydantic import get_fields -from numpy import ndarray -from pydantic import BaseModel, ConfigDict, Field, model_validator - -logger = logging.getLogger(__name__) - -SPARK_API_URL = "wss://spark-api.xf-yun.com/v3.5/chat" -SPARK_LLM_DOMAIN = "generalv3.5" - - -def convert_message_to_dict(message: BaseMessage) -> dict: - message_dict: Dict[str, Any] - if isinstance(message, ChatMessage): - message_dict = {"role": "user", "content": message.content} - elif isinstance(message, HumanMessage): - message_dict = {"role": "user", "content": message.content} - elif isinstance(message, AIMessage): - message_dict = {"role": "assistant", "content": message.content} - if "function_call" in message.additional_kwargs: - message_dict["function_call"] = message.additional_kwargs["function_call"] - if message_dict["content"] == "": - message_dict["content"] = None - if "tool_calls" in message.additional_kwargs: - message_dict["tool_calls"] = message.additional_kwargs["tool_calls"] - if message_dict["content"] == "": - message_dict["content"] = None - elif isinstance(message, SystemMessage): - message_dict = {"role": "system", "content": message.content} - else: - raise ValueError(f"Got unknown type {message}") - return message_dict - - -def convert_dict_to_message(_dict: Mapping[str, Any]) -> BaseMessage: - msg_role = _dict["role"] - msg_content = _dict["content"] - if msg_role == "user": - return HumanMessage(content=msg_content) - if msg_role == "assistant": - invalid_tool_calls = [] - additional_kwargs: Dict[str, Any] = {} - if function_call := _dict.get("function_call"): - additional_kwargs["function_call"] = dict(function_call) - tool_calls = [] - if raw_tool_calls := _dict.get("tool_calls"): - additional_kwargs["tool_calls"] = raw_tool_calls - for raw_tool_call in raw_tool_calls: - try: - tool_calls.append(parse_tool_call(raw_tool_call, return_id=True)) - except Exception as exc: - invalid_tool_calls.append(make_invalid_tool_call(raw_tool_call, str(exc))) - else: - additional_kwargs = {} - return AIMessage( - content=msg_content or "", - additional_kwargs=additional_kwargs, - tool_calls=tool_calls, - invalid_tool_calls=invalid_tool_calls, - ) - if msg_role == "system": - return SystemMessage(content=msg_content) - return ChatMessage(content=msg_content, role=msg_role) - - -def _convert_delta_to_message_chunk( - _dict: Mapping[str, Any], default_class: Type[BaseMessageChunk] -) -> BaseMessageChunk: - msg_role = cast(str, _dict.get("role")) - msg_content = cast(str, _dict.get("content") or "") - additional_kwargs: Dict[str, Any] = {} - if _dict.get("function_call"): - function_call = dict(_dict["function_call"]) - if "name" in function_call and function_call["name"] is None: - function_call["name"] = "" - additional_kwargs["function_call"] = function_call - if _dict.get("tool_calls"): - additional_kwargs["tool_calls"] = _dict["tool_calls"] - if msg_role == "user" or default_class == HumanMessageChunk: - return HumanMessageChunk(content=msg_content) - if msg_role == "assistant" or default_class == AIMessageChunk: - return AIMessageChunk(content=msg_content, additional_kwargs=additional_kwargs) - if msg_role == "function" or default_class == FunctionMessageChunk: - return FunctionMessageChunk(content=msg_content, name=_dict["name"]) - if msg_role == "tool" or default_class == ToolMessageChunk: - return ToolMessageChunk(content=msg_content, tool_call_id=_dict["tool_call_id"]) - if msg_role or default_class == ChatMessageChunk: - return ChatMessageChunk(content=msg_content, role=msg_role) - return default_class(content=msg_content) # type: ignore[call-arg] - - -class ChatSparkLLM(BaseChatModel): - client: Any = None - spark_app_id: Optional[str] = Field(default=None, alias="app_id") - spark_api_key: Optional[str] = Field(default=None, alias="api_key") - spark_api_secret: Optional[str] = Field(default=None, alias="api_secret") - spark_api_url: Optional[str] = Field(default=None, alias="api_url") - spark_llm_domain: Optional[str] = Field(default=None, alias="model") - spark_user_id: str = "lc_user" - streaming: bool = False - request_timeout: int = Field(30, alias="timeout") - temperature: float = 0.5 - top_k: int = 4 - model_kwargs: Dict[str, Any] = Field(default_factory=dict) - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def build_extra(cls, values: Dict[str, Any]) -> Dict[str, Any]: - extra = values.get("model_kwargs", {}) - all_required_field_names = get_pydantic_field_names(cls) - for field_name in list(values): - if field_name in extra: - raise ValueError(f"Found {field_name} supplied twice.") - if field_name not in all_required_field_names: - extra[field_name] = values.pop(field_name) - invalid_model_kwargs = all_required_field_names.intersection(extra.keys()) - if invalid_model_kwargs: - raise ValueError( - f"Parameters {invalid_model_kwargs} should be specified explicitly. " - "Instead they were passed in as part of `model_kwargs` parameter." - ) - values["model_kwargs"] = extra - return values - - @model_validator(mode="before") - @classmethod - def validate_environment(cls, values: Dict[str, Any]) -> Dict[str, Any]: - values["spark_app_id"] = get_from_dict_or_env(values, ["spark_app_id", "app_id"], "IFLYTEK_SPARK_APP_ID") - values["spark_api_key"] = get_from_dict_or_env( - values, ["spark_api_key", "api_key"], "IFLYTEK_SPARK_API_KEY" - ) - values["spark_api_secret"] = get_from_dict_or_env( - values, ["spark_api_secret", "api_secret"], "IFLYTEK_SPARK_API_SECRET" - ) - values["spark_api_url"] = get_from_dict_or_env( - values, "spark_api_url", "IFLYTEK_SPARK_API_URL", SPARK_API_URL - ) - values["spark_llm_domain"] = get_from_dict_or_env( - values, "spark_llm_domain", "IFLYTEK_SPARK_LLM_DOMAIN", SPARK_LLM_DOMAIN - ) - - model_kwargs = values.setdefault("model_kwargs", {}) - field_values = {name: field.default for name, field in get_fields(cls).items() if field.default is not None} - field_values.update(values) - model_kwargs["temperature"] = field_values.get("temperature") - model_kwargs["top_k"] = field_values.get("top_k") - - values["client"] = _SparkLLMClient( - app_id=values["spark_app_id"], - api_key=values["spark_api_key"], - api_secret=values["spark_api_secret"], - api_url=values["spark_api_url"], - spark_domain=values["spark_llm_domain"], - model_kwargs=model_kwargs, - ) - return values - - def _stream( - self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[CallbackManagerForLLMRun] = None, - **kwargs: Any, - ) -> Iterator[ChatGenerationChunk]: - default_chunk_class = AIMessageChunk - self.client.arun( - [convert_message_to_dict(message) for message in messages], - self.spark_user_id, - self.model_kwargs, - streaming=True, - ) - for content in self.client.subscribe(timeout=self.request_timeout): - if "data" not in content: - continue - delta = content["data"] - generation_info = {} - if "reasoning_content" in delta: - generation_info["reasoning_content"] = delta.pop("reasoning_content") - chunk = _convert_delta_to_message_chunk(delta, default_chunk_class) - generation_chunk = ChatGenerationChunk(message=chunk, generation_info=generation_info or None) - if run_manager: - run_manager.on_llm_new_token(str(chunk.content), chunk=generation_chunk) - yield generation_chunk - - def _generate( - self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[CallbackManagerForLLMRun] = None, - stream: Optional[bool] = None, - **kwargs: Any, - ) -> ChatResult: - if stream or self.streaming: - return generate_from_stream(self._stream(messages=messages, stop=stop, run_manager=run_manager, **kwargs)) - - self.client.arun( - [convert_message_to_dict(message) for message in messages], - self.spark_user_id, - self.model_kwargs, - False, - ) - completion: Dict[str, Any] = {} - llm_output: Dict[str, Any] = {} - for content in self.client.subscribe(timeout=self.request_timeout): - if "usage" in content: - llm_output["token_usage"] = content["usage"] - if "data" in content: - completion = content["data"] - - generation_info = {} - if "reasoning_content" in completion: - generation_info["reasoning_content"] = completion.pop("reasoning_content") - return ChatResult( - generations=[ChatGeneration(message=convert_dict_to_message(completion), generation_info=generation_info or None)], - llm_output=llm_output, - ) - - @property - def _llm_type(self) -> str: - return "spark-llm-chat" - - -class _SparkLLMClient: - def __init__( - self, - app_id: str, - api_key: str, - api_secret: str, - api_url: Optional[str] = None, - spark_domain: Optional[str] = None, - model_kwargs: Optional[dict] = None, - ): - import websocket - - self.websocket_client = websocket - self.api_url = api_url or SPARK_API_URL - self.app_id = app_id - self.model_kwargs = model_kwargs - self.spark_domain = spark_domain or SPARK_LLM_DOMAIN - self.queue: Queue[Dict[str, Any]] = Queue() - self.blocking_message = {"content": "", "role": "assistant"} - self.api_key = api_key - self.api_secret = api_secret - - @staticmethod - def _create_url(api_url: str, api_key: str, api_secret: str) -> str: - date = format_date_time(mktime(datetime.now().timetuple())) - parsed_url = urlparse(api_url) - host = parsed_url.netloc - path = parsed_url.path - signature_origin = f"host: {host}\ndate: {date}\nGET {path} HTTP/1.1" - signature_sha = hmac.new( - api_secret.encode("utf-8"), - signature_origin.encode("utf-8"), - digestmod=hashlib.sha256, - ).digest() - signature_sha_base64 = base64.b64encode(signature_sha).decode(encoding="utf-8") - authorization_origin = ( - f'api_key="{api_key}", algorithm="hmac-sha256", headers="host date request-line", ' - f'signature="{signature_sha_base64}"' - ) - authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode(encoding="utf-8") - return urlunparse( - ( - parsed_url.scheme, - parsed_url.netloc, - parsed_url.path, - parsed_url.params, - urlencode({"authorization": authorization, "date": date, "host": host}), - parsed_url.fragment, - ) - ) - - def run( - self, - messages: List[Dict[str, Any]], - user_id: str, - model_kwargs: Optional[dict] = None, - streaming: bool = False, - ) -> None: - self.websocket_client.enableTrace(False) - ws = self.websocket_client.WebSocketApp( - self._create_url(self.api_url, self.api_key, self.api_secret), - on_message=self.on_message, - on_error=self.on_error, - on_close=self.on_close, - on_open=self.on_open, - ) - ws.messages = messages # type: ignore[attr-defined] - ws.user_id = user_id # type: ignore[attr-defined] - ws.model_kwargs = self.model_kwargs if model_kwargs is None else model_kwargs # type: ignore[attr-defined] - ws.streaming = streaming # type: ignore[attr-defined] - ws.run_forever() - - def arun( - self, - messages: List[Dict[str, Any]], - user_id: str, - model_kwargs: Optional[dict] = None, - streaming: bool = False, - ) -> threading.Thread: - thread = threading.Thread(target=self.run, args=(messages, user_id, model_kwargs, streaming)) - thread.start() - return thread - - def on_error(self, ws: Any, error: Optional[Any]) -> None: - self.queue.put({"error": error}) - ws.close() - - def on_close(self, ws: Any, close_status_code: int, close_reason: str) -> None: - logger.debug({"close_status_code": close_status_code, "close_reason": close_reason}) - self.queue.put({"done": True}) - - def on_open(self, ws: Any) -> None: - self.blocking_message = {"content": "", "role": "assistant"} - ws.send(json.dumps(self.gen_params(messages=ws.messages, user_id=ws.user_id, model_kwargs=ws.model_kwargs))) - - def on_message(self, ws: Any, message: str) -> None: - data = json.loads(message) - code = data["header"]["code"] - if code != 0: - self.queue.put({"error": f"Code: {code}, Error: {data['header']['message']}"}) - ws.close() - return - - choices = data["payload"]["choices"] - status = choices["status"] - text_chunk = choices["text"][0] - content = text_chunk.get("content", "") - if ws.streaming: - self.queue.put({"data": text_chunk}) - else: - self.blocking_message["content"] += content - if "reasoning_content" in text_chunk: - self.blocking_message["reasoning_content"] = text_chunk["reasoning_content"] - if status == 2: - if not ws.streaming: - self.queue.put({"data": self.blocking_message}) - usage_data = data.get("payload", {}).get("usage", {}).get("text", {}) - self.queue.put({"usage": usage_data}) - ws.close() - - def gen_params( - self, messages: List[Dict[str, Any]], user_id: str, model_kwargs: Optional[dict] = None - ) -> Dict[str, Any]: - data: Dict[str, Any] = { - "header": {"app_id": self.app_id, "uid": user_id}, - "parameter": {"chat": {"domain": self.spark_domain}}, - "payload": {"message": {"text": messages}}, - } - if model_kwargs: - data["parameter"]["chat"].update(model_kwargs) - return data - - def subscribe(self, timeout: Optional[int] = 30) -> Generator[Dict[str, Any], None, None]: - while True: - try: - content = self.queue.get(timeout=timeout) - except queue.Empty as exc: - raise TimeoutError(f"SparkLLMClient wait LLM api response timeout {timeout} seconds") from exc - if "error" in content: - raise ConnectionError(content["error"]) - if "usage" in content: - yield content - continue - if "done" in content or "data" not in content: - break - yield content - - -class Url: - def __init__(self, host: str, path: str, schema: str) -> None: - self.host = host - self.path = path - self.schema = schema - - -class SparkLLMTextEmbeddings(BaseModel, Embeddings): - spark_app_id: Optional[str] = Field(default=None, alias="app_id") - spark_api_key: Optional[str] = Field(default=None, alias="api_key") - spark_api_secret: Optional[str] = Field(default=None, alias="api_secret") - base_url: str = "https://emb-cn-huabei-1.xf-yun.com/" - domain: str = "para" - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def validate_environment(cls, values: Dict[str, Any]) -> Dict[str, Any]: - values["spark_app_id"] = get_from_dict_or_env(values, ["spark_app_id", "app_id"], "SPARK_APP_ID") - values["spark_api_key"] = get_from_dict_or_env(values, ["spark_api_key", "api_key"], "SPARK_API_KEY") - values["spark_api_secret"] = get_from_dict_or_env( - values, ["spark_api_secret", "api_secret"], "SPARK_API_SECRET" - ) - return values - - def _embed(self, texts: List[str], host: str) -> List[List[float]]: - url = self._assemble_ws_auth_url( - request_url=host, - method="POST", - api_key=self.spark_api_key or "", - api_secret=self.spark_api_secret or "", - ) - embedding_result: List[List[float]] = [] - for text in texts: - response = requests.post( - url, - json=self._get_body(self.spark_app_id or "", {"messages": [{"content": text, "role": "user"}]}), - headers={"content-type": "application/json"}, - timeout=30, - ) - response.raise_for_status() - parsed = self._parser_message(response.text) - if parsed is None: - raise ValueError("Failed to parse Spark embedding response") - embedding_result.append(parsed.tolist()) - return embedding_result - - def embed_documents(self, texts: List[str]) -> List[List[float]]: - return self._embed(texts, self.base_url) - - def embed_query(self, text: str) -> List[float]: - return self._embed([text], self.base_url)[0] - - @staticmethod - def _assemble_ws_auth_url( - request_url: str, method: str = "GET", api_key: str = "", api_secret: str = "" - ) -> str: - url = SparkLLMTextEmbeddings._parse_url(request_url) - date = format_date_time(mktime(datetime.now().timetuple())) - signature_origin = f"host: {url.host}\ndate: {date}\n{method} {url.path} HTTP/1.1" - signature_sha = hmac.new( - api_secret.encode("utf-8"), - signature_origin.encode("utf-8"), - digestmod=hashlib.sha256, - ).digest() - signature_sha_str = base64.b64encode(signature_sha).decode(encoding="utf-8") - authorization_origin = ( - 'api_key="%s", algorithm="%s", headers="%s", signature="%s"' - % (api_key, "hmac-sha256", "host date request-line", signature_sha_str) - ) - authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode(encoding="utf-8") - return request_url + "?" + urlencode({"host": url.host, "date": date, "authorization": authorization}) - - @staticmethod - def _parse_url(request_url: str) -> Url: - stidx = request_url.index("://") - host = request_url[stidx + 3 :] - schema = request_url[: stidx + 3] - edidx = host.index("/") - if edidx <= 0: - raise AssembleHeaderException("invalid request url:" + request_url) - return Url(host[:edidx], host[edidx:], schema) - - def _get_body(self, appid: str, text: dict) -> Dict[str, Any]: - return { - "header": {"app_id": appid, "uid": "39769795890", "status": 3}, - "parameter": {"emb": {"domain": self.domain, "feature": {"encoding": "utf8"}}}, - "payload": {"messages": {"text": base64.b64encode(json.dumps(text).encode("utf-8")).decode()}}, - } - - @staticmethod - def _parser_message(message: str) -> Optional[ndarray]: - data = json.loads(message) - code = data["header"]["code"] - if code != 0: - logger.warning("Request error: %s, %s", code, data) - return None - text_data = base64.b64decode(data["payload"]["feature"]["text"]) - float_dtype = np.dtype(np.float32).newbyteorder("<") - text = np.frombuffer(text_data, dtype=float_dtype) - return text[:2560] if len(text) > 2560 else text - - -class AssembleHeaderException(Exception): - def __init__(self, msg: str) -> None: - self.message = msg diff --git a/apps/models_provider/tests/test_xunfei_openai_embedding.py b/apps/models_provider/tests/test_xunfei_openai_embedding.py new file mode 100644 index 00000000000..5c15d922c1a --- /dev/null +++ b/apps/models_provider/tests/test_xunfei_openai_embedding.py @@ -0,0 +1,47 @@ +# coding=utf-8 +from django.test import SimpleTestCase + +from common.exception.app_exception import AppApiException +from models_provider.impl.xf_model_provider.credential.embedding import XunFeiOpenAIEmbeddingCredential +from models_provider.impl.xf_model_provider.model.embedding import XunFeiOpenAIEmbeddingModel + + +class _FakeProvider: + def get_model_type_list(self): + return [{"key": "Embedding Model", "value": "EMBEDDING"}] + + +class XunfeiOpenAIEmbeddingTests(SimpleTestCase): + def _credential(self): + return { + "api_base": "https://maas-api.cn-huabei-1.xf-yun.com/v2", + "api_key": "ak-4ca28ba5xxxxxxxxxxxxxxxx", + "model_id": "sde0a5839", + } + + def test_new_instance_uses_model_id_as_model_name(self): + model = XunFeiOpenAIEmbeddingModel.new_instance("EMBEDDING", "embedding", self._credential()) + self.assertEqual(model.model_name, "sde0a5839") + self.assertIsNotNone(model.client) + + def test_new_instance_falls_back_to_model_name_without_model_id(self): + credential = {k: v for k, v in self._credential().items() if k != "model_id"} + model = XunFeiOpenAIEmbeddingModel.new_instance("EMBEDDING", "embedding", credential) + self.assertEqual(model.model_name, "embedding") + + def test_credential_default_api_base(self): + credential = XunFeiOpenAIEmbeddingCredential() + data = credential.get_default_form_data() + self.assertEqual(data["api_base"], "https://maas-api.cn-huabei-1.xf-yun.com/v2") + + def test_credential_requires_model_id(self): + credential = XunFeiOpenAIEmbeddingCredential() + with self.assertRaises(AppApiException): + credential.is_valid( + "EMBEDDING", + "embedding", + {"api_base": "https://maas-api.cn-huabei-1.xf-yun.com/v2", "api_key": "ak-xxx"}, + {}, + _FakeProvider(), + raise_exception=True, + ) diff --git a/apps/models_provider/tests/test_xunfei_openai_image.py b/apps/models_provider/tests/test_xunfei_openai_image.py new file mode 100644 index 00000000000..0705d23499f --- /dev/null +++ b/apps/models_provider/tests/test_xunfei_openai_image.py @@ -0,0 +1,52 @@ +# coding=utf-8 +from django.test import SimpleTestCase + +from models_provider.impl.xf_model_provider.credential.image import XunFeiImageModelCredential +from models_provider.impl.xf_model_provider.model.image import XunFeiOpenAIImage + + +class XunfeiOpenAIImageTests(SimpleTestCase): + def _credential(self): + return { + "api_base": "https://maas-api.cn-huabei-1.xf-yun.com/v2", + "api_key": "ak-4ca28ba5xxxxxxxxxxxxxxxx", + } + + def test_new_instance_accepts_api_base_key(self): + model = XunFeiOpenAIImage.new_instance( + "IMAGE", + "spark-x2.6", + self._credential(), + temperature=0.5, + max_tokens=4096, + streaming=True, + ) + + self.assertEqual(model.openai_api_base, "https://maas-api.cn-huabei-1.xf-yun.com/v2") + self.assertEqual(model.model_name, "spark-x2.6") + self.assertEqual(model.temperature, 0.5) + self.assertEqual(model.max_tokens, 4096) + + def test_payload_uses_max_tokens(self): + model = XunFeiOpenAIImage.new_instance( + "IMAGE", + "spark-x2.6", + self._credential(), + temperature=0.5, + max_tokens=4096, + streaming=True, + ) + payload = model._get_request_payload(model._convert_input([{"role": "user", "content": "hi"}])) + self.assertEqual(payload["max_tokens"], 4096) + self.assertNotIn("max_completion_tokens", payload) + + def test_credential_default_api_base(self): + credential = XunFeiImageModelCredential() + data = credential.get_default_form_data() + self.assertEqual(data["api_base"], "https://maas-api.cn-huabei-1.xf-yun.com/v2") + + def test_params_setting_form_returns_params(self): + credential = XunFeiImageModelCredential() + form = credential.get_model_params_setting_form("spark-x2.6") + self.assertEqual(form.temperature.default_value, 0.5) + self.assertEqual(form.max_tokens.default_value, 4096) diff --git a/apps/models_provider/tests/test_xunfei_openai_llm.py b/apps/models_provider/tests/test_xunfei_openai_llm.py new file mode 100644 index 00000000000..43cd9ec142d --- /dev/null +++ b/apps/models_provider/tests/test_xunfei_openai_llm.py @@ -0,0 +1,58 @@ +# coding=utf-8 +from django.test import SimpleTestCase + +from models_provider.impl.xf_model_provider.credential.llm import XunFeiOpenAILLMModelCredential +from models_provider.impl.xf_model_provider.model.llm import XunFeiOpenAIChatModel + + +class XunfeiOpenAILLMModelTests(SimpleTestCase): + def _credential(self): + return { + "api_base": "https://maas-api.cn-huabei-1.xf-yun.com/v2", + "api_key": "ak-4ca28ba5xxxxxxxxxxxxxxxx", + } + + def test_new_instance_accepts_api_base_key_and_params(self): + model = XunFeiOpenAIChatModel.new_instance( + "LLM", + "spark-x2.6", + self._credential(), + temperature=0.5, + max_tokens=4096, + streaming=False, + ) + + self.assertEqual(model.openai_api_base, "https://maas-api.cn-huabei-1.xf-yun.com/v2") + self.assertEqual(model.model_name, "spark-x2.6") + self.assertEqual(model.temperature, 0.5) + self.assertEqual(model.max_tokens, 4096) + + def test_new_instance_does_not_duplicate_temperature(self): + model = XunFeiOpenAIChatModel.new_instance( + "LLM", + "spark-x2.6", + self._credential(), + temperature=0.5, + max_tokens=4096, + streaming=False, + ) + + # 走标准 ChatOpenAI 字段,不应再出现重复的 temperature 键。 + self.assertEqual(model.temperature, 0.5) + self.assertEqual(model.max_tokens, 4096) + self.assertEqual(model.model_kwargs.get("temperature"), None) + # 请求体中 temperature/max_tokens 只出现一次,不会重复注入。 + payload = model._get_request_payload(model._convert_input([{"role": "user", "content": "hi"}])) + self.assertEqual(payload["temperature"], 0.5) + self.assertEqual(payload["max_tokens"], 4096) + + def test_credential_default_api_base(self): + credential = XunFeiOpenAILLMModelCredential() + data = credential.get_default_form_data() + self.assertEqual(data["api_base"], "https://maas-api.cn-huabei-1.xf-yun.com/v2") + + def test_params_setting_form_returns_params(self): + credential = XunFeiOpenAILLMModelCredential() + form = credential.get_model_params_setting_form("spark-x2.6") + self.assertEqual(form.temperature.default_value, 0.5) + self.assertEqual(form.max_tokens.default_value, 4096)