feat(tunnel): 重连指数退避、多worker兼容与手动节点请求计数

- Proxy tunnel 重连策略从固定1s改为指数退避+jitter,首次重试立即执行,
  稳定连接30s后重置退避计数,上限3s保证快速恢复
- 多连接启动时增加错峰延迟,避免同时发起连接风暴
- 服务端 tunnel ping间隔和空闲超时支持环境变量配置
- 修复多worker启动时tunnel状态重置逻辑,仅leader执行重置避免覆盖其他worker连接
- Resolver增加本地tunnel缺失的限频告警和更短缓存TTL,加速多worker场景恢复
- 用量记录中统计手动代理节点的请求数和失败数
This commit is contained in:
fawney19
2026-03-01 17:37:11 +08:00
parent 2c1e51a490
commit d4840df447
6 changed files with 340 additions and 22 deletions

View File

@@ -202,21 +202,19 @@ pub struct Config {
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)] #[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
pub log_json: bool, pub log_json: bool,
/// Deprecated: reconnect now uses a fixed 1s delay. Kept for config compatibility. /// Tunnel reconnect base delay in milliseconds (used by exponential backoff)
#[arg( #[arg(
long, long,
env = "AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS", env = "AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS",
default_value_t = 500, default_value_t = 500
hide = true
)] )]
pub tunnel_reconnect_base_ms: u64, pub tunnel_reconnect_base_ms: u64,
/// Deprecated: reconnect now uses a fixed 1s delay. Kept for config compatibility. /// Tunnel reconnect max delay in milliseconds (cap for exponential backoff)
#[arg( #[arg(
long, long,
env = "AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS", env = "AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS",
default_value_t = 30000, default_value_t = 30000
hide = true
)] )]
pub tunnel_reconnect_max_ms: u64, pub tunnel_reconnect_max_ms: u64,

View File

