feat: 独立角色创建模型,完善角色创建结果信息;改进错误重试逻辑;集成POW服务

This commit is contained in:
TheSmallHanCat
2026-02-24 01:42:36 +08:00
parent 29fddfa85b
commit 8b406e4e5c
6 changed files with 331 additions and 40 deletions

View File

@@ -55,6 +55,8 @@ async def list_models(api_key: str = Depends(verify_api_key_header)):
description += f" - {config['width']}x{config['height']}"
elif config['type'] == 'video':
description += f" - {config['orientation']}"
elif config['type'] == 'avatar_create':
description += " - create avatar from video"
elif config['type'] == 'prompt_enhance':
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
@@ -105,18 +107,22 @@ async def create_chat_completion(
if isinstance(content, str):
# Simple string format
prompt = content
# Extract remix_target_id from prompt if not already provided
# Extract sora id from prompt if not already provided
extracted_id = _extract_remix_id(prompt)
if extracted_id:
if not remix_target_id:
remix_target_id = _extract_remix_id(prompt)
remix_target_id = extracted_id
elif isinstance(content, list):
# Array format (OpenAI multimodal)
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
prompt = item.get("text", "")
# Extract remix_target_id from prompt if not already provided
# Extract sora id from prompt if not already provided
extracted_id = _extract_remix_id(prompt)
if extracted_id:
if not remix_target_id:
remix_target_id = _extract_remix_id(prompt)
remix_target_id = extracted_id
elif item.get("type") == "image_url":
# Extract base64 image from data URI
image_url = item.get("image_url", {})
@@ -149,7 +155,7 @@ async def create_chat_completion(
# Check if this is a video model
model_config = MODEL_CONFIG[request.model]
is_video_model = model_config["type"] == "video"
is_video_model = model_config["type"] in ["video", "avatar_create"]
# For video models with video parameter, we need streaming
if is_video_model and (video_data or remix_target_id):

View File

@@ -207,6 +207,12 @@ MODEL_CONFIG = {
"type": "prompt_enhance",
"expansion_level": "long",
"duration_s": 20
},
# Avatar creation model (character creation only)
"avatar-create": {
"type": "avatar_create",
"orientation": "portrait",
"n_frames": 300
}
}
@@ -265,6 +271,13 @@ class GenerationHandler:
return False
if "429" in error_str or "rate limit" in error_str:
return False
# 参数/模型使用错误无需重试
if "invalid model" in error_str:
return False
if "avatar-create" in error_str:
return False
if "参数错误" in error_str:
return False
# 其他所有错误都可以重试
return True
@@ -299,6 +312,20 @@ class GenerationHandler:
return final_username
def _extract_generation_id(self, text: str) -> str:
"""Extract generation ID from text.
Supported format: gen_[a-zA-Z0-9]+
"""
if not text:
return ""
match = re.search(r'gen_[a-zA-Z0-9]+', text)
if match:
return match.group(0)
return ""
def _clean_remix_link_from_prompt(self, prompt: str) -> str:
"""Remove remix link from prompt
@@ -429,9 +456,10 @@ class GenerationHandler:
raise ValueError(f"Invalid model: {model}")
model_config = MODEL_CONFIG[model]
is_video = model_config["type"] == "video"
is_video = model_config["type"] in ["video", "avatar_create"]
is_image = model_config["type"] == "image"
is_prompt_enhance = model_config["type"] == "prompt_enhance"
is_avatar_create = model_config["type"] == "avatar_create"
# Handle prompt enhancement
if is_prompt_enhance:
@@ -445,40 +473,50 @@ class GenerationHandler:
if available:
if is_image:
message = "All tokens available for image generation. Please enable streaming to use the generation feature."
elif is_avatar_create:
message = "All tokens available for avatar creation. Please enable streaming to create avatar."
else:
message = "All tokens available for video generation. Please enable streaming to use the generation feature."
else:
if is_image:
message = "No available models for image generation"
elif is_avatar_create:
message = "No available tokens for avatar creation"
else:
message = "No available models for video generation"
yield self._format_non_stream_response(message, is_availability_check=True)
return
# Handle character creation and remix flows for video models
if is_video:
# Handle avatar creation model (character creation only)
if is_avatar_create:
# Priority: video > prompt内generation_id(gen_xxx)
if video:
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
async for chunk in self._handle_character_creation_only(video_data, model_config):
yield chunk
return
# generation_id 仅从提示词解析
source_generation_id = self._extract_generation_id(prompt) if prompt else None
if source_generation_id:
async for chunk in self._handle_character_creation_from_generation_id(source_generation_id, model_config):
yield chunk
return
raise Exception("avatar-create 模型需要传入视频文件,或在提示词中包含 generation_idgen_xxx")
# Handle remix flow for regular video models
if model_config["type"] == "video":
# Remix flow: remix_target_id provided
if remix_target_id:
async for chunk in self._handle_remix(remix_target_id, prompt, model_config):
yield chunk
return
# Character creation flow: video provided
# Character creation has been isolated into avatar-create model
if video:
# Decode video if it's base64
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
# If no prompt, just create character and return
if not prompt:
async for chunk in self._handle_character_creation_only(video_data, model_config):
yield chunk
return
else:
# If prompt provided, create character and generate video
async for chunk in self._handle_character_and_video_generation(video_data, prompt, model_config):
yield chunk
return
raise Exception("角色创建已独立为 avatar-create 模型,请切换模型后重试。")
# Streaming mode: proceed with actual generation
# Check if model requires Pro subscription
@@ -797,7 +835,15 @@ class GenerationHandler:
# Try generation
# Only show init message on first attempt (not on retries)
show_init = (retry_count == 0)
async for chunk in self.handle_generation(model, prompt, image, video, remix_target_id, stream, show_init_message=show_init):
async for chunk in self.handle_generation(
model,
prompt,
image,
video,
remix_target_id,
stream,
show_init_message=show_init
):
yield chunk
# If successful, return
return
@@ -1669,6 +1715,17 @@ class GenerationHandler:
# Log successful character creation
duration = time.time() - start_time
character_card = {
"username": username,
"display_name": display_name,
"character_id": character_id,
"cameo_id": cameo_id,
"profile_asset_url": profile_asset_url,
"instruction_set": instruction_set,
"public": True,
"source_model": "avatar-create",
"created_at": int(datetime.now().timestamp())
}
await self._log_request(
token_id=token_obj.id,
operation="character_only",
@@ -1678,18 +1735,28 @@ class GenerationHandler:
},
response_data={
"success": True,
"username": username,
"display_name": display_name,
"character_id": character_id,
"cameo_id": cameo_id
"card": character_card
},
status_code=200,
duration=duration
)
# Step 7: Return success message
# Step 7: Return structured character card
yield self._format_stream_chunk(
content=f"角色创建成功,角色名@{username}",
content=json.dumps({
"event": "character_card",
"card": character_card
}, ensure_ascii=False)
)
# Step 8: Return summary message
yield self._format_stream_chunk(
content=(
f"角色创建成功,角色名@{username}\n"
f"显示名:{display_name}\n"
f"Character ID{character_id}\n"
f"Cameo ID{cameo_id}"
),
finish_reason="STOP"
)
yield "data: [DONE]\n\n"
@@ -1741,6 +1808,182 @@ class GenerationHandler:
)
raise
async def _handle_character_creation_from_generation_id(self, generation_id: str, model_config: Dict) -> AsyncGenerator[str, None]:
"""Handle character creation from generation id (gen_xxx)."""
token_obj = await self.load_balancer.select_token(for_video_generation=True)
if not token_obj:
raise Exception("No available tokens for character creation")
start_time = time.time()
normalized_generation_id = self._extract_generation_id((generation_id or "").strip())
try:
yield self._format_stream_chunk(
reasoning_content="**Character Creation Begins**\n\nInitializing character creation from generation id...\n",
is_first=True
)
if not normalized_generation_id:
raise Exception("无效 generation_id请传入 gen_xxx。")
# Step 1: Create cameo from generation
yield self._format_stream_chunk(
reasoning_content=f"Creating character from generation: {normalized_generation_id} ...\n"
)
cameo_id = await self.sora_client.create_character_from_generation(
generation_id=normalized_generation_id,
token=token_obj.token,
timestamps=[0, 3]
)
debug_logger.log_info(f"Character-from-generation submitted, cameo_id: {cameo_id}")
# Step 2: Poll cameo processing
yield self._format_stream_chunk(
reasoning_content="Processing generation to extract character...\n"
)
cameo_status = await self._poll_cameo_status(cameo_id, token_obj.token)
debug_logger.log_info(f"Cameo status: {cameo_status}")
# Extract character info
username_hint = cameo_status.get("username_hint", "character")
display_name = cameo_status.get("display_name_hint", "Character")
username = self._process_character_username(username_hint)
yield self._format_stream_chunk(
reasoning_content=f"✨ 角色已识别: {display_name} (@{username})\n"
)
# Step 3: Download avatar
yield self._format_stream_chunk(
reasoning_content="Downloading character avatar...\n"
)
profile_asset_url = cameo_status.get("profile_asset_url")
if not profile_asset_url:
raise Exception("Profile asset URL not found in cameo status")
avatar_data = await self.sora_client.download_character_image(profile_asset_url)
debug_logger.log_info(f"Avatar downloaded, size: {len(avatar_data)} bytes")
# Step 4: Upload avatar
yield self._format_stream_chunk(
reasoning_content="Uploading character avatar...\n"
)
asset_pointer = await self.sora_client.upload_character_image(avatar_data, token_obj.token)
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
# Step 5: Finalize character
yield self._format_stream_chunk(
reasoning_content="Finalizing character creation...\n"
)
instruction_set = cameo_status.get("instruction_set_hint") or cameo_status.get("instruction_set")
character_id = await self.sora_client.finalize_character(
cameo_id=cameo_id,
username=username,
display_name=display_name,
profile_asset_pointer=asset_pointer,
instruction_set=instruction_set,
token=token_obj.token
)
debug_logger.log_info(f"Character finalized, character_id: {character_id}")
# Step 6: Set public
yield self._format_stream_chunk(
reasoning_content="Setting character as public...\n"
)
await self.sora_client.set_character_public(cameo_id, token_obj.token)
debug_logger.log_info("Character set as public")
# Log success
duration = time.time() - start_time
character_card = {
"username": username,
"display_name": display_name,
"character_id": character_id,
"cameo_id": cameo_id,
"profile_asset_url": profile_asset_url,
"instruction_set": instruction_set,
"public": True,
"source_model": "avatar-create",
"source_generation_id": normalized_generation_id,
"created_at": int(datetime.now().timestamp())
}
await self._log_request(
token_id=token_obj.id,
operation="character_only",
request_data={
"type": "character_creation",
"has_video": False,
"has_generation_id": True,
"generation_id": normalized_generation_id
},
response_data={
"success": True,
"card": character_card
},
status_code=200,
duration=duration
)
yield self._format_stream_chunk(
content=json.dumps({
"event": "character_card",
"card": character_card
}, ensure_ascii=False)
)
yield self._format_stream_chunk(
content=(
f"角色创建成功,角色名@{username}\n"
f"显示名:{display_name}\n"
f"Character ID{character_id}\n"
f"Cameo ID{cameo_id}"
),
finish_reason="STOP"
)
yield "data: [DONE]\n\n"
except Exception as e:
error_response = None
try:
error_response = json.loads(str(e))
except:
pass
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
duration = time.time() - start_time
await self._log_request(
token_id=token_obj.id if token_obj else None,
operation="character_only",
request_data={
"type": "character_creation",
"has_video": False,
"has_generation_id": bool(normalized_generation_id),
"generation_id": normalized_generation_id
},
response_data={
"success": False,
"error": str(e)
},
status_code=429 if is_cf_or_429 else 500,
duration=duration
)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
debug_logger.log_error(
error_message=f"Character creation from generation id failed: {str(e)}",
status_code=429 if is_cf_or_429 else 500,
response_text=str(e)
)
raise
async def _handle_character_and_video_generation(self, video_data, prompt: str, model_config: Dict) -> AsyncGenerator[str, None]:
"""Handle character creation and video generation

View File

@@ -10,9 +10,12 @@ from ..core.logger import debug_logger
class POWServiceClient:
"""Client for external POW service API"""
async def get_sentinel_token(self) -> Optional[Tuple[str, str, str]]:
async def get_sentinel_token(self, access_token: Optional[str] = None) -> Optional[Tuple[str, str, str]]:
"""Get sentinel token from external POW service
Args:
access_token: Optional access token to send to POW service
Returns:
Tuple of (sentinel_token, device_id, user_agent) or None on failure
"""
@@ -39,6 +42,10 @@ class POWServiceClient:
"Accept": "application/json"
}
# Add access_token to headers if provided
if access_token:
headers["X-Access-Token"] = access_token
try:
debug_logger.log_info(f"[POW Service] Requesting token from {api_url}")

