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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions apps/models_provider/impl/xf_model_provider/credential/embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,22 +18,28 @@
from common.utils.logger import maxkb_logger


class XFEmbeddingCredential(BaseForm, BaseModelCredential):
class XunFeiOpenAIEmbeddingCredential(BaseForm, BaseModelCredential):
def is_valid(
self,
model_type: str,
model_name,
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"))
Expand All @@ -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)
73 changes: 50 additions & 23 deletions apps/models_provider/impl/xf_model_provider/credential/image.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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):
Expand All @@ -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()
51 changes: 11 additions & 40 deletions apps/models_provider/impl/xf_model_provider/credential/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +19,7 @@
from common.utils.logger import maxkb_logger


class XunFeiLLMModelGeneralParams(BaseForm):
class XunFeiOpenAILLMModelParams(BaseForm):
temperature = forms.SliderField(
TooltipLabel(
_("Temperature"),
Expand All @@ -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,
Expand All @@ -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))
Expand All @@ -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()
98 changes: 26 additions & 72 deletions apps/models_provider/impl/xf_model_provider/model/embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of embedding.py diff

1. Functional bugs / regressions

a) optional_params are stored but never used — self.optional_params is set in __init__ and then ignored in embed_documents. Any user-configured OpenAI-compatible options (dimensions, user, encoding_format, timeout, etc.) silently have no effect. Merge them into the request:

res = self.client.create(input=texts, model=self.model_name, **self.optional_params)

b) chunk_size is accepted but ignored — Neither the caller-supplied chunk_size nor any default batching is applied. The OpenAI embeddings API caps inputs per request (2,048 items) and total tokens per request; large MaxKB document imports will fail. Batch it.

c) Response ordering is not guaranteed — The OpenAI schema includes index per item, and some compatible providers return data out of order. [e.embedding for e in res.data] can silently misalign embeddings with texts. Sort by e.index, and validate len(res.data) == len(texts).

d) No handling of empty input — embed_documents([]) goes straight to the API and typically errors. Return [] early.

e) Credentials are not validated — api_base may be None/"" and api_key may be missing; openai.OpenAI(...) will either fall back to the public OpenAI endpoint (unintended) or fail with an opaque error. Validate in new_instance.

f) Return type changed — The old implementation returned np.ndarray (float32, little-endian) for embed_query/_parser_message; the new one returns List[float]. Verify every downstream consumer (vector store, schema dimension check, cast calls) accepts plain lists — otherwise you get silent type errors at runtime.

g) Output dimensionality — The old code truncated to 2560 dimensions. There is no equivalent now: if dimensions isn't passed (or model_id differs), you can get a different vector length than the DB column expects. Pass dimensions via optional_params or validate length.

h) Class rename — XFEmbedding → XunFeiOpenAIEmbeddingModel. Any imports/registries referencing the old name will break. Confirm new_instance is wired to the correct provider/model type, and note that model_credential.get("model_id") or model_name changes the model-selection precedence versus the old code.

2. Robustness / reliability

i) No error context — Raw openai exceptions propagate. The old code retried 3× with backoff; the OpenAI SDK retries automatically (max_retries, default 2) but does not handle rate-limit-specific logic for this provider. Set explicit timeout and max_retries and wrap/log errors.

j) QPS throttling removed — The old worker enforced time.sleep(0.5) (QPS=2). That serialized requests globally (a bottleneck, but also a guard). Without it, concurrent embed_documents calls can blow through the provider's rate limits. Consider an asyncio.Semaphore/threading.Semaphore or batching instead of per-call throttling.

k) Client lifetime — openai.OpenAI(...) owns an httpx client. Instantiating one per new_instance call (if called per request) creates and leaks connection pools. Keep one client per model instance and ensure close()/context-manager use, or cache instances.

l) encoding_format="float" — Fine as an explicit default, but some OpenAI-compatible gateways reject it or return base64 despite the request. Make it a default in optional_params rather than hard-coded, and defensively handle base64 responses if you support them.

3. Style / typing

m) Inconsistent typing — from typing import Dict, List combined with int | None (PEP 604, Python ≥3.10). Pick one: either list[str] / int | None (and drop typing.List), or Optional[int]. Make sure the declared Python version matches.

n) embed_query lacks a return annotation, and self.client = ...embeddings stores a resource rather than the client; naming it self._embeddings better reflects that.

4. Suggested implementation

from typing import Dict, List, Optional
import openai

class XunFeiOpenAIEmbeddingModel(MaxKBBaseEmbeddingModel):
    def supports_image_embedding(self) -> bool:
        return False

    def is_cache_model(self) -> bool:
        return False

    def __init__(self, api_key, base_url, model_name: str,
                 optional_params: dict, chunk_size: int = 16,
                 timeout: float = 60.0, max_retries: int = 3):
        self._embeddings = openai.OpenAI(
            api_key=api_key, base_url=base_url or None,
            timeout=timeout, max_retries=max_retries,
        ).embeddings
        self.model_name = model_name
        self.optional_params = dict(optional_params or {})
        self.optional_params.setdefault("encoding_format", "float")
        self.chunk_size = chunk_size

    @staticmethod
    def new_instance(model_type, model_name, model_credential: Dict[str, object], **model_kwargs):
        api_key = model_credential.get("api_key")
        base_url = model_credential.get("api_base")
        if not api_key or not base_url:
            raise ValueError("XunFei OpenAI-compatible embedding requires 'api_key' and 'api_base'")
        return XunFeiOpenAIEmbeddingModel(
            api_key=api_key,
            base_url=base_url,
            model_name=model_credential.get("model_id") or model_name,
            optional_params=MaxKBBaseEmbeddingModel.filter_optional_params(model_kwargs),
        )

    def embed_query(self, text: str) -> List[float]:
        return self.embed_documents([text])[0]

    def embed_documents(self, texts: List[str], chunk_size: Optional[int] = None) -> List[List[float]]:
        if not texts:
            return []
        size = chunk_size or self.chunk_size
        vectors: List[List[float]] = []
        for i in range(0, len(texts), size):
            batch = texts[i:i + size]
            res = self._embeddings.create(
                input=batch, model=self.model_name, **self.optional_params
            )
            data = sorted(res.data, key=lambda d: d.index)
            if len(data) != len(batch):
                raise ValueError(
                    f"Embedding count mismatch: sent {len(batch)}, got {len(data)}"
                )
            vectors.extend(d.embedding for d in data)
        return vectors

5. Checklist before merging

  • Confirm maxkb base class contract for return type (list vs ndarray) and chunk_size.
  • Verify vector dimension matches the DB column (dimensions param if applicable).
  • Update provider registry/imports for the renamed class.
  • Add a unit test for batching, ordering, and empty input.

Loading
Loading