@@ -6,16 +6,26 @@ pub mod stream_handler;
pub mod writer; pub mod writer;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{error, info}; use tracing::{error, info};
use crate::state::{AppState, ServerContext}; use crate::state::{AppState, ServerContext};
/// Fixed reconnect delay -- short enough for fast recovery, long enough to /// If a tunnel stays connected at least this long, treat the next disconnect
/// avoid CPU spin when the network is completely down. /// as a non-failure and reset reconnect backoff.
const RECONNECT_DELAY: Duration = Duration::from_secs(1); const STABLE_SESSION_RESET_AFTER: Duration = Duration::from_secs(30);
/// Startup staggering step per secondary connection, used to avoid
/// simultaneous bursts when a pool of tunnels starts together.
const STARTUP_STAGGER_STEP_MS: u64 = 150;
/// Upper bound for startup staggering.
const MAX_STARTUP_STAGGER_MS: u64 = 1_500;
/// Keep a tiny floor for repeated reconnects; first retry is still immediate.
const MIN_RECONNECT_DELAY_MS: u64 = 50;
/// Even under sustained failures, keep probing frequently so recovery is fast
/// once cross-border network quality improves.
const RECONNECT_PROBE_MAX_DELAY_MS: u64 = 3_000;
/// Run the tunnel mode main loop (connect, dispatch, reconnect). /// Run the tunnel mode main loop (connect, dispatch, reconnect).
/// ///
@@ -28,8 +38,29 @@ pub async fn run(
mut shutdown: watch::Receiver<bool>, mut shutdown: watch::Receiver<bool>,
) { ) {
info!(server = %server.server_label, conn = conn_idx, "starting tunnel"); info!(server = %server.server_label, conn = conn_idx, "starting tunnel");
let reconnect_salt = compute_connection_salt(server, conn_idx);
let startup_delay = compute_startup_stagger(conn_idx, reconnect_salt);
if !startup_delay.is_zero() {
info!(
server = %server.server_label,
conn = conn_idx,
delay_ms = startup_delay.as_millis(),
"startup stagger before first connect"
);
tokio::select! {
_ = tokio::time::sleep(startup_delay) => {}
_ = shutdown.changed() => {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during startup stagger");
return;
}
}
}
let mut consecutive_failures: u32 = 0;
loop { loop {
let started_at = Instant::now();
match client::connect_and_run(state, server, conn_idx, &mut shutdown).await { match client::connect_and_run(state, server, conn_idx, &mut shutdown).await {
Ok(client::TunnelOutcome::Shutdown) => { Ok(client::TunnelOutcome::Shutdown) => {
info!(server = %server.server_label, conn = conn_idx, "tunnel shut down gracefully"); info!(server = %server.server_label, conn = conn_idx, "tunnel shut down gracefully");
@@ -48,8 +79,31 @@ pub async fn run(
return; return;
} }
// Reset backoff after a stable session to keep recovery snappy when
// failures are only occasional.
let connected_for = started_at.elapsed();
if connected_for >= STABLE_SESSION_RESET_AFTER {
consecutive_failures = 0;
} else {
consecutive_failures = consecutive_failures.saturating_add(1);
}
let reconnect_delay = compute_reconnect_delay(
state.config.tunnel_reconnect_base_ms,
state.config.tunnel_reconnect_max_ms,
consecutive_failures,
reconnect_salt,
);
info!(
server = %server.server_label,
conn = conn_idx,
failures = consecutive_failures,
delay_ms = reconnect_delay.as_millis(),
"waiting before reconnect"
);
tokio::select! { tokio::select! {
_ = tokio::time::sleep(RECONNECT_DELAY) => {} _ = tokio::time::sleep(reconnect_delay) => {}
_ = shutdown.changed() => { _ = shutdown.changed() => {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during reconnect wait"); info!(server = %server.server_label, conn = conn_idx, "shutdown requested during reconnect wait");
return; return;
@@ -57,3 +111,126 @@ pub async fn run(
} }
} }
} }
fn compute_connection_salt(server: &ServerContext, conn_idx: usize) -> u64 {
// FNV-1a style hash over server label + connection index.
let mut h: u64 = 0xcbf29ce484222325;
for &b in server.server_label.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h ^= conn_idx as u64;
mix_u64(h)
}
fn compute_startup_stagger(conn_idx: usize, salt: u64) -> Duration {
if conn_idx == 0 {
return Duration::ZERO;
}
let base = (conn_idx as u64).saturating_mul(STARTUP_STAGGER_STEP_MS);
let jitter = mix_u64(salt) % 301; // 0..=300ms
Duration::from_millis((base + jitter).min(MAX_STARTUP_STAGGER_MS))
}
fn compute_reconnect_delay(
base_ms: u64,
max_ms: u64,
consecutive_failures: u32,
salt: u64,
) -> Duration {
// First retry should be immediate to maximize recovery speed on transient
// blips (the user's primary expectation in poor networks).
if consecutive_failures <= 1 {
return Duration::ZERO;
}
// Keep a sane minimum for repeated failures.
let base_ms = base_ms.max(MIN_RECONNECT_DELAY_MS);
let max_ms = max_ms.max(base_ms);
let cap_ms = compute_reconnect_cap_ms(base_ms, max_ms, consecutive_failures)
.min(RECONNECT_PROBE_MAX_DELAY_MS.max(base_ms));
// Equal-jitter: randomize in [cap/2, cap], preventing synchronized reconnect
// storms while keeping reconnect latency bounded.
if cap_ms <= 1 {
return Duration::from_millis(cap_ms);
}
let half = cap_ms / 2;
let span = cap_ms - half;
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let mixed = mix_u64(now_nanos ^ salt);
let jitter = if span == 0 { 0 } else { mixed % (span + 1) };
Duration::from_millis(half + jitter)
}
fn compute_reconnect_cap_ms(base_ms: u64, max_ms: u64, consecutive_failures: u32) -> u64 {
if consecutive_failures <= 1 {
return base_ms.min(max_ms);
}
let shift = (consecutive_failures - 1).min(31);
let factor = 1u64 << shift;
base_ms.saturating_mul(factor).min(max_ms)
}
fn mix_u64(mut x: u64) -> u64 {
// SplitMix64 finalizer - cheap bit mixing for pseudo-random jitter.
x ^= x >> 30;
x = x.wrapping_mul(0xbf58476d1ce4e5b9);
x ^= x >> 27;
x = x.wrapping_mul(0x94d049bb133111eb);
x ^ (x >> 31)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
compute_reconnect_cap_ms, compute_reconnect_delay, compute_startup_stagger,
MAX_STARTUP_STAGGER_MS, RECONNECT_PROBE_MAX_DELAY_MS, STARTUP_STAGGER_STEP_MS,
};
#[test]
fn reconnect_cap_grows_exponentially_and_caps() {
let base = 500;
let max = 30_000;
assert_eq!(compute_reconnect_cap_ms(base, max, 0), 500);
assert_eq!(compute_reconnect_cap_ms(base, max, 1), 500);
assert_eq!(compute_reconnect_cap_ms(base, max, 2), 1_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 3), 2_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 4), 4_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 5), 8_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 6), 16_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 7), 30_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 20), 30_000);
}
#[test]
fn startup_stagger_is_zero_for_primary_and_bounded_for_secondary() {
assert_eq!(compute_startup_stagger(0, 42), Duration::ZERO);
let d1 = compute_startup_stagger(1, 42);
let d2 = compute_startup_stagger(2, 42);
assert!(d1 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS));
assert!(d1 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
assert!(d2 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS * 2));
assert!(d2 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
}
#[test]
fn reconnect_delay_is_immediate_on_first_failure() {
assert_eq!(compute_reconnect_delay(700, 45_000, 1, 123), Duration::ZERO);
}
#[test]
fn reconnect_delay_stays_within_probe_ceiling_after_many_failures() {
let d = compute_reconnect_delay(500, 45_000, 100, 12345);
assert!(d <= Duration::from_millis(RECONNECT_PROBE_MAX_DELAY_MS));
}
}

