mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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:
@@ -1518,12 +1518,13 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
clearingOAuthInvalidKeyId.value = key.id
|
||||
try {
|
||||
await clearOAuthInvalid(key.id)
|
||||
showSuccess('已清除 OAuth 异常标记')
|
||||
showSuccess('已清除 OAuth 异常标记,Key 已自动启用')
|
||||
// 更新本地数据
|
||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||
if (keyInList) {
|
||||
keyInList.oauth_invalid_at = null
|
||||
keyInList.oauth_invalid_reason = null
|
||||
keyInList.is_active = true
|
||||
}
|
||||
await loadEndpoints()
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -199,11 +199,12 @@ async def clear_oauth_invalid(
|
||||
old_reason = key.oauth_invalid_reason
|
||||
key.oauth_invalid_at = None
|
||||
key.oauth_invalid_reason = None
|
||||
key.is_active = True
|
||||
db.commit()
|
||||
|
||||
logger.info("[OK] 手动清除 Key {}... 的 OAuth 失效标记 (原因: {})", key_id[:8], old_reason)
|
||||
logger.info("[OK] 手动清除 Key {}... 的 OAuth 失效标记并自动启用 (原因: {})", key_id[:8], old_reason)
|
||||
|
||||
return {"message": "已清除 OAuth 失效标记"}
|
||||
return {"message": "已清除 OAuth 失效标记,Key 已自动启用"}
|
||||
|
||||
|
||||
# ========== Provider Keys API ==========
|
||||
@@ -1575,8 +1576,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
||||
if "401" in error_msg or "认证失败" in error_msg:
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = "Kiro Token 无效或已过期"
|
||||
key.is_active = False
|
||||
db.commit()
|
||||
logger.warning("[KIRO_QUOTA] Key {} Token 无效,已标记为异常", key.id)
|
||||
logger.warning("[KIRO_QUOTA] Key {} Token 无效,已标记为异常并自动停用", key.id)
|
||||
return {
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
|
||||
@@ -690,8 +690,9 @@ async def refresh_oauth(
|
||||
# 标记为失效
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = str(e)
|
||||
key.is_active = False
|
||||
db.commit()
|
||||
logger.warning("Kiro Key {} token 刷新失败,已标记为失效: {}", key_id, e)
|
||||
logger.warning("Kiro Key {} token 刷新失败,已标记为失效并自动停用: {}", key_id, e)
|
||||
raise InvalidRequestException("Kiro token refresh 失败,请检查凭据是否有效")
|
||||
|
||||
# 更新 key
|
||||
@@ -699,6 +700,7 @@ async def refresh_oauth(
|
||||
key.auth_config = crypto_service.encrypt(json.dumps(new_cfg.to_dict()))
|
||||
key.oauth_invalid_at = None
|
||||
key.oauth_invalid_reason = None
|
||||
key.is_active = True
|
||||
db.commit()
|
||||
|
||||
return CompleteOAuthResponse(
|
||||
@@ -787,8 +789,9 @@ async def refresh_oauth(
|
||||
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = error_reason
|
||||
key.is_active = False
|
||||
db.commit()
|
||||
logger.warning("Key {} OAuth token 刷新失败,已标记为失效: {}", key_id, error_reason)
|
||||
logger.warning("Key {} OAuth token 刷新失败,已标记为失效并自动停用: {}", key_id, error_reason)
|
||||
|
||||
raise InvalidRequestException(f"token refresh 失败: {error_reason}")
|
||||
|
||||
@@ -841,6 +844,7 @@ async def refresh_oauth(
|
||||
if not is_account_level_block(getattr(key, "oauth_invalid_reason", None)):
|
||||
key.oauth_invalid_at = None
|
||||
key.oauth_invalid_reason = None
|
||||
key.is_active = True
|
||||
db.commit()
|
||||
|
||||
return CompleteOAuthResponse(
|
||||
|
||||
@@ -886,6 +886,7 @@ async def test_model(
|
||||
api_key.oauth_invalid_reason = (
|
||||
f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
|
||||
)
|
||||
api_key.is_active = False
|
||||
db.commit()
|
||||
oauth_email = None
|
||||
if getattr(api_key, "auth_config", None):
|
||||
|
||||
@@ -265,10 +265,10 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
provider_id: str | None = None,
|
||||
api_key_id: str | None = None,
|
||||
model_name: str | None = None,
|
||||
# Provider 上下文(Gemini Chat 适配器忽略,仅保持签名兼容)
|
||||
auth_type: str | None = None, # noqa: ARG003
|
||||
provider_type: str | None = None, # noqa: ARG003
|
||||
decrypted_auth_config: dict[str, Any] | None = None, # noqa: ARG003
|
||||
# Provider 上下文
|
||||
auth_type: str | None = None,
|
||||
provider_type: str | None = None,
|
||||
decrypted_auth_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""测试 Gemini API 模型连接性(非流式)"""
|
||||
from src.api.handlers.base.endpoint_checker import run_endpoint_check
|
||||
@@ -283,18 +283,60 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
"status_code": 400,
|
||||
}
|
||||
|
||||
# 使用基类配置方法,但重写URL构建逻辑
|
||||
base_url_resolved = cls.build_endpoint_url(base_url)
|
||||
url = f"{base_url_resolved}/models/{effective_model_name}:generateContent"
|
||||
is_antigravity = provider_type and provider_type.lower() == "antigravity"
|
||||
is_oauth = auth_type == "oauth"
|
||||
|
||||
# Antigravity provider 使用 v1internal 路径,而非标准 Gemini API 路径
|
||||
if is_antigravity:
|
||||
from src.services.provider.adapters.antigravity.constants import (
|
||||
V1INTERNAL_PATH_TEMPLATE,
|
||||
get_http_user_agent as _get_antigravity_ua,
|
||||
)
|
||||
from src.services.provider.adapters.antigravity.url_availability import url_availability
|
||||
|
||||
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
ag_base = ordered_urls[0] if ordered_urls else base_url
|
||||
path = V1INTERNAL_PATH_TEMPLATE.format(action="generateContent")
|
||||
url = f"{str(ag_base).rstrip('/')}{path}"
|
||||
else:
|
||||
# 使用基类配置方法,但重写URL构建逻辑
|
||||
base_url_resolved = cls.build_endpoint_url(base_url)
|
||||
url = f"{base_url_resolved}/models/{effective_model_name}:generateContent"
|
||||
|
||||
# 构建请求组件
|
||||
headers = cls.build_headers_with_extra(api_key, extra_headers)
|
||||
# Antigravity 需要特定的 User-Agent
|
||||
merged_extra = dict(extra_headers) if extra_headers else {}
|
||||
if is_antigravity:
|
||||
merged_extra["User-Agent"] = _get_antigravity_ua()
|
||||
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
|
||||
|
||||
# OAuth 统一处理:替换端点默认认证头(x-goog-api-key)为 Authorization: Bearer
|
||||
if is_oauth:
|
||||
from src.core.api_format import get_auth_config_for_endpoint
|
||||
|
||||
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||
if default_auth_header.lower() != "authorization":
|
||||
headers.pop(default_auth_header, None)
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
body = cls.build_request_body(request_data)
|
||||
|
||||
# 应用请求体规则(在格式转换后应用,确保规则效果不被覆盖)
|
||||
if body_rules:
|
||||
body = apply_body_rules(body, body_rules)
|
||||
|
||||
# Antigravity 需要将请求体包装为 v1internal 信封格式
|
||||
if is_antigravity:
|
||||
from src.services.provider.adapters.antigravity.envelope import wrap_v1internal_request
|
||||
|
||||
project_id = (decrypted_auth_config or {}).get("project_id", "")
|
||||
body = wrap_v1internal_request(
|
||||
body,
|
||||
project_id=project_id,
|
||||
model=effective_model_name,
|
||||
request_type="endpoint_test",
|
||||
)
|
||||
|
||||
# 应用请求头规则(在请求头构建后应用)
|
||||
if header_rules:
|
||||
# 获取认证头名称,防止被规则覆盖
|
||||
|
||||
@@ -720,15 +720,21 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
|
||||
btype = str(block.get("type") or "unknown")
|
||||
if btype == "thinking":
|
||||
thinking = str(block.get("thinking") or "")
|
||||
thinking_raw = block.get("thinking")
|
||||
signature = block.get("signature")
|
||||
if thinking:
|
||||
|
||||
if isinstance(thinking_raw, str) and thinking_raw:
|
||||
blocks.append(
|
||||
ThinkingBlock(
|
||||
thinking=thinking,
|
||||
thinking=thinking_raw,
|
||||
signature=str(signature) if signature else None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
dropped_key = f"claude_block:{btype}"
|
||||
dropped[dropped_key] = dropped.get(dropped_key, 0) + 1
|
||||
blocks.append(UnknownBlock(raw_type=btype, payload=block))
|
||||
|
||||
continue
|
||||
|
||||
if btype == "text":
|
||||
|
||||
@@ -565,12 +565,27 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
state.message_id = state.message_id or "gemini"
|
||||
ss["message_started"] = True
|
||||
ss.setdefault("text_block_started", False)
|
||||
ss.setdefault("text_block_stopped", False)
|
||||
ss.setdefault("thinking_block_started", False)
|
||||
ss.setdefault("thinking_block_stopped", False)
|
||||
# 根据首先出现的内容类型延迟分配块索引。
|
||||
# 当不存在思考内容时,golden tests 期望文本位于索引0。
|
||||
ss.setdefault("text_block_index", None)
|
||||
ss.setdefault("thinking_block_index", None)
|
||||
ss.setdefault("accumulated_text", "")
|
||||
ss.setdefault("accumulated_thinking", "")
|
||||
ss.setdefault("next_block_index", 2) # 0 预留给 thinking, 1 预留给 text
|
||||
ss.setdefault("next_block_index", 0)
|
||||
events.append(MessageStartEvent(message_id=state.message_id, model=model))
|
||||
|
||||
def _reserve_block_index(slot_key: str) -> int:
|
||||
idx = ss.get(slot_key)
|
||||
if isinstance(idx, int) and idx >= 0:
|
||||
return idx
|
||||
next_idx = int(ss.get("next_block_index") or 0)
|
||||
ss[slot_key] = next_idx
|
||||
ss["next_block_index"] = next_idx + 1
|
||||
return next_idx
|
||||
|
||||
candidates = chunk.get("candidates") or []
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
return events
|
||||
@@ -595,7 +610,7 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
is_thought = part.get("thought") is True
|
||||
|
||||
if is_thought:
|
||||
# Thinking content → block_index=0
|
||||
# Thinking content
|
||||
prev = str(ss.get("accumulated_thinking") or "")
|
||||
if text.startswith(prev):
|
||||
delta = text[len(prev) :]
|
||||
@@ -609,12 +624,18 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
ss["thinking_block_started"] = True
|
||||
events.append(
|
||||
ContentBlockStartEvent(
|
||||
block_index=0, block_type=ContentType.THINKING
|
||||
block_index=_reserve_block_index("thinking_block_index"),
|
||||
block_type=ContentType.THINKING,
|
||||
)
|
||||
)
|
||||
events.append(ContentDeltaEvent(block_index=0, text_delta=delta))
|
||||
events.append(
|
||||
ContentDeltaEvent(
|
||||
block_index=_reserve_block_index("thinking_block_index"),
|
||||
text_delta=delta,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Regular text → block_index=1
|
||||
# Regular text
|
||||
prev = str(ss.get("accumulated_text") or "")
|
||||
if text.startswith(prev):
|
||||
delta = text[len(prev) :]
|
||||
@@ -624,29 +645,38 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
ss["accumulated_text"] = prev + delta
|
||||
|
||||
if delta:
|
||||
# 切换:先关闭 thinking block(Claude 协议要求顺序 stop/start)
|
||||
# Transition: stop thinking block before text (Claude requires stop/start ordering).
|
||||
if ss.get("thinking_block_started") and not ss.get(
|
||||
"thinking_block_stopped"
|
||||
):
|
||||
ss["thinking_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=0))
|
||||
events.append(
|
||||
ContentBlockStopEvent(
|
||||
block_index=_reserve_block_index("thinking_block_index")
|
||||
)
|
||||
)
|
||||
|
||||
if not ss.get("text_block_started"):
|
||||
ss["text_block_started"] = True
|
||||
events.append(
|
||||
ContentBlockStartEvent(
|
||||
block_index=1, block_type=ContentType.TEXT
|
||||
block_index=_reserve_block_index("text_block_index"),
|
||||
block_type=ContentType.TEXT,
|
||||
)
|
||||
)
|
||||
events.append(ContentDeltaEvent(block_index=1, text_delta=delta))
|
||||
events.append(
|
||||
ContentDeltaEvent(
|
||||
block_index=_reserve_block_index("text_block_index"),
|
||||
text_delta=delta,
|
||||
)
|
||||
)
|
||||
|
||||
# 提取 thoughtSignature(对齐 AM:缓存到 session)
|
||||
sig = part.get("thoughtSignature") or part.get("thought_signature")
|
||||
if isinstance(sig, str) and sig and ss.get("thinking_block_started"):
|
||||
# 仅在 thinking block 已开启时发射 signature delta
|
||||
events.append(
|
||||
ContentDeltaEvent(
|
||||
block_index=0,
|
||||
block_index=_reserve_block_index("thinking_block_index"),
|
||||
text_delta="",
|
||||
extra={"thought_signature": sig},
|
||||
)
|
||||
@@ -662,10 +692,10 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
# 关闭前面的 thinking/text block(如果还开着)
|
||||
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
|
||||
ss["thinking_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=0))
|
||||
events.append(ContentBlockStopEvent(block_index=_reserve_block_index("thinking_block_index")))
|
||||
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
|
||||
ss["text_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=1))
|
||||
events.append(ContentBlockStopEvent(block_index=_reserve_block_index("text_block_index")))
|
||||
|
||||
name = str(func_call.get("name") or "")
|
||||
args = func_call.get("args")
|
||||
@@ -676,7 +706,7 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
fc_id = func_call.get("id")
|
||||
tool_id = fc_id if isinstance(fc_id, str) and fc_id else None
|
||||
|
||||
block_index = int(ss.get("next_block_index") or 2)
|
||||
block_index = int(ss.get("next_block_index") or 0)
|
||||
ss["next_block_index"] = block_index + 1
|
||||
|
||||
events.append(
|
||||
@@ -711,7 +741,7 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
|
||||
# 确保 mime_type 和 data 都非空
|
||||
if mime_type and data and len(data) > 10: # base64 图片数据至少几十个字符
|
||||
block_index = int(ss.get("next_block_index") or 1)
|
||||
block_index = int(ss.get("next_block_index") or 0)
|
||||
ss["next_block_index"] = block_index + 1
|
||||
|
||||
# 使用 ContentBlockStartEvent 传递图片数据
|
||||
@@ -736,10 +766,10 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
# 先补齐 content_block_stop(所有已开启的 block),再发送 MessageStop
|
||||
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
|
||||
ss["thinking_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=0))
|
||||
events.append(ContentBlockStopEvent(block_index=_reserve_block_index("thinking_block_index")))
|
||||
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
|
||||
ss["text_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=1))
|
||||
events.append(ContentBlockStopEvent(block_index=_reserve_block_index("text_block_index")))
|
||||
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info))
|
||||
|
||||
if "error" in chunk:
|
||||
@@ -1377,9 +1407,41 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
|
||||
if isinstance(b, ThinkingBlock):
|
||||
if b.thinking:
|
||||
signature: str | None = b.signature or None
|
||||
|
||||
if signature is None and target_variant == "antigravity":
|
||||
model_str = str(model or "")
|
||||
try:
|
||||
from src.services.provider.adapters.antigravity.constants import (
|
||||
DUMMY_THOUGHT_SIGNATURE,
|
||||
)
|
||||
from src.services.provider.adapters.antigravity.signature_cache import (
|
||||
signature_cache,
|
||||
)
|
||||
|
||||
cached_or_dummy = signature_cache.get_or_dummy(model_str, b.thinking)
|
||||
|
||||
# Prefer cached real signature > dummy signature.
|
||||
if (
|
||||
isinstance(cached_or_dummy, str)
|
||||
and cached_or_dummy
|
||||
and cached_or_dummy != DUMMY_THOUGHT_SIGNATURE
|
||||
):
|
||||
signature = cached_or_dummy
|
||||
elif isinstance(cached_or_dummy, str) and cached_or_dummy:
|
||||
signature = cached_or_dummy
|
||||
except Exception:
|
||||
# Best-effort fallback: Gemini models can accept a dummy signature.
|
||||
if model_str.startswith("gemini-"):
|
||||
signature = "skip_thought_signature_validator"
|
||||
|
||||
# For Antigravity, missing signature is likely to fail upstream validation.
|
||||
if target_variant == "antigravity" and not signature:
|
||||
continue
|
||||
|
||||
thought_part: dict[str, Any] = {"text": b.thinking, "thought": True}
|
||||
if b.signature:
|
||||
thought_part["thoughtSignature"] = b.signature
|
||||
if signature:
|
||||
thought_part["thoughtSignature"] = signature
|
||||
parts.append(thought_part)
|
||||
continue
|
||||
|
||||
|
||||
@@ -402,11 +402,26 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
state.model = model
|
||||
ss["message_started"] = True
|
||||
ss.setdefault("thinking_block_started", False)
|
||||
ss.setdefault("thinking_block_stopped", False)
|
||||
ss.setdefault("text_block_started", False)
|
||||
ss.setdefault("text_block_stopped", False)
|
||||
# 根据首先出现的内容类型延迟分配块索引。
|
||||
# 这样可以避免预留空白:当不存在思考内容时,测试期望文本位于索引0。
|
||||
ss.setdefault("thinking_block_index", None)
|
||||
ss.setdefault("text_block_index", None)
|
||||
ss.setdefault("tool_id_to_block_index", {})
|
||||
ss.setdefault("next_block_index", 2) # 0=thinking, 1=text, 2+=tools
|
||||
ss.setdefault("next_block_index", 0)
|
||||
events.append(MessageStartEvent(message_id=msg_id, model=model))
|
||||
|
||||
def _reserve_block_index(slot_key: str) -> int:
|
||||
idx = ss.get(slot_key)
|
||||
if isinstance(idx, int) and idx >= 0:
|
||||
return idx
|
||||
next_idx = int(ss.get("next_block_index") or 0)
|
||||
ss[slot_key] = next_idx
|
||||
ss["next_block_index"] = next_idx + 1
|
||||
return next_idx
|
||||
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices or not isinstance(choices, list):
|
||||
# OpenAI streaming may send a final "usage-only" chunk when
|
||||
@@ -432,24 +447,44 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
# reasoning_content delta (thinking)
|
||||
reasoning_delta = delta.get("reasoning_content")
|
||||
if isinstance(reasoning_delta, str) and reasoning_delta:
|
||||
thinking_index = _reserve_block_index("thinking_block_index")
|
||||
if not ss.get("thinking_block_started"):
|
||||
ss["thinking_block_started"] = True
|
||||
events.append(
|
||||
ContentBlockStartEvent(block_index=0, block_type=ContentType.THINKING)
|
||||
ContentBlockStartEvent(
|
||||
block_index=thinking_index,
|
||||
block_type=ContentType.THINKING,
|
||||
)
|
||||
)
|
||||
events.append(ContentDeltaEvent(block_index=0, text_delta=reasoning_delta))
|
||||
events.append(ContentDeltaEvent(block_index=thinking_index, text_delta=reasoning_delta))
|
||||
|
||||
# content delta
|
||||
content_delta = delta.get("content")
|
||||
if isinstance(content_delta, str) and content_delta:
|
||||
# 从 thinking 过渡到 text 时,先关闭 thinking block
|
||||
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
|
||||
# From thinking -> text, close thinking block first (only when text hasn't started yet).
|
||||
if (
|
||||
ss.get("thinking_block_started")
|
||||
and not ss.get("thinking_block_stopped")
|
||||
and not ss.get("text_block_started")
|
||||
):
|
||||
ss["thinking_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=0))
|
||||
events.append(
|
||||
ContentBlockStopEvent(block_index=_reserve_block_index("thinking_block_index"))
|
||||
)
|
||||
if not ss.get("text_block_started"):
|
||||
ss["text_block_started"] = True
|
||||
events.append(ContentBlockStartEvent(block_index=1, block_type=ContentType.TEXT))
|
||||
events.append(ContentDeltaEvent(block_index=1, text_delta=content_delta))
|
||||
events.append(
|
||||
ContentBlockStartEvent(
|
||||
block_index=_reserve_block_index("text_block_index"),
|
||||
block_type=ContentType.TEXT,
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
ContentDeltaEvent(
|
||||
block_index=_reserve_block_index("text_block_index"),
|
||||
text_delta=content_delta,
|
||||
)
|
||||
)
|
||||
|
||||
# tool_calls delta
|
||||
tool_calls = delta.get("tool_calls")
|
||||
@@ -497,10 +532,14 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
# 先补齐 content_block_stop(thinking + text),再发送 MessageStop
|
||||
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
|
||||
ss["thinking_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=0))
|
||||
events.append(
|
||||
ContentBlockStopEvent(block_index=_reserve_block_index("thinking_block_index"))
|
||||
)
|
||||
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
|
||||
ss["text_block_stopped"] = True
|
||||
events.append(ContentBlockStopEvent(block_index=1))
|
||||
events.append(
|
||||
ContentBlockStopEvent(block_index=_reserve_block_index("text_block_index"))
|
||||
)
|
||||
# 解析 usage(需要请求时设置 stream_options.include_usage: true)
|
||||
usage_info = self._openai_usage_to_internal(chunk.get("usage"))
|
||||
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info))
|
||||
@@ -1504,7 +1543,7 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
if tool_key in mapping:
|
||||
return int(mapping[tool_key])
|
||||
|
||||
next_idx = int(ss.get("next_block_index") or 1)
|
||||
next_idx = int(ss.get("next_block_index") or 0)
|
||||
mapping[tool_key] = next_idx
|
||||
ss["next_block_index"] = next_idx + 1
|
||||
return next_idx
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -204,3 +204,63 @@ async def test_antigravity_forces_conversion_path_in_stream_with_prefetch() -> N
|
||||
|
||||
assert mock_convert.call_count >= 1
|
||||
assert any(b"data: {}" in c for c in out)
|
||||
|
||||
|
||||
def test_wrap_v1internal_request_injects_thought_signature_from_tool_cache() -> None:
|
||||
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
|
||||
|
||||
signature_cache.clear()
|
||||
|
||||
tool_id = "toolu_123"
|
||||
sig = "a" * 60
|
||||
signature_cache.cache_tool_signature(tool_id, sig)
|
||||
|
||||
gemini_request = {
|
||||
"model": "gemini-2.0-flash",
|
||||
"contents": [
|
||||
{"role": "user", "parts": [{"text": "hi"}]},
|
||||
{"role": "model", "parts": [{"function_call": {"name": "do", "id": tool_id}}]},
|
||||
],
|
||||
}
|
||||
|
||||
wrapped = wrap_v1internal_request(
|
||||
gemini_request,
|
||||
project_id="project-123",
|
||||
model="gemini-2.0-flash",
|
||||
)
|
||||
|
||||
model_turn = wrapped["request"]["contents"][1]
|
||||
part = model_turn["parts"][0]
|
||||
assert part["thoughtSignature"] == sig
|
||||
|
||||
|
||||
def test_wrap_v1internal_request_injects_session_signature_when_tool_cache_missing() -> None:
|
||||
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
|
||||
|
||||
signature_cache.clear()
|
||||
|
||||
session_id = "sid-123"
|
||||
sig = "b" * 60
|
||||
signature_cache.cache_session_signature(session_id, sig, message_count=2)
|
||||
|
||||
gemini_request = {
|
||||
"model": "gemini-2.0-flash",
|
||||
"sessionId": session_id,
|
||||
"contents": [
|
||||
{"role": "user", "parts": [{"text": "hi"}]},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [{"functionCall": {"name": "do", "id": "toolu_missing"}}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
wrapped = wrap_v1internal_request(
|
||||
gemini_request,
|
||||
project_id="project-123",
|
||||
model="gemini-2.0-flash",
|
||||
)
|
||||
|
||||
model_turn = wrapped["request"]["contents"][1]
|
||||
part = model_turn["parts"][0]
|
||||
assert part["thoughtSignature"] == sig
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.services.provider.adapters.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
|
||||
from src.services.provider.adapters.antigravity.signature_cache import ThinkingSignatureCache
|
||||
|
||||
@@ -51,6 +53,22 @@ def test_tool_signature_short_ignored() -> None:
|
||||
assert cache.get_tool_signature("toolu_123") is None
|
||||
|
||||
|
||||
def test_tool_signature_cache_enforces_limit(monkeypatch: Any) -> None:
|
||||
import src.services.provider.adapters.antigravity.signature_cache as sc_mod
|
||||
|
||||
# Use a small limit to make eviction deterministic in tests.
|
||||
monkeypatch.setattr(sc_mod, "_TOOL_CACHE_LIMIT", 3)
|
||||
cache = sc_mod.ThinkingSignatureCache()
|
||||
cache.cache_tool_signature("toolu_1", _SIG_A)
|
||||
cache.cache_tool_signature("toolu_2", _SIG_B)
|
||||
cache.cache_tool_signature("toolu_3", _SIG_C)
|
||||
cache.cache_tool_signature("toolu_4", _SIG_D)
|
||||
|
||||
# Oldest entry should be evicted once the limit is exceeded.
|
||||
assert cache.get_tool_signature("toolu_1") is None
|
||||
assert cache.get_tool_signature("toolu_4") == _SIG_D
|
||||
|
||||
|
||||
# ===== Layer 2: Thinking Families =====
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user