Files
litellm-config/volc_limit_sync.py
T
vibe-code 4e98c14920 perf: 已软踢模型短路跳过 token_counter 预估
- _soft_evicted_block: 扫描 router 中 {model}-limit-reached 条目返回
  (usage, limit), 供 async_pre_call_hook 直接 429
- 省去注定被拒请求的 token_counter 调用(含多模态图片 URL 下载等昂贵路径)
- 与 _check_request_block 的 limit-reached 恒 429 双保险
- stub 验证: 已踢/未踢/不存在三场景符合预期
2026-08-13 09:04:30 +00:00

1712 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import sys
import time
import json
import hmac
import math
import random
import hashlib
import datetime
import requests
import redis
import traceback
import threading
from urllib.parse import urlparse
# ==========================================
# 📦 LiteLLM Imports (Safe Handling)
# ==========================================
try:
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.exceptions import (
ServiceUnavailableError,
ContextWindowExceededError,
RateLimitError,
)
LITELLM_AVAILABLE = True
except ImportError:
LITELLM_AVAILABLE = False
# Define dummy classes to prevent errors if run standalone without litellm
class CustomLogger:
pass
class ServiceUnavailableError(Exception):
pass
class ContextWindowExceededError(Exception):
pass
class RateLimitError(Exception):
pass
# ==========================================
# 🏷️ Unified Log Tag (统一日志标识,便于过滤)
# ==========================================
# 本脚本所有输出统一带 [VolcSync] 前缀,方便日志筛选:
# docker logs litellm 2>&1 | grep VolcSync
# 实现方式:模块级重绑定 print(`print` 在模块命名空间中按调用时查找),
# 一处覆盖 log()/dprint 与全部裸 print 调用;对模块外部无任何影响。
LOG_TAG = "[VolcSync]"
_original_print = print
def _tagged_print(*args, **kwargs):
# 统一北京时间戳(显式 UTC+8,与业务口径一致,不依赖容器 TZ)。
# 性能:µs 级/次,相对打印本身(含 flush)可忽略。
ts = time.strftime("%m-%d %H:%M:%S", time.gmtime(time.time() + 8 * 3600))
if args and isinstance(args[0], str):
args = (args[0].lstrip("\n"), *args[1:])
_original_print(f"[{ts}] {LOG_TAG}", *args, **kwargs)
print = _tagged_print
# ==========================================
# 🛠️ Configuration & Constants
# ==========================================
# 日志级别配置
LOG_LEVEL = os.getenv("DAILY_LIMIT_LOG_LEVEL", "INFO").upper()
def get_log_level_value(level_name):
levels = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40}
return levels.get(level_name, 20)
CURRENT_LEVEL_VAL = get_log_level_value(LOG_LEVEL)
DEBUG_MODE = CURRENT_LEVEL_VAL <= 10
# Volcengine API Config
ACCESS_KEY = os.getenv(
"VOLC_ACCESS_KEY", "AKLTNGYxZmUyNzNmZjk3NGMyZmEzNjVjZmY4NGM5ZjcxMjU"
)
SECRET_KEY = os.getenv(
"VOLC_SECRET_KEY", "Tm1VNE5XSmlORGs1WXpGbU5EVmtabUUyT1RZeVltTTBNalkxTWpjeE1tTQ=="
)
HOST = "open.volcengineapi.com"
REGION = "cn-beijing"
# Daemon Config
SMALL_BUFFER = 50000 # 覆盖资源包过期/API 未结算等非消耗性余额跳降
CHECK_INTERVAL = 10
# 前置闸门预留量:请求开始判定 usage + reserve >= limit 即 429。
# reserve 优先取本次请求自带 max_tokens(真实上限,零采集损失),
# 未设置时 fallback 本常量。堵住"并发请求穿过闸门后集体撞线"的窗口。
PREBLOCK_RESERVE_TOKENS = int(os.getenv("PREBLOCK_RESERVE_TOKENS", "50000"))
# 思考模型输出侧兜底下限:deepseek-v4-flash 等默认思考模式,
# usage.total_tokens 含 reasoning tokens,且不受 max_tokens 限制
# (max_tokens 只约束正文 completion,思考 token 另计照常计费)。
# 短文本请求若客户端声明 max_tokens=16,reserve 输出侧仅 16,
# 实际消耗 ~150+(思考占大头),闸门形同虚设。取
# max(声明值, 本下限) 兜住思考开销;2M 限额下 4096 偏差仅 0.2%。
THINKING_RESERVE_FLOOR = int(os.getenv("THINKING_RESERVE_FLOOR", "4096"))
# stable_limit 缺失时(首次同步前/新增模型/TTL 过期)的保守兜底限额。
# 免费模型余额常态 < 2M(昨日采多少返多少),若 fallback 裸奔到采集上限 2M,
# 余额不足的模型会被烧穿扣费。宁可短时收紧,不可裸奔。
FALLBACK_LIMIT = int(os.getenv("FALLBACK_LIMIT", "900000"))
# Redis Config
REDIS_HOST = os.getenv(
"REDIS_HOST", "redis"
) # Default to 'redis' (docker service name)
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
# Suffixes to clean from Volcengine response
SUFFIXES_TO_REMOVE = [
"_pack_data_collaboration",
"pack_data_collaboration",
"_data_collaboration_resource_pack",
"_data_collaboration",
"data_collaboration",
"协作奖励计划资源包",
"免费在线推理资源包",
"免费在线推理包",
"资源包",
"_free_infer_res_pack",
"_pack_free_infer",
"_free_inference_resource_pack",
"free_inference_resource_pack",
"_infer_res_pack",
"-free-infer-res-pack", # Matches: DeepSeek-V3-free-infer-res-pack
]
# Manual package-name aliases for monitored models.
# 常规版本号模型(如 xxx-260215)会根据 config.yaml 自动推导成 xxx,
# 这里只保留特殊别名或一对多映射,减少新增/删除监控模型时需要手改的地方。
MANUAL_MONITORED_MODEL_ALIASES = {
"doubao-seed-1-6": ["doubao-seed-1-6-250615", "doubao-seed-1-6-251015"],
"doubao-seed-code": "doubao-seed-code-preview-251028",
"doubao-seed-translation": "doubao-seed-translation-250915",
"doubao-seed-2-0-code": "doubao-seed-2-0-code-preview-260215",
# 火山资源包名用 MMDD(deepseek-v4-flash-0731),上游模型名用 -ga-YYMMDD(-ga-260731),
# 两者不一致,必须手工映射,否则资源包会被 "Not in monitored models" 跳过。
"deepseek-v4-flash-0731": "deepseek-v4-flash-ga-260731",
}
# Auto-discovered monitored upstream models and endpoint pairs from config.yaml
MONITORED_MODEL_PAIRS = set()
MONITORED_MODEL_ALIASES = {}
# ==========================================
# 🛑 Hard Daily Limits (Local Override / 本地每日硬限额)
# ==========================================
# Configured directly here to bypass API latency.
# These limits are applied LOCALLY and update immediately.
# 这里的限额是本地强制执行的,不受API延迟影响,用于防止超支。
#
# Format: "clean-model-name": limit_in_tokens
# Or with hash: "clean-model-name:hash": limit_in_tokens
HARD_DAILY_LIMITS = {
# 火山免费额度规则(2026-08):每模型每日采集上限 2M,次日按采集量发放资源包。
# 请求前闸门(async_pre_call_hook)已不滞后 → 无需打折(旧 1.8M/480 万为 post-hoc 时代遗产)。
# 统一 2M,余额相关的动态限额由 balance_at_sync - (usage_now - usage_at_sync) 实时。
"__DEFAULT__": 2_000_000,
}
# ==========================================
# 🚨 Soft Evict Thresholds(软踢预警线)
# ==========================================
# 提前于硬限额/动态墙踢出模型的预警线,留出缓冲:
# 1. 用量墙:usage ≥ SOFT_EVICT_USAGE_LIMIT(默认 1.95M) 即踢——避免贴 2M 边时
# 大请求 429/小请求放行的灰色地带(reserve 低估 / 并发击穿余量)
# 2. 余额墙:balance_at_sync < SOFT_EVICT_BALANCE_LIMIT(10万) 且 usage > 0 即踢——
# usage>0 限定防误杀小额初始池模型(B0<10 万未使用时被永久踢出死锁)
# 恢复条件对称(should_restore_model):usage < min(effective_limit, 用量墙)
# 且 (余额 ≥ 10万 或 usage == 0),否则踢了立即被恢复成踢/恢复抖动
SOFT_EVICT_USAGE_LIMIT = float(os.getenv("SOFT_EVICT_USAGE_LIMIT", "1950000"))
SOFT_EVICT_BALANCE_LIMIT = float(os.getenv("SOFT_EVICT_BALANCE_LIMIT", "100000"))
# ==========================================
# 🔌 Redis Connection
# ==========================================
try:
redis_client = redis.Redis(
host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, decode_responses=True
)
# Check connection only if running as script, to avoid noise on import
# redis_client.ping()
except Exception as e:
print(f"❌ Redis connection failed: {str(e)}", flush=True)
redis_client = None
# ==========================================
# 📝 Logging Helpers
# ==========================================
def log(msg, level="INFO"):
msg_val = get_log_level_value(level)
if msg_val >= CURRENT_LEVEL_VAL:
icon = {"DEBUG": "🔍", "INFO": "ℹ️", "WARNING": "⚠️", "ERROR": "❌"}.get(
level, "📝"
)
print(f"{icon} {msg}", flush=True)
def dprint(msg):
log(msg, level="INFO")
# ==========================================
# 🧮 Utility Functions (Hash, Clean, Sign)
# ==========================================
def generate_api_base_hash(api_base):
"""Generate robust hash from api_base ignoring path variations."""
if not api_base:
return "default"
try:
s = api_base.strip()
parsed = urlparse(s)
if parsed.scheme and parsed.netloc:
clean_base = f"{parsed.scheme}://{parsed.netloc}".lower()
else:
clean_base = s.rstrip("/").lower()
except Exception:
clean_base = api_base.strip().rstrip("/").lower()
h = hashlib.md5(clean_base.encode()).hexdigest()[:8]
return h
def clean_model_name(name):
"""LiteLLM style cleaning: remove provider prefixes."""
if not name:
return ""
return (
name.replace("openai/", "")
.replace("azure/", "")
.replace("anthropic/", "")
.replace("volcengine/", "")
)
def strip_model_version_suffix(model_name):
"""Convert foo-bar-260215 -> foo-bar for common versioned upstream names."""
if not model_name:
return ""
parts = model_name.rsplit("-", 1)
if len(parts) == 2 and len(parts[1]) == 6 and parts[1].isdigit():
return parts[0]
return model_name
def build_monitored_model_aliases(monitored_models):
"""
Build alias -> monitored upstream model(s) mapping.
Common versioned names are derived automatically from config.yaml.
"""
alias_map = {}
def add_alias(alias_name, target_model):
clean_alias = clean_model_name(alias_name)
clean_target = clean_model_name(target_model)
if not clean_alias or not clean_target:
return
alias_map.setdefault(clean_alias, [])
if clean_target not in alias_map[clean_alias]:
alias_map[clean_alias].append(clean_target)
for target_model in sorted(monitored_models):
add_alias(target_model, target_model)
base_alias = strip_model_version_suffix(target_model)
if base_alias != target_model:
add_alias(base_alias, target_model)
for alias_name, targets in MANUAL_MONITORED_MODEL_ALIASES.items():
if isinstance(targets, list):
for target_model in targets:
add_alias(alias_name, target_model)
else:
add_alias(alias_name, targets)
return alias_map
def normalize_volc_name(raw_name):
"""Normalize Volcengine package names before monitored-model resolution."""
if not raw_name:
return "unknown"
clean_name = raw_name
for suffix in sorted(SUFFIXES_TO_REMOVE, key=len, reverse=True):
if clean_name.endswith(suffix):
clean_name = clean_name.replace(suffix, "")
break
clean_name = clean_name.replace("_", "-").replace(".", "-")
clean_name = clean_name.lower().strip()
return clean_name
def sign(key, msg):
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def get_signature_key(key, date_stamp, region_name, service_name):
k_date = sign(key.encode("utf-8"), date_stamp)
k_region = sign(k_date, region_name)
k_service = sign(k_region, service_name)
k_signing = sign(k_service, "request")
return k_signing
def get_signed_headers(action, version, body_dict, service="billing"):
t = datetime.datetime.utcnow()
amz_date = t.strftime("%Y%m%dT%H%M%SZ")
date_stamp = t.strftime("%Y%m%d")
request_body = json.dumps(body_dict)
payload_hash = hashlib.sha256(request_body.encode("utf-8")).hexdigest()
canonical_headers = f"content-type:application/json\nhost:{HOST}\nx-content-sha256:{payload_hash}\nx-date:{amz_date}\n"
signed_headers = "content-type;host;x-content-sha256;x-date"
canonical_request = f"POST\n/\nAction={action}&Version={version}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
credential_scope = f"{date_stamp}/{REGION}/{service}/request"
string_to_sign = f"HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
signing_key = get_signature_key(SECRET_KEY, date_stamp, REGION, service)
signature = hmac.new(
signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
).hexdigest()
authorization_header = f"HMAC-SHA256 Credential={ACCESS_KEY}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
return {
"Content-Type": "application/json",
"Host": HOST,
"X-Date": amz_date,
"X-Content-Sha256": payload_hash,
"Authorization": authorization_header,
}, request_body
# ==========================================
# 🎯 Limit Logic (Unified)
# ==========================================
def get_usage_key(clean_model, api_base=None):
# Enforce Beijing Time (UTC+8) for daily key rotation at 00:00 CST
# Beijing 00:00 = UTC 16:00 (Previous Day) -> simple offset: UTC_timestamp + 8 hours
now = time.time()
beijing_time_ts = now + (8 * 3600)
date_str = time.strftime("%Y-%m-%d", time.gmtime(beijing_time_ts))
suffix = f":{generate_api_base_hash(api_base)}" if api_base else ""
return f"daily_usage:{date_str}:{clean_model}{suffix}"
def get_static_limit_for_hash(clean_model, current_hash=None):
"""Check static configuration in HARD_DAILY_LIMITS using an optional endpoint hash."""
if current_hash:
specific_key = f"{clean_model}:{current_hash}"
if specific_key in HARD_DAILY_LIMITS:
return HARD_DAILY_LIMITS[specific_key]
# 2. Exact Model Name
if clean_model in HARD_DAILY_LIMITS:
return HARD_DAILY_LIMITS[clean_model]
# 3. Fuzzy Match
for quota_model, limit_val in HARD_DAILY_LIMITS.items():
if quota_model == "__DEFAULT__":
continue
if ":" not in quota_model and quota_model in clean_model:
return limit_val
# 4. Universal Default Fallback
if "__DEFAULT__" in HARD_DAILY_LIMITS:
return HARD_DAILY_LIMITS["__DEFAULT__"]
return None
def get_static_limit(clean_model, api_base):
"""Check static configuration in HARD_DAILY_LIMITS."""
current_hash = generate_api_base_hash(api_base) if api_base else None
return get_static_limit_for_hash(clean_model, current_hash)
def get_monitored_static_limit(clean_model, model_hashes=None):
"""
Resolve the static limit for monitored-model debug output.
If multiple endpoint hashes exist, show the strictest applicable static cap.
"""
specific_limits = []
for current_hash in model_hashes or []:
limit_val = get_static_limit_for_hash(clean_model, current_hash)
if limit_val is not None:
specific_limits.append(float(limit_val))
if specific_limits:
return min(specific_limits)
return get_static_limit_for_hash(clean_model, None)
def calculate_effective_limit(static_limit, dynamic_limit):
"""Return the effective cap after combining static and dynamic limits."""
try:
if dynamic_limit is None:
return float(static_limit) if static_limit is not None else None
dynamic_limit = float(dynamic_limit)
if static_limit is None:
return dynamic_limit
return min(float(static_limit), dynamic_limit)
except (ValueError, TypeError):
return float(static_limit) if static_limit is not None else None
def format_limit_debug_fields(static_limit=None, balance_at_sync=None, usage_at_sync=None, effective_limit=None):
"""Render explicit limit labels for debug logs."""
fields = []
if static_limit is not None:
fields.append(f"StaticLimit={int(float(static_limit)):,}")
if balance_at_sync is not None:
fields.append(f"BalAtSync={int(float(balance_at_sync)):,}")
if usage_at_sync is not None:
fields.append(f"UsageAtSync={int(float(usage_at_sync)):,}")
if effective_limit is not None:
fields.append(f"EffectiveLimit={int(float(effective_limit)):,}")
return " | ".join(fields) if fields else "EffectiveLimit=unknown"
def get_effective_limit(clean_model, api_base, current_usage=None):
"""
Calculate effective limit using the Snapshot-Offset strategy.
limit = balance_at_sync + usage_at_sync - SMALL_BUFFER
= B0 + L - SMALL_BUFFER # L = 同步时点滞后窗口未结算消耗(滞后收敛时 L=0, 虚高随消费增长, 见 docs/volc-quota-notes.md §4.2/§7.5)
_check_request_block 用 current_usage + reserve >= limit 撞线,语义正确。
current_usage 参数仅用于无同步数据的 fallback 路径,非公式必需。
"""
if not redis_client:
return get_static_limit(clean_model, api_base)
# 1. Get Static Limit (Hard Config)
static_limit = get_static_limit(clean_model, api_base)
# 2. Get Dynamic Limit from Snapshot
# balance_at_sync + usage_at_sync = B0 + L(B0 当日初始免费池, L 同步时点滞后未结算消耗;
# 仅滞后收敛 L=0 时恰为 B0, 未收敛时虚高 L, 见 docs/volc-quota-notes.md §7.5)
# 累计限额 = B0 + L - SMALL_BUFFER,两次同步间不变
balance_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}"
usage_key = f"limit:dynamic:volcengine:usage_at_sync:{clean_model}"
balance_val = redis_client.get(balance_key)
usage_val = redis_client.get(usage_key)
effective_limit = None
if balance_val is not None and usage_val is not None:
try:
balance_at_sync = float(balance_val)
usage_at_sync = float(usage_val)
# 累计限额 = B0 + L - SMALL_BUFFER(L=同步时点滞后未结算消耗),不依赖 current_usage
dynamic_limit = balance_at_sync + usage_at_sync - SMALL_BUFFER
dynamic_limit = max(0, dynamic_limit)
effective_limit = calculate_effective_limit(static_limit, dynamic_limit)
except (ValueError, TypeError):
effective_limit = calculate_effective_limit(static_limit, None)
else:
# Fallback: 无同步数据时用保守兜底
fallback = float(static_limit) if static_limit is not None else float(FALLBACK_LIMIT)
fallback = min(fallback, float(FALLBACK_LIMIT))
effective_limit = calculate_effective_limit(fallback, None)
return effective_limit
# ==========================================
# 👻 Handler Logic (DailyLimitHandler)
# ==========================================
# 🚨 Soft Evict / Restore Decision(软踢与恢复对称判定)
# ==========================================
def soft_evict_wall(effective_limit):
"""软踢用量墙 = min(有效限额, SOFT_EVICT_USAGE_LIMIT);None 表示无限额。"""
if effective_limit is None:
return None
return min(float(effective_limit), SOFT_EVICT_USAGE_LIMIT)
def should_soft_evict(clean_model, api_base, current_usage, effective_limit):
"""
软踢判定:任一满足即触发
- 用量墙:current_usage >= min(effective_limit, SOFT_EVICT_USAGE_LIMIT)
- 余额墙:balance_at_sync < SOFT_EVICT_BALANCE_LIMIT 且 current_usage > 0
(balance_at_sync 缺失视为充足;usage>0 防小额初始池模型误杀死锁)
返回 (triggered, reason)。
"""
wall = soft_evict_wall(effective_limit)
if wall is not None and float(current_usage) >= wall:
return True, f"usage {int(float(current_usage)):,} >= soft wall {int(wall):,}"
try:
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}"
bal_val = redis_client.get(bal_key) if redis_client else None
if (
bal_val is not None
and float(bal_val) < SOFT_EVICT_BALANCE_LIMIT
and float(current_usage) > 0
):
return (
True,
f"balance {int(float(bal_val)):,} < {SOFT_EVICT_BALANCE_LIMIT:.0f} (usage>0)",
)
except Exception:
pass
return False, ""
def _soft_evicted_block(model_name):
"""
若模型已被软踢(router 中存在 {model_name}-limit-reached 条目),返回
(usage, effective_limit) 供直接 429;否则 None。用于 async_pre_call_hook 短路:
已踢模型注定被拒,跳过 litellm.token_counter 精确预估(含多模态图片下载等昂贵路径)。
"""
if not LITELLM_AVAILABLE or not redis_client:
return None
try:
from litellm.proxy.proxy_server import llm_router
if not llm_router or not llm_router.model_list:
return None
for entry in llm_router.model_list:
entry_name = entry.get("model_name") or ""
if entry_name == f"{model_name}-limit-reached" and "_daily_limit_backup" in entry:
params = entry.get("litellm_params", {})
clean = clean_model_name(params.get("model", ""))
api_base = params.get("api_base", "")
usage_key = get_usage_key(clean, api_base)
usage = float(redis_client.get(usage_key) or 0)
limit = get_effective_limit(clean, api_base)
return (usage, limit)
except Exception:
pass
return None
def should_restore_model(clean_model, api_base, current_usage, effective_limit):
"""
恢复判定(与 should_soft_evict 对称,防踢/恢复抖动):
usage < min(effective_limit, SOFT_EVICT_USAGE_LIMIT) 且 (余额 ≥ 10万 或 usage == 0)
effective_limit None 时仅按余额条件判定(原逻辑 None → 可恢复)。
"""
wall = soft_evict_wall(effective_limit)
if wall is not None and float(current_usage) >= wall:
return False
try:
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}"
bal_val = redis_client.get(bal_key) if redis_client else None
if (
bal_val is not None
and float(bal_val) < SOFT_EVICT_BALANCE_LIMIT
and float(current_usage) > 0
):
return False
except Exception:
pass
return True
# ==========================================
def soft_evict_from_router(target_model, target_api_base):
"""
Temporarily disable a model in the router by renaming it.
"""
if not LITELLM_AVAILABLE:
return
try:
from litellm.proxy.proxy_server import llm_router
if not llm_router:
return
# Prepare target identifiers
target_clean = clean_model_name(target_model)
target_hash = generate_api_base_hash(target_api_base)
for i, model_entry in enumerate(llm_router.model_list):
# Skip if already evicted/backed up
if "_daily_limit_backup" in model_entry:
continue
params = model_entry.get("litellm_params", {})
m_model = params.get("model", "")
m_base = params.get("api_base", "")
# Check Match
m_clean = clean_model_name(m_model)
m_hash = generate_api_base_hash(m_base)
# Match on Name AND Hash (Strict)
if m_clean == target_clean and m_hash == target_hash:
dprint(
f"🚫 Soft-evicting {model_entry.get('model_name')} (Limit Reached)"
)
# Create Backup
model_entry["_daily_limit_backup"] = {
"model_name": model_entry.get("model_name"),
"api_base": m_base,
}
# Disable by renaming (Router won't find it)
model_entry["model_name"] = (
f"{model_entry.get('model_name')}-limit-reached"
)
# Optional: break after finding first match?
# Or continue to evict all instances (e.g. load balancing same model)?
# Let's evict ALL matches to be safe.
# return
except Exception as e:
log(f"❌ Soft evict failed: {e}", level="ERROR")
last_restore_check = 0
def restore_blocked_models():
"""
Periodically check if blocked models have recovered quota.
Optimized for Volcengine:
- Quota usually resets in Beijing Time mornings (uncertain time).
- Logic:
- During Beijing Morning (00:00 - 14:00): Check every 30 mins.
- Other times: Check every 4 hours (low probability of reset).
"""
global last_restore_check
now = time.time()
# Calculate Dynamic Interval based on Beijing Time
# UTC+8 Logic
utc_now_struct = time.gmtime(now)
beijing_hour = (utc_now_struct.tm_hour + 8) % 24
# Morning Window: 07:00 - 13:00
if 7 <= beijing_hour < 13:
interval = 1800 # 30 minutes
else:
interval = 14400 # 4 hours
if now - last_restore_check < interval:
return
last_restore_check = now
try:
from litellm.proxy.proxy_server import llm_router
# ... (rest of the function) ...
if not llm_router or not llm_router.model_list:
return
restored_count = 0
for i, model_entry in enumerate(llm_router.model_list):
backup = model_entry.get("_daily_limit_backup")
if not backup:
continue
params = model_entry.get("litellm_params", {})
upstream_model = params.get("model", "")
clean_model = clean_model_name(upstream_model)
original_api_base = backup.get("api_base")
# 只恢复监控内的火山条目(clean + 端点 hash),与 soft_evict/请求闸门口径一致,
# 避免按 clean 名误恢复同名不同端点(如 newapi 渠道)被踢的条目
if MONITORED_MODEL_PAIRS and (
clean_model,
generate_api_base_hash(params.get("api_base", "")),
) not in MONITORED_MODEL_PAIRS:
continue
# Check Quota
# Need current usage to calculate dynamic limit correctly?
# get_effective_limit handles fetching usage if None passed.
effective_limit = get_effective_limit(clean_model, original_api_base)
usage_key = get_usage_key(clean_model, original_api_base)
current_val = float(redis_client.get(usage_key) or 0)
should_restore = should_restore_model(
clean_model, original_api_base, current_val, effective_limit
)
if should_restore:
dprint(f"♻️ [RESTORE] Restoring {backup['model_name']} (Quota OK)")
llm_router.model_list[i]["model_name"] = backup["model_name"]
llm_router.model_list[i]["litellm_params"]["api_base"] = (
original_api_base
)
del llm_router.model_list[i]["_daily_limit_backup"]
restored_count += 1
if restored_count > 0:
dprint(f"✅ Restored {restored_count} models.")
except Exception as e:
log(f"❌ Restore check failed: {e}", level="ERROR")
def restore_model_if_healthy(clean_model, allowed_hashes=None):
"""
若指定模型已被软踢(_daily_limit_backup)且当前限额已恢复(usage < effective limit),
立即恢复其路由条目。供同步守护在写健康限额后调用,不等 restore_blocked_models 的
30min/4h 闸门;否则额度到账后模型会带额度空转被禁(有额度但 no healthy deployments)。
allowed_hashes: 限定匹配的 api_base hash 集合(None = 不限,按 clean 名匹配)。
返回恢复的条目数;任何异常 fail-safe 返回 0。
"""
if not LITELLM_AVAILABLE or not redis_client:
return 0
try:
from litellm.proxy.proxy_server import llm_router
if not llm_router or not llm_router.model_list:
return 0
restored = 0
for i, model_entry in enumerate(llm_router.model_list):
backup = model_entry.get("_daily_limit_backup")
if not backup:
continue
params = model_entry.get("litellm_params", {})
m_model = params.get("model", "")
if clean_model_name(m_model) != clean_model:
continue
m_base = params.get("api_base", "")
if allowed_hashes is not None and generate_api_base_hash(
m_base
) not in allowed_hashes:
continue
effective_limit = get_effective_limit(clean_model, m_base)
usage_key = get_usage_key(clean_model, m_base)
current_val = float(redis_client.get(usage_key) or 0)
should_restore = should_restore_model(
clean_model, m_base, current_val, effective_limit
)
if should_restore:
dprint(f"♻️ [RESTORE] Restoring {backup['model_name']} (Quota OK)")
llm_router.model_list[i]["model_name"] = backup["model_name"]
llm_router.model_list[i]["litellm_params"]["api_base"] = m_base
del llm_router.model_list[i]["_daily_limit_backup"]
restored += 1
if restored > 0:
log(f"✅ Restored {restored} entry/entries of {clean_model}.", level="INFO")
return restored
except Exception as e:
log(f"❌ Restore failed for {clean_model}: {e}", level="ERROR")
return 0
def startup_audit():
"""
🚀 Startup Audit:
System initialization check. Iterate through all models in the router.
If a model is found to be already over the limit in Redis, kick it out immediately.
系统启动自检:遍历 Router 中所有模型,如果发现 Redis 中记录已超额,直接踢出。
"""
if not redis_client or not LITELLM_AVAILABLE:
return
try:
from litellm.proxy.proxy_server import llm_router
if not llm_router or not llm_router.model_list:
dprint("⚠️ Startup audit skipped: llm_router not ready.")
return
dprint("🚀 Starting Startup Audit (Pre-emptive Block)...")
blocked_count = 0
# Create a copy to iterate safely
# We only need to read configs to find targets
for model_entry in llm_router.model_list:
# Skip if already blocked (shouldn't happen on fresh start, but good practice)
if "_daily_limit_backup" in model_entry:
continue
params = model_entry.get("litellm_params", {})
raw_model = params.get("model", "")
api_base = params.get("api_base", "")
# 1. Clean Name
clean_model = clean_model_name(raw_model)
if not clean_model:
continue
# 只审计监控内的火山模型(clean + 端点 hash),与请求闸门口径一致,
# 避免误判同名非监控渠道(如 newapi 别名模型)超限而误踢
if MONITORED_MODEL_PAIRS and (
clean_model,
generate_api_base_hash(api_base),
) not in MONITORED_MODEL_PAIRS:
continue
# 2. Check Limit & Usage
# We use effective limit logic which checks both static and dynamic
effective_limit = get_effective_limit(clean_model, api_base)
if effective_limit is None:
continue
usage_key = get_usage_key(clean_model, api_base)
current_val = float(redis_client.get(usage_key) or 0)
evict, evict_reason = should_soft_evict(
clean_model, api_base, current_val, effective_limit
)
if evict:
dprint(
f"🚫 [STARTUP BLOCK] {raw_model} soft-evict: {evict_reason}. Evicting now."
)
soft_evict_from_router(raw_model, api_base)
blocked_count += 1
if blocked_count > 0:
dprint(f"✅ Startup Audit Complete: Blocked {blocked_count} models.")
else:
dprint("✅ Startup Audit Complete: All models healthy.")
except ImportError:
dprint("⚠️ Startup audit skipped: litellm not found.")
except Exception as e:
log(f"❌ Startup audit failed: {e}", level="ERROR")
def _check_request_block(model_name, reserve=0.0):
"""
请求前闸门:对外模型名 → 判定是否超限。
只拦截 MONITORED_MODEL_PAIRS 内的火山监控条目;newapi 等非监控渠道天然放行。
reserve: 本次请求的预估消耗(max_tokens 或常量兜底),usage + reserve >= limit 即拦截,
避免并发请求在闸门检查后集体撞线(限额是低频快照,用量是实时累计)。
返回 (clean_model, usage, limit) 表示超限,None 表示放行。任何异常 fail-open。
"""
if not redis_client or not LITELLM_AVAILABLE:
return None
try:
from litellm.proxy.proxy_server import llm_router
if not llm_router or not llm_router.model_list:
return None
for entry in llm_router.model_list:
entry_name = entry.get("model_name") or ""
# 匹配请求名;被限额软踢的条目(原名被重命名为 {name}-limit-reached)
# 也参与判定,保证超限模型无论是否已踢都返回 429,而非 router 400
if entry_name != model_name:
if not (
"_daily_limit_backup" in entry
and entry_name == f"{model_name}-limit-reached"
):
continue
params = entry.get("litellm_params", {})
raw_model = params.get("model", "")
api_base = params.get("api_base", "")
clean = clean_model_name(raw_model)
h = generate_api_base_hash(api_base)
# 只拦已纳入监控的火山模型(模型 + 端点粒度)
if MONITORED_MODEL_PAIRS and (clean, h) not in MONITORED_MODEL_PAIRS:
continue
effective_limit = get_effective_limit(clean, api_base)
if effective_limit is None:
continue
usage_key = get_usage_key(clean, api_base)
val = redis_client.get(usage_key)
usage = float(val or 0)
# 已被软踢的条目恒 429(无论 reserve),避免踢后灰色区间(如 1.95M~2M)
# 小请求被放行 → 路由原名不存在 → 400 路由错误
if entry_name == f"{model_name}-limit-reached" and "_daily_limit_backup" in entry:
return (clean, usage, effective_limit)
if usage + float(reserve) >= float(effective_limit):
return (clean, usage, effective_limit)
return None
except Exception as e:
log(f"❌ Pre-block check failed: {e}", level="ERROR")
return None
def quota_interceptor(kwargs, user_api_key_dict=None):
try:
# 1. 尝试恢复(定期)
restore_blocked_models()
except (ServiceUnavailableError, ContextWindowExceededError):
raise
except Exception:
pass
class DailyLimitHandler(CustomLogger):
def __init__(self):
if LITELLM_AVAILABLE:
if quota_interceptor not in litellm.input_callback:
litellm.input_callback.append(quota_interceptor)
dprint("✅ quota_interceptor registered")
if self not in litellm.callbacks:
litellm.callbacks.append(self)
dprint("✅ DailyLimitHandler registered")
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
"""
请求前限额闸门:在 route_request 之前拒绝超限请求(429),
不进入 router → 不触发 num_retries / fallback 放大。
"""
if call_type not in ("completion", "acompletion", "text_completion"):
return None
if not data or not data.get("model"):
return None
# 已被软踢的模型直接 429,跳过 token_counter 精确预估(注定被拒,无需计数,
# 也避免多模态图片 URL 下载等昂贵路径)。注:与 _check_request_block 的
# limit-reached 恒 429 双保险——这里短路省 token_counter,那里兜底路由阶段。
evicted = _soft_evicted_block(data.get("model"))
if evicted is not None:
usage, ev_limit = evicted
limit_disp = int(ev_limit) if ev_limit is not None else 0
log(
f"🚫 [PRE-BLOCK] {data['model']} soft-evicted, 429 (usage {int(usage):,}/{limit_disp:,})",
level="WARNING",
)
raise RateLimitError(
message=f"Daily limit reached for model '{data['model']}' ({int(usage):,}/{limit_disp:,}) (soft-evicted)",
llm_provider="volcengine",
model=data["model"],
)
# 预留本次请求的预估消耗 = 输入 token(精确) + 输出上限(max_tokens)。
# 输入用 litellm.token_counter 精确计数(未知模型 fallback cl100k_base,
# 对中文偏高更安全)。
# 输出侧:思考模型 usage 含 reasoning tokens 且不受 max_tokens 限制,
# 故取 max(请求声明 max_tokens, THINKING_RESERVE_FLOOR) 兜底思考开销,
# 防短文本 + 小 max_tokens 请求 reserve 低估(实测 34 vs 实际 150+)。
try:
declared = (
data.get("max_tokens")
or data.get("max_completion_tokens")
or PREBLOCK_RESERVE_TOKENS
)
max_out = max(float(declared), float(THINKING_RESERVE_FLOOR))
except (TypeError, ValueError):
max_out = max(
float(THINKING_RESERVE_FLOOR), float(PREBLOCK_RESERVE_TOKENS)
)
input_tokens = 0
try:
from litellm import token_counter as _tc
input_tokens = _tc(
model=data.get("model", ""),
messages=data.get("messages", []),
)
except Exception:
# token_counter 出错时保守:用 PREBLOCK_RESERVE_TOKENS 兜底输入
input_tokens = PREBLOCK_RESERVE_TOKENS
reserve = input_tokens + max_out
if DEBUG_MODE:
log(
f"🔍 Pre-reserve {data['model']}: input={int(input_tokens):,} + max_out={int(max_out):,} = reserve={int(reserve):,}",
level="DEBUG",
)
blocked = _check_request_block(data["model"], reserve=reserve)
if not blocked:
return None
clean, usage, limit = blocked
log(
f"🚫 [PRE-BLOCK] {data['model']} daily limit reached ({int(usage):,}+{int(reserve):,}/{int(limit):,})",
level="WARNING",
)
raise RateLimitError(
message=f"Daily limit reached for model '{data['model']}' ({int(usage):,}/{int(limit):,})",
llm_provider="volcengine",
model=data["model"],
)
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self._record_usage(kwargs, response_obj)
def log_success_event(self, kwargs, response_obj, start_time, end_time):
self._record_usage(kwargs, response_obj)
def _record_usage(self, kwargs, response_obj):
if not redis_client:
return
try:
litellm_params = kwargs.get("litellm_params") or {}
raw_model = litellm_params.get("model") or kwargs.get("model")
clean_model = clean_model_name(raw_model)
api_base = litellm_params.get("api_base")
# FILTER: Only track models explicitly discovered as VolcEngine in config.yaml
# This prevents tracking NewAPI aliases that point to other providers
current_hash = generate_api_base_hash(api_base)
if (
MONITORED_MODEL_PAIRS
and (clean_model, current_hash) not in MONITORED_MODEL_PAIRS
):
return
usage = None
if hasattr(response_obj, "usage"):
usage = response_obj.usage
elif isinstance(response_obj, dict):
usage = response_obj.get("usage")
if hasattr(usage, "model_dump"):
usage = usage.model_dump()
elif hasattr(usage, "dict"):
usage = usage.dict()
if not usage:
return
total_tokens = usage.get("total_tokens", 0)
if clean_model and total_tokens > 0:
key = get_usage_key(clean_model, api_base)
current_total = redis_client.incrby(key, int(total_tokens))
redis_client.expire(key, 172800)
# Get Limit (Static + Dynamic)
effective_limit = get_effective_limit(
clean_model, api_base, current_usage=current_total
)
limit_str = (
f" | Limit: {int(effective_limit):,}" if effective_limit is not None else ""
)
# Get Balance At Sync for Log
balance_key = f"limit:dynamic:volcengine:balance_at_sync:{clean_model}"
bal_val = redis_client.get(balance_key)
bal_display = ""
if bal_val is not None:
try:
bal_int = int(float(bal_val))
bal_display = f" | BalAtSync: {bal_int:,}"
except Exception as e:
print(
f"DEBUG: Error calculating balance for {clean_model}: {e}"
)
pass
else:
# Debug why key is missing
if DEBUG_MODE:
print(f"DEBUG: Balance key not found: {balance_key}")
key_display = key.split(":", 2)[-1]
log(
f"📊 [Usage] {key_display}: +{total_tokens} (Today: {current_total:,}{bal_display}{limit_str})",
level="INFO",
)
evict, evict_reason = should_soft_evict(
clean_model, api_base, current_total, effective_limit
)
if evict:
log(
f"🚫 [BLOCK] {clean_model} soft-evict: {evict_reason} (limit: {effective_limit})",
level="WARNING",
)
soft_evict_from_router(raw_model, api_base)
except Exception as e:
log(
f"❌ _record_usage failed: {e}\n{traceback.format_exc()}", level="ERROR"
)
# Module Level Instance
proxy_handler_instance = DailyLimitHandler()
# ==========================================
# 🔄 Daemon Logic (Active Monitor Loop)
# ==========================================
def fetch_all_packages():
action = "ListResourcePackages"
version = "2022-01-01"
all_packages = []
current_token = ""
seen_tokens = set()
while True:
try:
payload = {
"ResourceType": "Package",
"MaxResults": "20",
# 服务端只返回生效中的数据包,跳过 UsedUp/Expired/Refunded 等
# 余额为 0 的无用包,显著减少翻页次数与 QPS 占用。
# 周期型包(月度重置)状态仍为 Effective,不会被误过滤。
"Status": "Effective",
"NextToken": current_token,
}
headers, body = get_signed_headers(action, version, payload)
url = f"https://{HOST}/?Action={action}&Version={version}"
response = requests.post(url, data=body, headers=headers, timeout=60)
response.raise_for_status()
data = response.json()
items = data.get("Result", {}).get("List", [])
if items:
all_packages.extend(items)
next_token = data.get("Result", {}).get("NextToken", "")
# Infinite Loop Protection
# 火山 API 翻页到头后 NextToken 会循环回起始 token(实测数据只有
# ~8 页时第 9 页绕回第一个 token),必须用 seen_tokens 拦下载断。
if not next_token:
break
if next_token == current_token:
break
if next_token in seen_tokens:
log(
f"⚠️ VolcEngine pagination cycle detected (token {next_token} repeated). "
f"Stopping after {len(all_packages)} packages.",
level="WARNING",
)
break
seen_tokens.add(next_token)
current_token = next_token
time.sleep(0.2)
except Exception as e:
print(f"Error fetching packages: {e}")
break
return all_packages
def process_packages_breakdown(packages):
model_counts = {}
seen_package_hashes = set()
print(f"\n--- Raw Package Processing ---", flush=True)
for pkg in packages:
try:
# 0. Deduplication (Exact Match)
# VolcEngine pagination bugs might return duplicate items or pages.
# We assume identical JSON objects are duplicates.
pkg_hash = hashlib.md5(json.dumps(pkg, sort_keys=True).encode()).hexdigest()
if pkg_hash in seen_package_hashes:
if DEBUG_MODE:
print(f"DEBUG: Skipping duplicate package (Hash: {pkg_hash})")
continue
seen_package_hashes.add(pkg_hash)
raw_code = pkg.get("ConfigurationCode", "")
raw_name = pkg.get("ConfigurationName", "Unknown")
target_raw = raw_code if raw_code else raw_name
# 1. Normalize name (strips suffixes, handles case/hyphens)
norm_result = normalize_volc_name(target_raw)
status = pkg.get("Status", "")
if status == "Effective":
available = pkg.get("AvailableAmount", 0)
else:
available = 0
unit = pkg.get("Unit", "token")
# 2. Resolve to monitored upstream models.
# 普通版本号模型会自动从 config.yaml 推导;特殊别名走手工覆盖。
target_models = MONITORED_MODEL_ALIASES.get(norm_result, [norm_result])
# Debug log
if (
DEBUG_MODE
or "doubao" in str(target_models)
or "glm" in str(target_models)
):
print(
f"DEBUG: Pkg Raw='{target_raw}' -> Norm='{norm_result}' -> Targets={target_models}, Avail={int(float(available)):,}",
flush=True,
)
if unit.lower() == "token":
for model_name in target_models:
if model_name == "unknown":
continue
# Aggregate balances (Handle float string '123.00' -> float -> int)
try:
amount = int(float(available))
except ValueError:
print(
f"Error converting available amount '{available}' to int for {model_name}"
)
amount = 0
model_counts[model_name] = model_counts.get(model_name, 0) + amount
except Exception as e:
print(f"Error processing package: {e}")
continue
return model_counts
def discover_monitored_models():
"""
🔎 从 config.yaml 自动发现纳入监控的火山模型,填充 MONITORED_MODEL_PAIRS /
MONITORED_MODEL_ALIASES。返回监控模型名 set。
独立函数化:volc_consume.py 手动运行(无守护线程)时也可复用。
"""
global MONITORED_MODEL_PAIRS, MONITORED_MODEL_ALIASES
MONITORED_MODEL_PAIRS.clear()
MONITORED_MODEL_ALIASES.clear()
MONITORED_MODELS = set()
try:
if os.path.exists("config.yaml"):
with open("config.yaml", "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
print(
"Auto-discovering monitored Volcengine models from config.yaml / 正在从 config.yaml 自动发现纳入监控的火山模型...",
flush=True,
)
# Robust Parsing Logic
current_entry = {
"is_volc": False,
"upstream_model": None,
"api_base_val": None,
}
for line in lines:
sline = line.strip()
# Skip commented lines (YAML comments). Otherwise commented-out
# api_base lines (e.g. a disabled model block) leak into the
# currently-open entry and can misclassify a NewAPI entry as
# VolcEngine.
if sline.startswith("#"):
continue
# Start of a new model definition
if sline.startswith("- model_name:"):
# Commit previous entry if valid
if (
current_entry.get("is_volc")
and current_entry.get("upstream_model")
and current_entry.get("api_base_val")
):
MONITORED_MODELS.add(current_entry["upstream_model"])
# Compute Hash
h = generate_api_base_hash(current_entry["api_base_val"])
MONITORED_MODEL_PAIRS.add((current_entry["upstream_model"], h))
print(
f"✅ [Config] Added monitored pair: {current_entry['upstream_model']} | Hash: {h} | Base: {str(current_entry['api_base_val'])[:30]}...",
flush=True,
)
# Reset for new entry
current_entry = {
"is_volc": False,
"upstream_model": None,
"api_base_val": None,
}
continue
# Capture API Base & Check for Volcengine
if "api_base:" in sline:
try:
raw_val = sline.split(":", 1)[1].strip()
# Resolve Env Var
if raw_val.startswith("os.environ/"):
env_key = raw_val.replace("os.environ/", "")
resolved_val = os.getenv(env_key, "")
if not resolved_val:
print(
f"⚠️ [Config] Warning: Env var {env_key} resolved to EMPTY!",
flush=True,
)
else:
resolved_val = raw_val
current_entry["api_base_val"] = resolved_val
# Identify as Volcengine if using HUOSHAN env or specific host
if (
"HUOSHAN_BASE_URL" in sline
or "volcengineapi" in resolved_val
):
current_entry["is_volc"] = True
except Exception:
pass
# Capture Upstream Model Name
# Matches: "model: openai/deepseek..."
if sline.startswith("model:"):
try:
raw_val = sline.split(":", 1)[1].strip()
# Clean prefixes
clean = (
raw_val.replace("openai/", "")
.replace("azure/", "")
.replace("anthropic/", "")
.replace("volcengine/", "")
)
current_entry["upstream_model"] = clean
except IndexError:
pass
# Commit the last entry
if (
current_entry.get("is_volc")
and current_entry.get("upstream_model")
and current_entry.get("api_base_val")
):
MONITORED_MODELS.add(current_entry["upstream_model"])
h = generate_api_base_hash(current_entry["api_base_val"])
MONITORED_MODEL_PAIRS.add((current_entry["upstream_model"], h))
if DEBUG_MODE:
print(
f"DEBUG: Added monitored pair: {current_entry['upstream_model']} @ {h}"
)
MONITORED_MODEL_ALIASES = build_monitored_model_aliases(MONITORED_MODELS)
print(
f"Auto-discovered / 已自动发现 {len(MONITORED_MODELS)} 个纳入监控的火山上游模型: {MONITORED_MODELS}",
flush=True,
)
print(
f"Monitored model pair hashes / 监控模型端点哈希: {[p[1] for p in MONITORED_MODEL_PAIRS]}",
flush=True,
)
if DEBUG_MODE:
print(
f"DEBUG: Monitored model aliases / 监控模型别名: {MONITORED_MODEL_ALIASES}",
flush=True,
)
except Exception as e:
print(f"Config Auto-discovery failed / 配置自动发现失败: {e}", flush=True)
return MONITORED_MODELS
def _maybe_trigger_daily_consume():
"""
🕰️ 每日北京时间 23:20 触发免费额度消耗任务(洗过期资源包)。
- consume:last_run:{北京日期} 去重(SETNX,当天只触发一次)
- daemon 线程执行,不阻塞主循环;线程内 0-10 分钟随机抖动,避免固定 23:20 被风控识别
- 延迟 import volc_consume,避免模块级循环 import
"""
if not redis_client:
return
try:
now = time.time()
beijing_ts = now + (8 * 3600)
beijing_hour = time.gmtime(beijing_ts).tm_hour
beijing_min = time.gmtime(beijing_ts).tm_min
if beijing_hour != 23 or beijing_min < 20:
return # 23:20 起才触发(北京时间)
date_str = time.strftime("%Y-%m-%d", time.gmtime(beijing_ts))
lock_key = f"consume:last_run:{date_str}"
if redis_client.set(lock_key, "1", nx=True, ex=172800) is None:
return # 今天已触发过
log("🚀 触发每日消耗任务(洗过期资源包防退费)...", level="INFO")
def _run():
try:
time.sleep(random.randint(0, 600))
import volc_consume # 延迟导入,避免模块级循环 import
volc_consume.run_consume_all()
except Exception as e:
log(f"❌ 消耗任务异常: {e}\n{traceback.format_exc()}", level="ERROR")
t = threading.Thread(target=_run, daemon=True)
t.start()
except Exception as e:
log(f"❌ 消耗任务调度失败: {e}", level="ERROR")
def compute_sync_interval(min_balance, is_idle):
"""按最小余额 + 空闲状态计算同步间隔(秒)。
min_balance 是 balance_at_sync 中的最小值(实际余额,非扣 SMALL_BUFFER 后)。"""
if min_balance < 100_000:
base_interval = 300 # 紧急 5 minutes
elif min_balance < 400_000:
base_interval = 600 # 低余额 10 minutes
else:
base_interval = 1800 # 常规 30 minutes
if is_idle:
interval = min(base_interval * 4, 3600)
if interval < 300:
interval = 300
else:
interval = base_interval
return interval
def active_monitor_loop():
# Config Discovery
MONITORED_MODELS = discover_monitored_models()
if not redis_client:
print(
"❌ Redis not connected. Exiting monitor loop / Redis 未连接,退出监控循环.",
flush=True,
)
sys.exit(1)
print(
f"✅ Redis connected at / Redis 已连接于 {REDIS_HOST}:{REDIS_PORT}", flush=True
)
# ==========================================
# 🚀 Run Startup Audit Immediately
# ==========================================
# Give LiteLLM a moment to initialize router if running concurrently
time.sleep(2)
startup_audit()
last_usage_map = {}
last_sync_time = 0
current_sync_interval = 0
global_min_remaining = 0
IDLE_THRESHOLD = 300
idle_start_time = 0
print(
"Starting Active Monitor Loop (Safety-Margin + Idle Awareness) / 正在启动主动监控循环 (安全余量 + 空闲感知)...",
flush=True,
)
print(f"SmallBuffer / 缓冲: {SMALL_BUFFER}", flush=True)
while True:
try:
time.sleep(CHECK_INTERVAL)
_maybe_trigger_daily_consume()
now = time.time()
# Enforce Beijing Time for daily operations
beijing_time_ts = now + (8 * 3600)
today_str = time.strftime("%Y-%m-%d", time.gmtime(beijing_time_ts))
# Idle Check - Only exit idle if a "low quota" model has usage
LOW_QUOTA_THRESHOLD = 800000 # 判定为“低额度”的阈值
model_usages = {}
any_low_quota_usage = False
for m in MONITORED_MODELS:
m_usage = 0
target_hashes = [p[1] for p in MONITORED_MODEL_PAIRS if p[0] == m]
for h in target_hashes:
val = redis_client.get(f"daily_usage:{today_str}:{m}:{h}")
if val: m_usage += float(val)
val_no_hash = redis_client.get(f"daily_usage:{today_str}:{m}")
if val_no_hash: m_usage += float(val_no_hash)
model_usages[m] = m_usage
# Calculate delta for this specific model
m_delta = m_usage - last_usage_map.get(m, 0.0)
# Check if this model is considered "low quota"
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{m}"
bal_val = redis_client.get(bal_key)
# 未知额度视为充足,避免首次启动时所有模型被误判为"低额度"
remaining = float(bal_val) if bal_val is not None else float(LOW_QUOTA_THRESHOLD)
if m_delta > 0 and remaining < LOW_QUOTA_THRESHOLD:
any_low_quota_usage = True
time_diff = now - last_sync_time
if not any_low_quota_usage:
if idle_start_time == 0:
idle_start_time = now
else:
idle_start_time = 0
is_idle = (idle_start_time > 0) and (
(now - idle_start_time) > IDLE_THRESHOLD
)
# Dynamic Interval: 按最小正剩余余额分档(见 compute_sync_interval)
current_sync_interval = compute_sync_interval(
global_min_remaining, is_idle
)
# =====================================================
# 🚀 Per-Model Burst Detection (精细化突发流量检测)
# =====================================================
burst_triggered = False
burst_reason = ""
for model in MONITORED_MODELS:
# 1. Get Current Usage (calculated above)
curr_usage = model_usages.get(model, 0.0)
# 2. Get Last Synced Usage (Snapshot)
last_usage = last_usage_map.get(model, 0.0)
# 3. Calculate Delta since last sync
model_delta = curr_usage - last_usage
# 4. Get Current Balance for Threshold Calculation
bal_key = f"limit:dynamic:volcengine:balance_at_sync:{model}"
bal_val = redis_client.get(bal_key)
current_balance = float(bal_val) if bal_val else 0.0
# 5. Determine Dynamic Threshold based on Balance
# Rich models get loose limits, Poor models get strict limits.
if current_balance > 1000000:
model_threshold = 500000
elif current_balance > 500000:
model_threshold = 100000
elif current_balance > 100000:
model_threshold = 50000
else:
model_threshold = 10000 # Strict for poor models
# 6. Check Trigger
if model_delta > model_threshold:
burst_triggered = True
burst_reason = f"{model} (+{model_delta:,} > {model_threshold:,})"
break # One burst is enough to trigger sync
should_sync = (
burst_triggered
or (time_diff > current_sync_interval)
or (last_sync_time == 0)
)
if should_sync:
trigger_reason = (
f"Burst: {burst_reason}" if burst_triggered else "Timer Expired"
)
# For Timer Expired, we don't have a specific delta to show, or we can calculate global delta if needed.
# But for now, let's just show the reason.
print(
f"{'💤' if is_idle else '⚡'} Sync Triggered ({trigger_reason}) / 同步触发... Interval: {current_sync_interval}s",
flush=True,
)
packages = fetch_all_packages()
model_balances = process_packages_breakdown(packages)
print(
f"DEBUG: Processed models / 已处理模型: {list(model_balances.keys())}",
flush=True,
)
temp_min_remaining = float("inf")
configured_models_found = False
for model_name, balance in model_balances.items():
print(
f"DEBUG: Checking {model_name} (Bal: {int(balance):,})",
flush=True,
)
# Target Resolution
targets = [model_name]
for target in targets:
# STRICT FILTER: Only sync models explicitly configured with VolcEngine Base URL
if target not in MONITORED_MODELS:
if DEBUG_MODE:
print(
f"DEBUG: Skipping {target} (Not in monitored models / 未纳入监控模型)",
flush=True,
)
continue
# Fetch Current Usage from Redis
current_usage = 0
target_hashes = [
p[1] for p in MONITORED_MODEL_PAIRS if p[0] == target
]
# Sum usage across all configured endpoints for this model
for h in target_hashes:
now_ts = time.time()
beijing_time_ts = now_ts + (8 * 3600)
usage_date = time.strftime(
"%Y-%m-%d", time.gmtime(beijing_time_ts)
)
u_key = f"daily_usage:{usage_date}:{target}:{h}"
val = redis_client.get(u_key)
if val:
current_usage += float(val)
balance_key = f"limit:dynamic:volcengine:balance_at_sync:{target}"
usage_key = f"limit:dynamic:volcengine:usage_at_sync:{target}"
# Store snapshot: balance_at_sync, usage_at_sync
redis_client.setex(balance_key, 86400, int(balance))
redis_client.setex(usage_key, 86400, int(current_usage))
if DEBUG_MODE:
static_limit = get_monitored_static_limit(
target, target_hashes
)
print(
f"DEBUG: {target} Bal={int(balance):,} | Usage={int(current_usage):,} | BalAtSync={int(balance):,} UsageAtSync={int(current_usage):,}",
flush=True,
)
# 即时恢复:同步发现限额已恢复(usage < effective limit)时立刻解除软踢,
restore_model_if_healthy(target, target_hashes)
# Track min balance for frequency control
configured_models_found = True
if balance > 0 and balance < temp_min_remaining:
temp_min_remaining = balance
# If all models are dead (temp_min_remaining is still inf), set to 0 for logic but handle gracefully
# If we found models but all were <= 0, then temp_min_remaining is inf.
# Logic:
# 1. No models found at all -> Default to Safe (300k)
# 2. Models found, but all 0 -> "All Dead Mode"
# 3. Models found, some > 0 -> Use min of >0 models
if not configured_models_found:
global_min_remaining = 300000 # Default
elif temp_min_remaining == float("inf"):
# All active models are <= 0
# Check time of day to decide if we should panic
utc_now_struct = time.gmtime(now)
beijing_hour = (utc_now_struct.tm_hour + 8) % 24
if 7 <= beijing_hour < 13:
# Morning Reset Window: Check relatively frequently
global_min_remaining = (
200000 # Pretend we have some quota to get ~5min interval
)
else:
# 返还"不定时"(可能下午到账):All Dead 不能睡 60 分钟,
# 否则额度到账后最多空转 60 分钟才解锁。收紧到 30 分钟。
global_min_remaining = (
300000 # 30-minute sync while waiting for reset
)
else:
global_min_remaining = temp_min_remaining
# Update snapshots for next iteration delta calculation
for m, u_val in model_usages.items():
last_usage_map[m] = u_val
last_sync_time = now
if global_min_remaining <= 100000:
mode_icon = "🆘 紧急模式"
elif global_min_remaining <= 300000:
mode_icon = "🚨 低余额模式"
else:
mode_icon = "ℹ️ 常规模式"
# 用本次同步后的新 Min Rem 重算,避免"旧 interval + 新 Min Rem"混打误导
next_interval = compute_sync_interval(global_min_remaining, is_idle)
print(
f"{mode_icon}: 下一次同步 {next_interval}s (Min Rem: {int(global_min_remaining):,}, {'空闲' if is_idle else '活跃'}) / 同步间隔 {next_interval}s",
flush=True,
)
except Exception as e:
print(f"Error in monitor loop / 监控循环出错: {e}", flush=True)
print(traceback.format_exc(), end="")
time.sleep(5)
if __name__ == "__main__":
active_monitor_loop()
else:
# Auto-start when loaded as a module/callback
print(
"🚀 Initializing VolcSync Background Monitor... / 正在初始化火山引擎同步后台监控...",
flush=True,
)
t = threading.Thread(target=active_monitor_loop, daemon=True)
t.start()