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

@@ -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":

View File

@@ -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 blockClaude 协议要求顺序 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

View File

@@ -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_stopthinking + 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