View File

@@ -9,7 +9,7 @@ import random
import string
import re
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any, Tuple, List
from uuid import uuid4
from urllib.request import Request, urlopen, build_opener, ProxyHandler
from urllib.error import HTTPError, URLError
@@ -231,12 +231,13 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
await context.close()
async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool = False) -> str:
async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool = False, access_token: Optional[str] = None) -> str:
"""Get sentinel token with caching support
Args:
proxy_url: Optional proxy URL
force_refresh: Force refresh token (e.g., after 400 error)
access_token: Optional access token to send to external POW service
Returns:
Sentinel token string or None
@@ -250,7 +251,7 @@ async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool
if config.pow_service_mode == "external":
debug_logger.log_info("[POW] Using external POW service (cached sentinel)")
from .pow_service_client import pow_service_client
result = await pow_service_client.get_sentinel_token()
result = await pow_service_client.get_sentinel_token(access_token=access_token)
if result:
sentinel_token, device_id, service_user_agent = result
@@ -754,7 +755,7 @@ class SoraClient:
# Check if external POW service is configured
if config.pow_service_mode == "external":
debug_logger.log_info("[Sentinel] Using external POW service...")
result = await pow_service_client.get_sentinel_token()
result = await pow_service_client.get_sentinel_token(access_token=token)
if result:
sentinel_token, device_id, service_user_agent = result
@@ -1141,7 +1142,7 @@ class SoraClient:
# Try to get cached sentinel token first (using lightweight Playwright approach)
try:
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=False)
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=False, access_token=token)
except Exception as e:
# 403/429 errors from oai-did fetch - don't retry, just fail
error_str = str(e)
@@ -1175,7 +1176,7 @@ class SoraClient:
_invalidate_sentinel_cache()
try:
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True)
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True, access_token=token)
except Exception as refresh_e:
# 403/429 errors - don't continue
error_str = str(refresh_e)
@@ -1432,6 +1433,33 @@ class SoraClient:
result = await self._make_request("POST", "/characters/upload", token, multipart=mp)
return result.get("id")
async def create_character_from_generation(self, generation_id: str, token: str,
timestamps: Optional[List[int]] = None) -> str:
"""Create character cameo from generation id.
Args:
generation_id: Generation ID (gen_xxx)
token: Access token
timestamps: Optional frame timestamps, defaults to [0, 3]
Returns:
cameo_id
"""
if timestamps is None:
timestamps = [0, 3]
json_data = {
"generation_id": generation_id,
"character_id": None,
"timestamps": timestamps
}
result = await self._make_request("POST", "/characters/from-generation", token, json_data=json_data)
return result.get("id")
async def get_post_detail(self, post_id: str, token: str) -> Dict[str, Any]:
"""Get Sora post detail by post id (s_xxx)."""
return await self._make_request("GET", f"/project_y/post/{post_id}", token)
async def get_cameo_status(self, cameo_id: str, token: str) -> Dict[str, Any]:
"""Get character (cameo) processing status

