feat: 动态 block 索引分配、OAuth 自动启停联动与 Antigravity 签名注入增强

- Gemini/OpenAI normalizer 改为按实际出现顺序延迟分配 thinking/text block 索引
- OAuth 失效标记时自动停用 Key,清除/刷新成功时自动启用
- Antigravity thought signature 注入支持 tool-specific 签名优先,再回退 session 级签名
- Claude normalizer 对非字符串 thinking block 降级为 UnknownBlock
- Gemini normalizer 反序列化时为 Antigravity 目标补充 signature
- signature_cache _prune 增加超限驱逐
- model/provider 包 __init__ 改为惰性导入避免循环依赖
- build_antigravity_url 复制 query_params 防止修改调用方原始字典
- upstream_fetcher build_all_format_configs 兼容非 EndpointFetchConfig 对象
- Antigravity HTTP 状态判定扩展与 Gemini endpoint check 支持 v1internal
- 新增 thought signature 注入与 signature cache 驱逐测试

Closes #165

Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-02-10 17:53:04 +08:00
parent 0bb17a1502
commit 8949ad6d9e
19 changed files with 467 additions and 87 deletions

View File

@@ -4,11 +4,16 @@
包含模型管理、成本计算等功能。
"""
from src.services.model.availability import ModelAvailabilityQuery
from src.services.model.cost import ModelCostService
from src.services.model.fetch_scheduler import ModelFetchScheduler, get_model_fetch_scheduler
from src.services.model.global_model import GlobalModelService
from src.services.model.service import ModelService
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from src.services.model.availability import ModelAvailabilityQuery
from src.services.model.cost import ModelCostService
from src.services.model.fetch_scheduler import ModelFetchScheduler, get_model_fetch_scheduler
from src.services.model.global_model import GlobalModelService
from src.services.model.service import ModelService
__all__ = [
"ModelService",
@@ -18,3 +23,46 @@ __all__ = [
"ModelFetchScheduler",
"get_model_fetch_scheduler",
]
def __getattr__(name: str) -> Any:
"""Lazy attribute access to avoid import-time side effects.
Importing `src.services.model` should not eagerly import the whole model
service stack (scheduler/services), which can create circular imports during
test collection.
"""
if name == "ModelAvailabilityQuery":
from src.services.model.availability import ModelAvailabilityQuery as _ModelAvailabilityQuery
return _ModelAvailabilityQuery
if name == "ModelCostService":
from src.services.model.cost import ModelCostService as _ModelCostService
return _ModelCostService
if name == "ModelFetchScheduler":
from src.services.model.fetch_scheduler import ModelFetchScheduler as _ModelFetchScheduler
return _ModelFetchScheduler
if name == "get_model_fetch_scheduler":
from src.services.model.fetch_scheduler import (
get_model_fetch_scheduler as _get_model_fetch_scheduler,
)
return _get_model_fetch_scheduler
if name == "GlobalModelService":
from src.services.model.global_model import GlobalModelService as _GlobalModelService
return _GlobalModelService
if name == "ModelService":
from src.services.model.service import ModelService as _ModelService
return _ModelService
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -202,12 +202,26 @@ def build_all_format_configs(
fmt = next((f for f in candidates if f in format_to_endpoint), None)
if fmt is not None:
cfg = format_to_endpoint[fmt]
base_url = str(getattr(cfg, "base_url", "") or "")
extra_headers: dict[str, str] | None
if isinstance(cfg, EndpointFetchConfig):
extra_headers = cfg.extra_headers
else:
# 允许直接传递类似 ProviderEndpoint 的对象(测试/独立使用场景)。
candidate_extra = getattr(cfg, "extra_headers", None)
if isinstance(candidate_extra, dict):
extra_headers = {str(k): str(v) for k, v in candidate_extra.items() if k}
else:
extra_headers = get_extra_headers_from_endpoint(cfg)
configs.append(
{
"api_key": api_key_value,
"base_url": cfg.base_url,
"base_url": base_url,
"api_format": fmt,
"extra_headers": cfg.extra_headers,
"extra_headers": extra_headers,
}
)
return configs

View File

@@ -747,9 +747,10 @@ class ErrorClassifier:
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
key.is_active = False
self.db.commit()
logger.warning(
" [{}] {} 因 403 VALIDATION_REQUIRED 已标记为账号异常",
" [{}] {} 因 403 VALIDATION_REQUIRED 已标记为账号异常并自动停用",
request_id,
self._format_key_display(key),
)

View File

@@ -4,12 +4,42 @@ Provider 服务模块
包含 Provider 管理、格式处理、传输层等功能。
"""
from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.service import ProviderService
from src.services.provider.transport import build_provider_url
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.service import ProviderService
from src.services.provider.transport import build_provider_url
__all__ = [
"ProviderService",
"normalize_endpoint_signature",
"build_provider_url",
]
def __getattr__(name: str) -> Any:
"""Lazy attribute access to avoid import-time side effects.
Importing `src.services.provider` should not eagerly import the whole provider
service stack (which can create circular imports during test collection).
"""
if name == "ProviderService":
from src.services.provider.service import ProviderService as _ProviderService
return _ProviderService
if name == "normalize_endpoint_signature":
from src.services.provider.format import (
normalize_endpoint_signature as _normalize_endpoint_signature,
)
return _normalize_endpoint_signature
if name == "build_provider_url":
from src.services.provider.transport import build_provider_url as _build_provider_url
return _build_provider_url
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -53,10 +53,14 @@ def get_http_user_agent() -> str:
def update_user_agent_version(version: str) -> None:
"""更新 User-Agent 中的版本号(由 refresh_user_agent 调用)。"""
global _ua_version # noqa: PLW0603
global HTTP_USER_AGENT, _ua_version # noqa: PLW0603
version = str(version or "").strip()
if not version:
return
with _ua_lock:
_ua_version = version
# Backward compat: keep module-level constant in sync.
HTTP_USER_AGENT = f"antigravity/{_ua_version} {_PLATFORM_TAG}"
def parse_version_string(text: str) -> str | None:
"""从任意文本中提取 X.Y.Z 格式的版本号。"""

