perf(transport): 全链路传输压缩优化

- 上游请求启用 HTTP/2 (HPACK 头部压缩 + 多路复用),添加 h2 依赖
- 上游请求体超过阈值时自动 gzip 压缩,使用紧凑 JSON 序列化
- 添加 brotli 依赖,Accept-Encoding 支持 gzip/deflate/br
- 隧道帧压缩: Rust 端响应帧和 Python 端请求/响应帧均支持 gzip
- Rust 端压缩/解压逻辑统一提取到 protocol.rs
- Rust 端请求头构建改用 .headers() 替换 reqwest 默认值
- 新增 ENABLE_HTTP2、ENABLE_REQUEST_COMPRESSION 等环境变量配置
This commit is contained in:
fawney19
2026-02-28 20:53:11 +08:00
parent 3ff67fec2f
commit a2d1cff3b0
10 changed files with 279 additions and 59 deletions

View File

@@ -14,7 +14,7 @@ use tracing::{debug, error, info, warn};
use crate::state::{AppState, ServerContext}; use crate::state::{AppState, ServerContext};
use super::heartbeat::HeartbeatHandle; use super::heartbeat::HeartbeatHandle;
use super::protocol::{Frame, MsgType, RequestMeta}; use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
use super::stream_handler; use super::stream_handler;
use super::writer::FrameSender; use super::writer::FrameSender;
@@ -92,8 +92,15 @@ where
match frame.msg_type { match frame.msg_type {
MsgType::RequestHeaders => { MsgType::RequestHeaders => {
// Parse request metadata // Decompress if the frame is gzip-compressed, then parse metadata
let meta: RequestMeta = match serde_json::from_slice(&frame.payload) { let payload = match decompress_if_gzip(&frame) {
Ok(p) => p,
Err(e) => {
warn!(stream_id = frame.stream_id, error = %e, "frame decompress failed");
continue;
}
};
let meta: RequestMeta = match serde_json::from_slice(&payload) {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata"); warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata");

View File

@@ -159,3 +159,53 @@ pub struct ResponseMeta {
/// Header list preserving duplicates (e.g. multiple Set-Cookie). /// Header list preserving duplicates (e.g. multiple Set-Cookie).
pub headers: Vec<(String, String)>, pub headers: Vec<(String, String)>,
} }
// ---------------------------------------------------------------------------
// Tunnel frame compression helpers
// ---------------------------------------------------------------------------
/// Minimum payload size to attempt gzip compression (bytes).
const COMPRESS_MIN_SIZE: usize = 512;
/// If the frame has the GZIP_COMPRESSED flag, decompress the payload; otherwise
/// return a clone of the raw payload bytes.
pub fn decompress_if_gzip(frame: &Frame) -> Result<Bytes, std::io::Error> {
if frame.is_gzip() {
decompress_gzip(&frame.payload)
} else {
Ok(frame.payload.clone())
}
}
/// Gzip-compress `data` if it is large enough and compression actually shrinks
/// the payload. Returns `(payload, extra_flags)` where `extra_flags` contains
/// `GZIP_COMPRESSED` when compression was applied.
pub fn compress_payload(data: Bytes) -> (Bytes, u8) {
if data.len() >= COMPRESS_MIN_SIZE {
if let Ok(compressed) = compress_gzip(&data) {
if compressed.len() < data.len() {
return (compressed, flags::GZIP_COMPRESSED);
}
}
}
(data, 0)
}
fn decompress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(data);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf)?;
Ok(Bytes::from(buf))
}
fn compress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(data)?;
let compressed = encoder.finish()?;
Ok(Bytes::from(compressed))
}

View File

@@ -15,7 +15,9 @@ use tracing::{debug, warn};
use crate::state::{AppState, ServerContext}; use crate::state::{AppState, ServerContext};
use crate::target_filter; use crate::target_filter;
use super::protocol::{flags, Frame, MsgType, RequestMeta, ResponseMeta}; use super::protocol::{
compress_payload, decompress_if_gzip, flags, Frame, MsgType, RequestMeta, ResponseMeta,
};
use super::writer::FrameSender; use super::writer::FrameSender;
/// Maximum response body chunk size per frame (32 KB). /// Maximum response body chunk size per frame (32 KB).
@@ -95,21 +97,17 @@ async fn handle_stream_inner(
match body_rx.recv().await { match body_rx.recv().await {
Some(frame) => { Some(frame) => {
if frame.msg_type == MsgType::RequestBody { if frame.msg_type == MsgType::RequestBody {
let payload = if frame.is_gzip() { let payload = match decompress_if_gzip(&frame) {
match decompress_gzip(&frame.payload) { Ok(d) => d,
Ok(d) => d, Err(e) => {
Err(e) => { send_error(
send_error( frame_tx,
frame_tx, stream_id,
stream_id, &format!("gzip decompress failed: {e}"),
&format!("gzip decompress failed: {e}"), )
) .await;
.await; return;
return;
}
} }
} else {
frame.payload.clone()
}; };
if !payload.is_empty() { if !payload.is_empty() {
body_parts.push(payload); body_parts.push(payload);
@@ -194,21 +192,23 @@ async fn handle_stream_inner(
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)); let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
let method: reqwest::Method = meta.method.parse().unwrap_or(reqwest::Method::GET); let method: reqwest::Method = meta.method.parse().unwrap_or(reqwest::Method::GET);
let mut req = client.request(method, &meta.url); // Build a complete HeaderMap from tunnel headers, then set it all at once
// via .headers() which *replaces* reqwest defaults (e.g. Accept: */*),
// ensuring upstream sees exactly what Aether server intended.
let mut header_map = reqwest::header::HeaderMap::with_capacity(meta.headers.len());
for (k, v) in &meta.headers { for (k, v) in &meta.headers {
let k_lower = k.to_ascii_lowercase(); let k_lower = k.to_ascii_lowercase();
// Skip hop-by-hop and security-sensitive headers
if BLOCKED_HEADERS.contains(&k_lower.as_str()) { if BLOCKED_HEADERS.contains(&k_lower.as_str()) {
continue; continue;
} }
// Validate header name/value are valid HTTP
if let (Ok(name), Ok(value)) = ( if let (Ok(name), Ok(value)) = (
reqwest::header::HeaderName::from_bytes(k.as_bytes()), reqwest::header::HeaderName::from_bytes(k.as_bytes()),
reqwest::header::HeaderValue::from_str(v), reqwest::header::HeaderValue::from_str(v),
) { ) {
req = req.header(name, value); header_map.insert(name, value);
} }
} }
let mut req = client.request(method, &meta.url).headers(header_map);
let body_size = body.len(); let body_size = body.len();
if !body.is_empty() { if !body.is_empty() {
req = req.body(body); req = req.body(body);
@@ -258,39 +258,51 @@ async fn handle_stream_inner(
status, status,
headers: resp_headers, headers: resp_headers,
}; };
let meta_json = serde_json::to_vec(&resp_meta).unwrap_or_default(); let meta_json: Bytes = serde_json::to_vec(&resp_meta).unwrap_or_default().into();
let (meta_payload, meta_flags) = compress_payload(meta_json);
if !send_frame( if !send_frame(
frame_tx, frame_tx,
Frame::new(stream_id, MsgType::ResponseHeaders, 0, meta_json), Frame::new(
stream_id,
MsgType::ResponseHeaders,
meta_flags,
meta_payload,
),
) )
.await .await
{ {
return; return;
} }
// Stream response body // Stream response body — relay upstream bytes through the tunnel.
// Apply tunnel-level frame compression for chunks that benefit from it
// (e.g. uncompressed SSE text). Already-compressed data (gzip/br from
// upstream Content-Encoding) won't shrink further and will be sent as-is
// thanks to the size check in compress_payload().
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
while let Some(chunk_result) = stream.next().await { while let Some(chunk_result) = stream.next().await {
match chunk_result { match chunk_result {
Ok(chunk) => { Ok(chunk) => {
if chunk.len() <= MAX_CHUNK_SIZE { if chunk.len() <= MAX_CHUNK_SIZE {
let (payload, extra_flags) = compress_payload(chunk);
if !send_frame( if !send_frame(
frame_tx, frame_tx,
Frame::new(stream_id, MsgType::ResponseBody, 0, chunk), Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
) )
.await .await
{ {
return; return;
} }
} else { } else {
// Split oversized chunks // Split oversized chunks, compress each slice
let mut offset = 0; let mut offset = 0;
while offset < chunk.len() { while offset < chunk.len() {
let end = (offset + MAX_CHUNK_SIZE).min(chunk.len()); let end = (offset + MAX_CHUNK_SIZE).min(chunk.len());
let slice = chunk.slice(offset..end); let slice = chunk.slice(offset..end);
let (payload, extra_flags) = compress_payload(slice);
if !send_frame( if !send_frame(
frame_tx, frame_tx,
Frame::new(stream_id, MsgType::ResponseBody, 0, slice), Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
) )
.await .await
{ {
@@ -337,12 +349,3 @@ async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
) )
.await; .await;
} }
fn decompress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(data);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf)?;
Ok(Bytes::from(buf))
}

View File

@@ -27,7 +27,8 @@ dependencies = [
"pydantic>=2.12.5", "pydantic>=2.12.5",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"openai>=2.16.0", "openai>=2.16.0",
"httpx[socks]>=0.28.1", "httpx[socks,http2]>=0.28.1",
"brotli>=1.1.0",
"sqlalchemy>=2.0.46", "sqlalchemy>=2.0.46",
"alembic>=1.18.3", "alembic>=1.18.3",
"bcrypt>=5.0.0", "bcrypt>=5.0.0",

View File

@@ -73,7 +73,7 @@ class HTTPClientPool:
# 双重检查,避免重复创建 # 双重检查,避免重复创建
if cls._default_client is None: if cls._default_client is None:
cls._default_client = httpx.AsyncClient( cls._default_client = httpx.AsyncClient(
http2=False, # 暂时禁用HTTP/2以提高兼容性 http2=config.enable_http2,
verify=get_ssl_context(), # 使用 certifi 证书 verify=get_ssl_context(), # 使用 certifi 证书
timeout=httpx.Timeout( timeout=httpx.Timeout(
connect=config.http_connect_timeout, connect=config.http_connect_timeout,
@@ -106,7 +106,7 @@ class HTTPClientPool:
""" """
if cls._default_client is None: if cls._default_client is None:
cls._default_client = httpx.AsyncClient( cls._default_client = httpx.AsyncClient(
http2=False, # 暂时禁用HTTP/2以提高兼容性 http2=config.enable_http2,
verify=get_ssl_context(), # 使用 certifi 证书 verify=get_ssl_context(), # 使用 certifi 证书
timeout=httpx.Timeout( timeout=httpx.Timeout(
connect=config.http_connect_timeout, connect=config.http_connect_timeout,
@@ -143,7 +143,7 @@ class HTTPClientPool:
if name not in cls._clients: if name not in cls._clients:
# 合并默认配置和自定义配置 # 合并默认配置和自定义配置
default_config = { default_config = {
"http2": False, "http2": config.enable_http2,
"verify": get_ssl_context(), "verify": get_ssl_context(),
"timeout": httpx.Timeout( "timeout": httpx.Timeout(
connect=config.http_connect_timeout, connect=config.http_connect_timeout,
@@ -282,7 +282,7 @@ class HTTPClientPool:
# 创建新客户端(使用默认超时,请求时可覆盖) # 创建新客户端(使用默认超时,请求时可覆盖)
client_config: dict[str, Any] = { client_config: dict[str, Any] = {
"http2": False, "http2": config.enable_http2,
"verify": get_ssl_context_for_profile(tls_profile), "verify": get_ssl_context_for_profile(tls_profile),
"follow_redirects": True, "follow_redirects": True,
"limits": httpx.Limits( "limits": httpx.Limits(
@@ -377,7 +377,7 @@ class HTTPClientPool:
response = await client.get('https://example.com') response = await client.get('https://example.com')
""" """
default_config = { default_config = {
"http2": False, "http2": config.enable_http2,
"verify": get_ssl_context(), "verify": get_ssl_context(),
"timeout": httpx.Timeout( "timeout": httpx.Timeout(
connect=config.http_connect_timeout, connect=config.http_connect_timeout,
@@ -416,7 +416,7 @@ class HTTPClientPool:
配置好的 httpx.AsyncClient 实例(调用者需要负责关闭) 配置好的 httpx.AsyncClient 实例(调用者需要负责关闭)
""" """
client_config: dict[str, Any] = { client_config: dict[str, Any] = {
"http2": False, "http2": config.enable_http2,
"verify": get_ssl_context_for_profile(tls_profile), "verify": get_ssl_context_for_profile(tls_profile),
"follow_redirects": True, "follow_redirects": True,
} }

View File

@@ -177,6 +177,22 @@ class Config:
) )
self.http_keepalive_expiry = float(os.getenv("HTTP_KEEPALIVE_EXPIRY", "30.0")) self.http_keepalive_expiry = float(os.getenv("HTTP_KEEPALIVE_EXPIRY", "30.0"))
# 上游传输优化配置
# ENABLE_HTTP2: 是否对上游请求启用 HTTP/2HPACK 头部压缩 + 多路复用)
# - 三家上游Claude/OpenAI/Gemini均已确认支持 HTTP/2
# - 出现兼容性问题时可通过环境变量快速回退到 HTTP/1.1
self.enable_http2 = os.getenv("ENABLE_HTTP2", "true").lower() == "true"
# ENABLE_REQUEST_COMPRESSION: 是否对上游请求体启用 gzip 压缩
# - 仅对超过 REQUEST_COMPRESSION_MIN_SIZE 的请求体生效
# - 上游 Cloudflare/Google Front End 层会透明解压
self.enable_request_compression = (
os.getenv("ENABLE_REQUEST_COMPRESSION", "true").lower() == "true"
)
# REQUEST_COMPRESSION_MIN_SIZE: 触发请求体压缩的最小字节数
# - gzip 有固定头部开销,小请求压缩后可能反而变大
# - 默认 1024 字节1KB
self.request_compression_min_size = int(os.getenv("REQUEST_COMPRESSION_MIN_SIZE", "1024"))
# 流式处理配置 # 流式处理配置
# STREAM_PREFETCH_LINES: 预读行数,用于检测嵌套错误 # STREAM_PREFETCH_LINES: 预读行数,用于检测嵌套错误
# STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭 # STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭

View File

@@ -39,6 +39,7 @@ BROWSER_FINGERPRINT_HEADERS: dict[str, str] = {
"Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36" "Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36"
), ),
"Accept": "application/json", "Accept": "application/json",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN", "Accept-Language": "zh-CN",
"sec-ch-ua": '"Not=A?Brand";v="24", "Chromium";v="140"', "sec-ch-ua": '"Not=A?Brand";v="24", "Chromium";v="140"',
"sec-ch-ua-mobile": "?0", "sec-ch-ua-mobile": "?0",
@@ -73,7 +74,7 @@ UPSTREAM_DROP_HEADERS: frozenset[str] = frozenset(
"content-length", "content-length",
"transfer-encoding", "transfer-encoding",
"connection", "connection",
# 编码头 - 避免客户端请求 brotli/zstd 但 httpx 不支持 # 编码头 - 丢弃客户端值,由 BROWSER_FINGERPRINT_HEADERS 统一设置
"accept-encoding", "accept-encoding",
# 反向代理 / 网关注入的头部 - 属于本站基础设施,不应泄露给上游 # 反向代理 / 网关注入的头部 - 属于本站基础设施,不应泄露给上游
"x-real-ip", "x-real-ip",

View File

@@ -7,13 +7,16 @@
from __future__ import annotations from __future__ import annotations
import gzip
import hashlib import hashlib
import json
import time import time
from typing import Any from typing import Any
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
import httpx import httpx
from src.config import config
from src.core.exceptions import ProxyNodeUnavailableError from src.core.exceptions import ProxyNodeUnavailableError
from src.core.logger import logger from src.core.logger import logger
@@ -614,6 +617,30 @@ def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, An
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _maybe_compress_payload(
payload: Any,
headers: dict[str, str],
) -> tuple[bytes, dict[str, str]]:
"""
将 payload 序列化为 JSON bytes按配置决定是否 gzip 压缩。
NOTE: 使用紧凑分隔符 ``(",", ":")`` 序列化(无空格),相比 httpx ``json=``
参数的默认 ``json.dumps``(带空格分隔符)体积更小,所有上游 API 均兼容。
Returns:
(body_bytes, updated_headers)
"""
json_bytes = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
if config.enable_request_compression and len(json_bytes) >= config.request_compression_min_size:
compressed = gzip.compress(json_bytes, compresslevel=6)
if len(compressed) < len(json_bytes):
headers = {**headers, "Content-Encoding": "gzip"}
return compressed, headers
return json_bytes, headers
def build_post_kwargs( def build_post_kwargs(
_delegate_cfg: dict[str, Any] | None = None, _delegate_cfg: dict[str, Any] | None = None,
*, *,
@@ -631,10 +658,11 @@ def build_post_kwargs(
``_delegate_cfg`` 和 ``refresh_auth`` 已废弃tunnel 模式下认证由 transport 层处理), ``_delegate_cfg`` 和 ``refresh_auth`` 已废弃tunnel 模式下认证由 transport 层处理),
保留仅为兼容现有调用方签名。 保留仅为兼容现有调用方签名。
""" """
content, final_headers = _maybe_compress_payload(payload, headers)
return { return {
"url": url, "url": url,
"json": payload, "content": content,
"headers": headers, "headers": final_headers,
"timeout": httpx.Timeout(timeout), "timeout": httpx.Timeout(timeout),
} }
@@ -655,11 +683,12 @@ def build_stream_kwargs(
``_delegate_cfg`` 已废弃,保留仅为兼容现有调用方签名。 ``_delegate_cfg`` 已废弃,保留仅为兼容现有调用方签名。
""" """
content, final_headers = _maybe_compress_payload(payload, headers)
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"method": "POST", "method": "POST",
"url": url, "url": url,
"json": payload, "content": content,
"headers": headers, "headers": final_headers,
} }
if timeout is not None: if timeout is not None:
kwargs["timeout"] = httpx.Timeout(timeout) kwargs["timeout"] = httpx.Timeout(timeout)

View File

@@ -8,6 +8,7 @@ WebSocket 隧道管理器
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import gzip
import json import json
import time import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -21,6 +22,10 @@ from src.core.logger import logger
from .tunnel_protocol import Frame, FrameFlags, MsgType from .tunnel_protocol import Frame, FrameFlags, MsgType
# 隧道帧压缩的最小 payload 大小(字节)
# 小于此值的帧压缩收益不大,反而增加 CPU 开销
_TUNNEL_COMPRESS_MIN_SIZE = 512
class TunnelConnection: class TunnelConnection:
"""单条 tunnel 连接""" """单条 tunnel 连接"""
@@ -309,7 +314,7 @@ class TunnelManager:
stream_state = conn.create_stream(stream_id) stream_state = conn.create_stream(stream_id)
try: try:
# 发送 REQUEST_HEADERS # 发送 REQUEST_HEADERS(大元数据帧压缩)
meta = json.dumps( meta = json.dumps(
{ {
"method": method, "method": method,
@@ -318,13 +323,19 @@ class TunnelManager:
"timeout": int(timeout), "timeout": int(timeout),
} }
).encode() ).encode()
await conn.send_frame(Frame(stream_id, MsgType.REQUEST_HEADERS, 0, meta)) meta_payload, meta_flags = _compress_frame_payload(meta)
# 发送 REQUEST_BODY + END_STREAM
body_data = body or b""
await conn.send_frame( await conn.send_frame(
Frame(stream_id, MsgType.REQUEST_BODY, FrameFlags.END_STREAM, body_data) Frame(stream_id, MsgType.REQUEST_HEADERS, meta_flags, meta_payload)
) )
# 发送 REQUEST_BODY + END_STREAM大请求体帧压缩
body_data = body or b""
if body_data:
body_payload, body_flags = _compress_frame_payload(body_data)
else:
body_payload, body_flags = body_data, 0
body_flags |= FrameFlags.END_STREAM
await conn.send_frame(Frame(stream_id, MsgType.REQUEST_BODY, body_flags, body_payload))
except Exception: except Exception:
conn.remove_stream(stream_id) conn.remove_stream(stream_id)
raise raise
@@ -348,14 +359,16 @@ class TunnelManager:
if not stream: if not stream:
return return
try: try:
meta = json.loads(frame.payload) payload = _decompress_frame_payload(frame)
meta = json.loads(payload)
stream.set_response_headers(meta["status"], meta.get("headers", [])) stream.set_response_headers(meta["status"], meta.get("headers", []))
except Exception as e: except Exception as e:
stream.set_error(f"invalid response headers: {e}") stream.set_error(f"invalid response headers: {e}")
elif frame.msg_type == MsgType.RESPONSE_BODY: elif frame.msg_type == MsgType.RESPONSE_BODY:
if stream: if stream:
stream.push_body_chunk(frame.payload) payload = _decompress_frame_payload(frame)
stream.push_body_chunk(payload)
elif frame.msg_type == MsgType.STREAM_END: elif frame.msg_type == MsgType.STREAM_END:
if stream: if stream:
@@ -426,6 +439,32 @@ class TunnelManager:
logger.debug("heartbeat ACK send failed for node_id={}", conn.node_id) logger.debug("heartbeat ACK send failed for node_id={}", conn.node_id)
# ---------------------------------------------------------------------------
# 隧道帧压缩 / 解压
# ---------------------------------------------------------------------------
def _compress_frame_payload(data: bytes) -> tuple[bytes, int]:
"""按配置对帧 payload 进行 gzip 压缩。
Returns:
(payload, flags) — 若压缩则 flags 含 GZIP_COMPRESSED否则 flags=0。
"""
if len(data) >= _TUNNEL_COMPRESS_MIN_SIZE:
compressed = gzip.compress(data, compresslevel=6)
# 仅在压缩确实缩小时使用
if len(compressed) < len(data):
return compressed, FrameFlags.GZIP_COMPRESSED
return data, 0
def _decompress_frame_payload(frame: Frame) -> bytes:
"""如果帧设置了 GZIP_COMPRESSED 标志则解压,否则原样返回。"""
if frame.is_gzip:
return gzip.decompress(frame.payload)
return frame.payload
# 全局单例 # 全局单例
_tunnel_manager: TunnelManager | None = None _tunnel_manager: TunnelManager | None = None

78
uv.lock generated
View File

@@ -14,11 +14,12 @@ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "brotli" },
{ name = "certifi" }, { name = "certifi" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "httpx", extra = ["socks"] }, { name = "httpx", extra = ["http2", "socks"] },
{ name = "ldap3" }, { name = "ldap3" },
{ name = "loguru" }, { name = "loguru" },
{ name = "openai" }, { name = "openai" },
@@ -70,13 +71,14 @@ requires-dist = [
{ name = "apscheduler", specifier = ">=3.11.2" }, { name = "apscheduler", specifier = ">=3.11.2" },
{ name = "asyncpg", specifier = ">=0.31.0" }, { name = "asyncpg", specifier = ">=0.31.0" },
{ name = "bcrypt", specifier = ">=5.0.0" }, { name = "bcrypt", specifier = ">=5.0.0" },
{ name = "brotli", specifier = ">=1.1.0" },
{ name = "certifi", specifier = ">=2026.1.4" }, { name = "certifi", specifier = ">=2026.1.4" },
{ name = "cryptography", specifier = ">=46.0.4" }, { name = "cryptography", specifier = ">=46.0.4" },
{ name = "curl-cffi", marker = "extra == 'tls'", specifier = ">=0.7.0" }, { name = "curl-cffi", marker = "extra == 'tls'", specifier = ">=0.7.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.128.0" },
{ name = "gunicorn", specifier = ">=24.1.1" }, { name = "gunicorn", specifier = ">=24.1.1" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.25.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.25.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "httpx", extras = ["http2", "socks"], specifier = ">=0.28.1" },
{ name = "ldap3", specifier = ">=2.9.1" }, { name = "ldap3", specifier = ">=2.9.1" },
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "openai", specifier = ">=2.16.0" }, { name = "openai", specifier = ">=2.16.0" },
@@ -458,6 +460,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010 }, { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010 },
] ]
[[package]]
name = "brotli"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543 },
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288 },
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071 },
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913 },
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762 },
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494 },
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302 },
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913 },
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362 },
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115 },
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523 },
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289 },
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076 },
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880 },
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737 },
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440 },
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313 },
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945 },
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368 },
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116 },
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080 },
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453 },
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168 },
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098 },
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861 },
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594 },
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455 },
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164 },
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280 },
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639 },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.1.4" version = "2026.1.4"
@@ -1094,6 +1134,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
] ]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 },
]
[[package]] [[package]]
name = "hatch-vcs" name = "hatch-vcs"
version = "0.5.0" version = "0.5.0"
@@ -1122,6 +1175,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/a5/48cb7efb8b4718b1a4c0c331e3364a3a33f614ff0d6afd2b93ee883d3c47/hatchling-1.28.0-py3-none-any.whl", hash = "sha256:dc48722b68b3f4bbfa3ff618ca07cdea6750e7d03481289ffa8be1521d18a961", size = 76075 }, { url = "https://files.pythonhosted.org/packages/0d/a5/48cb7efb8b4718b1a4c0c331e3364a3a33f614ff0d6afd2b93ee883d3c47/hatchling-1.28.0-py3-none-any.whl", hash = "sha256:dc48722b68b3f4bbfa3ff618ca07cdea6750e7d03481289ffa8be1521d18a961", size = 76075 },
] ]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 },
]
[[package]] [[package]]
name = "httpcore" name = "httpcore"
version = "1.0.9" version = "1.0.9"
@@ -1180,10 +1242,22 @@ wheels = [
] ]
[package.optional-dependencies] [package.optional-dependencies]
http2 = [
{ name = "h2" },
]
socks = [ socks = [
{ name = "socksio" }, { name = "socksio" },
] ]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"