Spaces:
Paused
Paused
Commit ·
a5784e9
0
Parent(s):
Deploy AI Studio Proxy API to Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +31 -0
- .env.example +414 -0
- .gitattributes +79 -0
- .github/workflows/pr-check.yml +229 -0
- .github/workflows/release.yml +441 -0
- .github/workflows/upstream-sync.yml +324 -0
- .gitignore +296 -0
- CONTRIBUTING.md +73 -0
- Dockerfile +98 -0
- LICENSE +661 -0
- README.md +214 -0
- api_utils/__init__.py +74 -0
- api_utils/app.py +454 -0
- api_utils/auth_manager.py +89 -0
- api_utils/auth_utils.py +39 -0
- api_utils/client_connection.py +196 -0
- api_utils/common_utils.py +6 -0
- api_utils/context_init.py +38 -0
- api_utils/context_types.py +54 -0
- api_utils/dependencies.py +169 -0
- api_utils/error_utils.py +47 -0
- api_utils/mcp_adapter.py +57 -0
- api_utils/model_switching.py +121 -0
- api_utils/page_response.py +39 -0
- api_utils/queue_worker.py +467 -0
- api_utils/request_processor.py +975 -0
- api_utils/response_generators.py +613 -0
- api_utils/response_payloads.py +39 -0
- api_utils/routers/__init__.py +42 -0
- api_utils/routers/api_keys.py +122 -0
- api_utils/routers/auth_files.py +152 -0
- api_utils/routers/chat.py +92 -0
- api_utils/routers/health.py +66 -0
- api_utils/routers/helper.py +79 -0
- api_utils/routers/info.py +49 -0
- api_utils/routers/logs_ws.py +36 -0
- api_utils/routers/model_capabilities.py +104 -0
- api_utils/routers/models.py +70 -0
- api_utils/routers/ports.py +353 -0
- api_utils/routers/proxy.py +163 -0
- api_utils/routers/queue.py +99 -0
- api_utils/routers/server.py +140 -0
- api_utils/routers/static.py +96 -0
- api_utils/server_state.py +126 -0
- api_utils/sse.py +41 -0
- api_utils/tools_registry.py +120 -0
- api_utils/utils.py +52 -0
- api_utils/utils_ext/__init__.py +95 -0
- api_utils/utils_ext/cooldown_manager.py +99 -0
- api_utils/utils_ext/files.py +201 -0
.dockerignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.git/
|
| 3 |
+
.gitignore
|
| 4 |
+
.vscode/
|
| 5 |
+
.env
|
| 6 |
+
.venv
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
__pycache__/
|
| 10 |
+
*.pyc
|
| 11 |
+
*.log
|
| 12 |
+
*.DS_Store
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
.mypy_cache/
|
| 15 |
+
.ruff_cache/
|
| 16 |
+
htmlcov/
|
| 17 |
+
coverage.xml
|
| 18 |
+
deprecated_javascript_version/
|
| 19 |
+
memory-bank/
|
| 20 |
+
logs/
|
| 21 |
+
outputs/
|
| 22 |
+
work/
|
| 23 |
+
auth_profiles/**/*.json
|
| 24 |
+
auth_profiles/key.txt
|
| 25 |
+
config/profile_usage.json
|
| 26 |
+
config/cooldown_status.json
|
| 27 |
+
static/frontend/node_modules/
|
| 28 |
+
static/frontend/dist/
|
| 29 |
+
# auth_profiles/ # Handled by volume mount or Hugging Face Secrets
|
| 30 |
+
# certs/ # Handled by volume mount or generated in container
|
| 31 |
+
# logs/ # Supervisord logs to stdout/stderr
|
.env.example
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Studio Proxy API Configuration Template
|
| 2 |
+
# Copy this file to .env and modify as needed
|
| 3 |
+
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# 1. Server Port Configuration
|
| 6 |
+
# =============================================================================
|
| 7 |
+
|
| 8 |
+
# FastAPI Main Service Port
|
| 9 |
+
# The port where the OpenAI-compatible API will listen.
|
| 10 |
+
PORT=2048
|
| 11 |
+
|
| 12 |
+
# Streaming Proxy Service Port
|
| 13 |
+
# Set to 0 to disable the streaming proxy service.
|
| 14 |
+
STREAM_PORT=3120
|
| 15 |
+
|
| 16 |
+
# GUI Launcher Default Port Configuration
|
| 17 |
+
# These are only used for default suggestions in the launcher.
|
| 18 |
+
DEFAULT_FASTAPI_PORT=2048
|
| 19 |
+
DEFAULT_CAMOUFOX_PORT=9222
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# 2. Proxy Configuration
|
| 23 |
+
# =============================================================================
|
| 24 |
+
|
| 25 |
+
# Unified Proxy Configuration (Recommended)
|
| 26 |
+
# High priority; configures HTTP_PROXY, HTTPS_PROXY, and internal browser proxy.
|
| 27 |
+
UNIFIED_PROXY_CONFIG=http://127.0.0.1:7890
|
| 28 |
+
|
| 29 |
+
# Legacy Proxy Settings (Only used if UNIFIED_PROXY_CONFIG is not set)
|
| 30 |
+
# HTTP_PROXY=http://127.0.0.1:7890
|
| 31 |
+
# HTTPS_PROXY=http://127.0.0.1:7890
|
| 32 |
+
|
| 33 |
+
# Proxy Bypass List (Separated by semicolons)
|
| 34 |
+
# NO_PROXY=localhost;127.0.0.1;*.local
|
| 35 |
+
|
| 36 |
+
# =============================================================================
|
| 37 |
+
# 3. Logging & Debugging
|
| 38 |
+
# =============================================================================
|
| 39 |
+
|
| 40 |
+
# Server Log Level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
| 41 |
+
SERVER_LOG_LEVEL=INFO
|
| 42 |
+
|
| 43 |
+
# Whether to redirect print output to logs
|
| 44 |
+
SERVER_REDIRECT_PRINT=false
|
| 45 |
+
|
| 46 |
+
# Enable Debug and Trace Logs
|
| 47 |
+
DEBUG_LOGS_ENABLED=false
|
| 48 |
+
TRACE_LOGS_ENABLED=false
|
| 49 |
+
|
| 50 |
+
# JSON Structured Logging
|
| 51 |
+
JSON_LOGS=false
|
| 52 |
+
|
| 53 |
+
# Log Rotation Configuration
|
| 54 |
+
LOG_FILE_MAX_BYTES=10485760
|
| 55 |
+
LOG_FILE_BACKUP_COUNT=5
|
| 56 |
+
|
| 57 |
+
# =============================================================================
|
| 58 |
+
# 4. Authentication Configuration
|
| 59 |
+
# =============================================================================
|
| 60 |
+
|
| 61 |
+
# Auto-save Authentication Information
|
| 62 |
+
# Set to true to automatically save auth status (cookies/localStorage) after login.
|
| 63 |
+
AUTO_SAVE_AUTH=false
|
| 64 |
+
|
| 65 |
+
# Auth Save Timeout (seconds)
|
| 66 |
+
# Timeout while waiting for user to provide a filename for the saved auth.
|
| 67 |
+
AUTH_SAVE_TIMEOUT=30
|
| 68 |
+
|
| 69 |
+
# Auto Rotate Auth Profile (true/false)
|
| 70 |
+
# Automatically switch to a different auth profile when quota is exceeded or issues occur.
|
| 71 |
+
AUTO_ROTATE_AUTH_PROFILE=true
|
| 72 |
+
|
| 73 |
+
# Auto Auth Rotation on Startup (true/false)
|
| 74 |
+
# Automatically select an available auth profile when starting.
|
| 75 |
+
AUTO_AUTH_ROTATION_ON_STARTUP=false
|
| 76 |
+
|
| 77 |
+
# Auto Confirm Login
|
| 78 |
+
AUTO_CONFIRM_LOGIN=true
|
| 79 |
+
|
| 80 |
+
# Quota Rotation Thresholds (Graceful Rotation)
|
| 81 |
+
# Soft Limit: Triggers "Rotation Pending". Current stream finishes, then rotates.
|
| 82 |
+
QUOTA_SOFT_LIMIT=850000
|
| 83 |
+
# Hard Limit: Triggers immediate "Kill Signal" to prevent hard bans.
|
| 84 |
+
QUOTA_HARD_LIMIT=950000
|
| 85 |
+
|
| 86 |
+
# -----------------------------------------------------------------------------
|
| 87 |
+
# Cookie Refresh Configuration
|
| 88 |
+
# -----------------------------------------------------------------------------
|
| 89 |
+
# Automatically refresh and persist browser cookies to keep auth profiles fresh.
|
| 90 |
+
# This helps prevent cookie expiration issues during long-running sessions.
|
| 91 |
+
|
| 92 |
+
# Enable automatic cookie refresh (default: true)
|
| 93 |
+
COOKIE_REFRESH_ENABLED=true
|
| 94 |
+
|
| 95 |
+
# Periodic refresh interval in seconds (default: 1800 = 30 minutes)
|
| 96 |
+
COOKIE_REFRESH_INTERVAL_SECONDS=1800
|
| 97 |
+
|
| 98 |
+
# Enable cookie save after successful API requests (default: true)
|
| 99 |
+
COOKIE_REFRESH_ON_REQUEST_ENABLED=true
|
| 100 |
+
|
| 101 |
+
# Number of successful requests between cookie saves (default: 10)
|
| 102 |
+
COOKIE_REFRESH_REQUEST_INTERVAL=10
|
| 103 |
+
|
| 104 |
+
# Enable cookie save on graceful shutdown (default: true)
|
| 105 |
+
COOKIE_REFRESH_ON_SHUTDOWN=true
|
| 106 |
+
|
| 107 |
+
# =============================================================================
|
| 108 |
+
# 5. Browser & Model Configuration
|
| 109 |
+
# =============================================================================
|
| 110 |
+
|
| 111 |
+
# Launch Mode (normal, debug, headless, virtual_display, direct_debug_no_browser)
|
| 112 |
+
LAUNCH_MODE=normal
|
| 113 |
+
|
| 114 |
+
# Camoufox WebSocket Endpoint
|
| 115 |
+
# For connecting to existing external Camoufox/Chrome instance.
|
| 116 |
+
# CAMOUFOX_WS_ENDPOINT=ws://127.0.0.1:9222
|
| 117 |
+
|
| 118 |
+
# Quick Launch (Direct Launch)
|
| 119 |
+
# Skip launcher menu and use .env configuration directly.
|
| 120 |
+
DIRECT_LAUNCH=false
|
| 121 |
+
|
| 122 |
+
# Only collect attachments from the current user's message (true/false)
|
| 123 |
+
ONLY_COLLECT_CURRENT_USER_ATTACHMENTS=false
|
| 124 |
+
|
| 125 |
+
# Camoufox WebSocket Capture Timeout (seconds)
|
| 126 |
+
ENDPOINT_CAPTURE_TIMEOUT=45
|
| 127 |
+
|
| 128 |
+
# =============================================================================
|
| 129 |
+
# 6. API Default Parameter Configuration
|
| 130 |
+
# =============================================================================
|
| 131 |
+
|
| 132 |
+
# Default Sampling Parameters
|
| 133 |
+
DEFAULT_TEMPERATURE=1.0
|
| 134 |
+
DEFAULT_MAX_OUTPUT_TOKENS=65536
|
| 135 |
+
DEFAULT_TOP_P=0.95
|
| 136 |
+
DEFAULT_STOP_SEQUENCES=["User:"]
|
| 137 |
+
|
| 138 |
+
# Thinking Model Configuration (e.g., gemini-2.0-flash-thinking)
|
| 139 |
+
ENABLE_THINKING_BUDGET=true
|
| 140 |
+
DEFAULT_THINKING_BUDGET=8192
|
| 141 |
+
|
| 142 |
+
# Thinking Budget Level Values (tokens) - Used for "low", "medium", "high" presets
|
| 143 |
+
THINKING_BUDGET_LOW=10923
|
| 144 |
+
THINKING_BUDGET_MEDIUM=21845
|
| 145 |
+
THINKING_BUDGET_HIGH=32768
|
| 146 |
+
|
| 147 |
+
# Gemini 3 Default Thinking Levels
|
| 148 |
+
DEFAULT_THINKING_LEVEL_PRO=high
|
| 149 |
+
DEFAULT_THINKING_LEVEL_FLASH=high
|
| 150 |
+
|
| 151 |
+
# Control if disabling streaming also disables thinking budget (default behavior: false)
|
| 152 |
+
DISABLE_THINKING_BUDGET_ON_STREAMING_DISABLE=false
|
| 153 |
+
|
| 154 |
+
# Feature Toggles (Google Search, URL Context)
|
| 155 |
+
ENABLE_GOOGLE_SEARCH=false
|
| 156 |
+
ENABLE_URL_CONTEXT=false
|
| 157 |
+
|
| 158 |
+
# =============================================================================
|
| 159 |
+
# Function Calling Configuration (Native vs Emulated)
|
| 160 |
+
# =============================================================================
|
| 161 |
+
# This configures how OpenAI-compatible tool calls (tools parameter) are handled.
|
| 162 |
+
# NOTE: XML-based tools in prompts are NOT affected - they pass through as plain text.
|
| 163 |
+
|
| 164 |
+
# Function Calling Mode: "auto" | "native" | "emulated"
|
| 165 |
+
# - "auto": (RECOMMENDED) Tries native first, auto-fallback to emulated on failure
|
| 166 |
+
# - "native": AI Studio UI-driven function calling (best reliability)
|
| 167 |
+
# - "emulated": Text-based prompt injection (legacy, backwards compatible)
|
| 168 |
+
#
|
| 169 |
+
# When to use each:
|
| 170 |
+
# - "auto": Best for most users - resilient with automatic fallback
|
| 171 |
+
# - "native": When you need maximum reliability and your models support it
|
| 172 |
+
# - "emulated": For older setups or if native mode causes issues
|
| 173 |
+
FUNCTION_CALLING_MODE=auto
|
| 174 |
+
|
| 175 |
+
# Enable automatic fallback to emulated mode when native mode fails
|
| 176 |
+
# Only applies when FUNCTION_CALLING_MODE=native (auto mode always has fallback)
|
| 177 |
+
FUNCTION_CALLING_NATIVE_FALLBACK=true
|
| 178 |
+
|
| 179 |
+
# Timeout for function calling UI operations (milliseconds)
|
| 180 |
+
FUNCTION_CALLING_UI_TIMEOUT=10000
|
| 181 |
+
|
| 182 |
+
# Native mode retry attempts before fallback
|
| 183 |
+
FUNCTION_CALLING_NATIVE_RETRY_COUNT=3
|
| 184 |
+
|
| 185 |
+
# Clear function definitions between requests (stateless behavior)
|
| 186 |
+
# Set to false if you want to reuse tool definitions across requests
|
| 187 |
+
FUNCTION_CALLING_CLEAR_BETWEEN_REQUESTS=true
|
| 188 |
+
|
| 189 |
+
# -----------------------------------------------------------------------------
|
| 190 |
+
# Function Calling Debug Logging (Master Switch)
|
| 191 |
+
# -----------------------------------------------------------------------------
|
| 192 |
+
# When false, ALL function calling logs (console & modular) are disabled.
|
| 193 |
+
# Useful for production to minimize noise and disk I/O.
|
| 194 |
+
FUNCTION_CALLING_DEBUG=false
|
| 195 |
+
|
| 196 |
+
# Enable function calling state caching for performance
|
| 197 |
+
# Reduces UI operations when same tools are used in subsequent requests
|
| 198 |
+
FUNCTION_CALLING_CACHE_ENABLED=true
|
| 199 |
+
|
| 200 |
+
# Cache TTL in seconds (0 = no expiration within session)
|
| 201 |
+
FUNCTION_CALLING_CACHE_TTL=0
|
| 202 |
+
|
| 203 |
+
# -----------------------------------------------------------------------------
|
| 204 |
+
# Function Calling Improvements (FC-001 to FC-004)
|
| 205 |
+
# -----------------------------------------------------------------------------
|
| 206 |
+
# These settings control advanced function calling compatibility features.
|
| 207 |
+
# See docs/architecture/FUNCTION_CALLING_IMPROVEMENTS.md for details.
|
| 208 |
+
|
| 209 |
+
# FC-001: thoughtSignature Support for Gemini 3
|
| 210 |
+
# Add thoughtSignature to functionCall parts for Gemini 3 model compatibility.
|
| 211 |
+
# When replaying conversation history with tool calls, Gemini 3 may require
|
| 212 |
+
# this field for validation. Safe for older models (ignored if not needed).
|
| 213 |
+
FUNCTION_CALLING_THOUGHT_SIGNATURE=true
|
| 214 |
+
|
| 215 |
+
# FC-004: Type Case Normalization (UPPERCASE types)
|
| 216 |
+
# Convert JSON Schema types to UPPERCASE (e.g., "string" -> "STRING").
|
| 217 |
+
# The iBUHub/AIStudioToAPI project uses UPPERCASE types.
|
| 218 |
+
# WARNING: Set to false by default - requires UI verification before enabling.
|
| 219 |
+
# Only enable after confirming AI Studio UI accepts UPPERCASE types.
|
| 220 |
+
FUNCTION_CALLING_UPPERCASE_TYPES=false
|
| 221 |
+
|
| 222 |
+
# -----------------------------------------------------------------------------
|
| 223 |
+
# Modular Per-Component Logging (Active only if FUNCTION_CALLING_DEBUG=true)
|
| 224 |
+
# -----------------------------------------------------------------------------
|
| 225 |
+
# Fine-grained logging with separate log files in logs/fc_debug/<module>.log
|
| 226 |
+
# See docs/architecture/FC_DEBUG_LOGGING_DESIGN.md for full documentation.
|
| 227 |
+
|
| 228 |
+
# Enable individual modules for targeted debugging. Only enabled modules
|
| 229 |
+
# will create log files. All default to false to minimize disk I/O.
|
| 230 |
+
#
|
| 231 |
+
# ORCHESTRATOR: Mode selection, fallback logic, high-level flow
|
| 232 |
+
# UI: Browser UI automation (toggle, dialog, paste)
|
| 233 |
+
# CACHE: Cache hits/misses/invalidation
|
| 234 |
+
# WIRE: Wire format parsing from network responses
|
| 235 |
+
# DOM: DOM-based function call extraction
|
| 236 |
+
# SCHEMA: Tool schema conversion and validation
|
| 237 |
+
# RESPONSE: Response formatting for OpenAI compatibility
|
| 238 |
+
|
| 239 |
+
FC_DEBUG_ORCHESTRATOR=false
|
| 240 |
+
FC_DEBUG_UI=false
|
| 241 |
+
FC_DEBUG_CACHE=false
|
| 242 |
+
FC_DEBUG_WIRE=false
|
| 243 |
+
FC_DEBUG_DOM=false
|
| 244 |
+
FC_DEBUG_SCHEMA=false
|
| 245 |
+
FC_DEBUG_RESPONSE=false
|
| 246 |
+
|
| 247 |
+
# -----------------------------------------------------------------------------
|
| 248 |
+
# Per-Module Log Levels
|
| 249 |
+
# -----------------------------------------------------------------------------
|
| 250 |
+
# Available levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 251 |
+
# Only affects modules that are enabled above.
|
| 252 |
+
# Recommendation: Use DEBUG for troubleshooting, INFO for general monitoring.
|
| 253 |
+
|
| 254 |
+
FC_DEBUG_LEVEL_ORCHESTRATOR=DEBUG
|
| 255 |
+
FC_DEBUG_LEVEL_UI=DEBUG
|
| 256 |
+
FC_DEBUG_LEVEL_CACHE=DEBUG
|
| 257 |
+
FC_DEBUG_LEVEL_WIRE=DEBUG
|
| 258 |
+
FC_DEBUG_LEVEL_DOM=DEBUG
|
| 259 |
+
FC_DEBUG_LEVEL_SCHEMA=DEBUG
|
| 260 |
+
FC_DEBUG_LEVEL_RESPONSE=DEBUG
|
| 261 |
+
|
| 262 |
+
# -----------------------------------------------------------------------------
|
| 263 |
+
# Log File Rotation
|
| 264 |
+
# -----------------------------------------------------------------------------
|
| 265 |
+
# Configure rotation to prevent unbounded disk usage.
|
| 266 |
+
# Max file size in bytes (default: 5MB = 5242880)
|
| 267 |
+
# Backup count determines how many rotated files to keep.
|
| 268 |
+
|
| 269 |
+
FC_DEBUG_LOG_MAX_BYTES=5242880
|
| 270 |
+
FC_DEBUG_LOG_BACKUP_COUNT=3
|
| 271 |
+
|
| 272 |
+
# -----------------------------------------------------------------------------
|
| 273 |
+
# Payload Truncation
|
| 274 |
+
# -----------------------------------------------------------------------------
|
| 275 |
+
# Large payloads (tool definitions, arguments) can clutter logs.
|
| 276 |
+
# Enable truncation to keep logs readable while preserving key info.
|
| 277 |
+
|
| 278 |
+
# Master switch for truncation
|
| 279 |
+
FC_DEBUG_TRUNCATE_ENABLED=true
|
| 280 |
+
|
| 281 |
+
# Maximum characters for different payload types:
|
| 282 |
+
# - TOOL_DEF: Tool/function schema definitions (often 10KB+)
|
| 283 |
+
# - ARGS: Function call arguments
|
| 284 |
+
# - RESPONSE: Response bodies
|
| 285 |
+
|
| 286 |
+
FC_DEBUG_TRUNCATE_MAX_TOOL_DEF=500
|
| 287 |
+
FC_DEBUG_TRUNCATE_MAX_ARGS=1000
|
| 288 |
+
FC_DEBUG_TRUNCATE_MAX_RESPONSE=2000
|
| 289 |
+
|
| 290 |
+
# -----------------------------------------------------------------------------
|
| 291 |
+
# Combined Log (Optional)
|
| 292 |
+
# -----------------------------------------------------------------------------
|
| 293 |
+
# Additionally write all FC debug logs to a single combined file.
|
| 294 |
+
# Useful for seeing cross-module request flow in one place.
|
| 295 |
+
# File: logs/fc_debug/fc_combined.log
|
| 296 |
+
|
| 297 |
+
FC_DEBUG_COMBINED_LOG=false
|
| 298 |
+
|
| 299 |
+
# =============================================================================
|
| 300 |
+
# Quick Start Examples:
|
| 301 |
+
# =============================================================================
|
| 302 |
+
#
|
| 303 |
+
# Example 1: Debug cache issues only
|
| 304 |
+
# FUNCTION_CALLING_DEBUG=true
|
| 305 |
+
# FC_DEBUG_CACHE=true
|
| 306 |
+
#
|
| 307 |
+
# Example 2: Full FC debugging with combined log
|
| 308 |
+
# FUNCTION_CALLING_DEBUG=true
|
| 309 |
+
# FC_DEBUG_ORCHESTRATOR=true
|
| 310 |
+
# FC_DEBUG_UI=true
|
| 311 |
+
# FC_DEBUG_CACHE=true
|
| 312 |
+
# FC_DEBUG_WIRE=true
|
| 313 |
+
# FC_DEBUG_DOM=true
|
| 314 |
+
# FC_DEBUG_SCHEMA=true
|
| 315 |
+
# FC_DEBUG_RESPONSE=true
|
| 316 |
+
# FC_DEBUG_COMBINED_LOG=true
|
| 317 |
+
#
|
| 318 |
+
# Example 3: Production monitoring (errors only)
|
| 319 |
+
# FUNCTION_CALLING_DEBUG=true
|
| 320 |
+
# FC_DEBUG_ORCHESTRATOR=true
|
| 321 |
+
# FC_DEBUG_LEVEL_ORCHESTRATOR=ERROR
|
| 322 |
+
# =============================================================================
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# =============================================================================
|
| 326 |
+
# 7. Advanced Timeout Configuration (milliseconds)
|
| 327 |
+
# =============================================================================
|
| 328 |
+
|
| 329 |
+
# Response Completion Total Timeout
|
| 330 |
+
# Default: 600000 (10 minutes)
|
| 331 |
+
RESPONSE_COMPLETION_TIMEOUT=600000
|
| 332 |
+
|
| 333 |
+
# Initial Wait Time Before Polling
|
| 334 |
+
INITIAL_WAIT_MS_BEFORE_POLLING=500
|
| 335 |
+
|
| 336 |
+
# Polling Interval
|
| 337 |
+
POLLING_INTERVAL=300
|
| 338 |
+
POLLING_INTERVAL_STREAM=180
|
| 339 |
+
|
| 340 |
+
# Silence Timeout (Base threshold for inactivity detection)
|
| 341 |
+
SILENCE_TIMEOUT_MS=60000
|
| 342 |
+
|
| 343 |
+
# Page Action Timeouts
|
| 344 |
+
POST_SPINNER_CHECK_DELAY_MS=500
|
| 345 |
+
FINAL_STATE_CHECK_TIMEOUT_MS=1500
|
| 346 |
+
POST_COMPLETION_BUFFER=700
|
| 347 |
+
|
| 348 |
+
# UI Generation Wait Configuration
|
| 349 |
+
UI_GENERATION_WAIT_TIMEOUT_MS=5000
|
| 350 |
+
UI_GENERATION_CHECK_INTERVAL_MS=500
|
| 351 |
+
UI_STABILIZATION_BUFFER_MS=500
|
| 352 |
+
|
| 353 |
+
# Clear Chat Related Timeouts
|
| 354 |
+
CLEAR_CHAT_VERIFY_TIMEOUT_MS=4000
|
| 355 |
+
CLEAR_CHAT_VERIFY_INTERVAL_MS=4000
|
| 356 |
+
|
| 357 |
+
# Interaction Timeouts
|
| 358 |
+
CLICK_TIMEOUT_MS=3000
|
| 359 |
+
CLIPBOARD_READ_TIMEOUT_MS=3000
|
| 360 |
+
WAIT_FOR_ELEMENT_TIMEOUT_MS=10000
|
| 361 |
+
|
| 362 |
+
# Stream Related Configuration
|
| 363 |
+
PSEUDO_STREAM_DELAY=0.01
|
| 364 |
+
|
| 365 |
+
# =============================================================================
|
| 366 |
+
# 8. GUI Launcher Configuration
|
| 367 |
+
# =============================================================================
|
| 368 |
+
|
| 369 |
+
# GUI Default Proxy and Port settings
|
| 370 |
+
GUI_DEFAULT_PROXY_ADDRESS=http://127.0.0.1:7890
|
| 371 |
+
GUI_DEFAULT_STREAM_PORT=3120
|
| 372 |
+
GUI_DEFAULT_HELPER_ENDPOINT=
|
| 373 |
+
|
| 374 |
+
# =============================================================================
|
| 375 |
+
# 9. Script Injection Configuration
|
| 376 |
+
# =============================================================================
|
| 377 |
+
|
| 378 |
+
# Whether to enable Tampermonkey script injection (Deprecated)
|
| 379 |
+
ENABLE_SCRIPT_INJECTION=false
|
| 380 |
+
|
| 381 |
+
# Tampermonkey script file path (relative to project root)
|
| 382 |
+
USERSCRIPT_PATH=browser_utils/more_models.js
|
| 383 |
+
|
| 384 |
+
# =============================================================================
|
| 385 |
+
# 10. Miscellaneous System Config
|
| 386 |
+
# =============================================================================
|
| 387 |
+
|
| 388 |
+
# Model Metadata
|
| 389 |
+
MODEL_NAME=AI-Studio_Proxy_API
|
| 390 |
+
CHAT_COMPLETION_ID_PREFIX=chatcmpl-
|
| 391 |
+
AI_STUDIO_URL_PATTERN=aistudio.google.com/
|
| 392 |
+
EXCLUDED_MODELS_FILENAME=excluded_models.txt
|
| 393 |
+
|
| 394 |
+
# Internal URL Markers and Fallbacks
|
| 395 |
+
DEFAULT_FALLBACK_MODEL_ID=no model list
|
| 396 |
+
MODELS_ENDPOINT_URL_CONTAINS=MakerSuiteService/ListModels
|
| 397 |
+
USER_INPUT_START_MARKER_SERVER=__USER_INPUT_START__
|
| 398 |
+
USER_INPUT_END_MARKER_SERVER=__USER_INPUT_END__
|
| 399 |
+
|
| 400 |
+
# =============================================================================
|
| 401 |
+
# 11. Stream State and Error Suppression
|
| 402 |
+
# =============================================================================
|
| 403 |
+
|
| 404 |
+
# Stream Timeout Log State Configuration
|
| 405 |
+
STREAM_MAX_INITIAL_ERRORS=3
|
| 406 |
+
STREAM_WARNING_INTERVAL_AFTER_SUPPRESS=60.0
|
| 407 |
+
STREAM_SUPPRESS_DURATION_AFTER_INITIAL_BURST=400.0
|
| 408 |
+
|
| 409 |
+
# =============================================================================
|
| 410 |
+
# 12. Frontend Build Configuration
|
| 411 |
+
# =============================================================================
|
| 412 |
+
|
| 413 |
+
# Skip frontend build check (for environments without Node.js/npm)
|
| 414 |
+
SKIP_FRONTEND_BUILD=false
|
.gitattributes
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Enforce LF line endings for all text files (cross-platform consistency)
|
| 2 |
+
* text=auto eol=lf
|
| 3 |
+
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# TRANSLATION FORK MERGE STRATEGY
|
| 6 |
+
# =============================================================================
|
| 7 |
+
# This is an English translation fork of a Chinese upstream repository.
|
| 8 |
+
# The README.md will always conflict because:
|
| 9 |
+
# - Fork: English README (intentional localization)
|
| 10 |
+
# - Upstream: Chinese README
|
| 11 |
+
#
|
| 12 |
+
# The 'merge=ours' strategy tells Git to always keep our (fork's) version
|
| 13 |
+
# during merges. This serves as additional protection alongside the workflow
|
| 14 |
+
# conflict handling in .github/workflows/upstream-sync.yml
|
| 15 |
+
# =============================================================================
|
| 16 |
+
README.md merge=ours
|
| 17 |
+
|
| 18 |
+
# Binary files (no line ending conversion)
|
| 19 |
+
*.png binary
|
| 20 |
+
*.jpg binary
|
| 21 |
+
*.jpeg binary
|
| 22 |
+
*.gif binary
|
| 23 |
+
*.ico binary
|
| 24 |
+
*.woff binary
|
| 25 |
+
*.woff2 binary
|
| 26 |
+
*.ttf binary
|
| 27 |
+
*.eot binary
|
| 28 |
+
*.pyc binary
|
| 29 |
+
*.pyd binary
|
| 30 |
+
*.so binary
|
| 31 |
+
*.dll binary
|
| 32 |
+
*.exe binary
|
| 33 |
+
*.zip binary
|
| 34 |
+
*.tar binary
|
| 35 |
+
*.gz binary
|
| 36 |
+
*.db binary
|
| 37 |
+
*.sqlite binary
|
| 38 |
+
*.sqlite3 binary
|
| 39 |
+
|
| 40 |
+
# Python files (explicit LF)
|
| 41 |
+
*.py text eol=lf
|
| 42 |
+
*.pyi text eol=lf
|
| 43 |
+
*.pyx text eol=lf
|
| 44 |
+
|
| 45 |
+
# Configuration files
|
| 46 |
+
*.toml text eol=lf
|
| 47 |
+
*.ini text eol=lf
|
| 48 |
+
*.cfg text eol=lf
|
| 49 |
+
*.conf text eol=lf
|
| 50 |
+
*.yaml text eol=lf
|
| 51 |
+
*.yml text eol=lf
|
| 52 |
+
*.json text eol=lf
|
| 53 |
+
*.md text eol=lf
|
| 54 |
+
*.txt text eol=lf
|
| 55 |
+
.env* text eol=lf
|
| 56 |
+
.gitignore text eol=lf
|
| 57 |
+
.gitattributes text eol=lf
|
| 58 |
+
|
| 59 |
+
# Shell scripts
|
| 60 |
+
*.sh text eol=lf
|
| 61 |
+
*.bash text eol=lf
|
| 62 |
+
|
| 63 |
+
# Windows scripts (keep CRLF for Windows)
|
| 64 |
+
*.bat text eol=crlf
|
| 65 |
+
*.cmd text eol=crlf
|
| 66 |
+
*.ps1 text eol=crlf
|
| 67 |
+
|
| 68 |
+
# JavaScript/TypeScript
|
| 69 |
+
*.js text eol=lf
|
| 70 |
+
*.ts text eol=lf
|
| 71 |
+
*.jsx text eol=lf
|
| 72 |
+
*.tsx text eol=lf
|
| 73 |
+
*.css text eol=lf
|
| 74 |
+
*.scss text eol=lf
|
| 75 |
+
*.html text eol=lf
|
| 76 |
+
|
| 77 |
+
# Lock files
|
| 78 |
+
poetry.lock text eol=lf
|
| 79 |
+
package-lock.json text eol=lf
|
.github/workflows/pr-check.yml
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PR Check Workflow
|
| 2 |
+
# Runs linting, type checking, and tests on pull requests and pushes to main
|
| 3 |
+
#
|
| 4 |
+
# NOTE: Lint and type check are set to continue-on-error because this is a fork
|
| 5 |
+
# of an upstream project with pre-existing code quality issues. These jobs will
|
| 6 |
+
# report issues but won't block the workflow from completing successfully.
|
| 7 |
+
# As code quality improves, these can be made blocking again.
|
| 8 |
+
#
|
| 9 |
+
# CACHING STRATEGY:
|
| 10 |
+
# - Poetry virtualenv: Cached per OS/Python version/poetry.lock hash
|
| 11 |
+
# - Pip cache: Cached per OS/Python version for faster package resolution
|
| 12 |
+
# - Playwright browsers: Cached per OS/Playwright version (test job only)
|
| 13 |
+
name: PR Check
|
| 14 |
+
|
| 15 |
+
on:
|
| 16 |
+
push:
|
| 17 |
+
branches: [main]
|
| 18 |
+
pull_request:
|
| 19 |
+
branches: [main]
|
| 20 |
+
|
| 21 |
+
# Cancel in-progress runs for the same branch
|
| 22 |
+
concurrency:
|
| 23 |
+
group: ${{ github.workflow }}-${{ github.ref }}
|
| 24 |
+
cancel-in-progress: true
|
| 25 |
+
|
| 26 |
+
env:
|
| 27 |
+
POETRY_VERSION: "1.8.3"
|
| 28 |
+
PYTHON_KEYRING_BACKEND: "keyring.backends.null.Keyring"
|
| 29 |
+
# Playwright browser cache location (Linux)
|
| 30 |
+
PLAYWRIGHT_BROWSERS_PATH: ~/.cache/ms-playwright
|
| 31 |
+
|
| 32 |
+
jobs:
|
| 33 |
+
lint:
|
| 34 |
+
name: Lint (Python ${{ matrix.python-version }})
|
| 35 |
+
runs-on: ubuntu-latest
|
| 36 |
+
# Continue even if linting fails - reports issues without blocking
|
| 37 |
+
continue-on-error: true
|
| 38 |
+
strategy:
|
| 39 |
+
fail-fast: false
|
| 40 |
+
matrix:
|
| 41 |
+
python-version: ["3.10", "3.11"]
|
| 42 |
+
|
| 43 |
+
steps:
|
| 44 |
+
- name: Checkout code
|
| 45 |
+
uses: actions/checkout@v4
|
| 46 |
+
|
| 47 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 48 |
+
uses: actions/setup-python@v5
|
| 49 |
+
with:
|
| 50 |
+
python-version: ${{ matrix.python-version }}
|
| 51 |
+
|
| 52 |
+
- name: Install Poetry
|
| 53 |
+
uses: snok/install-poetry@v1
|
| 54 |
+
with:
|
| 55 |
+
version: ${{ env.POETRY_VERSION }}
|
| 56 |
+
virtualenvs-create: true
|
| 57 |
+
virtualenvs-in-project: true
|
| 58 |
+
|
| 59 |
+
- name: Cache pip downloads
|
| 60 |
+
uses: actions/cache@v4
|
| 61 |
+
with:
|
| 62 |
+
path: ~/.cache/pip
|
| 63 |
+
key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
| 64 |
+
restore-keys: |
|
| 65 |
+
pip-${{ runner.os }}-${{ matrix.python-version }}-
|
| 66 |
+
|
| 67 |
+
- name: Cache Poetry virtualenv
|
| 68 |
+
uses: actions/cache@v4
|
| 69 |
+
id: cache-deps
|
| 70 |
+
with:
|
| 71 |
+
path: .venv
|
| 72 |
+
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
| 73 |
+
restore-keys: |
|
| 74 |
+
venv-${{ runner.os }}-${{ matrix.python-version }}-
|
| 75 |
+
|
| 76 |
+
- name: Install dependencies
|
| 77 |
+
run: poetry install --no-interaction --no-root
|
| 78 |
+
|
| 79 |
+
- name: Run Ruff linting
|
| 80 |
+
run: poetry run ruff check . --output-format=github
|
| 81 |
+
|
| 82 |
+
typecheck:
|
| 83 |
+
name: Type Check (Python ${{ matrix.python-version }})
|
| 84 |
+
runs-on: ubuntu-latest
|
| 85 |
+
# Continue even if type checking fails - reports issues without blocking
|
| 86 |
+
continue-on-error: true
|
| 87 |
+
strategy:
|
| 88 |
+
fail-fast: false
|
| 89 |
+
matrix:
|
| 90 |
+
python-version: ["3.10", "3.11"]
|
| 91 |
+
|
| 92 |
+
steps:
|
| 93 |
+
- name: Checkout code
|
| 94 |
+
uses: actions/checkout@v4
|
| 95 |
+
|
| 96 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 97 |
+
uses: actions/setup-python@v5
|
| 98 |
+
with:
|
| 99 |
+
python-version: ${{ matrix.python-version }}
|
| 100 |
+
|
| 101 |
+
- name: Install Poetry
|
| 102 |
+
uses: snok/install-poetry@v1
|
| 103 |
+
with:
|
| 104 |
+
version: ${{ env.POETRY_VERSION }}
|
| 105 |
+
virtualenvs-create: true
|
| 106 |
+
virtualenvs-in-project: true
|
| 107 |
+
|
| 108 |
+
- name: Cache pip downloads
|
| 109 |
+
uses: actions/cache@v4
|
| 110 |
+
with:
|
| 111 |
+
path: ~/.cache/pip
|
| 112 |
+
key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
| 113 |
+
restore-keys: |
|
| 114 |
+
pip-${{ runner.os }}-${{ matrix.python-version }}-
|
| 115 |
+
|
| 116 |
+
- name: Cache Poetry virtualenv
|
| 117 |
+
uses: actions/cache@v4
|
| 118 |
+
id: cache-deps
|
| 119 |
+
with:
|
| 120 |
+
path: .venv
|
| 121 |
+
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
| 122 |
+
restore-keys: |
|
| 123 |
+
venv-${{ runner.os }}-${{ matrix.python-version }}-
|
| 124 |
+
|
| 125 |
+
- name: Install dependencies
|
| 126 |
+
run: poetry install --no-interaction --no-root
|
| 127 |
+
|
| 128 |
+
- name: Run Pyright type checking
|
| 129 |
+
# Use exit 0 to always succeed - Pyright will still report issues in the log
|
| 130 |
+
run: poetry run pyright || true
|
| 131 |
+
|
| 132 |
+
test:
|
| 133 |
+
name: Test (Python ${{ matrix.python-version }})
|
| 134 |
+
runs-on: ubuntu-latest
|
| 135 |
+
timeout-minutes: 20 # Prevent stuck jobs from blocking indefinitely
|
| 136 |
+
# Remove dependency on lint/typecheck - tests should run regardless
|
| 137 |
+
# Tests are the most critical check for functionality
|
| 138 |
+
strategy:
|
| 139 |
+
fail-fast: false
|
| 140 |
+
matrix:
|
| 141 |
+
python-version: ["3.10", "3.11"]
|
| 142 |
+
|
| 143 |
+
steps:
|
| 144 |
+
- name: Checkout code
|
| 145 |
+
uses: actions/checkout@v4
|
| 146 |
+
|
| 147 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 148 |
+
uses: actions/setup-python@v5
|
| 149 |
+
with:
|
| 150 |
+
python-version: ${{ matrix.python-version }}
|
| 151 |
+
|
| 152 |
+
- name: Install Poetry
|
| 153 |
+
uses: snok/install-poetry@v1
|
| 154 |
+
with:
|
| 155 |
+
version: ${{ env.POETRY_VERSION }}
|
| 156 |
+
virtualenvs-create: true
|
| 157 |
+
virtualenvs-in-project: true
|
| 158 |
+
|
| 159 |
+
- name: Cache pip downloads
|
| 160 |
+
uses: actions/cache@v4
|
| 161 |
+
with:
|
| 162 |
+
path: ~/.cache/pip
|
| 163 |
+
key: pip-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
| 164 |
+
restore-keys: |
|
| 165 |
+
pip-${{ runner.os }}-${{ matrix.python-version }}-
|
| 166 |
+
|
| 167 |
+
- name: Cache Poetry virtualenv
|
| 168 |
+
uses: actions/cache@v4
|
| 169 |
+
id: cache-deps
|
| 170 |
+
with:
|
| 171 |
+
path: .venv
|
| 172 |
+
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
| 173 |
+
restore-keys: |
|
| 174 |
+
venv-${{ runner.os }}-${{ matrix.python-version }}-
|
| 175 |
+
|
| 176 |
+
- name: Install dependencies
|
| 177 |
+
run: poetry install --no-interaction --no-root
|
| 178 |
+
|
| 179 |
+
- name: Get Playwright version
|
| 180 |
+
id: playwright-version
|
| 181 |
+
run: |
|
| 182 |
+
PLAYWRIGHT_VERSION=$(poetry run python -c "import playwright; print(playwright.__version__)" 2>/dev/null || echo "unknown")
|
| 183 |
+
echo "version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
|
| 184 |
+
|
| 185 |
+
- name: Cache Playwright browsers
|
| 186 |
+
uses: actions/cache@v4
|
| 187 |
+
id: cache-playwright
|
| 188 |
+
with:
|
| 189 |
+
path: ~/.cache/ms-playwright
|
| 190 |
+
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
| 191 |
+
restore-keys: |
|
| 192 |
+
playwright-${{ runner.os }}-
|
| 193 |
+
|
| 194 |
+
- name: Install Playwright browsers
|
| 195 |
+
if: steps.cache-playwright.outputs.cache-hit != 'true'
|
| 196 |
+
run: poetry run playwright install chromium --with-deps
|
| 197 |
+
|
| 198 |
+
- name: Install Playwright system dependencies
|
| 199 |
+
if: steps.cache-playwright.outputs.cache-hit == 'true'
|
| 200 |
+
run: poetry run playwright install-deps chromium
|
| 201 |
+
|
| 202 |
+
- name: Run pytest with coverage
|
| 203 |
+
run: |
|
| 204 |
+
poetry run pytest \
|
| 205 |
+
-n auto \
|
| 206 |
+
--dist=loadfile \
|
| 207 |
+
-m "not integration" \
|
| 208 |
+
--cov-report=xml \
|
| 209 |
+
--cov-report=term-missing \
|
| 210 |
+
--junitxml=test-results.xml
|
| 211 |
+
env:
|
| 212 |
+
LAUNCH_MODE: test
|
| 213 |
+
STREAM_PORT: "0"
|
| 214 |
+
|
| 215 |
+
- name: Upload coverage report
|
| 216 |
+
uses: actions/upload-artifact@v4
|
| 217 |
+
if: always()
|
| 218 |
+
with:
|
| 219 |
+
name: coverage-report-${{ matrix.python-version }}
|
| 220 |
+
path: coverage.xml
|
| 221 |
+
retention-days: 7
|
| 222 |
+
|
| 223 |
+
- name: Upload test results
|
| 224 |
+
uses: actions/upload-artifact@v4
|
| 225 |
+
if: always()
|
| 226 |
+
with:
|
| 227 |
+
name: test-results-${{ matrix.python-version }}
|
| 228 |
+
path: test-results.xml
|
| 229 |
+
retention-days: 7
|
.github/workflows/release.yml
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Automated Release Workflow for AI Studio Proxy API
|
| 2 |
+
# Creates GitHub releases with source code archives when version tags are pushed,
|
| 3 |
+
# on pushes to main (nightly builds), or manually triggered via workflow_dispatch.
|
| 4 |
+
#
|
| 5 |
+
# Release Types:
|
| 6 |
+
# - Stable: Push a tag (git tag v0.1.0 && git push origin v0.1.0)
|
| 7 |
+
# - Nightly: Automatic on every push to main branch (rolling release)
|
| 8 |
+
# - Manual: Actions -> Release -> Run workflow -> Enter version (e.g., v0.2.0)
|
| 9 |
+
# The tag will be auto-created on the current HEAD if it doesn't exist.
|
| 10 |
+
|
| 11 |
+
name: Release
|
| 12 |
+
|
| 13 |
+
on:
|
| 14 |
+
# Trigger on pushes to main branch for nightly builds
|
| 15 |
+
push:
|
| 16 |
+
branches:
|
| 17 |
+
- main
|
| 18 |
+
tags:
|
| 19 |
+
- 'v*.*.*' # Matches v1.0.0, v2.1.3, etc.
|
| 20 |
+
workflow_dispatch:
|
| 21 |
+
inputs:
|
| 22 |
+
version:
|
| 23 |
+
description: 'Version to release (e.g., v0.1.0 or 0.1.0)'
|
| 24 |
+
required: true
|
| 25 |
+
type: string
|
| 26 |
+
|
| 27 |
+
# Ensure only one release workflow runs at a time
|
| 28 |
+
concurrency:
|
| 29 |
+
group: release-${{ github.ref }}
|
| 30 |
+
cancel-in-progress: false
|
| 31 |
+
|
| 32 |
+
permissions:
|
| 33 |
+
contents: write # Required for creating releases
|
| 34 |
+
|
| 35 |
+
jobs:
|
| 36 |
+
# ===========================================================================
|
| 37 |
+
# Stable Release Job
|
| 38 |
+
# ===========================================================================
|
| 39 |
+
# Runs on tag pushes (v*.*.*) or manual workflow_dispatch
|
| 40 |
+
# Creates versioned, stable releases
|
| 41 |
+
release:
|
| 42 |
+
name: Create Release
|
| 43 |
+
runs-on: ubuntu-latest
|
| 44 |
+
# Only run on tag pushes or manual triggers (not on main branch pushes)
|
| 45 |
+
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
| 46 |
+
|
| 47 |
+
steps:
|
| 48 |
+
- name: Validate version format (manual trigger)
|
| 49 |
+
if: github.event_name == 'workflow_dispatch'
|
| 50 |
+
run: |
|
| 51 |
+
VERSION_RAW="${{ github.event.inputs.version }}"
|
| 52 |
+
|
| 53 |
+
if [[ "$VERSION_RAW" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
| 54 |
+
VERSION="v$VERSION_RAW"
|
| 55 |
+
else
|
| 56 |
+
VERSION="$VERSION_RAW"
|
| 57 |
+
fi
|
| 58 |
+
|
| 59 |
+
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
| 60 |
+
echo "::error::Invalid version format '$VERSION_RAW'. Expected format: v1.2.3, 1.2.3, or pre-release variants like v1.2.3-beta.1"
|
| 61 |
+
exit 1
|
| 62 |
+
fi
|
| 63 |
+
echo "Version format validated: $VERSION"
|
| 64 |
+
|
| 65 |
+
- name: Checkout repository
|
| 66 |
+
uses: actions/checkout@v4
|
| 67 |
+
with:
|
| 68 |
+
fetch-depth: 0 # Full history for changelog generation
|
| 69 |
+
# For workflow_dispatch, checkout default branch first, then verify/checkout tag
|
| 70 |
+
# For tag pushes, checkout the tag directly
|
| 71 |
+
ref: ${{ github.event_name == 'workflow_dispatch' && '' || github.ref }}
|
| 72 |
+
|
| 73 |
+
- name: Determine version
|
| 74 |
+
id: version
|
| 75 |
+
run: |
|
| 76 |
+
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
| 77 |
+
VERSION_RAW="${{ github.event.inputs.version }}"
|
| 78 |
+
if [[ "$VERSION_RAW" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
| 79 |
+
VERSION="v$VERSION_RAW"
|
| 80 |
+
else
|
| 81 |
+
VERSION="$VERSION_RAW"
|
| 82 |
+
fi
|
| 83 |
+
else
|
| 84 |
+
VERSION="${GITHUB_REF#refs/tags/}"
|
| 85 |
+
fi
|
| 86 |
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
| 87 |
+
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
| 88 |
+
echo "Release version: $VERSION"
|
| 89 |
+
|
| 90 |
+
- name: Setup Git for tagging (manual trigger)
|
| 91 |
+
if: github.event_name == 'workflow_dispatch'
|
| 92 |
+
run: |
|
| 93 |
+
git config user.name "github-actions[bot]"
|
| 94 |
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
| 95 |
+
|
| 96 |
+
- name: Create or verify tag (manual trigger)
|
| 97 |
+
if: github.event_name == 'workflow_dispatch'
|
| 98 |
+
run: |
|
| 99 |
+
VERSION="${{ steps.version.outputs.version }}"
|
| 100 |
+
# Fetch all tags to ensure we have the latest
|
| 101 |
+
git fetch --tags --force
|
| 102 |
+
|
| 103 |
+
if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then
|
| 104 |
+
echo "✓ Tag $VERSION already exists"
|
| 105 |
+
echo "Checking out existing tag..."
|
| 106 |
+
git checkout "refs/tags/$VERSION"
|
| 107 |
+
else
|
| 108 |
+
echo "Tag $VERSION does not exist, creating it on current HEAD..."
|
| 109 |
+
CURRENT_SHA=$(git rev-parse HEAD)
|
| 110 |
+
echo "Creating tag $VERSION at commit $CURRENT_SHA"
|
| 111 |
+
|
| 112 |
+
# Create the tag locally
|
| 113 |
+
git tag -a "$VERSION" -m "Release $VERSION"
|
| 114 |
+
|
| 115 |
+
# Push the tag to remote
|
| 116 |
+
git push origin "$VERSION"
|
| 117 |
+
|
| 118 |
+
echo "✓ Tag $VERSION created and pushed successfully"
|
| 119 |
+
fi
|
| 120 |
+
|
| 121 |
+
- name: Generate changelog
|
| 122 |
+
id: changelog
|
| 123 |
+
env:
|
| 124 |
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 125 |
+
run: |
|
| 126 |
+
VERSION="${{ steps.version.outputs.version }}"
|
| 127 |
+
|
| 128 |
+
# Find the previous tag for comparison
|
| 129 |
+
PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | grep -v "^$VERSION$" | head -n 1)
|
| 130 |
+
|
| 131 |
+
if [ -n "$PREVIOUS_TAG" ]; then
|
| 132 |
+
echo "Generating changelog from $PREVIOUS_TAG to $VERSION"
|
| 133 |
+
|
| 134 |
+
# Generate commit log grouped by type
|
| 135 |
+
CHANGELOG=$(cat << 'CHANGELOG_EOF'
|
| 136 |
+
## What's Changed
|
| 137 |
+
|
| 138 |
+
CHANGELOG_EOF
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
TEMP_FILE=$(mktemp)
|
| 142 |
+
|
| 143 |
+
while IFS= read -r commit_sha || [ -n "$commit_sha" ]; do
|
| 144 |
+
if [ -z "$commit_sha" ]; then
|
| 145 |
+
continue
|
| 146 |
+
fi
|
| 147 |
+
|
| 148 |
+
commit_msg=$(git log -1 --pretty=format:"%s" "$commit_sha")
|
| 149 |
+
short_sha=$(git rev-parse --short "$commit_sha")
|
| 150 |
+
git_author=$(git log -1 --pretty=format:"%an" "$commit_sha")
|
| 151 |
+
|
| 152 |
+
api_response=$(gh api "repos/${{ github.repository }}/commits/$commit_sha" 2>/dev/null || echo '{}')
|
| 153 |
+
github_username=$(echo "$api_response" | jq -r '.author.login // empty')
|
| 154 |
+
|
| 155 |
+
if [ -n "$github_username" ]; then
|
| 156 |
+
echo "- ${commit_msg} by @${github_username} (${short_sha})" >> "$TEMP_FILE"
|
| 157 |
+
else
|
| 158 |
+
echo "- ${commit_msg} by ${git_author} (${short_sha})" >> "$TEMP_FILE"
|
| 159 |
+
fi
|
| 160 |
+
done < <(git log --pretty=format:"%H" "$PREVIOUS_TAG..$VERSION" 2>/dev/null || true)
|
| 161 |
+
|
| 162 |
+
if [ -s "$TEMP_FILE" ]; then
|
| 163 |
+
CHANGELOG="$CHANGELOG
|
| 164 |
+
$(cat "$TEMP_FILE")"
|
| 165 |
+
else
|
| 166 |
+
CHANGELOG="$CHANGELOG
|
| 167 |
+
- Various improvements and updates"
|
| 168 |
+
fi
|
| 169 |
+
|
| 170 |
+
rm -f "$TEMP_FILE"
|
| 171 |
+
|
| 172 |
+
CHANGELOG="$CHANGELOG
|
| 173 |
+
|
| 174 |
+
**Full Changelog**: https://github.com/${{ github.repository }}/compare/$PREVIOUS_TAG...$VERSION"
|
| 175 |
+
else
|
| 176 |
+
echo "No previous tag found, this appears to be the first release"
|
| 177 |
+
CHANGELOG="## What's Changed
|
| 178 |
+
|
| 179 |
+
This is the first automated release in this repository.
|
| 180 |
+
|
| 181 |
+
See the commit history for details."
|
| 182 |
+
|
| 183 |
+
fi
|
| 184 |
+
|
| 185 |
+
# Write to file for use in release
|
| 186 |
+
echo "$CHANGELOG" > CHANGELOG.md
|
| 187 |
+
echo "Changelog generated successfully"
|
| 188 |
+
|
| 189 |
+
- name: Build release body
|
| 190 |
+
id: release_body
|
| 191 |
+
run: |
|
| 192 |
+
VERSION="${{ steps.version.outputs.version }}"
|
| 193 |
+
VERSION_NUMBER="${{ steps.version.outputs.version_number }}"
|
| 194 |
+
|
| 195 |
+
cat > RELEASE_BODY.md << 'EOF'
|
| 196 |
+
${{ steps.version.outputs.version }} brings improvements and updates to the AI Studio Proxy API.
|
| 197 |
+
|
| 198 |
+
EOF
|
| 199 |
+
|
| 200 |
+
# Append the generated changelog
|
| 201 |
+
cat CHANGELOG.md >> RELEASE_BODY.md
|
| 202 |
+
|
| 203 |
+
cat >> RELEASE_BODY.md << 'EOF'
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## Installation
|
| 208 |
+
|
| 209 |
+
### Quick Start
|
| 210 |
+
```bash
|
| 211 |
+
# Clone the repository
|
| 212 |
+
git clone https://github.com/${{ github.repository }}.git
|
| 213 |
+
cd AIstudioProxyAPI
|
| 214 |
+
|
| 215 |
+
# Install dependencies with Poetry
|
| 216 |
+
poetry install
|
| 217 |
+
|
| 218 |
+
# Run the server
|
| 219 |
+
poetry run python server.py
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
### Docker
|
| 223 |
+
```bash
|
| 224 |
+
docker-compose -f docker/docker-compose.yml up -d
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
For full installation and configuration details, see the [README](https://github.com/${{ github.repository }}/blob/main/README.md).
|
| 228 |
+
|
| 229 |
+
## Source Code
|
| 230 |
+
|
| 231 |
+
Source code archives (zip and tar.gz) are automatically attached below.
|
| 232 |
+
|
| 233 |
+
---
|
| 234 |
+
|
| 235 |
+
**Thank you to all contributors!**
|
| 236 |
+
EOF
|
| 237 |
+
|
| 238 |
+
echo "Release body generated successfully"
|
| 239 |
+
|
| 240 |
+
- name: Create GitHub Release
|
| 241 |
+
uses: softprops/action-gh-release@v2
|
| 242 |
+
with:
|
| 243 |
+
name: "AI Studio Proxy API ${{ steps.version.outputs.version }}"
|
| 244 |
+
tag_name: ${{ steps.version.outputs.version }}
|
| 245 |
+
body_path: RELEASE_BODY.md
|
| 246 |
+
draft: false
|
| 247 |
+
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
|
| 248 |
+
generate_release_notes: false # We generate our own
|
| 249 |
+
make_latest: true
|
| 250 |
+
env:
|
| 251 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 252 |
+
|
| 253 |
+
- name: Release summary
|
| 254 |
+
run: |
|
| 255 |
+
VERSION="${{ steps.version.outputs.version }}"
|
| 256 |
+
echo "## Release Created Successfully!" >> $GITHUB_STEP_SUMMARY
|
| 257 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 258 |
+
echo "**Version:** $VERSION" >> $GITHUB_STEP_SUMMARY
|
| 259 |
+
echo "**Release URL:** https://github.com/${{ github.repository }}/releases/tag/$VERSION" >> $GITHUB_STEP_SUMMARY
|
| 260 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 261 |
+
echo "### Included Assets" >> $GITHUB_STEP_SUMMARY
|
| 262 |
+
echo "- Source code (zip)" >> $GITHUB_STEP_SUMMARY
|
| 263 |
+
echo "- Source code (tar.gz)" >> $GITHUB_STEP_SUMMARY
|
| 264 |
+
|
| 265 |
+
# ===========================================================================
|
| 266 |
+
# Nightly Release Job
|
| 267 |
+
# ===========================================================================
|
| 268 |
+
# Runs on every push to main branch (not on tags)
|
| 269 |
+
# Creates/updates a rolling "nightly" release with the latest development code
|
| 270 |
+
nightly:
|
| 271 |
+
name: Create Nightly Release
|
| 272 |
+
runs-on: ubuntu-latest
|
| 273 |
+
# Only run on pushes to main branch, NOT on tag pushes
|
| 274 |
+
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
| 275 |
+
|
| 276 |
+
steps:
|
| 277 |
+
- name: Checkout repository
|
| 278 |
+
uses: actions/checkout@v4
|
| 279 |
+
with:
|
| 280 |
+
fetch-depth: 0 # Full history for changelog generation
|
| 281 |
+
|
| 282 |
+
- name: Get build info
|
| 283 |
+
id: build_info
|
| 284 |
+
run: |
|
| 285 |
+
# Get short commit SHA and date for versioning
|
| 286 |
+
SHORT_SHA=$(git rev-parse --short HEAD)
|
| 287 |
+
BUILD_DATE=$(date +'%Y-%m-%d')
|
| 288 |
+
BUILD_TIME=$(date +'%H:%M:%S UTC')
|
| 289 |
+
COMMIT_MSG=$(git log -1 --pretty=format:"%s")
|
| 290 |
+
|
| 291 |
+
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
| 292 |
+
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
| 293 |
+
echo "build_time=$BUILD_TIME" >> $GITHUB_OUTPUT
|
| 294 |
+
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
|
| 295 |
+
|
| 296 |
+
echo "Build info: $SHORT_SHA @ $BUILD_DATE $BUILD_TIME"
|
| 297 |
+
|
| 298 |
+
- name: Generate recent changes
|
| 299 |
+
id: changes
|
| 300 |
+
env:
|
| 301 |
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 302 |
+
run: |
|
| 303 |
+
# Build nightly changelog from latest stable tag to HEAD
|
| 304 |
+
echo "## What's Changed" > NIGHTLY_CHANGES.md
|
| 305 |
+
echo "" >> NIGHTLY_CHANGES.md
|
| 306 |
+
|
| 307 |
+
LATEST_STABLE_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
|
| 308 |
+
|
| 309 |
+
if [ -n "$LATEST_STABLE_TAG" ]; then
|
| 310 |
+
echo "_Changes since ${LATEST_STABLE_TAG}_" >> NIGHTLY_CHANGES.md
|
| 311 |
+
echo "" >> NIGHTLY_CHANGES.md
|
| 312 |
+
echo "Using commit range: ${LATEST_STABLE_TAG}..HEAD"
|
| 313 |
+
else
|
| 314 |
+
echo "_No stable release tag found. Showing last 20 commits (first release scenario)._" >> NIGHTLY_CHANGES.md
|
| 315 |
+
echo "" >> NIGHTLY_CHANGES.md
|
| 316 |
+
echo "No stable tag found; falling back to last 20 commits"
|
| 317 |
+
fi
|
| 318 |
+
|
| 319 |
+
TEMP_FILE=$(mktemp)
|
| 320 |
+
|
| 321 |
+
while IFS= read -r commit_sha || [ -n "$commit_sha" ]; do
|
| 322 |
+
if [ -z "$commit_sha" ]; then
|
| 323 |
+
continue
|
| 324 |
+
fi
|
| 325 |
+
|
| 326 |
+
commit_msg=$(git log -1 --pretty=format:"%s" "$commit_sha")
|
| 327 |
+
short_sha=$(git rev-parse --short "$commit_sha")
|
| 328 |
+
git_author=$(git log -1 --pretty=format:"%an" "$commit_sha")
|
| 329 |
+
|
| 330 |
+
api_response=$(gh api "repos/${{ github.repository }}/commits/$commit_sha" 2>/dev/null || echo '{}')
|
| 331 |
+
github_username=$(echo "$api_response" | jq -r '.author.login // empty')
|
| 332 |
+
|
| 333 |
+
if [ -n "$github_username" ]; then
|
| 334 |
+
echo "- ${commit_msg} by @${github_username} (${short_sha})" >> "$TEMP_FILE"
|
| 335 |
+
else
|
| 336 |
+
echo "- ${commit_msg} by ${git_author} (${short_sha})" >> "$TEMP_FILE"
|
| 337 |
+
fi
|
| 338 |
+
done < <(
|
| 339 |
+
if [ -n "$LATEST_STABLE_TAG" ]; then
|
| 340 |
+
git log --pretty=format:"%H" "${LATEST_STABLE_TAG}..HEAD" 2>/dev/null || true
|
| 341 |
+
else
|
| 342 |
+
git log --pretty=format:"%H" -20 2>/dev/null || true
|
| 343 |
+
fi
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
if [ -s "$TEMP_FILE" ]; then
|
| 347 |
+
cat "$TEMP_FILE" >> NIGHTLY_CHANGES.md
|
| 348 |
+
else
|
| 349 |
+
if [ -n "$LATEST_STABLE_TAG" ]; then
|
| 350 |
+
echo "- No commits found since ${LATEST_STABLE_TAG}" >> NIGHTLY_CHANGES.md
|
| 351 |
+
else
|
| 352 |
+
echo "- Various improvements and updates" >> NIGHTLY_CHANGES.md
|
| 353 |
+
fi
|
| 354 |
+
fi
|
| 355 |
+
|
| 356 |
+
rm -f "$TEMP_FILE"
|
| 357 |
+
echo "" >> NIGHTLY_CHANGES.md
|
| 358 |
+
|
| 359 |
+
echo "Recent changes generated"
|
| 360 |
+
|
| 361 |
+
- name: Build nightly release body
|
| 362 |
+
run: |
|
| 363 |
+
cat > NIGHTLY_BODY.md << 'EOF'
|
| 364 |
+
## ⚠️ Nightly Build (Development Version)
|
| 365 |
+
|
| 366 |
+
**This is an automated nightly build from the `main` branch.**
|
| 367 |
+
|
| 368 |
+
> **Warning**: This release may contain untested features, breaking changes, or bugs.
|
| 369 |
+
> For stable releases, please use a [versioned release](https://github.com/${{ github.repository }}/releases?q=v&expanded=true).
|
| 370 |
+
|
| 371 |
+
### Build Information
|
| 372 |
+
- **Commit**: `${{ steps.build_info.outputs.short_sha }}`
|
| 373 |
+
- **Date**: ${{ steps.build_info.outputs.build_date }} ${{ steps.build_info.outputs.build_time }}
|
| 374 |
+
- **Latest Change**: ${{ steps.build_info.outputs.commit_msg }}
|
| 375 |
+
|
| 376 |
+
EOF
|
| 377 |
+
|
| 378 |
+
cat NIGHTLY_CHANGES.md >> NIGHTLY_BODY.md
|
| 379 |
+
|
| 380 |
+
cat >> NIGHTLY_BODY.md << 'EOF'
|
| 381 |
+
|
| 382 |
+
---
|
| 383 |
+
|
| 384 |
+
## Quick Start
|
| 385 |
+
|
| 386 |
+
```bash
|
| 387 |
+
# Clone the repository
|
| 388 |
+
git clone https://github.com/${{ github.repository }}.git
|
| 389 |
+
cd AIstudioProxyAPI
|
| 390 |
+
|
| 391 |
+
# Install dependencies
|
| 392 |
+
poetry install
|
| 393 |
+
|
| 394 |
+
# Run the server
|
| 395 |
+
poetry run python server.py
|
| 396 |
+
```
|
| 397 |
+
|
| 398 |
+
---
|
| 399 |
+
|
| 400 |
+
*This nightly release is automatically updated on every push to the main branch.*
|
| 401 |
+
|
| 402 |
+
**Thank you to all contributors!**
|
| 403 |
+
EOF
|
| 404 |
+
|
| 405 |
+
echo "Nightly release body generated"
|
| 406 |
+
|
| 407 |
+
- name: Delete existing nightly release
|
| 408 |
+
run: |
|
| 409 |
+
# Delete existing nightly release if it exists (to avoid accumulation)
|
| 410 |
+
echo "Checking for existing nightly release..."
|
| 411 |
+
if gh release view nightly &>/dev/null; then
|
| 412 |
+
echo "Deleting existing nightly release..."
|
| 413 |
+
gh release delete nightly --yes --cleanup-tag
|
| 414 |
+
echo "Existing nightly release deleted"
|
| 415 |
+
else
|
| 416 |
+
echo "No existing nightly release found"
|
| 417 |
+
fi
|
| 418 |
+
env:
|
| 419 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 420 |
+
|
| 421 |
+
- name: Create nightly release
|
| 422 |
+
uses: softprops/action-gh-release@v2
|
| 423 |
+
with:
|
| 424 |
+
name: "Nightly Build (Latest)"
|
| 425 |
+
tag_name: nightly
|
| 426 |
+
body_path: NIGHTLY_BODY.md
|
| 427 |
+
draft: false
|
| 428 |
+
prerelease: true # Mark as prerelease since it's development code
|
| 429 |
+
generate_release_notes: false
|
| 430 |
+
make_latest: false # Don't mark nightly as "latest" - reserve that for stable releases
|
| 431 |
+
target_commitish: ${{ github.sha }}
|
| 432 |
+
env:
|
| 433 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 434 |
+
|
| 435 |
+
- name: Nightly release summary
|
| 436 |
+
run: |
|
| 437 |
+
echo "## Nightly Release Updated!" >> $GITHUB_STEP_SUMMARY
|
| 438 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 439 |
+
echo "**Commit:** ${{ steps.build_info.outputs.short_sha }}" >> $GITHUB_STEP_SUMMARY
|
| 440 |
+
echo "**Build Date:** ${{ steps.build_info.outputs.build_date }}" >> $GITHUB_STEP_SUMMARY
|
| 441 |
+
echo "**Release URL:** https://github.com/${{ github.repository }}/releases/tag/nightly" >> $GITHUB_STEP_SUMMARY
|
.github/workflows/upstream-sync.yml
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Upstream Sync Workflow
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# Automatically syncs this repository with the upstream repository.
|
| 5 |
+
# Creates a PR when new changes are detected from upstream.
|
| 6 |
+
#
|
| 7 |
+
# Local repository: current default branch (main)
|
| 8 |
+
# Upstream source: CJackHwang/AIstudioProxyAPI (main)
|
| 9 |
+
#
|
| 10 |
+
# CONFLICT HANDLING:
|
| 11 |
+
# README.md can conflict when this repository keeps custom docs/content.
|
| 12 |
+
# The workflow auto-resolves README.md conflicts by keeping local version.
|
| 13 |
+
# Other conflicts will fail the workflow and require manual intervention.
|
| 14 |
+
# =============================================================================
|
| 15 |
+
|
| 16 |
+
name: Sync with Upstream
|
| 17 |
+
|
| 18 |
+
on:
|
| 19 |
+
# Run every 6 hours (at 00:00, 06:00, 12:00, 18:00 UTC)
|
| 20 |
+
# This ensures timely detection of upstream changes while avoiding excessive API calls
|
| 21 |
+
schedule:
|
| 22 |
+
- cron: '0 */6 * * *'
|
| 23 |
+
|
| 24 |
+
# Allow manual trigger from GitHub Actions UI
|
| 25 |
+
workflow_dispatch:
|
| 26 |
+
inputs:
|
| 27 |
+
force_sync:
|
| 28 |
+
description: 'Force sync even if no new commits detected'
|
| 29 |
+
required: false
|
| 30 |
+
default: false
|
| 31 |
+
type: boolean
|
| 32 |
+
|
| 33 |
+
# Ensure only one sync workflow runs at a time
|
| 34 |
+
concurrency:
|
| 35 |
+
group: upstream-sync
|
| 36 |
+
cancel-in-progress: true
|
| 37 |
+
|
| 38 |
+
jobs:
|
| 39 |
+
sync:
|
| 40 |
+
name: Sync Repository with Upstream
|
| 41 |
+
runs-on: ubuntu-latest
|
| 42 |
+
|
| 43 |
+
# Required permissions for creating PRs and pushing branches
|
| 44 |
+
permissions:
|
| 45 |
+
contents: write
|
| 46 |
+
pull-requests: write
|
| 47 |
+
|
| 48 |
+
steps:
|
| 49 |
+
# -----------------------------------------------------------------------
|
| 50 |
+
# Step 1: Checkout current repository
|
| 51 |
+
# -----------------------------------------------------------------------
|
| 52 |
+
- name: Checkout Repository
|
| 53 |
+
uses: actions/checkout@v4
|
| 54 |
+
with:
|
| 55 |
+
# Fetch all history to properly compare with upstream
|
| 56 |
+
fetch-depth: 0
|
| 57 |
+
# Use the default GITHUB_TOKEN
|
| 58 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
| 59 |
+
|
| 60 |
+
# -----------------------------------------------------------------------
|
| 61 |
+
# Step 2: Configure Git identity for commits
|
| 62 |
+
# -----------------------------------------------------------------------
|
| 63 |
+
- name: Configure Git Identity
|
| 64 |
+
run: |
|
| 65 |
+
git config user.name "github-actions[bot]"
|
| 66 |
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
| 67 |
+
|
| 68 |
+
# -----------------------------------------------------------------------
|
| 69 |
+
# Step 3: Add upstream remote and fetch latest changes
|
| 70 |
+
# -----------------------------------------------------------------------
|
| 71 |
+
- name: Add Upstream Remote
|
| 72 |
+
run: |
|
| 73 |
+
echo "Adding upstream remote..."
|
| 74 |
+
git remote add upstream https://github.com/CJackHwang/AIstudioProxyAPI.git
|
| 75 |
+
|
| 76 |
+
echo "Fetching upstream changes..."
|
| 77 |
+
git fetch upstream main --tags
|
| 78 |
+
|
| 79 |
+
echo "Upstream remote added and fetched successfully."
|
| 80 |
+
|
| 81 |
+
# -----------------------------------------------------------------------
|
| 82 |
+
# Step 4: Check for new commits from upstream
|
| 83 |
+
# -----------------------------------------------------------------------
|
| 84 |
+
- name: Check for Upstream Changes
|
| 85 |
+
id: check_changes
|
| 86 |
+
run: |
|
| 87 |
+
echo "Comparing local main with upstream/main..."
|
| 88 |
+
|
| 89 |
+
# Get commit counts
|
| 90 |
+
LOCAL_COMMIT=$(git rev-parse HEAD)
|
| 91 |
+
UPSTREAM_COMMIT=$(git rev-parse upstream/main)
|
| 92 |
+
|
| 93 |
+
echo "Local HEAD: $LOCAL_COMMIT"
|
| 94 |
+
echo "Upstream HEAD: $UPSTREAM_COMMIT"
|
| 95 |
+
|
| 96 |
+
# Count commits ahead and behind
|
| 97 |
+
COMMITS_BEHIND=$(git rev-list --count HEAD..upstream/main)
|
| 98 |
+
COMMITS_AHEAD=$(git rev-list --count upstream/main..HEAD)
|
| 99 |
+
|
| 100 |
+
echo "Commits behind upstream: $COMMITS_BEHIND"
|
| 101 |
+
echo "Commits ahead of upstream: $COMMITS_AHEAD"
|
| 102 |
+
|
| 103 |
+
# Determine if sync is needed
|
| 104 |
+
if [ "$COMMITS_BEHIND" -gt 0 ]; then
|
| 105 |
+
echo "has_changes=true" >> $GITHUB_OUTPUT
|
| 106 |
+
echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT
|
| 107 |
+
echo "::notice::Found $COMMITS_BEHIND new commit(s) from upstream"
|
| 108 |
+
else
|
| 109 |
+
echo "has_changes=false" >> $GITHUB_OUTPUT
|
| 110 |
+
echo "commits_behind=0" >> $GITHUB_OUTPUT
|
| 111 |
+
echo "::notice::Fork is up to date with upstream"
|
| 112 |
+
fi
|
| 113 |
+
|
| 114 |
+
# Get the date for PR title
|
| 115 |
+
echo "sync_date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
| 116 |
+
|
| 117 |
+
# Get recent upstream commit messages for PR body
|
| 118 |
+
if [ "$COMMITS_BEHIND" -gt 0 ]; then
|
| 119 |
+
echo "Getting recent upstream commits..."
|
| 120 |
+
COMMIT_LOG=$(git log --oneline HEAD..upstream/main | head -20)
|
| 121 |
+
|
| 122 |
+
# Write commit log to file for multi-line output
|
| 123 |
+
echo "$COMMIT_LOG" > /tmp/commit_log.txt
|
| 124 |
+
|
| 125 |
+
# Use delimiter for multi-line output
|
| 126 |
+
{
|
| 127 |
+
echo 'commit_log<<EOF'
|
| 128 |
+
cat /tmp/commit_log.txt
|
| 129 |
+
echo 'EOF'
|
| 130 |
+
} >> $GITHUB_OUTPUT
|
| 131 |
+
fi
|
| 132 |
+
|
| 133 |
+
# -----------------------------------------------------------------------
|
| 134 |
+
# Step 5: Create sync branch with upstream changes
|
| 135 |
+
# -----------------------------------------------------------------------
|
| 136 |
+
# CONFLICT RESOLUTION STRATEGY:
|
| 137 |
+
# 1. Attempt merge with upstream
|
| 138 |
+
# 2. If conflicts occur, check if ONLY expected files conflict (README.md)
|
| 139 |
+
# 3. Auto-resolve expected conflicts by keeping fork's version (--ours)
|
| 140 |
+
# 4. Fail only if unexpected files have conflicts
|
| 141 |
+
# -----------------------------------------------------------------------
|
| 142 |
+
- name: Create Sync Branch
|
| 143 |
+
id: create_branch
|
| 144 |
+
if: steps.check_changes.outputs.has_changes == 'true' || github.event.inputs.force_sync == 'true'
|
| 145 |
+
run: |
|
| 146 |
+
BRANCH_NAME="sync/upstream-${{ steps.check_changes.outputs.sync_date }}"
|
| 147 |
+
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
| 148 |
+
|
| 149 |
+
echo "Creating sync branch: $BRANCH_NAME"
|
| 150 |
+
|
| 151 |
+
# Check if branch already exists remotely
|
| 152 |
+
if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then
|
| 153 |
+
echo "::warning::Sync branch already exists. Deleting and recreating..."
|
| 154 |
+
git push origin --delete "$BRANCH_NAME" || true
|
| 155 |
+
fi
|
| 156 |
+
|
| 157 |
+
# Create new branch from current main
|
| 158 |
+
git checkout -b "$BRANCH_NAME"
|
| 159 |
+
|
| 160 |
+
echo "Merging upstream/main into sync branch..."
|
| 161 |
+
|
| 162 |
+
# =====================================================================
|
| 163 |
+
# EXPECTED CONFLICT FILES (Translation Fork)
|
| 164 |
+
# These files are expected to conflict because they are intentionally
|
| 165 |
+
# different in this English fork vs the Chinese upstream.
|
| 166 |
+
# =====================================================================
|
| 167 |
+
EXPECTED_CONFLICT_FILES="README.md"
|
| 168 |
+
|
| 169 |
+
# Attempt merge with upstream
|
| 170 |
+
# Using --no-edit to auto-generate merge commit message
|
| 171 |
+
if git merge upstream/main --no-edit --allow-unrelated-histories; then
|
| 172 |
+
echo "merge_success=true" >> $GITHUB_OUTPUT
|
| 173 |
+
echo "auto_resolved=false" >> $GITHUB_OUTPUT
|
| 174 |
+
echo "::notice::Merge successful (no conflicts)"
|
| 175 |
+
else
|
| 176 |
+
echo "::warning::Merge conflicts detected. Checking if auto-resolvable..."
|
| 177 |
+
|
| 178 |
+
# Get list of conflicting files
|
| 179 |
+
CONFLICTING_FILES=$(git diff --name-only --diff-filter=U)
|
| 180 |
+
echo "Conflicting files:"
|
| 181 |
+
echo "$CONFLICTING_FILES"
|
| 182 |
+
|
| 183 |
+
# Check if all conflicts are in expected files
|
| 184 |
+
UNEXPECTED_CONFLICTS=""
|
| 185 |
+
for file in $CONFLICTING_FILES; do
|
| 186 |
+
IS_EXPECTED=false
|
| 187 |
+
for expected in $EXPECTED_CONFLICT_FILES; do
|
| 188 |
+
if [ "$file" = "$expected" ]; then
|
| 189 |
+
IS_EXPECTED=true
|
| 190 |
+
break
|
| 191 |
+
fi
|
| 192 |
+
done
|
| 193 |
+
|
| 194 |
+
if [ "$IS_EXPECTED" = false ]; then
|
| 195 |
+
UNEXPECTED_CONFLICTS="$UNEXPECTED_CONFLICTS $file"
|
| 196 |
+
fi
|
| 197 |
+
done
|
| 198 |
+
|
| 199 |
+
# Trim whitespace
|
| 200 |
+
UNEXPECTED_CONFLICTS=$(echo "$UNEXPECTED_CONFLICTS" | xargs)
|
| 201 |
+
|
| 202 |
+
if [ -n "$UNEXPECTED_CONFLICTS" ]; then
|
| 203 |
+
# Unexpected conflicts found - fail the workflow
|
| 204 |
+
echo "merge_success=false" >> $GITHUB_OUTPUT
|
| 205 |
+
echo "auto_resolved=false" >> $GITHUB_OUTPUT
|
| 206 |
+
echo "::error::Unexpected conflicts in: $UNEXPECTED_CONFLICTS"
|
| 207 |
+
echo "::error::Manual intervention required for these files."
|
| 208 |
+
|
| 209 |
+
# Abort the merge to leave clean state
|
| 210 |
+
git merge --abort
|
| 211 |
+
exit 1
|
| 212 |
+
else
|
| 213 |
+
# Only expected conflicts - auto-resolve by keeping fork's version
|
| 214 |
+
echo "::notice::All conflicts are in expected files. Auto-resolving..."
|
| 215 |
+
|
| 216 |
+
for file in $CONFLICTING_FILES; do
|
| 217 |
+
echo " Resolving $file by keeping fork's version (--ours)..."
|
| 218 |
+
git checkout --ours "$file"
|
| 219 |
+
git add "$file"
|
| 220 |
+
done
|
| 221 |
+
|
| 222 |
+
# Complete the merge with resolved conflicts
|
| 223 |
+
git commit --no-edit
|
| 224 |
+
|
| 225 |
+
echo "merge_success=true" >> $GITHUB_OUTPUT
|
| 226 |
+
echo "auto_resolved=true" >> $GITHUB_OUTPUT
|
| 227 |
+
echo "::notice::Merge successful (auto-resolved expected conflicts)"
|
| 228 |
+
fi
|
| 229 |
+
fi
|
| 230 |
+
|
| 231 |
+
# -----------------------------------------------------------------------
|
| 232 |
+
# Step 6: Push sync branch to origin
|
| 233 |
+
# -----------------------------------------------------------------------
|
| 234 |
+
- name: Push Sync Branch
|
| 235 |
+
if: steps.create_branch.outputs.merge_success == 'true'
|
| 236 |
+
run: |
|
| 237 |
+
echo "Pushing sync branch to origin..."
|
| 238 |
+
git push origin "${{ steps.create_branch.outputs.branch_name }}"
|
| 239 |
+
echo "::notice::Sync branch pushed successfully"
|
| 240 |
+
|
| 241 |
+
# -----------------------------------------------------------------------
|
| 242 |
+
# Step 7: Create Pull Request
|
| 243 |
+
# -----------------------------------------------------------------------
|
| 244 |
+
- name: Create Pull Request
|
| 245 |
+
if: steps.create_branch.outputs.merge_success == 'true'
|
| 246 |
+
uses: peter-evans/create-pull-request@v7
|
| 247 |
+
id: create_pr
|
| 248 |
+
with:
|
| 249 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
| 250 |
+
branch: ${{ steps.create_branch.outputs.branch_name }}
|
| 251 |
+
base: main
|
| 252 |
+
title: "chore: Sync with upstream [${{ steps.check_changes.outputs.sync_date }}]"
|
| 253 |
+
body: |
|
| 254 |
+
## Upstream Sync
|
| 255 |
+
|
| 256 |
+
This PR syncs the fork with the upstream repository.
|
| 257 |
+
|
| 258 |
+
### Summary
|
| 259 |
+
- **Upstream Repository**: [CJackHwang/AIstudioProxyAPI](https://github.com/CJackHwang/AIstudioProxyAPI)
|
| 260 |
+
- **Commits Behind**: ${{ steps.check_changes.outputs.commits_behind }}
|
| 261 |
+
- **Sync Date**: ${{ steps.check_changes.outputs.sync_date }}
|
| 262 |
+
- **Auto-resolved Conflicts**: ${{ steps.create_branch.outputs.auto_resolved == 'true' && '✅ Yes (README.md kept from fork)' || '❌ None' }}
|
| 263 |
+
|
| 264 |
+
### Recent Upstream Commits
|
| 265 |
+
```
|
| 266 |
+
${{ steps.check_changes.outputs.commit_log }}
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
### Review Notes
|
| 270 |
+
- Please review changes carefully before merging
|
| 271 |
+
- Check for any conflicts with fork-specific modifications
|
| 272 |
+
- Ensure translation/localization files are preserved
|
| 273 |
+
${{ steps.create_branch.outputs.auto_resolved == 'true' && '- ⚠️ **README.md conflict was auto-resolved** - The English README from this fork was preserved' || '' }}
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
*This PR was automatically generated by the [Upstream Sync Workflow](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*
|
| 277 |
+
labels: |
|
| 278 |
+
upstream-sync
|
| 279 |
+
automated
|
| 280 |
+
draft: false
|
| 281 |
+
delete-branch: false
|
| 282 |
+
|
| 283 |
+
# -----------------------------------------------------------------------
|
| 284 |
+
# Step 8: Output Results
|
| 285 |
+
# -----------------------------------------------------------------------
|
| 286 |
+
- name: Output Results
|
| 287 |
+
if: always()
|
| 288 |
+
run: |
|
| 289 |
+
echo "=========================================="
|
| 290 |
+
echo "Upstream Sync Workflow Complete"
|
| 291 |
+
echo "=========================================="
|
| 292 |
+
echo ""
|
| 293 |
+
|
| 294 |
+
if [ "${{ steps.check_changes.outputs.has_changes }}" == "true" ]; then
|
| 295 |
+
echo "Status: Changes detected from upstream"
|
| 296 |
+
echo "Commits behind: ${{ steps.check_changes.outputs.commits_behind }}"
|
| 297 |
+
|
| 298 |
+
if [ "${{ steps.create_branch.outputs.merge_success }}" == "true" ]; then
|
| 299 |
+
echo "Merge: Successful"
|
| 300 |
+
|
| 301 |
+
if [ "${{ steps.create_branch.outputs.auto_resolved }}" == "true" ]; then
|
| 302 |
+
echo "Conflicts: Auto-resolved (expected files only)"
|
| 303 |
+
echo " - README.md: Kept fork's English version"
|
| 304 |
+
else
|
| 305 |
+
echo "Conflicts: None"
|
| 306 |
+
fi
|
| 307 |
+
|
| 308 |
+
echo "PR Created: Yes"
|
| 309 |
+
echo "Branch: ${{ steps.create_branch.outputs.branch_name }}"
|
| 310 |
+
else
|
| 311 |
+
echo "Merge: Failed (unexpected conflicts detected)"
|
| 312 |
+
echo "PR Created: No"
|
| 313 |
+
echo ""
|
| 314 |
+
echo "Manual intervention required to resolve conflicts."
|
| 315 |
+
echo "Expected conflict files (auto-resolved): README.md"
|
| 316 |
+
echo "Unexpected conflicts require manual resolution."
|
| 317 |
+
fi
|
| 318 |
+
else
|
| 319 |
+
echo "Status: Fork is up to date with upstream"
|
| 320 |
+
echo "No action required."
|
| 321 |
+
fi
|
| 322 |
+
|
| 323 |
+
echo ""
|
| 324 |
+
echo "=========================================="
|
.gitignore
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
certs/*
|
| 5 |
+
npm-debug.log*
|
| 6 |
+
yarn-debug.log*
|
| 7 |
+
yarn-error.log*
|
| 8 |
+
pnpm-debug.log*
|
| 9 |
+
lerna-debug.log*
|
| 10 |
+
/upload_images
|
| 11 |
+
/upload_files
|
| 12 |
+
|
| 13 |
+
# Diagnostic reports (https://nodejs.org/api/report.html)
|
| 14 |
+
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
| 15 |
+
|
| 16 |
+
# Runtime data
|
| 17 |
+
pids
|
| 18 |
+
*.pid
|
| 19 |
+
*.seed
|
| 20 |
+
*.pid.lock
|
| 21 |
+
|
| 22 |
+
# Directory for instrumented libs generated by jscoverage/JSCover
|
| 23 |
+
lib-cov
|
| 24 |
+
|
| 25 |
+
# Coverage directory used by tools like istanbul
|
| 26 |
+
coverage
|
| 27 |
+
*.lcov
|
| 28 |
+
coverage_*.txt
|
| 29 |
+
coverage_*.html
|
| 30 |
+
|
| 31 |
+
# nyc test coverage
|
| 32 |
+
.nyc_output
|
| 33 |
+
|
| 34 |
+
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
| 35 |
+
.grunt
|
| 36 |
+
|
| 37 |
+
# node-waf configuration
|
| 38 |
+
.lock-wscript
|
| 39 |
+
|
| 40 |
+
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
| 41 |
+
build/Release
|
| 42 |
+
|
| 43 |
+
# Dependency directories
|
| 44 |
+
node_modules/
|
| 45 |
+
jspm_packages/
|
| 46 |
+
|
| 47 |
+
# Snowpack dependency directory (https://snowpack.dev/)
|
| 48 |
+
web_modules/
|
| 49 |
+
|
| 50 |
+
# TypeScript cache
|
| 51 |
+
*.tsbuildinfo
|
| 52 |
+
|
| 53 |
+
# Optional npm cache directory
|
| 54 |
+
.npm
|
| 55 |
+
|
| 56 |
+
# Optional eslint cache
|
| 57 |
+
.eslintcache
|
| 58 |
+
|
| 59 |
+
# Optional stylelint cache
|
| 60 |
+
.stylelintcache
|
| 61 |
+
|
| 62 |
+
# Microbundle cache
|
| 63 |
+
.rpt2_cache/
|
| 64 |
+
.rts2_cache_cjs/
|
| 65 |
+
.rts2_cache_es/
|
| 66 |
+
.rts2_cache_umd/
|
| 67 |
+
|
| 68 |
+
# Optional REPL history
|
| 69 |
+
.node_repl_history
|
| 70 |
+
|
| 71 |
+
# Output of 'npm pack'
|
| 72 |
+
*.tgz
|
| 73 |
+
|
| 74 |
+
# Yarn Integrity file
|
| 75 |
+
.yarn-integrity
|
| 76 |
+
|
| 77 |
+
# dotenv environment variables file
|
| 78 |
+
.env.development.local
|
| 79 |
+
.env.test.local
|
| 80 |
+
.env.production.local
|
| 81 |
+
.env.local
|
| 82 |
+
|
| 83 |
+
# Windows artifacts
|
| 84 |
+
NUL
|
| 85 |
+
|
| 86 |
+
# Test backup files
|
| 87 |
+
*.backup
|
| 88 |
+
|
| 89 |
+
# parcel-bundler cache (https://parceljs.org/)
|
| 90 |
+
.cache
|
| 91 |
+
.parcel-cache
|
| 92 |
+
|
| 93 |
+
# Next.js build output
|
| 94 |
+
.next
|
| 95 |
+
out
|
| 96 |
+
|
| 97 |
+
# Nuxt.js build output
|
| 98 |
+
.nuxt
|
| 99 |
+
dist
|
| 100 |
+
|
| 101 |
+
# Gatsby files
|
| 102 |
+
.cache/
|
| 103 |
+
# Comment in the next line if you're using Gatsby Cloud
|
| 104 |
+
# .gatsby/
|
| 105 |
+
public
|
| 106 |
+
|
| 107 |
+
# vuepress build output
|
| 108 |
+
.vuepress/dist
|
| 109 |
+
|
| 110 |
+
# Serverless directories
|
| 111 |
+
.serverless/
|
| 112 |
+
|
| 113 |
+
# FuseBox cache
|
| 114 |
+
.fusebox/
|
| 115 |
+
|
| 116 |
+
# DynamoDB Local files
|
| 117 |
+
.dynamodb/
|
| 118 |
+
|
| 119 |
+
# TernJS port file
|
| 120 |
+
.tern-port
|
| 121 |
+
|
| 122 |
+
# Stores VSCode versions used for testing VSCode extensions
|
| 123 |
+
.vscode-test
|
| 124 |
+
|
| 125 |
+
# macOS files
|
| 126 |
+
.DS_Store
|
| 127 |
+
.AppleDouble
|
| 128 |
+
.LSOverride
|
| 129 |
+
|
| 130 |
+
# Thumbnails
|
| 131 |
+
._*
|
| 132 |
+
|
| 133 |
+
# Files that might appear on external disk
|
| 134 |
+
.Spotlight-V100
|
| 135 |
+
.Trashes
|
| 136 |
+
|
| 137 |
+
# Temporary files created by editors
|
| 138 |
+
*~
|
| 139 |
+
#*.swp
|
| 140 |
+
|
| 141 |
+
# IDE config folders
|
| 142 |
+
.idea/
|
| 143 |
+
.vscode/
|
| 144 |
+
|
| 145 |
+
# Custom
|
| 146 |
+
errors/
|
| 147 |
+
|
| 148 |
+
# Python
|
| 149 |
+
__pycache__/
|
| 150 |
+
*.py[cod]
|
| 151 |
+
*$py.class
|
| 152 |
+
|
| 153 |
+
# Python Libraries
|
| 154 |
+
*.egg-info/
|
| 155 |
+
*.egg
|
| 156 |
+
|
| 157 |
+
# Distribution / packaging
|
| 158 |
+
.Python
|
| 159 |
+
build/
|
| 160 |
+
dist/
|
| 161 |
+
part/
|
| 162 |
+
sdist/
|
| 163 |
+
*.manifest
|
| 164 |
+
*.spec
|
| 165 |
+
wheels/
|
| 166 |
+
|
| 167 |
+
# PyInstaller
|
| 168 |
+
# Usually these files are written by a python script from a template
|
| 169 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 170 |
+
*.manifest
|
| 171 |
+
*.spec
|
| 172 |
+
|
| 173 |
+
# Installer logs
|
| 174 |
+
pip-log.txt
|
| 175 |
+
pip-delete-this-directory.txt
|
| 176 |
+
|
| 177 |
+
# Unit test / coverage reports
|
| 178 |
+
htmlcov/
|
| 179 |
+
.tox/
|
| 180 |
+
.nox/
|
| 181 |
+
.coverage
|
| 182 |
+
.coverage.*
|
| 183 |
+
.cache
|
| 184 |
+
nosetests.xml
|
| 185 |
+
coverage.xml
|
| 186 |
+
*.cover
|
| 187 |
+
*.py,cover
|
| 188 |
+
.hypothesis/
|
| 189 |
+
.pytest_cache/
|
| 190 |
+
.testmondata*
|
| 191 |
+
|
| 192 |
+
# Environments
|
| 193 |
+
.venv
|
| 194 |
+
env/
|
| 195 |
+
venv/
|
| 196 |
+
ENV/
|
| 197 |
+
env.bak/
|
| 198 |
+
venv.bak/
|
| 199 |
+
|
| 200 |
+
# Jupyter Notebook
|
| 201 |
+
.ipynb_checkpoints
|
| 202 |
+
profile_default/
|
| 203 |
+
ipython_config.py
|
| 204 |
+
|
| 205 |
+
# pyenv
|
| 206 |
+
.python-version
|
| 207 |
+
|
| 208 |
+
# Celery stuff
|
| 209 |
+
celerybeat-schedule
|
| 210 |
+
celerybeat.pid
|
| 211 |
+
|
| 212 |
+
# SageMath parsed files
|
| 213 |
+
*.sage.py
|
| 214 |
+
|
| 215 |
+
# Environments
|
| 216 |
+
.env
|
| 217 |
+
.venv
|
| 218 |
+
env/
|
| 219 |
+
venv/
|
| 220 |
+
ENV/
|
| 221 |
+
env.bak/
|
| 222 |
+
venv.bak/
|
| 223 |
+
|
| 224 |
+
# Error snapshots directory (Python specific)
|
| 225 |
+
# New date-based structure: errors_py/YYYY-MM-DD/HH-MM-SS_reqid_errorname/
|
| 226 |
+
errors_py/*/
|
| 227 |
+
!errors_py/.gitkeep
|
| 228 |
+
logs/
|
| 229 |
+
errors_py
|
| 230 |
+
|
| 231 |
+
# Authentication Profiles (Sensitive)
|
| 232 |
+
auth_profiles/active/*
|
| 233 |
+
!auth_profiles/active/.gitkeep
|
| 234 |
+
auth_profiles/saved/*
|
| 235 |
+
!auth_profiles/saved/.gitkeep
|
| 236 |
+
auth_profiles/emergency/*
|
| 237 |
+
!auth_profiles/emergency/.gitkeep
|
| 238 |
+
config/profile_usage.json
|
| 239 |
+
config/cooldown_status.json
|
| 240 |
+
auth_profiles/locked
|
| 241 |
+
auth_profilesBackup
|
| 242 |
+
|
| 243 |
+
# Camoufox/Playwright Profile Data (Assume these are generated/temporary)
|
| 244 |
+
camoufox_profile/
|
| 245 |
+
chrome_temp_profile/
|
| 246 |
+
|
| 247 |
+
# Deprecated Javascript Version node_modules
|
| 248 |
+
deprecated_javascript_version/node_modules/
|
| 249 |
+
|
| 250 |
+
.roomodes
|
| 251 |
+
memory-bank/
|
| 252 |
+
gui_config.json
|
| 253 |
+
.rooignore
|
| 254 |
+
.kiloignore
|
| 255 |
+
.kilocodeignore
|
| 256 |
+
|
| 257 |
+
# key
|
| 258 |
+
key.txt
|
| 259 |
+
|
| 260 |
+
# 脚本注入相关文件
|
| 261 |
+
# 用户自定义的模型配置文件(保留示例文件)
|
| 262 |
+
browser_utils/model_configs.json
|
| 263 |
+
browser_utils/my_*.json
|
| 264 |
+
# 用户自定义的油猴脚本(如果不是默认的)
|
| 265 |
+
browser_utils/custom_*.js
|
| 266 |
+
browser_utils/my_*.js
|
| 267 |
+
# 临时生成的脚本文件
|
| 268 |
+
browser_utils/generated_*.js
|
| 269 |
+
# Docker 环境的实际配置文件(保留示例文件)
|
| 270 |
+
docker/.env
|
| 271 |
+
docker/my_*.json
|
| 272 |
+
|
| 273 |
+
monkeytype.sqlite3
|
| 274 |
+
|
| 275 |
+
# Pyright output
|
| 276 |
+
baseline_pyright.txt
|
| 277 |
+
pyright_*.txt
|
| 278 |
+
|
| 279 |
+
# Ruff cache (explicit)
|
| 280 |
+
.ruff_cache/
|
| 281 |
+
|
| 282 |
+
# Temporary debug/output files
|
| 283 |
+
pyright_output.txt
|
| 284 |
+
pyright_utils_full.txt
|
| 285 |
+
temp_*.txt
|
| 286 |
+
temp_*.md
|
| 287 |
+
utils_errors.txt
|
| 288 |
+
# React frontend build artifacts and dependencies
|
| 289 |
+
static/frontend/node_modules/
|
| 290 |
+
static/frontend/coverage/
|
| 291 |
+
static/frontend/dist/
|
| 292 |
+
|
| 293 |
+
# GUI Launcher user config (contains user-specific settings)
|
| 294 |
+
gui/user_config.json
|
| 295 |
+
|
| 296 |
+
.multi-instance-runtime/
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 贡献指南
|
| 2 |
+
|
| 3 |
+
感谢你参与 AI Studio Proxy API 的改进。
|
| 4 |
+
|
| 5 |
+
## 本地开发准备
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
git clone https://github.com/CJackHwang/AIstudioProxyAPI.git
|
| 9 |
+
cd AIstudioProxyAPI
|
| 10 |
+
poetry install --with dev
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
## 提交前检查(必须)
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
poetry run ruff check .
|
| 17 |
+
poetry run pyright
|
| 18 |
+
poetry run pytest
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
如涉及前端改动,请额外执行:
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
cd static/frontend
|
| 25 |
+
npm ci
|
| 26 |
+
npm run build
|
| 27 |
+
npm run test
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## 分支与提交规范
|
| 31 |
+
|
| 32 |
+
- 新功能:`feat/...`
|
| 33 |
+
- 缺陷修复:`fix/...`
|
| 34 |
+
- 文档改动:`docs/...`
|
| 35 |
+
- 重构:`refactor/...`
|
| 36 |
+
|
| 37 |
+
建议使用 Conventional Commits:
|
| 38 |
+
|
| 39 |
+
- `feat:` 新能力
|
| 40 |
+
- `fix:` 缺陷修复
|
| 41 |
+
- `docs:` 文档更新
|
| 42 |
+
- `refactor:` 重构
|
| 43 |
+
- `test:` 测试改进
|
| 44 |
+
- `chore:` 工程性调整
|
| 45 |
+
|
| 46 |
+
## Pull Request 要求
|
| 47 |
+
|
| 48 |
+
- 说明变更动机、核心实现和影响范围。
|
| 49 |
+
- 如涉及配置/接口变更,必须更新文档。
|
| 50 |
+
- 引入新环境变量时,必须同步更新 `.env.example`。
|
| 51 |
+
- 通过 CI 检查后再请求合并。
|
| 52 |
+
|
| 53 |
+
## CI/CD 工作流
|
| 54 |
+
|
| 55 |
+
- `PR Check`:运行 lint/typecheck/tests。
|
| 56 |
+
- `Release`:tag 或手动触发发布。
|
| 57 |
+
- `Sync with Upstream`:从上游仓库同步提交并自动建 PR。
|
| 58 |
+
|
| 59 |
+
## 参考文档
|
| 60 |
+
|
| 61 |
+
- [快速开始](docs/quick-start.md)
|
| 62 |
+
- [配置参考](docs/configuration-reference.md)
|
| 63 |
+
- [排障指南](docs/troubleshooting.md)
|
| 64 |
+
- [开发、测试与发布](docs/development-and-release.md)
|
| 65 |
+
|
| 66 |
+
## Issue 反馈建议
|
| 67 |
+
|
| 68 |
+
请尽量提供:
|
| 69 |
+
|
| 70 |
+
- 复现步骤
|
| 71 |
+
- 期望行为与实际行为
|
| 72 |
+
- Python 版本 / 操作系统
|
| 73 |
+
- 相关日志(如 `logs/`、`errors_py/`)
|
Dockerfile
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces Dockerfile.
|
| 2 |
+
# The original Docker setup lives in docker/Dockerfile; this root Dockerfile is
|
| 3 |
+
# required by Spaces and keeps the public HTTP service on port 7860.
|
| 4 |
+
FROM python:3.10-slim-bookworm AS builder
|
| 5 |
+
|
| 6 |
+
ARG DEBIAN_FRONTEND=noninteractive
|
| 7 |
+
ARG PROXY_ADDR
|
| 8 |
+
|
| 9 |
+
RUN if [ -n "$PROXY_ADDR" ]; then \
|
| 10 |
+
printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \
|
| 11 |
+
fi && \
|
| 12 |
+
apt-get update && \
|
| 13 |
+
apt-get install -y --no-install-recommends curl && \
|
| 14 |
+
apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
| 15 |
+
if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi
|
| 16 |
+
|
| 17 |
+
ENV HTTP_PROXY=${PROXY_ADDR}
|
| 18 |
+
ENV HTTPS_PROXY=${PROXY_ADDR}
|
| 19 |
+
ENV POETRY_HOME="/opt/poetry"
|
| 20 |
+
ENV POETRY_VERSION=1.8.3
|
| 21 |
+
ENV PATH="${POETRY_HOME}/bin:${PATH}"
|
| 22 |
+
|
| 23 |
+
RUN curl -sSL https://install.python-poetry.org | python3 - --version ${POETRY_VERSION}
|
| 24 |
+
|
| 25 |
+
WORKDIR /app_builder
|
| 26 |
+
COPY pyproject.toml poetry.lock ./
|
| 27 |
+
RUN poetry config virtualenvs.create false --local && \
|
| 28 |
+
poetry install --only main --no-root --no-interaction --no-ansi
|
| 29 |
+
|
| 30 |
+
FROM python:3.10-slim-bookworm
|
| 31 |
+
|
| 32 |
+
ARG DEBIAN_FRONTEND=noninteractive
|
| 33 |
+
ARG PROXY_ADDR
|
| 34 |
+
|
| 35 |
+
ENV HTTP_PROXY=${PROXY_ADDR}
|
| 36 |
+
ENV HTTPS_PROXY=${PROXY_ADDR}
|
| 37 |
+
|
| 38 |
+
RUN if [ -n "$PROXY_ADDR" ]; then \
|
| 39 |
+
printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$PROXY_ADDR" "$PROXY_ADDR" > /etc/apt/apt.conf.d/99proxy; \
|
| 40 |
+
fi && \
|
| 41 |
+
apt-get update && \
|
| 42 |
+
apt-get install -y --no-install-recommends \
|
| 43 |
+
ca-certificates curl fonts-liberation \
|
| 44 |
+
libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
|
| 45 |
+
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 \
|
| 46 |
+
libpangocairo-1.0-0 libu2f-udev libx11-6 libx11-xcb1 libxcb1 \
|
| 47 |
+
libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 \
|
| 48 |
+
libxrender1 libxtst6 && \
|
| 49 |
+
apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
| 50 |
+
if [ -n "$PROXY_ADDR" ]; then rm -f /etc/apt/apt.conf.d/99proxy; fi
|
| 51 |
+
|
| 52 |
+
RUN groupadd -r appgroup && useradd -r -g appgroup -s /bin/bash -d /app appuser
|
| 53 |
+
|
| 54 |
+
WORKDIR /app
|
| 55 |
+
|
| 56 |
+
COPY --from=builder /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/
|
| 57 |
+
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
| 58 |
+
COPY --from=builder /opt/poetry/bin/poetry /usr/local/bin/poetry
|
| 59 |
+
COPY . .
|
| 60 |
+
|
| 61 |
+
RUN camoufox fetch && \
|
| 62 |
+
python -m playwright install firefox && \
|
| 63 |
+
python scripts/update_browserforge_data.py
|
| 64 |
+
|
| 65 |
+
RUN mkdir -p /var/cache/camoufox && \
|
| 66 |
+
if [ -d /root/.cache/camoufox ]; then cp -a /root/.cache/camoufox/. /var/cache/camoufox/; fi && \
|
| 67 |
+
mkdir -p /app/.cache && \
|
| 68 |
+
ln -s /var/cache/camoufox /app/.cache/camoufox && \
|
| 69 |
+
mkdir -p /app/logs \
|
| 70 |
+
/app/auth_profiles/active \
|
| 71 |
+
/app/auth_profiles/saved \
|
| 72 |
+
/app/auth_profiles/emergency \
|
| 73 |
+
/app/certs \
|
| 74 |
+
/app/browser_utils/custom_scripts \
|
| 75 |
+
/home/appuser/.cache/ms-playwright \
|
| 76 |
+
/home/appuser/.mozilla && \
|
| 77 |
+
chown -R appuser:appgroup /app /home/appuser /var/cache/camoufox
|
| 78 |
+
|
| 79 |
+
USER appuser
|
| 80 |
+
|
| 81 |
+
ENV HOME=/app
|
| 82 |
+
ENV PLAYWRIGHT_BROWSERS_PATH=/home/appuser/.cache/ms-playwright
|
| 83 |
+
ENV PYTHONUNBUFFERED=1
|
| 84 |
+
ENV PORT=7860
|
| 85 |
+
ENV SERVER_PORT=7860
|
| 86 |
+
ENV DEFAULT_FASTAPI_PORT=7860
|
| 87 |
+
ENV DEFAULT_CAMOUFOX_PORT=9222
|
| 88 |
+
ENV STREAM_PORT=3120
|
| 89 |
+
ENV SERVER_LOG_LEVEL=INFO
|
| 90 |
+
ENV DEBUG_LOGS_ENABLED=false
|
| 91 |
+
ENV AUTO_CONFIRM_LOGIN=true
|
| 92 |
+
ENV INTERNAL_CAMOUFOX_PROXY=""
|
| 93 |
+
ENV HTTP_PROXY=""
|
| 94 |
+
ENV HTTPS_PROXY=""
|
| 95 |
+
|
| 96 |
+
EXPOSE 7860
|
| 97 |
+
|
| 98 |
+
CMD ["python", "scripts/huggingface/entrypoint.py"]
|
LICENSE
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 19 November 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
| 5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 6 |
+
of this license document, but changing it is not allowed.
|
| 7 |
+
|
| 8 |
+
Preamble
|
| 9 |
+
|
| 10 |
+
The GNU Affero General Public License is a free, copyleft license for
|
| 11 |
+
software and other kinds of works, specifically designed to ensure
|
| 12 |
+
cooperation with the community in the case of network server software.
|
| 13 |
+
|
| 14 |
+
The licenses for most software and other practical works are designed
|
| 15 |
+
to take away your freedom to share and change the works. By contrast,
|
| 16 |
+
our General Public Licenses are intended to guarantee your freedom to
|
| 17 |
+
share and change all versions of a program--to make sure it remains free
|
| 18 |
+
software for all its users.
|
| 19 |
+
|
| 20 |
+
When we speak of free software, we are referring to freedom, not
|
| 21 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 22 |
+
have the freedom to distribute copies of free software (and charge for
|
| 23 |
+
them if you wish), that you receive source code or can get it if you
|
| 24 |
+
want it, that you can change the software or use pieces of it in new
|
| 25 |
+
free programs, and that you know you can do these things.
|
| 26 |
+
|
| 27 |
+
Developers that use our General Public Licenses protect your rights
|
| 28 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
| 29 |
+
you this License which gives you legal permission to copy, distribute
|
| 30 |
+
and/or modify the software.
|
| 31 |
+
|
| 32 |
+
A secondary benefit of defending all users' freedom is that
|
| 33 |
+
improvements made in alternate versions of the program, if they
|
| 34 |
+
receive widespread use, become available for other developers to
|
| 35 |
+
incorporate. Many developers of free software are heartened and
|
| 36 |
+
encouraged by the resulting cooperation. However, in the case of
|
| 37 |
+
software used on network servers, this result may fail to come about.
|
| 38 |
+
The GNU General Public License permits making a modified version and
|
| 39 |
+
letting the public access it on a server without ever releasing its
|
| 40 |
+
source code to the public.
|
| 41 |
+
|
| 42 |
+
The GNU Affero General Public License is designed specifically to
|
| 43 |
+
ensure that, in such cases, the modified source code becomes available
|
| 44 |
+
to the community. It requires the operator of a network server to
|
| 45 |
+
provide the source code of the modified version running there to the
|
| 46 |
+
users of that server. Therefore, public use of a modified version, on
|
| 47 |
+
a publicly accessible server, gives the public access to the source
|
| 48 |
+
code of the modified version.
|
| 49 |
+
|
| 50 |
+
An older license, called the Affero General Public License and
|
| 51 |
+
published by Affero, was designed to accomplish similar goals. This is
|
| 52 |
+
a different license, not a version of the Affero GPL, but Affero has
|
| 53 |
+
released a new version of the Affero GPL which permits relicensing under
|
| 54 |
+
this license.
|
| 55 |
+
|
| 56 |
+
The precise terms and conditions for copying, distribution and
|
| 57 |
+
modification follow.
|
| 58 |
+
|
| 59 |
+
TERMS AND CONDITIONS
|
| 60 |
+
|
| 61 |
+
0. Definitions.
|
| 62 |
+
|
| 63 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
| 64 |
+
|
| 65 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 66 |
+
works, such as semiconductor masks.
|
| 67 |
+
|
| 68 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 69 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 70 |
+
"recipients" may be individuals or organizations.
|
| 71 |
+
|
| 72 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 73 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 74 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 75 |
+
earlier work or a work "based on" the earlier work.
|
| 76 |
+
|
| 77 |
+
A "covered work" means either the unmodified Program or a work based
|
| 78 |
+
on the Program.
|
| 79 |
+
|
| 80 |
+
To "propagate" a work means to do anything with it that, without
|
| 81 |
+
permission, would make you directly or secondarily liable for
|
| 82 |
+
infringement under applicable copyright law, except executing it on a
|
| 83 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 84 |
+
distribution (with or without modification), making available to the
|
| 85 |
+
public, and in some countries other activities as well.
|
| 86 |
+
|
| 87 |
+
To "convey" a work means any kind of propagation that enables other
|
| 88 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 89 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 90 |
+
|
| 91 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 92 |
+
to the extent that it includes a convenient and prominently visible
|
| 93 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 94 |
+
tells the user that there is no warranty for the work (except to the
|
| 95 |
+
extent that warranties are provided), that licensees may convey the
|
| 96 |
+
work under this License, and how to view a copy of this License. If
|
| 97 |
+
the interface presents a list of user commands or options, such as a
|
| 98 |
+
menu, a prominent item in the list meets this criterion.
|
| 99 |
+
|
| 100 |
+
1. Source Code.
|
| 101 |
+
|
| 102 |
+
The "source code" for a work means the preferred form of the work
|
| 103 |
+
for making modifications to it. "Object code" means any non-source
|
| 104 |
+
form of a work.
|
| 105 |
+
|
| 106 |
+
A "Standard Interface" means an interface that either is an official
|
| 107 |
+
standard defined by a recognized standards body, or, in the case of
|
| 108 |
+
interfaces specified for a particular programming language, one that
|
| 109 |
+
is widely used among developers working in that language.
|
| 110 |
+
|
| 111 |
+
The "System Libraries" of an executable work include anything, other
|
| 112 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 113 |
+
packaging a Major Component, but which is not part of that Major
|
| 114 |
+
Component, and (b) serves only to enable use of the work with that
|
| 115 |
+
Major Component, or to implement a Standard Interface for which an
|
| 116 |
+
implementation is available to the public in source code form. A
|
| 117 |
+
"Major Component", in this context, means a major essential component
|
| 118 |
+
(kernel, window system, and so on) of the specific operating system
|
| 119 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 120 |
+
produce the work, or an object code interpreter used to run it.
|
| 121 |
+
|
| 122 |
+
The "Corresponding Source" for a work in object code form means all
|
| 123 |
+
the source code needed to generate, install, and (for an executable
|
| 124 |
+
work) run the object code and to modify the work, including scripts to
|
| 125 |
+
control those activities. However, it does not include the work's
|
| 126 |
+
System Libraries, or general-purpose tools or generally available free
|
| 127 |
+
programs which are used unmodified in performing those activities but
|
| 128 |
+
which are not part of the work. For example, Corresponding Source
|
| 129 |
+
includes interface definition files associated with source files for
|
| 130 |
+
the work, and the source code for shared libraries and dynamically
|
| 131 |
+
linked subprograms that the work is specifically designed to require,
|
| 132 |
+
such as by intimate data communication or control flow between those
|
| 133 |
+
subprograms and other parts of the work.
|
| 134 |
+
|
| 135 |
+
The Corresponding Source need not include anything that users
|
| 136 |
+
can regenerate automatically from other parts of the Corresponding
|
| 137 |
+
Source.
|
| 138 |
+
|
| 139 |
+
The Corresponding Source for a work in source code form is that
|
| 140 |
+
same work.
|
| 141 |
+
|
| 142 |
+
2. Basic Permissions.
|
| 143 |
+
|
| 144 |
+
All rights granted under this License are granted for the term of
|
| 145 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 146 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 147 |
+
permission to run the unmodified Program. The output from running a
|
| 148 |
+
covered work is covered by this License only if the output, given its
|
| 149 |
+
content, constitutes a covered work. This License acknowledges your
|
| 150 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 151 |
+
|
| 152 |
+
You may make, run and propagate covered works that you do not
|
| 153 |
+
convey, without conditions so long as your license otherwise remains
|
| 154 |
+
in force. You may convey covered works to others for the sole purpose
|
| 155 |
+
of having them make modifications exclusively for you, or provide you
|
| 156 |
+
with facilities for running those works, provided that you comply with
|
| 157 |
+
the terms of this License in conveying all material for which you do
|
| 158 |
+
not control copyright. Those thus making or running the covered works
|
| 159 |
+
for you must do so exclusively on your behalf, under your direction
|
| 160 |
+
and control, on terms that prohibit them from making any copies of
|
| 161 |
+
your copyrighted material outside their relationship with you.
|
| 162 |
+
|
| 163 |
+
Conveying under any other circumstances is permitted solely under
|
| 164 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 165 |
+
makes it unnecessary.
|
| 166 |
+
|
| 167 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 168 |
+
|
| 169 |
+
No covered work shall be deemed part of an effective technological
|
| 170 |
+
measure under any applicable law fulfilling obligations under article
|
| 171 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 172 |
+
similar laws prohibiting or restricting circumvention of such
|
| 173 |
+
measures.
|
| 174 |
+
|
| 175 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 176 |
+
circumvention of technological measures to the extent such circumvention
|
| 177 |
+
is effected by exercising rights under this License with respect to
|
| 178 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 179 |
+
modification of the work as a means of enforcing, against the work's
|
| 180 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 181 |
+
technological measures.
|
| 182 |
+
|
| 183 |
+
4. Conveying Verbatim Copies.
|
| 184 |
+
|
| 185 |
+
You may convey verbatim copies of the Program's source code as you
|
| 186 |
+
receive it, in any medium, provided that you conspicuously and
|
| 187 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 188 |
+
keep intact all notices stating that this License and any
|
| 189 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 190 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 191 |
+
recipients a copy of this License along with the Program.
|
| 192 |
+
|
| 193 |
+
You may charge any price or no price for each copy that you convey,
|
| 194 |
+
and you may offer support or warranty protection for a fee.
|
| 195 |
+
|
| 196 |
+
5. Conveying Modified Source Versions.
|
| 197 |
+
|
| 198 |
+
You may convey a work based on the Program, or the modifications to
|
| 199 |
+
produce it from the Program, in the form of source code under the
|
| 200 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 201 |
+
|
| 202 |
+
a) The work must carry prominent notices stating that you modified
|
| 203 |
+
it, and giving a relevant date.
|
| 204 |
+
|
| 205 |
+
b) The work must carry prominent notices stating that it is
|
| 206 |
+
released under this License and any conditions added under section
|
| 207 |
+
7. This requirement modifies the requirement in section 4 to
|
| 208 |
+
"keep intact all notices".
|
| 209 |
+
|
| 210 |
+
c) You must license the entire work, as a whole, under this
|
| 211 |
+
License to anyone who comes into possession of a copy. This
|
| 212 |
+
License will therefore apply, along with any applicable section 7
|
| 213 |
+
additional terms, to the whole of the work, and all its parts,
|
| 214 |
+
regardless of how they are packaged. This License gives no
|
| 215 |
+
permission to license the work in any other way, but it does not
|
| 216 |
+
invalidate such permission if you have separately received it.
|
| 217 |
+
|
| 218 |
+
d) If the work has interactive user interfaces, each must display
|
| 219 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 220 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 221 |
+
work need not make them do so.
|
| 222 |
+
|
| 223 |
+
A compilation of a covered work with other separate and independent
|
| 224 |
+
works, which are not by their nature extensions of the covered work,
|
| 225 |
+
and which are not combined with it such as to form a larger program,
|
| 226 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 227 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 228 |
+
used to limit the access or legal rights of the compilation's users
|
| 229 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 230 |
+
in an aggregate does not cause this License to apply to the other
|
| 231 |
+
parts of the aggregate.
|
| 232 |
+
|
| 233 |
+
6. Conveying Non-Source Forms.
|
| 234 |
+
|
| 235 |
+
You may convey a covered work in object code form under the terms
|
| 236 |
+
of sections 4 and 5, provided that you also convey the
|
| 237 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 238 |
+
in one of these ways:
|
| 239 |
+
|
| 240 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 241 |
+
(including a physical distribution medium), accompanied by the
|
| 242 |
+
Corresponding Source fixed on a durable physical medium
|
| 243 |
+
customarily used for software interchange.
|
| 244 |
+
|
| 245 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 246 |
+
(including a physical distribution medium), accompanied by a
|
| 247 |
+
written offer, valid for at least three years and valid for as
|
| 248 |
+
long as you offer spare parts or customer support for that product
|
| 249 |
+
model, to give anyone who possesses the object code either (1) a
|
| 250 |
+
copy of the Corresponding Source for all the software in the
|
| 251 |
+
product that is covered by this License, on a durable physical
|
| 252 |
+
medium customarily used for software interchange, for a price no
|
| 253 |
+
more than your reasonable cost of physically performing this
|
| 254 |
+
conveying of source, or (2) access to copy the
|
| 255 |
+
Corresponding Source from a network server at no charge.
|
| 256 |
+
|
| 257 |
+
c) Convey individual copies of the object code with a copy of the
|
| 258 |
+
written offer to provide the Corresponding Source. This
|
| 259 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 260 |
+
only if you received the object code with such an offer, in accord
|
| 261 |
+
with subsection 6b.
|
| 262 |
+
|
| 263 |
+
d) Convey the object code by offering access from a designated
|
| 264 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 265 |
+
Corresponding Source in the same way through the same place at no
|
| 266 |
+
further charge. You need not require recipients to copy the
|
| 267 |
+
Corresponding Source along with the object code. If the place to
|
| 268 |
+
copy the object code is a network server, the Corresponding Source
|
| 269 |
+
may be on a different server (operated by you or a third party)
|
| 270 |
+
that supports equivalent copying facilities, provided you maintain
|
| 271 |
+
clear directions next to the object code saying where to find the
|
| 272 |
+
Corresponding Source. Regardless of what server hosts the
|
| 273 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 274 |
+
available for as long as needed to satisfy these requirements.
|
| 275 |
+
|
| 276 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 277 |
+
you inform other peers where the object code and Corresponding
|
| 278 |
+
Source of the work are being offered to the general public at no
|
| 279 |
+
charge under subsection 6d.
|
| 280 |
+
|
| 281 |
+
A separable portion of the object code, whose source code is excluded
|
| 282 |
+
from the Corresponding Source as a System Library, need not be
|
| 283 |
+
included in conveying the object code work.
|
| 284 |
+
|
| 285 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 286 |
+
tangible personal property which is normally used for personal, family,
|
| 287 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 288 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 289 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 290 |
+
product received by a particular user, "normally used" refers to a
|
| 291 |
+
typical or common use of that class of product, regardless of the status
|
| 292 |
+
of the particular user or of the way in which the particular user
|
| 293 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 294 |
+
is a consumer product regardless of whether the product has substantial
|
| 295 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 296 |
+
the only significant mode of use of the product.
|
| 297 |
+
|
| 298 |
+
"Installation Information" for a User Product means any methods,
|
| 299 |
+
procedures, authorization keys, or other information required to install
|
| 300 |
+
and execute modified versions of a covered work in that User Product from
|
| 301 |
+
a modified version of its Corresponding Source. The information must
|
| 302 |
+
suffice to ensure that the continued functioning of the modified object
|
| 303 |
+
code is in no case prevented or interfered with solely because
|
| 304 |
+
modification has been made.
|
| 305 |
+
|
| 306 |
+
If you convey an object code work under this section in, or with, or
|
| 307 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 308 |
+
part of a transaction in which the right of possession and use of the
|
| 309 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 310 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 311 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 312 |
+
by the Installation Information. But this requirement does not apply
|
| 313 |
+
if neither you nor any third party retains the ability to install
|
| 314 |
+
modified object code on the User Product (for example, the work has
|
| 315 |
+
been installed in ROM).
|
| 316 |
+
|
| 317 |
+
The requirement to provide Installation Information does not include a
|
| 318 |
+
requirement to continue to provide support service, warranty, or updates
|
| 319 |
+
for a work that has been modified or installed by the recipient, or for
|
| 320 |
+
the User Product in which it has been modified or installed. Access to a
|
| 321 |
+
network may be denied when the modification itself materially and
|
| 322 |
+
adversely affects the operation of the network or violates the rules and
|
| 323 |
+
protocols for communication across the network.
|
| 324 |
+
|
| 325 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 326 |
+
in accord with this section must be in a format that is publicly
|
| 327 |
+
documented (and with an implementation available to the public in
|
| 328 |
+
source code form), and must require no special password or key for
|
| 329 |
+
unpacking, reading or copying.
|
| 330 |
+
|
| 331 |
+
7. Additional Terms.
|
| 332 |
+
|
| 333 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 334 |
+
License by making exceptions from one or more of its conditions.
|
| 335 |
+
Additional permissions that are applicable to the entire Program shall
|
| 336 |
+
be treated as though they were included in this License, to the extent
|
| 337 |
+
that they are valid under applicable law. If additional permissions
|
| 338 |
+
apply only to part of the Program, that part may be used separately
|
| 339 |
+
under those permissions, but the entire Program remains governed by
|
| 340 |
+
this License without regard to the additional permissions.
|
| 341 |
+
|
| 342 |
+
When you convey a copy of a covered work, you may at your option
|
| 343 |
+
remove any additional permissions from that copy, or from any part of
|
| 344 |
+
it. (Additional permissions may be written to require their own
|
| 345 |
+
removal in certain cases when you modify the work.) You may place
|
| 346 |
+
additional permissions on material, added by you to a covered work,
|
| 347 |
+
for which you have or can give appropriate copyright permission.
|
| 348 |
+
|
| 349 |
+
Notwithstanding any other provision of this License, for material you
|
| 350 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 351 |
+
that material) supplement the terms of this License with terms:
|
| 352 |
+
|
| 353 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 354 |
+
terms of sections 15 and 16 of this License; or
|
| 355 |
+
|
| 356 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 357 |
+
author attributions in that material or in the Appropriate Legal
|
| 358 |
+
Notices displayed by works containing it; or
|
| 359 |
+
|
| 360 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 361 |
+
requiring that modified versions of such material be marked in
|
| 362 |
+
reasonable ways as different from the original version; or
|
| 363 |
+
|
| 364 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 365 |
+
authors of the material; or
|
| 366 |
+
|
| 367 |
+
e) Declining to grant rights under trademark law for use of some
|
| 368 |
+
trade names, trademarks, or service marks; or
|
| 369 |
+
|
| 370 |
+
f) Requiring indemnification of licensors and authors of that
|
| 371 |
+
material by anyone who conveys the material (or modified versions of
|
| 372 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 373 |
+
any liability that these contractual assumptions directly impose on
|
| 374 |
+
those licensors and authors.
|
| 375 |
+
|
| 376 |
+
All other non-permissive additional terms are considered "further
|
| 377 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 378 |
+
received it, or any part of it, contains a notice stating that it is
|
| 379 |
+
governed by this License along with a term that is a further
|
| 380 |
+
restriction, you may remove that term. If a license document contains
|
| 381 |
+
a further restriction but permits relicensing or conveying under this
|
| 382 |
+
License, you may add to a covered work material governed by the terms
|
| 383 |
+
of that license document, provided that the further restriction does
|
| 384 |
+
not survive such relicensing or conveying.
|
| 385 |
+
|
| 386 |
+
If you add terms to a covered work in accord with this section, you
|
| 387 |
+
must place, in the relevant source files, a statement of the
|
| 388 |
+
additional terms that apply to those files, or a notice indicating
|
| 389 |
+
where to find the applicable terms.
|
| 390 |
+
|
| 391 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 392 |
+
form of a separately written license, or stated as exceptions;
|
| 393 |
+
the above requirements apply either way.
|
| 394 |
+
|
| 395 |
+
8. Termination.
|
| 396 |
+
|
| 397 |
+
You may not propagate or modify a covered work except as expressly
|
| 398 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 399 |
+
modify it is void, and will automatically terminate your rights under
|
| 400 |
+
this License (including any patent licenses granted under the third
|
| 401 |
+
paragraph of section 11).
|
| 402 |
+
|
| 403 |
+
However, if you cease all violation of this License, then your
|
| 404 |
+
license from a particular copyright holder is reinstated (a)
|
| 405 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 406 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 407 |
+
holder fails to notify you of the violation by some reasonable means
|
| 408 |
+
prior to 60 days after the cessation.
|
| 409 |
+
|
| 410 |
+
Moreover, your license from a particular copyright holder is
|
| 411 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 412 |
+
violation by some reasonable means, this is the first time you have
|
| 413 |
+
received notice of violation of this License (for any work) from that
|
| 414 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 415 |
+
your receipt of the notice.
|
| 416 |
+
|
| 417 |
+
Termination of your rights under this section does not terminate the
|
| 418 |
+
licenses of parties who have received copies or rights from you under
|
| 419 |
+
this License. If your rights have been terminated and not permanently
|
| 420 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 421 |
+
material under section 10.
|
| 422 |
+
|
| 423 |
+
9. Acceptance Not Required for Having Copies.
|
| 424 |
+
|
| 425 |
+
You are not required to accept this License in order to receive or
|
| 426 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 427 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 428 |
+
to receive a copy likewise does not require acceptance. However,
|
| 429 |
+
nothing other than this License grants you permission to propagate or
|
| 430 |
+
modify any covered work. These actions infringe copyright if you do
|
| 431 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 432 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 433 |
+
|
| 434 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 435 |
+
|
| 436 |
+
Each time you convey a covered work, the recipient automatically
|
| 437 |
+
receives a license from the original licensors, to run, modify and
|
| 438 |
+
propagate that work, subject to this License. You are not responsible
|
| 439 |
+
for enforcing compliance by third parties with this License.
|
| 440 |
+
|
| 441 |
+
An "entity transaction" is a transaction transferring control of an
|
| 442 |
+
organization, or substantially all assets of one, or subdividing an
|
| 443 |
+
organization, or merging organizations. If propagation of a covered
|
| 444 |
+
work results from an entity transaction, each party to that
|
| 445 |
+
transaction who receives a copy of the work also receives whatever
|
| 446 |
+
licenses to the work the party's predecessor in interest had or could
|
| 447 |
+
give under the previous paragraph, plus a right to possession of the
|
| 448 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 449 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 450 |
+
|
| 451 |
+
You may not impose any further restrictions on the exercise of the
|
| 452 |
+
rights granted or affirmed under this License. For example, you may
|
| 453 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 454 |
+
rights granted under this License, and you may not initiate litigation
|
| 455 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 456 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 457 |
+
sale, or importing the Program or any portion of it.
|
| 458 |
+
|
| 459 |
+
11. Patents.
|
| 460 |
+
|
| 461 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 462 |
+
License of the Program or a work on which the Program is based. The
|
| 463 |
+
work thus licensed is called the contributor's "contributor version".
|
| 464 |
+
|
| 465 |
+
A contributor's "essential patent claims" are all patent claims
|
| 466 |
+
owned or controlled by the contributor, whether already acquired or
|
| 467 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 468 |
+
by this License, of making, using, or selling its contributor version,
|
| 469 |
+
but do not include claims that would be infringed only as a
|
| 470 |
+
consequence of further modification of the contributor version. For
|
| 471 |
+
purposes of this definition, "control" includes the right to grant
|
| 472 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 473 |
+
this License.
|
| 474 |
+
|
| 475 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 476 |
+
patent license under the contributor's essential patent claims, to
|
| 477 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 478 |
+
propagate the contents of its contributor version.
|
| 479 |
+
|
| 480 |
+
In the following three paragraphs, a "patent license" is any express
|
| 481 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 482 |
+
(such as an express permission to practice a patent or covenant not to
|
| 483 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 484 |
+
party means to make such an agreement or commitment not to enforce a
|
| 485 |
+
patent against the party.
|
| 486 |
+
|
| 487 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 488 |
+
and the Corresponding Source of the work is not available for anyone
|
| 489 |
+
to copy, free of charge and under the terms of this License, through a
|
| 490 |
+
publicly available network server or other readily accessible means,
|
| 491 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 492 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 493 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 494 |
+
consistent with the requirements of this License, to extend the patent
|
| 495 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 496 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 497 |
+
covered work in a country, or your recipient's use of the covered work
|
| 498 |
+
in a country, would infringe one or more identifiable patents in that
|
| 499 |
+
country that you have reason to believe are valid.
|
| 500 |
+
|
| 501 |
+
If, pursuant to or in connection with a single transaction or
|
| 502 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 503 |
+
covered work, and grant a patent license to some of the parties
|
| 504 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 505 |
+
or convey a specific copy of the covered work, then the patent license
|
| 506 |
+
you grant is automatically extended to all recipients of the covered
|
| 507 |
+
work and works based on it.
|
| 508 |
+
|
| 509 |
+
A patent license is "discriminatory" if it does not include within
|
| 510 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 511 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 512 |
+
specifically granted under this License. You may not convey a covered
|
| 513 |
+
work if you are a party to an arrangement with a third party that is
|
| 514 |
+
in the business of distributing software, under which you make payment
|
| 515 |
+
to the third party based on the extent of your activity of conveying
|
| 516 |
+
the work, and under which the third party grants, to any of the
|
| 517 |
+
parties who would receive the covered work from you, a discriminatory
|
| 518 |
+
patent license (a) in connection with copies of the covered work
|
| 519 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 520 |
+
for and in connection with specific products or compilations that
|
| 521 |
+
contain the covered work, unless you entered into that arrangement,
|
| 522 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 523 |
+
|
| 524 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 525 |
+
any implied license or other defenses to infringement that may
|
| 526 |
+
otherwise be available to you under applicable patent law.
|
| 527 |
+
|
| 528 |
+
12. No Surrender of Others' Freedom.
|
| 529 |
+
|
| 530 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 531 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 532 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 533 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 534 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 535 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 536 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 537 |
+
the Program, the only way you could satisfy both those terms and this
|
| 538 |
+
License would be to refrain entirely from conveying the Program.
|
| 539 |
+
|
| 540 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
| 541 |
+
|
| 542 |
+
Notwithstanding any other provision of this License, if you modify the
|
| 543 |
+
Program, your modified version must prominently offer all users
|
| 544 |
+
interacting with it remotely through a computer network (if your version
|
| 545 |
+
supports such interaction) an opportunity to receive the Corresponding
|
| 546 |
+
Source of your version by providing access to the Corresponding Source
|
| 547 |
+
from a network server at no charge, through some standard or customary
|
| 548 |
+
means of facilitating copying of software. This Corresponding Source
|
| 549 |
+
shall include the Corresponding Source for any work covered by version 3
|
| 550 |
+
of the GNU General Public License that is incorporated pursuant to the
|
| 551 |
+
following paragraph.
|
| 552 |
+
|
| 553 |
+
Notwithstanding any other provision of this License, you have
|
| 554 |
+
permission to link or combine any covered work with a work licensed
|
| 555 |
+
under version 3 of the GNU General Public License into a single
|
| 556 |
+
combined work, and to convey the resulting work. The terms of this
|
| 557 |
+
License will continue to apply to the part which is the covered work,
|
| 558 |
+
but the work with which it is combined will remain governed by version
|
| 559 |
+
3 of the GNU General Public License.
|
| 560 |
+
|
| 561 |
+
14. Revised Versions of this License.
|
| 562 |
+
|
| 563 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 564 |
+
the GNU Affero General Public License from time to time. Such new versions
|
| 565 |
+
will be similar in spirit to the present version, but may differ in detail to
|
| 566 |
+
address new problems or concerns.
|
| 567 |
+
|
| 568 |
+
Each version is given a distinguishing version number. If the
|
| 569 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
| 570 |
+
Public License "or any later version" applies to it, you have the
|
| 571 |
+
option of following the terms and conditions either of that numbered
|
| 572 |
+
version or of any later version published by the Free Software
|
| 573 |
+
Foundation. If the Program does not specify a version number of the
|
| 574 |
+
GNU Affero General Public License, you may choose any version ever published
|
| 575 |
+
by the Free Software Foundation.
|
| 576 |
+
|
| 577 |
+
If the Program specifies that a proxy can decide which future
|
| 578 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
| 579 |
+
public statement of acceptance of a version permanently authorizes you
|
| 580 |
+
to choose that version for the Program.
|
| 581 |
+
|
| 582 |
+
Later license versions may give you additional or different
|
| 583 |
+
permissions. However, no additional obligations are imposed on any
|
| 584 |
+
author or copyright holder as a result of your choosing to follow a
|
| 585 |
+
later version.
|
| 586 |
+
|
| 587 |
+
15. Disclaimer of Warranty.
|
| 588 |
+
|
| 589 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 590 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 591 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 592 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 593 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 594 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 595 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 596 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 597 |
+
|
| 598 |
+
16. Limitation of Liability.
|
| 599 |
+
|
| 600 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 601 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 602 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 603 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 604 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 605 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 606 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 607 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 608 |
+
SUCH DAMAGES.
|
| 609 |
+
|
| 610 |
+
17. Interpretation of Sections 15 and 16.
|
| 611 |
+
|
| 612 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 613 |
+
above cannot be given local legal effect according to their terms,
|
| 614 |
+
reviewing courts shall apply local law that most closely approximates
|
| 615 |
+
an absolute waiver of all civil liability in connection with the
|
| 616 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 617 |
+
copy of the Program in return for a fee.
|
| 618 |
+
|
| 619 |
+
END OF TERMS AND CONDITIONS
|
| 620 |
+
|
| 621 |
+
How to Apply These Terms to Your New Programs
|
| 622 |
+
|
| 623 |
+
If you develop a new program, and you want it to be of the greatest
|
| 624 |
+
possible use to the public, the best way to achieve this is to make it
|
| 625 |
+
free software which everyone can redistribute and change under these terms.
|
| 626 |
+
|
| 627 |
+
To do so, attach the following notices to the program. It is safest
|
| 628 |
+
to attach them to the start of each source file to most effectively
|
| 629 |
+
state the exclusion of warranty; and each file should have at least
|
| 630 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 631 |
+
|
| 632 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 633 |
+
Copyright (C) <year> <name of author>
|
| 634 |
+
|
| 635 |
+
This program is free software: you can redistribute it and/or modify
|
| 636 |
+
it under the terms of the GNU Affero General Public License as published
|
| 637 |
+
by the Free Software Foundation, either version 3 of the License, or
|
| 638 |
+
(at your option) any later version.
|
| 639 |
+
|
| 640 |
+
This program is distributed in the hope that it will be useful,
|
| 641 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 642 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 643 |
+
GNU Affero General Public License for more details.
|
| 644 |
+
|
| 645 |
+
You should have received a copy of the GNU Affero General Public License
|
| 646 |
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 647 |
+
|
| 648 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 649 |
+
|
| 650 |
+
If your software can interact with users remotely through a computer
|
| 651 |
+
network, you should also make sure that it provides a way for users to
|
| 652 |
+
get its source. For example, if your program is a web application, its
|
| 653 |
+
interface could display a "Source" link that leads users to an archive
|
| 654 |
+
of the code. There are many ways you could offer source, and different
|
| 655 |
+
solutions will be better for different programs; see section 13 for the
|
| 656 |
+
specific requirements.
|
| 657 |
+
|
| 658 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 659 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 660 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
| 661 |
+
<https://www.gnu.org/licenses/>.
|
README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AI Studio Proxy API
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
license: agpl-3.0
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# AI Studio Proxy API
|
| 9 |
+
|
| 10 |
+
将 Google AI Studio 网页界面转换为 OpenAI 兼容 API 的代理服务。通过 Camoufox + Playwright 自动化,提供稳定可控的 API 访问。
|
| 11 |
+
|
| 12 |
+
[](https://www.star-history.com/#CJackHwang/AIstudioProxyAPI&Date)
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 主要特性
|
| 17 |
+
|
| 18 |
+
- **OpenAI 兼容 API**:支持 `/v1/chat/completions`、`/v1/models`
|
| 19 |
+
- **函数调用三模式**:`auto` / `native` / `emulated`,支持失败回退
|
| 20 |
+
- **认证轮转与 Cookie 刷新**:支持 profile 自动轮转、周期刷新与关停保存
|
| 21 |
+
- **启动链路完整**:CLI 启动器、内置 Web UI、桌面 GUI 启动器
|
| 22 |
+
- **现代化前端**:内置设置页、状态检查与日志能力
|
| 23 |
+
- **CI/CD 工作流**:PR 检查、Release、Upstream Sync
|
| 24 |
+
|
| 25 |
+
## 系统要求
|
| 26 |
+
|
| 27 |
+
| 组件 | 要求 | 推荐 |
|
| 28 |
+
| --- | --- | --- |
|
| 29 |
+
| Python | >=3.9, <4.0 | 3.10+ / 3.11+ |
|
| 30 |
+
| 依赖管理 | Poetry | 最新版本 |
|
| 31 |
+
| Node.js | 前端构建需要 | LTS |
|
| 32 |
+
| 内存 | >=2GB | >=4GB |
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 🚀 快速开始
|
| 37 |
+
|
| 38 |
+
### 1. 克隆并安装
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
git clone https://github.com/CJackHwang/AIstudioProxyAPI.git
|
| 42 |
+
cd AIstudioProxyAPI
|
| 43 |
+
poetry install --with dev
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### 2. 配置环境
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
cp .env.example .env
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
建议先确认:`PORT`、`STREAM_PORT`、`UNIFIED_PROXY_CONFIG`、`LAUNCH_MODE`、`FUNCTION_CALLING_MODE`。
|
| 53 |
+
|
| 54 |
+
### 3. 首次认证并启动
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
# 首次建议 debug,完成登录并保存 auth
|
| 58 |
+
poetry run python launch_camoufox.py --debug
|
| 59 |
+
|
| 60 |
+
# 日常建议 headless
|
| 61 |
+
poetry run python launch_camoufox.py --headless
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### 快速测试
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
# 健康检查
|
| 68 |
+
curl http://127.0.0.1:2048/health
|
| 69 |
+
|
| 70 |
+
# 模型列表
|
| 71 |
+
curl http://127.0.0.1:2048/v1/models
|
| 72 |
+
|
| 73 |
+
# 聊天请求
|
| 74 |
+
curl -X POST http://127.0.0.1:2048/v1/chat/completions \
|
| 75 |
+
-H "Content-Type: application/json" \
|
| 76 |
+
-d '{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"你好"}]}'
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
访问 `http://127.0.0.1:2048/` 使用内置 Web UI。
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## 系统架构
|
| 84 |
+
|
| 85 |
+
```mermaid
|
| 86 |
+
graph TD
|
| 87 |
+
subgraph "用户端"
|
| 88 |
+
User["用户"]
|
| 89 |
+
WebUI["Web UI"]
|
| 90 |
+
APIClient["API 客户端"]
|
| 91 |
+
end
|
| 92 |
+
|
| 93 |
+
subgraph "启动与配置"
|
| 94 |
+
Launcher["launch_camoufox.py"]
|
| 95 |
+
Env[".env 配置"]
|
| 96 |
+
end
|
| 97 |
+
|
| 98 |
+
subgraph "核心服务"
|
| 99 |
+
FastAPI["FastAPI 应用<br/>api_utils/"]
|
| 100 |
+
BrowserOps["页面控制与自动化<br/>browser_utils/"]
|
| 101 |
+
StreamProxy["流式代理<br/>stream/"]
|
| 102 |
+
end
|
| 103 |
+
|
| 104 |
+
subgraph "外部依赖"
|
| 105 |
+
Camoufox["Camoufox 浏览器"]
|
| 106 |
+
AIStudio["Google AI Studio"]
|
| 107 |
+
end
|
| 108 |
+
|
| 109 |
+
User --> Launcher
|
| 110 |
+
Launcher --> Env
|
| 111 |
+
WebUI --> FastAPI
|
| 112 |
+
APIClient --> FastAPI
|
| 113 |
+
FastAPI --> BrowserOps
|
| 114 |
+
FastAPI --> StreamProxy
|
| 115 |
+
BrowserOps --> Camoufox --> AIStudio
|
| 116 |
+
StreamProxy --> AIStudio
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## 运行模式
|
| 122 |
+
|
| 123 |
+
| 命令 | 说明 | 场景 |
|
| 124 |
+
| --- | --- | --- |
|
| 125 |
+
| `python launch_camoufox.py --headless` | 无头模式 | 日常使用、服务器 |
|
| 126 |
+
| `python launch_camoufox.py --debug` | 调试模式 | 首次认证、故障排查 |
|
| 127 |
+
| `python launch_camoufox.py --virtual-display` | 虚拟显示 | Linux 无 GUI 环境 |
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## ⚙️ 配置
|
| 132 |
+
|
| 133 |
+
项目使用 `.env` 统一配置管理:
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
cp .env.example .env
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
核心配置示例:
|
| 140 |
+
|
| 141 |
+
| 配置 | 默认值 | 说明 |
|
| 142 |
+
| --- | --- | --- |
|
| 143 |
+
| `PORT` | `2048` | 主 API 端口 |
|
| 144 |
+
| `STREAM_PORT` | `3120` | 流式代理端口(`0` 关闭) |
|
| 145 |
+
| `UNIFIED_PROXY_CONFIG` | 空 | HTTP/HTTPS 代理 |
|
| 146 |
+
| `AUTO_ROTATE_AUTH_PROFILE` | `true` | 认证自动轮转 |
|
| 147 |
+
| `FUNCTION_CALLING_MODE` | `auto` | 函数调用模式 |
|
| 148 |
+
|
| 149 |
+
详细项见:[配置参考](docs/configuration-reference.md)
|
| 150 |
+
|
| 151 |
+
> 说明:配置默认值以 `.env.example` 为准;少数配置存在代码兜底默认值,详见配置参考中的说明。
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## 📚 文档
|
| 156 |
+
|
| 157 |
+
- [文档总览](docs/README.md)
|
| 158 |
+
- [快速开始](docs/quick-start.md)
|
| 159 |
+
- [部署与运维指南](docs/deployment-and-operations.md)
|
| 160 |
+
- [API 使用说明](docs/api-usage.md)
|
| 161 |
+
- [函数调用模式](docs/function-calling.md)
|
| 162 |
+
- [认证轮转与 Cookie 刷新](docs/auth-rotation-cookie-refresh.md)
|
| 163 |
+
- [排障指南](docs/troubleshooting.md)
|
| 164 |
+
- [开发、测试与发布](docs/development-and-release.md)
|
| 165 |
+
- [多实例 Docker 管理器](scripts/multi-instance-manager/README.md)
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
## 客户端配置示例
|
| 170 |
+
|
| 171 |
+
以 Open WebUI 为例:
|
| 172 |
+
|
| 173 |
+
1. 进入设置 -> 连接
|
| 174 |
+
2. API Base URL 填 `http://127.0.0.1:2048/v1`
|
| 175 |
+
3. 若你未配置 API Keys,可留空或填任意字符;若已配置,请填写有效 Key
|
| 176 |
+
4. 保存后即可对话
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## 开发检查
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
poetry run ruff check .
|
| 184 |
+
poetry run pyright
|
| 185 |
+
poetry run pytest
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
前端构建:
|
| 189 |
+
|
| 190 |
+
```bash
|
| 191 |
+
cd static/frontend
|
| 192 |
+
npm ci
|
| 193 |
+
npm run build
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
## 致谢
|
| 199 |
+
|
| 200 |
+
- **项目发起与主要开发**: [@CJackHwang](https://github.com/CJackHwang)
|
| 201 |
+
- **核心维护**(架构重构、测试体系): [@NikkeTryHard](https://github.com/NikkeTryHard)
|
| 202 |
+
- **功能完善、页面操作优化**: [@ayuayue](https://github.com/ayuayue)
|
| 203 |
+
- **实时流式功能优化**: [@luispater](https://github.com/luispater)
|
| 204 |
+
- **项目重构贡献**: [@yattin](https://github.com/yattin)(Holt)
|
| 205 |
+
- **下游维护分支致谢作者**: [@MasuRii](https://github.com/MasuRii)
|
| 206 |
+
- **社区支持**: [Linux.do 社区](https://linux.do/)
|
| 207 |
+
|
| 208 |
+
## License
|
| 209 |
+
|
| 210 |
+
[AGPLv3](LICENSE)
|
| 211 |
+
|
| 212 |
+
## 支持作者
|
| 213 |
+
|
| 214 |
+
如果本项目对你有帮助,欢迎支持作者持续开发。
|
api_utils/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Utilities Module
|
| 3 |
+
Provides FastAPI application initialization, route handlers, and utility functions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# Application initialization
|
| 7 |
+
from .app import create_app
|
| 8 |
+
|
| 9 |
+
# Queue worker
|
| 10 |
+
from .queue_worker import queue_worker
|
| 11 |
+
|
| 12 |
+
# Request processor
|
| 13 |
+
from .request_processor import (
|
| 14 |
+
_process_request_refactored, # pyright: ignore[reportPrivateUsage]
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# Route handlers (aggregated from routers)
|
| 18 |
+
from .routers import (
|
| 19 |
+
cancel_request,
|
| 20 |
+
chat_completions,
|
| 21 |
+
get_api_info,
|
| 22 |
+
get_queue_status,
|
| 23 |
+
health_check,
|
| 24 |
+
list_models,
|
| 25 |
+
read_index,
|
| 26 |
+
websocket_log_endpoint,
|
| 27 |
+
)
|
| 28 |
+
from .sse import (
|
| 29 |
+
generate_sse_chunk,
|
| 30 |
+
generate_sse_error_chunk,
|
| 31 |
+
generate_sse_stop_chunk,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Utility functions
|
| 35 |
+
from .utils import prepare_combined_prompt
|
| 36 |
+
from .utils_ext.helper import use_helper_get_response
|
| 37 |
+
from .utils_ext.stream import (
|
| 38 |
+
clear_stream_queue,
|
| 39 |
+
use_stream_response,
|
| 40 |
+
)
|
| 41 |
+
from .utils_ext.tokens import (
|
| 42 |
+
calculate_usage_stats,
|
| 43 |
+
estimate_tokens,
|
| 44 |
+
)
|
| 45 |
+
from .utils_ext.validation import validate_chat_request
|
| 46 |
+
|
| 47 |
+
__all__ = [
|
| 48 |
+
# Application initialization
|
| 49 |
+
"create_app",
|
| 50 |
+
# Route handlers
|
| 51 |
+
"read_index",
|
| 52 |
+
"get_api_info",
|
| 53 |
+
"health_check",
|
| 54 |
+
"list_models",
|
| 55 |
+
"chat_completions",
|
| 56 |
+
"cancel_request",
|
| 57 |
+
"get_queue_status",
|
| 58 |
+
"websocket_log_endpoint",
|
| 59 |
+
# Utility functions
|
| 60 |
+
"generate_sse_chunk",
|
| 61 |
+
"generate_sse_stop_chunk",
|
| 62 |
+
"generate_sse_error_chunk",
|
| 63 |
+
"use_stream_response",
|
| 64 |
+
"clear_stream_queue",
|
| 65 |
+
"use_helper_get_response",
|
| 66 |
+
"validate_chat_request",
|
| 67 |
+
"prepare_combined_prompt",
|
| 68 |
+
"estimate_tokens",
|
| 69 |
+
"calculate_usage_stats",
|
| 70 |
+
# Request processor
|
| 71 |
+
"_process_request_refactored",
|
| 72 |
+
# Queue worker
|
| 73 |
+
"queue_worker",
|
| 74 |
+
]
|
api_utils/app.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application initialization and lifecycle management
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import multiprocessing
|
| 7 |
+
import queue
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
from asyncio import Lock, Queue
|
| 11 |
+
from contextlib import asynccontextmanager
|
| 12 |
+
from typing import Awaitable, Callable
|
| 13 |
+
|
| 14 |
+
from fastapi import FastAPI, Request
|
| 15 |
+
from fastapi.responses import JSONResponse
|
| 16 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 17 |
+
from starlette.types import ASGIApp
|
| 18 |
+
|
| 19 |
+
import stream
|
| 20 |
+
from api_utils.server_state import state
|
| 21 |
+
|
| 22 |
+
# --- browser_utils module imports ---
|
| 23 |
+
from browser_utils import (
|
| 24 |
+
_close_page_logic,
|
| 25 |
+
_handle_initial_model_state_and_storage,
|
| 26 |
+
_initialize_page_logic,
|
| 27 |
+
enable_temporary_chat_mode,
|
| 28 |
+
load_excluded_models,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# --- Configuration imports ---
|
| 32 |
+
from config import EXCLUDED_MODELS_FILENAME, NO_PROXY_ENV, get_environment_variable
|
| 33 |
+
|
| 34 |
+
# --- logging_utils module imports ---
|
| 35 |
+
from logging_utils import restore_original_streams, setup_server_logging
|
| 36 |
+
|
| 37 |
+
# --- models module imports ---
|
| 38 |
+
from models import WebSocketConnectionManager
|
| 39 |
+
|
| 40 |
+
from . import auth_utils
|
| 41 |
+
|
| 42 |
+
VERSION = "0.1.0"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# --- Lifespan Context Manager ---
|
| 46 |
+
def _setup_logging():
|
| 47 |
+
log_level_env = get_environment_variable("SERVER_LOG_LEVEL", "INFO")
|
| 48 |
+
redirect_print_env = get_environment_variable("SERVER_REDIRECT_PRINT", "false")
|
| 49 |
+
state.log_ws_manager = WebSocketConnectionManager()
|
| 50 |
+
return setup_server_logging(
|
| 51 |
+
logger_instance=state.logger,
|
| 52 |
+
log_ws_manager=state.log_ws_manager,
|
| 53 |
+
log_level_name=log_level_env,
|
| 54 |
+
redirect_print_str=redirect_print_env,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _initialize_globals():
|
| 59 |
+
from api_utils.server_state import state
|
| 60 |
+
|
| 61 |
+
state.request_queue = Queue()
|
| 62 |
+
state.processing_lock = Lock()
|
| 63 |
+
state.model_switching_lock = Lock()
|
| 64 |
+
state.params_cache_lock = Lock()
|
| 65 |
+
|
| 66 |
+
# Initialize model_list_fetch_event
|
| 67 |
+
state.model_list_fetch_event = asyncio.Event()
|
| 68 |
+
|
| 69 |
+
auth_utils.initialize_keys()
|
| 70 |
+
|
| 71 |
+
# Initialize Auth Rotation Lock
|
| 72 |
+
from config.global_state import GlobalState
|
| 73 |
+
|
| 74 |
+
GlobalState.init_rotation_lock()
|
| 75 |
+
|
| 76 |
+
state.logger.info("API keys and global locks initialized.")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _initialize_proxy_settings():
|
| 80 |
+
stream_port_env = get_environment_variable("STREAM_PORT")
|
| 81 |
+
if stream_port_env == "0":
|
| 82 |
+
proxy_server_url = get_environment_variable(
|
| 83 |
+
"HTTPS_PROXY"
|
| 84 |
+
) or get_environment_variable("HTTP_PROXY")
|
| 85 |
+
else:
|
| 86 |
+
proxy_server_url = f"http://127.0.0.1:{stream_port_env or 3120}/"
|
| 87 |
+
|
| 88 |
+
if proxy_server_url:
|
| 89 |
+
state.PLAYWRIGHT_PROXY_SETTINGS = {"server": proxy_server_url}
|
| 90 |
+
if NO_PROXY_ENV:
|
| 91 |
+
state.PLAYWRIGHT_PROXY_SETTINGS["bypass"] = NO_PROXY_ENV.replace(",", ";")
|
| 92 |
+
state.logger.info(
|
| 93 |
+
f"Playwright proxy settings configured: {state.PLAYWRIGHT_PROXY_SETTINGS}"
|
| 94 |
+
)
|
| 95 |
+
else:
|
| 96 |
+
state.logger.info("No proxy configured for Playwright.")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def _start_stream_proxy():
|
| 100 |
+
stream_port_env = get_environment_variable("STREAM_PORT")
|
| 101 |
+
if stream_port_env != "0":
|
| 102 |
+
port = int(stream_port_env or 3120)
|
| 103 |
+
stream_proxy_server_env = (
|
| 104 |
+
get_environment_variable("UNIFIED_PROXY_CONFIG")
|
| 105 |
+
or get_environment_variable("HTTPS_PROXY")
|
| 106 |
+
or get_environment_variable("HTTP_PROXY")
|
| 107 |
+
)
|
| 108 |
+
state.logger.info(
|
| 109 |
+
f"Starting STREAM proxy on port {port} with upstream proxy: {stream_proxy_server_env}"
|
| 110 |
+
)
|
| 111 |
+
state.STREAM_QUEUE = multiprocessing.Queue()
|
| 112 |
+
state.STREAM_PROCESS = multiprocessing.Process(
|
| 113 |
+
target=stream.start,
|
| 114 |
+
args=(state.STREAM_QUEUE, port, stream_proxy_server_env),
|
| 115 |
+
)
|
| 116 |
+
state.STREAM_PROCESS.start()
|
| 117 |
+
state.logger.info("STREAM proxy process started. Waiting for 'READY' signal...")
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
ready_signal = await asyncio.to_thread(state.STREAM_QUEUE.get, timeout=15)
|
| 121 |
+
if ready_signal == "READY":
|
| 122 |
+
state.logger.info(
|
| 123 |
+
"[SUCCESS] Received 'READY' signal from STREAM proxy."
|
| 124 |
+
)
|
| 125 |
+
else:
|
| 126 |
+
state.logger.warning(
|
| 127 |
+
f"Received unexpected signal from proxy: {ready_signal}"
|
| 128 |
+
)
|
| 129 |
+
except queue.Empty:
|
| 130 |
+
state.logger.error(
|
| 131 |
+
"[ERROR] Timed out waiting for STREAM proxy to become ready. Startup will likely fail."
|
| 132 |
+
)
|
| 133 |
+
raise RuntimeError("STREAM proxy failed to start in time.")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def _initialize_browser_and_page():
|
| 137 |
+
from playwright.async_api import async_playwright
|
| 138 |
+
|
| 139 |
+
state.logger.info("Starting Playwright...")
|
| 140 |
+
state.playwright_manager = await async_playwright().start()
|
| 141 |
+
state.is_playwright_ready = True
|
| 142 |
+
state.logger.info("Playwright started.")
|
| 143 |
+
|
| 144 |
+
ws_endpoint = get_environment_variable("CAMOUFOX_WS_ENDPOINT")
|
| 145 |
+
launch_mode = get_environment_variable("LAUNCH_MODE", "unknown")
|
| 146 |
+
|
| 147 |
+
if not ws_endpoint and launch_mode != "direct_debug_no_browser":
|
| 148 |
+
raise ValueError("CAMOUFOX_WS_ENDPOINT environment variable is missing.")
|
| 149 |
+
|
| 150 |
+
if ws_endpoint:
|
| 151 |
+
state.logger.info(f"Connecting to browser at: {ws_endpoint}")
|
| 152 |
+
state.browser_instance = await state.playwright_manager.firefox.connect(
|
| 153 |
+
ws_endpoint, timeout=30000
|
| 154 |
+
)
|
| 155 |
+
state.is_browser_connected = True
|
| 156 |
+
state.logger.info(f"Connected to browser: {state.browser_instance.version}")
|
| 157 |
+
|
| 158 |
+
state.page_instance, state.is_page_ready = await _initialize_page_logic(
|
| 159 |
+
state.browser_instance
|
| 160 |
+
)
|
| 161 |
+
if state.is_page_ready:
|
| 162 |
+
await _handle_initial_model_state_and_storage(state.page_instance)
|
| 163 |
+
await enable_temporary_chat_mode(state.page_instance)
|
| 164 |
+
state.logger.info("Page initialized successfully.")
|
| 165 |
+
else:
|
| 166 |
+
state.logger.error("Page initialization failed.")
|
| 167 |
+
state.page_instance = None
|
| 168 |
+
state.is_page_ready = False
|
| 169 |
+
state.current_ai_studio_model_id = None
|
| 170 |
+
|
| 171 |
+
if not state.model_list_fetch_event.is_set():
|
| 172 |
+
state.model_list_fetch_event.set()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
async def _shutdown_resources():
|
| 176 |
+
logger = state.logger
|
| 177 |
+
logger.info("Shutting down resources...")
|
| 178 |
+
|
| 179 |
+
# Signal global shutdown if event exists
|
| 180 |
+
try:
|
| 181 |
+
from config import GlobalState
|
| 182 |
+
|
| 183 |
+
if hasattr(GlobalState, "IS_SHUTTING_DOWN") and hasattr(
|
| 184 |
+
GlobalState.IS_SHUTTING_DOWN, "set"
|
| 185 |
+
):
|
| 186 |
+
GlobalState.IS_SHUTTING_DOWN.set()
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.debug(f"Failed to set IS_SHUTTING_DOWN: {e}")
|
| 189 |
+
|
| 190 |
+
state.should_exit = True
|
| 191 |
+
|
| 192 |
+
if state.STREAM_PROCESS:
|
| 193 |
+
try:
|
| 194 |
+
state.STREAM_PROCESS.terminate()
|
| 195 |
+
state.STREAM_PROCESS.join(timeout=3)
|
| 196 |
+
if state.STREAM_PROCESS.is_alive():
|
| 197 |
+
logger.warning("STREAM proxy did not terminate, killing...")
|
| 198 |
+
state.STREAM_PROCESS.kill()
|
| 199 |
+
state.STREAM_PROCESS.join(timeout=1)
|
| 200 |
+
except Exception as e:
|
| 201 |
+
logger.error(f"Error terminating STREAM proxy: {e}")
|
| 202 |
+
finally:
|
| 203 |
+
if state.STREAM_QUEUE:
|
| 204 |
+
try:
|
| 205 |
+
state.STREAM_QUEUE.close()
|
| 206 |
+
state.STREAM_QUEUE.join_thread()
|
| 207 |
+
except Exception:
|
| 208 |
+
pass
|
| 209 |
+
state.STREAM_PROCESS = None
|
| 210 |
+
state.STREAM_QUEUE = None
|
| 211 |
+
logger.info("STREAM proxy terminated.")
|
| 212 |
+
|
| 213 |
+
if state.worker_task and not state.worker_task.done():
|
| 214 |
+
logger.info("Cancelling worker task...")
|
| 215 |
+
state.worker_task.cancel()
|
| 216 |
+
try:
|
| 217 |
+
await asyncio.wait_for(state.worker_task, timeout=2.0)
|
| 218 |
+
logger.info("Worker task cancelled.")
|
| 219 |
+
except asyncio.TimeoutError:
|
| 220 |
+
logger.warning("Worker task did not respond to cancellation within 2s.")
|
| 221 |
+
except asyncio.CancelledError:
|
| 222 |
+
logger.debug("Worker task cancellation acknowledged (CancelledError).")
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error(f"Error cancelling worker task: {e}")
|
| 225 |
+
finally:
|
| 226 |
+
state.worker_task = None
|
| 227 |
+
|
| 228 |
+
if state.page_instance:
|
| 229 |
+
try:
|
| 230 |
+
await _close_page_logic()
|
| 231 |
+
except asyncio.CancelledError:
|
| 232 |
+
logger.debug("Page closure cancelled (CancelledError).")
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error(f"Error during page closure: {e}")
|
| 235 |
+
finally:
|
| 236 |
+
state.page_instance = None
|
| 237 |
+
state.is_page_ready = False
|
| 238 |
+
|
| 239 |
+
if state.browser_instance:
|
| 240 |
+
try:
|
| 241 |
+
if state.browser_instance.is_connected():
|
| 242 |
+
await state.browser_instance.close()
|
| 243 |
+
logger.info("Browser connection closed.")
|
| 244 |
+
except asyncio.CancelledError:
|
| 245 |
+
logger.debug("Browser closure cancelled (CancelledError).")
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.error(f"Error during browser closure: {e}")
|
| 248 |
+
finally:
|
| 249 |
+
state.browser_instance = None
|
| 250 |
+
state.is_browser_connected = False
|
| 251 |
+
|
| 252 |
+
if state.playwright_manager:
|
| 253 |
+
try:
|
| 254 |
+
await state.playwright_manager.stop()
|
| 255 |
+
logger.info("Playwright stopped.")
|
| 256 |
+
except asyncio.CancelledError:
|
| 257 |
+
logger.debug("Playwright stop cancelled (CancelledError).")
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"Error stopping playwright: {e}")
|
| 260 |
+
finally:
|
| 261 |
+
state.playwright_manager = None
|
| 262 |
+
state.is_playwright_ready = False
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@asynccontextmanager
|
| 266 |
+
async def lifespan(app: FastAPI):
|
| 267 |
+
"""FastAPI application lifecycle management"""
|
| 268 |
+
from .queue_worker import queue_worker
|
| 269 |
+
|
| 270 |
+
original_streams = sys.stdout, sys.stderr
|
| 271 |
+
initial_stdout, initial_stderr = _setup_logging()
|
| 272 |
+
logger = state.logger
|
| 273 |
+
|
| 274 |
+
_initialize_globals()
|
| 275 |
+
_initialize_proxy_settings()
|
| 276 |
+
load_excluded_models(EXCLUDED_MODELS_FILENAME)
|
| 277 |
+
|
| 278 |
+
state.is_initializing = True
|
| 279 |
+
startup_start_time = time.time()
|
| 280 |
+
logger.info("Starting AI Studio Proxy Server...")
|
| 281 |
+
|
| 282 |
+
try:
|
| 283 |
+
await _start_stream_proxy()
|
| 284 |
+
await _initialize_browser_and_page()
|
| 285 |
+
|
| 286 |
+
launch_mode = get_environment_variable("LAUNCH_MODE", "unknown")
|
| 287 |
+
if state.is_page_ready or launch_mode == "direct_debug_no_browser":
|
| 288 |
+
state.worker_task = asyncio.create_task(queue_worker())
|
| 289 |
+
logger.info("Request processing worker started.")
|
| 290 |
+
else:
|
| 291 |
+
raise RuntimeError("Failed to initialize browser/page, worker not started.")
|
| 292 |
+
|
| 293 |
+
logger.info("[WATCHDOG] Starting Quota Watchdog Task...")
|
| 294 |
+
watchdog_func = state.quota_watchdog
|
| 295 |
+
if watchdog_func:
|
| 296 |
+
app.state.watchdog_task = asyncio.create_task(watchdog_func())
|
| 297 |
+
else:
|
| 298 |
+
logger.warning(
|
| 299 |
+
"[WATCHDOG] Quota Watchdog function not found, task not started."
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# Start periodic cookie refresh task
|
| 303 |
+
try:
|
| 304 |
+
from browser_utils.cookie_refresh import start_periodic_refresh
|
| 305 |
+
|
| 306 |
+
cookie_refresh_task = start_periodic_refresh()
|
| 307 |
+
if cookie_refresh_task:
|
| 308 |
+
app.state.cookie_refresh_task = cookie_refresh_task
|
| 309 |
+
except Exception as e:
|
| 310 |
+
logger.warning(f"[COOKIE-REFRESH] Failed to start periodic refresh: {e}")
|
| 311 |
+
|
| 312 |
+
startup_duration = time.time() - startup_start_time
|
| 313 |
+
logger.info(f"Server startup complete. (Took: {startup_duration:.2f}s)")
|
| 314 |
+
state.is_initializing = False
|
| 315 |
+
yield
|
| 316 |
+
except Exception as e:
|
| 317 |
+
logger.critical(f"Application startup failed: {e}", exc_info=True)
|
| 318 |
+
await _shutdown_resources()
|
| 319 |
+
raise RuntimeError(f"Application startup failed: {e}") from e
|
| 320 |
+
finally:
|
| 321 |
+
logger.info("Shutting down server...")
|
| 322 |
+
|
| 323 |
+
# Stop periodic cookie refresh and save cookies before shutdown
|
| 324 |
+
if hasattr(app.state, "cookie_refresh_task"):
|
| 325 |
+
logger.info("[STOP] Stopping Cookie Refresh Task...")
|
| 326 |
+
try:
|
| 327 |
+
from browser_utils.cookie_refresh import (
|
| 328 |
+
save_cookies_on_shutdown,
|
| 329 |
+
stop_periodic_refresh,
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
await stop_periodic_refresh()
|
| 333 |
+
# Save cookies one final time before shutdown
|
| 334 |
+
await save_cookies_on_shutdown()
|
| 335 |
+
except Exception as e:
|
| 336 |
+
logger.warning(f"[COOKIE-REFRESH] Shutdown save error: {e}")
|
| 337 |
+
|
| 338 |
+
if hasattr(app.state, "watchdog_task"):
|
| 339 |
+
logger.info("[STOP] Stopping Quota Watchdog...")
|
| 340 |
+
task = app.state.watchdog_task
|
| 341 |
+
if hasattr(task, "cancel"):
|
| 342 |
+
task.cancel()
|
| 343 |
+
# Only await if it's actually an asyncio task or future
|
| 344 |
+
if isinstance(task, (asyncio.Task, asyncio.Future)):
|
| 345 |
+
try:
|
| 346 |
+
await task
|
| 347 |
+
except asyncio.CancelledError:
|
| 348 |
+
pass
|
| 349 |
+
|
| 350 |
+
try:
|
| 351 |
+
await _shutdown_resources()
|
| 352 |
+
finally:
|
| 353 |
+
restore_original_streams(initial_stdout, initial_stderr)
|
| 354 |
+
restore_original_streams(*original_streams)
|
| 355 |
+
logger.info("Server shut down.")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
class APIKeyAuthMiddleware(BaseHTTPMiddleware):
|
| 359 |
+
def __init__(self, app: ASGIApp):
|
| 360 |
+
super().__init__(app)
|
| 361 |
+
self.excluded_paths = [
|
| 362 |
+
"/v1/models",
|
| 363 |
+
"/health",
|
| 364 |
+
"/docs",
|
| 365 |
+
"/openapi.json",
|
| 366 |
+
"/redoc",
|
| 367 |
+
"/favicon.ico",
|
| 368 |
+
]
|
| 369 |
+
|
| 370 |
+
async def dispatch(
|
| 371 |
+
self, request: Request, call_next: Callable[[Request], Awaitable]
|
| 372 |
+
):
|
| 373 |
+
if not auth_utils.API_KEYS:
|
| 374 |
+
return await call_next(request)
|
| 375 |
+
if not request.url.path.startswith("/v1/"):
|
| 376 |
+
return await call_next(request)
|
| 377 |
+
for excluded_path in self.excluded_paths:
|
| 378 |
+
if request.url.path == excluded_path or request.url.path.startswith(
|
| 379 |
+
excluded_path + "/"
|
| 380 |
+
):
|
| 381 |
+
return await call_next(request)
|
| 382 |
+
api_key = request.headers.get("Authorization")
|
| 383 |
+
if api_key and api_key.startswith("Bearer "):
|
| 384 |
+
api_key = api_key[7:]
|
| 385 |
+
if not api_key:
|
| 386 |
+
api_key = request.headers.get("X-API-Key")
|
| 387 |
+
if not api_key or not auth_utils.verify_api_key(api_key):
|
| 388 |
+
return JSONResponse(
|
| 389 |
+
status_code=401,
|
| 390 |
+
content={
|
| 391 |
+
"error": {
|
| 392 |
+
"message": "Invalid or missing API key. Please provide a valid API key using 'Authorization: Bearer <your_key>' or 'X-API-Key: <your_key>' header.",
|
| 393 |
+
"type": "invalid_request_error",
|
| 394 |
+
"param": None,
|
| 395 |
+
"code": "invalid_api_key",
|
| 396 |
+
}
|
| 397 |
+
},
|
| 398 |
+
)
|
| 399 |
+
return await call_next(request)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def create_app() -> FastAPI:
|
| 403 |
+
"""Create FastAPI application instance"""
|
| 404 |
+
app = FastAPI(
|
| 405 |
+
title="AI Studio Proxy Server (Integrated Mode)",
|
| 406 |
+
description="Proxy server interacting with AI Studio via Playwright.",
|
| 407 |
+
version=VERSION,
|
| 408 |
+
lifespan=lifespan,
|
| 409 |
+
)
|
| 410 |
+
app.add_middleware(APIKeyAuthMiddleware)
|
| 411 |
+
from fastapi.responses import FileResponse
|
| 412 |
+
|
| 413 |
+
from .routers import (
|
| 414 |
+
add_api_key,
|
| 415 |
+
auth_files_router,
|
| 416 |
+
cancel_request,
|
| 417 |
+
chat_completions,
|
| 418 |
+
delete_api_key,
|
| 419 |
+
get_api_info,
|
| 420 |
+
get_api_keys,
|
| 421 |
+
get_queue_status,
|
| 422 |
+
health_check,
|
| 423 |
+
list_models,
|
| 424 |
+
model_capabilities_router,
|
| 425 |
+
ports_router,
|
| 426 |
+
proxy_router,
|
| 427 |
+
read_index,
|
| 428 |
+
serve_react_assets,
|
| 429 |
+
test_api_key,
|
| 430 |
+
websocket_log_endpoint,
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
app.get("/", response_class=FileResponse)(read_index)
|
| 434 |
+
app.get("/assets/{filename:path}")(serve_react_assets)
|
| 435 |
+
app.get("/api/info")(get_api_info)
|
| 436 |
+
app.get("/health")(health_check)
|
| 437 |
+
app.get("/v1/models")(list_models)
|
| 438 |
+
app.post("/v1/chat/completions")(chat_completions)
|
| 439 |
+
app.post("/v1/cancel/{req_id}")(cancel_request)
|
| 440 |
+
app.get("/v1/queue")(get_queue_status)
|
| 441 |
+
app.websocket("/ws/logs")(websocket_log_endpoint)
|
| 442 |
+
app.include_router(model_capabilities_router)
|
| 443 |
+
app.include_router(proxy_router)
|
| 444 |
+
app.include_router(auth_files_router)
|
| 445 |
+
app.include_router(ports_router)
|
| 446 |
+
from api_utils.routers import helper_router, server_router
|
| 447 |
+
|
| 448 |
+
app.include_router(server_router)
|
| 449 |
+
app.include_router(helper_router)
|
| 450 |
+
app.get("/api/keys")(get_api_keys)
|
| 451 |
+
app.post("/api/keys")(add_api_key)
|
| 452 |
+
app.post("/api/keys/test")(test_api_key)
|
| 453 |
+
app.delete("/api/keys")(delete_api_key)
|
| 454 |
+
return app
|
api_utils/auth_manager.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import glob
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
from typing import List, Optional, Set
|
| 6 |
+
|
| 7 |
+
from launcher.config import SAVED_AUTH_DIR
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger("AuthManager")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AuthManager:
|
| 13 |
+
"""
|
| 14 |
+
Manages authentication profiles for rotation and error recovery.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
self.failed_profiles: Set[str] = set()
|
| 19 |
+
self.current_profile: Optional[str] = None
|
| 20 |
+
|
| 21 |
+
# Initialize with the profile from environment if available
|
| 22 |
+
initial_profile = os.environ.get("ACTIVE_AUTH_JSON_PATH")
|
| 23 |
+
if initial_profile:
|
| 24 |
+
self.current_profile = initial_profile
|
| 25 |
+
|
| 26 |
+
async def get_available_profiles(self) -> List[str]:
|
| 27 |
+
"""List all .json files in the saved auth directory."""
|
| 28 |
+
if not os.path.exists(SAVED_AUTH_DIR):
|
| 29 |
+
logger.warning(f"Saved auth directory not found: {SAVED_AUTH_DIR}")
|
| 30 |
+
return []
|
| 31 |
+
|
| 32 |
+
loop = asyncio.get_running_loop()
|
| 33 |
+
pattern = os.path.join(SAVED_AUTH_DIR, "*.json")
|
| 34 |
+
# Run glob in executor to avoid blocking the event loop
|
| 35 |
+
profiles = await loop.run_in_executor(None, glob.glob, pattern)
|
| 36 |
+
return sorted(profiles) # Sort for deterministic order
|
| 37 |
+
|
| 38 |
+
async def get_next_profile(self) -> str:
|
| 39 |
+
"""
|
| 40 |
+
Get the next available profile that hasn't failed yet.
|
| 41 |
+
Raises RuntimeError if no profiles are available.
|
| 42 |
+
"""
|
| 43 |
+
profiles = await self.get_available_profiles()
|
| 44 |
+
|
| 45 |
+
# Get set of basenames for failed profiles (prevents duplicates due to path differences)
|
| 46 |
+
failed_basenames = {os.path.basename(p) for p in self.failed_profiles}
|
| 47 |
+
current_basename = (
|
| 48 |
+
os.path.basename(self.current_profile) if self.current_profile else None
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Filter out failed profiles by basename comparison
|
| 52 |
+
available = [
|
| 53 |
+
p
|
| 54 |
+
for p in profiles
|
| 55 |
+
if os.path.basename(p) not in failed_basenames
|
| 56 |
+
and os.path.basename(p)
|
| 57 |
+
!= current_basename # Also exclude the current profile
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
if not available:
|
| 61 |
+
msg = f"All authentication profiles exhausted. Failed: {len(self.failed_profiles)}, Total: {len(profiles)}"
|
| 62 |
+
logger.critical(msg)
|
| 63 |
+
raise RuntimeError(msg)
|
| 64 |
+
|
| 65 |
+
# Simple strategy: Pick the first available one.
|
| 66 |
+
next_profile = available[0]
|
| 67 |
+
self.current_profile = next_profile
|
| 68 |
+
logger.info(f"Switched to auth profile: {os.path.basename(next_profile)}")
|
| 69 |
+
return next_profile
|
| 70 |
+
|
| 71 |
+
def mark_profile_failed(self, profile_path: Optional[str] = None) -> None:
|
| 72 |
+
"""Mark a profile as failed so it won't be used again in this cycle."""
|
| 73 |
+
target = profile_path or self.current_profile
|
| 74 |
+
if target:
|
| 75 |
+
self.failed_profiles.add(target)
|
| 76 |
+
logger.warning(f"Marked auth profile as failed: {os.path.basename(target)}")
|
| 77 |
+
else:
|
| 78 |
+
logger.warning(
|
| 79 |
+
"Attempted to mark profile failed but no profile provided or active."
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def reset_failures(self) -> None:
|
| 83 |
+
"""Reset the failure tracking."""
|
| 84 |
+
self.failed_profiles.clear()
|
| 85 |
+
logger.info("Auth profile failure tracking reset.")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Global instance
|
| 89 |
+
auth_manager = AuthManager()
|
api_utils/auth_utils.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Set
|
| 3 |
+
|
| 4 |
+
API_KEYS: Set[str] = set()
|
| 5 |
+
KEY_FILE_PATH = os.path.join(
|
| 6 |
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 7 |
+
"auth_profiles",
|
| 8 |
+
"key.txt",
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_api_keys():
|
| 13 |
+
"""Loads API keys from the key file into the API_KEYS set."""
|
| 14 |
+
global API_KEYS
|
| 15 |
+
API_KEYS.clear()
|
| 16 |
+
if os.path.exists(KEY_FILE_PATH):
|
| 17 |
+
with open(KEY_FILE_PATH, "r") as f:
|
| 18 |
+
for line in f:
|
| 19 |
+
key = line.strip()
|
| 20 |
+
if key:
|
| 21 |
+
API_KEYS.add(key)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def initialize_keys():
|
| 25 |
+
"""Initializes API keys. Ensures key.txt exists and loads keys."""
|
| 26 |
+
if not os.path.exists(KEY_FILE_PATH):
|
| 27 |
+
with open(KEY_FILE_PATH, "w"):
|
| 28 |
+
pass # Create an empty file
|
| 29 |
+
load_api_keys()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def verify_api_key(api_key_from_header: str) -> bool:
|
| 33 |
+
"""
|
| 34 |
+
Verifies the API key.
|
| 35 |
+
Returns True if API_KEYS is empty (no validation) or if the key is valid.
|
| 36 |
+
"""
|
| 37 |
+
if not API_KEYS:
|
| 38 |
+
return True
|
| 39 |
+
return api_key_from_header in API_KEYS
|
api_utils/client_connection.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from asyncio import Event, Task
|
| 3 |
+
from typing import Any, Callable, Coroutine, Dict, Tuple
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException, Request
|
| 6 |
+
|
| 7 |
+
from models import ClientDisconnectedError
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def check_client_connection(req_id: str, http_request: Request) -> bool:
|
| 11 |
+
"""
|
| 12 |
+
Checks if the client is still connected.
|
| 13 |
+
Returns True if connected, False if disconnected.
|
| 14 |
+
"""
|
| 15 |
+
try:
|
| 16 |
+
if hasattr(http_request, "_receive"):
|
| 17 |
+
try:
|
| 18 |
+
# Use a very short timeout to check for disconnect message
|
| 19 |
+
# _receive is a private Starlette/FastAPI method that returns a coroutine
|
| 20 |
+
receive_obj = http_request # type: ignore[misc]
|
| 21 |
+
receive_coro: Coroutine[Any, Any, Dict[str, Any]] = (
|
| 22 |
+
receive_obj._receive()
|
| 23 |
+
) # type: ignore[misc]
|
| 24 |
+
receive_task: Task[Dict[str, Any]] = asyncio.create_task(receive_coro)
|
| 25 |
+
done, pending = await asyncio.wait([receive_task], timeout=0.01)
|
| 26 |
+
|
| 27 |
+
if done:
|
| 28 |
+
message = receive_task.result()
|
| 29 |
+
if message.get("type") == "http.disconnect":
|
| 30 |
+
return False
|
| 31 |
+
else:
|
| 32 |
+
# Cancel the task if it didn't complete immediately
|
| 33 |
+
receive_task.cancel()
|
| 34 |
+
try:
|
| 35 |
+
await receive_task
|
| 36 |
+
except asyncio.CancelledError:
|
| 37 |
+
pass
|
| 38 |
+
# If it didn't complete immediately, proceed to fallback check
|
| 39 |
+
except asyncio.CancelledError:
|
| 40 |
+
raise
|
| 41 |
+
except Exception:
|
| 42 |
+
# If checking fails, proceed to fallback
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
# Fallback to is_disconnected() if available (Starlette/FastAPI)
|
| 46 |
+
# Wrap in wait_for to prevent infinite hang in some ASGI implementations
|
| 47 |
+
if hasattr(http_request, "is_disconnected"):
|
| 48 |
+
try:
|
| 49 |
+
# Handle both sync and async versions for better mock compatibility
|
| 50 |
+
res = http_request.is_disconnected()
|
| 51 |
+
if asyncio.iscoroutine(res):
|
| 52 |
+
if await asyncio.wait_for(res, timeout=0.01):
|
| 53 |
+
return False
|
| 54 |
+
elif res:
|
| 55 |
+
return False
|
| 56 |
+
except (asyncio.TimeoutError, asyncio.CancelledError):
|
| 57 |
+
# If it times out, it's likely still connected
|
| 58 |
+
return True
|
| 59 |
+
|
| 60 |
+
return True
|
| 61 |
+
except asyncio.CancelledError:
|
| 62 |
+
raise
|
| 63 |
+
except Exception as e:
|
| 64 |
+
# Re-raise to allow caller to log/handle
|
| 65 |
+
raise e
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def enhanced_disconnect_monitor(
|
| 69 |
+
req_id: str,
|
| 70 |
+
http_request: Request,
|
| 71 |
+
completion_event: asyncio.Event,
|
| 72 |
+
logger: Any,
|
| 73 |
+
) -> bool:
|
| 74 |
+
"""
|
| 75 |
+
Monitors for client disconnect during streaming.
|
| 76 |
+
Returns True if disconnected, False otherwise.
|
| 77 |
+
"""
|
| 78 |
+
disconnect_detection_count = 0
|
| 79 |
+
while not completion_event.is_set():
|
| 80 |
+
try:
|
| 81 |
+
is_connected = await check_client_connection(req_id, http_request)
|
| 82 |
+
if not is_connected:
|
| 83 |
+
disconnect_detection_count += 1
|
| 84 |
+
if disconnect_detection_count >= 3:
|
| 85 |
+
logger.info(
|
| 86 |
+
f"[{req_id}] Client disconnect confirmed during streaming."
|
| 87 |
+
)
|
| 88 |
+
completion_event.set()
|
| 89 |
+
return True
|
| 90 |
+
else:
|
| 91 |
+
disconnect_detection_count = 0
|
| 92 |
+
await asyncio.sleep(0.2)
|
| 93 |
+
except asyncio.CancelledError:
|
| 94 |
+
break
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"[{req_id}] Error in enhanced_disconnect_monitor: {e}")
|
| 97 |
+
break
|
| 98 |
+
return False
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def non_streaming_disconnect_monitor(
|
| 102 |
+
req_id: str,
|
| 103 |
+
http_request: Request,
|
| 104 |
+
result_future: asyncio.Future,
|
| 105 |
+
logger: Any,
|
| 106 |
+
) -> bool:
|
| 107 |
+
"""
|
| 108 |
+
Monitors for client disconnect during non-streaming processing.
|
| 109 |
+
Returns True if disconnected, False otherwise.
|
| 110 |
+
"""
|
| 111 |
+
while not result_future.done():
|
| 112 |
+
try:
|
| 113 |
+
is_connected = await check_client_connection(req_id, http_request)
|
| 114 |
+
if not is_connected:
|
| 115 |
+
logger.info(
|
| 116 |
+
f"[{req_id}] Client disconnect detected during non-streaming."
|
| 117 |
+
)
|
| 118 |
+
if not result_future.done():
|
| 119 |
+
result_future.set_exception(
|
| 120 |
+
HTTPException(status_code=499, detail="Client disconnected")
|
| 121 |
+
)
|
| 122 |
+
return True
|
| 123 |
+
await asyncio.sleep(0.3)
|
| 124 |
+
except asyncio.CancelledError:
|
| 125 |
+
break
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"[{req_id}] Error in non_streaming_disconnect_monitor: {e}")
|
| 128 |
+
break
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def setup_disconnect_monitoring(
|
| 133 |
+
req_id: str, http_request: Request, result_future
|
| 134 |
+
) -> Tuple[Event, asyncio.Task, Callable]:
|
| 135 |
+
from api_utils.server_state import state
|
| 136 |
+
|
| 137 |
+
logger = state.logger
|
| 138 |
+
|
| 139 |
+
client_disconnected_event = Event()
|
| 140 |
+
disconnect_count = 0
|
| 141 |
+
disconnect_threshold = 5 # Require 5 consecutive disconnect signals (1.5 seconds)
|
| 142 |
+
|
| 143 |
+
async def check_disconnect_periodically():
|
| 144 |
+
nonlocal disconnect_count
|
| 145 |
+
while not client_disconnected_event.is_set():
|
| 146 |
+
try:
|
| 147 |
+
is_connected = await check_client_connection(req_id, http_request)
|
| 148 |
+
if not is_connected:
|
| 149 |
+
disconnect_count += 1
|
| 150 |
+
if disconnect_count >= disconnect_threshold:
|
| 151 |
+
logger.info(
|
| 152 |
+
f"[{req_id}] Active detection of client disconnect (consecutive {disconnect_count} times)."
|
| 153 |
+
)
|
| 154 |
+
client_disconnected_event.set()
|
| 155 |
+
if not result_future.done():
|
| 156 |
+
result_future.set_exception(
|
| 157 |
+
HTTPException(
|
| 158 |
+
status_code=499,
|
| 159 |
+
detail=f"[{req_id}] Client closed the request",
|
| 160 |
+
)
|
| 161 |
+
)
|
| 162 |
+
break
|
| 163 |
+
else:
|
| 164 |
+
logger.debug(
|
| 165 |
+
f"[{req_id}] Active detection of potential disconnect (round {disconnect_count}/{disconnect_threshold})"
|
| 166 |
+
)
|
| 167 |
+
else:
|
| 168 |
+
disconnect_count = 0 # Reset counter on successful connection
|
| 169 |
+
|
| 170 |
+
await asyncio.sleep(0.3)
|
| 171 |
+
except asyncio.CancelledError:
|
| 172 |
+
# Task cancelled, exit gracefully
|
| 173 |
+
break
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f"(Disco Check Task) Error: {e}")
|
| 176 |
+
client_disconnected_event.set()
|
| 177 |
+
if not result_future.done():
|
| 178 |
+
result_future.set_exception(
|
| 179 |
+
HTTPException(
|
| 180 |
+
status_code=500,
|
| 181 |
+
detail=f"[{req_id}] Internal disconnect checker error: {e}",
|
| 182 |
+
)
|
| 183 |
+
)
|
| 184 |
+
break
|
| 185 |
+
|
| 186 |
+
disconnect_check_task = asyncio.create_task(check_disconnect_periodically())
|
| 187 |
+
|
| 188 |
+
def check_client_disconnected(stage: str = "") -> bool:
|
| 189 |
+
if client_disconnected_event.is_set():
|
| 190 |
+
logger.info(f"Client disconnected detected at stage: '{stage}'")
|
| 191 |
+
raise ClientDisconnectedError(
|
| 192 |
+
f"[{req_id}] Client disconnected at stage: {stage}"
|
| 193 |
+
)
|
| 194 |
+
return False
|
| 195 |
+
|
| 196 |
+
return client_disconnected_event, disconnect_check_task, check_client_disconnected
|
api_utils/common_utils.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def random_id(length: int = 24) -> str:
|
| 5 |
+
charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
| 6 |
+
return "".join(random.choice(charset) for _ in range(length))
|
api_utils/context_init.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import cast
|
| 2 |
+
|
| 3 |
+
from logging_utils import set_request_id
|
| 4 |
+
from models import ChatCompletionRequest
|
| 5 |
+
|
| 6 |
+
from .context_types import RequestContext
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def initialize_request_context(
|
| 10 |
+
req_id: str, request: ChatCompletionRequest
|
| 11 |
+
) -> RequestContext:
|
| 12 |
+
from api_utils.server_state import state
|
| 13 |
+
|
| 14 |
+
set_request_id(req_id)
|
| 15 |
+
state.logger.debug(
|
| 16 |
+
f"[Request] Parameters: Model={request.model}, Stream={request.stream}"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
context: RequestContext = cast(
|
| 20 |
+
RequestContext,
|
| 21 |
+
{
|
| 22 |
+
"logger": state.logger,
|
| 23 |
+
"page": state.page_instance,
|
| 24 |
+
"is_page_ready": state.is_page_ready,
|
| 25 |
+
"parsed_model_list": state.parsed_model_list,
|
| 26 |
+
"current_ai_studio_model_id": state.current_ai_studio_model_id,
|
| 27 |
+
"model_switching_lock": state.model_switching_lock,
|
| 28 |
+
"page_params_cache": state.page_params_cache,
|
| 29 |
+
"params_cache_lock": state.params_cache_lock,
|
| 30 |
+
"is_streaming": request.stream,
|
| 31 |
+
"model_actually_switched": False,
|
| 32 |
+
"requested_model": request.model,
|
| 33 |
+
"model_id_to_use": None,
|
| 34 |
+
"needs_model_switching": False,
|
| 35 |
+
},
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
return context
|
api_utils/context_types.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from asyncio import Future, Lock
|
| 3 |
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union
|
| 4 |
+
|
| 5 |
+
from playwright.async_api import Page as AsyncPage
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from fastapi import Request
|
| 9 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 10 |
+
|
| 11 |
+
from models.chat import ChatCompletionRequest
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class QueueItem(TypedDict):
|
| 15 |
+
"""Type definition for items in the request queue.
|
| 16 |
+
|
| 17 |
+
This defines the structure of each item put into the request_queue,
|
| 18 |
+
ensuring type safety for queue operations.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
req_id: str
|
| 22 |
+
request_data: "ChatCompletionRequest"
|
| 23 |
+
http_request: "Request"
|
| 24 |
+
result_future: "Future[Union[JSONResponse, StreamingResponse]]"
|
| 25 |
+
enqueue_time: float
|
| 26 |
+
cancelled: bool
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RequestContext(TypedDict):
|
| 30 |
+
"""Request context with all keys always present after initialization.
|
| 31 |
+
|
| 32 |
+
All keys are required (always exist in the dict) after context_init.py initialization.
|
| 33 |
+
Optional[] types indicate that the VALUE can be None, not that the key might not exist.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
# Core components (always set by context_init.py)
|
| 37 |
+
req_id: str
|
| 38 |
+
logger: logging.Logger
|
| 39 |
+
page: Optional[AsyncPage] # Value can be None if browser not ready
|
| 40 |
+
is_page_ready: bool
|
| 41 |
+
parsed_model_list: List[Dict[str, Any]]
|
| 42 |
+
current_ai_studio_model_id: Optional[str] # Value can be None initially
|
| 43 |
+
|
| 44 |
+
# Locks (always set by server_state)
|
| 45 |
+
model_switching_lock: Lock
|
| 46 |
+
page_params_cache: Dict[str, Any]
|
| 47 |
+
params_cache_lock: Lock
|
| 48 |
+
|
| 49 |
+
# Request-specific state (always initialized)
|
| 50 |
+
is_streaming: bool
|
| 51 |
+
model_actually_switched: bool
|
| 52 |
+
requested_model: Optional[str] # Value can be None if not specified
|
| 53 |
+
model_id_to_use: Optional[str] # Value set during model analysis
|
| 54 |
+
needs_model_switching: bool
|
api_utils/dependencies.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI Dependencies Module
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from asyncio import Event, Lock, Queue
|
| 7 |
+
from typing import Any, Dict, List, Set
|
| 8 |
+
|
| 9 |
+
from api_utils.context_types import QueueItem
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_logger() -> logging.Logger:
|
| 13 |
+
from api_utils.server_state import state
|
| 14 |
+
|
| 15 |
+
return state.logger
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_log_ws_manager():
|
| 19 |
+
from api_utils.server_state import state
|
| 20 |
+
|
| 21 |
+
return state.log_ws_manager
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_request_queue() -> "Queue[QueueItem]":
|
| 25 |
+
from typing import cast
|
| 26 |
+
|
| 27 |
+
from api_utils.server_state import state
|
| 28 |
+
|
| 29 |
+
return cast("Queue[QueueItem]", state.request_queue)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_processing_lock() -> Lock:
|
| 33 |
+
from typing import cast
|
| 34 |
+
|
| 35 |
+
from api_utils.server_state import state
|
| 36 |
+
|
| 37 |
+
return cast(Lock, state.processing_lock)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_worker_task():
|
| 41 |
+
from api_utils.server_state import state
|
| 42 |
+
|
| 43 |
+
return state.worker_task
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_server_state() -> Dict[str, Any]:
|
| 47 |
+
from api_utils.server_state import state
|
| 48 |
+
|
| 49 |
+
# Return immutable snapshot to prevent downstream modifications to global references
|
| 50 |
+
return dict(
|
| 51 |
+
is_initializing=state.is_initializing,
|
| 52 |
+
is_playwright_ready=state.is_playwright_ready,
|
| 53 |
+
is_browser_connected=state.is_browser_connected,
|
| 54 |
+
is_page_ready=state.is_page_ready,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def get_page_instance():
|
| 59 |
+
from api_utils.server_state import state
|
| 60 |
+
|
| 61 |
+
return state.page_instance
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_model_list_fetch_event() -> Event:
|
| 65 |
+
from typing import cast
|
| 66 |
+
|
| 67 |
+
from api_utils.server_state import state
|
| 68 |
+
|
| 69 |
+
return cast(Event, state.model_list_fetch_event)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_parsed_model_list() -> List[Dict[str, Any]]:
|
| 73 |
+
from api_utils.server_state import state
|
| 74 |
+
|
| 75 |
+
return state.parsed_model_list
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def get_excluded_model_ids() -> Set[str]:
|
| 79 |
+
from api_utils.server_state import state
|
| 80 |
+
|
| 81 |
+
return state.excluded_model_ids
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_current_ai_studio_model_id() -> str:
|
| 85 |
+
from typing import cast
|
| 86 |
+
|
| 87 |
+
from api_utils.server_state import state
|
| 88 |
+
|
| 89 |
+
return cast(str, state.current_ai_studio_model_id)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def ensure_request_lock():
|
| 93 |
+
"""
|
| 94 |
+
Dependency that acts as a 'Parking Lot' for requests.
|
| 95 |
+
If Auth Rotation is in progress (Lock is cleared) or Quota is Exceeded (Rotation imminent),
|
| 96 |
+
this will pause the request until the system is ready.
|
| 97 |
+
"""
|
| 98 |
+
import asyncio
|
| 99 |
+
import time
|
| 100 |
+
|
| 101 |
+
from api_utils.server_state import state as server_state
|
| 102 |
+
from config.global_state import GlobalState
|
| 103 |
+
|
| 104 |
+
logger = server_state.logger
|
| 105 |
+
|
| 106 |
+
# A request is considered "queued" if it has to wait for the lock.
|
| 107 |
+
is_waiting = (
|
| 108 |
+
GlobalState.IS_QUOTA_EXCEEDED or not GlobalState.AUTH_ROTATION_LOCK.is_set()
|
| 109 |
+
)
|
| 110 |
+
if is_waiting:
|
| 111 |
+
GlobalState.queued_request_count += 1
|
| 112 |
+
|
| 113 |
+
start_time = time.time()
|
| 114 |
+
max_total_wait = 60.0 # 60 second hard timeout for request parking
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
# Wait loop to handle both Lock and Quota states
|
| 118 |
+
# We wait if:
|
| 119 |
+
# 1. Lock is NOT set (Rotation in progress)
|
| 120 |
+
# 2. Quota IS exceeded (Rotation about to start, or we need to wait for it)
|
| 121 |
+
while (
|
| 122 |
+
GlobalState.IS_QUOTA_EXCEEDED or not GlobalState.AUTH_ROTATION_LOCK.is_set()
|
| 123 |
+
):
|
| 124 |
+
# Check for total timeout
|
| 125 |
+
if time.time() - start_time > max_total_wait:
|
| 126 |
+
logger.error(
|
| 127 |
+
f"🚨 Request parking timeout after {max_total_wait}s. Quota={GlobalState.IS_QUOTA_EXCEEDED}, LockSet={GlobalState.AUTH_ROTATION_LOCK.is_set()}"
|
| 128 |
+
)
|
| 129 |
+
from fastapi import HTTPException
|
| 130 |
+
|
| 131 |
+
raise HTTPException(
|
| 132 |
+
status_code=530, # Custom code for state resolution timeout
|
| 133 |
+
detail="System state resolution timeout - please try again later",
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
if not GlobalState.AUTH_ROTATION_LOCK.is_set():
|
| 137 |
+
# Rotation in progress. Wait for lock to open with timeout.
|
| 138 |
+
try:
|
| 139 |
+
await asyncio.wait_for(
|
| 140 |
+
GlobalState.AUTH_ROTATION_LOCK.wait(), timeout=30.0
|
| 141 |
+
)
|
| 142 |
+
except asyncio.TimeoutError:
|
| 143 |
+
logger.warning(
|
| 144 |
+
"🚨 Lock wait timeout after 30s. Service may be unavailable."
|
| 145 |
+
)
|
| 146 |
+
from fastapi import HTTPException
|
| 147 |
+
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=503,
|
| 150 |
+
detail="Service temporarily unavailable - timeout waiting for system lock",
|
| 151 |
+
)
|
| 152 |
+
else:
|
| 153 |
+
# Lock is Open, but Quota is still marked Exceeded.
|
| 154 |
+
# This implies the Watchdog is about to rotate, or we are in a race.
|
| 155 |
+
# We wait for the recovery event which signals rotation completion.
|
| 156 |
+
try:
|
| 157 |
+
if GlobalState.IS_RECOVERING:
|
| 158 |
+
# If recovery is active, wait for it to finish
|
| 159 |
+
await asyncio.wait_for(
|
| 160 |
+
GlobalState.RECOVERY_EVENT.wait(), timeout=30.0
|
| 161 |
+
)
|
| 162 |
+
else:
|
| 163 |
+
# Watchdog hasn't started yet, wait briefly
|
| 164 |
+
await asyncio.sleep(0.1)
|
| 165 |
+
except asyncio.TimeoutError:
|
| 166 |
+
await asyncio.sleep(0.1)
|
| 167 |
+
finally:
|
| 168 |
+
if is_waiting:
|
| 169 |
+
GlobalState.queued_request_count -= 1
|
api_utils/error_utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Optional
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def http_error(
|
| 7 |
+
status_code: int, detail: str, headers: Optional[Dict[str, str]] = None
|
| 8 |
+
) -> HTTPException:
|
| 9 |
+
return HTTPException(
|
| 10 |
+
status_code=status_code, detail=detail, headers=headers or None
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def client_cancelled(req_id: str, message: str = "Request cancelled.") -> HTTPException:
|
| 15 |
+
return http_error(499, f"[{req_id}] {message}")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def client_disconnected(req_id: str, stage: str = "") -> HTTPException:
|
| 19 |
+
suffix = f" during {stage}" if stage else ""
|
| 20 |
+
return http_error(499, f"[{req_id}] Client disconnected{suffix}.")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def processing_timeout(
|
| 24 |
+
req_id: str, message: str = "Processing timed out."
|
| 25 |
+
) -> HTTPException:
|
| 26 |
+
return http_error(504, f"[{req_id}] {message}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def bad_request(req_id: str, message: str) -> HTTPException:
|
| 30 |
+
return http_error(400, f"[{req_id}] {message}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def server_error(req_id: str, message: str) -> HTTPException:
|
| 34 |
+
return http_error(500, f"[{req_id}] {message}")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def upstream_error(req_id: str, message: str) -> HTTPException:
|
| 38 |
+
# 502 Bad Gateway for upstream/playwright failures
|
| 39 |
+
return http_error(502, f"[{req_id}] {message}")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def service_unavailable(req_id: str, retry_after_seconds: int = 30) -> HTTPException:
|
| 43 |
+
return http_error(
|
| 44 |
+
503,
|
| 45 |
+
f"[{req_id}] Service currently unavailable. Please try again later.",
|
| 46 |
+
headers={"Retry-After": str(retry_after_seconds)},
|
| 47 |
+
)
|
api_utils/mcp_adapter.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _normalize_endpoint(ep: str) -> str:
|
| 10 |
+
if not ep:
|
| 11 |
+
raise RuntimeError("MCP HTTP endpoint not provided")
|
| 12 |
+
return ep.rstrip("/")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def execute_mcp_tool(name: str, params: Dict[str, Any]) -> str:
|
| 16 |
+
"""
|
| 17 |
+
Minimal MCP-over-HTTP adapter:
|
| 18 |
+
- POST {MCP_HTTP_ENDPOINT}/tools/execute with {name, arguments}
|
| 19 |
+
- Returns JSON string.
|
| 20 |
+
Compatible with servers exposing MCP-like HTTP interface.
|
| 21 |
+
"""
|
| 22 |
+
ep = os.environ.get("MCP_HTTP_ENDPOINT")
|
| 23 |
+
if not ep:
|
| 24 |
+
raise RuntimeError("MCP_HTTP_ENDPOINT not configured")
|
| 25 |
+
url = f"{_normalize_endpoint(ep)}/tools/execute"
|
| 26 |
+
payload = {"name": name, "arguments": params}
|
| 27 |
+
headers = {"Content-Type": "application/json"}
|
| 28 |
+
timeout = float(os.environ.get("MCP_HTTP_TIMEOUT", "15"))
|
| 29 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 30 |
+
resp = await client.post(url, json=payload, headers=headers)
|
| 31 |
+
resp.raise_for_status()
|
| 32 |
+
try:
|
| 33 |
+
data = resp.json()
|
| 34 |
+
except asyncio.CancelledError:
|
| 35 |
+
raise
|
| 36 |
+
except Exception:
|
| 37 |
+
data = {"raw": resp.text}
|
| 38 |
+
return json.dumps(data, ensure_ascii=False)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def execute_mcp_tool_with_endpoint(
|
| 42 |
+
endpoint: str, name: str, params: Dict[str, Any]
|
| 43 |
+
) -> str:
|
| 44 |
+
url = f"{_normalize_endpoint(endpoint)}/tools/execute"
|
| 45 |
+
payload = {"name": name, "arguments": params}
|
| 46 |
+
headers = {"Content-Type": "application/json"}
|
| 47 |
+
timeout = float(os.environ.get("MCP_HTTP_TIMEOUT", "15"))
|
| 48 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 49 |
+
resp = await client.post(url, json=payload, headers=headers)
|
| 50 |
+
resp.raise_for_status()
|
| 51 |
+
try:
|
| 52 |
+
data = resp.json()
|
| 53 |
+
except asyncio.CancelledError:
|
| 54 |
+
raise
|
| 55 |
+
except Exception:
|
| 56 |
+
data = {"raw": resp.text}
|
| 57 |
+
return json.dumps(data, ensure_ascii=False)
|
api_utils/model_switching.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from playwright.async_api import Page as AsyncPage
|
| 3 |
+
|
| 4 |
+
from api_utils.server_state import state
|
| 5 |
+
from logging_utils import set_request_id
|
| 6 |
+
|
| 7 |
+
from .context_types import RequestContext
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def analyze_model_requirements(
|
| 11 |
+
req_id: str, context: RequestContext, requested_model: str, proxy_model_name: str
|
| 12 |
+
) -> RequestContext:
|
| 13 |
+
set_request_id(req_id)
|
| 14 |
+
logger = context["logger"]
|
| 15 |
+
current_ai_studio_model_id = context["current_ai_studio_model_id"]
|
| 16 |
+
parsed_model_list = context["parsed_model_list"]
|
| 17 |
+
|
| 18 |
+
if requested_model and requested_model != proxy_model_name:
|
| 19 |
+
requested_model_id = requested_model.split("/")[-1]
|
| 20 |
+
logger.info(f"[{req_id}] Requesting model: {requested_model_id}")
|
| 21 |
+
|
| 22 |
+
if parsed_model_list:
|
| 23 |
+
valid_model_ids = [
|
| 24 |
+
str(m.get("id")) for m in parsed_model_list if m.get("id")
|
| 25 |
+
]
|
| 26 |
+
if requested_model_id not in valid_model_ids:
|
| 27 |
+
from .error_utils import bad_request
|
| 28 |
+
|
| 29 |
+
raise bad_request(
|
| 30 |
+
req_id,
|
| 31 |
+
f"Invalid model '{requested_model_id}'. Available models: {', '.join(valid_model_ids)}",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
context["model_id_to_use"] = requested_model_id
|
| 35 |
+
if current_ai_studio_model_id != requested_model_id:
|
| 36 |
+
context["needs_model_switching"] = True
|
| 37 |
+
logger.info(
|
| 38 |
+
f"[{req_id}] Model switch needed: Current={current_ai_studio_model_id} -> Target={requested_model_id}"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return context
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def handle_model_switching(
|
| 45 |
+
req_id: str, context: RequestContext
|
| 46 |
+
) -> RequestContext:
|
| 47 |
+
set_request_id(req_id)
|
| 48 |
+
if not context["needs_model_switching"]:
|
| 49 |
+
return context
|
| 50 |
+
|
| 51 |
+
logger = context["logger"]
|
| 52 |
+
page = context["page"]
|
| 53 |
+
model_switching_lock = context["model_switching_lock"]
|
| 54 |
+
model_id_to_use = context["model_id_to_use"]
|
| 55 |
+
|
| 56 |
+
# Assert non-None values required for model switching
|
| 57 |
+
assert page is not None, "Page must be ready for model switching"
|
| 58 |
+
assert model_id_to_use is not None, "Target model ID must be set"
|
| 59 |
+
|
| 60 |
+
async with model_switching_lock:
|
| 61 |
+
if state.current_ai_studio_model_id != model_id_to_use:
|
| 62 |
+
logger.info(
|
| 63 |
+
f"[{req_id}] Preparing to switch model: {state.current_ai_studio_model_id} -> {model_id_to_use}"
|
| 64 |
+
)
|
| 65 |
+
from browser_utils import switch_ai_studio_model
|
| 66 |
+
|
| 67 |
+
switch_success = await switch_ai_studio_model(page, model_id_to_use, req_id)
|
| 68 |
+
if switch_success:
|
| 69 |
+
state.current_ai_studio_model_id = model_id_to_use
|
| 70 |
+
context["model_actually_switched"] = True
|
| 71 |
+
context["current_ai_studio_model_id"] = model_id_to_use
|
| 72 |
+
logger.info(
|
| 73 |
+
f"[{req_id}] ✅ Model switched successfully: {state.current_ai_studio_model_id}"
|
| 74 |
+
)
|
| 75 |
+
else:
|
| 76 |
+
# Current model ID should exist when switching fails
|
| 77 |
+
current_model = state.current_ai_studio_model_id or "unknown"
|
| 78 |
+
await _handle_model_switch_failure(
|
| 79 |
+
req_id,
|
| 80 |
+
page,
|
| 81 |
+
model_id_to_use,
|
| 82 |
+
current_model,
|
| 83 |
+
logger,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
return context
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def _handle_model_switch_failure(
|
| 90 |
+
req_id: str, page: AsyncPage, model_id_to_use: str, model_before_switch: str, logger
|
| 91 |
+
) -> None:
|
| 92 |
+
logger.warning(f"[{req_id}] ❌ Failed to switch to model {model_id_to_use}.")
|
| 93 |
+
state.current_ai_studio_model_id = model_before_switch
|
| 94 |
+
from .error_utils import http_error
|
| 95 |
+
|
| 96 |
+
raise http_error(
|
| 97 |
+
422,
|
| 98 |
+
f"[{req_id}] Failed to switch to model '{model_id_to_use}'. Ensure model is available.",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def handle_parameter_cache(req_id: str, context: RequestContext) -> None:
|
| 103 |
+
set_request_id(req_id)
|
| 104 |
+
logger = context["logger"]
|
| 105 |
+
params_cache_lock = context["params_cache_lock"]
|
| 106 |
+
page_params_cache = context["page_params_cache"]
|
| 107 |
+
current_ai_studio_model_id = context["current_ai_studio_model_id"]
|
| 108 |
+
model_actually_switched = context["model_actually_switched"]
|
| 109 |
+
|
| 110 |
+
async with params_cache_lock:
|
| 111 |
+
cached_model_for_params = page_params_cache.get(
|
| 112 |
+
"last_known_model_id_for_params"
|
| 113 |
+
)
|
| 114 |
+
if model_actually_switched or (
|
| 115 |
+
current_ai_studio_model_id != cached_model_for_params
|
| 116 |
+
):
|
| 117 |
+
logger.info(f"[{req_id}] Model changed, parameter cache invalidated.")
|
| 118 |
+
page_params_cache.clear()
|
| 119 |
+
page_params_cache["last_known_model_id_for_params"] = (
|
| 120 |
+
current_ai_studio_model_id
|
| 121 |
+
)
|
api_utils/page_response.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Callable
|
| 4 |
+
|
| 5 |
+
from playwright.async_api import Error as PlaywrightAsyncError
|
| 6 |
+
from playwright.async_api import Page as AsyncPage
|
| 7 |
+
from playwright.async_api import expect as expect_async
|
| 8 |
+
|
| 9 |
+
from config import RESPONSE_CONTAINER_SELECTOR, RESPONSE_TEXT_SELECTOR
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def locate_response_elements(
|
| 13 |
+
page: AsyncPage,
|
| 14 |
+
req_id: str,
|
| 15 |
+
logger: logging.Logger,
|
| 16 |
+
check_client_disconnected: Callable[[str], bool],
|
| 17 |
+
) -> None:
|
| 18 |
+
"""Locate response container and text elements, including timeout and error handling."""
|
| 19 |
+
logger.info(f"[{req_id}] Locating response elements...")
|
| 20 |
+
response_container = page.locator(RESPONSE_CONTAINER_SELECTOR).last
|
| 21 |
+
response_element = response_container.locator(RESPONSE_TEXT_SELECTOR)
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
await expect_async(response_container).to_be_attached(timeout=20000)
|
| 25 |
+
check_client_disconnected("After Response Container Attached: ")
|
| 26 |
+
await expect_async(response_element).to_be_attached(timeout=90000)
|
| 27 |
+
logger.info(f"[{req_id}] Response elements located.")
|
| 28 |
+
except (PlaywrightAsyncError, asyncio.TimeoutError) as locate_err:
|
| 29 |
+
from .error_utils import upstream_error
|
| 30 |
+
|
| 31 |
+
raise upstream_error(
|
| 32 |
+
req_id, f"Failed to locate AI Studio response elements: {locate_err}"
|
| 33 |
+
)
|
| 34 |
+
except Exception as locate_exc:
|
| 35 |
+
from .error_utils import server_error
|
| 36 |
+
|
| 37 |
+
raise server_error(
|
| 38 |
+
req_id, f"Unexpected error while locating response elements: {locate_exc}"
|
| 39 |
+
)
|
api_utils/queue_worker.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Queue Worker Module
|
| 3 |
+
Handles tasks in the request queue
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import time
|
| 8 |
+
from asyncio import Event, Future, Task
|
| 9 |
+
from typing import Callable, Optional, cast
|
| 10 |
+
|
| 11 |
+
from fastapi import HTTPException, Request
|
| 12 |
+
from playwright.async_api import Locator
|
| 13 |
+
from playwright.async_api import expect as expect_async
|
| 14 |
+
|
| 15 |
+
from api_utils.context_types import QueueItem
|
| 16 |
+
from models import QuotaExceededError
|
| 17 |
+
|
| 18 |
+
from .client_connection import check_client_connection
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def queue_worker() -> None:
|
| 22 |
+
"""Queue worker, processes tasks in the request queue"""
|
| 23 |
+
# Delayed imports to avoid circularity
|
| 24 |
+
from api_utils.server_state import state
|
| 25 |
+
from config import RESPONSE_COMPLETION_TIMEOUT
|
| 26 |
+
|
| 27 |
+
logger = state.logger
|
| 28 |
+
request_queue = state.request_queue
|
| 29 |
+
processing_lock = state.processing_lock
|
| 30 |
+
model_switching_lock = state.model_switching_lock
|
| 31 |
+
params_cache_lock = state.params_cache_lock
|
| 32 |
+
from browser_utils.auth_rotation import perform_auth_rotation
|
| 33 |
+
from browser_utils.page_controller import PageController
|
| 34 |
+
from config.global_state import GlobalState
|
| 35 |
+
|
| 36 |
+
from .error_utils import (
|
| 37 |
+
client_cancelled,
|
| 38 |
+
client_disconnected,
|
| 39 |
+
server_error,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Internal imports for queue worker logic
|
| 43 |
+
from .request_processor import (
|
| 44 |
+
ClientDisconnectedError,
|
| 45 |
+
_process_request_refactored,
|
| 46 |
+
_test_client_connection,
|
| 47 |
+
save_error_snapshot,
|
| 48 |
+
)
|
| 49 |
+
from .utils_ext.stream import clear_stream_queue
|
| 50 |
+
|
| 51 |
+
logger.info("--- Queue Worker Started ---")
|
| 52 |
+
|
| 53 |
+
# Validate that required globals are initialized
|
| 54 |
+
if request_queue is None:
|
| 55 |
+
logger.critical("FATAL: request_queue is None! Initialization failed.")
|
| 56 |
+
raise RuntimeError("request_queue not initialized")
|
| 57 |
+
|
| 58 |
+
if processing_lock is None:
|
| 59 |
+
logger.critical("FATAL: processing_lock is None! Initialization failed.")
|
| 60 |
+
raise RuntimeError("processing_lock not initialized")
|
| 61 |
+
|
| 62 |
+
if model_switching_lock is None:
|
| 63 |
+
logger.critical("FATAL: model_switching_lock is None! Initialization failed.")
|
| 64 |
+
raise RuntimeError("model_switching_lock not initialized")
|
| 65 |
+
|
| 66 |
+
if params_cache_lock is None:
|
| 67 |
+
logger.critical("FATAL: params_cache_lock is None! Initialization failed.")
|
| 68 |
+
raise RuntimeError("params_cache_lock not initialized")
|
| 69 |
+
|
| 70 |
+
logger.debug(
|
| 71 |
+
f"Queue worker initialized with queue={request_queue}, lock={processing_lock}"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
was_last_request_streaming = False
|
| 75 |
+
last_request_completion_time = 0.0
|
| 76 |
+
shutdown_check_interval = 0.1
|
| 77 |
+
|
| 78 |
+
while True:
|
| 79 |
+
request_item: Optional[QueueItem] = None
|
| 80 |
+
result_future: Optional[Future] = None
|
| 81 |
+
http_request: Optional[Request] = None
|
| 82 |
+
req_id: str = "UNKNOWN"
|
| 83 |
+
completion_event: Optional[Event] = None
|
| 84 |
+
submit_btn_loc: Optional[Locator] = None
|
| 85 |
+
client_disco_checker: Optional[Callable[[str], bool]] = None
|
| 86 |
+
disconnect_monitor_task: Optional[Task] = None
|
| 87 |
+
client_disconnected_early: bool = False
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
# [SHUTDOWN] Check shutdown signal
|
| 91 |
+
if GlobalState.IS_SHUTTING_DOWN.is_set():
|
| 92 |
+
logger.info("🚨 Queue Worker detected shutdown signal, exiting.")
|
| 93 |
+
break
|
| 94 |
+
|
| 95 |
+
# Clean up disconnected requests in queue
|
| 96 |
+
queue_size = request_queue.qsize()
|
| 97 |
+
if queue_size > 0:
|
| 98 |
+
checked_count = 0
|
| 99 |
+
items_to_requeue = []
|
| 100 |
+
processed_ids = set()
|
| 101 |
+
|
| 102 |
+
while checked_count < queue_size and checked_count < 10:
|
| 103 |
+
if GlobalState.IS_SHUTTING_DOWN.is_set():
|
| 104 |
+
break
|
| 105 |
+
try:
|
| 106 |
+
item = request_queue.get_nowait()
|
| 107 |
+
item_req_id = item.get("req_id", "unknown")
|
| 108 |
+
if item_req_id in processed_ids:
|
| 109 |
+
items_to_requeue.append(item)
|
| 110 |
+
continue
|
| 111 |
+
processed_ids.add(item_req_id)
|
| 112 |
+
|
| 113 |
+
if not item.get("cancelled", False):
|
| 114 |
+
item_http_req = item.get("http_request")
|
| 115 |
+
if item_http_req:
|
| 116 |
+
try:
|
| 117 |
+
if not await check_client_connection(
|
| 118 |
+
item_req_id, item_http_req
|
| 119 |
+
):
|
| 120 |
+
logger.info(
|
| 121 |
+
f"[{item_req_id}] (Worker Queue Check) Client disconnect detected."
|
| 122 |
+
)
|
| 123 |
+
item["cancelled"] = True
|
| 124 |
+
item_fut = item.get("result_future")
|
| 125 |
+
if item_fut and not item_fut.done():
|
| 126 |
+
item_fut.set_exception(
|
| 127 |
+
client_disconnected(
|
| 128 |
+
item_req_id,
|
| 129 |
+
"Client disconnected while queued.",
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.error(
|
| 134 |
+
f"[{item_req_id}] (Worker Queue Check) Error: {e}"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
items_to_requeue.append(item)
|
| 138 |
+
checked_count += 1
|
| 139 |
+
except asyncio.QueueEmpty:
|
| 140 |
+
break
|
| 141 |
+
|
| 142 |
+
for item in items_to_requeue:
|
| 143 |
+
await request_queue.put(item)
|
| 144 |
+
|
| 145 |
+
# [AUTH-ROTATION] Handle quota or rotation needs
|
| 146 |
+
if GlobalState.IS_QUOTA_EXCEEDED or GlobalState.NEEDS_ROTATION:
|
| 147 |
+
reason = (
|
| 148 |
+
"Quota Exceeded"
|
| 149 |
+
if GlobalState.IS_QUOTA_EXCEEDED
|
| 150 |
+
else "Graceful Rotation Pending"
|
| 151 |
+
)
|
| 152 |
+
logger.info(f"⏸️ Pausing worker for Auth Rotation ({reason})...")
|
| 153 |
+
GlobalState.start_recovery()
|
| 154 |
+
try:
|
| 155 |
+
current_model_id = state.current_ai_studio_model_id
|
| 156 |
+
rotation_success = await perform_auth_rotation(
|
| 157 |
+
target_model_id=current_model_id or ""
|
| 158 |
+
)
|
| 159 |
+
if rotation_success:
|
| 160 |
+
GlobalState.NEEDS_ROTATION = False
|
| 161 |
+
logger.info("✅ Auth rotation completed successfully.")
|
| 162 |
+
else:
|
| 163 |
+
logger.error("❌ Auth rotation failed.")
|
| 164 |
+
await asyncio.sleep(1)
|
| 165 |
+
finally:
|
| 166 |
+
GlobalState.finish_recovery()
|
| 167 |
+
if not rotation_success:
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
if GlobalState.IS_SHUTTING_DOWN.is_set():
|
| 171 |
+
break
|
| 172 |
+
|
| 173 |
+
# Get next request
|
| 174 |
+
try:
|
| 175 |
+
current_timeout = (
|
| 176 |
+
shutdown_check_interval
|
| 177 |
+
if GlobalState.IS_SHUTTING_DOWN.is_set()
|
| 178 |
+
else 5.0
|
| 179 |
+
)
|
| 180 |
+
request_item = await asyncio.wait_for(
|
| 181 |
+
request_queue.get(), timeout=current_timeout
|
| 182 |
+
)
|
| 183 |
+
except asyncio.TimeoutError:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
if request_item is None:
|
| 187 |
+
continue
|
| 188 |
+
|
| 189 |
+
req_id = request_item["req_id"]
|
| 190 |
+
request_data = request_item["request_data"]
|
| 191 |
+
http_request = request_item["http_request"]
|
| 192 |
+
result_future = request_item["result_future"]
|
| 193 |
+
|
| 194 |
+
GlobalState.CURRENT_STREAM_REQ_ID = req_id
|
| 195 |
+
logger.info(f"[{req_id}] (Worker) Processing request dequeued.")
|
| 196 |
+
|
| 197 |
+
if GlobalState.IS_QUOTA_EXCEEDED:
|
| 198 |
+
logger.warning(f"[{req_id}] (Worker) ⛔ Quota exceeded, re-queueing.")
|
| 199 |
+
await request_queue.put(request_item)
|
| 200 |
+
request_queue.task_done()
|
| 201 |
+
continue
|
| 202 |
+
|
| 203 |
+
if request_item.get("cancelled", False):
|
| 204 |
+
if result_future and not result_future.done():
|
| 205 |
+
result_future.set_exception(
|
| 206 |
+
client_cancelled(req_id, "Request cancelled by user")
|
| 207 |
+
)
|
| 208 |
+
request_queue.task_done()
|
| 209 |
+
continue
|
| 210 |
+
|
| 211 |
+
is_streaming_request = request_data.stream
|
| 212 |
+
|
| 213 |
+
# Initial connection check
|
| 214 |
+
if not await _test_client_connection(req_id, http_request):
|
| 215 |
+
if result_future and not result_future.done():
|
| 216 |
+
result_future.set_exception(
|
| 217 |
+
HTTPException(status_code=499, detail="Client disconnected")
|
| 218 |
+
)
|
| 219 |
+
request_queue.task_done()
|
| 220 |
+
continue
|
| 221 |
+
|
| 222 |
+
# Streaming delay
|
| 223 |
+
current_time = time.time()
|
| 224 |
+
if (
|
| 225 |
+
was_last_request_streaming
|
| 226 |
+
and is_streaming_request
|
| 227 |
+
and (current_time - last_request_completion_time < 1.0)
|
| 228 |
+
):
|
| 229 |
+
await asyncio.sleep(
|
| 230 |
+
max(0.5, 1.0 - (current_time - last_request_completion_time))
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# Wait for lock
|
| 234 |
+
async with processing_lock:
|
| 235 |
+
logger.info(f"[{req_id}] (Worker) Lock acquired.")
|
| 236 |
+
|
| 237 |
+
if not await _test_client_connection(req_id, http_request):
|
| 238 |
+
if result_future and not result_future.done():
|
| 239 |
+
result_future.set_exception(
|
| 240 |
+
HTTPException(status_code=499, detail="Client disconnected")
|
| 241 |
+
)
|
| 242 |
+
elif result_future and result_future.done():
|
| 243 |
+
logger.info(f"[{req_id}] (Worker) Future already done.")
|
| 244 |
+
else:
|
| 245 |
+
try:
|
| 246 |
+
returned_value = await _process_request_refactored(
|
| 247 |
+
req_id, request_data, http_request, result_future
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
if (
|
| 251 |
+
isinstance(returned_value, tuple)
|
| 252 |
+
and len(returned_value) == 3
|
| 253 |
+
):
|
| 254 |
+
completion_event, submit_btn_loc, client_disco_checker = (
|
| 255 |
+
returned_value
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
if completion_event:
|
| 259 |
+
if isinstance(completion_event, dict):
|
| 260 |
+
if (
|
| 261 |
+
completion_event.get("done")
|
| 262 |
+
and is_streaming_request
|
| 263 |
+
):
|
| 264 |
+
if state.STREAM_QUEUE:
|
| 265 |
+
await state.STREAM_QUEUE.put(completion_event)
|
| 266 |
+
if result_future and not result_future.done():
|
| 267 |
+
result_future.set_result(completion_event)
|
| 268 |
+
client_disconnected_early = False
|
| 269 |
+
elif hasattr(completion_event, "wait"):
|
| 270 |
+
client_disconnected_early = False
|
| 271 |
+
comp_ev = cast(Event, completion_event)
|
| 272 |
+
|
| 273 |
+
async def enhanced_disconnect_monitor_fn():
|
| 274 |
+
nonlocal client_disconnected_early
|
| 275 |
+
disco_count = 0
|
| 276 |
+
while not comp_ev.is_set():
|
| 277 |
+
if GlobalState.IS_SHUTTING_DOWN.is_set():
|
| 278 |
+
comp_ev.set()
|
| 279 |
+
break
|
| 280 |
+
if (
|
| 281 |
+
GlobalState.IS_QUOTA_EXCEEDED
|
| 282 |
+
and not GlobalState.IS_RECOVERING
|
| 283 |
+
):
|
| 284 |
+
# Abort if quota exceeded and not recovering
|
| 285 |
+
client_disconnected_early = True
|
| 286 |
+
comp_ev.set()
|
| 287 |
+
break
|
| 288 |
+
|
| 289 |
+
if not await _test_client_connection(
|
| 290 |
+
req_id, http_request
|
| 291 |
+
):
|
| 292 |
+
disco_count += 1
|
| 293 |
+
if disco_count >= 3:
|
| 294 |
+
client_disconnected_early = True
|
| 295 |
+
comp_ev.set()
|
| 296 |
+
break
|
| 297 |
+
else:
|
| 298 |
+
disco_count = 0
|
| 299 |
+
await asyncio.sleep(0.2)
|
| 300 |
+
|
| 301 |
+
disconnect_monitor_task = asyncio.create_task(
|
| 302 |
+
enhanced_disconnect_monitor_fn()
|
| 303 |
+
)
|
| 304 |
+
await asyncio.wait_for(
|
| 305 |
+
comp_ev.wait(),
|
| 306 |
+
timeout=RESPONSE_COMPLETION_TIMEOUT / 1000 + 60,
|
| 307 |
+
)
|
| 308 |
+
else:
|
| 309 |
+
# Non-streaming
|
| 310 |
+
client_disconnected_early = False
|
| 311 |
+
res_fut = cast(Future, result_future)
|
| 312 |
+
|
| 313 |
+
async def non_streaming_monitor_fn():
|
| 314 |
+
nonlocal client_disconnected_early
|
| 315 |
+
while not res_fut.done():
|
| 316 |
+
if GlobalState.IS_SHUTTING_DOWN.is_set():
|
| 317 |
+
res_fut.cancel()
|
| 318 |
+
break
|
| 319 |
+
if not await _test_client_connection(
|
| 320 |
+
req_id, http_request
|
| 321 |
+
):
|
| 322 |
+
client_disconnected_early = True
|
| 323 |
+
res_fut.set_exception(
|
| 324 |
+
HTTPException(
|
| 325 |
+
status_code=499,
|
| 326 |
+
detail="Client disconnected",
|
| 327 |
+
)
|
| 328 |
+
)
|
| 329 |
+
break
|
| 330 |
+
await asyncio.sleep(0.3)
|
| 331 |
+
|
| 332 |
+
disconnect_monitor_task = asyncio.create_task(
|
| 333 |
+
non_streaming_monitor_fn()
|
| 334 |
+
)
|
| 335 |
+
await asyncio.wait_for(
|
| 336 |
+
asyncio.shield(res_fut),
|
| 337 |
+
timeout=RESPONSE_COMPLETION_TIMEOUT / 1000 + 60,
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# Post-processing button handling
|
| 341 |
+
if client_disconnected_early:
|
| 342 |
+
if submit_btn_loc:
|
| 343 |
+
try:
|
| 344 |
+
if await submit_btn_loc.is_enabled(timeout=2000):
|
| 345 |
+
await submit_btn_loc.click(
|
| 346 |
+
timeout=5000, force=True
|
| 347 |
+
)
|
| 348 |
+
except Exception:
|
| 349 |
+
pass
|
| 350 |
+
elif (
|
| 351 |
+
submit_btn_loc and client_disco_checker and completion_event
|
| 352 |
+
):
|
| 353 |
+
try:
|
| 354 |
+
client_disco_checker("Post-stream check")
|
| 355 |
+
await asyncio.sleep(0.5)
|
| 356 |
+
client_disco_checker("Post-sleep check")
|
| 357 |
+
if await submit_btn_loc.is_enabled(timeout=2000):
|
| 358 |
+
await submit_btn_loc.click(timeout=5000, force=True)
|
| 359 |
+
await expect_async(submit_btn_loc).to_be_disabled(
|
| 360 |
+
timeout=10000
|
| 361 |
+
)
|
| 362 |
+
except ClientDisconnectedError:
|
| 363 |
+
pass
|
| 364 |
+
except Exception:
|
| 365 |
+
await save_error_snapshot(f"button_timeout_{req_id}")
|
| 366 |
+
|
| 367 |
+
except QuotaExceededError:
|
| 368 |
+
raise
|
| 369 |
+
except Exception as e:
|
| 370 |
+
logger.error(f"[{req_id}] (Worker) Error: {e}")
|
| 371 |
+
if result_future and not result_future.done():
|
| 372 |
+
result_future.set_exception(
|
| 373 |
+
server_error(req_id, f"Error: {e}")
|
| 374 |
+
)
|
| 375 |
+
finally:
|
| 376 |
+
if (
|
| 377 |
+
disconnect_monitor_task
|
| 378 |
+
and not disconnect_monitor_task.done()
|
| 379 |
+
):
|
| 380 |
+
disconnect_monitor_task.cancel()
|
| 381 |
+
try:
|
| 382 |
+
await disconnect_monitor_task
|
| 383 |
+
except asyncio.CancelledError:
|
| 384 |
+
pass
|
| 385 |
+
|
| 386 |
+
# [ROTATION] Post-request rotation check
|
| 387 |
+
just_rotated = False
|
| 388 |
+
if GlobalState.NEEDS_ROTATION:
|
| 389 |
+
current_model_id_rot = state.current_ai_studio_model_id
|
| 390 |
+
if await perform_auth_rotation(
|
| 391 |
+
target_model_id=current_model_id_rot or ""
|
| 392 |
+
):
|
| 393 |
+
GlobalState.NEEDS_ROTATION = False
|
| 394 |
+
just_rotated = True
|
| 395 |
+
|
| 396 |
+
# [CLEANUP]
|
| 397 |
+
try:
|
| 398 |
+
await clear_stream_queue()
|
| 399 |
+
|
| 400 |
+
# [COOKIE-REFRESH] Save cookies after successful requests
|
| 401 |
+
if not client_disconnected_early and not GlobalState.IS_QUOTA_EXCEEDED:
|
| 402 |
+
try:
|
| 403 |
+
from browser_utils.cookie_refresh import (
|
| 404 |
+
maybe_refresh_on_request,
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
await maybe_refresh_on_request()
|
| 408 |
+
except Exception as cookie_err:
|
| 409 |
+
logger.debug(
|
| 410 |
+
f"[{req_id}] Cookie refresh error (non-critical): {cookie_err}"
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
if (
|
| 414 |
+
not GlobalState.IS_QUOTA_EXCEEDED
|
| 415 |
+
and not just_rotated
|
| 416 |
+
and not GlobalState.IS_SHUTTING_DOWN.is_set()
|
| 417 |
+
):
|
| 418 |
+
if submit_btn_loc and client_disco_checker:
|
| 419 |
+
s_page = state.page_instance
|
| 420 |
+
s_ready = state.is_page_ready
|
| 421 |
+
s_browser = state.browser_instance
|
| 422 |
+
|
| 423 |
+
if (
|
| 424 |
+
s_page
|
| 425 |
+
and s_ready
|
| 426 |
+
and s_browser
|
| 427 |
+
and s_browser.is_connected()
|
| 428 |
+
):
|
| 429 |
+
try:
|
| 430 |
+
controller = PageController(s_page, logger, req_id)
|
| 431 |
+
await controller.clear_chat_history(lambda stage: False)
|
| 432 |
+
except Exception:
|
| 433 |
+
try:
|
| 434 |
+
await s_page.reload()
|
| 435 |
+
except Exception:
|
| 436 |
+
pass
|
| 437 |
+
except Exception as e:
|
| 438 |
+
logger.error(f"[{req_id}] Cleanup error: {e}")
|
| 439 |
+
|
| 440 |
+
was_last_request_streaming = is_streaming_request
|
| 441 |
+
last_request_completion_time = time.time()
|
| 442 |
+
|
| 443 |
+
except asyncio.CancelledError:
|
| 444 |
+
if result_future and not result_future.done():
|
| 445 |
+
result_future.cancel()
|
| 446 |
+
break
|
| 447 |
+
except QuotaExceededError:
|
| 448 |
+
try:
|
| 449 |
+
if await _test_client_connection(req_id, http_request):
|
| 450 |
+
request_queue.put_nowait(request_item)
|
| 451 |
+
elif result_future and not result_future.done():
|
| 452 |
+
result_future.set_exception(
|
| 453 |
+
HTTPException(
|
| 454 |
+
status_code=499, detail="Disconnected during quota error"
|
| 455 |
+
)
|
| 456 |
+
)
|
| 457 |
+
except Exception:
|
| 458 |
+
pass
|
| 459 |
+
except Exception as e:
|
| 460 |
+
logger.error(f"[{req_id}] Unexpected error: {e}", exc_info=True)
|
| 461 |
+
if result_future and not result_future.done():
|
| 462 |
+
result_future.set_exception(server_error(req_id, f"Error: {e}"))
|
| 463 |
+
finally:
|
| 464 |
+
if request_item:
|
| 465 |
+
request_queue.task_done()
|
| 466 |
+
|
| 467 |
+
logger.info("--- Queue Worker Stopped ---")
|
api_utils/request_processor.py
ADDED
|
@@ -0,0 +1,975 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Request Processor Module
|
| 3 |
+
Contains core request processing logic
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import shutil
|
| 10 |
+
from asyncio import Event, Future
|
| 11 |
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
| 12 |
+
|
| 13 |
+
from fastapi import HTTPException, Request
|
| 14 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 15 |
+
from playwright.async_api import (
|
| 16 |
+
Error as PlaywrightAsyncError,
|
| 17 |
+
)
|
| 18 |
+
from playwright.async_api import (
|
| 19 |
+
Locator,
|
| 20 |
+
)
|
| 21 |
+
from playwright.async_api import (
|
| 22 |
+
Page as AsyncPage,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# --- browser_utils Module Imports ---
|
| 26 |
+
from browser_utils import (
|
| 27 |
+
save_error_snapshot,
|
| 28 |
+
)
|
| 29 |
+
from browser_utils.page_controller import PageController
|
| 30 |
+
|
| 31 |
+
# --- Configuration Module Imports ---
|
| 32 |
+
from config import (
|
| 33 |
+
MODEL_NAME,
|
| 34 |
+
RESPONSE_COMPLETION_TIMEOUT,
|
| 35 |
+
SUBMIT_BUTTON_SELECTOR,
|
| 36 |
+
UPLOAD_FILES_DIR,
|
| 37 |
+
get_environment_variable,
|
| 38 |
+
)
|
| 39 |
+
from config.global_state import GlobalState
|
| 40 |
+
|
| 41 |
+
# --- logging_utils Module Imports ---
|
| 42 |
+
from logging_utils import log_context
|
| 43 |
+
|
| 44 |
+
# --- models Module Imports ---
|
| 45 |
+
from models import (
|
| 46 |
+
ChatCompletionRequest,
|
| 47 |
+
ClientDisconnectedError,
|
| 48 |
+
QuotaExceededError,
|
| 49 |
+
QuotaExceededRetry,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
from .client_connection import (
|
| 53 |
+
check_client_connection as _check_client_connection,
|
| 54 |
+
)
|
| 55 |
+
from .client_connection import (
|
| 56 |
+
setup_disconnect_monitoring as _setup_disconnect_monitoring,
|
| 57 |
+
)
|
| 58 |
+
from .common_utils import random_id as _random_id
|
| 59 |
+
from .context_init import initialize_request_context as _init_request_context
|
| 60 |
+
from .context_types import RequestContext
|
| 61 |
+
from .error_utils import (
|
| 62 |
+
bad_request,
|
| 63 |
+
client_disconnected,
|
| 64 |
+
server_error,
|
| 65 |
+
upstream_error,
|
| 66 |
+
)
|
| 67 |
+
from .model_switching import (
|
| 68 |
+
analyze_model_requirements as ms_analyze,
|
| 69 |
+
)
|
| 70 |
+
from .model_switching import (
|
| 71 |
+
handle_model_switching as ms_switch,
|
| 72 |
+
)
|
| 73 |
+
from .model_switching import (
|
| 74 |
+
handle_parameter_cache as ms_param_cache,
|
| 75 |
+
)
|
| 76 |
+
from .page_response import locate_response_elements
|
| 77 |
+
from .response_generators import (
|
| 78 |
+
gen_sse_from_aux_stream,
|
| 79 |
+
gen_sse_from_playwright,
|
| 80 |
+
resilient_stream_generator,
|
| 81 |
+
)
|
| 82 |
+
from .response_payloads import build_chat_completion_response_json
|
| 83 |
+
|
| 84 |
+
# --- api_utils Module Imports ---
|
| 85 |
+
from .utils import (
|
| 86 |
+
maybe_execute_tools,
|
| 87 |
+
prepare_combined_prompt,
|
| 88 |
+
)
|
| 89 |
+
from .utils_ext.files import collect_and_validate_attachments
|
| 90 |
+
from .utils_ext.function_calling_orchestrator import (
|
| 91 |
+
FunctionCallingState,
|
| 92 |
+
get_function_calling_orchestrator,
|
| 93 |
+
)
|
| 94 |
+
from .utils_ext.stream import use_stream_response
|
| 95 |
+
from .utils_ext.tokens import calculate_usage_stats
|
| 96 |
+
from .utils_ext.usage_tracker import increment_profile_usage
|
| 97 |
+
from .utils_ext.validation import validate_chat_request
|
| 98 |
+
|
| 99 |
+
_initialize_request_context = _init_request_context
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# Wrapper function for backward compatibility
|
| 103 |
+
async def _test_client_connection(req_id: str, http_request) -> bool:
|
| 104 |
+
"""Test if client is still connected - wrapper for _check_client_connection"""
|
| 105 |
+
return await _check_client_connection(req_id, http_request)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def _analyze_model_requirements(
|
| 109 |
+
req_id: str, context: RequestContext, request: ChatCompletionRequest
|
| 110 |
+
) -> RequestContext:
|
| 111 |
+
"""Proxy to model_switching.analyze_model_requirements"""
|
| 112 |
+
return await ms_analyze(req_id, context, request.model, MODEL_NAME)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def _validate_page_status(
|
| 116 |
+
req_id: str, context: RequestContext, check_client_disconnected: Callable
|
| 117 |
+
) -> None:
|
| 118 |
+
"""Validate page status"""
|
| 119 |
+
page = context["page"]
|
| 120 |
+
is_page_ready = context["is_page_ready"]
|
| 121 |
+
|
| 122 |
+
if not page or page.is_closed() or not is_page_ready:
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=503,
|
| 125 |
+
detail=f"[{req_id}] AI Studio page lost or not ready.",
|
| 126 |
+
headers={"Retry-After": "30"},
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
check_client_disconnected("Initial Page Check")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def _handle_model_switching(
|
| 133 |
+
req_id: str, context: RequestContext, check_client_disconnected: Callable
|
| 134 |
+
) -> RequestContext:
|
| 135 |
+
"""Proxy to model_switching.handle_model_switching"""
|
| 136 |
+
return await ms_switch(req_id, context)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
async def _handle_model_switch_failure(
|
| 140 |
+
req_id: str, page: AsyncPage, model_id_to_use: str, model_before_switch: str, logger
|
| 141 |
+
) -> None:
|
| 142 |
+
"""Handle model switch failure"""
|
| 143 |
+
from api_utils.server_state import state
|
| 144 |
+
|
| 145 |
+
logger.warning(f"[{req_id}] Failed to switch model to {model_id_to_use}.")
|
| 146 |
+
# Attempt to restore global state
|
| 147 |
+
state.current_ai_studio_model_id = model_before_switch
|
| 148 |
+
|
| 149 |
+
raise HTTPException(
|
| 150 |
+
status_code=422,
|
| 151 |
+
detail=f"[{req_id}] Failed to switch to model '{model_id_to_use}'. Ensure model is available.",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
async def _handle_parameter_cache(req_id: str, context: RequestContext) -> None:
|
| 156 |
+
"""Proxy to model_switching.handle_parameter_cache"""
|
| 157 |
+
await ms_param_cache(req_id, context)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def _prepare_and_validate_request(
|
| 161 |
+
req_id: str,
|
| 162 |
+
request: ChatCompletionRequest,
|
| 163 |
+
check_client_disconnected: Callable,
|
| 164 |
+
fc_state: Optional[FunctionCallingState] = None,
|
| 165 |
+
) -> Tuple[str, List[str], Optional[List[Dict[str, Any]]]]:
|
| 166 |
+
"""Prepare and validate request, return (combined prompt, attachment path list, tool_exec_results)."""
|
| 167 |
+
try:
|
| 168 |
+
validate_chat_request(request.messages, req_id)
|
| 169 |
+
except ValueError as e:
|
| 170 |
+
raise bad_request(req_id, f"Invalid request: {e}")
|
| 171 |
+
|
| 172 |
+
prepared_prompt, attachments_list = prepare_combined_prompt(
|
| 173 |
+
request.messages,
|
| 174 |
+
req_id,
|
| 175 |
+
getattr(request, "tools", None),
|
| 176 |
+
getattr(request, "tool_choice", None),
|
| 177 |
+
fc_state=fc_state,
|
| 178 |
+
)
|
| 179 |
+
# Active function execution based on tools/tool_choice (supports per-request MCP endpoints)
|
| 180 |
+
try:
|
| 181 |
+
# Inject mcp_endpoint into utils.maybe_execute_tools registration logic
|
| 182 |
+
if hasattr(request, "mcp_endpoint") and request.mcp_endpoint:
|
| 183 |
+
from .tools_registry import register_runtime_tools
|
| 184 |
+
|
| 185 |
+
register_runtime_tools(
|
| 186 |
+
getattr(request, "tools", None), request.mcp_endpoint
|
| 187 |
+
)
|
| 188 |
+
tool_exec_results = await maybe_execute_tools(
|
| 189 |
+
request.messages, request.tools, getattr(request, "tool_choice", None)
|
| 190 |
+
)
|
| 191 |
+
except asyncio.CancelledError:
|
| 192 |
+
raise
|
| 193 |
+
except Exception:
|
| 194 |
+
tool_exec_results = None
|
| 195 |
+
|
| 196 |
+
check_client_disconnected("After Prompt Prep")
|
| 197 |
+
# Inline results at the end of the prompt for submission together
|
| 198 |
+
if tool_exec_results:
|
| 199 |
+
try:
|
| 200 |
+
for res in tool_exec_results:
|
| 201 |
+
name = res.get("name")
|
| 202 |
+
args = res.get("arguments")
|
| 203 |
+
result_str = res.get("result")
|
| 204 |
+
prepared_prompt += f"\n---\nTool Execution: {name}\nArguments:\n{args}\nResult:\n{result_str}\n"
|
| 205 |
+
except Exception:
|
| 206 |
+
pass
|
| 207 |
+
|
| 208 |
+
# Process and validate attachments
|
| 209 |
+
# Acceptance criteria: Only accept data:/file:/absolute paths provided by current request
|
| 210 |
+
final_attachments = collect_and_validate_attachments(
|
| 211 |
+
request, req_id, attachments_list
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
return prepared_prompt, final_attachments, tool_exec_results
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def _handle_response_processing(
|
| 218 |
+
req_id: str,
|
| 219 |
+
request: ChatCompletionRequest,
|
| 220 |
+
page: Optional[AsyncPage],
|
| 221 |
+
context: RequestContext,
|
| 222 |
+
result_future: Future,
|
| 223 |
+
submit_button_locator: Locator,
|
| 224 |
+
check_client_disconnected: Callable,
|
| 225 |
+
prompt_length: int,
|
| 226 |
+
timeout: float,
|
| 227 |
+
silence_threshold: float = 60.0,
|
| 228 |
+
) -> Optional[Tuple[Event, Locator, Callable]]:
|
| 229 |
+
"""Handle response generation"""
|
| 230 |
+
stream_port = get_environment_variable("STREAM_PORT")
|
| 231 |
+
use_stream = stream_port != "0"
|
| 232 |
+
|
| 233 |
+
if use_stream:
|
| 234 |
+
return await _handle_auxiliary_stream_response(
|
| 235 |
+
req_id,
|
| 236 |
+
request,
|
| 237 |
+
context,
|
| 238 |
+
result_future,
|
| 239 |
+
submit_button_locator,
|
| 240 |
+
check_client_disconnected,
|
| 241 |
+
timeout=timeout,
|
| 242 |
+
silence_threshold=silence_threshold,
|
| 243 |
+
)
|
| 244 |
+
else:
|
| 245 |
+
return await _handle_playwright_response(
|
| 246 |
+
req_id,
|
| 247 |
+
request,
|
| 248 |
+
page,
|
| 249 |
+
context,
|
| 250 |
+
result_future,
|
| 251 |
+
submit_button_locator,
|
| 252 |
+
check_client_disconnected,
|
| 253 |
+
prompt_length,
|
| 254 |
+
timeout=timeout,
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
async def _handle_auxiliary_stream_response(
|
| 259 |
+
req_id: str,
|
| 260 |
+
request: ChatCompletionRequest,
|
| 261 |
+
context: RequestContext,
|
| 262 |
+
result_future: Future[Union[StreamingResponse, JSONResponse]],
|
| 263 |
+
submit_button_locator: Locator,
|
| 264 |
+
check_client_disconnected: Callable,
|
| 265 |
+
timeout: float,
|
| 266 |
+
silence_threshold: float = 60.0,
|
| 267 |
+
) -> Optional[Tuple[Event, Locator, Callable]]:
|
| 268 |
+
"""Auxiliary stream response processing path"""
|
| 269 |
+
from api_utils.server_state import state
|
| 270 |
+
|
| 271 |
+
logger = state.logger
|
| 272 |
+
|
| 273 |
+
is_streaming = request.stream
|
| 274 |
+
current_ai_studio_model_id = context.get("current_ai_studio_model_id")
|
| 275 |
+
|
| 276 |
+
if is_streaming:
|
| 277 |
+
try:
|
| 278 |
+
completion_event = Event()
|
| 279 |
+
page = context["page"]
|
| 280 |
+
|
| 281 |
+
# [RESILIENT-WRAPPER] Wrap the stream generator with retry/rotation logic
|
| 282 |
+
def aux_stream_factory(event_to_signal: Event):
|
| 283 |
+
return gen_sse_from_aux_stream(
|
| 284 |
+
req_id,
|
| 285 |
+
request,
|
| 286 |
+
current_ai_studio_model_id or MODEL_NAME,
|
| 287 |
+
check_client_disconnected,
|
| 288 |
+
event_to_signal,
|
| 289 |
+
timeout=timeout,
|
| 290 |
+
silence_threshold=silence_threshold,
|
| 291 |
+
page=page, # <--- CRITICAL: This enables the auto-scroll logic in stream.py
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
resilient_gen = resilient_stream_generator(
|
| 295 |
+
req_id,
|
| 296 |
+
current_ai_studio_model_id or MODEL_NAME,
|
| 297 |
+
aux_stream_factory,
|
| 298 |
+
completion_event,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
if not result_future.done():
|
| 302 |
+
result_future.set_result(
|
| 303 |
+
StreamingResponse(resilient_gen, media_type="text/event-stream")
|
| 304 |
+
)
|
| 305 |
+
else:
|
| 306 |
+
if not completion_event.is_set():
|
| 307 |
+
completion_event.set()
|
| 308 |
+
|
| 309 |
+
return (
|
| 310 |
+
completion_event,
|
| 311 |
+
submit_button_locator,
|
| 312 |
+
check_client_disconnected,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
except asyncio.CancelledError:
|
| 316 |
+
if completion_event and not completion_event.is_set():
|
| 317 |
+
completion_event.set()
|
| 318 |
+
raise
|
| 319 |
+
except Exception as e:
|
| 320 |
+
logger.error(
|
| 321 |
+
f"[{req_id}] Error getting stream data from queue: {e}", exc_info=True
|
| 322 |
+
)
|
| 323 |
+
raise
|
| 324 |
+
else:
|
| 325 |
+
# Non-streaming logic using auxiliary stream
|
| 326 |
+
content = None
|
| 327 |
+
reasoning_content = None
|
| 328 |
+
functions = None
|
| 329 |
+
final_data_from_aux_stream = None
|
| 330 |
+
|
| 331 |
+
page = context["page"]
|
| 332 |
+
# Disable silence detection for non-streaming requests to prevent premature timeouts
|
| 333 |
+
async for raw_data in use_stream_response(
|
| 334 |
+
req_id,
|
| 335 |
+
page=page,
|
| 336 |
+
check_client_disconnected=check_client_disconnected,
|
| 337 |
+
timeout=timeout,
|
| 338 |
+
silence_threshold=silence_threshold,
|
| 339 |
+
enable_silence_detection=False,
|
| 340 |
+
):
|
| 341 |
+
check_client_disconnected(f"Non-streaming aux stream - loop ({req_id}): ")
|
| 342 |
+
|
| 343 |
+
if isinstance(raw_data, str):
|
| 344 |
+
try:
|
| 345 |
+
data = json.loads(raw_data)
|
| 346 |
+
except json.JSONDecodeError:
|
| 347 |
+
logger.warning(
|
| 348 |
+
f"[{req_id}] Failed to parse non-stream data JSON: {raw_data}"
|
| 349 |
+
)
|
| 350 |
+
continue
|
| 351 |
+
elif isinstance(raw_data, dict):
|
| 352 |
+
data = raw_data
|
| 353 |
+
else:
|
| 354 |
+
continue
|
| 355 |
+
|
| 356 |
+
if not isinstance(data, dict):
|
| 357 |
+
continue
|
| 358 |
+
|
| 359 |
+
final_data_from_aux_stream = data
|
| 360 |
+
if data.get("done"):
|
| 361 |
+
content = data.get("body")
|
| 362 |
+
reasoning_content = data.get("reason")
|
| 363 |
+
functions = data.get("function")
|
| 364 |
+
break
|
| 365 |
+
|
| 366 |
+
if (
|
| 367 |
+
final_data_from_aux_stream
|
| 368 |
+
and final_data_from_aux_stream.get("reason") == "internal_timeout"
|
| 369 |
+
):
|
| 370 |
+
logger.error(
|
| 371 |
+
f"[{req_id}] Non-stream request failed via aux stream: Internal Timeout"
|
| 372 |
+
)
|
| 373 |
+
raise HTTPException(
|
| 374 |
+
status_code=502,
|
| 375 |
+
detail=f"[{req_id}] Aux stream processing error (Internal Timeout)",
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
if (
|
| 379 |
+
final_data_from_aux_stream
|
| 380 |
+
and final_data_from_aux_stream.get("done") is True
|
| 381 |
+
and content is None
|
| 382 |
+
):
|
| 383 |
+
logger.error(
|
| 384 |
+
f"[{req_id}] Non-stream request completed via aux stream but no content provided"
|
| 385 |
+
)
|
| 386 |
+
raise HTTPException(
|
| 387 |
+
status_code=502,
|
| 388 |
+
detail=f"[{req_id}] Aux stream completed but no content provided",
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
model_name_for_json = current_ai_studio_model_id or MODEL_NAME
|
| 392 |
+
|
| 393 |
+
# Consolidate reasoning content with body content
|
| 394 |
+
consolidated_content = ""
|
| 395 |
+
if reasoning_content and reasoning_content.strip():
|
| 396 |
+
consolidated_content += reasoning_content.strip()
|
| 397 |
+
if content and content.strip():
|
| 398 |
+
if consolidated_content:
|
| 399 |
+
consolidated_content += "\n\n"
|
| 400 |
+
consolidated_content += content.strip()
|
| 401 |
+
|
| 402 |
+
message_payload = {"role": "assistant", "content": consolidated_content}
|
| 403 |
+
finish_reason_val = "stop"
|
| 404 |
+
|
| 405 |
+
if functions and len(functions) > 0:
|
| 406 |
+
tool_calls_list: List[Dict[str, Any]] = []
|
| 407 |
+
for func_idx, function_call_data in enumerate(functions):
|
| 408 |
+
tool_calls_list.append(
|
| 409 |
+
{
|
| 410 |
+
"id": f"call_{_random_id()}",
|
| 411 |
+
"index": func_idx,
|
| 412 |
+
"type": "function",
|
| 413 |
+
"function": {
|
| 414 |
+
"name": function_call_data["name"],
|
| 415 |
+
"arguments": json.dumps(function_call_data["params"]),
|
| 416 |
+
},
|
| 417 |
+
}
|
| 418 |
+
)
|
| 419 |
+
message_payload["tool_calls"] = tool_calls_list
|
| 420 |
+
finish_reason_val = "tool_calls"
|
| 421 |
+
message_payload["content"] = None
|
| 422 |
+
|
| 423 |
+
usage_stats = calculate_usage_stats(
|
| 424 |
+
[msg.model_dump() for msg in request.messages],
|
| 425 |
+
consolidated_content or "",
|
| 426 |
+
"",
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
total_tokens = usage_stats.get("total_tokens", 0)
|
| 430 |
+
GlobalState.increment_token_count(total_tokens)
|
| 431 |
+
|
| 432 |
+
from api_utils.server_state import state
|
| 433 |
+
|
| 434 |
+
if (
|
| 435 |
+
hasattr(state, "current_auth_profile_path")
|
| 436 |
+
and state.current_auth_profile_path
|
| 437 |
+
):
|
| 438 |
+
await increment_profile_usage(state.current_auth_profile_path, total_tokens)
|
| 439 |
+
|
| 440 |
+
response_payload = build_chat_completion_response_json(
|
| 441 |
+
req_id,
|
| 442 |
+
model_name_for_json,
|
| 443 |
+
message_payload,
|
| 444 |
+
finish_reason_val,
|
| 445 |
+
usage_stats,
|
| 446 |
+
system_fingerprint="camoufox-proxy",
|
| 447 |
+
seed=request.seed
|
| 448 |
+
if hasattr(request, "seed") and request.seed is not None
|
| 449 |
+
else 0,
|
| 450 |
+
response_format=(
|
| 451 |
+
request.response_format
|
| 452 |
+
if hasattr(request, "response_format")
|
| 453 |
+
and isinstance(request.response_format, dict)
|
| 454 |
+
else {}
|
| 455 |
+
),
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
if not result_future.done():
|
| 459 |
+
response_json_str = json.dumps(response_payload, ensure_ascii=False)
|
| 460 |
+
if len(response_json_str) > 10000: # 10KB threshold
|
| 461 |
+
logger.info(
|
| 462 |
+
f"[{req_id}] Large response detected ({len(response_json_str)} chars), using efficient chunking"
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
async def generate_json_chunks():
|
| 466 |
+
chunk_size = 8192 # 8KB chunks
|
| 467 |
+
for i in range(0, len(response_json_str), chunk_size):
|
| 468 |
+
chunk = response_json_str[i : i + chunk_size]
|
| 469 |
+
yield chunk
|
| 470 |
+
await asyncio.sleep(0.01)
|
| 471 |
+
|
| 472 |
+
result_future.set_result(
|
| 473 |
+
StreamingResponse(
|
| 474 |
+
generate_json_chunks(), media_type="application/json"
|
| 475 |
+
)
|
| 476 |
+
)
|
| 477 |
+
else:
|
| 478 |
+
result_future.set_result(JSONResponse(content=response_payload))
|
| 479 |
+
return response_payload
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
async def _handle_playwright_response(
|
| 483 |
+
req_id: str,
|
| 484 |
+
request: ChatCompletionRequest,
|
| 485 |
+
page: AsyncPage,
|
| 486 |
+
context: dict,
|
| 487 |
+
result_future: Future,
|
| 488 |
+
submit_button_locator: Locator,
|
| 489 |
+
check_client_disconnected: Callable,
|
| 490 |
+
prompt_length: int,
|
| 491 |
+
timeout: float,
|
| 492 |
+
) -> Optional[Tuple[Event, Locator, Callable]]:
|
| 493 |
+
"""Handle response using Playwright - Enhanced version with integrity verification"""
|
| 494 |
+
from api_utils.server_state import state
|
| 495 |
+
|
| 496 |
+
logger = state.logger
|
| 497 |
+
|
| 498 |
+
is_streaming = request.stream
|
| 499 |
+
current_ai_studio_model_id = context.get("current_ai_studio_model_id")
|
| 500 |
+
|
| 501 |
+
await locate_response_elements(page, req_id, logger, check_client_disconnected)
|
| 502 |
+
check_client_disconnected("After Response Element Located: ")
|
| 503 |
+
|
| 504 |
+
if is_streaming:
|
| 505 |
+
completion_event = Event()
|
| 506 |
+
|
| 507 |
+
def playwright_stream_factory(event_to_signal: Event):
|
| 508 |
+
return gen_sse_from_playwright(
|
| 509 |
+
page,
|
| 510 |
+
logger,
|
| 511 |
+
req_id,
|
| 512 |
+
current_ai_studio_model_id or MODEL_NAME,
|
| 513 |
+
request,
|
| 514 |
+
check_client_disconnected,
|
| 515 |
+
event_to_signal,
|
| 516 |
+
prompt_length=prompt_length,
|
| 517 |
+
timeout=timeout,
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
resilient_gen = resilient_stream_generator(
|
| 521 |
+
req_id,
|
| 522 |
+
current_ai_studio_model_id or MODEL_NAME,
|
| 523 |
+
playwright_stream_factory,
|
| 524 |
+
completion_event,
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
if not result_future.done():
|
| 528 |
+
result_future.set_result(
|
| 529 |
+
StreamingResponse(resilient_gen, media_type="text/event-stream")
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 533 |
+
else:
|
| 534 |
+
page_controller = PageController(page, logger, req_id)
|
| 535 |
+
response_data = await page_controller.get_response_with_integrity_check(
|
| 536 |
+
check_client_disconnected, prompt_length, timeout=timeout
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
final_content = response_data.get("content", "")
|
| 540 |
+
reasoning_content = response_data.get("reasoning_content", "")
|
| 541 |
+
recovery_method = response_data.get("recovery_method", "direct")
|
| 542 |
+
|
| 543 |
+
if recovery_method == "integrity_verification":
|
| 544 |
+
logger.info(
|
| 545 |
+
f"[{req_id}] Successfully recovered content via integrity verification ({len(final_content)} chars)"
|
| 546 |
+
)
|
| 547 |
+
await save_error_snapshot(
|
| 548 |
+
f"integrity_recovery_success_{req_id}",
|
| 549 |
+
extra_context={
|
| 550 |
+
"content_length": len(final_content),
|
| 551 |
+
"reasoning_length": len(reasoning_content),
|
| 552 |
+
"recovery_trigger": response_data.get("trigger_reason", ""),
|
| 553 |
+
},
|
| 554 |
+
)
|
| 555 |
+
elif recovery_method == "direct":
|
| 556 |
+
logger.info(
|
| 557 |
+
f"[{req_id}] Successfully retrieved content directly ({len(final_content)} chars)"
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
consolidated_content = ""
|
| 561 |
+
if reasoning_content and reasoning_content.strip():
|
| 562 |
+
consolidated_content += reasoning_content.strip()
|
| 563 |
+
if final_content and final_content.strip():
|
| 564 |
+
if consolidated_content:
|
| 565 |
+
consolidated_content += "\n\n"
|
| 566 |
+
consolidated_content += final_content.strip()
|
| 567 |
+
|
| 568 |
+
usage_stats = calculate_usage_stats(
|
| 569 |
+
[msg.model_dump() for msg in request.messages],
|
| 570 |
+
consolidated_content,
|
| 571 |
+
"",
|
| 572 |
+
)
|
| 573 |
+
logger.info(f"[{req_id}] Token usage stats: {usage_stats}")
|
| 574 |
+
|
| 575 |
+
total_tokens = usage_stats.get("total_tokens", 0)
|
| 576 |
+
GlobalState.increment_token_count(total_tokens)
|
| 577 |
+
|
| 578 |
+
from api_utils.server_state import state
|
| 579 |
+
|
| 580 |
+
if (
|
| 581 |
+
hasattr(state, "current_auth_profile_path")
|
| 582 |
+
and state.current_auth_profile_path
|
| 583 |
+
):
|
| 584 |
+
await increment_profile_usage(state.current_auth_profile_path, total_tokens)
|
| 585 |
+
|
| 586 |
+
model_name_for_json = current_ai_studio_model_id or MODEL_NAME
|
| 587 |
+
|
| 588 |
+
# Handle function calls if detected
|
| 589 |
+
if response_data.get("has_function_calls"):
|
| 590 |
+
from api_utils.utils_ext.function_calling_orchestrator import (
|
| 591 |
+
get_function_calling_orchestrator,
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
orchestrator = get_function_calling_orchestrator()
|
| 595 |
+
message_payload, finish_reason_val = (
|
| 596 |
+
orchestrator.format_function_calls_for_response(
|
| 597 |
+
response_data.get("function_calls", []), consolidated_content
|
| 598 |
+
)
|
| 599 |
+
)
|
| 600 |
+
else:
|
| 601 |
+
message_payload = {"role": "assistant", "content": consolidated_content}
|
| 602 |
+
finish_reason_val = "stop"
|
| 603 |
+
|
| 604 |
+
response_payload = build_chat_completion_response_json(
|
| 605 |
+
req_id,
|
| 606 |
+
model_name_for_json,
|
| 607 |
+
message_payload,
|
| 608 |
+
finish_reason_val,
|
| 609 |
+
usage_stats,
|
| 610 |
+
system_fingerprint="camoufox-proxy",
|
| 611 |
+
seed=request.seed
|
| 612 |
+
if hasattr(request, "seed") and request.seed is not None
|
| 613 |
+
else 0,
|
| 614 |
+
response_format=(
|
| 615 |
+
request.response_format
|
| 616 |
+
if hasattr(request, "response_format")
|
| 617 |
+
and isinstance(request.response_format, dict)
|
| 618 |
+
else {}
|
| 619 |
+
),
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
if not result_future.done():
|
| 623 |
+
response_json_str = json.dumps(response_payload, ensure_ascii=False)
|
| 624 |
+
if len(response_json_str) > 10000:
|
| 625 |
+
|
| 626 |
+
async def generate_json_chunks():
|
| 627 |
+
chunk_size = 8192
|
| 628 |
+
for i in range(0, len(response_json_str), chunk_size):
|
| 629 |
+
yield response_json_str[i : i + chunk_size]
|
| 630 |
+
await asyncio.sleep(0.01)
|
| 631 |
+
|
| 632 |
+
result_future.set_result(
|
| 633 |
+
StreamingResponse(
|
| 634 |
+
generate_json_chunks(), media_type="application/json"
|
| 635 |
+
)
|
| 636 |
+
)
|
| 637 |
+
else:
|
| 638 |
+
result_future.set_result(JSONResponse(content=response_payload))
|
| 639 |
+
|
| 640 |
+
return response_payload
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
async def _cleanup_request_resources(
|
| 644 |
+
req_id: str,
|
| 645 |
+
disconnect_check_task: Optional[asyncio.Task],
|
| 646 |
+
completion_event: Optional[Event],
|
| 647 |
+
result_future: Future,
|
| 648 |
+
is_streaming: bool,
|
| 649 |
+
) -> None:
|
| 650 |
+
"""Cleanup request resources"""
|
| 651 |
+
from api_utils.server_state import state
|
| 652 |
+
|
| 653 |
+
logger = state.logger
|
| 654 |
+
|
| 655 |
+
if disconnect_check_task and not disconnect_check_task.done():
|
| 656 |
+
disconnect_check_task.cancel()
|
| 657 |
+
try:
|
| 658 |
+
await disconnect_check_task
|
| 659 |
+
except asyncio.CancelledError:
|
| 660 |
+
pass
|
| 661 |
+
|
| 662 |
+
# Clean up upload subdirectory
|
| 663 |
+
try:
|
| 664 |
+
req_dir = os.path.join(UPLOAD_FILES_DIR, req_id)
|
| 665 |
+
if os.path.isdir(req_dir):
|
| 666 |
+
shutil.rmtree(req_dir, ignore_errors=True)
|
| 667 |
+
logger.debug(f"Cleaned up request upload directory: {req_dir}")
|
| 668 |
+
except asyncio.CancelledError:
|
| 669 |
+
raise
|
| 670 |
+
except Exception as clean_err:
|
| 671 |
+
logger.warning(f"[{req_id}] Failed to clean up upload directory: {clean_err}")
|
| 672 |
+
|
| 673 |
+
if (
|
| 674 |
+
is_streaming
|
| 675 |
+
and completion_event
|
| 676 |
+
and not completion_event.is_set()
|
| 677 |
+
and (result_future.done() and result_future.exception() is not None)
|
| 678 |
+
):
|
| 679 |
+
logger.warning(
|
| 680 |
+
f"[{req_id}] Stream request exception, ensuring completion event is set."
|
| 681 |
+
)
|
| 682 |
+
completion_event.set()
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
async def process_request_with_retry(
|
| 686 |
+
req_id: str,
|
| 687 |
+
request: ChatCompletionRequest,
|
| 688 |
+
http_request: Request,
|
| 689 |
+
result_future: Future,
|
| 690 |
+
) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]:
|
| 691 |
+
"""Wrapper around _process_request_refactored with retry mechanism for quota"""
|
| 692 |
+
from api_utils.server_state import state
|
| 693 |
+
|
| 694 |
+
logger = state.logger
|
| 695 |
+
|
| 696 |
+
max_retries = 3
|
| 697 |
+
attempt = 0
|
| 698 |
+
while attempt < max_retries:
|
| 699 |
+
attempt += 1
|
| 700 |
+
try:
|
| 701 |
+
return await _process_request_refactored(
|
| 702 |
+
req_id, request, http_request, result_future
|
| 703 |
+
)
|
| 704 |
+
except QuotaExceededRetry:
|
| 705 |
+
logger.warning(
|
| 706 |
+
f"[{req_id}] Quota wall hit (attempt {attempt}/{max_retries}). Waiting for rotation..."
|
| 707 |
+
)
|
| 708 |
+
await GlobalState.rotation_complete_event.wait()
|
| 709 |
+
logger.info(f"[{req_id}] Rotation complete. Retrying request.")
|
| 710 |
+
continue
|
| 711 |
+
logger.error(f"[{req_id}] Request failed after {max_retries} retries due to quota.")
|
| 712 |
+
raise Exception(f"Request failed after {max_retries} retries due to quota issues.")
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
async def process_request(
|
| 716 |
+
req_id: str,
|
| 717 |
+
request: ChatCompletionRequest,
|
| 718 |
+
http_request: Request,
|
| 719 |
+
result_future: Future,
|
| 720 |
+
) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]:
|
| 721 |
+
"""Main entry point for request processing"""
|
| 722 |
+
return await process_request_with_retry(
|
| 723 |
+
req_id, request, http_request, result_future
|
| 724 |
+
)
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
async def _process_request_refactored(
|
| 728 |
+
req_id: str,
|
| 729 |
+
request: ChatCompletionRequest,
|
| 730 |
+
http_request: Request,
|
| 731 |
+
result_future: Future,
|
| 732 |
+
) -> Optional[Tuple[Event, Locator, Callable[[str], bool]]]:
|
| 733 |
+
"""Core Request Processing Function - Refactored Version"""
|
| 734 |
+
from api_utils.server_state import state
|
| 735 |
+
|
| 736 |
+
logger = state.logger
|
| 737 |
+
|
| 738 |
+
# 0. Check Auth Rotation Lock
|
| 739 |
+
if not GlobalState.AUTH_ROTATION_LOCK.is_set():
|
| 740 |
+
logger.info(f"[{req_id}] Request held: Waiting for auth rotation...")
|
| 741 |
+
await GlobalState.AUTH_ROTATION_LOCK.wait()
|
| 742 |
+
logger.info(f"[{req_id}] ▶️ Resuming after Auth Rotation.")
|
| 743 |
+
|
| 744 |
+
# [GR-03] Pre-Flight Graceful Rotation Check
|
| 745 |
+
if GlobalState.NEEDS_ROTATION:
|
| 746 |
+
logger.info(f"[{req_id}] 🔄 Graceful Rotation Pending. Initiating rotation...")
|
| 747 |
+
from api_utils.server_state import state
|
| 748 |
+
|
| 749 |
+
current_model_id = state.current_ai_studio_model_id
|
| 750 |
+
from browser_utils.auth_rotation import perform_auth_rotation
|
| 751 |
+
|
| 752 |
+
if await perform_auth_rotation(target_model_id=current_model_id):
|
| 753 |
+
GlobalState.NEEDS_ROTATION = False
|
| 754 |
+
logger.info(f"[{req_id}] ✅ Pre-flight rotation complete.")
|
| 755 |
+
|
| 756 |
+
is_connected = await _test_client_connection(req_id, http_request)
|
| 757 |
+
if not is_connected:
|
| 758 |
+
logger.info(f"[{req_id}] Client disconnected before processing.")
|
| 759 |
+
if not result_future.done():
|
| 760 |
+
result_future.set_exception(
|
| 761 |
+
HTTPException(status_code=499, detail="Client disconnected")
|
| 762 |
+
)
|
| 763 |
+
return None
|
| 764 |
+
|
| 765 |
+
stream_port = get_environment_variable("STREAM_PORT")
|
| 766 |
+
use_stream = stream_port != "0"
|
| 767 |
+
if use_stream:
|
| 768 |
+
try:
|
| 769 |
+
from api_utils import clear_stream_queue
|
| 770 |
+
|
| 771 |
+
await clear_stream_queue()
|
| 772 |
+
except asyncio.CancelledError:
|
| 773 |
+
raise
|
| 774 |
+
except Exception as clear_err:
|
| 775 |
+
logger.warning(f"[Stream] Error clearing queue: {clear_err}")
|
| 776 |
+
|
| 777 |
+
context = await _initialize_request_context(req_id, request)
|
| 778 |
+
context = await _analyze_model_requirements(req_id, context, request)
|
| 779 |
+
|
| 780 |
+
(
|
| 781 |
+
_,
|
| 782 |
+
disconnect_check_task,
|
| 783 |
+
check_client_disconnected,
|
| 784 |
+
) = await _setup_disconnect_monitoring(req_id, http_request, result_future)
|
| 785 |
+
|
| 786 |
+
page = context["page"]
|
| 787 |
+
submit_button_locator = page.locator(SUBMIT_BUTTON_SELECTOR) if page else None
|
| 788 |
+
completion_event = None
|
| 789 |
+
|
| 790 |
+
try:
|
| 791 |
+
await _validate_page_status(req_id, context, check_client_disconnected)
|
| 792 |
+
if page is None:
|
| 793 |
+
raise server_error(req_id, "Page is None")
|
| 794 |
+
|
| 795 |
+
page_controller = PageController(page, context["logger"], req_id)
|
| 796 |
+
await _handle_model_switching(req_id, context, check_client_disconnected)
|
| 797 |
+
await _handle_parameter_cache(req_id, context)
|
| 798 |
+
|
| 799 |
+
# --- Native Function Calling Setup (Phase 3) ---
|
| 800 |
+
# Configure native function calling if mode is native/auto and tools are present
|
| 801 |
+
fc_orchestrator = get_function_calling_orchestrator()
|
| 802 |
+
fc_state: Optional[FunctionCallingState] = None
|
| 803 |
+
|
| 804 |
+
if getattr(request, "tools", None):
|
| 805 |
+
try:
|
| 806 |
+
fc_state = await fc_orchestrator.prepare_request(
|
| 807 |
+
tools=request.tools,
|
| 808 |
+
tool_choice=getattr(request, "tool_choice", None),
|
| 809 |
+
page_controller=page_controller,
|
| 810 |
+
check_client_disconnected=check_client_disconnected,
|
| 811 |
+
req_id=req_id,
|
| 812 |
+
)
|
| 813 |
+
except Exception as fc_err:
|
| 814 |
+
logger.warning(
|
| 815 |
+
f"[{req_id}] Function calling setup failed: {fc_err}, continuing with emulated mode"
|
| 816 |
+
)
|
| 817 |
+
# Continue with request - fallback to emulated mode happens in prepare_combined_prompt
|
| 818 |
+
|
| 819 |
+
(
|
| 820 |
+
prepared_prompt,
|
| 821 |
+
attachments_list,
|
| 822 |
+
tool_exec_results,
|
| 823 |
+
) = await _prepare_and_validate_request(
|
| 824 |
+
req_id, request, check_client_disconnected, fc_state=fc_state
|
| 825 |
+
)
|
| 826 |
+
|
| 827 |
+
# [TOOL-FORCED] If tool was executed locally (forced), return immediately bypassing AI Studio flow
|
| 828 |
+
if tool_exec_results:
|
| 829 |
+
logger.info(
|
| 830 |
+
f"[{req_id}] Active tool execution detected, returning results immediately."
|
| 831 |
+
)
|
| 832 |
+
tool_calls_list = []
|
| 833 |
+
for res in tool_exec_results:
|
| 834 |
+
tool_calls_list.append(
|
| 835 |
+
{
|
| 836 |
+
"id": f"call_{_random_id()}",
|
| 837 |
+
"type": "function",
|
| 838 |
+
"function": {
|
| 839 |
+
"name": res["name"],
|
| 840 |
+
"arguments": res["arguments"],
|
| 841 |
+
},
|
| 842 |
+
}
|
| 843 |
+
)
|
| 844 |
+
|
| 845 |
+
message_payload = {
|
| 846 |
+
"role": "assistant",
|
| 847 |
+
"content": None,
|
| 848 |
+
"tool_calls": tool_calls_list,
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
usage_stats = calculate_usage_stats(
|
| 852 |
+
[msg.model_dump() for msg in request.messages],
|
| 853 |
+
"",
|
| 854 |
+
"",
|
| 855 |
+
)
|
| 856 |
+
|
| 857 |
+
response_payload = build_chat_completion_response_json(
|
| 858 |
+
req_id,
|
| 859 |
+
request.model or MODEL_NAME,
|
| 860 |
+
message_payload,
|
| 861 |
+
"tool_calls",
|
| 862 |
+
usage_stats,
|
| 863 |
+
seed=request.seed
|
| 864 |
+
if hasattr(request, "seed") and request.seed is not None
|
| 865 |
+
else 0,
|
| 866 |
+
)
|
| 867 |
+
|
| 868 |
+
if not result_future.done():
|
| 869 |
+
result_future.set_result(JSONResponse(content=response_payload))
|
| 870 |
+
|
| 871 |
+
# Return dummy event for forced tool execution to satisfy type requirement
|
| 872 |
+
dummy_event = Event()
|
| 873 |
+
dummy_event.set()
|
| 874 |
+
return dummy_event, submit_button_locator, check_client_disconnected
|
| 875 |
+
|
| 876 |
+
request_params = request.model_dump(exclude_none=True)
|
| 877 |
+
if "stop" in request.model_fields_set and request.stop is None:
|
| 878 |
+
request_params["stop"] = None
|
| 879 |
+
|
| 880 |
+
with log_context("Adjusting Parameters", context["logger"], silent=True):
|
| 881 |
+
await page_controller.adjust_parameters(
|
| 882 |
+
request_params,
|
| 883 |
+
context["page_params_cache"],
|
| 884 |
+
context["params_cache_lock"],
|
| 885 |
+
context["model_id_to_use"],
|
| 886 |
+
context["parsed_model_list"],
|
| 887 |
+
check_client_disconnected,
|
| 888 |
+
)
|
| 889 |
+
|
| 890 |
+
check_client_disconnected("Final check before submitting prompt")
|
| 891 |
+
|
| 892 |
+
with log_context("Execution", context["logger"], silent=True):
|
| 893 |
+
await page_controller.submit_prompt(
|
| 894 |
+
prepared_prompt, attachments_list, check_client_disconnected
|
| 895 |
+
)
|
| 896 |
+
|
| 897 |
+
# Sync page reference if changed
|
| 898 |
+
if page_controller.page != page:
|
| 899 |
+
logger.info(f"[{req_id}] Page updated, syncing references...")
|
| 900 |
+
page = page_controller.page
|
| 901 |
+
context["page"] = page
|
| 902 |
+
submit_button_locator = page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 903 |
+
|
| 904 |
+
calc_timeout = 5.0 + (len(prepared_prompt) / 1000.0)
|
| 905 |
+
config_timeout = RESPONSE_COMPLETION_TIMEOUT / 1000.0
|
| 906 |
+
dynamic_timeout = max(calc_timeout, config_timeout)
|
| 907 |
+
dynamic_silence_threshold = max(60.0, dynamic_timeout / 2.0)
|
| 908 |
+
|
| 909 |
+
logger.info(
|
| 910 |
+
f"[{req_id}] Dynamic timeout: {dynamic_timeout:.2f}s, silence threshold: {dynamic_silence_threshold:.2f}s"
|
| 911 |
+
)
|
| 912 |
+
|
| 913 |
+
response_result = await _handle_response_processing(
|
| 914 |
+
req_id,
|
| 915 |
+
request,
|
| 916 |
+
page,
|
| 917 |
+
context,
|
| 918 |
+
result_future,
|
| 919 |
+
submit_button_locator,
|
| 920 |
+
check_client_disconnected,
|
| 921 |
+
len(prepared_prompt),
|
| 922 |
+
timeout=dynamic_timeout,
|
| 923 |
+
silence_threshold=dynamic_silence_threshold,
|
| 924 |
+
)
|
| 925 |
+
|
| 926 |
+
if response_result:
|
| 927 |
+
if isinstance(response_result, dict):
|
| 928 |
+
return response_result, submit_button_locator, check_client_disconnected
|
| 929 |
+
if isinstance(response_result, tuple):
|
| 930 |
+
completion_event, _, _ = response_result
|
| 931 |
+
return (
|
| 932 |
+
completion_event,
|
| 933 |
+
submit_button_locator,
|
| 934 |
+
check_client_disconnected,
|
| 935 |
+
)
|
| 936 |
+
|
| 937 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 938 |
+
|
| 939 |
+
except ClientDisconnectedError as disco_err:
|
| 940 |
+
logger.info(f"[{req_id}] Client disconnected: {disco_err}")
|
| 941 |
+
if not result_future.done():
|
| 942 |
+
result_future.set_exception(client_disconnected(req_id, "Disconnected"))
|
| 943 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 944 |
+
except HTTPException as http_err:
|
| 945 |
+
logger.warning(f"[{req_id}] HTTP exception: {http_err.status_code}")
|
| 946 |
+
if not result_future.done():
|
| 947 |
+
result_future.set_exception(http_err)
|
| 948 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 949 |
+
except QuotaExceededError as quota_err:
|
| 950 |
+
logger.warning(f"[{req_id}] Quota Exceeded: {quota_err}")
|
| 951 |
+
if not GlobalState.IS_QUOTA_EXCEEDED:
|
| 952 |
+
GlobalState.set_quota_exceeded(message=str(quota_err))
|
| 953 |
+
raise quota_err
|
| 954 |
+
except PlaywrightAsyncError as pw_err:
|
| 955 |
+
logger.error(f"[{req_id}] Playwright error: {pw_err}")
|
| 956 |
+
await save_error_snapshot(f"process_pw_error_{req_id}")
|
| 957 |
+
if not result_future.done():
|
| 958 |
+
result_future.set_exception(
|
| 959 |
+
upstream_error(req_id, f"Interaction failed: {pw_err}")
|
| 960 |
+
)
|
| 961 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 962 |
+
except Exception as e:
|
| 963 |
+
logger.exception(f"[{req_id}] Unexpected error")
|
| 964 |
+
await save_error_snapshot(f"process_error_{req_id}")
|
| 965 |
+
if not result_future.done():
|
| 966 |
+
result_future.set_exception(server_error(req_id, str(e)))
|
| 967 |
+
return completion_event, submit_button_locator, check_client_disconnected
|
| 968 |
+
finally:
|
| 969 |
+
await _cleanup_request_resources(
|
| 970 |
+
req_id,
|
| 971 |
+
disconnect_check_task,
|
| 972 |
+
completion_event,
|
| 973 |
+
result_future,
|
| 974 |
+
request.stream or False,
|
| 975 |
+
)
|
api_utils/response_generators.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import random
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
from asyncio import Event
|
| 8 |
+
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, cast
|
| 9 |
+
|
| 10 |
+
from playwright.async_api import Page as AsyncPage
|
| 11 |
+
|
| 12 |
+
from api_utils.utils_ext.usage_tracker import increment_profile_usage
|
| 13 |
+
from config import CHAT_COMPLETION_ID_PREFIX
|
| 14 |
+
from config.global_state import GlobalState
|
| 15 |
+
from logging_utils import set_request_id
|
| 16 |
+
from models import (
|
| 17 |
+
ChatCompletionRequest,
|
| 18 |
+
ClientDisconnectedError,
|
| 19 |
+
QuotaExceededError,
|
| 20 |
+
QuotaExceededRetry,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
from .common_utils import random_id
|
| 24 |
+
from .sse import generate_sse_chunk, generate_sse_stop_chunk
|
| 25 |
+
from .utils_ext.stream import use_stream_response
|
| 26 |
+
from .utils_ext.tokens import calculate_usage_stats
|
| 27 |
+
|
| 28 |
+
# Pattern to strip emulated function call text from body content
|
| 29 |
+
# This prevents "Request function call: ..." from being sent as text content
|
| 30 |
+
_FUNCTION_CALL_TEXT_PATTERN = re.compile(
|
| 31 |
+
r"Request\s+function\s+call:\s*[^\n]+(?:\n(?:Parameters:\s*)?\s*\{[\s\S]*?\})?",
|
| 32 |
+
re.IGNORECASE,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Pattern to strip control characters like <ctrl46> from body content
|
| 36 |
+
# These appear in AI Studio's wire format as string delimiters
|
| 37 |
+
# Also captures trailing } or { that may follow control chars (JSON leak artifacts)
|
| 38 |
+
_CONTROL_CHAR_PATTERN = re.compile(r"<ctrl\d+>[\}\{]?")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _clean_body_text(body: str) -> str:
|
| 42 |
+
"""Clean body text by removing control characters and JSON artifacts."""
|
| 43 |
+
if not body:
|
| 44 |
+
return body
|
| 45 |
+
return _CONTROL_CHAR_PATTERN.sub("", body)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def resilient_stream_generator(
|
| 49 |
+
req_id: str,
|
| 50 |
+
model_name: str,
|
| 51 |
+
generator_factory: Callable[[Event], AsyncGenerator[str, None]],
|
| 52 |
+
completion_event: Event,
|
| 53 |
+
) -> AsyncGenerator[str, None]:
|
| 54 |
+
"""
|
| 55 |
+
Wraps a stream generator with resiliency logic.
|
| 56 |
+
Handles QuotaExceededError by triggering auth rotation and retrying.
|
| 57 |
+
"""
|
| 58 |
+
from api_utils.server_state import state
|
| 59 |
+
|
| 60 |
+
logger = state.logger
|
| 61 |
+
from browser_utils.auth_rotation import perform_auth_rotation
|
| 62 |
+
|
| 63 |
+
max_retries = 3
|
| 64 |
+
retry_count = 0
|
| 65 |
+
|
| 66 |
+
inner_event = Event()
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
while retry_count <= max_retries:
|
| 70 |
+
try:
|
| 71 |
+
if inner_event.is_set():
|
| 72 |
+
inner_event.clear()
|
| 73 |
+
|
| 74 |
+
async for chunk in generator_factory(inner_event):
|
| 75 |
+
yield chunk
|
| 76 |
+
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
except (QuotaExceededError, QuotaExceededRetry) as e:
|
| 80 |
+
retry_count += 1
|
| 81 |
+
if retry_count > max_retries:
|
| 82 |
+
logger.error(
|
| 83 |
+
f"[{req_id}] Max retries ({max_retries}) exhausted for quota recovery."
|
| 84 |
+
)
|
| 85 |
+
yield f"data: {json.dumps({'error': 'Max retries exhausted for quota recovery.'}, ensure_ascii=False)}\n\n"
|
| 86 |
+
return
|
| 87 |
+
|
| 88 |
+
logger.warning(
|
| 89 |
+
f"[{req_id}] Quota limit hit during stream: {str(e)}. Initiating rotation (Attempt {retry_count}/{max_retries})..."
|
| 90 |
+
)
|
| 91 |
+
yield f": processing auth rotation (attempt {retry_count})...\n\n"
|
| 92 |
+
|
| 93 |
+
rotation_task = asyncio.create_task(
|
| 94 |
+
perform_auth_rotation(target_model_id=model_name)
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
rotation_start = time.time()
|
| 98 |
+
while not rotation_task.done():
|
| 99 |
+
if time.time() - rotation_start > 120:
|
| 100 |
+
logger.error(f"[{req_id}] Rotation timed out.")
|
| 101 |
+
yield f"data: {json.dumps({'error': 'Auth rotation timed out.'}, ensure_ascii=False)}\n\n"
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
yield ": processing auth rotation...\n\n"
|
| 105 |
+
await asyncio.sleep(2)
|
| 106 |
+
|
| 107 |
+
success = await rotation_task
|
| 108 |
+
if success:
|
| 109 |
+
logger.info(
|
| 110 |
+
f"[{req_id}] Auth rotation successful. Retrying stream generation..."
|
| 111 |
+
)
|
| 112 |
+
yield ": auth rotation complete, retrying...\n\n"
|
| 113 |
+
continue
|
| 114 |
+
else:
|
| 115 |
+
logger.error(f"[{req_id}] Auth rotation failed.")
|
| 116 |
+
yield f"data: {json.dumps({'error': 'Auth rotation failed.'}, ensure_ascii=False)}\n\n"
|
| 117 |
+
return
|
| 118 |
+
except Exception:
|
| 119 |
+
raise
|
| 120 |
+
finally:
|
| 121 |
+
if not completion_event.is_set():
|
| 122 |
+
completion_event.set()
|
| 123 |
+
logger.info(f"[{req_id}] Resilient stream completion event set")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
async def gen_sse_from_aux_stream(
|
| 127 |
+
req_id: str,
|
| 128 |
+
request: ChatCompletionRequest,
|
| 129 |
+
model_name_for_stream: str,
|
| 130 |
+
check_client_disconnected: Callable[[str], bool],
|
| 131 |
+
event_to_set: Event,
|
| 132 |
+
timeout: float,
|
| 133 |
+
silence_threshold: float = 60.0,
|
| 134 |
+
page: Optional[AsyncPage] = None,
|
| 135 |
+
stream_state: Optional[Dict[str, Any]] = None,
|
| 136 |
+
) -> AsyncGenerator[str, None]:
|
| 137 |
+
"""Auxiliary stream queue -> OpenAI compatible SSE generator."""
|
| 138 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 139 |
+
set_request_id(req_id)
|
| 140 |
+
|
| 141 |
+
last_reason_pos = 0
|
| 142 |
+
last_body_pos = 0
|
| 143 |
+
chat_completion_id = f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{int(time.time())}-{random.randint(100, 999)}"
|
| 144 |
+
created_timestamp = int(time.time())
|
| 145 |
+
|
| 146 |
+
full_reasoning_content = ""
|
| 147 |
+
full_body_content = ""
|
| 148 |
+
data_receiving = False
|
| 149 |
+
is_response_finalized = False
|
| 150 |
+
finish_reason = "stop"
|
| 151 |
+
|
| 152 |
+
has_started_body = False
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
async for raw_data in use_stream_response(
|
| 156 |
+
req_id,
|
| 157 |
+
timeout=timeout,
|
| 158 |
+
silence_threshold=silence_threshold,
|
| 159 |
+
page=page,
|
| 160 |
+
check_client_disconnected=check_client_disconnected,
|
| 161 |
+
enable_silence_detection=True,
|
| 162 |
+
):
|
| 163 |
+
data_receiving = True
|
| 164 |
+
|
| 165 |
+
if (
|
| 166 |
+
GlobalState.CURRENT_STREAM_REQ_ID
|
| 167 |
+
and GlobalState.CURRENT_STREAM_REQ_ID != req_id
|
| 168 |
+
):
|
| 169 |
+
logger.warning(f"[{req_id}] 🧟 Zombie Stream Detected! Terminating.")
|
| 170 |
+
break
|
| 171 |
+
|
| 172 |
+
if GlobalState.QUOTA_EXCEEDED_EVENT.is_set():
|
| 173 |
+
raise QuotaExceededRetry("Quota exceeded detected mid-stream.")
|
| 174 |
+
|
| 175 |
+
if is_response_finalized:
|
| 176 |
+
logger.warning(
|
| 177 |
+
f"[{req_id}] ⚠️ Extraneous message received after response finalization. Ignoring."
|
| 178 |
+
)
|
| 179 |
+
continue
|
| 180 |
+
|
| 181 |
+
# Holding Pattern for Recovery
|
| 182 |
+
if GlobalState.IS_RECOVERING:
|
| 183 |
+
logger.info(
|
| 184 |
+
f"[{req_id}] ⏸️ System in Recovery Mode. Holding stream open..."
|
| 185 |
+
)
|
| 186 |
+
recovery_wait_start = time.time()
|
| 187 |
+
while GlobalState.IS_RECOVERING:
|
| 188 |
+
if time.time() - recovery_wait_start > 120.0:
|
| 189 |
+
logger.error(f"[{req_id}] ❌ Recovery Timed Out. Aborting.")
|
| 190 |
+
yield generate_sse_chunk(
|
| 191 |
+
"\n\n[SYSTEM: Service Recovery Failed. Please retry.]",
|
| 192 |
+
req_id,
|
| 193 |
+
model_name_for_stream,
|
| 194 |
+
)
|
| 195 |
+
yield generate_sse_stop_chunk(req_id, model_name_for_stream)
|
| 196 |
+
break
|
| 197 |
+
yield ": heartbeat\n\n"
|
| 198 |
+
await asyncio.sleep(1.0)
|
| 199 |
+
|
| 200 |
+
if GlobalState.IS_RECOVERING:
|
| 201 |
+
break
|
| 202 |
+
logger.info(f"[{req_id}] ▶️ Recovery Complete. Resuming stream.")
|
| 203 |
+
|
| 204 |
+
if GlobalState.IS_QUOTA_EXCEEDED and not GlobalState.IS_RECOVERING:
|
| 205 |
+
logger.warning(
|
| 206 |
+
f"[{req_id}] ⚠️ Quota exceeded detected. Waiting for recovery initiation..."
|
| 207 |
+
)
|
| 208 |
+
await asyncio.sleep(1)
|
| 209 |
+
if GlobalState.IS_RECOVERING:
|
| 210 |
+
continue
|
| 211 |
+
logger.warning(
|
| 212 |
+
f"[{req_id}] ⛔ Quota exceeded, waiting for worker to pick up signal..."
|
| 213 |
+
)
|
| 214 |
+
await asyncio.sleep(2)
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
check_client_disconnected(f"Stream generator loop ({req_id}): ")
|
| 219 |
+
except ClientDisconnectedError:
|
| 220 |
+
logger.info(
|
| 221 |
+
f"[{req_id}] Client disconnected, terminating stream generation"
|
| 222 |
+
)
|
| 223 |
+
if data_receiving and not event_to_set.is_set():
|
| 224 |
+
event_to_set.set()
|
| 225 |
+
break
|
| 226 |
+
|
| 227 |
+
data: Any
|
| 228 |
+
if isinstance(raw_data, str):
|
| 229 |
+
try:
|
| 230 |
+
data = json.loads(raw_data)
|
| 231 |
+
except json.JSONDecodeError:
|
| 232 |
+
logger.warning(
|
| 233 |
+
f"[{req_id}] Failed to parse stream data JSON: {raw_data}"
|
| 234 |
+
)
|
| 235 |
+
continue
|
| 236 |
+
elif isinstance(raw_data, dict):
|
| 237 |
+
data = cast(Dict[str, Any], raw_data)
|
| 238 |
+
else:
|
| 239 |
+
continue
|
| 240 |
+
|
| 241 |
+
if not isinstance(data, dict):
|
| 242 |
+
continue
|
| 243 |
+
|
| 244 |
+
typed_data: Dict[str, Any] = cast(Dict[str, Any], data)
|
| 245 |
+
reason = str(typed_data.get("reason", ""))
|
| 246 |
+
body = _clean_body_text(str(typed_data.get("body", "")))
|
| 247 |
+
done = bool(typed_data.get("done", False))
|
| 248 |
+
function = cast(List[Any], typed_data.get("function", []))
|
| 249 |
+
|
| 250 |
+
if reason:
|
| 251 |
+
full_reasoning_content = reason
|
| 252 |
+
if body:
|
| 253 |
+
full_body_content = body
|
| 254 |
+
|
| 255 |
+
# The Latch: Reasoning Handling
|
| 256 |
+
if len(reason) > last_reason_pos:
|
| 257 |
+
reason_delta = reason[last_reason_pos:]
|
| 258 |
+
if not has_started_body:
|
| 259 |
+
output = {
|
| 260 |
+
"id": chat_completion_id,
|
| 261 |
+
"object": "chat.completion.chunk",
|
| 262 |
+
"model": model_name_for_stream,
|
| 263 |
+
"created": created_timestamp,
|
| 264 |
+
"choices": [
|
| 265 |
+
{
|
| 266 |
+
"index": 0,
|
| 267 |
+
"delta": {
|
| 268 |
+
"role": "assistant",
|
| 269 |
+
"content": None,
|
| 270 |
+
"reasoning_content": reason_delta,
|
| 271 |
+
},
|
| 272 |
+
"finish_reason": None,
|
| 273 |
+
}
|
| 274 |
+
],
|
| 275 |
+
}
|
| 276 |
+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 277 |
+
last_reason_pos = len(reason)
|
| 278 |
+
|
| 279 |
+
# The Latch: Body Handling
|
| 280 |
+
# ALWAYS strip "Request function call:..." text from body
|
| 281 |
+
# This prevents emulated FC text from appearing as content to clients
|
| 282 |
+
# even when function call detection fails (race condition protection)
|
| 283 |
+
original_body = body
|
| 284 |
+
if body:
|
| 285 |
+
body = _FUNCTION_CALL_TEXT_PATTERN.sub("", body).strip()
|
| 286 |
+
if body != original_body:
|
| 287 |
+
full_body_content = body
|
| 288 |
+
# If we stripped FC text but function is empty, try parsing from the original
|
| 289 |
+
if not function:
|
| 290 |
+
from api_utils.utils_ext.function_call_response_parser import (
|
| 291 |
+
parse_emulated_function_calls_static,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
parsed_fc = parse_emulated_function_calls_static(original_body)
|
| 295 |
+
if parsed_fc:
|
| 296 |
+
function = parsed_fc
|
| 297 |
+
# Demoted from INFO to DEBUG - this is normal fallback behavior
|
| 298 |
+
# when model outputs text format instead of native FC
|
| 299 |
+
logger.debug(
|
| 300 |
+
f"[{req_id}] Recovered function calls from emulated text"
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
if len(body) > last_body_pos:
|
| 304 |
+
body_delta = body[last_body_pos:]
|
| 305 |
+
# Only stream body content if there's actual content after stripping
|
| 306 |
+
if body_delta.strip():
|
| 307 |
+
has_started_body = True
|
| 308 |
+
output = {
|
| 309 |
+
"id": chat_completion_id,
|
| 310 |
+
"object": "chat.completion.chunk",
|
| 311 |
+
"model": model_name_for_stream,
|
| 312 |
+
"created": created_timestamp,
|
| 313 |
+
"choices": [
|
| 314 |
+
{
|
| 315 |
+
"index": 0,
|
| 316 |
+
"delta": {
|
| 317 |
+
"role": "assistant",
|
| 318 |
+
"content": body_delta,
|
| 319 |
+
},
|
| 320 |
+
"finish_reason": None,
|
| 321 |
+
}
|
| 322 |
+
],
|
| 323 |
+
}
|
| 324 |
+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 325 |
+
last_body_pos = len(body)
|
| 326 |
+
|
| 327 |
+
if done:
|
| 328 |
+
is_recovering = GlobalState.IS_RECOVERING
|
| 329 |
+
is_quota_exceeded = GlobalState.IS_QUOTA_EXCEEDED
|
| 330 |
+
|
| 331 |
+
if (
|
| 332 |
+
done
|
| 333 |
+
and not has_started_body
|
| 334 |
+
and not is_recovering
|
| 335 |
+
and not is_quota_exceeded
|
| 336 |
+
):
|
| 337 |
+
try:
|
| 338 |
+
from browser_utils.operations import check_quota_limit
|
| 339 |
+
|
| 340 |
+
if page:
|
| 341 |
+
await check_quota_limit(page, req_id)
|
| 342 |
+
except Exception:
|
| 343 |
+
pass
|
| 344 |
+
await asyncio.sleep(2.0)
|
| 345 |
+
is_quota_exceeded = GlobalState.IS_QUOTA_EXCEEDED
|
| 346 |
+
is_recovering = GlobalState.IS_RECOVERING
|
| 347 |
+
|
| 348 |
+
if (
|
| 349 |
+
not has_started_body
|
| 350 |
+
and not is_recovering
|
| 351 |
+
and not is_quota_exceeded
|
| 352 |
+
and not function
|
| 353 |
+
):
|
| 354 |
+
# Only show synthetic message when there's truly no content AND no function calls
|
| 355 |
+
# In native FC mode, empty body with function calls is expected
|
| 356 |
+
fallback_text = (
|
| 357 |
+
"\n\n*(Model finished thinking but generated no output.)*"
|
| 358 |
+
)
|
| 359 |
+
output = {
|
| 360 |
+
"id": chat_completion_id,
|
| 361 |
+
"object": "chat.completion.chunk",
|
| 362 |
+
"model": model_name_for_stream,
|
| 363 |
+
"created": created_timestamp,
|
| 364 |
+
"choices": [
|
| 365 |
+
{
|
| 366 |
+
"index": 0,
|
| 367 |
+
"delta": {
|
| 368 |
+
"role": "assistant",
|
| 369 |
+
"content": fallback_text,
|
| 370 |
+
},
|
| 371 |
+
"finish_reason": None,
|
| 372 |
+
}
|
| 373 |
+
],
|
| 374 |
+
}
|
| 375 |
+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 376 |
+
full_body_content += fallback_text
|
| 377 |
+
has_started_body = True
|
| 378 |
+
elif is_recovering or is_quota_exceeded:
|
| 379 |
+
while GlobalState.IS_QUOTA_EXCEEDED or GlobalState.IS_RECOVERING:
|
| 380 |
+
yield ": heartbeat\n\n"
|
| 381 |
+
await asyncio.sleep(1.0)
|
| 382 |
+
|
| 383 |
+
if function:
|
| 384 |
+
finish_reason = "tool_calls"
|
| 385 |
+
tool_calls_list = []
|
| 386 |
+
for func_idx, function_call_data in enumerate(function):
|
| 387 |
+
if isinstance(function_call_data, dict):
|
| 388 |
+
tool_calls_list.append(
|
| 389 |
+
{
|
| 390 |
+
"id": f"call_{random_id()}",
|
| 391 |
+
"index": func_idx,
|
| 392 |
+
"type": "function",
|
| 393 |
+
"function": {
|
| 394 |
+
"name": function_call_data.get("name", ""),
|
| 395 |
+
"arguments": json.dumps(
|
| 396 |
+
function_call_data.get("params", {})
|
| 397 |
+
),
|
| 398 |
+
},
|
| 399 |
+
}
|
| 400 |
+
)
|
| 401 |
+
choice_item = {
|
| 402 |
+
"index": 0,
|
| 403 |
+
"delta": {
|
| 404 |
+
"tool_calls": tool_calls_list,
|
| 405 |
+
},
|
| 406 |
+
"finish_reason": None,
|
| 407 |
+
}
|
| 408 |
+
else:
|
| 409 |
+
finish_reason = "stop"
|
| 410 |
+
choice_item = {
|
| 411 |
+
"index": 0,
|
| 412 |
+
"delta": {},
|
| 413 |
+
"finish_reason": None,
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
output = {
|
| 417 |
+
"id": chat_completion_id,
|
| 418 |
+
"object": "chat.completion.chunk",
|
| 419 |
+
"model": model_name_for_stream,
|
| 420 |
+
"created": created_timestamp,
|
| 421 |
+
"choices": [choice_item],
|
| 422 |
+
}
|
| 423 |
+
yield f"data: {json.dumps(output, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 424 |
+
is_response_finalized = True
|
| 425 |
+
break
|
| 426 |
+
|
| 427 |
+
except (QuotaExceededError, QuotaExceededRetry):
|
| 428 |
+
raise
|
| 429 |
+
except ClientDisconnectedError:
|
| 430 |
+
logger.info(f"[{req_id}] Client disconnected in stream generator")
|
| 431 |
+
if data_receiving and not event_to_set.is_set():
|
| 432 |
+
event_to_set.set()
|
| 433 |
+
except asyncio.CancelledError:
|
| 434 |
+
if not event_to_set.is_set():
|
| 435 |
+
event_to_set.set()
|
| 436 |
+
raise
|
| 437 |
+
except Exception as e:
|
| 438 |
+
logger.error(f"[{req_id}] Error in stream generator: {e}", exc_info=True)
|
| 439 |
+
try:
|
| 440 |
+
error_chunk = {
|
| 441 |
+
"id": chat_completion_id,
|
| 442 |
+
"object": "chat.completion.chunk",
|
| 443 |
+
"model": model_name_for_stream,
|
| 444 |
+
"created": created_timestamp,
|
| 445 |
+
"choices": [
|
| 446 |
+
{
|
| 447 |
+
"index": 0,
|
| 448 |
+
"delta": {
|
| 449 |
+
"role": "assistant",
|
| 450 |
+
"content": f"\n\n[Error: {str(e)}]",
|
| 451 |
+
},
|
| 452 |
+
"finish_reason": "stop",
|
| 453 |
+
}
|
| 454 |
+
],
|
| 455 |
+
}
|
| 456 |
+
yield f"data: {json.dumps(error_chunk, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 457 |
+
except Exception:
|
| 458 |
+
pass
|
| 459 |
+
finally:
|
| 460 |
+
try:
|
| 461 |
+
usage_stats = calculate_usage_stats(
|
| 462 |
+
[msg.model_dump() for msg in request.messages],
|
| 463 |
+
full_body_content,
|
| 464 |
+
full_reasoning_content,
|
| 465 |
+
)
|
| 466 |
+
total_tokens = usage_stats.get("total_tokens", 0)
|
| 467 |
+
GlobalState.increment_token_count(total_tokens)
|
| 468 |
+
from api_utils.server_state import state
|
| 469 |
+
|
| 470 |
+
if (
|
| 471 |
+
hasattr(state, "current_auth_profile_path")
|
| 472 |
+
and state.current_auth_profile_path
|
| 473 |
+
):
|
| 474 |
+
await increment_profile_usage(
|
| 475 |
+
state.current_auth_profile_path, total_tokens
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
final_chunk = {
|
| 479 |
+
"id": chat_completion_id,
|
| 480 |
+
"object": "chat.completion.chunk",
|
| 481 |
+
"model": model_name_for_stream,
|
| 482 |
+
"created": created_timestamp,
|
| 483 |
+
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
|
| 484 |
+
"usage": usage_stats,
|
| 485 |
+
}
|
| 486 |
+
yield f"data: {json.dumps(final_chunk, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 487 |
+
except Exception as usage_err:
|
| 488 |
+
logger.error(f"[{req_id}] Error sending usage stats: {usage_err}")
|
| 489 |
+
|
| 490 |
+
yield "data: [DONE]\n\n"
|
| 491 |
+
if not event_to_set.is_set():
|
| 492 |
+
event_to_set.set()
|
| 493 |
+
|
| 494 |
+
if stream_state is not None:
|
| 495 |
+
stream_state["has_content"] = bool(
|
| 496 |
+
full_body_content or full_reasoning_content
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
async def gen_sse_from_playwright(
|
| 501 |
+
page: AsyncPage,
|
| 502 |
+
logger: logging.Logger,
|
| 503 |
+
req_id: str,
|
| 504 |
+
model_name_for_stream: str,
|
| 505 |
+
request: ChatCompletionRequest,
|
| 506 |
+
check_client_disconnected: Callable[[str], bool],
|
| 507 |
+
completion_event: Event,
|
| 508 |
+
prompt_length: int,
|
| 509 |
+
timeout: float,
|
| 510 |
+
) -> AsyncGenerator[str, None]:
|
| 511 |
+
"""Playwright response -> OpenAI compatible SSE generator."""
|
| 512 |
+
from browser_utils.page_controller import PageController
|
| 513 |
+
from models import ClientDisconnectedError
|
| 514 |
+
|
| 515 |
+
set_request_id(req_id)
|
| 516 |
+
data_receiving = False
|
| 517 |
+
try:
|
| 518 |
+
page_controller = PageController(page, logger, req_id)
|
| 519 |
+
# Use get_response_with_function_calls which handles both content and functions
|
| 520 |
+
response_data = await page_controller.get_response_with_function_calls(
|
| 521 |
+
check_client_disconnected, prompt_length=prompt_length, timeout=timeout
|
| 522 |
+
)
|
| 523 |
+
final_content = response_data.get("content", "")
|
| 524 |
+
function_calls = response_data.get("function_calls", [])
|
| 525 |
+
|
| 526 |
+
data_receiving = True
|
| 527 |
+
lines = final_content.split("\n")
|
| 528 |
+
for line_idx, line in enumerate(lines):
|
| 529 |
+
try:
|
| 530 |
+
check_client_disconnected(
|
| 531 |
+
f"Playwright stream generator loop ({req_id}): "
|
| 532 |
+
)
|
| 533 |
+
except ClientDisconnectedError:
|
| 534 |
+
if data_receiving and not completion_event.is_set():
|
| 535 |
+
completion_event.set()
|
| 536 |
+
break
|
| 537 |
+
if line:
|
| 538 |
+
chunk_size = 5
|
| 539 |
+
for i in range(0, len(line), chunk_size):
|
| 540 |
+
yield generate_sse_chunk(
|
| 541 |
+
line[i : i + chunk_size], req_id, model_name_for_stream
|
| 542 |
+
)
|
| 543 |
+
await asyncio.sleep(0.03)
|
| 544 |
+
if line_idx < len(lines) - 1:
|
| 545 |
+
yield generate_sse_chunk("\n", req_id, model_name_for_stream)
|
| 546 |
+
await asyncio.sleep(0.01)
|
| 547 |
+
|
| 548 |
+
usage_stats = calculate_usage_stats(
|
| 549 |
+
[msg.model_dump() for msg in request.messages], final_content, ""
|
| 550 |
+
)
|
| 551 |
+
total_tokens = usage_stats.get("total_tokens", 0)
|
| 552 |
+
GlobalState.increment_token_count(total_tokens)
|
| 553 |
+
from api_utils.server_state import state
|
| 554 |
+
|
| 555 |
+
if (
|
| 556 |
+
hasattr(state, "current_auth_profile_path")
|
| 557 |
+
and state.current_auth_profile_path
|
| 558 |
+
):
|
| 559 |
+
await increment_profile_usage(state.current_auth_profile_path, total_tokens)
|
| 560 |
+
|
| 561 |
+
if function_calls:
|
| 562 |
+
from api_utils.utils_ext.function_calling_orchestrator import (
|
| 563 |
+
get_function_calling_orchestrator,
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
orchestrator = get_function_calling_orchestrator()
|
| 567 |
+
tool_calls_deltas = orchestrator.format_streaming_tool_calls(function_calls)
|
| 568 |
+
for delta in tool_calls_deltas:
|
| 569 |
+
chunk = {
|
| 570 |
+
"id": f"chatcmpl-{req_id}",
|
| 571 |
+
"object": "chat.completion.chunk",
|
| 572 |
+
"created": int(time.time()),
|
| 573 |
+
"model": model_name_for_stream,
|
| 574 |
+
"choices": [
|
| 575 |
+
{
|
| 576 |
+
"index": 0,
|
| 577 |
+
"delta": {"tool_calls": [delta]},
|
| 578 |
+
"finish_reason": None,
|
| 579 |
+
}
|
| 580 |
+
],
|
| 581 |
+
}
|
| 582 |
+
yield f"data: {json.dumps(chunk, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
| 583 |
+
|
| 584 |
+
yield generate_sse_stop_chunk(
|
| 585 |
+
req_id, model_name_for_stream, "tool_calls", usage_stats
|
| 586 |
+
)
|
| 587 |
+
else:
|
| 588 |
+
yield generate_sse_stop_chunk(
|
| 589 |
+
req_id, model_name_for_stream, "stop", usage_stats
|
| 590 |
+
)
|
| 591 |
+
except (QuotaExceededError, QuotaExceededRetry):
|
| 592 |
+
raise
|
| 593 |
+
except ClientDisconnectedError:
|
| 594 |
+
if data_receiving and not completion_event.is_set():
|
| 595 |
+
completion_event.set()
|
| 596 |
+
except asyncio.CancelledError:
|
| 597 |
+
if not completion_event.is_set():
|
| 598 |
+
completion_event.set()
|
| 599 |
+
raise
|
| 600 |
+
except Exception as e:
|
| 601 |
+
logger.error(
|
| 602 |
+
f"[{req_id}] Error in Playwright stream generator: {e}", exc_info=True
|
| 603 |
+
)
|
| 604 |
+
try:
|
| 605 |
+
yield generate_sse_chunk(
|
| 606 |
+
f"\n\n[Error: {str(e)}]", req_id, model_name_for_stream
|
| 607 |
+
)
|
| 608 |
+
yield generate_sse_stop_chunk(req_id, model_name_for_stream)
|
| 609 |
+
except Exception:
|
| 610 |
+
pass
|
| 611 |
+
finally:
|
| 612 |
+
if not completion_event.is_set():
|
| 613 |
+
completion_event.set()
|
api_utils/response_payloads.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Any, Dict, Optional
|
| 3 |
+
|
| 4 |
+
from config import CHAT_COMPLETION_ID_PREFIX
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def build_chat_completion_response_json(
|
| 8 |
+
req_id: str,
|
| 9 |
+
model_name: str,
|
| 10 |
+
message_payload: Dict[str, Any],
|
| 11 |
+
finish_reason: str,
|
| 12 |
+
usage_stats: Dict[str, int],
|
| 13 |
+
system_fingerprint: str = "camoufox-proxy",
|
| 14 |
+
seed: Optional[int] = None,
|
| 15 |
+
response_format: Optional[Dict[str, Any]] = None,
|
| 16 |
+
) -> Dict[str, Any]:
|
| 17 |
+
"""Construct an OpenAI-compatible non-streaming chat.completion JSON response."""
|
| 18 |
+
created_ts = int(time.time())
|
| 19 |
+
resp: Dict[str, Any] = {
|
| 20 |
+
"id": f"{CHAT_COMPLETION_ID_PREFIX}{req_id}-{created_ts}",
|
| 21 |
+
"object": "chat.completion",
|
| 22 |
+
"created": created_ts,
|
| 23 |
+
"model": model_name,
|
| 24 |
+
"choices": [
|
| 25 |
+
{
|
| 26 |
+
"index": 0,
|
| 27 |
+
"message": message_payload,
|
| 28 |
+
"finish_reason": finish_reason,
|
| 29 |
+
"native_finish_reason": finish_reason,
|
| 30 |
+
}
|
| 31 |
+
],
|
| 32 |
+
"usage": usage_stats,
|
| 33 |
+
"system_fingerprint": system_fingerprint,
|
| 34 |
+
}
|
| 35 |
+
if seed is not None:
|
| 36 |
+
resp["seed"] = seed
|
| 37 |
+
if response_format is not None:
|
| 38 |
+
resp["response_format"] = response_format
|
| 39 |
+
return resp
|
api_utils/routers/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modular FastAPI routers for api_utils.
|
| 3 |
+
Each module defines focused endpoint handlers. This package aggregates them.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# Re-export handlers for convenient imports
|
| 7 |
+
from .api_keys import add_api_key, delete_api_key, get_api_keys, test_api_key
|
| 8 |
+
from .auth_files import router as auth_files_router
|
| 9 |
+
from .chat import chat_completions
|
| 10 |
+
from .health import health_check
|
| 11 |
+
from .helper import router as helper_router
|
| 12 |
+
from .info import get_api_info
|
| 13 |
+
from .logs_ws import websocket_log_endpoint
|
| 14 |
+
from .model_capabilities import router as model_capabilities_router
|
| 15 |
+
from .models import list_models
|
| 16 |
+
from .ports import router as ports_router
|
| 17 |
+
from .proxy import router as proxy_router
|
| 18 |
+
from .queue import cancel_request, get_queue_status
|
| 19 |
+
from .server import router as server_router
|
| 20 |
+
from .static import read_index, serve_react_assets
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
"read_index",
|
| 24 |
+
"serve_react_assets",
|
| 25 |
+
"get_api_info",
|
| 26 |
+
"health_check",
|
| 27 |
+
"list_models",
|
| 28 |
+
"model_capabilities_router",
|
| 29 |
+
"chat_completions",
|
| 30 |
+
"cancel_request",
|
| 31 |
+
"get_queue_status",
|
| 32 |
+
"websocket_log_endpoint",
|
| 33 |
+
"get_api_keys",
|
| 34 |
+
"add_api_key",
|
| 35 |
+
"test_api_key",
|
| 36 |
+
"delete_api_key",
|
| 37 |
+
"proxy_router",
|
| 38 |
+
"auth_files_router",
|
| 39 |
+
"ports_router",
|
| 40 |
+
"server_router",
|
| 41 |
+
"helper_router",
|
| 42 |
+
]
|
api_utils/routers/api_keys.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from fastapi import Depends, HTTPException
|
| 4 |
+
from fastapi.responses import JSONResponse
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
from ..dependencies import get_logger
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ApiKeyRequest(BaseModel):
|
| 11 |
+
key: str
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ApiKeyTestRequest(BaseModel):
|
| 15 |
+
key: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def get_api_keys(logger: logging.Logger = Depends(get_logger)):
|
| 19 |
+
from .. import auth_utils
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
auth_utils.initialize_keys()
|
| 23 |
+
keys_info = list(auth_utils.API_KEYS)
|
| 24 |
+
return JSONResponse(
|
| 25 |
+
content={"success": True, "keys": keys_info, "total_count": len(keys_info)}
|
| 26 |
+
)
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.error(f"Failed to get API key list: {e}")
|
| 29 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def add_api_key(
|
| 33 |
+
request: ApiKeyRequest, logger: logging.Logger = Depends(get_logger)
|
| 34 |
+
):
|
| 35 |
+
from .. import auth_utils
|
| 36 |
+
|
| 37 |
+
key_value = request.key.strip()
|
| 38 |
+
if not key_value or len(key_value) < 8:
|
| 39 |
+
raise HTTPException(status_code=400, detail="Invalid API key format.")
|
| 40 |
+
|
| 41 |
+
auth_utils.initialize_keys()
|
| 42 |
+
if key_value in auth_utils.API_KEYS:
|
| 43 |
+
raise HTTPException(status_code=400, detail="API key already exists.")
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
key_file_path = auth_utils.KEY_FILE_PATH
|
| 47 |
+
with open(key_file_path, "a+", encoding="utf-8") as f:
|
| 48 |
+
f.seek(0)
|
| 49 |
+
if f.read():
|
| 50 |
+
f.write("\n")
|
| 51 |
+
f.write(key_value)
|
| 52 |
+
|
| 53 |
+
auth_utils.initialize_keys()
|
| 54 |
+
logger.info(f"API key added: {key_value[:4]}...{key_value[-4:]}")
|
| 55 |
+
return JSONResponse(
|
| 56 |
+
content={
|
| 57 |
+
"success": True,
|
| 58 |
+
"message": "API key added successfully",
|
| 59 |
+
"key_count": len(auth_utils.API_KEYS),
|
| 60 |
+
}
|
| 61 |
+
)
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Failed to add API key: {e}")
|
| 64 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def test_api_key(
|
| 68 |
+
request: ApiKeyTestRequest, logger: logging.Logger = Depends(get_logger)
|
| 69 |
+
):
|
| 70 |
+
from .. import auth_utils
|
| 71 |
+
|
| 72 |
+
key_value = request.key.strip()
|
| 73 |
+
if not key_value:
|
| 74 |
+
raise HTTPException(status_code=400, detail="API key cannot be empty.")
|
| 75 |
+
|
| 76 |
+
auth_utils.initialize_keys()
|
| 77 |
+
is_valid = auth_utils.verify_api_key(key_value)
|
| 78 |
+
logger.info(
|
| 79 |
+
f"API key test: {key_value[:4]}...{key_value[-4:]} - {'Valid' if is_valid else 'Invalid'}"
|
| 80 |
+
)
|
| 81 |
+
return JSONResponse(
|
| 82 |
+
content={
|
| 83 |
+
"success": True,
|
| 84 |
+
"valid": is_valid,
|
| 85 |
+
"message": "Key valid" if is_valid else "Key invalid or non-existent",
|
| 86 |
+
}
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def delete_api_key(
|
| 91 |
+
request: ApiKeyRequest, logger: logging.Logger = Depends(get_logger)
|
| 92 |
+
):
|
| 93 |
+
from .. import auth_utils
|
| 94 |
+
|
| 95 |
+
key_value = request.key.strip()
|
| 96 |
+
if not key_value:
|
| 97 |
+
raise HTTPException(status_code=400, detail="API key cannot be empty.")
|
| 98 |
+
|
| 99 |
+
auth_utils.initialize_keys()
|
| 100 |
+
if key_value not in auth_utils.API_KEYS:
|
| 101 |
+
raise HTTPException(status_code=404, detail="API key does not exist.")
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
key_file_path = auth_utils.KEY_FILE_PATH
|
| 105 |
+
with open(key_file_path, "r", encoding="utf-8") as f:
|
| 106 |
+
lines = f.readlines()
|
| 107 |
+
|
| 108 |
+
with open(key_file_path, "w", encoding="utf-8") as f:
|
| 109 |
+
f.writelines(line for line in lines if line.strip() != key_value)
|
| 110 |
+
|
| 111 |
+
auth_utils.initialize_keys()
|
| 112 |
+
logger.info(f"API key deleted: {key_value[:4]}...{key_value[-4:]}")
|
| 113 |
+
return JSONResponse(
|
| 114 |
+
content={
|
| 115 |
+
"success": True,
|
| 116 |
+
"message": "API key deleted successfully",
|
| 117 |
+
"key_count": len(auth_utils.API_KEYS),
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Failed to delete API key: {e}")
|
| 122 |
+
raise HTTPException(status_code=500, detail=str(e))
|
api_utils/routers/auth_files.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication Files API Router
|
| 3 |
+
|
| 4 |
+
Endpoints for managing authentication profile files.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import shutil
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, HTTPException
|
| 12 |
+
from fastapi.responses import JSONResponse
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
from config.settings import ACTIVE_AUTH_DIR, SAVED_AUTH_DIR
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AuthFileInfo(BaseModel):
|
| 21 |
+
"""Information about an auth file."""
|
| 22 |
+
|
| 23 |
+
name: str
|
| 24 |
+
path: str
|
| 25 |
+
size_bytes: int
|
| 26 |
+
is_active: bool = False
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ActivateRequest(BaseModel):
|
| 30 |
+
"""Request to activate an auth file."""
|
| 31 |
+
|
| 32 |
+
filename: str = Field(..., description="The name of the auth file to activate")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class AuthFilesResponse(BaseModel):
|
| 36 |
+
"""Response containing list of auth files."""
|
| 37 |
+
|
| 38 |
+
saved_files: list[AuthFileInfo]
|
| 39 |
+
active_file: Optional[str] = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _ensure_dirs() -> None:
|
| 43 |
+
"""Ensure auth directories exist."""
|
| 44 |
+
Path(ACTIVE_AUTH_DIR).mkdir(parents=True, exist_ok=True)
|
| 45 |
+
Path(SAVED_AUTH_DIR).mkdir(parents=True, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _get_active_file() -> Optional[str]:
|
| 49 |
+
"""Get the currently active auth file name."""
|
| 50 |
+
active_dir = Path(ACTIVE_AUTH_DIR)
|
| 51 |
+
if not active_dir.exists():
|
| 52 |
+
return None
|
| 53 |
+
json_files = list(active_dir.glob("*.json"))
|
| 54 |
+
if json_files:
|
| 55 |
+
return sorted(json_files)[0].name
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _list_saved_files() -> list[AuthFileInfo]:
|
| 60 |
+
"""List all saved auth files."""
|
| 61 |
+
saved_dir = Path(SAVED_AUTH_DIR)
|
| 62 |
+
active_file = _get_active_file()
|
| 63 |
+
files: list[AuthFileInfo] = []
|
| 64 |
+
|
| 65 |
+
if saved_dir.exists():
|
| 66 |
+
for f in sorted(saved_dir.glob("*.json")):
|
| 67 |
+
files.append(
|
| 68 |
+
AuthFileInfo(
|
| 69 |
+
name=f.name,
|
| 70 |
+
path=str(f),
|
| 71 |
+
size_bytes=f.stat().st_size,
|
| 72 |
+
is_active=(f.name == active_file),
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
return files
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.get("/files")
|
| 79 |
+
async def list_auth_files() -> JSONResponse:
|
| 80 |
+
"""List all saved auth files."""
|
| 81 |
+
_ensure_dirs()
|
| 82 |
+
files = _list_saved_files()
|
| 83 |
+
active = _get_active_file()
|
| 84 |
+
return JSONResponse(
|
| 85 |
+
content=AuthFilesResponse(
|
| 86 |
+
saved_files=files, # type: ignore[arg-type]
|
| 87 |
+
active_file=active,
|
| 88 |
+
).model_dump()
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.get("/active")
|
| 93 |
+
async def get_active_auth() -> JSONResponse:
|
| 94 |
+
"""Get the currently active auth file."""
|
| 95 |
+
_ensure_dirs()
|
| 96 |
+
active = _get_active_file()
|
| 97 |
+
return JSONResponse(content={"active_file": active})
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.post("/activate")
|
| 101 |
+
async def activate_auth_file(request: ActivateRequest) -> JSONResponse:
|
| 102 |
+
"""Activate the specified auth file."""
|
| 103 |
+
_ensure_dirs()
|
| 104 |
+
filename = request.filename
|
| 105 |
+
|
| 106 |
+
# Find source file
|
| 107 |
+
source_path: Optional[Path] = None
|
| 108 |
+
for search_dir in [SAVED_AUTH_DIR, ACTIVE_AUTH_DIR]:
|
| 109 |
+
candidate = Path(search_dir) / filename
|
| 110 |
+
if candidate.exists() and candidate.is_file():
|
| 111 |
+
source_path = candidate
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
if not source_path:
|
| 115 |
+
raise HTTPException(status_code=404, detail=f"Auth file '{filename}' not found")
|
| 116 |
+
|
| 117 |
+
# Clear existing active files
|
| 118 |
+
active_dir = Path(ACTIVE_AUTH_DIR)
|
| 119 |
+
for existing in active_dir.glob("*.json"):
|
| 120 |
+
existing.unlink()
|
| 121 |
+
|
| 122 |
+
# Copy to active directory
|
| 123 |
+
dest_path = active_dir / filename
|
| 124 |
+
shutil.copy2(source_path, dest_path)
|
| 125 |
+
|
| 126 |
+
return JSONResponse(
|
| 127 |
+
content={
|
| 128 |
+
"success": True,
|
| 129 |
+
"message": f"Auth file '{filename}' activated",
|
| 130 |
+
"active_file": filename,
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@router.delete("/deactivate")
|
| 136 |
+
async def deactivate_auth() -> JSONResponse:
|
| 137 |
+
"""Remove the currently active auth."""
|
| 138 |
+
_ensure_dirs()
|
| 139 |
+
active_dir = Path(ACTIVE_AUTH_DIR)
|
| 140 |
+
removed_count = 0
|
| 141 |
+
|
| 142 |
+
for f in active_dir.glob("*.json"):
|
| 143 |
+
f.unlink()
|
| 144 |
+
removed_count += 1
|
| 145 |
+
|
| 146 |
+
return JSONResponse(
|
| 147 |
+
content={
|
| 148 |
+
"success": True,
|
| 149 |
+
"message": f"Removed {removed_count} auth file(s)",
|
| 150 |
+
"active_file": None,
|
| 151 |
+
}
|
| 152 |
+
)
|
api_utils/routers/chat.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import random
|
| 4 |
+
import time
|
| 5 |
+
from asyncio import Future, Queue
|
| 6 |
+
|
| 7 |
+
from fastapi import Depends, HTTPException, Request
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
|
| 10 |
+
from config import RESPONSE_COMPLETION_TIMEOUT, get_environment_variable
|
| 11 |
+
from logging_utils import set_request_id, set_source
|
| 12 |
+
from models import ChatCompletionRequest
|
| 13 |
+
|
| 14 |
+
from ..dependencies import (
|
| 15 |
+
ensure_request_lock,
|
| 16 |
+
get_logger,
|
| 17 |
+
get_request_queue,
|
| 18 |
+
get_server_state,
|
| 19 |
+
get_worker_task,
|
| 20 |
+
)
|
| 21 |
+
from ..error_utils import service_unavailable
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def chat_completions(
|
| 25 |
+
request: ChatCompletionRequest,
|
| 26 |
+
http_request: Request,
|
| 27 |
+
logger: logging.Logger = Depends(get_logger),
|
| 28 |
+
request_queue: Queue = Depends(get_request_queue),
|
| 29 |
+
server_state: dict = Depends(get_server_state),
|
| 30 |
+
worker_task=Depends(get_worker_task),
|
| 31 |
+
_lock: None = Depends(ensure_request_lock),
|
| 32 |
+
) -> JSONResponse:
|
| 33 |
+
req_id = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=7))
|
| 34 |
+
|
| 35 |
+
# Set log context (Grid Logger)
|
| 36 |
+
set_request_id(req_id)
|
| 37 |
+
set_source("API")
|
| 38 |
+
|
| 39 |
+
logger.info(f"Received /v1/chat/completions request (Stream={request.stream})")
|
| 40 |
+
|
| 41 |
+
launch_mode = get_environment_variable("LAUNCH_MODE", "unknown")
|
| 42 |
+
browser_page_critical = launch_mode != "direct_debug_no_browser"
|
| 43 |
+
|
| 44 |
+
is_service_unavailable = (
|
| 45 |
+
server_state["is_initializing"]
|
| 46 |
+
or not server_state["is_playwright_ready"]
|
| 47 |
+
or (
|
| 48 |
+
browser_page_critical
|
| 49 |
+
and (
|
| 50 |
+
not server_state["is_page_ready"]
|
| 51 |
+
or not server_state["is_browser_connected"]
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
or not worker_task
|
| 55 |
+
or worker_task.done()
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
if is_service_unavailable:
|
| 59 |
+
raise service_unavailable(req_id)
|
| 60 |
+
|
| 61 |
+
result_future = Future()
|
| 62 |
+
queue_item = {
|
| 63 |
+
"req_id": req_id,
|
| 64 |
+
"request_data": request,
|
| 65 |
+
"http_request": http_request,
|
| 66 |
+
"result_future": result_future,
|
| 67 |
+
"enqueue_time": time.time(),
|
| 68 |
+
"cancelled": False,
|
| 69 |
+
}
|
| 70 |
+
await request_queue.put(queue_item)
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
timeout_seconds = RESPONSE_COMPLETION_TIMEOUT / 1000 + 120
|
| 74 |
+
return await asyncio.wait_for(result_future, timeout=timeout_seconds)
|
| 75 |
+
except asyncio.TimeoutError:
|
| 76 |
+
raise HTTPException(
|
| 77 |
+
status_code=504, detail=f"[{req_id}] Request processing timed out."
|
| 78 |
+
)
|
| 79 |
+
except asyncio.CancelledError:
|
| 80 |
+
logger.info(f"Request cancelled by client: {req_id}")
|
| 81 |
+
raise
|
| 82 |
+
except HTTPException as http_exc:
|
| 83 |
+
if http_exc.status_code == 499:
|
| 84 |
+
logger.info(f"Client disconnected: {http_exc.detail}")
|
| 85 |
+
else:
|
| 86 |
+
logger.warning(f"HTTP exception: {http_exc.detail}")
|
| 87 |
+
raise http_exc
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.exception("Error waiting for Worker response")
|
| 90 |
+
raise HTTPException(
|
| 91 |
+
status_code=500, detail=f"[{req_id}] Internal server error: {e}"
|
| 92 |
+
)
|
api_utils/routers/health.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from asyncio import Queue
|
| 2 |
+
from typing import Any, Dict
|
| 3 |
+
|
| 4 |
+
from fastapi import Depends
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
|
| 7 |
+
from config import get_environment_variable
|
| 8 |
+
|
| 9 |
+
from ..dependencies import get_request_queue, get_server_state, get_worker_task
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def health_check(
|
| 13 |
+
server_state: Dict[str, Any] = Depends(get_server_state),
|
| 14 |
+
worker_task=Depends(get_worker_task),
|
| 15 |
+
request_queue: Queue = Depends(get_request_queue),
|
| 16 |
+
) -> JSONResponse:
|
| 17 |
+
is_worker_running = bool(worker_task and not worker_task.done())
|
| 18 |
+
launch_mode = get_environment_variable("LAUNCH_MODE", "unknown")
|
| 19 |
+
browser_page_critical = launch_mode != "direct_debug_no_browser"
|
| 20 |
+
|
| 21 |
+
core_ready_conditions = [
|
| 22 |
+
not server_state["is_initializing"],
|
| 23 |
+
server_state["is_playwright_ready"],
|
| 24 |
+
]
|
| 25 |
+
if browser_page_critical:
|
| 26 |
+
core_ready_conditions.extend(
|
| 27 |
+
[server_state["is_browser_connected"], server_state["is_page_ready"]]
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
is_core_ready = all(core_ready_conditions)
|
| 31 |
+
status_val = "OK" if is_core_ready and is_worker_running else "Error"
|
| 32 |
+
q_size = request_queue.qsize() if request_queue else -1
|
| 33 |
+
|
| 34 |
+
status_message_parts = []
|
| 35 |
+
if server_state["is_initializing"]:
|
| 36 |
+
status_message_parts.append("Initialization in progress")
|
| 37 |
+
if not server_state["is_playwright_ready"]:
|
| 38 |
+
status_message_parts.append("Playwright not ready")
|
| 39 |
+
if browser_page_critical:
|
| 40 |
+
if not server_state["is_browser_connected"]:
|
| 41 |
+
status_message_parts.append("Browser not connected")
|
| 42 |
+
if not server_state["is_page_ready"]:
|
| 43 |
+
status_message_parts.append("Page not ready")
|
| 44 |
+
if not is_worker_running:
|
| 45 |
+
status_message_parts.append("Worker not running")
|
| 46 |
+
|
| 47 |
+
status = {
|
| 48 |
+
"status": status_val,
|
| 49 |
+
"message": "",
|
| 50 |
+
"details": {
|
| 51 |
+
**server_state,
|
| 52 |
+
"workerRunning": is_worker_running,
|
| 53 |
+
"queueLength": q_size,
|
| 54 |
+
"launchMode": launch_mode,
|
| 55 |
+
"browserAndPageCritical": browser_page_critical,
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
if status_val == "OK":
|
| 60 |
+
status["message"] = f"Service running; Queue length: {q_size}."
|
| 61 |
+
return JSONResponse(content=status, status_code=200)
|
| 62 |
+
else:
|
| 63 |
+
status["message"] = (
|
| 64 |
+
f"Service unavailable; Issue: {(', '.join(status_message_parts) or 'Unknown reason')}. Queue length: {q_size}."
|
| 65 |
+
)
|
| 66 |
+
return JSONResponse(content=status, status_code=503)
|
api_utils/routers/helper.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Helper Configuration API Router
|
| 3 |
+
|
| 4 |
+
Manages the Helper endpoint configuration.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter
|
| 13 |
+
from fastapi.responses import JSONResponse
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("CamoufoxLauncher")
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/helper", tags=["helper"])
|
| 19 |
+
|
| 20 |
+
# Config file path
|
| 21 |
+
_CONFIG_DIR = Path(__file__).parent.parent.parent / "config"
|
| 22 |
+
_HELPER_CONFIG_FILE = _CONFIG_DIR / "helper_config.json"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class HelperConfig(BaseModel):
|
| 26 |
+
"""Helper configuration."""
|
| 27 |
+
|
| 28 |
+
enabled: bool = False
|
| 29 |
+
endpoint: str = Field(default="", description="Helper endpoint URL")
|
| 30 |
+
sapisid: Optional[str] = Field(
|
| 31 |
+
default=None, description="SAPISID value (auto-extracted)"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _load_config() -> HelperConfig:
|
| 36 |
+
"""Load helper configuration from file."""
|
| 37 |
+
if _HELPER_CONFIG_FILE.exists():
|
| 38 |
+
try:
|
| 39 |
+
data = json.loads(_HELPER_CONFIG_FILE.read_text(encoding="utf-8"))
|
| 40 |
+
return HelperConfig(**data)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.warning(f"[Helper] Failed to load config: {e}")
|
| 43 |
+
return HelperConfig()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _save_config(config: HelperConfig) -> None:
|
| 47 |
+
"""Save helper configuration to file."""
|
| 48 |
+
try:
|
| 49 |
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
_HELPER_CONFIG_FILE.write_text(
|
| 51 |
+
json.dumps(config.model_dump(), indent=2, ensure_ascii=False),
|
| 52 |
+
encoding="utf-8",
|
| 53 |
+
)
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"[Helper] Failed to save config: {e}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.get("/config")
|
| 59 |
+
async def get_helper_config() -> JSONResponse:
|
| 60 |
+
"""Get Helper configuration."""
|
| 61 |
+
config = _load_config()
|
| 62 |
+
return JSONResponse(content=config.model_dump())
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.post("/config")
|
| 66 |
+
async def update_helper_config(config: HelperConfig) -> JSONResponse:
|
| 67 |
+
"""Update Helper configuration."""
|
| 68 |
+
_save_config(config)
|
| 69 |
+
logger.info(
|
| 70 |
+
f"[Helper] Config updated: enabled={config.enabled}, endpoint={config.endpoint}"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
return JSONResponse(
|
| 74 |
+
content={
|
| 75 |
+
"success": True,
|
| 76 |
+
"message": "Helper configuration saved",
|
| 77 |
+
"config": config.model_dump(),
|
| 78 |
+
}
|
| 79 |
+
)
|
api_utils/routers/info.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, Request
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
|
| 4 |
+
from config import MODEL_NAME, get_environment_variable
|
| 5 |
+
|
| 6 |
+
from ..dependencies import get_current_ai_studio_model_id
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def get_api_info(
|
| 10 |
+
request: Request,
|
| 11 |
+
current_ai_studio_model_id: str = Depends(get_current_ai_studio_model_id),
|
| 12 |
+
) -> JSONResponse:
|
| 13 |
+
from .. import auth_utils
|
| 14 |
+
|
| 15 |
+
server_port = request.url.port or get_environment_variable(
|
| 16 |
+
"SERVER_PORT_INFO", "8000"
|
| 17 |
+
)
|
| 18 |
+
host = request.headers.get("host") or f"127.0.0.1:{server_port}"
|
| 19 |
+
scheme = request.headers.get("x-forwarded-proto") or request.url.scheme or "http"
|
| 20 |
+
base_url = f"{scheme}://{host}"
|
| 21 |
+
api_base = f"{base_url}/v1"
|
| 22 |
+
effective_model_name = current_ai_studio_model_id or MODEL_NAME
|
| 23 |
+
|
| 24 |
+
api_key_required = bool(auth_utils.API_KEYS)
|
| 25 |
+
api_key_count = len(auth_utils.API_KEYS)
|
| 26 |
+
|
| 27 |
+
message = (
|
| 28 |
+
f"API Key is required. {api_key_count} valid key(s) configured."
|
| 29 |
+
if api_key_required
|
| 30 |
+
else "API Key is not required."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
return JSONResponse(
|
| 34 |
+
content={
|
| 35 |
+
"model_name": effective_model_name,
|
| 36 |
+
"api_base_url": api_base,
|
| 37 |
+
"server_base_url": base_url,
|
| 38 |
+
"api_key_required": api_key_required,
|
| 39 |
+
"api_key_count": api_key_count,
|
| 40 |
+
"auth_header": "Authorization: Bearer <token> or X-API-Key: <token>"
|
| 41 |
+
if api_key_required
|
| 42 |
+
else None,
|
| 43 |
+
"openai_compatible": True,
|
| 44 |
+
"supported_auth_methods": ["Authorization: Bearer", "X-API-Key"]
|
| 45 |
+
if api_key_required
|
| 46 |
+
else [],
|
| 47 |
+
"message": message,
|
| 48 |
+
}
|
| 49 |
+
)
|
api_utils/routers/logs_ws.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import uuid
|
| 4 |
+
|
| 5 |
+
from fastapi import Depends, WebSocket, WebSocketDisconnect
|
| 6 |
+
|
| 7 |
+
from models import WebSocketConnectionManager
|
| 8 |
+
|
| 9 |
+
from ..dependencies import get_log_ws_manager, get_logger
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def websocket_log_endpoint(
|
| 13 |
+
websocket: WebSocket,
|
| 14 |
+
logger: logging.Logger = Depends(get_logger),
|
| 15 |
+
log_ws_manager: WebSocketConnectionManager = Depends(get_log_ws_manager),
|
| 16 |
+
):
|
| 17 |
+
if not log_ws_manager:
|
| 18 |
+
await websocket.close(code=1011)
|
| 19 |
+
return
|
| 20 |
+
|
| 21 |
+
client_id = str(uuid.uuid4())
|
| 22 |
+
try:
|
| 23 |
+
await log_ws_manager.connect(client_id, websocket)
|
| 24 |
+
while True:
|
| 25 |
+
await websocket.receive_text()
|
| 26 |
+
except WebSocketDisconnect:
|
| 27 |
+
pass
|
| 28 |
+
except asyncio.CancelledError:
|
| 29 |
+
raise
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error(
|
| 32 |
+
f"Log WebSocket (client {client_id}) encountered an exception: {e}",
|
| 33 |
+
exc_info=True,
|
| 34 |
+
)
|
| 35 |
+
finally:
|
| 36 |
+
log_ws_manager.disconnect(client_id)
|
api_utils/routers/model_capabilities.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Model Capabilities API Endpoint
|
| 3 |
+
|
| 4 |
+
SINGLE SOURCE OF TRUTH for model thinking capabilities.
|
| 5 |
+
Frontend fetches this to determine UI controls dynamically.
|
| 6 |
+
|
| 7 |
+
Configuration is loaded from config/model_capabilities.json.
|
| 8 |
+
When new models are released, update the JSON file - no code changes needed.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
from functools import lru_cache
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter
|
| 18 |
+
from fastapi.responses import JSONResponse
|
| 19 |
+
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
# Config file path
|
| 23 |
+
_CONFIG_PATH = (
|
| 24 |
+
Path(__file__).parent.parent.parent / "config" / "model_capabilities.json"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@lru_cache(maxsize=1)
|
| 29 |
+
def _load_config() -> dict[str, Any]:
|
| 30 |
+
"""
|
| 31 |
+
Load model capabilities configuration from JSON file.
|
| 32 |
+
|
| 33 |
+
Uses LRU cache to avoid repeated file reads.
|
| 34 |
+
Raises FileNotFoundError if config is missing.
|
| 35 |
+
"""
|
| 36 |
+
if not _CONFIG_PATH.exists():
|
| 37 |
+
raise FileNotFoundError(f"Model capabilities config not found: {_CONFIG_PATH}")
|
| 38 |
+
|
| 39 |
+
with open(_CONFIG_PATH, encoding="utf-8") as f:
|
| 40 |
+
return json.load(f)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def reload_config() -> None:
|
| 44 |
+
"""Clear the config cache, forcing a reload on next access."""
|
| 45 |
+
_load_config.cache_clear()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _get_model_capabilities(model_id: str) -> dict[str, Any]:
|
| 49 |
+
"""
|
| 50 |
+
Determine thinking capabilities for a model.
|
| 51 |
+
|
| 52 |
+
Returns dict with:
|
| 53 |
+
- thinkingType: "level" | "budget" | "none"
|
| 54 |
+
- levels: List of thinking levels (for type="level")
|
| 55 |
+
- alwaysOn: Whether thinking is always on (for Gemini 2.5 Pro)
|
| 56 |
+
- budgetRange: [min, max] for budget slider
|
| 57 |
+
- supportsGoogleSearch: Whether the model supports Google Search
|
| 58 |
+
"""
|
| 59 |
+
config = _load_config()
|
| 60 |
+
categories = config.get("categories", {})
|
| 61 |
+
matchers = config.get("matchers", [])
|
| 62 |
+
|
| 63 |
+
model_lower = model_id.lower()
|
| 64 |
+
|
| 65 |
+
# Try each matcher in order (order matters: more specific first)
|
| 66 |
+
for matcher in matchers:
|
| 67 |
+
pattern = matcher.get("pattern", "")
|
| 68 |
+
category_name = matcher.get("category", "")
|
| 69 |
+
|
| 70 |
+
if pattern and category_name:
|
| 71 |
+
try:
|
| 72 |
+
if re.search(pattern, model_lower, re.IGNORECASE):
|
| 73 |
+
if category_name in categories:
|
| 74 |
+
return categories[category_name].copy()
|
| 75 |
+
except re.error:
|
| 76 |
+
# Invalid regex pattern, skip
|
| 77 |
+
continue
|
| 78 |
+
|
| 79 |
+
# Default to "other" category
|
| 80 |
+
return categories.get(
|
| 81 |
+
"other", {"thinkingType": "none", "supportsGoogleSearch": True}
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get("/api/model-capabilities")
|
| 86 |
+
async def get_model_capabilities() -> JSONResponse:
|
| 87 |
+
"""
|
| 88 |
+
Return thinking capabilities for all known model categories.
|
| 89 |
+
|
| 90 |
+
Frontend uses this to dynamically configure thinking controls.
|
| 91 |
+
"""
|
| 92 |
+
config = _load_config()
|
| 93 |
+
return JSONResponse(content=config)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.get("/api/model-capabilities/{model_id:path}")
|
| 97 |
+
async def get_single_model_capabilities(model_id: str) -> JSONResponse:
|
| 98 |
+
"""
|
| 99 |
+
Return thinking capabilities for a specific model.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
model_id: Model identifier (e.g., "gemini-2.5-flash-preview")
|
| 103 |
+
"""
|
| 104 |
+
return JSONResponse(content=_get_model_capabilities(model_id))
|
api_utils/routers/models.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import time
|
| 4 |
+
from asyncio import Event
|
| 5 |
+
from typing import Any, Dict, List, Set
|
| 6 |
+
|
| 7 |
+
from fastapi import Depends
|
| 8 |
+
from playwright.async_api import Page as AsyncPage
|
| 9 |
+
|
| 10 |
+
from config import DEFAULT_FALLBACK_MODEL_ID
|
| 11 |
+
|
| 12 |
+
from ..dependencies import (
|
| 13 |
+
ensure_request_lock,
|
| 14 |
+
get_excluded_model_ids,
|
| 15 |
+
get_logger,
|
| 16 |
+
get_model_list_fetch_event,
|
| 17 |
+
get_page_instance,
|
| 18 |
+
get_parsed_model_list,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def list_models(
|
| 23 |
+
logger: logging.Logger = Depends(get_logger),
|
| 24 |
+
model_list_fetch_event: Event = Depends(get_model_list_fetch_event),
|
| 25 |
+
page_instance: AsyncPage = Depends(get_page_instance),
|
| 26 |
+
parsed_model_list: List[Dict[str, Any]] = Depends(get_parsed_model_list),
|
| 27 |
+
excluded_model_ids: Set[str] = Depends(get_excluded_model_ids),
|
| 28 |
+
_lock: None = Depends(ensure_request_lock),
|
| 29 |
+
):
|
| 30 |
+
logger.debug("[API] Received /v1/models request.")
|
| 31 |
+
|
| 32 |
+
if (
|
| 33 |
+
not model_list_fetch_event.is_set()
|
| 34 |
+
and page_instance
|
| 35 |
+
and not page_instance.is_closed()
|
| 36 |
+
):
|
| 37 |
+
logger.info(
|
| 38 |
+
"/v1/models: Model list event not set, attempting to refresh page..."
|
| 39 |
+
)
|
| 40 |
+
try:
|
| 41 |
+
await page_instance.reload(wait_until="domcontentloaded", timeout=20000)
|
| 42 |
+
await asyncio.wait_for(model_list_fetch_event.wait(), timeout=10.0)
|
| 43 |
+
except asyncio.CancelledError:
|
| 44 |
+
raise
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error(f"/v1/models: Error refreshing or waiting for model list: {e}")
|
| 47 |
+
finally:
|
| 48 |
+
if not model_list_fetch_event.is_set():
|
| 49 |
+
model_list_fetch_event.set()
|
| 50 |
+
|
| 51 |
+
if parsed_model_list:
|
| 52 |
+
final_model_list = [
|
| 53 |
+
m
|
| 54 |
+
for m in parsed_model_list
|
| 55 |
+
if isinstance(m, dict) and m.get("id") not in excluded_model_ids
|
| 56 |
+
]
|
| 57 |
+
return {"object": "list", "data": final_model_list}
|
| 58 |
+
else:
|
| 59 |
+
logger.warning("Model list is empty, returning default fallback model.")
|
| 60 |
+
return {
|
| 61 |
+
"object": "list",
|
| 62 |
+
"data": [
|
| 63 |
+
{
|
| 64 |
+
"id": DEFAULT_FALLBACK_MODEL_ID,
|
| 65 |
+
"object": "model",
|
| 66 |
+
"created": int(time.time()),
|
| 67 |
+
"owned_by": "camoufox-proxy-fallback",
|
| 68 |
+
}
|
| 69 |
+
],
|
| 70 |
+
}
|
api_utils/routers/ports.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Port Configuration and Status API Router
|
| 3 |
+
|
| 4 |
+
Endpoints for port configuration, status querying, and process management.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import platform
|
| 10 |
+
import subprocess
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, HTTPException
|
| 15 |
+
from fastapi.responses import JSONResponse
|
| 16 |
+
from pydantic import BaseModel, Field, field_validator
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/ports", tags=["ports"])
|
| 19 |
+
|
| 20 |
+
# Config file path
|
| 21 |
+
_CONFIG_DIR = Path(__file__).parent.parent.parent
|
| 22 |
+
_PORTS_CONFIG_FILE = _CONFIG_DIR / "ports_config.json"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class PortConfig(BaseModel):
|
| 26 |
+
"""Port configuration model."""
|
| 27 |
+
|
| 28 |
+
fastapi_port: int = Field(default=2048, ge=1024, le=65535)
|
| 29 |
+
camoufox_debug_port: int = Field(default=9222, ge=1024, le=65535)
|
| 30 |
+
stream_proxy_port: int = Field(default=3120, ge=0, le=65535)
|
| 31 |
+
stream_proxy_enabled: bool = True
|
| 32 |
+
|
| 33 |
+
@field_validator("fastapi_port", "camoufox_debug_port")
|
| 34 |
+
@classmethod
|
| 35 |
+
def validate_required_port(cls, v: int) -> int:
|
| 36 |
+
if v < 1024:
|
| 37 |
+
raise ValueError("Port must be >= 1024")
|
| 38 |
+
return v
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ProcessInfo(BaseModel):
|
| 42 |
+
"""Information about a process."""
|
| 43 |
+
|
| 44 |
+
pid: int
|
| 45 |
+
name: str
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class PortStatus(BaseModel):
|
| 49 |
+
"""Status of a port."""
|
| 50 |
+
|
| 51 |
+
port: int
|
| 52 |
+
port_type: str
|
| 53 |
+
in_use: bool
|
| 54 |
+
processes: list[ProcessInfo] = []
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class KillRequest(BaseModel):
|
| 58 |
+
"""Request to kill a process."""
|
| 59 |
+
|
| 60 |
+
pid: int = Field(..., ge=1, description="PID of the process to terminate")
|
| 61 |
+
confirm: bool = Field(default=False, description="Confirm termination")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _load_port_config() -> PortConfig:
|
| 65 |
+
"""Load port config from file or environment."""
|
| 66 |
+
# Environment variables take priority
|
| 67 |
+
config = PortConfig(
|
| 68 |
+
fastapi_port=int(os.environ.get("DEFAULT_FASTAPI_PORT", "2048")),
|
| 69 |
+
camoufox_debug_port=int(os.environ.get("DEFAULT_CAMOUFOX_PORT", "9222")),
|
| 70 |
+
stream_proxy_port=int(os.environ.get("STREAM_PORT", "3120")),
|
| 71 |
+
stream_proxy_enabled=os.environ.get("STREAM_PORT", "3120") != "0",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Override with saved config if exists
|
| 75 |
+
if _PORTS_CONFIG_FILE.exists():
|
| 76 |
+
try:
|
| 77 |
+
data = json.loads(_PORTS_CONFIG_FILE.read_text(encoding="utf-8"))
|
| 78 |
+
config = PortConfig(**data)
|
| 79 |
+
except Exception:
|
| 80 |
+
pass
|
| 81 |
+
|
| 82 |
+
return config
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _save_port_config(config: PortConfig) -> None:
|
| 86 |
+
"""Save port config to file."""
|
| 87 |
+
_PORTS_CONFIG_FILE.write_text(
|
| 88 |
+
json.dumps(config.model_dump(), ensure_ascii=False, indent=2),
|
| 89 |
+
encoding="utf-8",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _find_processes_on_port(port: int) -> list[ProcessInfo]:
|
| 94 |
+
"""Find processes listening on a port."""
|
| 95 |
+
processes: list[ProcessInfo] = []
|
| 96 |
+
system = platform.system()
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
if system in ("Linux", "Darwin"):
|
| 100 |
+
cmd = f"lsof -ti tcp:{port} -sTCP:LISTEN"
|
| 101 |
+
result = subprocess.run(
|
| 102 |
+
cmd, shell=True, capture_output=True, text=True, timeout=5
|
| 103 |
+
)
|
| 104 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 105 |
+
pids = [
|
| 106 |
+
int(p) for p in result.stdout.strip().splitlines() if p.isdigit()
|
| 107 |
+
]
|
| 108 |
+
for pid in pids:
|
| 109 |
+
name = _get_process_name(pid)
|
| 110 |
+
processes.append(ProcessInfo(pid=pid, name=name))
|
| 111 |
+
|
| 112 |
+
elif system == "Windows":
|
| 113 |
+
cmd = "netstat -ano -p TCP"
|
| 114 |
+
result = subprocess.run(
|
| 115 |
+
cmd,
|
| 116 |
+
shell=True,
|
| 117 |
+
capture_output=True,
|
| 118 |
+
text=True,
|
| 119 |
+
timeout=10,
|
| 120 |
+
creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
|
| 121 |
+
)
|
| 122 |
+
if result.returncode == 0:
|
| 123 |
+
for line in result.stdout.strip().splitlines():
|
| 124 |
+
parts = line.split()
|
| 125 |
+
if (
|
| 126 |
+
len(parts) >= 5
|
| 127 |
+
and parts[0].upper() == "TCP"
|
| 128 |
+
and parts[3].upper() == "LISTENING"
|
| 129 |
+
):
|
| 130 |
+
local_addr = parts[1]
|
| 131 |
+
if local_addr.endswith(f":{port}"):
|
| 132 |
+
pid_str = parts[4]
|
| 133 |
+
if pid_str.isdigit():
|
| 134 |
+
pid = int(pid_str)
|
| 135 |
+
name = _get_process_name(pid)
|
| 136 |
+
processes.append(ProcessInfo(pid=pid, name=name))
|
| 137 |
+
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
# Deduplicate by PID
|
| 142 |
+
seen_pids: set[int] = set()
|
| 143 |
+
unique_processes: list[ProcessInfo] = []
|
| 144 |
+
for p in processes:
|
| 145 |
+
if p.pid not in seen_pids:
|
| 146 |
+
seen_pids.add(p.pid)
|
| 147 |
+
unique_processes.append(p)
|
| 148 |
+
|
| 149 |
+
return unique_processes
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _get_process_name(pid: int) -> str:
|
| 153 |
+
"""Get process name by PID."""
|
| 154 |
+
system = platform.system()
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
if system == "Linux":
|
| 158 |
+
result = subprocess.run(
|
| 159 |
+
["ps", "-p", str(pid), "-o", "comm="],
|
| 160 |
+
capture_output=True,
|
| 161 |
+
text=True,
|
| 162 |
+
timeout=3,
|
| 163 |
+
)
|
| 164 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 165 |
+
return result.stdout.strip()
|
| 166 |
+
|
| 167 |
+
elif system == "Darwin":
|
| 168 |
+
result = subprocess.run(
|
| 169 |
+
["ps", "-p", str(pid), "-o", "comm="],
|
| 170 |
+
capture_output=True,
|
| 171 |
+
text=True,
|
| 172 |
+
timeout=3,
|
| 173 |
+
)
|
| 174 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 175 |
+
return result.stdout.strip()
|
| 176 |
+
|
| 177 |
+
elif system == "Windows":
|
| 178 |
+
result = subprocess.run(
|
| 179 |
+
["tasklist", "/NH", "/FO", "CSV", "/FI", f"PID eq {pid}"],
|
| 180 |
+
capture_output=True,
|
| 181 |
+
text=True,
|
| 182 |
+
timeout=3,
|
| 183 |
+
creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
|
| 184 |
+
)
|
| 185 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 186 |
+
parts = result.stdout.strip().split('","')
|
| 187 |
+
if parts:
|
| 188 |
+
return parts[0].strip('"')
|
| 189 |
+
|
| 190 |
+
except Exception:
|
| 191 |
+
pass
|
| 192 |
+
|
| 193 |
+
return "Unknown"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _kill_process(pid: int) -> tuple[bool, str]:
|
| 197 |
+
"""Kill a process by PID. Returns (success, message)."""
|
| 198 |
+
system = platform.system()
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
if system in ("Linux", "Darwin"):
|
| 202 |
+
# Try SIGTERM first
|
| 203 |
+
subprocess.run(["kill", "-TERM", str(pid)], capture_output=True, timeout=3)
|
| 204 |
+
time.sleep(0.5)
|
| 205 |
+
|
| 206 |
+
# Check if still alive
|
| 207 |
+
check = subprocess.run(
|
| 208 |
+
["kill", "-0", str(pid)], capture_output=True, text=True
|
| 209 |
+
)
|
| 210 |
+
if check.returncode != 0:
|
| 211 |
+
return True, f"Process {pid} terminated (SIGTERM)"
|
| 212 |
+
|
| 213 |
+
# Force kill
|
| 214 |
+
subprocess.run(["kill", "-KILL", str(pid)], capture_output=True, timeout=3)
|
| 215 |
+
time.sleep(0.2)
|
| 216 |
+
|
| 217 |
+
# Verify
|
| 218 |
+
check = subprocess.run(
|
| 219 |
+
["kill", "-0", str(pid)], capture_output=True, text=True
|
| 220 |
+
)
|
| 221 |
+
if check.returncode != 0:
|
| 222 |
+
return True, f"Process {pid} force terminated (SIGKILL)"
|
| 223 |
+
else:
|
| 224 |
+
return False, f"Unable to terminate process {pid}"
|
| 225 |
+
|
| 226 |
+
elif system == "Windows":
|
| 227 |
+
result = subprocess.run(
|
| 228 |
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
| 229 |
+
capture_output=True,
|
| 230 |
+
text=True,
|
| 231 |
+
timeout=5,
|
| 232 |
+
creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
|
| 233 |
+
)
|
| 234 |
+
if result.returncode == 0:
|
| 235 |
+
return True, f"Process {pid} terminated"
|
| 236 |
+
else:
|
| 237 |
+
return False, f"Unable to terminate process {pid}: {result.stderr}"
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
return False, f"Error terminating process: {e}"
|
| 241 |
+
|
| 242 |
+
return False, "Unsupported OS"
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@router.get("/config")
|
| 246 |
+
async def get_port_config() -> JSONResponse:
|
| 247 |
+
"""Get port configuration."""
|
| 248 |
+
config = _load_port_config()
|
| 249 |
+
return JSONResponse(content=config.model_dump())
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
@router.post("/config")
|
| 253 |
+
async def update_port_config(config: PortConfig) -> JSONResponse:
|
| 254 |
+
"""
|
| 255 |
+
Update port configuration.
|
| 256 |
+
|
| 257 |
+
Note: Changes will take effect on next server restart.
|
| 258 |
+
"""
|
| 259 |
+
_save_port_config(config)
|
| 260 |
+
return JSONResponse(
|
| 261 |
+
content={
|
| 262 |
+
"success": True,
|
| 263 |
+
"config": config.model_dump(),
|
| 264 |
+
"message": "Configuration saved. Changes will take effect on next restart.",
|
| 265 |
+
}
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
@router.get("/status")
|
| 270 |
+
async def get_port_status() -> JSONResponse:
|
| 271 |
+
"""Get port occupation status."""
|
| 272 |
+
config = _load_port_config()
|
| 273 |
+
|
| 274 |
+
statuses: list[PortStatus] = []
|
| 275 |
+
|
| 276 |
+
# Check FastAPI port
|
| 277 |
+
fastapi_processes = _find_processes_on_port(config.fastapi_port)
|
| 278 |
+
statuses.append(
|
| 279 |
+
PortStatus(
|
| 280 |
+
port=config.fastapi_port,
|
| 281 |
+
port_type="FastAPI",
|
| 282 |
+
in_use=len(fastapi_processes) > 0,
|
| 283 |
+
processes=fastapi_processes,
|
| 284 |
+
)
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Check Camoufox debug port
|
| 288 |
+
camoufox_processes = _find_processes_on_port(config.camoufox_debug_port)
|
| 289 |
+
statuses.append(
|
| 290 |
+
PortStatus(
|
| 291 |
+
port=config.camoufox_debug_port,
|
| 292 |
+
port_type="Camoufox Debug",
|
| 293 |
+
in_use=len(camoufox_processes) > 0,
|
| 294 |
+
processes=camoufox_processes,
|
| 295 |
+
)
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# Check Stream proxy port (if enabled)
|
| 299 |
+
if config.stream_proxy_enabled and config.stream_proxy_port > 0:
|
| 300 |
+
stream_processes = _find_processes_on_port(config.stream_proxy_port)
|
| 301 |
+
statuses.append(
|
| 302 |
+
PortStatus(
|
| 303 |
+
port=config.stream_proxy_port,
|
| 304 |
+
port_type="Stream Proxy",
|
| 305 |
+
in_use=len(stream_processes) > 0,
|
| 306 |
+
processes=stream_processes,
|
| 307 |
+
)
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
return JSONResponse(content={"ports": [s.model_dump() for s in statuses]})
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@router.post("/kill")
|
| 314 |
+
async def kill_process(request: KillRequest) -> JSONResponse:
|
| 315 |
+
"""
|
| 316 |
+
Terminate process with specified PID.
|
| 317 |
+
|
| 318 |
+
Security validation:
|
| 319 |
+
- Requires confirm=true
|
| 320 |
+
- PID must belong to a process on a configured port
|
| 321 |
+
"""
|
| 322 |
+
if not request.confirm:
|
| 323 |
+
raise HTTPException(
|
| 324 |
+
status_code=400,
|
| 325 |
+
detail="Please set confirm=true to confirm process termination",
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
# Security: Validate PID belongs to a tracked port
|
| 329 |
+
config = _load_port_config()
|
| 330 |
+
tracked_pids: set[int] = set()
|
| 331 |
+
|
| 332 |
+
# Collect PIDs from all configured ports
|
| 333 |
+
for port in [config.fastapi_port, config.camoufox_debug_port]:
|
| 334 |
+
for proc in _find_processes_on_port(port):
|
| 335 |
+
tracked_pids.add(proc.pid)
|
| 336 |
+
|
| 337 |
+
# Also check stream proxy port if enabled
|
| 338 |
+
if config.stream_proxy_enabled and config.stream_proxy_port > 0:
|
| 339 |
+
for proc in _find_processes_on_port(config.stream_proxy_port):
|
| 340 |
+
tracked_pids.add(proc.pid)
|
| 341 |
+
|
| 342 |
+
if request.pid not in tracked_pids:
|
| 343 |
+
raise HTTPException(
|
| 344 |
+
status_code=403,
|
| 345 |
+
detail=f"Security validation failed: PID {request.pid} does not belong to a configured port. Only processes running on FastAPI ({config.fastapi_port}), Camoufox ({config.camoufox_debug_port}) or Stream Proxy ({config.stream_proxy_port}) ports can be terminated.",
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
success, message = _kill_process(request.pid)
|
| 349 |
+
|
| 350 |
+
return JSONResponse(
|
| 351 |
+
content={"success": success, "message": message, "pid": request.pid},
|
| 352 |
+
status_code=200 if success else 500,
|
| 353 |
+
)
|
api_utils/routers/proxy.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Proxy Configuration API Router
|
| 3 |
+
|
| 4 |
+
Endpoints for managing browser proxy settings and testing connectivity.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import socket
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter
|
| 13 |
+
from fastapi.responses import JSONResponse
|
| 14 |
+
from pydantic import BaseModel, Field, field_validator
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/proxy", tags=["proxy"])
|
| 17 |
+
|
| 18 |
+
# Config file path
|
| 19 |
+
_CONFIG_DIR = Path(__file__).parent.parent.parent
|
| 20 |
+
_PROXY_CONFIG_FILE = _CONFIG_DIR / "proxy_config.json"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ProxyConfig(BaseModel):
|
| 24 |
+
"""Proxy configuration model."""
|
| 25 |
+
|
| 26 |
+
enabled: bool = False
|
| 27 |
+
address: str = "http://127.0.0.1:7890"
|
| 28 |
+
|
| 29 |
+
@field_validator("address")
|
| 30 |
+
@classmethod
|
| 31 |
+
def validate_address(cls, v: str) -> str:
|
| 32 |
+
"""Validate proxy address format."""
|
| 33 |
+
v = v.strip()
|
| 34 |
+
if v and not (v.startswith("http://") or v.startswith("https://")):
|
| 35 |
+
raise ValueError("Proxy address must start with http:// or https://")
|
| 36 |
+
return v
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ProxyTestRequest(BaseModel):
|
| 40 |
+
"""Request model for proxy test."""
|
| 41 |
+
|
| 42 |
+
address: str = Field(..., description="Proxy address")
|
| 43 |
+
test_url: str = Field(
|
| 44 |
+
default="http://httpbin.org/get", description="Test target URL"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ProxyTestResult(BaseModel):
|
| 49 |
+
"""Result of proxy connectivity test."""
|
| 50 |
+
|
| 51 |
+
success: bool
|
| 52 |
+
message: str
|
| 53 |
+
latency_ms: Optional[float] = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _load_config() -> ProxyConfig:
|
| 57 |
+
"""Load proxy config from file."""
|
| 58 |
+
import json
|
| 59 |
+
|
| 60 |
+
if _PROXY_CONFIG_FILE.exists():
|
| 61 |
+
try:
|
| 62 |
+
data = json.loads(_PROXY_CONFIG_FILE.read_text(encoding="utf-8"))
|
| 63 |
+
return ProxyConfig(**data)
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
return ProxyConfig()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _save_config(config: ProxyConfig) -> None:
|
| 70 |
+
"""Save proxy config to file."""
|
| 71 |
+
import json
|
| 72 |
+
|
| 73 |
+
_PROXY_CONFIG_FILE.write_text(
|
| 74 |
+
json.dumps(config.model_dump(), ensure_ascii=False, indent=2),
|
| 75 |
+
encoding="utf-8",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/config")
|
| 80 |
+
async def get_proxy_config() -> JSONResponse:
|
| 81 |
+
"""Get current proxy configuration."""
|
| 82 |
+
config = _load_config()
|
| 83 |
+
return JSONResponse(content=config.model_dump())
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.post("/config")
|
| 87 |
+
async def update_proxy_config(config: ProxyConfig) -> JSONResponse:
|
| 88 |
+
"""Update proxy configuration."""
|
| 89 |
+
_save_config(config)
|
| 90 |
+
return JSONResponse(content={"success": True, "config": config.model_dump()})
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.post("/test")
|
| 94 |
+
async def test_proxy_connectivity(request: ProxyTestRequest) -> JSONResponse:
|
| 95 |
+
"""Test proxy connectivity."""
|
| 96 |
+
import httpx
|
| 97 |
+
|
| 98 |
+
proxy_addr = request.address.strip()
|
| 99 |
+
test_url = request.test_url
|
| 100 |
+
|
| 101 |
+
if not proxy_addr:
|
| 102 |
+
return JSONResponse(
|
| 103 |
+
content=ProxyTestResult(
|
| 104 |
+
success=False, message="Proxy address cannot be empty"
|
| 105 |
+
).model_dump(),
|
| 106 |
+
status_code=400,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
start_time = time.monotonic()
|
| 111 |
+
async with httpx.AsyncClient(
|
| 112 |
+
proxy=proxy_addr,
|
| 113 |
+
timeout=15.0,
|
| 114 |
+
follow_redirects=True,
|
| 115 |
+
) as client:
|
| 116 |
+
response = await client.get(test_url)
|
| 117 |
+
latency = (time.monotonic() - start_time) * 1000
|
| 118 |
+
|
| 119 |
+
if 200 <= response.status_code < 300:
|
| 120 |
+
return JSONResponse(
|
| 121 |
+
content=ProxyTestResult(
|
| 122 |
+
success=True,
|
| 123 |
+
message=f"Connection successful (HTTP {response.status_code})",
|
| 124 |
+
latency_ms=round(latency, 2),
|
| 125 |
+
).model_dump()
|
| 126 |
+
)
|
| 127 |
+
else:
|
| 128 |
+
return JSONResponse(
|
| 129 |
+
content=ProxyTestResult(
|
| 130 |
+
success=False,
|
| 131 |
+
message=f"HTTP Error: {response.status_code}",
|
| 132 |
+
latency_ms=round(latency, 2),
|
| 133 |
+
).model_dump()
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
except httpx.ProxyError as e:
|
| 137 |
+
return JSONResponse(
|
| 138 |
+
content=ProxyTestResult(
|
| 139 |
+
success=False, message=f"Proxy error: {e}"
|
| 140 |
+
).model_dump()
|
| 141 |
+
)
|
| 142 |
+
except httpx.ConnectTimeout:
|
| 143 |
+
return JSONResponse(
|
| 144 |
+
content=ProxyTestResult(
|
| 145 |
+
success=False, message="Connection timeout"
|
| 146 |
+
).model_dump()
|
| 147 |
+
)
|
| 148 |
+
except httpx.ReadTimeout:
|
| 149 |
+
return JSONResponse(
|
| 150 |
+
content=ProxyTestResult(success=False, message="Read timeout").model_dump()
|
| 151 |
+
)
|
| 152 |
+
except socket.gaierror as e:
|
| 153 |
+
return JSONResponse(
|
| 154 |
+
content=ProxyTestResult(
|
| 155 |
+
success=False, message=f"DNS resolution failed: {e}"
|
| 156 |
+
).model_dump()
|
| 157 |
+
)
|
| 158 |
+
except Exception as e:
|
| 159 |
+
return JSONResponse(
|
| 160 |
+
content=ProxyTestResult(
|
| 161 |
+
success=False, message=f"Unknown error: {e}"
|
| 162 |
+
).model_dump()
|
| 163 |
+
)
|
api_utils/routers/queue.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import time
|
| 3 |
+
from asyncio import Lock, Queue
|
| 4 |
+
|
| 5 |
+
from fastapi import Depends
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
|
| 8 |
+
from logging_utils import set_request_id
|
| 9 |
+
|
| 10 |
+
from ..dependencies import get_logger, get_processing_lock, get_request_queue
|
| 11 |
+
from ..error_utils import client_cancelled
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def cancel_queued_request(
|
| 15 |
+
req_id: str, request_queue: Queue, logger: logging.Logger
|
| 16 |
+
) -> bool:
|
| 17 |
+
set_request_id(req_id)
|
| 18 |
+
items_to_requeue = []
|
| 19 |
+
found = False
|
| 20 |
+
try:
|
| 21 |
+
while not request_queue.empty():
|
| 22 |
+
item = request_queue.get_nowait()
|
| 23 |
+
if item.get("req_id") == req_id:
|
| 24 |
+
logger.info("Found request in queue, marking as cancelled.")
|
| 25 |
+
item["cancelled"] = True
|
| 26 |
+
if (future := item.get("result_future")) and not future.done():
|
| 27 |
+
future.set_exception(client_cancelled(req_id))
|
| 28 |
+
found = True
|
| 29 |
+
items_to_requeue.append(item)
|
| 30 |
+
finally:
|
| 31 |
+
for item in items_to_requeue:
|
| 32 |
+
await request_queue.put(item)
|
| 33 |
+
return found
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def cancel_request(
|
| 37 |
+
req_id: str,
|
| 38 |
+
logger: logging.Logger = Depends(get_logger),
|
| 39 |
+
request_queue: Queue = Depends(get_request_queue),
|
| 40 |
+
):
|
| 41 |
+
set_request_id(req_id)
|
| 42 |
+
logger.info("Received cancellation request.")
|
| 43 |
+
if await cancel_queued_request(req_id, request_queue, logger):
|
| 44 |
+
return JSONResponse(
|
| 45 |
+
content={
|
| 46 |
+
"success": True,
|
| 47 |
+
"message": f"Request {req_id} marked as cancelled.",
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
else:
|
| 51 |
+
return JSONResponse(
|
| 52 |
+
status_code=404,
|
| 53 |
+
content={
|
| 54 |
+
"success": False,
|
| 55 |
+
"message": f"Request {req_id} not found in queue.",
|
| 56 |
+
},
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def get_queue_status(
|
| 61 |
+
request_queue: Queue = Depends(get_request_queue),
|
| 62 |
+
processing_lock: Lock = Depends(get_processing_lock),
|
| 63 |
+
):
|
| 64 |
+
# Extract all items temporarily to inspect queue contents
|
| 65 |
+
queue_items = []
|
| 66 |
+
try:
|
| 67 |
+
while not request_queue.empty():
|
| 68 |
+
item = request_queue.get_nowait()
|
| 69 |
+
queue_items.append(item)
|
| 70 |
+
except Exception:
|
| 71 |
+
pass
|
| 72 |
+
finally:
|
| 73 |
+
# Put all items back in original order
|
| 74 |
+
for item in queue_items:
|
| 75 |
+
await request_queue.put(item)
|
| 76 |
+
|
| 77 |
+
queue_length = len(queue_items)
|
| 78 |
+
|
| 79 |
+
return JSONResponse(
|
| 80 |
+
content={
|
| 81 |
+
"queue_length": queue_length,
|
| 82 |
+
"is_processing_locked": processing_lock.locked(),
|
| 83 |
+
"items": sorted(
|
| 84 |
+
[
|
| 85 |
+
{
|
| 86 |
+
"req_id": item.get("req_id", "unknown"),
|
| 87 |
+
"enqueue_time": item.get("enqueue_time", 0),
|
| 88 |
+
"wait_time_seconds": round(
|
| 89 |
+
time.time() - item.get("enqueue_time", 0), 2
|
| 90 |
+
),
|
| 91 |
+
"is_streaming": item.get("request_data").stream,
|
| 92 |
+
"cancelled": item.get("cancelled", False),
|
| 93 |
+
}
|
| 94 |
+
for item in queue_items
|
| 95 |
+
],
|
| 96 |
+
key=lambda x: x.get("enqueue_time", 0),
|
| 97 |
+
),
|
| 98 |
+
}
|
| 99 |
+
)
|
api_utils/routers/server.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Server Control API Router
|
| 3 |
+
|
| 4 |
+
Provides endpoints for server status and control operations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
|
| 17 |
+
from ..app import VERSION
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("CamoufoxLauncher")
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/api/server", tags=["server"])
|
| 22 |
+
|
| 23 |
+
# Track server start time
|
| 24 |
+
_SERVER_START_TIME: Optional[float] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _init_start_time() -> None:
|
| 28 |
+
"""Initialize server start time (called once at startup)."""
|
| 29 |
+
global _SERVER_START_TIME
|
| 30 |
+
if _SERVER_START_TIME is None:
|
| 31 |
+
_SERVER_START_TIME = time.time()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
_init_start_time()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ServerStatus(BaseModel):
|
| 38 |
+
"""Server status information."""
|
| 39 |
+
|
| 40 |
+
status: str
|
| 41 |
+
uptime_seconds: float
|
| 42 |
+
uptime_formatted: str
|
| 43 |
+
launch_mode: str
|
| 44 |
+
server_port: int
|
| 45 |
+
stream_port: int
|
| 46 |
+
version: str
|
| 47 |
+
python_version: str
|
| 48 |
+
started_at: str
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class RestartRequest(BaseModel):
|
| 52 |
+
"""Restart request with mode."""
|
| 53 |
+
|
| 54 |
+
mode: str = "headless" # headless, debug, virtual_display
|
| 55 |
+
confirm: bool = False
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _format_uptime(seconds: float) -> str:
|
| 59 |
+
"""Format uptime in human-readable format."""
|
| 60 |
+
days = int(seconds // 86400)
|
| 61 |
+
hours = int((seconds % 86400) // 3600)
|
| 62 |
+
minutes = int((seconds % 3600) // 60)
|
| 63 |
+
secs = int(seconds % 60)
|
| 64 |
+
|
| 65 |
+
parts = []
|
| 66 |
+
if days > 0:
|
| 67 |
+
parts.append(f"{days}d")
|
| 68 |
+
if hours > 0:
|
| 69 |
+
parts.append(f"{hours}h")
|
| 70 |
+
if minutes > 0:
|
| 71 |
+
parts.append(f"{minutes}m")
|
| 72 |
+
parts.append(f"{secs}s")
|
| 73 |
+
|
| 74 |
+
return " ".join(parts)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.get("/status")
|
| 78 |
+
async def get_server_status() -> JSONResponse:
|
| 79 |
+
"""Get server status information."""
|
| 80 |
+
import sys
|
| 81 |
+
|
| 82 |
+
uptime = time.time() - (_SERVER_START_TIME or time.time())
|
| 83 |
+
started_at = datetime.fromtimestamp(_SERVER_START_TIME or time.time())
|
| 84 |
+
|
| 85 |
+
status = ServerStatus(
|
| 86 |
+
status="running",
|
| 87 |
+
uptime_seconds=round(uptime, 2),
|
| 88 |
+
uptime_formatted=_format_uptime(uptime),
|
| 89 |
+
launch_mode=os.environ.get("LAUNCH_MODE", "unknown"),
|
| 90 |
+
server_port=int(
|
| 91 |
+
os.environ.get("SERVER_PORT_INFO", os.environ.get("PORT", 2048))
|
| 92 |
+
),
|
| 93 |
+
stream_port=int(os.environ.get("STREAM_PORT", 3120)),
|
| 94 |
+
version=VERSION,
|
| 95 |
+
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
| 96 |
+
started_at=started_at.isoformat(),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return JSONResponse(content=status.model_dump())
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.post("/restart")
|
| 103 |
+
async def restart_server(request: RestartRequest) -> JSONResponse:
|
| 104 |
+
"""
|
| 105 |
+
Request server restart.
|
| 106 |
+
|
| 107 |
+
Note: This operation terminates the current process and requires an external process manager to restart.
|
| 108 |
+
"""
|
| 109 |
+
if not request.confirm:
|
| 110 |
+
return JSONResponse(
|
| 111 |
+
content={
|
| 112 |
+
"success": False,
|
| 113 |
+
"message": "Restart operation requires confirmation. Please set confirm=true",
|
| 114 |
+
},
|
| 115 |
+
status_code=400,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
valid_modes = ["headless", "debug", "virtual_display"]
|
| 119 |
+
if request.mode not in valid_modes:
|
| 120 |
+
return JSONResponse(
|
| 121 |
+
content={
|
| 122 |
+
"success": False,
|
| 123 |
+
"message": f"Invalid launch mode. Valid options: {valid_modes}",
|
| 124 |
+
},
|
| 125 |
+
status_code=400,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
logger.info(f"[Server] Received restart request, target mode: {request.mode}")
|
| 129 |
+
|
| 130 |
+
# Set environment variable for next launch
|
| 131 |
+
os.environ["REQUESTED_RESTART_MODE"] = request.mode
|
| 132 |
+
|
| 133 |
+
# Return success - actual restart needs to be handled by process manager
|
| 134 |
+
return JSONResponse(
|
| 135 |
+
content={
|
| 136 |
+
"success": True,
|
| 137 |
+
"message": f"Server will restart in {request.mode} mode. Please refresh the page.",
|
| 138 |
+
"mode": request.mode,
|
| 139 |
+
}
|
| 140 |
+
)
|
api_utils/routers/static.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Static files serving routes
|
| 3 |
+
Uses FastAPI/Starlette native static files service
|
| 4 |
+
|
| 5 |
+
Optimization points:
|
| 6 |
+
- Use StaticFiles for high-performance static file serving
|
| 7 |
+
- Automatic handling of cache headers, byte-range requests, directory traversal protection
|
| 8 |
+
- SPA routing uses catch-all to return only index.html
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from fastapi import Depends, HTTPException
|
| 15 |
+
from fastapi.responses import FileResponse
|
| 16 |
+
from fastapi.staticfiles import StaticFiles
|
| 17 |
+
|
| 18 |
+
from ..dependencies import get_logger
|
| 19 |
+
|
| 20 |
+
_BASE_DIR = Path(__file__).parent.parent.parent
|
| 21 |
+
|
| 22 |
+
# React build directory
|
| 23 |
+
_REACT_DIST = _BASE_DIR / "static" / "frontend" / "dist"
|
| 24 |
+
_REACT_ASSETS = _REACT_DIST / "assets"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_static_files_app() -> StaticFiles | None:
|
| 28 |
+
"""
|
| 29 |
+
Create a StaticFiles app for the assets directory.
|
| 30 |
+
|
| 31 |
+
Returns None if the directory doesn't exist (frontend not built).
|
| 32 |
+
"""
|
| 33 |
+
if _REACT_ASSETS.exists():
|
| 34 |
+
return StaticFiles(directory=str(_REACT_ASSETS))
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def read_index(logger: logging.Logger = Depends(get_logger)) -> FileResponse:
|
| 39 |
+
"""Serve React index.html for SPA routing."""
|
| 40 |
+
react_index = _REACT_DIST / "index.html"
|
| 41 |
+
if react_index.exists():
|
| 42 |
+
return FileResponse(react_index, media_type="text/html")
|
| 43 |
+
|
| 44 |
+
logger.error("React build not found - run 'npm run build' in static/frontend/")
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=503,
|
| 47 |
+
detail="Frontend not built. Run 'npm run build' in static/frontend/",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def serve_react_assets(
|
| 52 |
+
filename: str, logger: logging.Logger = Depends(get_logger)
|
| 53 |
+
) -> FileResponse:
|
| 54 |
+
"""
|
| 55 |
+
Serve React built assets (JS, CSS, etc.).
|
| 56 |
+
|
| 57 |
+
Note: For production deployments, consider mounting StaticFiles directly
|
| 58 |
+
in the app configuration for better performance:
|
| 59 |
+
|
| 60 |
+
from fastapi.staticfiles import StaticFiles
|
| 61 |
+
app.mount("/assets", StaticFiles(directory="static/frontend/dist/assets"))
|
| 62 |
+
|
| 63 |
+
This fallback route is provided for flexibility and development convenience.
|
| 64 |
+
"""
|
| 65 |
+
asset_path = _REACT_ASSETS / filename
|
| 66 |
+
|
| 67 |
+
if not asset_path.exists():
|
| 68 |
+
logger.debug(f"Asset not found: {asset_path}")
|
| 69 |
+
raise HTTPException(status_code=404, detail=f"Asset {filename} not found")
|
| 70 |
+
|
| 71 |
+
# Security: Prevent directory traversal
|
| 72 |
+
try:
|
| 73 |
+
asset_path.resolve().relative_to(_REACT_ASSETS.resolve())
|
| 74 |
+
except ValueError:
|
| 75 |
+
logger.warning(f"Directory traversal attempt blocked: {filename}")
|
| 76 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 77 |
+
|
| 78 |
+
# Determine media type based on suffix
|
| 79 |
+
suffix_to_media_type = {
|
| 80 |
+
".js": "application/javascript",
|
| 81 |
+
".css": "text/css",
|
| 82 |
+
".map": "application/json",
|
| 83 |
+
".svg": "image/svg+xml",
|
| 84 |
+
".png": "image/png",
|
| 85 |
+
".jpg": "image/jpeg",
|
| 86 |
+
".jpeg": "image/jpeg",
|
| 87 |
+
".gif": "image/gif",
|
| 88 |
+
".ico": "image/x-icon",
|
| 89 |
+
".woff": "font/woff",
|
| 90 |
+
".woff2": "font/woff2",
|
| 91 |
+
".ttf": "font/ttf",
|
| 92 |
+
".eot": "application/vnd.ms-fontobject",
|
| 93 |
+
}
|
| 94 |
+
media_type = suffix_to_media_type.get(asset_path.suffix.lower())
|
| 95 |
+
|
| 96 |
+
return FileResponse(asset_path, media_type=media_type)
|
api_utils/server_state.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized server state module.
|
| 3 |
+
|
| 4 |
+
This module contains all shared state variables that were previously in server.py.
|
| 5 |
+
It has NO imports from project modules (only stdlib), making it safe to import
|
| 6 |
+
at module level anywhere in the codebase without circular dependency issues.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
from api_utils.server_state import state
|
| 10 |
+
# Access state attributes
|
| 11 |
+
page = state.page_instance
|
| 12 |
+
state.current_ai_studio_model_id = "new-model"
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import logging
|
| 17 |
+
import multiprocessing
|
| 18 |
+
from asyncio import Event, Lock, Queue, Task
|
| 19 |
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from playwright.async_api import (
|
| 23 |
+
Browser as AsyncBrowser,
|
| 24 |
+
)
|
| 25 |
+
from playwright.async_api import (
|
| 26 |
+
Page as AsyncPage,
|
| 27 |
+
)
|
| 28 |
+
from playwright.async_api import (
|
| 29 |
+
Playwright as AsyncPlaywright,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
from api_utils.context_types import QueueItem
|
| 33 |
+
from models.logging import WebSocketConnectionManager
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ServerState:
|
| 37 |
+
"""
|
| 38 |
+
Centralized container for all server state.
|
| 39 |
+
|
| 40 |
+
This class holds all mutable state that needs to be shared across modules.
|
| 41 |
+
Using a class allows for better organization and easier testing (state can be reset).
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(self) -> None:
|
| 45 |
+
"""Initialize all state variables with default values."""
|
| 46 |
+
self.reset()
|
| 47 |
+
|
| 48 |
+
def reset(self) -> None:
|
| 49 |
+
"""Reset all state to initial values. Useful for testing."""
|
| 50 |
+
# --- Stream Queue ---
|
| 51 |
+
self.STREAM_QUEUE: Optional[multiprocessing.Queue] = None
|
| 52 |
+
self.STREAM_PROCESS: Optional[multiprocessing.Process] = None
|
| 53 |
+
|
| 54 |
+
# --- Playwright/Browser State ---
|
| 55 |
+
self.playwright_manager: Optional["AsyncPlaywright"] = None
|
| 56 |
+
self.browser_instance: Optional["AsyncBrowser"] = None
|
| 57 |
+
self.page_instance: Optional["AsyncPage"] = None
|
| 58 |
+
self.is_playwright_ready: bool = False
|
| 59 |
+
self.is_browser_connected: bool = False
|
| 60 |
+
self.is_page_ready: bool = False
|
| 61 |
+
self.is_initializing: bool = False
|
| 62 |
+
|
| 63 |
+
# --- Proxy Configuration ---
|
| 64 |
+
self.PLAYWRIGHT_PROXY_SETTINGS: Optional[Dict[str, str]] = None
|
| 65 |
+
|
| 66 |
+
# --- Model State ---
|
| 67 |
+
self.global_model_list_raw_json: Optional[str] = None
|
| 68 |
+
self.parsed_model_list: List[Dict[str, Any]] = []
|
| 69 |
+
self.model_list_fetch_event: Event = asyncio.Event()
|
| 70 |
+
self.current_ai_studio_model_id: Optional[str] = None
|
| 71 |
+
self.current_auth_profile_path: Optional[str] = None
|
| 72 |
+
self.model_switching_lock: Lock = Lock()
|
| 73 |
+
self.excluded_model_ids: Set[str] = set()
|
| 74 |
+
|
| 75 |
+
# --- Request Processing State ---
|
| 76 |
+
self.request_queue: "Optional[Queue[QueueItem]]" = None
|
| 77 |
+
self.processing_lock: Optional[Lock] = None
|
| 78 |
+
self.worker_task: "Optional[Task[None]]" = None
|
| 79 |
+
|
| 80 |
+
# --- Parameter Cache ---
|
| 81 |
+
self.page_params_cache: Dict[str, Any] = {}
|
| 82 |
+
self.params_cache_lock: Lock = Lock()
|
| 83 |
+
|
| 84 |
+
# --- Debug Logging State ---
|
| 85 |
+
self.console_logs: List[Dict[str, Any]] = []
|
| 86 |
+
self.network_log: Dict[str, List[Dict[str, Any]]] = {
|
| 87 |
+
"requests": [],
|
| 88 |
+
"responses": [],
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
# --- Logging ---
|
| 92 |
+
self.logger: logging.Logger = logging.getLogger("AIStudioProxyServer")
|
| 93 |
+
self.log_ws_manager: Optional["WebSocketConnectionManager"] = None
|
| 94 |
+
|
| 95 |
+
# --- Control Flags ---
|
| 96 |
+
self.should_exit: bool = False
|
| 97 |
+
self.quota_watchdog: Optional[Callable] = None
|
| 98 |
+
|
| 99 |
+
def clear_debug_logs(self) -> None:
|
| 100 |
+
"""Clear console and network logs (called after each request)."""
|
| 101 |
+
self.console_logs = []
|
| 102 |
+
self.network_log = {"requests": [], "responses": []}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# Global singleton instance
|
| 106 |
+
state = ServerState()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# Convenience exports for backward compatibility
|
| 110 |
+
# These allow direct attribute access like: from server_state import page_instance
|
| 111 |
+
# But the recommended way is: from api_utils.server_state import state; state.page_instance
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def __getattr__(name: str) -> Any:
|
| 115 |
+
"""
|
| 116 |
+
Module-level attribute access for backward compatibility.
|
| 117 |
+
|
| 118 |
+
Allows:
|
| 119 |
+
from server_state import page_instance
|
| 120 |
+
Instead of:
|
| 121 |
+
from api_utils.server_state import state
|
| 122 |
+
page_instance = state.page_instance
|
| 123 |
+
"""
|
| 124 |
+
if hasattr(state, name):
|
| 125 |
+
return getattr(state, name)
|
| 126 |
+
raise AttributeError(f"module 'server_state' has no attribute '{name}'")
|
api_utils/sse.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import time
|
| 3 |
+
from typing import Any, Dict, Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def generate_sse_chunk(delta: str, req_id: str, model: str) -> str:
|
| 7 |
+
chunk_data: Dict[str, Any] = {
|
| 8 |
+
"id": f"chatcmpl-{req_id}",
|
| 9 |
+
"object": "chat.completion.chunk",
|
| 10 |
+
"created": int(time.time()),
|
| 11 |
+
"model": model,
|
| 12 |
+
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
|
| 13 |
+
}
|
| 14 |
+
return f"data: {json.dumps(chunk_data)}\n\n"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def generate_sse_stop_chunk(
|
| 18 |
+
req_id: str,
|
| 19 |
+
model: str,
|
| 20 |
+
reason: str = "stop",
|
| 21 |
+
usage: Optional[Dict[str, int]] = None,
|
| 22 |
+
) -> str:
|
| 23 |
+
stop_chunk_data: Dict[str, Any] = {
|
| 24 |
+
"id": f"chatcmpl-{req_id}",
|
| 25 |
+
"object": "chat.completion.chunk",
|
| 26 |
+
"created": int(time.time()),
|
| 27 |
+
"model": model,
|
| 28 |
+
"choices": [{"index": 0, "delta": {}, "finish_reason": reason}],
|
| 29 |
+
}
|
| 30 |
+
if usage:
|
| 31 |
+
stop_chunk_data["usage"] = usage
|
| 32 |
+
return f"data: {json.dumps(stop_chunk_data)}\n\ndata: [DONE]\n\n"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def generate_sse_error_chunk(
|
| 36 |
+
message: str, req_id: str, error_type: str = "server_error"
|
| 37 |
+
) -> str:
|
| 38 |
+
error_chunk = {
|
| 39 |
+
"error": {"message": message, "type": error_type, "param": None, "code": req_id}
|
| 40 |
+
}
|
| 41 |
+
return f"data: {json.dumps(error_chunk)}\n\n"
|
api_utils/tools_registry.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
from typing import Any, Dict, List, Optional, Set
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def tool_get_current_time(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 9 |
+
return {"current_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def tool_echo(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 13 |
+
return {"echo": params}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def tool_sum(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 17 |
+
values = params.get("values")
|
| 18 |
+
if isinstance(values, list):
|
| 19 |
+
try:
|
| 20 |
+
total = sum(float(v) for v in values)
|
| 21 |
+
except Exception:
|
| 22 |
+
total = None
|
| 23 |
+
else:
|
| 24 |
+
total = None
|
| 25 |
+
return {"sum": total, "count": len(values) if isinstance(values, list) else 0}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
FUNCTION_REGISTRY = {
|
| 29 |
+
"get_current_time": tool_get_current_time,
|
| 30 |
+
"echo": tool_echo,
|
| 31 |
+
"sum": tool_sum,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# Runtime-allowed tool names from incoming requests (OpenAI tools array)
|
| 35 |
+
_ALLOWED_RUNTIME_TOOLS: Set[str] = set()
|
| 36 |
+
_runtime_mcp_endpoint: Optional[str] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def register_runtime_tools(
|
| 40 |
+
tools: Optional[List[Dict[str, Any]]], mcp_endpoint: Optional[str] = None
|
| 41 |
+
) -> None:
|
| 42 |
+
"""Register tool names declared in the request as allowed.
|
| 43 |
+
The server may delegate unknown tools to MCP if configured.
|
| 44 |
+
"""
|
| 45 |
+
# Reset per-request registry to avoid leakage across requests
|
| 46 |
+
global _runtime_mcp_endpoint
|
| 47 |
+
_ALLOWED_RUNTIME_TOOLS.clear()
|
| 48 |
+
_runtime_mcp_endpoint = None
|
| 49 |
+
if not tools:
|
| 50 |
+
return
|
| 51 |
+
try:
|
| 52 |
+
for t in tools:
|
| 53 |
+
name = None
|
| 54 |
+
fn = t.get("function") if "function" in t else t
|
| 55 |
+
if isinstance(fn, dict):
|
| 56 |
+
name = fn.get("name") or t.get("name")
|
| 57 |
+
else:
|
| 58 |
+
name = t.get("name")
|
| 59 |
+
if name:
|
| 60 |
+
_ALLOWED_RUNTIME_TOOLS.add(str(name))
|
| 61 |
+
# Detect per-tool endpoint extension
|
| 62 |
+
ext_ep = (
|
| 63 |
+
t.get("x-mcp-endpoint")
|
| 64 |
+
or t.get("x_mcp_endpoint")
|
| 65 |
+
or (
|
| 66 |
+
isinstance(t.get("function"), dict)
|
| 67 |
+
and t["function"].get("x-mcp-endpoint")
|
| 68 |
+
)
|
| 69 |
+
or None
|
| 70 |
+
)
|
| 71 |
+
if ext_ep and not mcp_endpoint:
|
| 72 |
+
mcp_endpoint = ext_ep
|
| 73 |
+
# Capture per-request MCP endpoint if provided (explicit or via tool extension)
|
| 74 |
+
if mcp_endpoint:
|
| 75 |
+
_runtime_mcp_endpoint = mcp_endpoint
|
| 76 |
+
except Exception:
|
| 77 |
+
# be forgiving on malformed tools
|
| 78 |
+
pass
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def execute_tool_call(name: str, arguments_json: str) -> str:
|
| 82 |
+
"""Execute registered tools and return stringified result. Unknown tools return descriptive errors.
|
| 83 |
+
Fully asynchronous: built-in functions execute directly; MCP path uses async httpx client.
|
| 84 |
+
"""
|
| 85 |
+
try:
|
| 86 |
+
params = json.loads(arguments_json or "{}")
|
| 87 |
+
except Exception:
|
| 88 |
+
params = {}
|
| 89 |
+
|
| 90 |
+
func = FUNCTION_REGISTRY.get(name)
|
| 91 |
+
if not func:
|
| 92 |
+
# If tool is not built-in but declared, try MCP adapter if configured (env or per-request)
|
| 93 |
+
if name in _ALLOWED_RUNTIME_TOOLS:
|
| 94 |
+
try:
|
| 95 |
+
from api_utils.mcp_adapter import (
|
| 96 |
+
execute_mcp_tool,
|
| 97 |
+
execute_mcp_tool_with_endpoint,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if _runtime_mcp_endpoint:
|
| 101 |
+
return await execute_mcp_tool_with_endpoint(
|
| 102 |
+
_runtime_mcp_endpoint, name, params
|
| 103 |
+
)
|
| 104 |
+
if os.environ.get("MCP_HTTP_ENDPOINT"):
|
| 105 |
+
return await execute_mcp_tool(name, params)
|
| 106 |
+
except asyncio.CancelledError:
|
| 107 |
+
raise
|
| 108 |
+
except Exception as e:
|
| 109 |
+
return json.dumps(
|
| 110 |
+
{"error": f"MCP execution failed: {e}"}, ensure_ascii=False
|
| 111 |
+
)
|
| 112 |
+
return json.dumps(
|
| 113 |
+
{"error": f"Unknown tool: {name}", "arguments": params}, ensure_ascii=False
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
result = func(params)
|
| 118 |
+
return json.dumps(result, ensure_ascii=False)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
return json.dumps({"error": f"Execution failed: {e}"}, ensure_ascii=False)
|
api_utils/utils.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API utility function module
|
| 3 |
+
Contains utility functions for SSE generation, stream processing, token statistics, and request validation
|
| 4 |
+
(Refactored: logic moved to api_utils.utils_ext submodules)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
from .sse import generate_sse_stop_chunk
|
| 9 |
+
from .utils_ext import (
|
| 10 |
+
_extension_for_mime,
|
| 11 |
+
calculate_usage_stats,
|
| 12 |
+
clear_stream_queue,
|
| 13 |
+
collect_and_validate_attachments,
|
| 14 |
+
estimate_tokens,
|
| 15 |
+
extract_data_url_to_local,
|
| 16 |
+
extract_json_from_text,
|
| 17 |
+
get_latest_user_text,
|
| 18 |
+
maybe_execute_tools,
|
| 19 |
+
prepare_combined_prompt,
|
| 20 |
+
save_blob_to_local,
|
| 21 |
+
use_helper_get_response,
|
| 22 |
+
use_stream_response,
|
| 23 |
+
validate_chat_request,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# For backward compatibility with existing code that might import these private functions
|
| 27 |
+
_extract_json_from_text = extract_json_from_text
|
| 28 |
+
_get_latest_user_text = get_latest_user_text
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def generate_sse_stop_chunk_with_usage(req_id: str, model: str, usage_stats: dict, reason: str = "stop") -> str:
|
| 32 |
+
"""Generate SSE stop chunk with usage statistics"""
|
| 33 |
+
return generate_sse_stop_chunk(req_id, model, reason, usage_stats)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
"generate_sse_stop_chunk_with_usage",
|
| 38 |
+
"extract_data_url_to_local",
|
| 39 |
+
"save_blob_to_local",
|
| 40 |
+
"collect_and_validate_attachments",
|
| 41 |
+
"prepare_combined_prompt",
|
| 42 |
+
"maybe_execute_tools",
|
| 43 |
+
"extract_json_from_text",
|
| 44 |
+
"get_latest_user_text",
|
| 45 |
+
"use_stream_response",
|
| 46 |
+
"clear_stream_queue",
|
| 47 |
+
"use_helper_get_response",
|
| 48 |
+
"validate_chat_request",
|
| 49 |
+
"estimate_tokens",
|
| 50 |
+
"calculate_usage_stats",
|
| 51 |
+
"_extension_for_mime",
|
| 52 |
+
]
|
api_utils/utils_ext/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Extended utility submodules extracted from api_utils.utils.
|
| 3 |
+
This package groups stream, helper, validation, files, and tokens utilities.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from .files import (
|
| 7 |
+
_extension_for_mime,
|
| 8 |
+
collect_and_validate_attachments,
|
| 9 |
+
extract_data_url_to_local,
|
| 10 |
+
save_blob_to_local,
|
| 11 |
+
)
|
| 12 |
+
from .function_call_response_parser import (
|
| 13 |
+
FunctionCallParseResult,
|
| 14 |
+
FunctionCallResponseParser,
|
| 15 |
+
format_function_calls_to_openai,
|
| 16 |
+
)
|
| 17 |
+
from .function_calling import (
|
| 18 |
+
CallIdManager,
|
| 19 |
+
FunctionCallingConfig,
|
| 20 |
+
FunctionCallingMode,
|
| 21 |
+
ParsedFunctionCall,
|
| 22 |
+
PendingCall,
|
| 23 |
+
ResponseFormatter,
|
| 24 |
+
SchemaConversionError,
|
| 25 |
+
SchemaConverter,
|
| 26 |
+
build_assistant_message_with_tool_calls,
|
| 27 |
+
convert_openai_tools_to_gemini,
|
| 28 |
+
create_tool_calls_response,
|
| 29 |
+
get_finish_reason,
|
| 30 |
+
)
|
| 31 |
+
from .function_calling_cache import (
|
| 32 |
+
FunctionCallingCache,
|
| 33 |
+
FunctionCallingCacheEntry,
|
| 34 |
+
)
|
| 35 |
+
from .function_calling_orchestrator import (
|
| 36 |
+
FunctionCallingOrchestrator,
|
| 37 |
+
FunctionCallingState,
|
| 38 |
+
NativeFunctionCallingError,
|
| 39 |
+
get_effective_function_calling_mode,
|
| 40 |
+
get_function_calling_orchestrator,
|
| 41 |
+
reset_orchestrator,
|
| 42 |
+
should_skip_tool_injection,
|
| 43 |
+
)
|
| 44 |
+
from .helper import use_helper_get_response
|
| 45 |
+
from .prompts import prepare_combined_prompt
|
| 46 |
+
from .stream import clear_stream_queue, use_stream_response
|
| 47 |
+
from .string_utils import extract_json_from_text, get_latest_user_text
|
| 48 |
+
from .tokens import calculate_usage_stats, estimate_tokens
|
| 49 |
+
from .tools_execution import maybe_execute_tools
|
| 50 |
+
from .validation import validate_chat_request
|
| 51 |
+
|
| 52 |
+
__all__ = [
|
| 53 |
+
"use_stream_response",
|
| 54 |
+
"clear_stream_queue",
|
| 55 |
+
"use_helper_get_response",
|
| 56 |
+
"validate_chat_request",
|
| 57 |
+
"_extension_for_mime",
|
| 58 |
+
"extract_data_url_to_local",
|
| 59 |
+
"save_blob_to_local",
|
| 60 |
+
"collect_and_validate_attachments",
|
| 61 |
+
"estimate_tokens",
|
| 62 |
+
"calculate_usage_stats",
|
| 63 |
+
"prepare_combined_prompt",
|
| 64 |
+
"maybe_execute_tools",
|
| 65 |
+
"extract_json_from_text",
|
| 66 |
+
"get_latest_user_text",
|
| 67 |
+
# Function Calling utilities
|
| 68 |
+
"FunctionCallingMode",
|
| 69 |
+
"FunctionCallingConfig",
|
| 70 |
+
"SchemaConverter",
|
| 71 |
+
"SchemaConversionError",
|
| 72 |
+
"CallIdManager",
|
| 73 |
+
"PendingCall",
|
| 74 |
+
"ParsedFunctionCall",
|
| 75 |
+
"ResponseFormatter",
|
| 76 |
+
"build_assistant_message_with_tool_calls",
|
| 77 |
+
"get_finish_reason",
|
| 78 |
+
"convert_openai_tools_to_gemini",
|
| 79 |
+
"create_tool_calls_response",
|
| 80 |
+
# Function Calling Cache
|
| 81 |
+
"FunctionCallingCache",
|
| 82 |
+
"FunctionCallingCacheEntry",
|
| 83 |
+
# Function Calling Orchestrator
|
| 84 |
+
"FunctionCallingOrchestrator",
|
| 85 |
+
"FunctionCallingState",
|
| 86 |
+
"NativeFunctionCallingError",
|
| 87 |
+
"get_function_calling_orchestrator",
|
| 88 |
+
"reset_orchestrator",
|
| 89 |
+
"should_skip_tool_injection",
|
| 90 |
+
"get_effective_function_calling_mode",
|
| 91 |
+
# Function Call Response Parser
|
| 92 |
+
"FunctionCallResponseParser",
|
| 93 |
+
"FunctionCallParseResult",
|
| 94 |
+
"format_function_calls_to_openai",
|
| 95 |
+
]
|
api_utils/utils_ext/cooldown_manager.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import threading
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
COOLDOWN_FILE = os.path.join(
|
| 8 |
+
os.path.dirname(__file__), "..", "..", "config", "cooldown_status.json"
|
| 9 |
+
)
|
| 10 |
+
_lock = threading.Lock()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_cooldown_profiles():
|
| 14 |
+
"""
|
| 15 |
+
Loads the cooldown profiles from the persistent JSON file.
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
dict: A dictionary of cooldown profiles.
|
| 19 |
+
"""
|
| 20 |
+
with _lock:
|
| 21 |
+
if not os.path.exists(COOLDOWN_FILE):
|
| 22 |
+
return {}
|
| 23 |
+
try:
|
| 24 |
+
with open(COOLDOWN_FILE, "r") as f:
|
| 25 |
+
data = json.load(f)
|
| 26 |
+
|
| 27 |
+
profiles = {}
|
| 28 |
+
for profile, val in data.items():
|
| 29 |
+
if isinstance(val, dict):
|
| 30 |
+
# Handle nested model-specific cooldowns
|
| 31 |
+
model_cooldowns = {}
|
| 32 |
+
for model_id, ts in val.items():
|
| 33 |
+
try:
|
| 34 |
+
model_cooldowns[model_id] = datetime.fromisoformat(ts)
|
| 35 |
+
except (ValueError, TypeError):
|
| 36 |
+
continue
|
| 37 |
+
if model_cooldowns:
|
| 38 |
+
# Clean up redundant "default" entries when specific models exist
|
| 39 |
+
has_specific_models = any(
|
| 40 |
+
model_id != "default" for model_id in model_cooldowns.keys()
|
| 41 |
+
)
|
| 42 |
+
if has_specific_models and "default" in model_cooldowns:
|
| 43 |
+
logger = logging.getLogger("CooldownManager")
|
| 44 |
+
logger.info(
|
| 45 |
+
f"🧹 Cleaning up redundant 'default' entry for profile {os.path.basename(profile)}"
|
| 46 |
+
)
|
| 47 |
+
del model_cooldowns["default"]
|
| 48 |
+
profiles[profile] = model_cooldowns
|
| 49 |
+
else:
|
| 50 |
+
# Handle legacy/global cooldowns
|
| 51 |
+
try:
|
| 52 |
+
profiles[profile] = datetime.fromisoformat(val)
|
| 53 |
+
except (ValueError, TypeError):
|
| 54 |
+
continue
|
| 55 |
+
return profiles
|
| 56 |
+
except (json.JSONDecodeError, IOError):
|
| 57 |
+
return {}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def save_cooldown_profiles(profiles):
|
| 61 |
+
"""
|
| 62 |
+
Saves the cooldown profiles to the persistent JSON file.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
profiles (dict): A dictionary of cooldown profiles to save.
|
| 66 |
+
"""
|
| 67 |
+
with _lock:
|
| 68 |
+
try:
|
| 69 |
+
serializable_profiles = {}
|
| 70 |
+
for profile, data in profiles.items():
|
| 71 |
+
if isinstance(data, dict):
|
| 72 |
+
# Handle nested model-specific cooldowns
|
| 73 |
+
model_cooldowns = {}
|
| 74 |
+
for model_id, ts in data.items():
|
| 75 |
+
if isinstance(ts, datetime):
|
| 76 |
+
model_cooldowns[model_id] = ts.isoformat()
|
| 77 |
+
elif isinstance(ts, (int, float)):
|
| 78 |
+
try:
|
| 79 |
+
model_cooldowns[model_id] = datetime.fromtimestamp(
|
| 80 |
+
ts
|
| 81 |
+
).isoformat()
|
| 82 |
+
except (ValueError, OSError):
|
| 83 |
+
pass
|
| 84 |
+
serializable_profiles[profile] = model_cooldowns
|
| 85 |
+
|
| 86 |
+
elif isinstance(data, datetime):
|
| 87 |
+
serializable_profiles[profile] = data.isoformat()
|
| 88 |
+
elif isinstance(data, (int, float)):
|
| 89 |
+
try:
|
| 90 |
+
serializable_profiles[profile] = datetime.fromtimestamp(
|
| 91 |
+
data
|
| 92 |
+
).isoformat()
|
| 93 |
+
except (ValueError, OSError):
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
with open(COOLDOWN_FILE, "w") as f:
|
| 97 |
+
json.dump(serializable_profiles, f, indent=4)
|
| 98 |
+
except IOError:
|
| 99 |
+
pass
|
api_utils/utils_ext/files.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import binascii
|
| 3 |
+
import hashlib
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any, Dict, List, Optional, cast
|
| 8 |
+
from urllib.parse import unquote, urlparse
|
| 9 |
+
|
| 10 |
+
from logging_utils import set_request_id
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _extension_for_mime(mime_type: str) -> str:
|
| 14 |
+
mime_type = (mime_type or "").lower()
|
| 15 |
+
mapping = {
|
| 16 |
+
"image/png": ".png",
|
| 17 |
+
"image/jpeg": ".jpg",
|
| 18 |
+
"image/jpg": ".jpg",
|
| 19 |
+
"image/gif": ".gif",
|
| 20 |
+
"image/webp": ".webp",
|
| 21 |
+
"image/svg+xml": ".svg",
|
| 22 |
+
"image/bmp": ".bmp",
|
| 23 |
+
"video/mp4": ".mp4",
|
| 24 |
+
"video/webm": ".webm",
|
| 25 |
+
"video/ogg": ".ogv",
|
| 26 |
+
"audio/mpeg": ".mp3",
|
| 27 |
+
"audio/mp3": ".mp3",
|
| 28 |
+
"audio/wav": ".wav",
|
| 29 |
+
"audio/ogg": ".ogg",
|
| 30 |
+
"audio/webm": ".weba",
|
| 31 |
+
"application/pdf": ".pdf",
|
| 32 |
+
"application/zip": ".zip",
|
| 33 |
+
"application/x-zip-compressed": ".zip",
|
| 34 |
+
"application/json": ".json",
|
| 35 |
+
"text/plain": ".txt",
|
| 36 |
+
"text/markdown": ".md",
|
| 37 |
+
"text/html": ".html",
|
| 38 |
+
}
|
| 39 |
+
return mapping.get(
|
| 40 |
+
mime_type, f".{mime_type.split('/')[-1]}" if "/" in mime_type else ".bin"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def extract_data_url_to_local(
|
| 45 |
+
data_url: str, req_id: Optional[str] = None
|
| 46 |
+
) -> Optional[str]:
|
| 47 |
+
from config import UPLOAD_FILES_DIR
|
| 48 |
+
|
| 49 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 50 |
+
|
| 51 |
+
output_dir = (
|
| 52 |
+
UPLOAD_FILES_DIR if req_id is None else os.path.join(UPLOAD_FILES_DIR, req_id)
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
match = re.match(r"^data:(?P<mime>[^;]+);base64,(?P<data>.*)$", data_url)
|
| 56 |
+
if not match:
|
| 57 |
+
logger.error(
|
| 58 |
+
"Error: data:URL format is incorrect or does not contain base64 data."
|
| 59 |
+
)
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
mime_type = match.group("mime")
|
| 63 |
+
encoded_data = match.group("data")
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
decoded_bytes = base64.b64decode(encoded_data)
|
| 67 |
+
except binascii.Error as e:
|
| 68 |
+
logger.error(f"Error: Base64 decoding failed - {e}")
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
md5_hash = hashlib.md5(decoded_bytes).hexdigest()
|
| 72 |
+
file_extension = _extension_for_mime(mime_type)
|
| 73 |
+
output_filepath = os.path.join(output_dir, f"{md5_hash}{file_extension}")
|
| 74 |
+
|
| 75 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 76 |
+
|
| 77 |
+
if os.path.exists(output_filepath):
|
| 78 |
+
logger.info(f"File already exists, skipping save: {output_filepath}")
|
| 79 |
+
return output_filepath
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
with open(output_filepath, "wb") as f:
|
| 83 |
+
f.write(decoded_bytes)
|
| 84 |
+
logger.info(f"Saved data:URL to: {output_filepath}")
|
| 85 |
+
return output_filepath
|
| 86 |
+
except IOError as e:
|
| 87 |
+
logger.error(f"Error: Failed to save file - {e}")
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def save_blob_to_local(
|
| 92 |
+
raw_bytes: bytes,
|
| 93 |
+
mime_type: Optional[str] = None,
|
| 94 |
+
fmt_ext: Optional[str] = None,
|
| 95 |
+
req_id: Optional[str] = None,
|
| 96 |
+
) -> Optional[str]:
|
| 97 |
+
from config import UPLOAD_FILES_DIR
|
| 98 |
+
|
| 99 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 100 |
+
|
| 101 |
+
output_dir = (
|
| 102 |
+
UPLOAD_FILES_DIR if req_id is None else os.path.join(UPLOAD_FILES_DIR, req_id)
|
| 103 |
+
)
|
| 104 |
+
md5_hash = hashlib.md5(raw_bytes).hexdigest()
|
| 105 |
+
ext = None
|
| 106 |
+
if fmt_ext:
|
| 107 |
+
fmt_ext = fmt_ext.strip(". ")
|
| 108 |
+
ext = f".{fmt_ext}" if fmt_ext else None
|
| 109 |
+
if not ext and mime_type:
|
| 110 |
+
ext = _extension_for_mime(mime_type)
|
| 111 |
+
if not ext:
|
| 112 |
+
ext = ".bin"
|
| 113 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 114 |
+
output_filepath = os.path.join(output_dir, f"{md5_hash}{ext}")
|
| 115 |
+
if os.path.exists(output_filepath):
|
| 116 |
+
logger.info(f"File already exists, skipping save: {output_filepath}")
|
| 117 |
+
return output_filepath
|
| 118 |
+
try:
|
| 119 |
+
with open(output_filepath, "wb") as f:
|
| 120 |
+
f.write(raw_bytes)
|
| 121 |
+
logger.info(f"Saved binary to: {output_filepath}")
|
| 122 |
+
return output_filepath
|
| 123 |
+
except IOError as e:
|
| 124 |
+
logger.error(f"Error: Failed to save binary - {e}")
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def collect_and_validate_attachments(
|
| 129 |
+
request: Any, req_id: str, initial_image_list: List[str]
|
| 130 |
+
) -> List[str]:
|
| 131 |
+
"""
|
| 132 |
+
Collect and validate attachments in the request (including top-level and message-level), merged into image_list.
|
| 133 |
+
"""
|
| 134 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 135 |
+
|
| 136 |
+
# 1. Validate initial list
|
| 137 |
+
valid_images: List[str] = []
|
| 138 |
+
for p in initial_image_list:
|
| 139 |
+
if p and os.path.isabs(p) and os.path.exists(p):
|
| 140 |
+
valid_images.append(p)
|
| 141 |
+
|
| 142 |
+
set_request_id(req_id)
|
| 143 |
+
if len(valid_images) != len(initial_image_list):
|
| 144 |
+
logger.warning(
|
| 145 |
+
f"Filtered out non-existent attachment paths: {set(initial_image_list) - set(valid_images)}"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
image_list: List[str] = valid_images
|
| 149 |
+
|
| 150 |
+
# 2. Collect from request
|
| 151 |
+
def _process_attachments_list(items_list: List[Any], container_desc: str):
|
| 152 |
+
for it in items_list:
|
| 153 |
+
url_value: Optional[str] = None
|
| 154 |
+
if isinstance(it, str):
|
| 155 |
+
url_value = it
|
| 156 |
+
elif isinstance(it, dict):
|
| 157 |
+
typed_it: Dict[str, Any] = cast(Dict[str, Any], it)
|
| 158 |
+
url_raw: Any = typed_it.get("url") or typed_it.get("path")
|
| 159 |
+
if isinstance(url_raw, str):
|
| 160 |
+
url_value = url_raw
|
| 161 |
+
if not url_value:
|
| 162 |
+
continue
|
| 163 |
+
url_value = url_value.strip()
|
| 164 |
+
if not url_value:
|
| 165 |
+
continue
|
| 166 |
+
|
| 167 |
+
if url_value.startswith("data:"):
|
| 168 |
+
fp = extract_data_url_to_local(url_value, req_id=req_id)
|
| 169 |
+
if fp:
|
| 170 |
+
image_list.append(fp)
|
| 171 |
+
elif url_value.startswith("file:"):
|
| 172 |
+
parsed = urlparse(url_value)
|
| 173 |
+
lp = unquote(parsed.path)
|
| 174 |
+
if os.path.exists(lp):
|
| 175 |
+
image_list.append(lp)
|
| 176 |
+
else:
|
| 177 |
+
logger.warning(
|
| 178 |
+
f"{container_desc} attachment file URL does not exist: {lp}"
|
| 179 |
+
)
|
| 180 |
+
elif os.path.isabs(url_value) and os.path.exists(url_value):
|
| 181 |
+
image_list.append(url_value)
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
# Top-level attachments
|
| 185 |
+
top_level_atts = getattr(request, "attachments", None)
|
| 186 |
+
if isinstance(top_level_atts, list) and len(top_level_atts) > 0:
|
| 187 |
+
_process_attachments_list(top_level_atts, "request.attachments")
|
| 188 |
+
|
| 189 |
+
# Message-level attachments/images/files/media
|
| 190 |
+
messages = getattr(request, "messages", None)
|
| 191 |
+
if isinstance(messages, list):
|
| 192 |
+
for i, msg in enumerate(messages):
|
| 193 |
+
for field in ["attachments", "images", "files", "media"]:
|
| 194 |
+
items = getattr(msg, field, None)
|
| 195 |
+
if isinstance(items, list) and len(items) > 0:
|
| 196 |
+
_process_attachments_list(items, f"message[{i}].{field}")
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"Error collecting attachments: {e}")
|
| 200 |
+
|
| 201 |
+
return image_list
|