feat: 新增图片上传专用代理,完善轮询与POW调用链路,重构POW与token传递

This commit is contained in:
TheSmallHanCat
2026-03-07 12:57:21 +08:00
parent ad554d900a
commit e7d91b31a7
12 changed files with 680 additions and 189 deletions

View File

@@ -40,6 +40,8 @@ auto_disable_on_401 = true
[proxy]
proxy_enabled = false
proxy_url = ""
image_upload_proxy_enabled = false
image_upload_proxy_url = ""
[watermark_free]
watermark_free_enabled = false
@@ -63,7 +65,7 @@ timezone_offset = 8
[pow_service]
# beta测试目前仍处于测试阶段
# POW 计算模式local本地计算或 external外部服务
mode = "external"
mode = "local"
# 是否使用对应 token 进行 POW 计算(默认关闭)
# local 模式开启后会使用当前轮询 token 获取 POW
# external 模式开启后会向外部服务传递 accesstoken 字段

View File

@@ -35,6 +35,8 @@ error_ban_threshold = 3
[proxy]
proxy_enabled = true
proxy_url = "socks5://warp:1080"
image_upload_proxy_enabled = false
image_upload_proxy_url = ""
[watermark_free]
watermark_free_enabled = false

View File

@@ -129,8 +129,10 @@ class UpdateAdminConfigRequest(BaseModel):
auto_disable_on_401: Optional[bool] = None
class UpdateProxyConfigRequest(BaseModel):
proxy_enabled: bool
proxy_enabled: Optional[bool] = None
proxy_url: Optional[str] = None
image_upload_proxy_enabled: Optional[bool] = None
image_upload_proxy_url: Optional[str] = None
class TestProxyRequest(BaseModel):
test_url: Optional[str] = "https://sora.chatgpt.com"
@@ -166,6 +168,7 @@ class UpdateWatermarkFreeConfigRequest(BaseModel):
class UpdateCallLogicConfigRequest(BaseModel):
call_mode: Optional[str] = None # "default" or "polling"
polling_mode_enabled: Optional[bool] = None # Legacy support
poll_interval: Optional[float] = None # Progress polling interval (seconds)
class UpdatePowProxyConfigRequest(BaseModel):
pow_proxy_enabled: bool
@@ -943,7 +946,9 @@ async def get_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
config = await proxy_manager.get_proxy_config()
return {
"proxy_enabled": config.proxy_enabled,
"proxy_url": config.proxy_url
"proxy_url": config.proxy_url,
"image_upload_proxy_enabled": config.image_upload_proxy_enabled,
"image_upload_proxy_url": config.image_upload_proxy_url
}
@router.post("/api/proxy/config")
@@ -953,7 +958,26 @@ async def update_proxy_config(
):
"""Update proxy configuration"""
try:
await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
current_config = await proxy_manager.get_proxy_config()
proxy_enabled = current_config.proxy_enabled if request.proxy_enabled is None else request.proxy_enabled
proxy_url = current_config.proxy_url if request.proxy_url is None else request.proxy_url
image_upload_proxy_enabled = (
current_config.image_upload_proxy_enabled
if request.image_upload_proxy_enabled is None
else request.image_upload_proxy_enabled
)
image_upload_proxy_url = (
current_config.image_upload_proxy_url
if request.image_upload_proxy_url is None
else request.image_upload_proxy_url
)
await proxy_manager.update_proxy_config(
proxy_enabled,
proxy_url,
image_upload_proxy_enabled,
image_upload_proxy_url
)
return {"success": True, "message": "Proxy configuration updated"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -1350,11 +1374,19 @@ async def get_call_logic_config(token: str = Depends(verify_admin_token)) -> dic
call_mode = getattr(config_obj, "call_mode", None)
if call_mode not in ("default", "polling"):
call_mode = "polling" if config_obj.polling_mode_enabled else "default"
poll_interval = getattr(config_obj, "poll_interval", 2.5)
try:
poll_interval = float(poll_interval)
except (TypeError, ValueError):
poll_interval = 2.5
if poll_interval <= 0:
poll_interval = 2.5
return {
"success": True,
"config": {
"call_mode": call_mode,
"polling_mode_enabled": call_mode == "polling"
"polling_mode_enabled": call_mode == "polling",
"poll_interval": poll_interval
}
}
@@ -1371,13 +1403,26 @@ async def update_call_logic_config(
if call_mode is None:
raise HTTPException(status_code=400, detail="Invalid call_mode")
await db.update_call_logic_config(call_mode)
poll_interval = request.poll_interval
if poll_interval is not None:
try:
poll_interval = float(poll_interval)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="poll_interval must be a valid number")
if poll_interval <= 0:
raise HTTPException(status_code=400, detail="poll_interval must be greater than 0")
await db.update_call_logic_config(call_mode, poll_interval)
config.set_call_logic_mode(call_mode)
if poll_interval is not None:
config.set_poll_interval(poll_interval)
return {
"success": True,
"message": "Call logic configuration updated",
"call_mode": call_mode,
"polling_mode_enabled": call_mode == "polling"
"polling_mode_enabled": call_mode == "polling",
"poll_interval": config.poll_interval
}
except HTTPException:
raise

View File

@@ -56,6 +56,12 @@ class Config:
@property
def poll_interval(self) -> float:
return self._config["sora"]["poll_interval"]
def set_poll_interval(self, interval: float):
"""Set task progress polling interval in seconds"""
if "sora" not in self._config:
self._config["sora"] = {}
self._config["sora"]["poll_interval"] = float(interval)
@property
def max_poll_attempts(self) -> int:

View File

@@ -83,18 +83,25 @@ class Database:
# Get proxy config from config_dict if provided, otherwise use defaults
proxy_enabled = False
proxy_url = None
image_upload_proxy_enabled = False
image_upload_proxy_url = None
if config_dict:
proxy_config = config_dict.get("proxy", {})
proxy_enabled = proxy_config.get("proxy_enabled", False)
proxy_url = proxy_config.get("proxy_url", "")
image_upload_proxy_enabled = proxy_config.get("image_upload_proxy_enabled", False)
image_upload_proxy_url = proxy_config.get("image_upload_proxy_url", "")
# Convert empty string to None
proxy_url = proxy_url if proxy_url else None
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
await db.execute("""
INSERT INTO proxy_config (id, proxy_enabled, proxy_url)
VALUES (1, ?, ?)
""", (proxy_enabled, proxy_url))
INSERT INTO proxy_config (
id, proxy_enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url
)
VALUES (1, ?, ?, ?, ?)
""", (proxy_enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url))
# Ensure watermark_free_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM watermark_free_config")
@@ -187,6 +194,7 @@ class Database:
# Get call logic config from config_dict if provided, otherwise use defaults
call_mode = "default"
polling_mode_enabled = False
poll_interval = 2.5
if config_dict:
call_logic_config = config_dict.get("call_logic", {})
@@ -199,10 +207,22 @@ class Database:
else:
polling_mode_enabled = call_mode == "polling"
sora_config = config_dict.get("sora", {})
poll_interval = sora_config.get("poll_interval", 2.5)
if "poll_interval" in call_logic_config:
poll_interval = call_logic_config.get("poll_interval", poll_interval)
try:
poll_interval = float(poll_interval)
except (TypeError, ValueError):
poll_interval = 2.5
if poll_interval <= 0:
poll_interval = 2.5
await db.execute("""
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled)
VALUES (1, ?, ?)
""", (call_mode, polling_mode_enabled))
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled, poll_interval)
VALUES (1, ?, ?, ?)
""", (call_mode, polling_mode_enabled, poll_interval))
# Ensure pow_proxy_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM pow_proxy_config")
@@ -321,6 +341,42 @@ class Database:
except Exception as e:
print(f" ✗ Failed to add column '{col_name}': {e}")
# Check and add missing columns to proxy_config table
if await self._table_exists(db, "proxy_config"):
added_image_upload_proxy_enabled_column = False
added_image_upload_proxy_url_column = False
columns_to_add = [
("image_upload_proxy_enabled", "BOOLEAN DEFAULT 0"),
("image_upload_proxy_url", "TEXT"),
]
for col_name, col_type in columns_to_add:
if not await self._column_exists(db, "proxy_config", col_name):
try:
await db.execute(f"ALTER TABLE proxy_config ADD COLUMN {col_name} {col_type}")
print(f" ✓ Added column '{col_name}' to proxy_config table")
if col_name == "image_upload_proxy_enabled":
added_image_upload_proxy_enabled_column = True
if col_name == "image_upload_proxy_url":
added_image_upload_proxy_url_column = True
except Exception as e:
print(f" ✗ Failed to add column '{col_name}': {e}")
# On upgrade, initialize value from setting.toml only when columns are newly added
if config_dict and (added_image_upload_proxy_enabled_column or added_image_upload_proxy_url_column):
try:
proxy_config = config_dict.get("proxy", {})
image_upload_proxy_enabled = proxy_config.get("image_upload_proxy_enabled", False)
image_upload_proxy_url = proxy_config.get("image_upload_proxy_url", "")
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
await db.execute("""
UPDATE proxy_config
SET image_upload_proxy_enabled = ?, image_upload_proxy_url = ?
WHERE id = 1
""", (image_upload_proxy_enabled, image_upload_proxy_url))
except Exception as e:
print(f" ✗ Failed to initialize image upload proxy config from config: {e}")
# Check and add missing columns to pow_service_config table
if await self._table_exists(db, "pow_service_config"):
added_use_token_for_pow_column = False
@@ -350,6 +406,38 @@ class Database:
except Exception as e:
print(f" ✗ Failed to initialize use_token_for_pow from config: {e}")
# Check and add missing columns to call_logic_config table
if await self._table_exists(db, "call_logic_config"):
added_poll_interval_column = False
columns_to_add = [
("poll_interval", "REAL DEFAULT 2.5"),
]
for col_name, col_type in columns_to_add:
if not await self._column_exists(db, "call_logic_config", col_name):
try:
await db.execute(f"ALTER TABLE call_logic_config ADD COLUMN {col_name} {col_type}")
print(f" ✓ Added column '{col_name}' to call_logic_config table")
if col_name == "poll_interval":
added_poll_interval_column = True
except Exception as e:
print(f" ✗ Failed to add column '{col_name}': {e}")
# On upgrade, initialize value from setting.toml only when this column is newly added
if config_dict and added_poll_interval_column:
try:
poll_interval = config_dict.get("sora", {}).get("poll_interval", 2.5)
poll_interval = float(poll_interval)
if poll_interval <= 0:
poll_interval = 2.5
await db.execute("""
UPDATE call_logic_config
SET poll_interval = ?
WHERE id = 1
""", (poll_interval,))
except Exception as e:
print(f" ✗ Failed to initialize poll_interval from config: {e}")
# Check and add missing columns to watermark_free_config table
if await self._table_exists(db, "watermark_free_config"):
columns_to_add = [
@@ -389,8 +477,13 @@ class Database:
await db.commit()
print("Database migration check completed.")
async def init_db(self):
"""Initialize database tables - creates all tables and ensures data integrity"""
async def init_db(self, config_dict: dict = None):
"""Initialize database tables - creates all tables and ensures data integrity
Args:
config_dict: Configuration dictionary from setting.toml (optional).
Used to initialize newly-added proxy columns during migration.
"""
async with aiosqlite.connect(self.db_path) as db:
# Tokens table
await db.execute("""
@@ -503,6 +596,8 @@ class Database:
id INTEGER PRIMARY KEY DEFAULT 1,
proxy_enabled BOOLEAN DEFAULT 0,
proxy_url TEXT,
image_upload_proxy_enabled BOOLEAN DEFAULT 0,
image_upload_proxy_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -561,6 +656,7 @@ class Database:
id INTEGER PRIMARY KEY DEFAULT 1,
call_mode TEXT DEFAULT 'default',
polling_mode_enabled BOOLEAN DEFAULT 0,
poll_interval REAL DEFAULT 2.5,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -619,6 +715,28 @@ class Database:
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
# Migration: Add image upload proxy columns to proxy_config table if they don't exist
added_image_upload_proxy_enabled_column = False
added_image_upload_proxy_url_column = False
if not await self._column_exists(db, "proxy_config", "image_upload_proxy_enabled"):
await db.execute("ALTER TABLE proxy_config ADD COLUMN image_upload_proxy_enabled BOOLEAN DEFAULT 0")
added_image_upload_proxy_enabled_column = True
if not await self._column_exists(db, "proxy_config", "image_upload_proxy_url"):
await db.execute("ALTER TABLE proxy_config ADD COLUMN image_upload_proxy_url TEXT")
added_image_upload_proxy_url_column = True
# If migration added image upload proxy columns, initialize them from setting.toml defaults
if config_dict and (added_image_upload_proxy_enabled_column or added_image_upload_proxy_url_column):
proxy_config = config_dict.get("proxy", {})
image_upload_proxy_enabled = proxy_config.get("image_upload_proxy_enabled", False)
image_upload_proxy_url = proxy_config.get("image_upload_proxy_url", "")
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
await db.execute("""
UPDATE proxy_config
SET image_upload_proxy_enabled = ?, image_upload_proxy_url = ?
WHERE id = 1
""", (image_upload_proxy_enabled, image_upload_proxy_url))
# Migration: Add disabled_reason column to tokens table if it doesn't exist
if not await self._column_exists(db, "tokens", "disabled_reason"):
await db.execute("ALTER TABLE tokens ADD COLUMN disabled_reason TEXT")
@@ -1188,14 +1306,26 @@ class Database:
# This should not happen in normal operation as _ensure_config_rows should create it
return ProxyConfig(proxy_enabled=False)
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
async def update_proxy_config(
self,
enabled: bool,
proxy_url: Optional[str],
image_upload_proxy_enabled: bool = False,
image_upload_proxy_url: Optional[str] = None
):
"""Update proxy configuration"""
proxy_url = proxy_url if proxy_url else None
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE proxy_config
SET proxy_enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP
SET proxy_enabled = ?,
proxy_url = ?,
image_upload_proxy_enabled = ?,
image_upload_proxy_url = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (enabled, proxy_url))
""", (enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url))
await db.commit()
# Watermark-free config operations
@@ -1348,19 +1478,46 @@ class Database:
row_dict = dict(row)
if not row_dict.get("call_mode"):
row_dict["call_mode"] = "polling" if row_dict.get("polling_mode_enabled") else "default"
poll_interval = row_dict.get("poll_interval", 2.5)
try:
poll_interval = float(poll_interval)
except (TypeError, ValueError):
poll_interval = 2.5
if poll_interval <= 0:
poll_interval = 2.5
row_dict["poll_interval"] = poll_interval
return CallLogicConfig(**row_dict)
return CallLogicConfig(call_mode="default", polling_mode_enabled=False)
return CallLogicConfig(call_mode="default", polling_mode_enabled=False, poll_interval=2.5)
async def update_call_logic_config(self, call_mode: str):
async def update_call_logic_config(self, call_mode: str, poll_interval: Optional[float] = None):
"""Update call logic configuration"""
normalized = "polling" if call_mode == "polling" else "default"
polling_mode_enabled = normalized == "polling"
async with aiosqlite.connect(self.db_path) as db:
effective_poll_interval = 2.5
cursor = await db.execute("SELECT poll_interval FROM call_logic_config WHERE id = 1")
row = await cursor.fetchone()
if row and row[0] is not None:
try:
effective_poll_interval = float(row[0])
except (TypeError, ValueError):
effective_poll_interval = 2.5
if effective_poll_interval <= 0:
effective_poll_interval = 2.5
if poll_interval is not None:
try:
effective_poll_interval = float(poll_interval)
except (TypeError, ValueError):
effective_poll_interval = 2.5
if effective_poll_interval <= 0:
effective_poll_interval = 2.5
# Use INSERT OR REPLACE to ensure the row exists
await db.execute("""
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
""", (normalized, polling_mode_enabled))
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, poll_interval, updated_at)
VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP)
""", (normalized, polling_mode_enabled, effective_poll_interval))
await db.commit()
# POW proxy config operations

View File

@@ -102,6 +102,8 @@ class ProxyConfig(BaseModel):
id: int = 1
proxy_enabled: bool # Read from database, initialized from setting.toml on first startup
proxy_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
image_upload_proxy_enabled: bool = False # Image upload proxy enabled
image_upload_proxy_url: Optional[str] = None # Image upload proxy URL
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -145,6 +147,7 @@ class CallLogicConfig(BaseModel):
id: int = 1
call_mode: str = "default" # "default" or "polling"
polling_mode_enabled: bool = False # Read from database, initialized from setting.toml on first startup
poll_interval: float = 2.5 # Progress polling interval in seconds
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

View File

@@ -103,7 +103,7 @@ async def startup_event():
is_first_startup = not db.db_exists()
# Initialize database tables
await db.init_db()
await db.init_db(config_dict)
# Handle database initialization based on startup type
if is_first_startup:
@@ -142,7 +142,8 @@ async def startup_event():
# Load call logic configuration from database
call_logic_config = await db.get_call_logic_config()
config.set_call_logic_mode(call_logic_config.call_mode)
print(f"✓ Call logic mode: {call_logic_config.call_mode}")
config.set_poll_interval(call_logic_config.poll_interval)
print(f"✓ Call logic mode: {call_logic_config.call_mode}, poll_interval: {call_logic_config.poll_interval}s")
# Load POW service configuration from database
pow_service_config = await db.get_pow_service_config()

View File

@@ -611,7 +611,11 @@ class GenerationHandler:
is_first_chunk = False
image_data = self._decode_base64_image(image)
media_id = await self.sora_client.upload_image(image_data, token_obj.token)
media_id = await self.sora_client.upload_image(
image_data,
token_obj.token,
token_id=token_obj.id
)
if stream:
yield self._format_stream_chunk(
@@ -1712,7 +1716,11 @@ class GenerationHandler:
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)
asset_pointer = await self.sora_client.upload_character_image(
avatar_data,
token_obj.token,
token_id=token_obj.id
)
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
# Step 5: Finalize character
@@ -1896,7 +1904,11 @@ class GenerationHandler:
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)
asset_pointer = await self.sora_client.upload_character_image(
avatar_data,
token_obj.token,
token_id=token_obj.id
)
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
# Step 5: Finalize character
@@ -2096,7 +2108,11 @@ class GenerationHandler:
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)
asset_pointer = await self.sora_client.upload_character_image(
avatar_data,
token_obj.token,
token_id=token_obj.id
)
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
# Step 5: Finalize character

View File

@@ -1,29 +1,44 @@
"""POW Service Client - External POW service integration"""
import json
from typing import Optional, Tuple
"""POW Service Client - External POW service integration (POST /api/v1/sora/sentinel-token)"""
from typing import NamedTuple, Optional
from curl_cffi.requests import AsyncSession
from ..core.config import config
from ..core.logger import debug_logger
class POWServiceClient:
"""Client for external POW service API"""
class SentinelResult(NamedTuple):
"""Result from external sentinel-token API."""
async def get_sentinel_token(self, access_token: Optional[str] = None) -> Optional[Tuple[str, str, str]]:
"""Get sentinel token from external POW service
sentinel_token: str
device_id: Optional[str]
user_agent: Optional[str]
cookie_header: Optional[str]
class POWServiceClient:
"""Client for external POW service API."""
async def get_sentinel_token(
self,
access_token: Optional[str] = None,
session_token: Optional[str] = None,
proxy_url: Optional[str] = None,
device_type: str = "ios",
) -> Optional[SentinelResult]:
"""Get sentinel token from external POW service.
Args:
access_token: Optional access token to send to POW service
access_token: Sora access token (optional).
session_token: Sora session token (optional).
proxy_url: Proxy URL for upstream solver (optional).
device_type: Device type hint for upstream solver.
Returns:
Tuple of (sentinel_token, device_id, user_agent) or None on failure
SentinelResult or None on failure.
"""
# Read configuration dynamically on each call
server_url = config.pow_service_server_url
api_key = config.pow_service_api_key
proxy_enabled = config.pow_service_proxy_enabled
proxy_url = config.pow_service_proxy_url if proxy_enabled else None
request_proxy = config.pow_service_proxy_url if config.pow_service_proxy_enabled else None
if not server_url or not api_key:
debug_logger.log_error(
@@ -34,8 +49,7 @@ class POWServiceClient:
)
return None
# Construct API endpoint
api_url = f"{server_url.rstrip('/')}/api/pow/token"
api_url = f"{server_url.rstrip('/')}/api/v1/sora/sentinel-token"
headers = {
"Authorization": f"Bearer {api_key}",
@@ -43,10 +57,15 @@ class POWServiceClient:
"Content-Type": "application/json",
}
# Controlled by config switch: whether to pass current token to POW service
send_access_token = bool(config.pow_service_use_token_for_pow and access_token)
payload = {"device_type": device_type}
if access_token:
payload["access_token"] = access_token
if session_token:
payload["session_token"] = session_token
if proxy_url:
payload["proxy_url"] = proxy_url
def _mask_token(token_value: Optional[str]) -> str:
def _mask(token_value: Optional[str]) -> str:
if not token_value:
return "none"
if len(token_value) <= 10:
@@ -54,69 +73,33 @@ class POWServiceClient:
return f"{token_value[:6]}...{token_value[-4:]}"
debug_logger.log_info(
f"[POW Service] use_token_for_pow={config.pow_service_use_token_for_pow}, access_token={_mask_token(access_token)}"
f"[POW Service] POST {api_url} access_token={_mask(access_token)} proxy_url={proxy_url or 'none'}"
)
try:
debug_logger.log_info(f"[POW Service] Requesting token from {api_url}")
async with AsyncSession(impersonate="chrome131") as session:
# Preferred protocol: POST + JSON body
payload = {"flow": "sora_init"}
if send_access_token:
payload["accesstoken"] = access_token
response = await session.post(
api_url,
headers=headers,
json=payload,
proxy=proxy_url,
timeout=30
proxy=request_proxy,
timeout=30,
)
# Backward compatibility: older services may only support GET + X-Access-Token
if response.status_code in (404, 405, 415):
fallback_headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
if send_access_token:
fallback_headers["X-Access-Token"] = access_token
debug_logger.log_info(
f"[POW Service] POST unsupported ({response.status_code}), fallback to GET compatibility mode"
)
response = await session.get(
api_url,
headers=fallback_headers,
proxy=proxy_url,
timeout=30
)
if response.status_code != 200:
error_msg = f"POW service request failed: {response.status_code}"
debug_logger.log_error(
error_message=error_msg,
error_message=f"POW service request failed: {response.status_code}",
status_code=response.status_code,
response_text=response.text,
source="POWServiceClient"
source="POWServiceClient",
)
return None
data = response.json()
if not data.get("success"):
debug_logger.log_error(
error_message="POW service returned success=false",
status_code=response.status_code,
response_text=response.text,
source="POWServiceClient"
)
return None
token = data.get("token")
token = data.get("sentinel_token")
device_id = data.get("device_id")
user_agent = data.get("user_agent")
cached = data.get("cached", False)
cookie_header = data.get("cookie_header")
if not token:
debug_logger.log_error(
@@ -127,42 +110,16 @@ class POWServiceClient:
)
return None
# Parse token to extract device_id if not provided
token_data = None
if not device_id:
try:
token_data = json.loads(token)
device_id = token_data.get("id")
except:
pass
# 记录详细的 token 信息
cache_status = "cached" if cached else "fresh"
debug_logger.log_info("=" * 100)
debug_logger.log_info(f"[POW Service] Token obtained successfully ({cache_status})")
debug_logger.log_info(f"[POW Service] Token length: {len(token)}")
debug_logger.log_info(f"[POW Service] Device ID: {device_id}")
debug_logger.log_info(f"[POW Service] User Agent: {user_agent}")
# 解析并显示 token 结构
if not token_data:
try:
token_data = json.loads(token)
except:
debug_logger.log_info(f"[POW Service] Token is not valid JSON")
token_data = None
if token_data:
debug_logger.log_info(f"[POW Service] Token structure keys: {list(token_data.keys())}")
for key, value in token_data.items():
if isinstance(value, str) and len(value) > 100:
debug_logger.log_info(f"[POW Service] Token[{key}]: <string, length={len(value)}>")
else:
debug_logger.log_info(f"[POW Service] Token[{key}]: {value}")
debug_logger.log_info("=" * 100)
return token, device_id, user_agent
debug_logger.log_info(
f"[POW Service] sentinel_token len={len(token)} device_id={device_id} "
f"ua={bool(user_agent)} cookie_header={bool(cookie_header)}"
)
return SentinelResult(
sentinel_token=token,
device_id=device_id,
user_agent=user_agent,
cookie_header=cookie_header,
)
except Exception as e:
debug_logger.log_error(
@@ -174,5 +131,4 @@ class POWServiceClient:
return None
# Global instance
pow_service_client = POWServiceClient()

View File

@@ -36,9 +36,42 @@ class ProxyManager:
return config.proxy_url
return None
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
async def get_image_upload_proxy_url(self, token_id: Optional[int] = None) -> Optional[str]:
"""Get proxy URL specifically for image uploads
Priority:
1. Image upload proxy (if enabled in config)
2. Token-specific proxy (if token_id provided)
3. Global proxy (fallback)
4. None (no proxy)
Args:
token_id: Token ID (optional). Used for fallback to token-specific proxy.
Returns:
Proxy URL string or None
"""
config = await self.db.get_proxy_config()
if config.image_upload_proxy_enabled and config.image_upload_proxy_url:
return config.image_upload_proxy_url
# Fallback to standard proxy resolution
return await self.get_proxy_url(token_id=token_id)
async def update_proxy_config(
self,
enabled: bool,
proxy_url: Optional[str],
image_upload_proxy_enabled: bool = False,
image_upload_proxy_url: Optional[str] = None
):
"""Update proxy configuration"""
await self.db.update_proxy_config(enabled, proxy_url)
await self.db.update_proxy_config(
enabled,
proxy_url,
image_upload_proxy_enabled,
image_upload_proxy_url
)
async def get_proxy_config(self) -> ProxyConfig:
"""Get proxy configuration"""

View File

@@ -36,6 +36,54 @@ _cached_sentinel_token_map = {}
_cached_device_id = None
def _extract_device_id_from_sentinel(sentinel_token: Optional[str]) -> Optional[str]:
"""Extract device id from sentinel token JSON."""
if not sentinel_token:
return None
try:
data = json.loads(sentinel_token)
if isinstance(data, dict):
value = data.get("id")
return str(value) if value else None
except Exception:
return None
return None
def _build_session_cookie_header(session_token: str) -> str:
"""Build session cookie header used by ChatGPT/Sora requests."""
return f"__Secure-next-auth.session-token={session_token}"
async def _resolve_session_token(
access_token: Optional[str] = None,
token_id: Optional[int] = None,
) -> Optional[str]:
"""Resolve session token (st) from token_id or access token."""
if not token_id and not access_token:
return None
try:
from ..core.database import Database
db = Database()
token_obj = None
if token_id:
token_obj = await db.get_token(token_id)
# Fallback by access token if token_id is unavailable or has no st
if (not token_obj or not token_obj.st) and access_token:
token_obj = await db.get_token_by_value(access_token)
if token_obj and token_obj.st:
return token_obj.st
except Exception as e:
debug_logger.log_warning(f"[Sentinel] Failed to resolve session token: {e}")
return None
async def _get_browser(proxy_url: str = None):
"""Get or create browser instance (reuses existing browser)"""
global _browser, _playwright, _current_proxy
@@ -81,7 +129,12 @@ async def _close_browser():
_playwright = None
async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
async def _fetch_oai_did(
proxy_url: str = None,
max_retries: int = 3,
session_token: Optional[str] = None,
cookie_header: Optional[str] = None,
) -> str:
"""Fetch oai-did using curl_cffi (lightweight approach)
Raises:
@@ -92,8 +145,15 @@ async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
for attempt in range(max_retries):
try:
async with AsyncSession(impersonate="chrome120") as session:
headers = None
if cookie_header:
headers = {"Cookie": cookie_header}
elif session_token:
headers = {"Cookie": _build_session_cookie_header(session_token)}
response = await session.get(
"https://chatgpt.com/",
headers=headers,
proxy=proxy_url,
timeout=30,
allow_redirects=True
@@ -130,7 +190,11 @@ async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
return None
async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id: str = None) -> str:
async def _generate_sentinel_token_lightweight(
proxy_url: str = None,
device_id: str = None,
session_token: Optional[str] = None,
) -> str:
"""Generate sentinel token using lightweight Playwright approach
Uses route interception and SDK injection for minimal resource usage.
@@ -154,7 +218,7 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
# Get oai-did
if not device_id:
device_id = await _fetch_oai_did(proxy_url)
device_id = await _fetch_oai_did(proxy_url, session_token=session_token)
if not device_id:
debug_logger.log_info("[Sentinel] Failed to get oai-did")
@@ -171,13 +235,24 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
bypass_csp=True
)
# Set cookie
await context.add_cookies([{
# Set oai-did cookie (+ session cookie when token-aware POW is enabled)
cookies_to_set = [{
'name': 'oai-did',
'value': device_id,
'domain': 'sora.chatgpt.com',
'path': '/'
}])
}]
if session_token:
cookies_to_set.append({
'name': '__Secure-next-auth.session-token',
'value': session_token,
'domain': '.chatgpt.com',
'path': '/',
'secure': True,
'httpOnly': True,
'sameSite': 'None',
})
await context.add_cookies(cookies_to_set)
page = await context.new_page()
@@ -231,16 +306,22 @@ 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, access_token: Optional[str] = None) -> str:
async def _get_cached_sentinel_token(
proxy_url: str = None,
force_refresh: bool = False,
access_token: Optional[str] = None,
token_id: Optional[int] = None,
) -> Optional[Dict[str, Optional[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
token_id: Optional token id to resolve session token for local POW
Returns:
Sentinel token string or None
Dict with sentinel_token/device_id/user_agent/cookie_header or None
Raises:
Exception: If 403/429 when fetching oai-did
@@ -248,43 +329,82 @@ async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool
global _cached_sentinel_token_map
# Whether current request should be token-aware for POW
use_token_for_pow = bool(config.pow_service_use_token_for_pow and access_token)
cache_key = access_token if use_token_for_pow else "__default__"
use_token_for_pow = bool(config.pow_service_use_token_for_pow and (access_token or token_id))
disable_cache_for_local_token_pow = bool(use_token_for_pow and config.pow_service_mode == "local")
if use_token_for_pow and access_token:
cache_key = access_token
elif use_token_for_pow and token_id:
cache_key = f"token_id:{token_id}"
else:
cache_key = "__default__"
session_token = await _resolve_session_token(access_token=access_token, token_id=token_id) if use_token_for_pow else None
# Check if external POW service is configured
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(
access_token=access_token if use_token_for_pow else None
access_token=access_token if use_token_for_pow else None,
session_token=session_token if use_token_for_pow else None,
proxy_url=proxy_url,
)
if result:
sentinel_token, device_id, service_user_agent = result
sentinel_data = {
"sentinel_token": result.sentinel_token,
"device_id": result.device_id or _extract_device_id_from_sentinel(result.sentinel_token),
"user_agent": result.user_agent,
"cookie_header": result.cookie_header,
}
debug_logger.log_info("[POW] External service returned sentinel token successfully")
return sentinel_token
return sentinel_data
else:
# Fallback to local mode if external service fails
debug_logger.log_info("[POW] External service failed, falling back to local mode")
# Local mode (original logic)
# Return cached token if available and not forcing refresh
if not force_refresh and cache_key in _cached_sentinel_token_map:
# Local mode
# local + token-aware POW: do not use cache (compute each time)
if disable_cache_for_local_token_pow:
debug_logger.log_info("[Sentinel] Local token-aware POW enabled, cache bypassed")
# Otherwise keep legacy cache behavior
elif not force_refresh and cache_key in _cached_sentinel_token_map:
if use_token_for_pow:
debug_logger.log_info("[Sentinel] Using token-scoped cached token")
else:
debug_logger.log_info("[Sentinel] Using shared cached token")
return _cached_sentinel_token_map[cache_key]
cached_value = _cached_sentinel_token_map[cache_key]
# Backward compatibility: migrate legacy string cache to structured cache.
if isinstance(cached_value, str):
cached_value = {
"sentinel_token": cached_value,
"device_id": _extract_device_id_from_sentinel(cached_value),
"user_agent": None,
"cookie_header": None,
}
_cached_sentinel_token_map[cache_key] = cached_value
return cached_value
# Generate new token
debug_logger.log_info("[Sentinel] Generating new token...")
token = await _generate_sentinel_token_lightweight(proxy_url)
token = await _generate_sentinel_token_lightweight(
proxy_url=proxy_url,
session_token=session_token if use_token_for_pow else None,
)
if token:
_cached_sentinel_token_map[cache_key] = token
debug_logger.log_info("[Sentinel] Token cached successfully")
sentinel_data = {
"sentinel_token": token,
"device_id": _extract_device_id_from_sentinel(token),
"user_agent": None,
"cookie_header": None,
}
if not disable_cache_for_local_token_pow:
_cached_sentinel_token_map[cache_key] = sentinel_data
debug_logger.log_info("[Sentinel] Token cached successfully")
else:
debug_logger.log_info("[Sentinel] Local token-aware POW generated (not cached)")
return sentinel_data
return token
return None
def _invalidate_sentinel_cache(access_token: Optional[str] = None):
@@ -632,9 +752,17 @@ class SoraClient:
)
return None
async def _nf_create_urllib(self, token: str, payload: dict, sentinel_token: str,
proxy_url: Optional[str], token_id: Optional[int] = None,
user_agent: Optional[str] = None) -> Dict[str, Any]:
async def _nf_create_urllib(
self,
token: str,
payload: dict,
sentinel_token: str,
proxy_url: Optional[str],
token_id: Optional[int] = None,
user_agent: Optional[str] = None,
device_id: Optional[str] = None,
cookie_header: Optional[str] = None,
) -> Dict[str, Any]:
"""Make nf/create request
Returns:
@@ -647,9 +775,17 @@ class SoraClient:
if not user_agent:
user_agent = random.choice(DESKTOP_USER_AGENTS)
import json as json_mod
sentinel_data = json_mod.loads(sentinel_token)
device_id = sentinel_data.get("id", str(uuid4()))
sentinel_data = {}
if not device_id:
device_id = _extract_device_id_from_sentinel(sentinel_token)
if not device_id:
device_id = str(uuid4())
try:
parsed_data = json.loads(sentinel_token)
if isinstance(parsed_data, dict):
sentinel_data = parsed_data
except Exception:
sentinel_data = {}
headers = {
"Authorization": f"Bearer {token}",
@@ -674,7 +810,10 @@ class SoraClient:
}
# 添加 Cookie 头(关键修复)
if token_id:
if cookie_header:
headers["Cookie"] = cookie_header
debug_logger.log_info(f"[nf/create] Using cookie header from POW service (length: {len(cookie_header)})")
elif token_id:
try:
from src.core.database import Database
db = Database()
@@ -762,31 +901,46 @@ class SoraClient:
except URLError as exc:
raise Exception(f"URL Error: {exc}") from exc
async def _generate_sentinel_token(self, token: Optional[str] = None, user_agent: Optional[str] = None) -> Tuple[str, str]:
async def _generate_sentinel_token(
self,
token: Optional[str] = None,
user_agent: Optional[str] = None,
pow_proxy_url: Optional[str] = None,
token_id: Optional[int] = None,
) -> Dict[str, Optional[str]]:
"""Generate openai-sentinel-token by calling /backend-api/sentinel/req and solving PoW
Supports two modes:
- external: Get complete sentinel token from external POW service
- local: Generate POW locally and call sentinel/req endpoint
"""
use_token_for_pow = bool(config.pow_service_use_token_for_pow and (token or token_id))
session_token = await _resolve_session_token(access_token=token, token_id=token_id) if use_token_for_pow else None
# 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(
access_token=token if config.pow_service_use_token_for_pow else None
access_token=token if use_token_for_pow else None,
session_token=session_token if use_token_for_pow else None,
proxy_url=pow_proxy_url,
)
if result:
sentinel_token, device_id, service_user_agent = result
# Use service user agent if provided, otherwise use default
final_user_agent = service_user_agent if service_user_agent else (
final_user_agent = result.user_agent if result.user_agent else (
user_agent if user_agent else
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36"
)
debug_logger.log_info(f"[Sentinel] Got token from external service")
debug_logger.log_info(f"[Sentinel] Token cached successfully (external)")
return sentinel_token, final_user_agent
return {
"sentinel_token": result.sentinel_token,
"user_agent": final_user_agent,
"device_id": result.device_id or _extract_device_id_from_sentinel(result.sentinel_token),
"cookie_header": result.cookie_header,
}
else:
# Fallback to local mode if external service fails
debug_logger.log_info("[Sentinel] External service failed, falling back to local mode")
@@ -806,7 +960,7 @@ class SoraClient:
}
ua_with_pow = f"{user_agent} {json.dumps(init_payload, separators=(',', ':'))}"
proxy_url = await self.proxy_manager.get_proxy_url()
proxy_url = pow_proxy_url or await self.proxy_manager.get_proxy_url(token_id)
# Request sentinel/req endpoint
url = f"{self.CHATGPT_BASE_URL}/backend-api/sentinel/req"
@@ -827,6 +981,9 @@ class SoraClient:
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
}
if use_token_for_pow and session_token:
headers["Cookie"] = _build_session_cookie_header(session_token)
debug_logger.log_info("[Sentinel] Local mode enabled token-aware cookie for sentinel/req")
try:
async with AsyncSession(impersonate="chrome131") as session:
@@ -860,7 +1017,12 @@ class SoraClient:
parsed = json.loads(sentinel_token)
debug_logger.log_info(f"Final sentinel: p_prefix={parsed['p'][:10]}, p_suffix={parsed['p'][-5:]}, t_len={len(parsed['t'])}, c_len={len(parsed['c'])}, flow={parsed['flow']}")
return sentinel_token, user_agent
return {
"sentinel_token": sentinel_token,
"user_agent": user_agent,
"device_id": _extract_device_id_from_sentinel(sentinel_token),
"cookie_header": None,
}
@staticmethod
def is_storyboard_prompt(prompt: str) -> bool:
@@ -928,7 +1090,8 @@ class SoraClient:
json_data: Optional[Dict] = None,
multipart: Optional[Dict] = None,
add_sentinel_token: bool = False,
token_id: Optional[int] = None) -> Dict[str, Any]:
token_id: Optional[int] = None,
use_image_upload_proxy: bool = False) -> Dict[str, Any]:
"""Make HTTP request with proxy support
Args:
@@ -939,8 +1102,12 @@ class SoraClient:
multipart: Multipart form data (for file uploads)
add_sentinel_token: Whether to add openai-sentinel-token header (only for generation requests)
token_id: Token ID for getting token-specific proxy (optional)
use_image_upload_proxy: Whether to use dedicated image upload proxy selection
"""
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
if use_image_upload_proxy:
proxy_url = await self.proxy_manager.get_image_upload_proxy_url(token_id)
else:
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
headers = {
"Authorization": f"Bearer {token}",
@@ -949,9 +1116,14 @@ class SoraClient:
# 只在生成请求时添加 sentinel token
if add_sentinel_token:
sentinel_token, ua = await self._generate_sentinel_token(token)
headers["openai-sentinel-token"] = sentinel_token
headers["User-Agent"] = ua
sentinel_context = await self._generate_sentinel_token(token, token_id=token_id)
headers["openai-sentinel-token"] = sentinel_context["sentinel_token"]
if sentinel_context.get("user_agent"):
headers["User-Agent"] = sentinel_context["user_agent"]
if sentinel_context.get("device_id"):
headers["oai-device-id"] = sentinel_context["device_id"]
if sentinel_context.get("cookie_header"):
headers["Cookie"] = sentinel_context["cookie_header"]
if not multipart:
headers["Content-Type"] = "application/json"
@@ -1055,7 +1227,13 @@ class SoraClient:
"""Get user information"""
return await self._make_request("GET", "/me", token)
async def upload_image(self, image_data: bytes, token: str, filename: str = "image.png") -> str:
async def upload_image(
self,
image_data: bytes,
token: str,
filename: str = "image.png",
token_id: Optional[int] = None
) -> str:
"""Upload image and return media_id
使用 CurlMime 对象上传文件curl_cffi 的正确方式)
@@ -1085,7 +1263,14 @@ class SoraClient:
data=filename.encode('utf-8')
)
result = await self._make_request("POST", "/uploads", token, multipart=mp)
result = await self._make_request(
"POST",
"/uploads",
token,
multipart=mp,
token_id=token_id,
use_image_upload_proxy=True
)
return result["id"]
async def generate_image(self, prompt: str, token: str, width: int = 360,
@@ -1161,7 +1346,12 @@ 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, access_token=token)
sentinel_context = await _get_cached_sentinel_token(
pow_proxy_url,
force_refresh=False,
access_token=token,
token_id=token_id,
)
except Exception as e:
# 403/429 errors from oai-did fetch - don't retry, just fail
error_str = str(e)
@@ -1173,16 +1363,30 @@ class SoraClient:
source="Server"
)
raise
sentinel_token = None
sentinel_context = None
if not sentinel_token:
if not sentinel_context:
# Fallback to manual POW if lightweight approach fails
debug_logger.log_info("[Warning] Lightweight sentinel token failed, falling back to manual POW")
sentinel_token, user_agent = await self._generate_sentinel_token(token)
sentinel_context = await self._generate_sentinel_token(
token,
user_agent=user_agent,
pow_proxy_url=pow_proxy_url,
token_id=token_id,
)
# First attempt with cached/generated token
try:
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
result = await self._nf_create_urllib(
token,
json_data,
sentinel_context["sentinel_token"],
proxy_url,
token_id,
sentinel_context.get("user_agent") or user_agent,
sentinel_context.get("device_id"),
sentinel_context.get("cookie_header"),
)
return result["id"]
except Exception as e:
error_str = str(e)
@@ -1195,21 +1399,40 @@ class SoraClient:
_invalidate_sentinel_cache(token)
try:
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True, access_token=token)
sentinel_context = await _get_cached_sentinel_token(
pow_proxy_url,
force_refresh=True,
access_token=token,
token_id=token_id,
)
except Exception as refresh_e:
# 403/429 errors - don't continue
error_str = str(refresh_e)
if "403" in error_str or "429" in error_str:
raise refresh_e
sentinel_token = None
sentinel_context = None
if not sentinel_token:
if not sentinel_context:
# Fallback to manual POW
debug_logger.log_info("[Warning] Refresh failed, falling back to manual POW")
sentinel_token, user_agent = await self._generate_sentinel_token(token)
sentinel_context = await self._generate_sentinel_token(
token,
user_agent=user_agent,
pow_proxy_url=pow_proxy_url,
token_id=token_id,
)
# Retry with fresh token
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
result = await self._nf_create_urllib(
token,
json_data,
sentinel_context["sentinel_token"],
proxy_url,
token_id,
sentinel_context.get("user_agent") or user_agent,
sentinel_context.get("device_id"),
sentinel_context.get("cookie_header"),
)
return result["id"]
# For other errors, just re-raise
@@ -1560,7 +1783,12 @@ class SoraClient:
await self._make_request("POST", f"/project_y/cameos/by_id/{cameo_id}/update_v2", token, json_data=json_data)
return True
async def upload_character_image(self, image_data: bytes, token: str) -> str:
async def upload_character_image(
self,
image_data: bytes,
token: str,
token_id: Optional[int] = None
) -> str:
"""Upload character image and return asset_pointer
Args:
@@ -1582,7 +1810,14 @@ class SoraClient:
data=b"profile"
)
result = await self._make_request("POST", "/project_y/file/upload", token, multipart=mp)
result = await self._make_request(
"POST",
"/project_y/file/upload",
token,
multipart=mp,
token_id=token_id,
use_image_upload_proxy=True
)
return result.get("asset_pointer")
async def delete_character(self, character_id: str, token: str) -> bool:
@@ -1648,8 +1883,16 @@ class SoraClient:
# Generate sentinel token and call /nf/create using urllib
proxy_url = await self.proxy_manager.get_proxy_url()
sentinel_token, user_agent = await self._generate_sentinel_token(token)
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, user_agent=user_agent)
sentinel_context = await self._generate_sentinel_token(token)
result = await self._nf_create_urllib(
token,
json_data,
sentinel_context["sentinel_token"],
proxy_url,
user_agent=sentinel_context.get("user_agent"),
device_id=sentinel_context.get("device_id"),
cookie_header=sentinel_context.get("cookie_header"),
)
return result.get("id")
async def extend_video(self, generation_id: str, prompt: str, extension_duration_s: int,

View File

@@ -374,6 +374,28 @@
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
</div>
<div class="space-y-4 p-4 rounded-md bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800">
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-100">图片上传专用代理</h4>
</div>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgImageUploadProxyEnabled" class="h-4 w-4 rounded border-input">
<span class="text-sm font-medium">启用图片上传专用代理</span>
</label>
<p class="text-xs text-muted-foreground mt-1">启用后,图片上传将使用下方设置的专用代理</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">图片上传代理地址</label>
<input id="cfgImageUploadProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:8888 或 socks5://127.0.0.1:1080">
<p class="text-xs text-muted-foreground mt-1">仅用于图片上传操作,未启用时将使用全局代理</p>
</div>
</div>
<div>
<label class="text-sm font-medium mb-2 block">测试域名</label>
<input id="cfgProxyTestUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://sora.chatgpt.com" value="https://sora.chatgpt.com">
@@ -585,6 +607,11 @@
</select>
<p class="text-xs text-muted-foreground mt-2">随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮</p>
</div>
<div>
<label class="text-sm font-medium block">进度轮询间隔(秒)</label>
<input id="cfgCallLogicPollInterval" type="number" step="0.1" min="0.1" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="2.5">
<p class="text-xs text-muted-foreground mt-2">控制生成任务进度查询的时间间隔,保存后立即热更新生效</p>
</div>
<button onclick="saveCallLogicConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
</div>
</div>
@@ -1122,10 +1149,10 @@
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
downloadDebugLogs=async()=>{try{const token=localStorage.getItem('adminToken');if(!token){showToast('未登录','error');return}const r=await fetch('/api/admin/logs/download',{headers:{Authorization:`Bearer ${token}`}});if(!r.ok){if(r.status===404){showToast('日志文件不存在','error')}else{showToast('下载失败','error')}return}const blob=await r.blob();const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=`logs_${new Date().toISOString().split('T')[0]}.txt`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast('日志文件下载成功','success')}catch(e){showToast('下载失败: '+e.message,'error')}},
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||'';$('cfgImageUploadProxyEnabled').checked=d.image_upload_proxy_enabled||false;$('cfgImageUploadProxyUrl').value=d.image_upload_proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
setProxyStatus=(msg,type='muted')=>{const el=$('proxyStatusMessage');if(!el)return;if(!msg){el.textContent='';el.classList.add('hidden');return}el.textContent=msg;el.classList.remove('hidden','text-muted-foreground','text-green-600','text-red-600');if(type==='success')el.classList.add('text-green-600');else if(type==='error')el.classList.add('text-red-600');else el.classList.add('text-muted-foreground')},
testProxyConfig=async()=>{const enabled=$('cfgProxyEnabled').checked;const url=$('cfgProxyUrl').value.trim();const testUrl=$('cfgProxyTestUrl').value.trim()||'https://sora.chatgpt.com';if(!enabled||!url){setProxyStatus('代理未启用或地址为空','error');return}try{setProxyStatus('正在测试代理连接...','muted');const r=await apiRequest('/api/proxy/test',{method:'POST',body:JSON.stringify({test_url:testUrl})});if(!r)return;const d=await r.json();if(d.success){setProxyStatus(`${d.message||'代理可用'} - 测试域名: ${d.test_url||testUrl}`,'success')}else{setProxyStatus(`${d.message||'代理不可用'} - 测试域名: ${d.test_url||testUrl}`,'error')}}catch(e){setProxyStatus('代理测试失败: '+e.message,'error')}},
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim(),image_upload_proxy_enabled:$('cfgImageUploadProxyEnabled').checked,image_upload_proxy_url:$('cfgImageUploadProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';$('cfgFallbackOnFailure').checked=d.fallback_on_failure!==false;toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim(),fallbackOnFailure=$('cfgFallbackOnFailure').checked;if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null,fallback_on_failure:fallbackOnFailure})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
@@ -1147,8 +1174,8 @@
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
loadCharacters=async()=>{try{const r=await apiRequest('/api/characters');if(!r)return;const d=await r.json();const g=$('charactersGrid');if(!d||d.length===0){g.innerHTML='<div class="col-span-full text-center py-8 text-muted-foreground">暂无角色卡</div>';return}g.innerHTML=d.map(c=>`<div class="rounded-lg border border-border bg-background p-4"><div class="flex items-start gap-3"><img src="${c.avatar_path||'/static/favicon.ico'}" class="h-14 w-14 rounded-lg object-cover" onerror="this.src='/static/favicon.ico'"/><div class="flex-1 min-w-0"><div class="font-semibold truncate">${c.display_name||c.username}</div><div class="text-xs text-muted-foreground truncate">@${c.username}</div>${c.description?`<div class="text-xs text-muted-foreground mt-1 line-clamp-2">${c.description}</div>`:''}</div></div><div class="mt-3 flex gap-2"><button onclick="deleteCharacter(${c.id})" class="flex-1 inline-flex items-center justify-center rounded-md border border-destructive text-destructive hover:bg-destructive hover:text-white h-8 px-3 text-sm transition-colors">删除</button></div></div>`).join('')}catch(e){showToast('加载失败: '+e.message,'error')}},
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');const pollInterval=Number(d.config.poll_interval||2.5);$('cfgCallLogicMode').value=mode;$('cfgCallLogicPollInterval').value=Number.isFinite(pollInterval)&&pollInterval>0?pollInterval:2.5}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const pollInterval=parseFloat($('cfgCallLogicPollInterval').value||'2.5');if(!Number.isFinite(pollInterval)||pollInterval<=0)return showToast('进度轮询间隔必须大于0','error');const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode,poll_interval:pollInterval})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功(已立即生效)','success');await loadCallLogicConfig()}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
loadPowConfig=async()=>{try{const r=await apiRequest('/api/pow/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowMode').value=d.config.mode||'local';$('cfgPowUseTokenForPow').checked=d.config.use_token_for_pow||false;$('cfgPowServerUrl').value=d.config.server_url||'';$('cfgPowApiKey').value=d.config.api_key||'';$('cfgPowProxyEnabled').checked=d.config.proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.proxy_url||'';togglePowFields();togglePowProxyFields()}else{console.error('POW配置数据格式错误:',d)}}catch(e){console.error('加载POW配置失败:',e)}},
savePowConfig=async()=>{try{const mode=$('cfgPowMode').value;const useTokenForPow=$('cfgPowUseTokenForPow').checked;const serverUrl=$('cfgPowServerUrl').value.trim();const apiKey=$('cfgPowApiKey').value.trim();const proxyEnabled=$('cfgPowProxyEnabled').checked;const proxyUrl=$('cfgPowProxyUrl').value.trim();if(mode==='external'){if(!serverUrl)return showToast('请输入服务器地址','error');if(!apiKey)return showToast('请输入API密钥','error')}const r=await apiRequest('/api/pow/config',{method:'POST',body:JSON.stringify({mode:mode,use_token_for_pow:useTokenForPow,server_url:serverUrl||null,api_key:apiKey||null,proxy_enabled:proxyEnabled,proxy_url:proxyUrl||null})});if(!r)return;const d=await r.json();if(d.success){showToast('POW配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
loadPowProxyConfig=loadPowConfig,savePowProxyConfig=savePowConfig,loadPowServiceConfig=loadPowConfig,savePowServiceConfig=savePowConfig,