feat(fingerprint): 引入 per-key 请求指纹系统,替代全局 TLS 指纹开关

为每个 ProviderAPIKey 生成并持久化独立的请求指纹配置,涵盖 TLS impersonate
profile、浏览器 UA、Stainless SDK 头部、Node/Chrome/Electron 版本等维度。
指纹基于 key ID 确定性生成,支持手动编辑和批量重新生成。

- 新增 fingerprint 模块:生成、加载、校验、懒持久化
- 数据库迁移:provider_api_keys 新增 fingerprint JSON 列
- 请求链路注入:handler 基类设置上下文指纹,request_builder 和 envelope 消费
- HTTP Client 支持动态 impersonate profile 选择
- Antigravity 适配器使用指纹覆盖 UA/session/Node 版本
- 前端移除手动 TLS 指纹开关,新增批量 regenerate_fingerprint 操作
- 号池管理 UI 优化:token 缩写格式、blocked 行样式、时间显示改为日期格式
This commit is contained in:
fawney19
2026-03-05 02:07:34 +08:00
parent 1d04c41ae7
commit 32ccf61baa
24 changed files with 782 additions and 84 deletions

View File

@@ -14,6 +14,8 @@ import uuid
# ============== API 端点 ==============
# 唯一定义在 core 层,此处 re-export 保持向后兼容
from src.core.provider_templates.fixed_providers import ANTIGRAVITY_PROD_URL as PROD_BASE_URL
from src.services.provider.fingerprint import resolve_platform_token
from src.services.provider.request_context import get_current_fingerprint
DAILY_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"
SANDBOX_BASE_URL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
@@ -39,11 +41,27 @@ def _detect_platform_info() -> str:
_PLATFORM_INFO = _detect_platform_info()
def _build_antigravity_http_user_agent(
*,
platform_token: str,
version: str,
chrome_version: str,
electron_version: str,
) -> str:
return (
f"Mozilla/5.0 ({platform_token}) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Antigravity/{version} Chrome/{chrome_version} "
f"Electron/{electron_version} Safari/537.36"
)
# HTTP Header User-Agent对齐 AM constants.rs: 完整 Electron 浏览器格式)
HTTP_USER_AGENT = (
f"Mozilla/5.0 ({_PLATFORM_INFO}) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Antigravity/{_FALLBACK_VERSION} Chrome/{_FALLBACK_CHROME} "
f"Electron/{_FALLBACK_ELECTRON} Safari/537.36"
HTTP_USER_AGENT = _build_antigravity_http_user_agent(
platform_token=_PLATFORM_INFO,
version=_FALLBACK_VERSION,
chrome_version=_FALLBACK_CHROME,
electron_version=_FALLBACK_ELECTRON,
)
# V1InternalRequest.userAgent 字段(固定值)
@@ -57,10 +75,11 @@ _ua_version: str = _FALLBACK_VERSION
def get_http_user_agent() -> str:
"""返回当前 HTTP User-Agent 字符串(对齐 AM Electron UA 格式)。"""
with _ua_lock:
return (
f"Mozilla/5.0 ({_PLATFORM_INFO}) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Antigravity/{_ua_version} Chrome/{_FALLBACK_CHROME} "
f"Electron/{_FALLBACK_ELECTRON} Safari/537.36"
return _build_antigravity_http_user_agent(
platform_token=_PLATFORM_INFO,
version=_ua_version,
chrome_version=_FALLBACK_CHROME,
electron_version=_FALLBACK_ELECTRON,
)
@@ -72,10 +91,11 @@ def update_user_agent_version(version: str) -> None:
return
with _ua_lock:
_ua_version = version
HTTP_USER_AGENT = (
f"Mozilla/5.0 ({_PLATFORM_INFO}) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Antigravity/{_ua_version} Chrome/{_FALLBACK_CHROME} "
f"Electron/{_FALLBACK_ELECTRON} Safari/537.36"
HTTP_USER_AGENT = _build_antigravity_http_user_agent(
platform_token=_PLATFORM_INFO,
version=_ua_version,
chrome_version=_FALLBACK_CHROME,
electron_version=_FALLBACK_ELECTRON,
)
@@ -94,16 +114,43 @@ URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
_SESSION_ID = uuid.uuid4().hex # 每次进程启动生成一个固定 session ID
def _normalize_node_version(raw: str | None) -> str:
text = str(raw or "").strip()
if text.startswith(("v", "V")):
text = text[1:]
return text or "18.18.2"
def get_v1internal_extra_headers() -> dict[str, str]:
"""构建 v1internal 请求需要的额外 header对齐 AM upstream/client.rs"""
fp = get_current_fingerprint()
with _ua_lock:
version = _ua_version
user_agent = get_http_user_agent()
session_id = _SESSION_ID
node_version = "18.18.2"
if fp:
user_agent = _build_antigravity_http_user_agent(
platform_token=resolve_platform_token(
platform_info=fp.platform_info,
stainless_os=fp.stainless_os,
stainless_arch=fp.stainless_arch,
),
version=version,
chrome_version=fp.chrome_version or _FALLBACK_CHROME,
electron_version=fp.electron_version or _FALLBACK_ELECTRON,
)
session_id = fp.vscode_session_id or _SESSION_ID
node_version = _normalize_node_version(fp.node_version)
return {
"User-Agent": get_http_user_agent(),
"User-Agent": user_agent,
"x-client-name": "antigravity",
"x-client-version": version,
"x-vscode-sessionid": _SESSION_ID,
"x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x",
"x-vscode-sessionid": session_id,
"x-goog-api-client": f"gl-node/{node_version} fire/0.8.6 grpc/1.10.x",
}

View File

@@ -26,6 +26,7 @@ from src.services.provider.adapters.claude_code.context import (
get_claude_code_request_context,
set_claude_code_request_context,
)
from src.services.provider.request_context import get_current_fingerprint
_SESSION_MARKER = "_session_"
_DUMMY_THINKING_SIGNATURE = "skip_thought_signature_validator"
@@ -488,6 +489,7 @@ class ClaudeCodeEnvelope:
def extra_headers(self) -> dict[str, str] | None:
ctx = get_claude_code_request_context()
is_stream = bool(ctx.is_stream) if ctx else False
fp = get_current_fingerprint()
headers = dict(CLAUDE_CODE_DEFAULT_HEADERS)
headers["Accept"] = DEFAULT_ACCEPT
@@ -496,9 +498,17 @@ class ClaudeCodeEnvelope:
if is_stream:
headers["x-stainless-helper-method"] = STREAM_HELPER_METHOD
ua = str(getattr(config, "internal_user_agent_claude_cli", "") or "").strip()
if ua:
headers["User-Agent"] = ua
if fp:
headers["X-Stainless-Package-Version"] = fp.stainless_package_version
headers["X-Stainless-OS"] = fp.stainless_os
headers["X-Stainless-Arch"] = fp.stainless_arch
headers["X-Stainless-Runtime-Version"] = fp.stainless_runtime_version
headers["X-Stainless-Timeout"] = fp.stainless_timeout
headers["User-Agent"] = fp.user_agent
else:
ua = str(getattr(config, "internal_user_agent_claude_cli", "") or "").strip()
if ua:
headers["User-Agent"] = ua
return headers
@@ -586,6 +596,9 @@ class ClaudeCodeEnvelope:
is_stream=is_stream,
provider_id=provider_id,
)
fp = get_current_fingerprint()
if _ctx.enable_tls_fingerprint and fp:
return fp.impersonate
return tls_profile
async def post_wrap_request(self, request_body: dict[str, Any]) -> None:

View File

@@ -0,0 +1,358 @@
"""Per-key request fingerprint generation and lazy persistence helpers."""
from __future__ import annotations
import asyncio
import hashlib
import random
import secrets
import threading
import uuid
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from typing import Any
from src.core.logger import logger
# curl_cffi impersonate profile pool (Chrome family)
CHROME_IMPERSONATE_PROFILES: tuple[str, ...] = (
"chrome110",
"chrome116",
"chrome119",
"chrome120",
"chrome123",
"chrome124",
"chrome131",
"chrome133",
)
KNOWN_IMPERSONATE_PROFILES: frozenset[str] = frozenset(CHROME_IMPERSONATE_PROFILES)
_CHROME_VERSION_BY_PROFILE: dict[str, str] = {
"chrome110": "110.0.5481.177",
"chrome116": "116.0.5845.188",
"chrome119": "119.0.6045.214",
"chrome120": "120.0.6099.216",
"chrome123": "123.0.6312.122",
"chrome124": "124.0.6367.243",
"chrome131": "131.0.6778.265",
"chrome133": "133.0.6943.142",
}
_PLATFORM_VARIANTS: tuple[tuple[str, str, str, str], ...] = (
("Linux", "x64", "X11; Linux x86_64", "Linux x86_64"),
("Linux", "arm64", "X11; Linux arm64", "Linux arm64"),
("Windows", "x64", "Windows NT 10.0; Win64; x64", "Windows x64"),
("MacOS", "x64", "Macintosh; Intel Mac OS X 10_15_7", "Darwin x64"),
("MacOS", "arm64", "Macintosh; ARM Mac OS X 14_0_0", "Darwin arm64"),
)
_STAINLESS_PACKAGE_VERSIONS: tuple[str, ...] = ("0.68.0", "0.69.0", "0.70.0", "0.71.0")
_NODE_VERSIONS: tuple[str, ...] = ("v20.18.1", "v22.12.0", "v22.14.0", "v24.13.0")
_ELECTRON_VERSIONS: tuple[str, ...] = ("35.5.1", "36.7.1", "37.3.0", "38.7.0", "39.2.3")
_STAINLESS_TIMEOUTS: tuple[str, ...] = ("600", "900")
_PENDING_LAZY_PERSIST: set[str] = set()
_PENDING_LAZY_PERSIST_LOCK = threading.Lock()
_PENDING_LAZY_PERSIST_MAX = 2000
@dataclass(frozen=True, slots=True)
class FingerprintProfile:
# TLS layer
impersonate: str
# Claude Code / Claude Chat feature dimensions
stainless_package_version: str
stainless_os: str
stainless_arch: str
stainless_runtime_version: str
stainless_timeout: str
# Generic dimensions
user_agent: str
node_version: str
chrome_version: str
electron_version: str
vscode_session_id: str
platform_info: str
def _build_rng(seed: str | None) -> random.Random:
if seed is None:
return random.Random(secrets.randbits(64))
digest = hashlib.sha256(seed.encode("utf-8")).digest()
return random.Random(int.from_bytes(digest[:8], "big", signed=False))
def _normalize_impersonate(raw: Any, fallback: str) -> str:
value = str(raw or "").strip().lower()
return value if value in KNOWN_IMPERSONATE_PROFILES else fallback
def _build_user_agent(
platform_token: str,
chrome_version: str,
electron_version: str,
) -> str:
return (
f"Mozilla/5.0 ({platform_token}) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{chrome_version} Electron/{electron_version} Safari/537.36"
)
def resolve_platform_token(
fp: dict[str, str] | None = None,
*,
platform_info: str | None = None,
stainless_os: str | None = None,
stainless_arch: str | None = None,
) -> str:
"""Resolve a platform token string from fingerprint dict or explicit kwargs."""
if fp is not None:
os_name = str(fp.get("stainless_os") or "").lower()
arch = str(fp.get("stainless_arch") or "").lower()
info = str(fp.get("platform_info") or "").lower()
else:
os_name = str(stainless_os or "").lower()
arch = str(stainless_arch or "").lower()
info = str(platform_info or "").lower()
if os_name.startswith("win") or "windows" in info:
return "Windows NT 10.0; Win64; x64"
if os_name in {"darwin", "mac", "macos"} or "darwin" in info:
return (
"Macintosh; ARM Mac OS X 14_0_0"
if arch in {"arm64", "aarch64"}
else "Macintosh; Intel Mac OS X 10_15_7"
)
return "X11; Linux arm64" if arch in {"arm64", "aarch64"} else "X11; Linux x86_64"
def _normalize_text(value: Any, fallback: str) -> str:
text = str(value or "").strip()
return text or fallback
def _sanitize_fingerprint_dict(raw: dict[str, Any], key_id: str) -> dict[str, str]:
generated = generate_fingerprint(seed=key_id or None)
fp: dict[str, str] = {k: str(v) for k, v in generated.items()}
for key, value in raw.items():
if isinstance(value, str) and value.strip():
fp[key] = value.strip()
fp["impersonate"] = _normalize_impersonate(fp.get("impersonate"), generated["impersonate"])
fp["chrome_version"] = _normalize_text(
fp.get("chrome_version"),
_CHROME_VERSION_BY_PROFILE.get(fp["impersonate"], generated["chrome_version"]),
)
fp["node_version"] = _normalize_text(fp.get("node_version"), generated["node_version"])
fp["electron_version"] = _normalize_text(
fp.get("electron_version"),
generated["electron_version"],
)
fp["stainless_package_version"] = _normalize_text(
fp.get("stainless_package_version"),
generated["stainless_package_version"],
)
fp["stainless_os"] = _normalize_text(fp.get("stainless_os"), generated["stainless_os"])
fp["stainless_arch"] = _normalize_text(fp.get("stainless_arch"), generated["stainless_arch"])
fp["stainless_runtime_version"] = _normalize_text(
fp.get("stainless_runtime_version"),
generated["stainless_runtime_version"],
)
fp["stainless_timeout"] = _normalize_text(
fp.get("stainless_timeout"),
generated["stainless_timeout"],
)
fp["platform_info"] = _normalize_text(fp.get("platform_info"), generated["platform_info"])
fp["vscode_session_id"] = _normalize_text(
fp.get("vscode_session_id"),
generated["vscode_session_id"],
)
if not str(fp.get("user_agent") or "").strip():
fp["user_agent"] = _build_user_agent(
resolve_platform_token(fp),
fp["chrome_version"],
fp["electron_version"],
)
return fp
def generate_fingerprint(seed: str | None = None) -> dict[str, str]:
"""Generate a serializable fingerprint profile dict."""
rng = _build_rng(seed)
impersonate = rng.choice(CHROME_IMPERSONATE_PROFILES)
chrome_version = _CHROME_VERSION_BY_PROFILE.get(impersonate, "120.0.6099.216")
node_version = rng.choice(_NODE_VERSIONS)
electron_version = rng.choice(_ELECTRON_VERSIONS)
stainless_os, stainless_arch, platform_token, platform_info = rng.choice(_PLATFORM_VARIANTS)
if seed is None:
vscode_session_id = uuid.uuid4().hex
else:
vscode_session_id = uuid.uuid5(uuid.NAMESPACE_URL, f"aether:fingerprint:{seed}").hex
return {
"impersonate": impersonate,
"stainless_package_version": rng.choice(_STAINLESS_PACKAGE_VERSIONS),
"stainless_os": stainless_os,
"stainless_arch": stainless_arch,
"stainless_runtime_version": node_version,
"stainless_timeout": rng.choice(_STAINLESS_TIMEOUTS),
"node_version": node_version,
"chrome_version": chrome_version,
"electron_version": electron_version,
"vscode_session_id": vscode_session_id,
"platform_info": platform_info,
"user_agent": _build_user_agent(platform_token, chrome_version, electron_version),
}
def _dict_to_profile(d: dict[str, str]) -> FingerprintProfile:
return FingerprintProfile(
impersonate=d["impersonate"],
stainless_package_version=d["stainless_package_version"],
stainless_os=d["stainless_os"],
stainless_arch=d["stainless_arch"],
stainless_runtime_version=d["stainless_runtime_version"],
stainless_timeout=d["stainless_timeout"],
user_agent=d["user_agent"],
node_version=d["node_version"],
chrome_version=d["chrome_version"],
electron_version=d["electron_version"],
vscode_session_id=d["vscode_session_id"],
platform_info=d["platform_info"],
)
def load_fingerprint(raw: dict[str, Any] | None, key_id: str) -> FingerprintProfile:
"""Load fingerprint from DB JSON with deterministic fallback by key_id."""
if raw and isinstance(raw, dict):
normalized = _sanitize_fingerprint_dict(raw, key_id)
else:
normalized = generate_fingerprint(seed=key_id or None)
return _dict_to_profile(normalized)
def serialize_fingerprint(fp: FingerprintProfile) -> dict[str, str]:
return {k: str(v) for k, v in asdict(fp).items()}
def normalize_fingerprint(raw: dict[str, Any], key_id: str) -> dict[str, str]:
return serialize_fingerprint(load_fingerprint(raw, key_id))
def _persist_fingerprint_if_missing_sync(key_id: str, fp: dict[str, str]) -> None:
from src.database import create_session
from src.models.database import ProviderAPIKey
db = create_session()
try:
updated = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.id == key_id,
ProviderAPIKey.fingerprint.is_(None),
)
.update(
{
ProviderAPIKey.fingerprint: fp,
ProviderAPIKey.updated_at: datetime.now(timezone.utc),
},
synchronize_session=False,
)
)
if updated > 0:
db.commit()
except Exception as exc:
db.rollback()
logger.debug("lazy fingerprint persist failed for key {}: {}", key_id[:8], str(exc))
finally:
db.close()
def _mark_pending_persist(key_id: str) -> bool:
with _PENDING_LAZY_PERSIST_LOCK:
if key_id in _PENDING_LAZY_PERSIST:
return False
if len(_PENDING_LAZY_PERSIST) >= _PENDING_LAZY_PERSIST_MAX:
return False
_PENDING_LAZY_PERSIST.add(key_id)
return True
def _clear_pending_persist(key_id: str) -> None:
with _PENDING_LAZY_PERSIST_LOCK:
_PENDING_LAZY_PERSIST.discard(key_id)
def schedule_lazy_fingerprint_persist(key_id: str, fp: dict[str, str]) -> None:
"""Persist generated fingerprint in background when key.fingerprint is missing."""
key_id = str(key_id or "").strip()
if not key_id:
return
if not _mark_pending_persist(key_id):
return
payload = dict(fp)
async def _persist_async() -> None:
try:
await asyncio.to_thread(_persist_fingerprint_if_missing_sync, key_id, payload)
finally:
_clear_pending_persist(key_id)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
try:
_persist_fingerprint_if_missing_sync(key_id, payload)
finally:
_clear_pending_persist(key_id)
return
task = loop.create_task(_persist_async())
def _on_done(done_task: asyncio.Task[None]) -> None:
try:
done_task.result()
except Exception as exc:
logger.debug("lazy fingerprint background task failed: {}", str(exc))
task.add_done_callback(_on_done)
def ensure_key_fingerprint(
key: Any,
*,
persist_if_missing: bool = False,
) -> FingerprintProfile:
"""Get a key fingerprint, generating deterministic fallback if missing."""
key_id = str(getattr(key, "id", "") or "").strip()
raw = getattr(key, "fingerprint", None)
if isinstance(raw, dict) and raw:
return load_fingerprint(raw, key_id)
generated = generate_fingerprint(seed=key_id or None)
if persist_if_missing and key_id:
schedule_lazy_fingerprint_persist(key_id, generated)
return _dict_to_profile(generated)
__all__ = [
"CHROME_IMPERSONATE_PROFILES",
"FingerprintProfile",
"KNOWN_IMPERSONATE_PROFILES",
"ensure_key_fingerprint",
"generate_fingerprint",
"load_fingerprint",
"normalize_fingerprint",
"resolve_platform_token",
"schedule_lazy_fingerprint_persist",
"serialize_fingerprint",
]

