Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

194 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any
from langchain.chat_models import BaseChatModel
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
from yuxi import config as sys_config
from yuxi.models.providers.cache import model_cache
from yuxi.utils import get_docker_safe_url
from yuxi.utils.logging_config import logger
_TOOL_IMAGE_USER_TEXT = "Images returned by read_file are attached below. Inspect them when answering."
def resolve_chat_model_spec(model_spec: str | None, *, fallback: str | None = None) -> str:
"""解析空模型配置,不吞掉已经配置但无效的模型值。
这里仅处理模型为空时的优先级:请求或配置值、调用方 fallback、系统默认模型;
具体模型是否存在、是否为聊天模型仍由 model_cache 校验。
"""
for candidate in (model_spec, fallback, sys_config.default_model):
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
raise ValueError("model spec 不能为空")
def load_chat_model(fully_specified_name: str | None, **kwargs) -> BaseChatModel:
fully_specified_name = resolve_chat_model_spec(fully_specified_name)
info = model_cache.get_model_info(fully_specified_name)
if not info:
available_specs = model_cache.get_all_specs("chat")
available_ids = [item.spec for item in available_specs[:10]]
raise ValueError(
f"Unknown model spec: '{fully_specified_name}'. "
f"Available chat models ({len(available_specs)}): {available_ids}"
)
if info.model_type != "chat":
raise ValueError(f"Model {fully_specified_name} is not a chat model (type={info.model_type})")
api_key = info.api_key
base_url = get_docker_safe_url(info.base_url)
logger.debug(f"Loading model {fully_specified_name} with provider_type={info.provider_type}")
if info.provider_type == "anthropic":
from langchain_anthropic import ChatAnthropic
return ChatAnthropic(
model=info.model_id,
api_key=SecretStr(api_key),
base_url=base_url,
**kwargs,
)
if info.provider_type == "gemini":
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=info.model_id,
google_api_key=SecretStr(api_key),
**kwargs,
)
return _ToolCallChunkFixChatOpenAI(
model=info.model_id,
api_key=SecretStr(api_key),
base_url=base_url,
stream_usage=True,
**kwargs,
)
class _ToolCallChunkFixChatOpenAI(ChatOpenAI):
"""归一化流式 tool_call 续片中的空串 name/id,规避 v3 流式累积缺陷。"""
def _get_request_payload(self, input_, *, stop=None, **kwargs):
"""Override to bridge tool image blocks to user messages."""
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
return _bridge_tool_images_to_user_messages(payload)
async def _astream(self, *args, **kwargs):
async for chunk in super()._astream(*args, **kwargs):
_normalize_tool_call_chunks(chunk.message)
yield chunk
def _stream(self, *args, **kwargs):
for chunk in super()._stream(*args, **kwargs):
_normalize_tool_call_chunks(chunk.message)
yield chunk
def _bridge_tool_images_to_user_messages(payload: dict[str, Any]) -> dict[str, Any]:
"""将工具调用返回的 image_url 块桥接到用户消息中,避免工具消息中包含图片导致的渲染问题。"""
messages = payload.get("messages")
if not isinstance(messages, list):
return payload
if not any(isinstance(m, dict) and m.get("role") == "tool" and _tool_image_blocks(m) for m in messages):
return payload
bridged_messages: list[dict[str, Any]] = []
pending_images: list[dict[str, Any]] = []
def flush_pending_images() -> None:
nonlocal pending_images
if not pending_images:
return
bridged_messages.append(
{
"role": "user",
"content": [{"type": "text", "text": _TOOL_IMAGE_USER_TEXT}, *pending_images],
}
)
pending_images = []
for message in messages:
if not isinstance(message, dict):
flush_pending_images()
bridged_messages.append(message)
continue
role = message.get("role")
if role != "tool":
flush_pending_images()
image_blocks = _tool_image_blocks(message) if role == "tool" else []
if image_blocks:
pending_images.extend(image_blocks)
content = _text_without_images(message.get("content"), image_blocks)
if not content:
content = (
f"read_file returned {len(image_blocks)} image(s). "
"The image content is attached in the following user message for visual inspection."
)
message = {**message, "content": content}
bridged_messages.append(message)
flush_pending_images()
return {**payload, "messages": bridged_messages}
def _normalize_tool_call_chunks(message) -> None:
"""把工具调用续片里空字符串的 name/id 归一化为 None。
LangGraph v3 流式累积对 tool_call 字段是“后值覆盖”:部分 OpenAI 兼容提供商
siliconflow、阿里云百炼等)在续片里把 name/id 下发为空字符串 "",会覆盖首片
的真实值(siliconflow 丢 name、百炼丢 id),导致工具结果无法按 tool_call_id
关联、工具状态停留在“进行中”。OpenAI 官方在续片里发 None 不会触发覆盖,这里
把空串归一化为 None 对齐该行为。待上游修复 v3 协议后可移除。
"""
for chunk in message.tool_call_chunks:
if chunk.get("name") == "":
chunk["name"] = None
if chunk.get("id") == "":
chunk["id"] = None
def _tool_image_blocks(message: dict[str, Any]) -> list[dict[str, Any]]:
content = message.get("content")
if not isinstance(content, list):
return []
return [
block
for block in content
if isinstance(block, dict)
and block.get("type") == "image_url"
and isinstance(block.get("image_url"), dict)
and isinstance(block["image_url"].get("url"), str)
]
def _text_without_images(content: Any, image_blocks: list[dict[str, Any]]) -> str:
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
image_ids = {id(block) for block in image_blocks}
parts: list[str] = []
for block in content:
if id(block) in image_ids:
continue
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "input_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(part for part in parts if part)