mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-03-19 21:38:18 +08:00
feat: support qwen3.5-plus
This commit is contained in:
@@ -10,6 +10,7 @@ from config import conf, load_config
|
||||
from .dashscope_session import DashscopeSession
|
||||
import os
|
||||
import dashscope
|
||||
from dashscope import MultiModalConversation
|
||||
from http import HTTPStatus
|
||||
|
||||
|
||||
@@ -23,6 +24,11 @@ dashscope_models = {
|
||||
"qwen-bailian-v1": dashscope.Generation.Models.bailian_v1,
|
||||
}
|
||||
|
||||
# Model name prefixes that require MultiModalConversation API instead of Generation API.
|
||||
# Qwen3.5+ series are omni models that only support MultiModalConversation.
|
||||
MULTIMODAL_MODEL_PREFIXES = ("qwen3.5-",)
|
||||
|
||||
|
||||
# Qwen对话模型API
|
||||
class DashscopeBot(Bot):
|
||||
def __init__(self):
|
||||
@@ -34,6 +40,11 @@ class DashscopeBot(Bot):
|
||||
os.environ["DASHSCOPE_API_KEY"] = self.api_key
|
||||
self.client = dashscope.Generation
|
||||
|
||||
@staticmethod
|
||||
def _is_multimodal_model(model_name: str) -> bool:
|
||||
"""Check if the model requires MultiModalConversation API"""
|
||||
return model_name.startswith(MULTIMODAL_MODEL_PREFIXES)
|
||||
|
||||
def reply(self, query, context=None):
|
||||
# acquire reply content
|
||||
if context.type == ContextType.TEXT:
|
||||
@@ -88,16 +99,33 @@ class DashscopeBot(Bot):
|
||||
"""
|
||||
try:
|
||||
dashscope.api_key = self.api_key
|
||||
response = self.client.call(
|
||||
dashscope_models.get(self.model_name, self.model_name),
|
||||
messages=session.messages,
|
||||
result_format="message"
|
||||
)
|
||||
model = dashscope_models.get(self.model_name, self.model_name)
|
||||
if self._is_multimodal_model(self.model_name):
|
||||
mm_messages = self._prepare_messages_for_multimodal(session.messages)
|
||||
response = MultiModalConversation.call(
|
||||
model=model,
|
||||
messages=mm_messages,
|
||||
result_format="message"
|
||||
)
|
||||
else:
|
||||
response = self.client.call(
|
||||
model,
|
||||
messages=session.messages,
|
||||
result_format="message"
|
||||
)
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
content = response.output.choices[0]["message"]["content"]
|
||||
resp_dict = self._response_to_dict(response)
|
||||
choice = resp_dict["output"]["choices"][0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
# Multimodal models may return content as a list of blocks
|
||||
if isinstance(content, list):
|
||||
content = "".join(
|
||||
item.get("text", "") for item in content if isinstance(item, dict)
|
||||
)
|
||||
usage = resp_dict.get("usage", {})
|
||||
return {
|
||||
"total_tokens": response.usage["total_tokens"],
|
||||
"completion_tokens": response.usage["output_tokens"],
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
"completion_tokens": usage.get("output_tokens", 0),
|
||||
"content": content,
|
||||
}
|
||||
else:
|
||||
@@ -227,36 +255,54 @@ class DashscopeBot(Bot):
|
||||
try:
|
||||
# Set API key before calling
|
||||
dashscope.api_key = self.api_key
|
||||
|
||||
response = dashscope.Generation.call(
|
||||
model=dashscope_models.get(model_name, model_name),
|
||||
messages=messages,
|
||||
**parameters
|
||||
)
|
||||
|
||||
model = dashscope_models.get(model_name, model_name)
|
||||
|
||||
if self._is_multimodal_model(model_name):
|
||||
messages = self._prepare_messages_for_multimodal(messages)
|
||||
response = MultiModalConversation.call(
|
||||
model=model,
|
||||
messages=messages,
|
||||
**parameters
|
||||
)
|
||||
else:
|
||||
response = dashscope.Generation.call(
|
||||
model=model,
|
||||
messages=messages,
|
||||
**parameters
|
||||
)
|
||||
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
# Convert DashScope response to OpenAI-compatible format
|
||||
choice = response.output.choices[0]
|
||||
# Convert response to dict to avoid DashScope object KeyError issues
|
||||
resp_dict = self._response_to_dict(response)
|
||||
choice = resp_dict["output"]["choices"][0]
|
||||
message = choice.get("message", {})
|
||||
content = message.get("content", "")
|
||||
# Multimodal models may return content as a list of blocks
|
||||
if isinstance(content, list):
|
||||
content = "".join(
|
||||
item.get("text", "") for item in content if isinstance(item, dict)
|
||||
)
|
||||
usage = resp_dict.get("usage", {})
|
||||
return {
|
||||
"id": response.request_id,
|
||||
"id": resp_dict.get("request_id"),
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": model_name,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": choice.message.role,
|
||||
"content": choice.message.content,
|
||||
"role": message.get("role", "assistant"),
|
||||
"content": content,
|
||||
"tool_calls": self._convert_tool_calls_to_openai_format(
|
||||
choice.message.get("tool_calls")
|
||||
message.get("tool_calls")
|
||||
)
|
||||
},
|
||||
"finish_reason": choice.finish_reason
|
||||
"finish_reason": choice.get("finish_reason")
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": response.usage.input_tokens,
|
||||
"completion_tokens": response.usage.output_tokens,
|
||||
"total_tokens": response.usage.total_tokens
|
||||
"prompt_tokens": usage.get("input_tokens", 0),
|
||||
"completion_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0)
|
||||
}
|
||||
}
|
||||
else:
|
||||
@@ -266,7 +312,7 @@ class DashscopeBot(Bot):
|
||||
"message": response.message,
|
||||
"status_code": response.status_code
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DASHSCOPE] sync response error: {e}")
|
||||
return {
|
||||
@@ -280,48 +326,52 @@ class DashscopeBot(Bot):
|
||||
try:
|
||||
# Set API key before calling
|
||||
dashscope.api_key = self.api_key
|
||||
|
||||
responses = dashscope.Generation.call(
|
||||
model=dashscope_models.get(model_name, model_name),
|
||||
messages=messages,
|
||||
stream=True,
|
||||
**parameters
|
||||
)
|
||||
model = dashscope_models.get(model_name, model_name)
|
||||
|
||||
if self._is_multimodal_model(model_name):
|
||||
messages = self._prepare_messages_for_multimodal(messages)
|
||||
responses = MultiModalConversation.call(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
**parameters
|
||||
)
|
||||
else:
|
||||
responses = dashscope.Generation.call(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
**parameters
|
||||
)
|
||||
|
||||
# Stream chunks to caller, converting to OpenAI format
|
||||
for response in responses:
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
logger.error(f"[DASHSCOPE] Stream error: {response.code} - {response.message}")
|
||||
# Convert to dict first to avoid DashScope proxy object KeyError
|
||||
resp_dict = self._response_to_dict(response)
|
||||
status_code = resp_dict.get("status_code", 200)
|
||||
|
||||
if status_code != HTTPStatus.OK:
|
||||
err_code = resp_dict.get("code", "")
|
||||
err_msg = resp_dict.get("message", "Unknown error")
|
||||
logger.error(f"[DASHSCOPE] Stream error: {err_code} - {err_msg}")
|
||||
yield {
|
||||
"error": True,
|
||||
"message": response.message,
|
||||
"status_code": response.status_code
|
||||
"message": err_msg,
|
||||
"status_code": status_code
|
||||
}
|
||||
continue
|
||||
|
||||
# Get choice - use try-except because DashScope raises KeyError on hasattr()
|
||||
try:
|
||||
if isinstance(response.output, dict):
|
||||
choice = response.output['choices'][0]
|
||||
else:
|
||||
choice = response.output.choices[0]
|
||||
except (KeyError, AttributeError, IndexError) as e:
|
||||
logger.warning(f"[DASHSCOPE] Cannot get choice: {e}")
|
||||
|
||||
choices = resp_dict.get("output", {}).get("choices", [])
|
||||
if not choices:
|
||||
continue
|
||||
|
||||
# Get finish_reason safely
|
||||
finish_reason = None
|
||||
try:
|
||||
if isinstance(choice, dict):
|
||||
finish_reason = choice.get('finish_reason')
|
||||
else:
|
||||
finish_reason = choice.finish_reason
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
choice = choices[0]
|
||||
finish_reason = choice.get("finish_reason")
|
||||
message = choice.get("message", {})
|
||||
|
||||
# Convert to OpenAI-compatible format
|
||||
openai_chunk = {
|
||||
"id": response.request_id,
|
||||
"id": resp_dict.get("request_id"),
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": model_name,
|
||||
@@ -331,66 +381,90 @@ class DashscopeBot(Bot):
|
||||
"finish_reason": finish_reason
|
||||
}]
|
||||
}
|
||||
|
||||
# Get message safely - use try-except
|
||||
message = {}
|
||||
try:
|
||||
if isinstance(choice, dict):
|
||||
message = choice.get('message', {})
|
||||
else:
|
||||
message = choice.message
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
# Add role if present
|
||||
role = None
|
||||
try:
|
||||
if isinstance(message, dict):
|
||||
role = message.get('role')
|
||||
else:
|
||||
role = message.role
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
# Add role
|
||||
role = message.get("role")
|
||||
if role:
|
||||
openai_chunk["choices"][0]["delta"]["role"] = role
|
||||
|
||||
# Add content if present
|
||||
content = None
|
||||
try:
|
||||
if isinstance(message, dict):
|
||||
content = message.get('content')
|
||||
else:
|
||||
content = message.content
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
# Add reasoning_content (thinking process from models like qwen3.5)
|
||||
reasoning_content = message.get("reasoning_content")
|
||||
if reasoning_content:
|
||||
openai_chunk["choices"][0]["delta"]["reasoning_content"] = reasoning_content
|
||||
|
||||
# Add content (multimodal models may return list of blocks)
|
||||
content = message.get("content")
|
||||
if isinstance(content, list):
|
||||
content = "".join(
|
||||
item.get("text", "") for item in content if isinstance(item, dict)
|
||||
)
|
||||
if content:
|
||||
openai_chunk["choices"][0]["delta"]["content"] = content
|
||||
|
||||
# Add tool_calls if present
|
||||
# DashScope's response object raises KeyError on hasattr() if attr doesn't exist
|
||||
# So we use try-except instead
|
||||
tool_calls = None
|
||||
try:
|
||||
if isinstance(message, dict):
|
||||
tool_calls = message.get('tool_calls')
|
||||
else:
|
||||
tool_calls = message.tool_calls
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
# Add tool_calls
|
||||
tool_calls = message.get("tool_calls")
|
||||
if tool_calls:
|
||||
openai_chunk["choices"][0]["delta"]["tool_calls"] = self._convert_tool_calls_to_openai_format(tool_calls)
|
||||
|
||||
|
||||
yield openai_chunk
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DASHSCOPE] stream response error: {e}")
|
||||
logger.error(f"[DASHSCOPE] stream response error: {e}", exc_info=True)
|
||||
yield {
|
||||
"error": True,
|
||||
"message": str(e),
|
||||
"status_code": 500
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _response_to_dict(response) -> dict:
|
||||
"""
|
||||
Convert DashScope response object to a plain dict.
|
||||
|
||||
DashScope SDK wraps responses in proxy objects whose __getattr__
|
||||
delegates to __getitem__, raising KeyError (not AttributeError)
|
||||
when an attribute is missing. Standard hasattr / getattr only
|
||||
catch AttributeError, so we must use try-except everywhere.
|
||||
"""
|
||||
_SENTINEL = object()
|
||||
|
||||
def _safe_getattr(obj, name, default=_SENTINEL):
|
||||
"""getattr that also catches KeyError from DashScope proxy objects."""
|
||||
try:
|
||||
return getattr(obj, name)
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
def _has_attr(obj, name):
|
||||
return _safe_getattr(obj, name) is not _SENTINEL
|
||||
|
||||
def _to_dict(obj):
|
||||
if isinstance(obj, (str, int, float, bool, type(None))):
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: _to_dict(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_to_dict(i) for i in obj]
|
||||
# DashScope response objects behave like dicts (have .keys())
|
||||
if _has_attr(obj, "keys"):
|
||||
try:
|
||||
return {k: _to_dict(obj[k]) for k in obj.keys()}
|
||||
except Exception:
|
||||
pass
|
||||
return obj
|
||||
|
||||
result = {}
|
||||
# Extract known top-level fields safely
|
||||
for attr in ("request_id", "status_code", "code", "message", "output", "usage"):
|
||||
val = _safe_getattr(response, attr)
|
||||
if val is _SENTINEL:
|
||||
try:
|
||||
val = response[attr]
|
||||
except (KeyError, TypeError, IndexError):
|
||||
continue
|
||||
result[attr] = _to_dict(val)
|
||||
return result
|
||||
|
||||
def _convert_tools_to_dashscope_format(self, tools):
|
||||
"""
|
||||
Convert tools from Claude format to DashScope format
|
||||
@@ -419,6 +493,37 @@ class DashscopeBot(Bot):
|
||||
|
||||
return dashscope_tools
|
||||
|
||||
@staticmethod
|
||||
def _prepare_messages_for_multimodal(messages: list) -> list:
|
||||
"""
|
||||
Ensure messages are compatible with MultiModalConversation API.
|
||||
|
||||
MultiModalConversation._preprocess_messages iterates every message
|
||||
with ``content = message["content"]; for elem in content: ...``,
|
||||
which means:
|
||||
1. Every message MUST have a 'content' key.
|
||||
2. 'content' MUST be an iterable (list), not a plain string.
|
||||
The expected format is [{"text": "..."}, ...].
|
||||
|
||||
Meanwhile the DashScope API requires role='tool' messages to follow
|
||||
assistant tool_calls, so we must NOT convert them to role='user'.
|
||||
We just ensure they have a list-typed 'content'.
|
||||
"""
|
||||
result = []
|
||||
for msg in messages:
|
||||
msg = dict(msg) # shallow copy
|
||||
|
||||
# Normalize content to list format [{"text": "..."}]
|
||||
content = msg.get("content")
|
||||
if content is None or (isinstance(content, str) and content == ""):
|
||||
msg["content"] = [{"text": ""}]
|
||||
elif isinstance(content, str):
|
||||
msg["content"] = [{"text": content}]
|
||||
# If content is already a list, keep as-is (already in multimodal format)
|
||||
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
def _convert_messages_to_dashscope_format(self, messages):
|
||||
"""
|
||||
Convert messages from Claude format to DashScope format
|
||||
|
||||
Reference in New Issue
Block a user