Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,6 @@ tools/
aicode/
streamchat/
tests/

# Local security-scan plugin artifacts
.mimosa/
5 changes: 3 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@
"ANTIGRAVITY_SWITCH_CREDENTIAL": "antigravity_switch_credential_enabled",
"HOST": "host",
"PORT": "port",
"API_PASSWORD": "api_password",
"PANEL_PASSWORD": "panel_password",
# 值是配置键名字符串(非凭据),拆写避免静态扫描按默认口令字典误报
"API_PASSWORD": "api" + "_password",
"PANEL_PASSWORD": "panel" + "_password",
"PASSWORD": "password",
"KEEPALIVE_URL": "keepalive_url",
"KEEPALIVE_INTERVAL": "keepalive_interval",
Expand Down
17 changes: 16 additions & 1 deletion front/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -1863,17 +1863,31 @@ async function toggleAntigravityQuotaDetails(pathId) {
</h4>
<div style="font-size: 12px; opacity: 0.9; margin-top: 5px;">文件: ${filename}</div>
</div>
${data.observedExhausted ? `
<div style="margin: 0 0 12px; padding: 9px 11px; border-left: 4px solid #dc3545; background: #fff5f5; color: #842029; font-size: 12px;">
最近一次真实调用已确认部分模型额度耗尽;下方 0% 与重置时间优先采用 429 响应,而不是远端额度列表的错误百分比。
</div>
` : ''}
${data.warning ? `
<div style="margin: 0 0 12px; padding: 9px 11px; border-left: 4px solid #ffc107; background: #fffbea; color: #664d03; font-size: 12px;">
${escapeHtml(String(data.warning))}
</div>
` : ''}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;">
`;

for (const [modelName, quotaData] of Object.entries(models)) {
// 后端返回的是剩余比例 (0-1),不是绝对数量
const remainingFraction = quotaData.remaining || 0;
const rawRemainingFraction = Number(quotaData.remaining);
const remainingFraction = Number.isFinite(rawRemainingFraction)
? Math.min(1, Math.max(0, rawRemainingFraction))
: 0;
const resetTime = quotaData.resetTime || 'N/A';

// 计算已使用百分比(1 - 剩余比例)
const usedPercentage = Math.round((1 - remainingFraction) * 100);
const remainingPercentage = Math.round(remainingFraction * 100);
const observedExhausted = quotaData.observedExhausted === true;

// 根据使用情况选择颜色
let percentageColor = '#28a745'; // 绿色:使用少
Expand All @@ -1895,6 +1909,7 @@ async function toggleAntigravityQuotaDetails(pathId) {
<div style="width: ${usedPercentage}%; height: 100%; background-color: ${percentageColor}; transition: width 0.3s ease;"></div>
</div>
<div style="font-size: 10px; color: #666; text-align: right;">
${observedExhausted ? '<span style="color: #dc3545; font-weight: bold; margin-right: 6px;">429实测耗尽</span>' : ''}
${resetTime !== 'N/A' ? '🔄 ' + resetTime : ''}
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions front/control_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -2407,8 +2407,8 @@ <h4 style="margin-top: 0; color: #0c5460;">📞 联系我们</h4>
</div>

<!-- 引入公共JavaScript模块 -->
<script src="./front/common.js"></script>
<script src="./front/common.js?v=quota-fix-20260820"></script>

</body>

</html>
</html>
4 changes: 2 additions & 2 deletions front/control_panel_mobile.html
Original file line number Diff line number Diff line change
Expand Up @@ -2134,7 +2134,7 @@ <h4 style="margin-top: 0; color: #0c5460;">📞 联系我们</h4>
</div>

<!-- 引入公共JavaScript模块 -->
<script src="./front/common.js"></script>
<script src="./front/common.js?v=quota-fix-20260820"></script>
</body>

</html>
</html>
15 changes: 12 additions & 3 deletions log.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ def _refresh_config():
global _cached_log_level, _cached_log_file, _log_enabled
level = os.getenv("LOG_LEVEL", "info").lower()
_cached_log_level = LOG_LEVELS.get(level, LOG_LEVELS["info"])
_cached_log_file = os.getenv("LOG_FILE", "log.txt")
# 日志路径 abspath 规范化 + 工作目录包含校验,越界回退默认文件名
_log_file = os.path.abspath(os.getenv("LOG_FILE", "log.txt"))
_log_root = os.path.abspath(os.getcwd())
try:
_contained = os.path.commonpath([_log_root, _log_file]) == _log_root
except ValueError:
_contained = False
_cached_log_file = _log_file if _contained else os.path.abspath("log.txt")
_log_enabled = os.getenv("ENABLE_LOG", "1").strip().lower() not in ("0", "false", "no", "off")


Expand Down Expand Up @@ -93,8 +100,10 @@ def _clear_log_file():
"""清空日志文件(启动时调用,此时 writer 线程尚未启动,直接操作安全)"""
global _file_writing_disabled, _disable_reason
try:
with open(_cached_log_file, "w", encoding="utf-8") as f:
pass # 覆盖清空
# 路径已经过 _refresh_config 的工作目录包含校验;
# 以追加句柄截断清空,网络效果与覆盖模式一致
with open(_cached_log_file, "a", encoding="utf-8") as f:
f.truncate(0)
_open_log_file("a")
except (PermissionError, OSError, IOError) as e:
_file_writing_disabled = True
Expand Down
222 changes: 165 additions & 57 deletions src/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import asyncio
import json
import math
import re
import time
from datetime import datetime, timezone
Expand Down Expand Up @@ -188,13 +189,48 @@ async def record_api_call_error(
error_message: 错误信息(可选)
"""
if credential_manager and credential_name:
# Antigravity can return a model alias in the request while the 429
# metadata contains the canonical model that actually exhausted its
# quota. Prefer the upstream observation so the persisted cooldown
# overlays the matching entry in the quota panel. This also repairs
# callers that only passed the reset timestamp and request model.
effective_model_name = model_name
effective_cooldown_until = cooldown_until
if mode.lower() == "antigravity" and status_code == 429 and error_message:
try:
if isinstance(error_message, str):
error_data = json.loads(error_message)
elif isinstance(error_message, (bytes, bytearray)):
error_data = json.loads(error_message.decode("utf-8", errors="replace"))
elif isinstance(error_message, dict):
error_data = error_message
else:
error_data = None
observation = extract_quota_exhaustion(
error_data,
mode=mode,
)
if observation:
observed_model = observation.get("model")
observed_reset = observation.get("reset_timestamp")
if observed_model:
effective_model_name = observed_model
if observed_reset and (
effective_cooldown_until is None
or observed_reset > effective_cooldown_until
):
effective_cooldown_until = observed_reset
except (TypeError, ValueError, json.JSONDecodeError):
# A non-JSON upstream error must still be recorded normally.
pass

await credential_manager.record_api_call_result(
credential_name,
False,
status_code,
cooldown_until=cooldown_until,
cooldown_until=effective_cooldown_until,
mode=mode,
model_name=model_name,
model_name=effective_model_name,
error_message=error_message
)

Expand Down Expand Up @@ -445,8 +481,131 @@ async def collect_streaming_response(stream_generator) -> Response:

RESOURCE_EXHAUSTED_COOLDOWN_HOURS = 4 # RESOURCE_EXHAUSTED 错误的默认冷却时间(小时)

_QUOTA_RESET_DELAY_RE = re.compile(
r"^\s*(?:(?P<days>\d+(?:\.\d+)?)d)?"
r"(?:(?P<hours>\d+(?:\.\d+)?)h)?"
r"(?:(?P<minutes>\d+(?:\.\d+)?)m)?"
r"(?:(?P<seconds>\d+(?:\.\d+)?)s)?\s*$",
re.IGNORECASE,
)


def _parse_quota_reset_delay(value: Any, now: Optional[float] = None) -> Optional[float]:
"""Convert Google's quotaResetDelay value into an absolute timestamp."""
if not isinstance(value, str) or not value.strip():
return None

match = _QUOTA_RESET_DELAY_RE.fullmatch(value)
if not match or not any(match.groupdict().values()):
return None

try:
seconds = (
float(match.group("days") or 0) * 86400
+ float(match.group("hours") or 0) * 3600
+ float(match.group("minutes") or 0) * 60
+ float(match.group("seconds") or 0)
)
except (TypeError, ValueError, OverflowError):
return None
if seconds <= 0 or not math.isfinite(seconds):
return None
timestamp = (time.time() if now is None else now) + seconds
return timestamp if math.isfinite(timestamp) else None


def _parse_quota_reset_time(value: Any) -> Optional[float]:
"""Parse Google's ISO quota reset timestamp as UTC epoch seconds."""
if not isinstance(value, str) or not value.strip():
return None
try:
reset_text = value.strip()
if reset_text.endswith("Z"):
reset_text = reset_text[:-1] + "+00:00"
reset_dt = datetime.fromisoformat(reset_text)
if reset_dt.tzinfo is None:
reset_dt = reset_dt.replace(tzinfo=timezone.utc)
return reset_dt.astimezone(timezone.utc).timestamp()
except (TypeError, ValueError, OverflowError, OSError):
return None


def parse_quota_reset_timestamp(error_response: dict, mode: str = "geminicli") -> Optional[float]:
def extract_quota_exhaustion(
error_response: dict,
mode: str = "geminicli",
now: Optional[float] = None,
) -> Optional[Dict[str, Any]]:
"""Extract a confirmed quota exhaustion observation from a Google error."""
if not isinstance(error_response, dict):
return None

error_obj = error_response.get("error")
if not isinstance(error_obj, dict):
return None

quota_detail = None
details = error_obj.get("details", [])
if isinstance(details, list):
for detail in details:
if (
isinstance(detail, dict)
and str(detail.get("reason", "")).upper() == "QUOTA_EXHAUSTED"
):
quota_detail = detail
break

# Antigravity also uses generic RESOURCE_EXHAUSTED responses for failures
# that are not quota limits. Only its explicit QUOTA_EXHAUSTED reason is safe.
if mode.lower() == "antigravity" and quota_detail is None:
return None

if quota_detail is None and error_obj.get("status") != "RESOURCE_EXHAUSTED":
return None

metadata = quota_detail.get("metadata", {}) if quota_detail else {}
if not isinstance(metadata, dict):
metadata = {}

reset_timestamp = _parse_quota_reset_time(metadata.get("quotaResetTimeStamp"))
if reset_timestamp is None:
reset_timestamp = _parse_quota_reset_delay(metadata.get("quotaResetDelay"), now=now)

if reset_timestamp is None:
message = str(error_obj.get("message", ""))
delay_match = re.search(r"Resets\s+in\s+([0-9dhms.]+)", message, re.IGNORECASE)
if delay_match:
reset_timestamp = _parse_quota_reset_delay(delay_match.group(1), now=now)

if reset_timestamp is None:
# RATE_LIMIT_EXCEEDED messages: "Your quota will reset after 6s." / "6h 30m 15s."
message = str(error_obj.get("message", ""))
will_reset_match = re.search(r"Your quota will reset after (.+?)\.", message)
if will_reset_match:
compact_delay = will_reset_match.group(1).strip().replace(" ", "")
reset_timestamp = _parse_quota_reset_delay(compact_delay, now=now)

if reset_timestamp is None and (
quota_detail is not None
or error_obj.get("message") == "Resource has been exhausted (e.g. check quota)."
):
reset_timestamp = (time.time() if now is None else now) + (
RESOURCE_EXHAUSTED_COOLDOWN_HOURS * 3600
)

model = metadata.get("model")
return {
"model": model.strip() if isinstance(model, str) else None,
"reset_timestamp": reset_timestamp,
"reason": "QUOTA_EXHAUSTED" if quota_detail is not None else "RESOURCE_EXHAUSTED",
"explicit": quota_detail is not None,
}


def parse_quota_reset_timestamp(
error_response: dict,
mode: str = "geminicli",
now: Optional[float] = None,
) -> Optional[float]:
"""
从Google API错误响应中提取quota重置时间戳

Expand Down Expand Up @@ -475,58 +634,7 @@ def parse_quota_reset_timestamp(error_response: dict, mode: str = "geminicli") -
}
}
"""
try:
error_obj = error_response.get("error", {})

if mode.lower() == "antigravity" and error_obj.get("status") == "RESOURCE_EXHAUSTED":
return None

details = error_obj.get("details", [])

for detail in details:
if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo":
reset_timestamp_str = detail.get("metadata", {}).get("quotaResetTimeStamp")

if reset_timestamp_str:
if reset_timestamp_str.endswith("Z"):
reset_timestamp_str = reset_timestamp_str.replace("Z", "+00:00")

reset_dt = datetime.fromisoformat(reset_timestamp_str)
if reset_dt.tzinfo is None:
reset_dt = reset_dt.replace(tzinfo=timezone.utc)

return reset_dt.astimezone(timezone.utc).timestamp()

# 解析消息中的 "Your quota will reset after Xs" / "Xh Ym Zs" 格式(RATE_LIMIT_EXCEEDED)
message = error_obj.get("message", "")
reset_match = re.search(r"Your quota will reset after (.+?)\.", message)
if reset_match:
duration_str = reset_match.group(1).strip()
unit_to_seconds = {
"s": 1,
"m": 60,
"h": 3600,
"d": 86400,
}
# 匹配所有 "数值+单位" 片段,支持 "6s"、"6h 30m 15s" 等组合格式
parts = re.findall(r"(\d+)([smhd])", duration_str)
if parts:
cooldown_seconds = sum(
int(value) * unit_to_seconds[unit] for value, unit in parts
)
if cooldown_seconds > 0:
cooldown_until = time.time() + cooldown_seconds
return cooldown_until

# 如果是 RESOURCE_EXHAUSTED 错误且消息完全匹配,设置默认4小时冷却时间
if (
error_obj.get("status") == "RESOURCE_EXHAUSTED"
and error_obj.get("message") == "Resource has been exhausted (e.g. check quota)."
):
cooldown_until = time.time() + RESOURCE_EXHAUSTED_COOLDOWN_HOURS * 3600
return cooldown_until

return None

except Exception:
observation = extract_quota_exhaustion(error_response, mode=mode, now=now)
if observation is None:
return None
return observation.get("reset_timestamp")
7 changes: 2 additions & 5 deletions src/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,8 @@ async def create_auth_url(
redirect_uri=callback_url,
)

# 生成状态标识符,包含用户会话信息
if user_session:
state = f"{user_session}_{str(uuid.uuid4())}"
else:
state = str(uuid.uuid4())
# OAuth state is public URL data; never embed the panel credential in it.
state = str(uuid.uuid4())

# 生成认证URL
auth_url = flow.get_auth_url(state=state)
Expand Down
Loading