# ========== 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)