agent-Specialization/utils/api_client/message_mixin.py
JOJO 8e1a339102 refactor: split api_client, tool_result_formatter, tools_definition into sub-packages
- utils/api_client.py -> utils/api_client/ (mixin-based)
- utils/tool_result_formatter.py -> utils/tool_result_formatter/ (by tool category)
- core/main_terminal_parts/tools_definition.py -> core/main_terminal_parts/tools_definition/ (by tool group)
- Update AGENTS.md, CLAUDE.md, and split memory index
- Keep original files as compatibility re-exports
- Includes TerminalPanel display adjustment and ToolAction.vue diff line fix
2026-06-20 21:51:45 +08:00

304 lines
11 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.

# ========== api_client.py ==========
# utils/api_client.py - OpenAI-compatible API 客户端支持Web模式
import httpx
import json
import asyncio
import base64
import mimetypes
import os
from typing import List, Dict, Optional, AsyncGenerator, Any
from pathlib import Path
from datetime import datetime
from pathlib import Path
from typing import Tuple
try:
from config import (
OUTPUT_FORMATS,
DEFAULT_RESPONSE_MAX_TOKENS,
LOGS_DIR,
)
except ImportError:
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
OUTPUT_FORMATS,
DEFAULT_RESPONSE_MAX_TOKENS,
LOGS_DIR,
)
from utils.log_rotation import append_line, prune_dir
from utils.api_client.utils import _api_dump_enabled
class DeepSeekClientMessageMixin:
def _merge_system_messages(self, messages: List[Dict]) -> List[Dict]:
"""
仅合并最开头连续的 system 消息(系统提示),后续插入的 system 消息保持原样。
"""
if not messages:
return messages
merged_contents: List[str] = []
idx = 0
while idx < len(messages) and messages[idx].get("role") == "system":
content = messages[idx].get("content", "")
if isinstance(content, str):
merged_contents.append(content)
else:
merged_contents.append(json.dumps(content, ensure_ascii=False))
idx += 1
if not merged_contents:
return messages
merged = {
"role": "system",
"content": "\n\n".join(c for c in merged_contents if c)
}
return [merged] + messages[idx:]
@staticmethod
def _normalize_multimodal_capability(value: Any) -> str:
text = str(value or "none").strip().lower()
if text == "video,image":
return "image,video"
if text in {"none", "image", "video", "image,video"}:
return text
return "none"
@staticmethod
def _sanitize_content_for_capability(
content: Any,
*,
supports_image: bool,
supports_video: bool,
) -> Any:
"""根据模型多模态能力裁剪 content避免向纯文本模型发送 image_url/video_url。"""
if not isinstance(content, list):
return content
kept_parts: List[Dict[str, Any]] = []
text_segments: List[str] = []
dropped_media = False
unsupported_media_notice = "当前模型无查看图片/视频能力,无法返回结果"
for part in content:
if not isinstance(part, dict):
text = str(part).strip()
if text:
text_segments.append(text)
continue
ctype = str(part.get("type") or "").strip().lower()
if ctype == "text":
text = str(part.get("text") or "")
if text:
text_segments.append(text)
kept_parts.append({"type": "text", "text": text})
continue
if ctype == "image_url":
if supports_image:
kept_parts.append(part)
else:
dropped_media = True
continue
if ctype == "video_url":
if supports_video:
kept_parts.append(part)
else:
dropped_media = True
continue
# 其他未知/暂不支持类型统一忽略(纯文本模型下只保留 text
continue
# 纯文本模型:统一回退为字符串,规避供应商对 content part 类型的严格校验
if not supports_image and not supports_video:
text_payload = "\n".join(seg for seg in text_segments if seg).strip()
if dropped_media:
if text_payload:
if unsupported_media_notice in text_payload:
return text_payload
return f"{text_payload}\n\n{unsupported_media_notice}"
return unsupported_media_notice
return text_payload
# 半多模态模型(例如只支持图片):尽量保留可支持的 part
if kept_parts:
if len(kept_parts) == 1 and kept_parts[0].get("type") == "text":
return kept_parts[0].get("text", "")
return kept_parts
return "\n".join(seg for seg in text_segments if seg).strip()
def _sanitize_messages_for_model_capability(self, messages: List[Dict]) -> List[Dict]:
model_key = str(self.model_key or "").strip()
supports_image = False
supports_video = False
capability_source = "profile_fallback"
if model_key:
try:
from config.model_profiles import get_model_capabilities
caps = get_model_capabilities(model_key)
supports_image = bool(caps.get("supports_image"))
supports_video = bool(caps.get("supports_video"))
capability_source = "model_key"
except Exception:
capability_source = "profile_fallback"
if capability_source != "model_key":
multimodal = self._normalize_multimodal_capability(self.model_multimodal)
supports_image = multimodal in {"image", "image,video"}
supports_video = multimodal in {"video", "image,video"}
if supports_image and supports_video:
return messages
changed = 0
sanitized: List[Dict[str, Any]] = []
for message in messages or []:
if not isinstance(message, dict):
continue
msg_copy = dict(message)
original_content = msg_copy.get("content")
new_content = self._sanitize_content_for_capability(
original_content,
supports_image=supports_image,
supports_video=supports_video,
)
if new_content != original_content:
changed += 1
msg_copy["content"] = new_content
sanitized.append(msg_copy)
if changed:
self._debug_log(
{
"event": "sanitize_messages_for_model_capability",
"model_key": model_key,
"capability_source": capability_source,
"supports_image": supports_image,
"supports_video": supports_video,
"changed_messages": changed,
}
)
return sanitized
def _sanitize_message_fields_for_api(self, messages: List[Dict]) -> List[Dict]:
"""移除只供本地 UI/持久化使用、OpenAI-compatible API 不接受的消息字段。"""
sanitized: List[Dict[str, Any]] = []
stripped_fields: Dict[str, int] = {}
allowed_common = {"role", "content", "name"}
allowed_by_role = {
"assistant": allowed_common | {"tool_calls", "reasoning_content"},
"tool": allowed_common | {"tool_call_id"},
"user": allowed_common,
"system": allowed_common,
}
for message in messages or []:
if not isinstance(message, dict):
continue
role = str(message.get("role") or "").strip()
allowed = allowed_by_role.get(role, allowed_common)
clean: Dict[str, Any] = {}
for key, value in message.items():
if key not in allowed:
stripped_fields[key] = stripped_fields.get(key, 0) + 1
continue
if key == "tool_calls" and not value:
stripped_fields[key] = stripped_fields.get(key, 0) + 1
continue
clean[key] = value
sanitized.append(clean)
if stripped_fields:
self._debug_log(
{
"event": "sanitize_message_fields_for_api",
"stripped_fields": stripped_fields,
}
)
return sanitized
def _build_content_with_images(self, text: str, images: List[str], videos: Optional[List[Any]] = None) -> Any:
"""将文本与图片/视频路径拼成多模态 content用于 tool 消息)。"""
videos = videos or []
if not images and not videos:
return text
parts: List[Dict[str, Any]] = []
extra_videos: List[Any] = []
if text:
parts.append({"type": "text", "text": text})
base_path = Path(self.project_path or ".")
for path in images:
try:
abs_path = (base_path / path).resolve()
if not abs_path.exists() or not abs_path.is_file():
continue
mime, _ = mimetypes.guess_type(abs_path.name)
if mime and mime.startswith("video/"):
extra_videos.append(path)
continue
if mime and not mime.startswith("image/"):
continue
if not mime:
mime = "image/png"
data = abs_path.read_bytes()
b64 = base64.b64encode(data).decode("utf-8")
parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
except Exception:
continue
for item in [*videos, *extra_videos]:
try:
if isinstance(item, dict):
path = item.get("path") or ""
else:
path = item
if not path:
continue
abs_path = (base_path / path).resolve()
if not abs_path.exists() or not abs_path.is_file():
continue
mime, _ = mimetypes.guess_type(abs_path.name)
if not mime:
mime = "video/mp4"
data = abs_path.read_bytes()
b64 = base64.b64encode(data).decode("utf-8")
payload: Dict[str, Any] = {
"type": "video_url",
"video_url": {"url": f"data:{mime};base64,{b64}"}
}
if isinstance(item, dict) and item.get("fps") is not None:
payload["fps"] = item.get("fps")
parts.append(payload)
except Exception:
continue
return parts if parts else text
if read_type == "extract":
segments = data.get("segments", [])
header = (
f"{path} 抽取 {len(segments)} 个片段 {max_note}{truncated_note}"
).strip()
seg_texts = []
for idx, segment in enumerate(segments, 1):
seg_note = "(片段截断)" if segment.get("truncated") else ""
label = segment.get("label") or f"segment_{idx}"
snippet = segment.get("content", "")
seg_texts.append(
f"[{label}] 行 {segment.get('line_start')}~{segment.get('line_end')}{seg_note}\n```\n{snippet}\n```"
)
if not seg_texts:
seg_texts.append("未提供可抽取的片段。")
return "\n".join([header] + seg_texts)
return json.dumps(data, ensure_ascii=False)