- 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
304 lines
11 KiB
Python
304 lines
11 KiB
Python
# ========== 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)
|