Files
yingqing/core/backend/apps/ai/providers/volcano.py
T
Azmat@qq.com 0bd1db6bf9 模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
2026-09-21 16:24:37 +08:00

380 lines
16 KiB
Python

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:
if response.status_code >= 400:
# 光有状态码定位不了问题:ARK 的参数校验原因只在响应体里(例如某个
# 字段不被该模型支持)。带上正文再抛,否则每次都只能靠猜。
detail = (response.text or "").strip()[:600]
raise requests.HTTPError(
f"{response.status_code} from {endpoint}: {detail}", response=response
)
# 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
# finish_reason=length 说明输出撞到 max_tokens 被截断。调用方必须能区分
# 「模型没写」和「写了但被砍掉」,否则坏 JSON 只会被当成模型偷懒反复重试。
finish = choices[0].get("finish_reason")
if finish:
yield {"type": "finish", "reason": str(finish)}
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:
# Seedance 2.5 r2v 硬上限是 int32 正数(2147483647)。文档写 2^32-1,但 r2v 实际更严;
# 超一点整条 InvalidParameter。这里兜住所有上游构造路径。
try:
clamped = int(seed) & 2147483647
except (TypeError, ValueError):
clamped = None
if clamped:
body["seed"] = clamped
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