gmn2a / gemini_session.py
longxingdeng's picture
Persist Gemini cookies and stabilize rotation
204ad7a
Raw History Blame Contribute Delete
25.9 kB
"""Cookie 驱动的 Gemini 会话生命周期管理。
本模块把「基于 Cookie 初始化客户端」和「进程常驻期间持续自动轮换
``__Secure-1PSIDTS``」这两件事收敛到一个地方,供 ``main.py`` 直接使用。
为什么要单独写这一层,而不是直接 ``GeminiClient.init(auto_refresh=True)``:
1. **统一凭据来源。** ``SECURE_1PSID`` / ``SECURE_1PSIDTS`` 环境变量、上一次轮换
落盘的文本缓存、gemini-webapi 自己的 JSON 缓存,三者需要按「谁更新用谁」的
顺序收敛成一个候选序列,逐个尝试初始化。
2. **补上缺失的自愈能力。** gemini-webapi 的后台轮换任务在账号状态不是
``AVAILABLE`` 时会直接 ``break`` 并把 ``refresh_task`` 置空,活跃度看门狗同理;
之后再没有任何机制把它拉起来。对 7x24 常驻的 HF Space 来说,只要出现一次
「配额耗尽 / 临时不可用」,Cookie 就再也不会被轮换,直到进程重启——这正是
「进程一直在跑但 1PSIDTS 还是过期了」的根因。本模块用监督协程负责重新拉起。
3. **可观测。** 轮换是否发生、最后一次换值距今多久、后台任务是否还活着,都能通过
``status()`` 暴露给管理面板。
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional
from gemini_webapi import GeminiClient
from gemini_webapi.client import rotate_1psidts
logger = logging.getLogger(__name__)
PSID_COOKIE = "__Secure-1PSID"
PSIDTS_COOKIE = "__Secure-1PSIDTS"
_SAFE_PSID_PATTERN = re.compile(r"^[\w\-.]+$")
CredentialProvider = Callable[[], tuple[str, str]]
Validator = Callable[[GeminiClient, str], Awaitable[None]]
def is_safe_psid(psid: str) -> bool:
"""判断 1PSID 能否安全地拼进缓存文件名。"""
return bool(psid) and bool(_SAFE_PSID_PATTERN.match(psid))
def extract_cookie(cookies: Any, name: str) -> str:
"""从 curl_cffi / httpx 的 cookie 容器中读取指定 cookie 的值。
直接遍历 ``cookies.jar``,这样对 httpx 那种「必须带 domain 才能 get 到」的
容器同样有效。
"""
if cookies is None:
return ""
jar = getattr(cookies, "jar", None)
if jar is not None:
try:
for cookie in jar:
if getattr(cookie, "name", None) == name:
return getattr(cookie, "value", "") or ""
except TypeError:
pass
try:
return cookies.get(name) or ""
except Exception:
return ""
def mask_secret(value: str, keep: int = 4) -> str:
"""脱敏显示,用于日志和管理面板。"""
if not value:
return ""
if len(value) <= keep * 2:
return "*" * len(value)
return f"{value[:keep]}{'*' * 6}{value[-keep:]}"
class CookieStore:
"""统一管理 ``__Secure-1PSIDTS`` 的落盘缓存。
同时维护两份文件,读取时取「修改时间更新」的那一份:
* ``.cached_1psidts_{psid}.txt`` —— 纯文本,只存最新值。便于人工查看和复制,
也是排查凭据问题最直观的来源。
* ``.cached_cookies_{psid}.json`` —— gemini-webapi 自带格式,``get_access_token``
初始化时会优先读取它,所以必须让两边保持一致,否则会出现「缓存里是旧值、
但初始化却用了它」的诡异情况。
"""
def __init__(self, cache_dir: str | os.PathLike[str]):
self.cache_dir = Path(cache_dir)
def text_path(self, psid: str) -> Optional[Path]:
if not is_safe_psid(psid):
return None
return self.cache_dir / f".cached_1psidts_{psid}.txt"
def json_path(self, psid: str) -> Optional[Path]:
if not psid:
return None
return self.cache_dir / f".cached_cookies_{psid}.json"
def read_1psidts(self, psid: str) -> tuple[str, str]:
"""返回 ``(值, 来源)``,来源取值 ``text-cache`` / ``json-cache`` / ``""``。"""
candidates: list[tuple[float, str, str]] = []
text_path = self.text_path(psid)
if text_path is not None and text_path.is_file():
try:
value = text_path.read_text(encoding="utf-8").strip()
if value:
candidates.append((text_path.stat().st_mtime, value, "text-cache"))
except OSError as e:
logger.warning("Failed to read 1PSIDTS text cache %s: %s", text_path, e)
json_path = self.json_path(psid)
if json_path is not None and json_path.is_file():
try:
value = self._read_from_json(json_path)
if value:
candidates.append((json_path.stat().st_mtime, value, "json-cache"))
except (OSError, ValueError) as e:
logger.warning("Failed to read cookie cache %s: %s", json_path, e)
if not candidates:
return "", ""
candidates.sort(key=lambda item: item[0], reverse=True)
_, value, source = candidates[0]
return value, source
def write_1psidts(self, psid: str, value: str) -> bool:
"""把最新 1PSIDTS 写入文本缓存(原子替换)。返回是否真的写入了新值。"""
if not value:
return False
path = self.text_path(psid)
if path is None:
logger.warning("Skip caching 1PSIDTS: __Secure-1PSID is not a safe filename token.")
return False
try:
existing = path.read_text(encoding="utf-8").strip() if path.is_file() else ""
except OSError:
existing = ""
if existing == value:
return False
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.parent / f"{path.name}.{os.getpid()}.tmp"
tmp_path.write_text(value, encoding="utf-8")
os.replace(tmp_path, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
return True
except OSError as e:
logger.warning("Failed to persist 1PSIDTS to %s: %s", path, e)
return False
@staticmethod
def _read_from_json(path: Path) -> str:
payload = json.loads(path.read_text(encoding="utf-8"))
if isinstance(payload, dict):
payload = payload.get("cookies", [])
for cookie in payload or []:
if isinstance(cookie, dict) and cookie.get("name") == PSIDTS_COOKIE:
return cookie.get("value", "") or ""
return ""
class HfCookieBackup:
"""把 Cookie 缓存备份到 Hugging Face,用于跨重启续期(可选)。
两种目标可以单独或同时开启:
* ``dataset_repo``:上传/下载缓存文件到私有 Dataset。**不会**触发 Space 重启,
推荐用这种方式做持久化。
* ``space_repo``:回写 Space 的 Secret(默认 key 为 ``SECURE_1PSIDTS``)。HF 的
规则是「任何 Space 配置变更都会重启 Space」,所以这条路径默认关闭,并且带
最小间隔保护——它只在「进程已经要重启」这类场景下才有意义。
"""
def __init__(
self,
cache_dir: str | os.PathLike[str],
token: str = "",
dataset_repo: str = "",
space_repo: str = "",
secret_key: str = PSIDTS_COOKIE,
min_interval: float = 3600,
):
self.store = CookieStore(cache_dir)
self.token = token
self.dataset_repo = dataset_repo
self.space_repo = space_repo
self.secret_key = secret_key
self.min_interval = max(0.0, min_interval)
self._last_push_at = 0.0
self._api: Any = None
@property
def enabled(self) -> bool:
return bool(self.token and (self.dataset_repo or self.space_repo))
async def restore(self, psid: str) -> None:
"""本地没有缓存时,尝试从 Dataset 拉回上次备份。"""
if not (self.token and self.dataset_repo):
return
text_path = self.store.text_path(psid)
if text_path is not None and text_path.is_file():
return
await asyncio.to_thread(self._restore_blocking, psid)
async def push(self, psid: str, value: str, force: bool = False) -> dict:
"""备份当前 1PSIDTS,带最小间隔保护。"""
if not self.enabled:
return {"skipped": "backup not configured"}
now = time.time()
if not force and now - self._last_push_at < self.min_interval:
return {"skipped": "rate limited"}
self._last_push_at = now
return await asyncio.to_thread(self._push_blocking, psid, value)
def _restore_blocking(self, psid: str) -> None:
try:
from huggingface_hub import hf_hub_download
except ImportError:
logger.warning("huggingface_hub is not installed; cookie backup is disabled.")
return
for path in (self.store.json_path(psid), self.store.text_path(psid)):
if path is None or path.is_file():
continue
try:
downloaded = hf_hub_download(
repo_id=self.dataset_repo,
filename=path.name,
repo_type="dataset",
token=self.token,
local_dir=str(self.store.cache_dir),
)
logger.info("Restored cookie cache from HF dataset: %s", downloaded)
except Exception as e:
logger.debug("No remote cookie cache for %s: %s", path.name, e)
def _push_blocking(self, psid: str, value: str) -> dict:
try:
from huggingface_hub import HfApi
except ImportError:
return {"skipped": "huggingface_hub is not installed"}
result: dict = {}
api = self._api or HfApi(token=self.token)
self._api = api
if self.dataset_repo:
uploaded = []
for path in (self.store.text_path(psid), self.store.json_path(psid)):
if path is None or not path.is_file():
continue
try:
api.upload_file(
path_or_fileobj=str(path),
path_in_repo=path.name,
repo_id=self.dataset_repo,
repo_type="dataset",
)
uploaded.append(path.name)
except Exception as e:
logger.warning("Failed to upload %s to HF dataset: %s", path.name, e)
result["dataset_uploaded"] = uploaded
if self.space_repo:
try:
api.add_space_secret(
repo_id=self.space_repo,
key=self.secret_key,
value=value,
)
result["space_secret_updated"] = True
logger.warning(
"Wrote %s back to Space secret %s; note that HF restarts the Space on any config change.",
self.secret_key,
self.space_repo,
)
except Exception as e:
logger.warning("Failed to update Space secret: %s", e)
result["space_secret_updated"] = False
return result
class GeminiSessionManager:
"""维护「基于 Cookie 初始化」+「后台持续轮换 1PSIDTS」的会话生命周期。
与直接 ``GeminiClient.init(auto_refresh=True)`` 的区别在于自愈能力:监督协程会
周期性地检查会话健康度,发现后台轮换任务停止就重新拉起,发现会话失效就重建。
"""
def __init__(
self,
credential_provider: CredentialProvider,
cache_dir: str | os.PathLike[str],
validate: Optional[Validator] = None,
backup: Optional[HfCookieBackup] = None,
proxy: str = "",
timeout: float = 300,
watchdog_timeout: float = 120,
refresh_interval: float = 600,
health_interval: float = 120,
rearm_cooldown: float = 300,
init_retry_interval: float = 300,
verbose: bool = False,
):
self.credential_provider = credential_provider
self.validate = validate
self.backup = backup
self.proxy = proxy
self.timeout = max(30.0, float(timeout))
self.watchdog_timeout = max(30.0, float(watchdog_timeout))
self.refresh_interval = max(60.0, float(refresh_interval))
self.health_interval = max(30.0, float(health_interval))
self.rearm_cooldown = max(60.0, float(rearm_cooldown))
self.init_retry_interval = max(60.0, float(init_retry_interval))
self.verbose = verbose
self.client: Optional[GeminiClient] = None
self.lock = asyncio.Lock()
self.store = CookieStore(cache_dir)
self._supervisor_task: Optional[asyncio.Task] = None
self._rotation_task: Optional[asyncio.Task] = None
self._closing = False
self._next_init_at = 0.0
self._rearm_until = 0.0
self._source = ""
self._last_error = ""
self._last_1psidts = ""
self._last_psidts_change_at = 0.0
self._init_count = 0
self._recycle_count = 0
self._rearm_count = 0
self._rotation_count = 0
# ------------------------------------------------------------------ 生命周期
async def start(self) -> None:
"""启动后台监督协程(幂等)。"""
if self._supervisor_task is not None and not self._supervisor_task.done():
return
self._closing = False
self._supervisor_task = asyncio.create_task(self._supervisor(), name="gemini-session-supervisor")
logger.info(
"Gemini session supervisor started (health check every %ss, rotation interval %ss)",
int(self.health_interval),
int(self.refresh_interval),
)
async def stop(self) -> None:
"""停止监督协程并关闭客户端(关闭时会把最新 Cookie 落盘)。"""
self._closing = True
task, self._supervisor_task = self._supervisor_task, None
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
rotation_task, self._rotation_task = self._rotation_task, None
if rotation_task is not None and not rotation_task.done():
rotation_task.cancel()
try:
await rotation_task
except asyncio.CancelledError:
pass
async with self.lock:
await self._drop_client_locked("service shutdown")
# ------------------------------------------------------------------ 对外接口
async def get_client(self) -> GeminiClient:
"""返回一个可用的客户端;只在传输层失效时按需重建。
``gemini-webapi`` 的用户状态探针可能因为 Google 的辅助 RPC、出口限制或
短暂响应异常把 ``account_status`` 标为 ``UNAUTHENTICATED``,但主生成流仍然
可以工作。这里不能仅凭这个字段关闭正在服务请求的客户端;真正的认证失败由
生成请求的异常路径触发重建。
"""
client = self.client
if client is not None:
reason = self.session_transport_problem(client)
if not reason:
return client
logger.warning("Gemini client transport is unusable (%s); rebuilding on demand", reason)
async with self.lock:
if self.client is not None:
reason = self.session_transport_problem(self.client)
if reason:
await self._drop_client_locked(f"unusable transport: {reason}")
if self.client is None:
await self._initialize_locked()
if self.client is None:
raise RuntimeError(self._last_error or "Gemini client is unavailable")
return self.client
async def recover(self, reason: str = "manual recovery") -> GeminiClient:
"""强制丢弃当前会话并重建,供请求失败重试 / 管理面板使用。"""
async with self.lock:
await self._drop_client_locked(reason)
await self._initialize_locked()
if self.client is None:
raise RuntimeError(self._last_error or "Gemini client is unavailable")
return self.client
def status(self) -> dict:
"""运行时状态快照,用于日志和管理面板展示。"""
client = self.client
psid, env_psidts = self.credential_provider()
now = time.time()
cached_psidts, cached_source = self.store.read_1psidts(psid)
return {
"initialized": client is not None,
"supervisor_alive": bool(self._supervisor_task is not None and not self._supervisor_task.done()),
"credential_source": self._source,
"secure_1psid_masked": mask_secret(psid),
"env_1psidts_masked": mask_secret(env_psidts),
"live_1psidts_masked": mask_secret(self._last_1psidts),
"cached_1psidts_masked": mask_secret(cached_psidts),
"cached_1psidts_source": cached_source,
"rotation_count": self._rotation_count,
"last_1psidts_change_age_sec": int(now - self._last_psidts_change_at) if self._last_psidts_change_at else None,
"rotation_task_alive": bool(
client is not None and (
(client.refresh_task is not None and not client.refresh_task.done()) or
(self._rotation_task is not None and not self._rotation_task.done())
)
),
"managed_rotation_task_alive": bool(self._rotation_task is not None and not self._rotation_task.done()),
"auto_refresh": bool(getattr(client, "auto_refresh", False)),
"refresh_interval": int(self.refresh_interval),
"request_timeout": int(self.timeout),
"watchdog_timeout": int(self.watchdog_timeout),
"rearm_count": self._rearm_count,
"recycle_count": self._recycle_count,
"init_count": self._init_count,
"account_status": getattr(getattr(client, "account_status", None), "name", "unknown"),
"last_error": self._last_error,
"cache_dir": str(self.store.cache_dir),
"backup_enabled": bool(self.backup is not None and self.backup.enabled),
}
# ------------------------------------------------------------------ 内部实现
async def _initialize_locked(self) -> None:
"""按「缓存 -> 环境变量」的顺序逐个尝试初始化。调用方需持有 self.lock。"""
psid, env_psidts = self.credential_provider()
if not psid:
self._last_error = "SECURE_1PSID is not configured"
logger.error("%s", self._last_error)
return
cached_psidts, cached_source = self.store.read_1psidts(psid)
if self.backup is not None and not cached_psidts:
await self.backup.restore(psid)
cached_psidts, cached_source = self.store.read_1psidts(psid)
attempts: list[tuple[str, str]] = []
seen: set[str] = set()
for source, psidts in (
(f"cache ({cached_source})" if cached_source else "cache", cached_psidts),
("environment", env_psidts),
):
if not psidts or psidts in seen:
continue
seen.add(psidts)
attempts.append((source, psidts))
if not attempts:
self._last_error = "SECURE_1PSIDTS is missing and no rotated value has been cached yet"
logger.error("%s", self._last_error)
return
self._init_count += 1
last_error: Optional[BaseException] = None
for source, psidts in attempts:
kwargs = {"proxy": self.proxy} if self.proxy else {}
candidate = GeminiClient(psid, psidts, **kwargs)
try:
logger.info("Initializing Gemini client using %s credentials", source)
await candidate.init(
timeout=self.timeout,
auto_refresh=True,
refresh_interval=self.refresh_interval,
auto_close=False,
watchdog_timeout=self.watchdog_timeout,
verbose=self.verbose,
)
if self.validate is not None:
await self.validate(candidate, source)
except Exception as e:
last_error = e
logger.warning("Gemini session setup failed using %s 1PSIDTS: %s", source, e)
try:
await candidate.close()
except Exception:
pass
continue
self.client = candidate
if self._rotation_task is not None and not self._rotation_task.done():
self._rotation_task.cancel()
self._rotation_task = asyncio.create_task(self._rotation_supervisor(candidate), name="gemini-cookie-rotation-supervisor")
self._source = source
self._last_error = ""
live_psidts = extract_cookie(candidate.cookies, PSIDTS_COOKIE) or psidts
self._last_1psidts = live_psidts
self._last_psidts_change_at = time.time()
if self.store.write_1psidts(psid, live_psidts):
logger.info("Seeded __Secure-1PSIDTS cache from %s credentials", source)
logger.info(
"Gemini client ready (source=%s, 1psidts=%s, auto_refresh=%s, interval=%ss)",
source,
mask_secret(live_psidts),
candidate.auto_refresh,
int(self.refresh_interval),
)
return
self._last_error = str(last_error) if last_error is not None else "initialization failed"
logger.error("Failed to initialize Gemini client: %s", self._last_error)
async def _drop_client_locked(self, reason: str) -> None:
"""关闭并丢弃当前客户端。调用方需持有 self.lock。"""
client, self.client = self.client, None
if client is None:
return
if self._rotation_task is not None and not self._rotation_task.done():
self._rotation_task.cancel()
self._rotation_task = None
self._recycle_count += 1
logger.warning("Dropping Gemini client (%s)", reason)
# 关闭前先把当前 1PSIDTS 落盘;close() 内部也会保存 gemini-webapi 的 JSON 缓存
psid, _ = self.credential_provider()
live_psidts = extract_cookie(getattr(client, "cookies", None), PSIDTS_COOKIE)
if live_psidts:
self._last_1psidts = live_psidts
self.store.write_1psidts(psid, live_psidts)
try:
await client.close()
except Exception as e:
logger.warning("Failed to close Gemini client: %s", e)
@staticmethod
def session_transport_problem(client: GeminiClient) -> str:
"""判断客户端传输层是否已关闭,不采信易误报的账号状态探针。"""
if client is None:
return "client is missing"
if not getattr(client, "_running", False):
return "client is not running"
if getattr(client, "client", None) is None:
return "underlying HTTP session is closed"
return ""
@classmethod
def session_problem(cls, client: GeminiClient) -> str:
"""返回完整诊断;认证状态供异常恢复判断,不再触发后台主动关闭。"""
reason = cls.session_transport_problem(client)
if reason:
return reason
account_status = getattr(client, "account_status", None)
if getattr(account_status, "name", "") == "UNAUTHENTICATED":
return "account status is UNAUTHENTICATED"
return ""
async def _rotation_supervisor(self, client: GeminiClient) -> None:
"""轮换 Cookie,不依赖 gemini-webapi 的辅助账号状态探针。"""
while not self._closing and self.client is client and getattr(client, "_running", False):
try:
await asyncio.sleep(max(60.0, self.refresh_interval))
except asyncio.CancelledError:
raise
if self._closing or self.client is not client or not getattr(client, "_running", False):
return
try:
async with client._lock:
rotated = await rotate_1psidts(client.client, getattr(client, "verbose", False))
if rotated:
self._observe_rotation(client)
logger.info("Managed Cookie rotation completed despite account-status probe=%s", getattr(getattr(client, "account_status", None), "name", "unknown"))
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("Managed Cookie rotation failed: %s", e)
async def _supervisor(self) -> None:
"""常驻监督循环:保活 + 自愈。"""
logger.info("Gemini session supervisor loop running")
while not self._closing:
try:
await asyncio.sleep(self.health_interval)
except asyncio.CancelledError:
raise
if self._closing:
break
try:
await self._supervise_once()
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("Supervisor tick failed: %s", e, exc_info=True)
async def _supervise_once(self) -> None:
client = self.client
if client is None:
# 还没有可用会话:按退避策略尝试初始化,避免密集重试
now = time.time()
if now >= self._next_init_at:
try:
await self.get_client()
except Exception as e:
self._last_error = str(e)
self._next_init_at = now + self.init_retry_interval
logger.warning("Supervisor could not (re)initialize Gemini client: %s", e)
return
reason = self.session_transport_problem(client)
if reason:
logger.warning("Gemini client transport became unusable (%s); rebuilding", reason)
async with self.lock:
await self._drop_client_locked(reason)
try:
await self.get_client()
except Exception as e:
self._last_error = str(e)
self._next_init_at = time.time() + self.init_retry_interval
logger.warning("Rebuild after session loss did not succeed: %s", e)
return
self._observe_rotation(client)
await self._rearm_background_tasks(client)
def _observe_rotation(self, client: GeminiClient) -> None:
"""读取活跃会话里的 ``__Secure-1PSIDTS``,发现变化就落盘并触发备份。
gemini-webapi 的 ``rotate_1psidts`` 会把轮换结果直接写回 ``client.cookies``,
所以这里读到的就是当前真实生效的值。
"""
live_psidts = extract_cookie(getattr(client, "cookies", None), PSIDTS_COOKIE)
if not live_psidts or live_psidts == self._last_1psidts:
return
previous = self._last_1psidts
self._last_1psidts = live_psidts
self._last_psidts_change_at = time.time()
self._rotation_count += 1
logger.info(
"__Secure-1PSIDTS rotated in the background (%s -> %s)",
mask_secret(previous) or "(none)",
mask_secret(live_psidts),
)
psid, _ = self.credential_provider()
if self.store.write_1psidts(psid, live_psidts):
logger.info("Persisted the rotated __Secure-1PSIDTS to disk")
if self.backup is not None and self.backup.enabled:
asyncio.create_task(self._push_backup(psid, live_psidts))
async def _rearm_background_tasks(self, client: GeminiClient) -> None:
"""把 gemini-webapi 的后台轮换任务和活跃度看门狗重新拉起来。
``start_auto_refresh`` 在账号状态不是 ``AVAILABLE`` 时会直接退出并把
``refresh_task`` 置空,此后没有任何机制会再拉起它。``rearm_cooldown`` 用来
避免状态持续异常时的忙循环——每轮最多重启一次。
"""
if not getattr(client, "auto_refresh", False) or not getattr(client, "_running", False):
return
# gemini-webapi 2.1.x only starts these tasks when its auxiliary account
# status RPC says AVAILABLE. That RPC can be false-negative while the main
# generation endpoint still works, so the supervisor owns the re-arm decision.
now = time.time()
if now < self._rearm_until:
return
if client.refresh_task is None or client.refresh_task.done():
client.refresh_task = asyncio.create_task(client.start_auto_refresh())
self._rearm_until = now + self.rearm_cooldown
self._rearm_count += 1
logger.info(
"Re-armed the __Secure-1PSIDTS rotation task (account status: %s)",
getattr(getattr(client, "account_status", None), "name", "unknown"),
)
return
if client.activity_task is None or client.activity_task.done():
client.activity_task = asyncio.create_task(client.start_activity_watchdog())
self._rearm_until = now + self.rearm_cooldown
self._rearm_count += 1
logger.info("Re-armed the Gemini activity watchdog")
async def _push_backup(self, psid: str, value: str) -> None:
if self.backup is None:
return
try:
result = await self.backup.push(psid, value)
if result and not result.get("skipped"):
logger.info("Cookie backup result: %s", result)
except Exception as e:
logger.warning("Cookie backup failed: %s", e)