mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 格式的版本号。"""
|
||||
|
||||
@@ -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 signature(Layer 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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user