diff --git a/README.md b/README.md
index c711e9c..6f970c6 100644
--- a/README.md
+++ b/README.md
@@ -157,12 +157,12 @@ python main.py
| 模型 | 说明 | 输入 | 输出 |
|------|------|------|------|
-| `sora-image` | 文生图(默认横屏) | 文本 | 图片 |
-| `sora-image-landscape` | 文生图(横屏) | 文本 | 图片 |
-| `sora-image-portrait` | 文生图(竖屏) | 文本 | 图片 |
-| `sora-video` | 文生视频(默认横屏) | 文本 | 视频 |
-| `sora-video-landscape` | 文生视频(横屏) | 文本 | 视频 |
-| `sora-video-portrait` | 文生视频(竖屏) | 文本 | 视频 |
+| `sora-image` | 文生图(默认横屏) | 文本/图片 | 图片 |
+| `sora-image-landscape` | 文生图(横屏) | 文本/图片 | 图片 |
+| `sora-image-portrait` | 文生图(竖屏) | 文本/图片 | 图片 |
+| `sora-video` | 文生视频(默认横屏) | 文本/图片 | 视频 |
+| `sora-video-landscape` | 文生视频(横屏) | 文本/图片 | 视频 |
+| `sora-video-portrait` | 文生视频(竖屏) | 文本/图片 | 视频 |
#### 请求示例
diff --git a/config/setting.toml b/config/setting.toml
index 9ba56ba..35493f8 100644
--- a/config/setting.toml
+++ b/config/setting.toml
@@ -21,6 +21,7 @@ log_responses = true
mask_token = true
[cache]
+enabled = false
timeout = 600
base_url = "http://127.0.0.1:8000"
@@ -44,6 +45,9 @@ custom_parse_token = ""
[video_length]
default_length = "10s"
+[token_refresh]
+at_auto_refresh_enabled = false
+
[video_length.lengths]
10s = 300
15s = 450
diff --git a/config/setting_warp.toml b/config/setting_warp.toml
index 9bfb3ce..231906f 100644
--- a/config/setting_warp.toml
+++ b/config/setting_warp.toml
@@ -21,6 +21,7 @@ log_responses = true
mask_token = true
[cache]
+enabled = true
timeout = 600
base_url = "http://127.0.0.1:8000"
@@ -37,6 +38,9 @@ proxy_url = "socks5://warp:1080"
[watermark_free]
watermark_free_enabled = false
+parse_method = "third_party"
+custom_parse_url = ""
+custom_parse_token = ""
[video_length]
default_length = "10s"
@@ -44,3 +48,6 @@ default_length = "10s"
[video_length.lengths]
10s = 300
15s = 450
+
+[token_refresh]
+at_auto_refresh_enabled = false
diff --git a/docker-compose.warp.yml b/docker-compose.warp.yml
index 8495d27..ab8c067 100644
--- a/docker-compose.warp.yml
+++ b/docker-compose.warp.yml
@@ -2,7 +2,7 @@ version: '3.8'
services:
sora2api:
- image: thesmallhancat/sora2api:3.0
+ image: thesmallhancat/sora2api:3.1
container_name: sora2api
ports:
- "8000:8000"
diff --git a/docker-compose.yml b/docker-compose.yml
index 7980440..4b455e7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,7 @@ version: '3.8'
services:
sora2api:
- image: thesmallhancat/sora2api:3.0
+ image: thesmallhancat/sora2api:3.1
container_name: sora2api
ports:
- "8000:8000"
diff --git a/src/api/admin.py b/src/api/admin.py
index 500dcfb..e913e26 100644
--- a/src/api/admin.py
+++ b/src/api/admin.py
@@ -729,12 +729,49 @@ async def get_cache_config(token: str = Depends(verify_admin_token)):
return {
"success": True,
"config": {
+ "enabled": config.cache_enabled,
"timeout": config.cache_timeout,
"base_url": config.cache_base_url, # 返回实际配置的值,可能为空字符串
"effective_base_url": config.cache_base_url or f"http://{config.server_host}:{config.server_port}" # 实际生效的值
}
}
+@router.post("/api/cache/enabled")
+async def update_cache_enabled(
+ request: dict,
+ token: str = Depends(verify_admin_token)
+):
+ """Update cache enabled status"""
+ try:
+ enabled = request.get("enabled", True)
+
+ # Update config file
+ config_path = Path("config/setting.toml")
+ with open(config_path, "r", encoding="utf-8") as f:
+ config_data = toml.load(f)
+
+ if "cache" not in config_data:
+ config_data["cache"] = {}
+
+ config_data["cache"]["enabled"] = enabled
+
+ with open(config_path, "w", encoding="utf-8") as f:
+ toml.dump(config_data, f)
+
+ # Update in-memory config
+ config.set_cache_enabled(enabled)
+
+ # Reload config to ensure consistency
+ config.reload_config()
+
+ return {
+ "success": True,
+ "message": f"Cache {'enabled' if enabled else 'disabled'} successfully",
+ "enabled": enabled
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to update cache enabled status: {str(e)}")
+
# Generation timeout config endpoints
@router.get("/api/generation/timeout")
async def get_generation_timeout(token: str = Depends(verify_admin_token)):
@@ -862,3 +899,53 @@ async def update_video_length_config(
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update video length config: {str(e)}")
+
+# AT auto refresh config endpoints
+@router.get("/api/token-refresh/config")
+async def get_at_auto_refresh_config(token: str = Depends(verify_admin_token)):
+ """Get AT auto refresh configuration"""
+ # Reload config from file to get latest values
+ config.reload_config()
+
+ return {
+ "success": True,
+ "config": {
+ "at_auto_refresh_enabled": config.at_auto_refresh_enabled
+ }
+ }
+
+@router.post("/api/token-refresh/enabled")
+async def update_at_auto_refresh_enabled(
+ request: dict,
+ token: str = Depends(verify_admin_token)
+):
+ """Update AT auto refresh enabled status"""
+ try:
+ enabled = request.get("enabled", False)
+
+ # Update config file
+ config_path = Path("config/setting.toml")
+ with open(config_path, "r", encoding="utf-8") as f:
+ config_data = toml.load(f)
+
+ if "token_refresh" not in config_data:
+ config_data["token_refresh"] = {}
+
+ config_data["token_refresh"]["at_auto_refresh_enabled"] = enabled
+
+ with open(config_path, "w", encoding="utf-8") as f:
+ toml.dump(config_data, f)
+
+ # Update in-memory config
+ config.set_at_auto_refresh_enabled(enabled)
+
+ # Reload config to ensure consistency
+ config.reload_config()
+
+ return {
+ "success": True,
+ "message": f"AT auto refresh {'enabled' if enabled else 'disabled'} successfully",
+ "enabled": enabled
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to update AT auto refresh enabled status: {str(e)}")
diff --git a/src/core/config.py b/src/core/config.py
index 5d47188..9328160 100644
--- a/src/core/config.py
+++ b/src/core/config.py
@@ -120,6 +120,17 @@ class Config:
self._config["cache"] = {}
self._config["cache"]["base_url"] = base_url
+ @property
+ def cache_enabled(self) -> bool:
+ """Get cache enabled status"""
+ return self._config.get("cache", {}).get("enabled", True)
+
+ def set_cache_enabled(self, enabled: bool):
+ """Set cache enabled status"""
+ if "cache" not in self._config:
+ self._config["cache"] = {}
+ self._config["cache"]["enabled"] = enabled
+
@property
def image_timeout(self) -> int:
"""Get image generation timeout in seconds"""
@@ -168,5 +179,16 @@ class Config:
"""Get custom parse server access token"""
return self._config.get("watermark_free", {}).get("custom_parse_token", "")
+ @property
+ def at_auto_refresh_enabled(self) -> bool:
+ """Get AT auto refresh enabled status"""
+ return self._config.get("token_refresh", {}).get("at_auto_refresh_enabled", False)
+
+ def set_at_auto_refresh_enabled(self, enabled: bool):
+ """Set AT auto refresh enabled/disabled"""
+ if "token_refresh" not in self._config:
+ self._config["token_refresh"] = {}
+ self._config["token_refresh"]["at_auto_refresh_enabled"] = enabled
+
# Global config instance
config = Config()
diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py
index 7751f66..15b5436 100644
--- a/src/services/generation_handler.py
+++ b/src/services/generation_handler.py
@@ -229,6 +229,8 @@ class GenerationHandler:
start_time = time.time()
last_heartbeat_time = start_time # Track last heartbeat for image generation
heartbeat_interval = 10 # Send heartbeat every 10 seconds for image generation
+ last_status_output_time = start_time # Track last status output time for video generation
+ video_status_interval = 30 # Output status every 30 seconds for video generation
debug_logger.log_info(f"Starting task polling: task_id={task_id}, is_video={is_video}, timeout={timeout}s, max_attempts={max_attempts}")
@@ -275,16 +277,18 @@ class GenerationHandler:
else:
progress_pct = int(progress_pct * 100)
- # Only yield progress update if it changed
- if progress_pct != last_progress:
- last_progress = progress_pct
- status = task.get("status", "processing")
- debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
+ # Update last_progress for tracking
+ last_progress = progress_pct
+ status = task.get("status", "processing")
- if stream:
- yield self._format_stream_chunk(
- reasoning_content=f"**Video Generation Progress**: {progress_pct}% ({status})\n"
- )
+ # Output status every 30 seconds (not just when progress changes)
+ current_time = time.time()
+ if stream and (current_time - last_status_output_time >= video_status_interval):
+ last_status_output_time = current_time
+ debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
+ yield self._format_stream_chunk(
+ reasoning_content=f"**Video Generation Progress**: {progress_pct}% ({status})\n"
+ )
break
# If task not found in pending tasks, it's completed - fetch from drafts
@@ -356,43 +360,51 @@ class GenerationHandler:
if stream:
yield self._format_stream_chunk(
- reasoning_content=f"Video published successfully. Post ID: {post_id}\nNow caching watermark-free video...\n"
+ reasoning_content=f"Video published successfully. Post ID: {post_id}\nNow {'caching' if config.cache_enabled else 'preparing'} watermark-free video...\n"
)
- # Cache watermark-free video
- try:
- cached_filename = await self.file_cache.download_and_cache(watermark_free_url, "video")
- local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
- if stream:
- yield self._format_stream_chunk(
- reasoning_content="Watermark-free video cached successfully. Preparing final response...\n"
- )
-
- # Delete the published post after caching
+ # Cache watermark-free video (if cache enabled)
+ if config.cache_enabled:
try:
- debug_logger.log_info(f"Deleting published post: {post_id}")
- await self.sora_client.delete_post(post_id, token)
- debug_logger.log_info(f"Published post deleted successfully: {post_id}")
+ cached_filename = await self.file_cache.download_and_cache(watermark_free_url, "video")
+ local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream:
yield self._format_stream_chunk(
- reasoning_content="Published post deleted successfully.\n"
+ reasoning_content="Watermark-free video cached successfully. Preparing final response...\n"
)
- except Exception as delete_error:
- debug_logger.log_error(
- error_message=f"Failed to delete published post {post_id}: {str(delete_error)}",
- status_code=500,
- response_text=str(delete_error)
- )
+
+ # Delete the published post after caching
+ try:
+ debug_logger.log_info(f"Deleting published post: {post_id}")
+ await self.sora_client.delete_post(post_id, token)
+ debug_logger.log_info(f"Published post deleted successfully: {post_id}")
+ if stream:
+ yield self._format_stream_chunk(
+ reasoning_content="Published post deleted successfully.\n"
+ )
+ except Exception as delete_error:
+ debug_logger.log_error(
+ error_message=f"Failed to delete published post {post_id}: {str(delete_error)}",
+ status_code=500,
+ response_text=str(delete_error)
+ )
+ if stream:
+ yield self._format_stream_chunk(
+ reasoning_content=f"Warning: Failed to delete published post - {str(delete_error)}\n"
+ )
+ except Exception as cache_error:
+ # Fallback to watermark-free URL if caching fails
+ local_url = watermark_free_url
if stream:
yield self._format_stream_chunk(
- reasoning_content=f"Warning: Failed to delete published post - {str(delete_error)}\n"
+ reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original watermark-free URL instead...\n"
)
- except Exception as cache_error:
- # Fallback to watermark-free URL if caching fails
+ else:
+ # Cache disabled: use watermark-free URL directly
local_url = watermark_free_url
if stream:
yield self._format_stream_chunk(
- reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original watermark-free URL instead...\n"
+ reasoning_content="Cache is disabled. Using watermark-free URL directly...\n"
)
except Exception as publish_error:
@@ -410,34 +422,45 @@ class GenerationHandler:
url = item.get("downloadable_url") or item.get("url")
if not url:
raise Exception("Video URL not found")
- try:
- cached_filename = await self.file_cache.download_and_cache(url, "video")
- local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
- except Exception as cache_error:
+ if config.cache_enabled:
+ try:
+ cached_filename = await self.file_cache.download_and_cache(url, "video")
+ local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
+ except Exception as cache_error:
+ local_url = url
+ else:
local_url = url
else:
# Normal mode: use downloadable_url instead of url
url = item.get("downloadable_url") or item.get("url")
if url:
- # Cache video file
- if stream:
- yield self._format_stream_chunk(
- reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
- )
-
- try:
- cached_filename = await self.file_cache.download_and_cache(url, "video")
- local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
+ # Cache video file (if cache enabled)
+ if config.cache_enabled:
if stream:
yield self._format_stream_chunk(
- reasoning_content="Video file cached successfully. Preparing final response...\n"
+ reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
)
- except Exception as cache_error:
- # Fallback to original URL if caching fails
+
+ try:
+ cached_filename = await self.file_cache.download_and_cache(url, "video")
+ local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
+ if stream:
+ yield self._format_stream_chunk(
+ reasoning_content="Video file cached successfully. Preparing final response...\n"
+ )
+ except Exception as cache_error:
+ # Fallback to original URL if caching fails
+ local_url = url
+ if stream:
+ yield self._format_stream_chunk(
+ reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
+ )
+ else:
+ # Cache disabled: use original URL directly
local_url = url
if stream:
yield self._format_stream_chunk(
- reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
+ reasoning_content="**Video Generation Completed**\n\nCache is disabled. Using original URL directly...\n"
)
# Task completed
@@ -482,27 +505,37 @@ class GenerationHandler:
base_url = self._get_base_url()
local_urls = []
- for idx, url in enumerate(urls):
- try:
- cached_filename = await self.file_cache.download_and_cache(url, "image")
- local_url = f"{base_url}/tmp/{cached_filename}"
- local_urls.append(local_url)
- if stream and len(urls) > 1:
- yield self._format_stream_chunk(
- reasoning_content=f"Cached image {idx + 1}/{len(urls)}...\n"
- )
- except Exception as cache_error:
- # Fallback to original URL if caching fails
- local_urls.append(url)
- if stream:
- yield self._format_stream_chunk(
- reasoning_content=f"Warning: Failed to cache image {idx + 1} - {str(cache_error)}\nUsing original URL instead...\n"
- )
- if stream and all(u.startswith(base_url) for u in local_urls):
- yield self._format_stream_chunk(
- reasoning_content="All images cached successfully. Preparing final response...\n"
- )
+ # Check if cache is enabled
+ if config.cache_enabled:
+ for idx, url in enumerate(urls):
+ try:
+ cached_filename = await self.file_cache.download_and_cache(url, "image")
+ local_url = f"{base_url}/tmp/{cached_filename}"
+ local_urls.append(local_url)
+ if stream and len(urls) > 1:
+ yield self._format_stream_chunk(
+ reasoning_content=f"Cached image {idx + 1}/{len(urls)}...\n"
+ )
+ except Exception as cache_error:
+ # Fallback to original URL if caching fails
+ local_urls.append(url)
+ if stream:
+ yield self._format_stream_chunk(
+ reasoning_content=f"Warning: Failed to cache image {idx + 1} - {str(cache_error)}\nUsing original URL instead...\n"
+ )
+
+ if stream and all(u.startswith(base_url) for u in local_urls):
+ yield self._format_stream_chunk(
+ reasoning_content="All images cached successfully. Preparing final response...\n"
+ )
+ else:
+ # Cache disabled: use original URLs directly
+ local_urls = urls
+ if stream:
+ yield self._format_stream_chunk(
+ reasoning_content="Cache is disabled. Using original URLs directly...\n"
+ )
await self.db.update_task(
task_id, "completed", 100.0,
@@ -510,10 +543,10 @@ class GenerationHandler:
)
if stream:
- # Final response with content
- content_html = "".join([f"" for url in local_urls])
+ # Final response with content (Markdown format)
+ content_markdown = "\n".join([f"" for url in local_urls])
yield self._format_stream_chunk(
- content=content_html,
+ content=content_markdown,
finish_reason="STOP"
)
yield "data: [DONE]\n\n"
@@ -544,7 +577,7 @@ class GenerationHandler:
last_heartbeat_time = current_time
elapsed = int(current_time - start_time)
yield self._format_stream_chunk(
- reasoning_content=f"**Generating**\n\nImage generation in progress... ({elapsed}s elapsed)\n"
+ reasoning_content=f"Image generation in progress... ({elapsed}s elapsed)\n"
)
# If task not found in response, send heartbeat for image generation
@@ -554,7 +587,7 @@ class GenerationHandler:
last_heartbeat_time = current_time
elapsed = int(current_time - start_time)
yield self._format_stream_chunk(
- reasoning_content=f"**Generating**\n\nImage generation in progress... ({elapsed}s elapsed)\n"
+ reasoning_content=f"Image generation in progress... ({elapsed}s elapsed)\n"
)
# Progress update for stream mode (fallback if no status from API)
@@ -638,7 +671,7 @@ class GenerationHandler:
if media_type == "video":
content = f"```html\n\n```"
else:
- content = f"
"
+ content = f""
response = {
"id": f"chatcmpl-{datetime.now().timestamp()}",
diff --git a/src/services/load_balancer.py b/src/services/load_balancer.py
index bb76d2f..657eeea 100644
--- a/src/services/load_balancer.py
+++ b/src/services/load_balancer.py
@@ -25,6 +25,18 @@ class LoadBalancer:
Returns:
Selected token or None if no available tokens
"""
+ # Try to auto-refresh tokens expiring within 24 hours if enabled
+ if config.at_auto_refresh_enabled:
+ all_tokens = await self.token_manager.get_all_tokens()
+ for token in all_tokens:
+ if token.is_active and token.expiry_time:
+ from datetime import datetime
+ time_until_expiry = token.expiry_time - datetime.now()
+ hours_until_expiry = time_until_expiry.total_seconds() / 3600
+ # Refresh if expiry is within 24 hours
+ if hours_until_expiry <= 24:
+ await self.token_manager.auto_refresh_expiring_token(token.id)
+
active_tokens = await self.token_manager.get_active_tokens()
if not active_tokens:
diff --git a/src/services/token_manager.py b/src/services/token_manager.py
index cd5e3a9..71e42d5 100644
--- a/src/services/token_manager.py
+++ b/src/services/token_manager.py
@@ -871,3 +871,77 @@ class TokenManager:
print(f"Failed to refresh Sora2 remaining count: {e}")
except Exception as e:
print(f"Error in refresh_sora2_remaining_if_cooldown_expired: {e}")
+
+ async def auto_refresh_expiring_token(self, token_id: int) -> bool:
+ """
+ Auto refresh token when expiry time is within 24 hours using ST or RT
+
+ Returns:
+ True if refresh successful, False otherwise
+ """
+ try:
+ token_data = await self.db.get_token(token_id)
+ if not token_data:
+ return False
+
+ # Check if token is expiring within 24 hours
+ if not token_data.expiry_time:
+ return False # No expiry time set
+
+ time_until_expiry = token_data.expiry_time - datetime.now()
+ hours_until_expiry = time_until_expiry.total_seconds() / 3600
+
+ # Only refresh if expiry is within 24 hours (1440 minutes)
+ if hours_until_expiry > 24:
+ return False # Token not expiring soon
+
+ if hours_until_expiry < 0:
+ # Token already expired, still try to refresh
+ print(f"🔄 Token {token_id} 已过期,尝试自动刷新...")
+ else:
+ print(f"🔄 Token {token_id} 将在 {hours_until_expiry:.1f} 小时后过期,尝试自动刷新...")
+
+ # Priority: ST > RT
+ new_at = None
+ new_st = None
+ new_rt = None
+
+ if token_data.st:
+ # Try to refresh using ST
+ try:
+ print(f"📝 使用 ST 刷新 Token {token_id}...")
+ result = await self.st_to_at(token_data.st)
+ new_at = result.get("access_token")
+ # ST refresh doesn't return new ST, so keep the old one
+ new_st = token_data.st
+ print(f"✅ 使用 ST 刷新成功")
+ except Exception as e:
+ print(f"❌ 使用 ST 刷新失败: {e}")
+ new_at = None
+
+ if not new_at and token_data.rt:
+ # Try to refresh using RT
+ try:
+ print(f"📝 使用 RT 刷新 Token {token_id}...")
+ result = await self.rt_to_at(token_data.rt)
+ new_at = result.get("access_token")
+ new_rt = result.get("refresh_token", token_data.rt) # RT might be updated
+ print(f"✅ 使用 RT 刷新成功")
+ except Exception as e:
+ print(f"❌ 使用 RT 刷新失败: {e}")
+ new_at = None
+
+ if new_at:
+ # Update token with new AT
+ await self.update_token(token_id, token=new_at, st=new_st, rt=new_rt)
+ print(f"✅ Token {token_id} 已自动刷新")
+ return True
+ else:
+ # No ST or RT, disable token
+ print(f"⚠️ Token {token_id} 无法刷新(无 ST 或 RT),已禁用")
+ await self.disable_token(token_id)
+ return False
+
+ except Exception as e:
+ print(f"❌ 自动刷新 Token {token_id} 失败: {e}")
+ return False
diff --git a/static/manage.html b/static/manage.html
index 5595355..8669650 100644
--- a/static/manage.html
+++ b/static/manage.html
@@ -74,7 +74,22 @@