View File

@@ -197,7 +197,7 @@ def _inject_claude_tool_ids_request(inner_request: dict[str, Any], model: str) -
name = "unknown"
fc_id = fc.get("id")
if fc_id is None:
if not (isinstance(fc_id, str) and fc_id):
# 生成新 ID
count = name_counters.get(name, 0)
fc_id = f"call_{name}_{count}"
@@ -231,7 +231,7 @@ def _inject_claude_tool_ids_request(inner_request: dict[str, Any], model: str) -
continue
fr = part.get("functionResponse") or part.get("function_response")
if isinstance(fr, dict) and fr.get("id") is None:
if isinstance(fr, dict) and not (isinstance(fr.get("id"), str) and fr.get("id")):
name = fr.get("name", "")
if not isinstance(name, str) or not name:
name = "unknown"
@@ -514,22 +514,20 @@ def _inject_google_search_tool(inner_request: dict[str, Any]) -> None:
def _inject_thought_signatures(inner_request: dict[str, Any], session_id: str | None) -> None:
"""为 functionCall parts 注入 thoughtSignature(从 session signature cache
"""Inject thought signatures into functionCall parts.
对齐 AM wrapper.rs当 functionCall part 缺少 thoughtSignature 时,
session cache 中恢复签名,确保 thinking 模型的多轮 tool call 连续性。
Prefer tool-specific signatures (tool_use_id -> thoughtSignature), then fall back
to a session-level signature when available.
"""
if not session_id:
return
try:
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
except Exception:
return
cached_sig = signature_cache.get_session_signature(session_id)
if not cached_sig:
return
session_sig: str | None = None
if session_id:
session_sig = signature_cache.get_session_signature(session_id)
contents = inner_request.get("contents")
if not isinstance(contents, list):
@@ -545,9 +543,48 @@ def _inject_thought_signatures(inner_request: dict[str, Any], session_id: str |
for part in parts:
if not isinstance(part, dict):
continue
# 只处理有 functionCall 且缺少 thoughtSignature 的 part
if "functionCall" in part and part.get("thoughtSignature") is None:
part["thoughtSignature"] = cached_sig
fc = part.get("functionCall") or part.get("function_call")
if not isinstance(fc, dict):
continue
# Normalize existing signature aliases to thoughtSignature (if present).
sig_val: str | None = None
ts = part.get("thoughtSignature")
if isinstance(ts, str) and ts.strip():
sig_val = ts.strip()
if ts != sig_val:
part["thoughtSignature"] = sig_val
else:
tss = part.get("thought_signature")
if isinstance(tss, str) and tss.strip():
sig_val = tss.strip()
part["thoughtSignature"] = sig_val
part.pop("thought_signature", None)
else:
legacy = part.get("signature")
if isinstance(legacy, str) and legacy.strip():
sig_val = legacy.strip()
part["thoughtSignature"] = sig_val
part.pop("signature", None)
if sig_val:
continue
tool_id = fc.get("id")
tool_sig: str | None = None
if isinstance(tool_id, str) and tool_id:
tool_sig = signature_cache.get_tool_signature(tool_id)
chosen = tool_sig or session_sig
if not chosen:
continue
part["thoughtSignature"] = chosen
# Avoid sending duplicate aliases once we inject.
part.pop("thought_signature", None)
if part.get("signature") in (None, ""):
part.pop("signature", None)
# ---------------------------------------------------------------------------
@@ -782,8 +819,8 @@ def _inject_claude_tool_ids_response(response: dict[str, Any], model: str) -> No
for part in parts:
if not isinstance(part, dict):
continue
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("id") is None:
fc = part.get("functionCall") or part.get("function_call")
if isinstance(fc, dict) and not (isinstance(fc.get("id"), str) and fc.get("id")):
name = fc.get("name", "unknown")
if not isinstance(name, str):
name = "unknown"
@@ -976,7 +1013,7 @@ def cache_thought_signatures(model: str, response: dict[str, Any]) -> None:
signature_cache.cache(model, text, sig)
# 缓存 tool call signatureLayer 1
fc = part.get("functionCall")
fc = part.get("functionCall") or part.get("function_call")
if isinstance(fc, dict) and isinstance(sig, str) and sig:
tool_id = fc.get("id")
if isinstance(tool_id, str) and tool_id:
@@ -1070,9 +1107,9 @@ class AntigravityV1InternalEnvelope:
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
if not base_url:
return
if status_code == 200:
if 200 <= status_code < 300:
url_availability.mark_success(base_url)
elif status_code in (429, 500, 502, 503, 504):
elif status_code in (404, 408, 429) or 500 <= status_code < 600:
url_availability.mark_unavailable(base_url)
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002

View File

@@ -46,16 +46,18 @@ def build_antigravity_url(
action = "streamGenerateContent" if is_stream else "generateContent"
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
query_params = dict(effective_query_params)
# v1internal 流式请求同样支持 ?alt=sse
if is_stream:
effective_query_params.setdefault("alt", "sse")
query_params.setdefault("alt", "sse")
# 移除 v1internal 不支持的查询参数
effective_query_params.pop("beta", None)
query_params.pop("beta", None)
url = f"{str(base_url).rstrip('/')}{path}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_params:
query_string = urlencode(query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"

View File

@@ -85,7 +85,7 @@ class ThinkingSignatureCache:
with self._lock:
self._tool_sigs[tool_use_id] = _CacheEntry(signature)
if len(self._tool_sigs) > _TOOL_CACHE_LIMIT:
self._prune(self._tool_sigs)
self._prune(self._tool_sigs, limit=_TOOL_CACHE_LIMIT)
def get_tool_signature(self, tool_use_id: str) -> str | None:
"""查找工具调用对应的 signature。"""
@@ -107,7 +107,7 @@ class ThinkingSignatureCache:
with self._lock:
self._families[signature] = _CacheEntry(family)
if len(self._families) > _FAMILY_CACHE_LIMIT:
self._prune(self._families)
self._prune(self._families, limit=_FAMILY_CACHE_LIMIT)
def get_signature_family(self, signature: str) -> str | None:
"""查找 signature 所属的模型家族。"""
@@ -150,7 +150,7 @@ class ThinkingSignatureCache:
if should_store:
self._sessions[session_id] = _CacheEntry(_SessionEntry(signature, message_count))
if len(self._sessions) > _SESSION_CACHE_LIMIT:
self._prune(self._sessions)
self._prune(self._sessions, limit=_SESSION_CACHE_LIMIT)
def get_session_signature(self, session_id: str) -> str | None:
"""获取会话的最新 thinking signature。"""
@@ -210,13 +210,21 @@ class ThinkingSignatureCache:
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:32]
@staticmethod
def _prune(d: dict[str, _CacheEntry]) -> None:
"""清理已过期的缓存条目。"""
def _prune(d: dict[str, _CacheEntry], *, limit: int | None = None) -> None:
"""Remove expired entries and optionally enforce a size limit."""
now = time.monotonic()
expired = [k for k, v in d.items() if v.is_expired(now)]
for k in expired:
d.pop(k, None)
if limit is None or len(d) <= limit:
return
# Evict oldest entries by created_at.
excess = len(d) - limit
for k, _entry in sorted(d.items(), key=lambda kv: kv[1].created_at)[:excess]:
d.pop(k, None)
def clear(self) -> None:
"""清空所有缓存层(用于测试或手动重置)。"""
with self._lock:

View File

@@ -103,6 +103,7 @@ async def resolve_oauth_access_token(
if not is_account_level_block(getattr(row, "oauth_invalid_reason", None)):
row.oauth_invalid_at = None
row.oauth_invalid_reason = None
row.is_active = True
db.commit()
except Exception as e:
# Don't fail caller path; token is still usable for this request.