from dataclasses import dataclass import base64 import json import uuid from io import BytesIO from typing import Any, Iterator import requests from django.conf import settings from .base import AIProviderResult def _raise_volcano_error(response: requests.Response) -> None: """火山非 2xx:抽出真实报错(error.code / error.message)抛成可读异常, 供上层落 error_message → 前端如实显示真实报错参数,不再被通用「HTTP 400」吞掉细节。""" if response.ok: return code = message = "" try: err = (response.json() or {}).get("error") or {} code, message = str(err.get("code") or ""), str(err.get("message") or "") except Exception: pass if code or message: raise RuntimeError(f"火山报错 [{code}] {message}".strip()) raise RuntimeError(f"火山接口 {response.status_code}: {(response.text or '')[:300]}") @dataclass class VolcanoArkProvider: api_key: str | None = None base_url: str | None = None # 部分 OpenAI 兼容中转站(如 yunqi)的生图/改图端点要求 Azure 风格的 ?api-version=... 查询参数, # 不带会 404。火山官方直连用不到,默认 None。仅 OpenAICompatibleProvider 在拼 URL 时附加。 api_version: str | None = None def __post_init__(self) -> None: self.api_key = self.api_key or settings.VOLCANO.get("ark_api_key") self.base_url = self.base_url or settings.VOLCANO.get("ark_base_url") def submit(self, payload: dict[str, Any]) -> AIProviderResult: # The exact endpoint is resolved by ModelConfig; this adapter keeps IO centralized. endpoint = payload.get("endpoint") if not endpoint: raise ValueError("Volcano request payload requires endpoint") response = requests.post( f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", headers={"Authorization": f"Bearer {self.api_key}"}, json=payload.get("body", {}), timeout=60, ) response.raise_for_status() data = response.json() return AIProviderResult( provider_task_id=str(data.get("id") or data.get("task_id") or ""), status=str(data.get("status") or "submitted"), payload=data, ) def chat_completion( self, *, model: str, messages: list[dict[str, str]], endpoint: str = "chat/completions", timeout: float = 120, ) -> dict[str, Any]: if not self.api_key: raise ValueError("VOLCANO_ARK_API_KEY is not configured") response = requests.post( f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json={"model": model, "messages": messages}, timeout=timeout, ) response.raise_for_status() return response.json() def chat_completion_stream( self, *, model: str, messages: list[dict[str, Any]], endpoint: str = "chat/completions", temperature: float = 0.8, extra_body: dict[str, Any] | None = None, timeout: float = 300, ) -> Iterator[dict[str, Any]]: """流式对话:逐块 yield {type:'delta'|'tool_call'|'done', ...}。 OpenAI 兼容 SSE(火山 ARK / 各中转站同构),供脚本 agent 的 SSE 端实时转发。""" if not self.api_key: raise ValueError("api_key is not configured") body: dict[str, Any] = {"model": model, "messages": messages, "stream": True, "temperature": temperature} if extra_body: body.update(extra_body) with requests.post( f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream", }, json=body, stream=True, timeout=timeout, ) as response: response.raise_for_status() # SSE 响应常不带 charset,requests 会按 latin-1 解码 → 中文乱码。强制 UTF-8。 response.encoding = "utf-8" for raw in response.iter_lines(decode_unicode=True): if not raw or not raw.startswith("data:"): continue data = raw[5:].strip() if data == "[DONE]": break try: chunk = json.loads(data) except ValueError: continue choices = chunk.get("choices") or [] if not choices: continue delta = choices[0].get("delta") or {} # 推理模型(豆包 seed-pro / 部分中转 o系/gemini)思考阶段只发 reasoning_content, # 不发 content。必须单独转发,否则整个思考期(可达几十秒~分钟)前端零输出 = 假死。 reason = delta.get("reasoning_content") if reason: yield {"type": "reasoning", "text": reason} piece = delta.get("content") if piece: yield {"type": "delta", "text": piece} if delta.get("tool_calls"): yield {"type": "tool_call", "tool_calls": delta["tool_calls"]} yield {"type": "done"} @staticmethod def extract_text(data: dict[str, Any]) -> str: choices = data.get("choices") or [] if choices: message = choices[0].get("message") or {} content = message.get("content") if isinstance(content, str): return content if isinstance(content, list): return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict)) output = data.get("output") if isinstance(output, str): return output raise ValueError("Volcano response does not contain text content") def poll(self, provider_task_id: str) -> AIProviderResult: if not provider_task_id: raise ValueError("provider_task_id is required") return AIProviderResult(provider_task_id=provider_task_id, status="polling", payload={}) def image_generation( self, *, model: str, prompt: str, endpoint: str = "images/generations", image: str | list[str] | None = None, size: str = "2K", timeout: float = 180, ) -> dict[str, Any]: if not self.api_key: raise ValueError("VOLCANO_ARK_API_KEY is not configured") body: dict[str, Any] = { "model": model, "prompt": prompt, "response_format": "url", "watermark": False, "size": size, "sequential_image_generation": "disabled", } if image: body["image"] = image response = requests.post( f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json=body, timeout=timeout, ) # 非 2xx 抽出火山真实报错(error.code/message)而非裸 HTTP 400 —— 否则 Seedream 的 # 内容审核 / 尺寸过小(历史 4:5 套图 raise_for_status 吞 body 的踩坑)等真因看不见。 _raise_volcano_error(response) return response.json() def create_video_task( self, *, model: str, endpoint: str, prompt: str, ratio: str = "9:16", duration: int = 15, resolution: str = "720p", reference_images: list[str] | None = None, generate_audio: bool = True, content_items: list[dict[str, Any]] | None = None, seed: int | None = None, search_mode: str = "off", timeout: float = 120, ) -> dict[str, Any]: if not self.api_key: raise ValueError("VOLCANO_ARK_API_KEY is not configured") content: list[dict[str, Any]] = [{"type": "text", "text": prompt}] if content_items is not None: # 自由创作:调用方整段接管参考素材(混合 image/video/audio + role first_frame/last_frame 等) content.extend(content_items) else: for image_url in reference_images or []: content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"}) body = { "model": model, "content": content, "ratio": ratio, "duration": duration, "resolution": resolution, "watermark": False, # Seedance 直接出音效 + 人物声音(参考生视频);关掉则是哑片。默认开。 "generate_audio": generate_audio, } if seed is not None and seed != -1: body["seed"] = seed if search_mode == "smart": body["tools"] = [{"type": "web_search"}] response = requests.post( f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, json=body, timeout=timeout, ) _raise_volcano_error(response) return response.json() def poll_video_task( self, *, endpoint: str, provider_task_id: str, timeout: float = 60, ) -> dict[str, Any]: if not self.api_key: raise ValueError("VOLCANO_ARK_API_KEY is not configured") response = requests.get( f"{self.base_url.rstrip('/')}/{endpoint.rstrip('/')}/{provider_task_id}", headers={"Authorization": f"Bearer {self.api_key}"}, timeout=timeout, ) _raise_volcano_error(response) return response.json() @staticmethod def extract_first_media_url(data: dict[str, Any]) -> str: items = data.get("data") or [] for item in items: if item.get("url"): return item["url"] if item.get("b64_json"): return item["b64_json"] # 视频任务响应的 content 有两种形态:dict {video_url: "..."} 或 # list [{type:"video_url", video_url:{url:"..."}}](Seedance 2.0 多模态响应)。 content = data.get("content") if isinstance(content, dict): video_url = content.get("video_url") if isinstance(video_url, str) and video_url: return video_url if isinstance(video_url, dict) and video_url.get("url"): return video_url["url"] if isinstance(content, list): for item in content: if not isinstance(item, dict): continue video_url = item.get("video_url") if isinstance(video_url, str) and video_url: return video_url if isinstance(video_url, dict) and video_url.get("url"): return video_url["url"] raise ValueError("Volcano response does not contain media url") @staticmethod def media_to_bytes(media: str) -> tuple[BytesIO, str]: if media.startswith("http://") or media.startswith("https://"): response = requests.get(media, timeout=180) response.raise_for_status() return BytesIO(response.content), response.headers.get("content-type", "application/octet-stream") if "," in media and media.startswith("data:"): header, raw = media.split(",", 1) content_type = header.split(";")[0].replace("data:", "") or "application/octet-stream" return BytesIO(base64.b64decode(raw)), content_type return BytesIO(base64.b64decode(media)), "image/png" class TtsNotConfigured(ValueError): """语音合成凭证未配置(VOLC_TTS_APPID / VOLC_TTS_ACCESS_TOKEN)。""" @dataclass class VolcanoTtsProvider: """豆包语音合成大模型(openspeech V1 HTTP,非流式)。 注意:与 ARK 不是同一套钥匙,要在 火山引擎控制台→语音技术→语音合成大模型 创建应用拿 APPID/Access Token。""" appid: str | None = None access_token: str | None = None cluster: str | None = None base_url: str | None = None def __post_init__(self) -> None: cfg = getattr(settings, "VOLC_TTS", {}) or {} self.appid = self.appid or cfg.get("appid") self.access_token = self.access_token or cfg.get("access_token") self.cluster = self.cluster or cfg.get("cluster") or "volcano_tts" self.base_url = self.base_url or cfg.get("base_url") or "https://openspeech.bytedance.com/api/v1/tts" @property def configured(self) -> bool: return bool(self.appid and self.access_token) def synthesize( self, *, text: str, voice_type: str, speed_ratio: float = 1.0, uid: str = "airshelf", timeout: float = 60, ) -> tuple[bytes, int]: """合成一段语音。返回 (mp3 字节, 时长毫秒;接口没回时长则为 0)。""" if not self.configured: raise TtsNotConfigured( "语音合成未配置:请在后端环境变量设置 VOLC_TTS_APPID 和 VOLC_TTS_ACCESS_TOKEN" "(火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用)" ) body = { "app": {"appid": self.appid, "token": self.access_token, "cluster": self.cluster}, "user": {"uid": uid}, "audio": {"voice_type": voice_type, "encoding": "mp3", "speed_ratio": float(speed_ratio or 1.0)}, "request": {"reqid": str(uuid.uuid4()), "text": text, "operation": "query"}, } response = requests.post( self.base_url, headers={"Authorization": f"Bearer;{self.access_token}"}, json=body, timeout=timeout, ) response.raise_for_status() data = response.json() if data.get("code") != 3000 or not data.get("data"): raise ValueError(f"语音合成失败:{data.get('message') or data.get('code')}") audio = base64.b64decode(data["data"]) duration_ms = 0 try: duration_ms = int(float((data.get("addition") or {}).get("duration") or 0)) except (TypeError, ValueError): duration_ms = 0 return audio, duration_ms