Skip to content

Commit d0f2efe

Browse files
authored
feat: refactor static file serving with StaticFiles and enhance port kill security by restricting PIDs to tracked processes. (#293)
1 parent 3f8186d commit d0f2efe

File tree

11 files changed

+433
-188
lines changed

11 files changed

+433
-188
lines changed

.env.example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,15 @@ AI_STUDIO_URL_PATTERN=aistudio.google.com/
179179

180180
# 排除模型列表文件 (相对于项目根目录)
181181
# 此文件中列出的模型将不会在 /v1/models 端点中返回
182-
EXCLUDED_MODELS_FILENAME=excluded_models.txt
182+
EXCLUDED_MODELS_FILENAME=excluded_models.txt
183+
184+
# =============================================================================
185+
# 9. 前端构建配置 (Frontend Build Configuration)
186+
# =============================================================================
187+
188+
# 跳过前端构建检查 (Skip Frontend Build)
189+
# 设置为 true 可跳过启动时的前端构建检查。
190+
# 适用于没有 Node.js/npm 的环境,或使用预构建资源。
191+
# 也可以通过命令行参数 --skip-frontend-build 来设置。
192+
# Options: true, false, 1, 0, yes, no
193+
SKIP_FRONTEND_BUILD=false

api_utils/routers/model_capabilities.py

Lines changed: 61 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,48 @@
44
SINGLE SOURCE OF TRUTH for model thinking capabilities.
55
Frontend fetches this to determine UI controls dynamically.
66
7-
When new models are released, update ONLY this file.
7+
Configuration is loaded from config/model_capabilities.json.
8+
When new models are released, update the JSON file - no code changes needed.
89
"""
910

10-
from typing import Literal
11+
import json
12+
import re
13+
from functools import lru_cache
14+
from pathlib import Path
15+
from typing import Any
1116

1217
from fastapi import APIRouter
1318
from fastapi.responses import JSONResponse
1419

1520
router = APIRouter()
1621

17-
# Model category types
18-
ThinkingType = Literal["level", "budget", "none"]
22+
# Config file path
23+
_CONFIG_PATH = (
24+
Path(__file__).parent.parent.parent / "config" / "model_capabilities.json"
25+
)
1926

2027

21-
def _get_model_capabilities(model_id: str) -> dict:
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]:
2249
"""
2350
Determine thinking capabilities for a model.
2451
@@ -27,73 +54,32 @@ def _get_model_capabilities(model_id: str) -> dict:
2754
- levels: List of thinking levels (for type="level")
2855
- alwaysOn: Whether thinking is always on (for Gemini 2.5 Pro)
2956
- budgetRange: [min, max] for budget slider
57+
- supportsGoogleSearch: Whether the model supports Google Search
3058
"""
59+
config = _load_config()
60+
categories = config.get("categories", {})
61+
matchers = config.get("matchers", [])
62+
3163
model_lower = model_id.lower()
3264

33-
# Gemini 3 Flash: 4-level selector
34-
if (
35-
"gemini-3" in model_lower or "gemini3" in model_lower
36-
) and "flash" in model_lower:
37-
return {
38-
"thinkingType": "level",
39-
"levels": ["minimal", "low", "medium", "high"],
40-
"defaultLevel": "high",
41-
"supportsGoogleSearch": True,
42-
}
43-
44-
# Gemini 3 Pro: 2-level selector
45-
if ("gemini-3" in model_lower or "gemini3" in model_lower) and "pro" in model_lower:
46-
return {
47-
"thinkingType": "level",
48-
"levels": ["low", "high"],
49-
"defaultLevel": "high",
50-
"supportsGoogleSearch": True,
51-
}
52-
53-
# Gemini 2.5 Pro: Always-on thinking with budget
54-
if "gemini-2.5-pro" in model_lower or "gemini-2.5pro" in model_lower:
55-
return {
56-
"thinkingType": "budget",
57-
"alwaysOn": True,
58-
"budgetRange": [1024, 32768],
59-
"defaultBudget": 32768,
60-
"supportsGoogleSearch": True,
61-
}
62-
63-
# Gemini 2.5 Flash and latest variants: Toggle + budget
64-
if (
65-
"gemini-2.5-flash" in model_lower
66-
or "gemini-2.5flash" in model_lower
67-
or model_lower == "gemini-flash-latest"
68-
or model_lower == "gemini-flash-lite-latest"
69-
):
70-
return {
71-
"thinkingType": "budget",
72-
"alwaysOn": False,
73-
"budgetRange": [512, 24576],
74-
"defaultBudget": 24576,
75-
"supportsGoogleSearch": True,
76-
}
77-
78-
# Gemini 2.0 models: No thinking, no Google Search
79-
if "gemini-2.0" in model_lower or "gemini2.0" in model_lower:
80-
return {
81-
"thinkingType": "none",
82-
"supportsGoogleSearch": False,
83-
}
84-
85-
# Gemini robotics models: special case - has Google Search
86-
if "gemini-robotics" in model_lower:
87-
return {
88-
"thinkingType": "none",
89-
"supportsGoogleSearch": True,
90-
}
91-
92-
# Other models: No thinking controls, default to Google Search enabled
93-
return {
94-
"thinkingType": "none",
95-
"supportsGoogleSearch": True,
96-
}
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+
)
9783

9884

9985
@router.get("/api/model-capabilities")
@@ -103,68 +89,16 @@ async def get_model_capabilities() -> JSONResponse:
10389
10490
Frontend uses this to dynamically configure thinking controls.
10591
"""
106-
return JSONResponse(
107-
content={
108-
"categories": {
109-
"gemini3Flash": {
110-
"thinkingType": "level",
111-
"levels": ["minimal", "low", "medium", "high"],
112-
"defaultLevel": "high",
113-
"supportsGoogleSearch": True,
114-
},
115-
"gemini3Pro": {
116-
"thinkingType": "level",
117-
"levels": ["low", "high"],
118-
"defaultLevel": "high",
119-
"supportsGoogleSearch": True,
120-
},
121-
"gemini25Pro": {
122-
"thinkingType": "budget",
123-
"alwaysOn": True,
124-
"budgetRange": [1024, 32768],
125-
"defaultBudget": 32768,
126-
"supportsGoogleSearch": True,
127-
},
128-
"gemini25Flash": {
129-
"thinkingType": "budget",
130-
"alwaysOn": False,
131-
"budgetRange": [512, 24576],
132-
"defaultBudget": 24576,
133-
"supportsGoogleSearch": True,
134-
},
135-
"gemini2": {
136-
"thinkingType": "none",
137-
"supportsGoogleSearch": False,
138-
},
139-
"other": {
140-
"thinkingType": "none",
141-
"supportsGoogleSearch": True,
142-
},
143-
},
144-
"matchers": [
145-
# Order matters: more specific patterns first
146-
{
147-
"pattern": "gemini-3.*flash|gemini3.*flash",
148-
"category": "gemini3Flash",
149-
},
150-
{"pattern": "gemini-3.*pro|gemini3.*pro", "category": "gemini3Pro"},
151-
{
152-
"pattern": "gemini-2\\.5-pro|gemini-2\\.5pro",
153-
"category": "gemini25Pro",
154-
},
155-
{
156-
"pattern": "gemini-2\\.5-flash|gemini-2\\.5flash|gemini-flash-latest|gemini-flash-lite-latest",
157-
"category": "gemini25Flash",
158-
},
159-
{"pattern": "gemini-2\\.0|gemini2\\.0", "category": "gemini2"},
160-
],
161-
}
162-
)
92+
config = _load_config()
93+
return JSONResponse(content=config)
16394

16495

165-
@router.get("/api/model-capabilities/{model_id}")
96+
@router.get("/api/model-capabilities/{model_id:path}")
16697
async def get_single_model_capabilities(model_id: str) -> JSONResponse:
16798
"""
16899
Return thinking capabilities for a specific model.
100+
101+
Args:
102+
model_id: Model identifier (e.g., "gemini-2.5-flash-preview")
169103
"""
170104
return JSONResponse(content=_get_model_capabilities(model_id))

api_utils/routers/ports.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@
77
import json
88
import os
99
import platform
10-
import signal
1110
import subprocess
1211
from pathlib import Path
13-
from typing import Optional
1412

1513
from fastapi import APIRouter, HTTPException
1614
from fastapi.responses import JSONResponse
@@ -318,14 +316,43 @@ async def kill_process(request: KillRequest) -> JSONResponse:
318316
"""
319317
终止指定PID的进程。
320318
321-
需要 confirm=true 确认操作。
319+
安全性验证:
320+
- 需要 confirm=true 确认操作
321+
- PID必须属于配置的端口上的进程
322+
323+
Args:
324+
request: 包含 PID 和确认标志的请求
325+
326+
Raises:
327+
HTTPException 400: 未确认操作
328+
HTTPException 403: PID不属于跟踪的端口
322329
"""
323330
if not request.confirm:
324331
raise HTTPException(
325332
status_code=400,
326333
detail="请设置 confirm=true 确认终止进程",
327334
)
328335

336+
# Security: Validate PID belongs to a tracked port
337+
config = _load_port_config()
338+
tracked_pids: set[int] = set()
339+
340+
# Collect PIDs from all configured ports
341+
for port in [config.fastapi_port, config.camoufox_debug_port]:
342+
for proc in _find_processes_on_port(port):
343+
tracked_pids.add(proc.pid)
344+
345+
# Also check stream proxy port if enabled
346+
if config.stream_proxy_enabled and config.stream_proxy_port > 0:
347+
for proc in _find_processes_on_port(config.stream_proxy_port):
348+
tracked_pids.add(proc.pid)
349+
350+
if request.pid not in tracked_pids:
351+
raise HTTPException(
352+
status_code=403,
353+
detail=f"安全验证失败:PID {request.pid} 不属于配置的端口。只能终止在 FastAPI ({config.fastapi_port})、Camoufox ({config.camoufox_debug_port}) 或 Stream Proxy ({config.stream_proxy_port}) 端口上运行的进程。",
354+
)
355+
329356
success, message = _kill_process(request.pid)
330357

331358
return JSONResponse(

0 commit comments

Comments
 (0)