View File

@@ -10,11 +10,19 @@ per request.
from __future__ import annotations
import contextvars
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.services.provider.fingerprint import FingerprintProfile
_selected_base_url: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"provider_selected_base_url",
default=None,
)
_current_fingerprint: contextvars.ContextVar[FingerprintProfile | None] = contextvars.ContextVar(
"provider_current_fingerprint",
default=None,
)
def set_selected_base_url(url: str | None) -> None:
@@ -25,4 +33,17 @@ def get_selected_base_url() -> str | None:
return _selected_base_url.get()
__all__ = ["get_selected_base_url", "set_selected_base_url"]
def set_current_fingerprint(fp: FingerprintProfile | None) -> None:
_current_fingerprint.set(fp)
def get_current_fingerprint() -> FingerprintProfile | None:
return _current_fingerprint.get()
__all__ = [
"get_current_fingerprint",
"get_selected_base_url",
"set_current_fingerprint",
"set_selected_base_url",
]

View File

@@ -23,6 +23,7 @@ from src.models.endpoint_models import (
EndpointAPIKeyResponse,
EndpointAPIKeyUpdate,
)
from src.services.provider.fingerprint import generate_fingerprint, normalize_fingerprint
from src.services.provider_keys.auth_type import normalize_auth_type
from src.services.provider_keys.duplicate_check import check_duplicate_key
from src.services.provider_keys.key_side_effects import (
@@ -282,6 +283,12 @@ def _prepare_update_key_payload(
else:
update_data["proxy"] = key_data.proxy.model_dump(exclude_none=True)
if "fingerprint" in key_data.model_fields_set:
if key_data.fingerprint is None:
update_data["fingerprint"] = None
else:
update_data["fingerprint"] = normalize_fingerprint(key_data.fingerprint, key_id)
return _UpdateKeyPreparation(
update_data=update_data,
auto_fetch_enabled_before=auto_fetch_enabled_before,
@@ -335,8 +342,10 @@ def _prepare_create_key_payload(
if key_data.auth_config:
encrypted_auth_config = crypto_service.encrypt(json.dumps(key_data.auth_config))
new_key_id = str(uuid.uuid4())
new_key = ProviderAPIKey(
id=str(uuid.uuid4()),
id=new_key_id,
provider_id=provider_id,
api_formats=key_data.api_formats,
auth_type=auth_type,
@@ -359,6 +368,7 @@ def _prepare_create_key_payload(
model_exclude_patterns=(
key_data.model_exclude_patterns if key_data.model_exclude_patterns else None
),
fingerprint=generate_fingerprint(seed=new_key_id),
request_count=0,
success_count=0,
error_count=0,