View File

@@ -1559,6 +1559,9 @@
<option value="sora2pro-hd-portrait-15s">竖屏视频 15 秒 (Pro HD)</option>
<option value="sora2pro-hd-portrait-10s">竖屏视频 10 秒 (Pro HD)</option>
</optgroup>
<optgroup label="角色创建">
<option value="avatar-create">角色创建(视频优先 / 支持提示词generation_id</option>
</optgroup>
<optgroup label="图片">
<option value="gpt-image">方图 360×360</option>
<option value="gpt-image-landscape">横图 540×360</option>

View File

@@ -4771,9 +4771,13 @@
}
});
} else if (batchType === 'character') {
// 角色卡模式:只需要视频文件,不需要提示词
if (model !== 'avatar-create') {
showToast('角色卡模式请先切换模型为“角色创建(视频优先 / 支持提示词generation_id/avatar-create”', 'warn', { title: '模型不匹配', duration: 4200 });
return;
}
// 角色卡模式:只使用视频文件(提示词内 generation_id 请走普通模式)
if (!files.length) {
showToast('角色卡模式:请上传视频文件', 'warn', { title: '缺少视频', duration: 3600 });
showToast('角色卡模式:请上传视频文件提示词generation_id请用普通模式', 'warn', { title: '缺少视频', duration: 3600 });
return;
}
const videoFile = files.find((f) => (f.type || '').startsWith('video'));