peijun1 commited on
Commit
a5784e9
·
0 Parent(s):

Deploy AI Studio Proxy API to Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +31 -0
  2. .env.example +414 -0
  3. .gitattributes +79 -0
  4. .github/workflows/pr-check.yml +229 -0
  5. .github/workflows/release.yml +441 -0
  6. .github/workflows/upstream-sync.yml +324 -0
  7. .gitignore +296 -0
  8. CONTRIBUTING.md +73 -0
  9. Dockerfile +98 -0
  10. LICENSE +661 -0
  11. README.md +214 -0
  12. api_utils/__init__.py +74 -0
  13. api_utils/app.py +454 -0
  14. api_utils/auth_manager.py +89 -0
  15. api_utils/auth_utils.py +39 -0
  16. api_utils/client_connection.py +196 -0
  17. api_utils/common_utils.py +6 -0
  18. api_utils/context_init.py +38 -0
  19. api_utils/context_types.py +54 -0
  20. api_utils/dependencies.py +169 -0
  21. api_utils/error_utils.py +47 -0
  22. api_utils/mcp_adapter.py +57 -0
  23. api_utils/model_switching.py +121 -0
  24. api_utils/page_response.py +39 -0
  25. api_utils/queue_worker.py +467 -0
  26. api_utils/request_processor.py +975 -0
  27. api_utils/response_generators.py +613 -0
  28. api_utils/response_payloads.py +39 -0
  29. api_utils/routers/__init__.py +42 -0
  30. api_utils/routers/api_keys.py +122 -0
  31. api_utils/routers/auth_files.py +152 -0
  32. api_utils/routers/chat.py +92 -0
  33. api_utils/routers/health.py +66 -0
  34. api_utils/routers/helper.py +79 -0
  35. api_utils/routers/info.py +49 -0
  36. api_utils/routers/logs_ws.py +36 -0
  37. api_utils/routers/model_capabilities.py +104 -0
  38. api_utils/routers/models.py +70 -0
  39. api_utils/routers/ports.py +353 -0
  40. api_utils/routers/proxy.py +163 -0
  41. api_utils/routers/queue.py +99 -0
  42. api_utils/routers/server.py +140 -0
  43. api_utils/routers/static.py +96 -0
  44. api_utils/server_state.py +126 -0
  45. api_utils/sse.py +41 -0
  46. api_utils/tools_registry.py +120 -0
  47. api_utils/utils.py +52 -0
  48. api_utils/utils_ext/__init__.py +95 -0
  49. api_utils/utils_ext/cooldown_manager.py +99 -0
  50. 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
+ [![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/AIstudioProxyAPI&type=Date)](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