View File

@@ -8,6 +8,7 @@ aether-proxy 通过此端点建立 tunnel 连接。
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -37,11 +38,58 @@ def _get_node_lock(node_id: str) -> asyncio.Lock:
# 单帧最大 64 MB -- AI API 请求体可能包含多张 base64 图片,需要足够余量 # 单帧最大 64 MB -- AI API 请求体可能包含多张 base64 图片,需要足够余量
_MAX_FRAME_SIZE = 64 * 1024 * 1024 _MAX_FRAME_SIZE = 64 * 1024 * 1024
# WebSocket 空闲超时(秒)-- 需覆盖客户端 stale_timeout(45s) + 重连延迟(最长30s) 的窗口 # 默认 WebSocket 空闲超时(秒)-- 需覆盖客户端 stale_timeout(45s) + 重连延迟窗口
_IDLE_TIMEOUT = 90.0 _DEFAULT_IDLE_TIMEOUT = 90.0
# 服务端应用层 ping 间隔(秒)-- 与客户端 ping 间隔一致,确保高频心跳 # 默认服务端应用层 ping 间隔(秒)-- 与客户端 ping 间隔一致,确保高频心跳
_SERVER_PING_INTERVAL = 15.0 _DEFAULT_SERVER_PING_INTERVAL = 15.0
def _env_float(name: str, default: float, *, min_value: float, max_value: float) -> float:
"""读取并校验浮点环境变量,非法时回退默认值。"""
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
value = float(raw)
except ValueError:
logger.warning("invalid {}={}, fallback to {}", name, raw, default)
return default
if value < min_value or value > max_value:
logger.warning(
"{}={} out of range [{}, {}], fallback to {}",
name,
value,
min_value,
max_value,
default,
)
return default
return value
# 允许通过环境变量在弱网环境下调大容忍窗口(无需改代码)
_SERVER_PING_INTERVAL = _env_float(
"AETHER_PROXY_TUNNEL_SERVER_PING_INTERVAL",
_DEFAULT_SERVER_PING_INTERVAL,
min_value=5.0,
max_value=120.0,
)
_IDLE_TIMEOUT = _env_float(
"AETHER_PROXY_TUNNEL_SERVER_IDLE_TIMEOUT",
_DEFAULT_IDLE_TIMEOUT,
min_value=30.0,
max_value=600.0,
)
# 避免 idle timeout 过小导致 ping 尚未生效就被服务端断开
if _IDLE_TIMEOUT <= _SERVER_PING_INTERVAL * 2:
adjusted_idle = max(_SERVER_PING_INTERVAL * 3, 30.0)
logger.warning(
"AETHER_PROXY_TUNNEL_SERVER_IDLE_TIMEOUT too low for ping interval, auto-adjust to {}",
adjusted_idle,
)
_IDLE_TIMEOUT = adjusted_idle
async def _authenticate(ws: WebSocket) -> tuple[str, str] | None: async def _authenticate(ws: WebSocket) -> tuple[str, str] | None:

View File

@@ -76,15 +76,18 @@ async def _on_startup() -> None:
"""启动心跳检测调度器""" """启动心跳检测调度器"""
import logging import logging
from src.config import config
from src.services.proxy_node.health_scheduler import get_proxy_node_health_scheduler from src.services.proxy_node.health_scheduler import get_proxy_node_health_scheduler
from src.utils.task_coordinator import StartupTaskCoordinator from src.utils.task_coordinator import StartupTaskCoordinator
logger = logging.getLogger("aether.modules.proxy_nodes") logger = logging.getLogger("aether.modules.proxy_nodes")
# 服务端启动时TunnelManager 内存为空,所有 tunnel 连接都需要重新建立。 if config.worker_processes > 1:
# 重置 DB 中残留的 tunnel_connected=True 状态,避免 health_scheduler logger.warning(
# 误将未连接的节点标记为 ONLINE。 "检测到 WEB_CONCURRENCY={}。Proxy tunnel 连接是进程内资源,"
_reset_tunnel_connected_on_startup() "多 worker 场景可能出现节点显示 ONLINE 但当前 worker 无可用 tunnel 的情况。",
config.worker_processes,
)
from src.clients import get_redis_client from src.clients import get_redis_client
@@ -94,6 +97,9 @@ async def _on_startup() -> None:
proxy_node_health_scheduler = get_proxy_node_health_scheduler() proxy_node_health_scheduler = get_proxy_node_health_scheduler()
active = await task_coordinator.acquire("proxy_node_health") active = await task_coordinator.acquire("proxy_node_health")
if active: if active:
# 仅 leader worker 执行启动重置,避免多 worker 并发启动/重启时
# 把其他 worker 已建立的 tunnel 状态错误重置为 OFFLINE。
_reset_tunnel_connected_on_startup()
logger.info("启动 ProxyNode 心跳检测调度器...") logger.info("启动 ProxyNode 心跳检测调度器...")
await proxy_node_health_scheduler.start() await proxy_node_health_scheduler.start()
else: else:

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import gzip import gzip
import hashlib import hashlib
import json import json
import os
import time import time
from typing import Any from typing import Any
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
@@ -26,7 +27,10 @@ from src.core.logger import logger
_proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {} _proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
_PROXY_NODE_CACHE_TTL_SECONDS = 15.0 _PROXY_NODE_CACHE_TTL_SECONDS = 15.0
_PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS = 5.0 # 不可用节点使用更短的 TTL加速恢复感知 _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS = 5.0 # 不可用节点使用更短的 TTL加速恢复感知
_PROXY_NODE_CACHE_TUNNEL_LOCAL_MISS_TTL_SECONDS = 0.5 # 本地 worker 无 tunnel 时,快速重试
_PROXY_NODE_CACHE_MAX_SIZE = 256 _PROXY_NODE_CACHE_MAX_SIZE = 256
_TUNNEL_LOCAL_MISS_LOG_COOLDOWN_SECONDS = 30.0
_tunnel_local_miss_log_next_at: dict[str, float] = {}
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None: def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
@@ -74,9 +78,23 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
manager = get_tunnel_manager() manager = get_tunnel_manager()
if not manager.has_tunnel(node_id): if not manager.has_tunnel(node_id):
# 多 worker 部署时DB 可能显示 ONLINE其他 worker 有 tunnel
# 但当前 worker 无本地连接,请求仍不可用。记录限频告警便于定位。
if now >= _tunnel_local_miss_log_next_at.get(node_id, 0.0):
_tunnel_local_miss_log_next_at[node_id] = (
now + _TUNNEL_LOCAL_MISS_LOG_COOLDOWN_SECONDS
)
logger.warning(
"tunnel node {} has no local connection on pid={} "
"(db_status={}, db_tunnel_connected={}), request may fail on this worker",
node_id,
os.getpid(),
str(getattr(node, "status", "unknown")),
bool(getattr(node, "tunnel_connected", False)),
)
_proxy_node_cache[node_id] = ( _proxy_node_cache[node_id] = (
None, None,
now + _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS, now + _PROXY_NODE_CACHE_TUNNEL_LOCAL_MISS_TTL_SECONDS,
) )
return None return None
value: dict[str, Any] = { value: dict[str, Any] = {
@@ -127,6 +145,7 @@ _SYSTEM_PROXY_CACHE_TTL = 60.0
def invalidate_proxy_node_cache(node_id: str) -> None: def invalidate_proxy_node_cache(node_id: str) -> None:
"""主动清除指定节点的信息缓存tunnel 断开时调用,避免使用过期的连接状态)""" """主动清除指定节点的信息缓存tunnel 断开时调用,避免使用过期的连接状态)"""
_proxy_node_cache.pop(node_id, None) _proxy_node_cache.pop(node_id, None)
_tunnel_local_miss_log_next_at.pop(node_id, None)
def invalidate_system_proxy_cache() -> None: def invalidate_system_proxy_cache() -> None:
@@ -509,7 +528,10 @@ def resolve_proxy_info(proxy_config: dict[str, Any] | None) -> dict[str, Any] |
node_id = node_id.strip() node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id) node_info = _get_proxy_node_info(node_id)
node_name = node_info.get("name", "unknown") if node_info else "offline" node_name = node_info.get("name", "unknown") if node_info else "offline"
return {"node_id": node_id, "node_name": node_name, "source": source} info: dict[str, Any] = {"node_id": node_id, "node_name": node_name, "source": source}
if node_info and node_info.get("is_manual"):
info["is_manual"] = True
return info
# 旧格式 URL 模式 # 旧格式 URL 模式
proxy_url = effective_config.get("url") proxy_url = effective_config.get("url")

View File

@@ -7,7 +7,7 @@ from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.logger import logger from src.core.logger import logger
from src.models.database import ApiKey, Provider, Usage, User, UserModelUsageCount from src.models.database import ApiKey, Provider, ProxyNode, Usage, User, UserModelUsageCount
from src.services.provider_keys.codex_quota_sync_dispatcher import ( from src.services.provider_keys.codex_quota_sync_dispatcher import (
dispatch_codex_quota_sync_from_response_headers, dispatch_codex_quota_sync_from_response_headers,
) )
@@ -22,6 +22,50 @@ from src.services.usage._recording_helpers import (
from src.services.usage._types import UsageCostInfo, UsageRecordParams from src.services.usage._types import UsageCostInfo, UsageRecordParams
def _extract_manual_proxy_node_id(metadata: dict[str, Any] | None) -> str | None:
"""从 request_metadata 中提取手动代理节点 ID仅 is_manual 节点)。
Tunnel 节点的统计由 aether-proxy 心跳上报,此处只处理手动节点以避免重复计数。
"""
if not metadata:
return None
proxy = metadata.get("proxy")
if not isinstance(proxy, dict):
return None
if not proxy.get("is_manual"):
return None
node_id = proxy.get("node_id")
return node_id if isinstance(node_id, str) and node_id.strip() else None
def _increment_proxy_node_requests(
db: Session,
node_counts: dict[str, int],
failed_counts: dict[str, int] | None = None,
) -> None:
"""批量递增手动代理节点的 total_requests 和 failed_requests原子 SQL UPDATE"""
if not node_counts and not failed_counts:
return
from sqlalchemy import update
# 合并所有涉及的 node_id
all_ids = set(node_counts) | set(failed_counts or {})
for node_id in all_ids:
total = node_counts.get(node_id, 0)
failed = (failed_counts or {}).get(node_id, 0)
values: dict[str, Any] = {}
if total > 0:
values["total_requests"] = ProxyNode.total_requests + total
if failed > 0:
values["failed_requests"] = ProxyNode.failed_requests + failed
if values:
db.execute(
update(ProxyNode)
.where(ProxyNode.id == node_id, ProxyNode.is_manual == True) # noqa: E712
.values(**values)
)
class UsageRecordingMixin(UsageBillingIntegrationMixin): class UsageRecordingMixin(UsageBillingIntegrationMixin):
"""记录用量相关方法""" """记录用量相关方法"""
@@ -404,6 +448,12 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost) .values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
) )
# 更新手动代理节点请求计数tunnel 节点由心跳上报,不在此处统计)
manual_node_id = _extract_manual_proxy_node_id(metadata)
if manual_node_id:
failed = {manual_node_id: 1} if status == "failed" else None
_increment_proxy_node_requests(db, {manual_node_id: 1}, failed)
# 结算标记:终态请求写入 settled + finalized_at # 结算标记:终态请求写入 settled + finalized_at
if status not in ("pending", "streaming"): if status not in ("pending", "streaming"):
usage.billing_status = "settled" usage.billing_status = "settled"
@@ -785,6 +835,8 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
int int
) # (user_id, model) -> count ) # (user_id, model) -> count
provider_costs: dict[str, float] = defaultdict(float) # provider_id -> cost provider_costs: dict[str, float] = defaultdict(float) # provider_id -> cost
proxy_node_counts: dict[str, int] = defaultdict(int) # node_id -> request count
proxy_node_failed: dict[str, int] = defaultdict(int) # node_id -> failed count
quota_update_candidates: dict[str, dict[str, Any]] = {} quota_update_candidates: dict[str, dict[str, Any]] = {}
# 合并所有需要处理的记录(用于预取 user/api_key # 合并所有需要处理的记录(用于预取 user/api_key
@@ -944,6 +996,12 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
apikey_stats[key_id]["cost"] += total_cost apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
provider_api_key_id = record.get("provider_api_key_id") provider_api_key_id = record.get("provider_api_key_id")
response_headers = record.get("response_headers") response_headers = record.get("response_headers")
if ( if (
@@ -1005,6 +1063,12 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
apikey_stats[key_id]["cost"] += total_cost apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
provider_api_key_id = record.get("provider_api_key_id") provider_api_key_id = record.get("provider_api_key_id")
response_headers = record.get("response_headers") response_headers = record.get("response_headers")
if ( if (
@@ -1130,6 +1194,9 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
) )
) )
# 批量更新手动代理节点请求计数
_increment_proxy_node_requests(db, proxy_node_counts, proxy_node_failed)
# 配额头实时同步:同一 key 仅取本批次最后一组响应头并执行一次对比更新。 # 配额头实时同步:同一 key 仅取本批次最后一组响应头并执行一次对比更新。
for provider_api_key_id, response_headers in quota_update_candidates.items(): for provider_api_key_id, response_headers in quota_update_candidates.items():
dispatch_codex_quota_sync_from_response_headers( dispatch_codex_quota_sync_from_response_headers(