feat: Antigravity 和 Codex 服务支持

- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理
- 新增 Codex 服务:信封处理、元数据收集器
- 重构 provider transport 支持新的服务架构
- 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应
- 优化 OAuth 工具函数
- 添加相关测试用例
This commit is contained in:
fawney19
2026-02-05 15:57:52 +08:00
parent ed2ff5c1d7
commit 440721368f
44 changed files with 3498 additions and 134 deletions

View File

@@ -197,6 +197,15 @@ class GeminiNormalizer(FormatNormalizer):
) -> dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions)
target_variant_norm = str(target_variant or "").strip().lower()
is_antigravity = target_variant_norm == "antigravity"
allow_dummy_thought = bool(
is_antigravity and str(internal.model or "").startswith("gemini-")
)
thinking_enabled = (
self._is_antigravity_thinking_enabled(internal) if is_antigravity else False
)
# tools/tool_choice
tools = None
if internal.tools:
@@ -278,8 +287,45 @@ class GeminiNormalizer(FormatNormalizer):
generation_config["thinkingConfig"] = orig_gc["thinking_config"]
contents: list[dict[str, Any]] = []
for msg in internal.messages:
contents.append(self._internal_message_to_content(msg))
last_idx = len(internal.messages) - 1
for idx, msg in enumerate(internal.messages):
content = self._internal_message_to_content(
msg,
target_variant=target_variant_norm,
model=internal.model,
)
# Antigravity: Gemini models allow a dummy thought signature as a workaround
# for strict thought signature validation. Only apply to the last assistant
# turn (prefill scenario) when thinking is enabled and no thought part exists.
if (
allow_dummy_thought
and thinking_enabled
and idx == last_idx
and content.get("role") == "model"
):
parts = content.get("parts")
if isinstance(parts, list) and parts:
has_thought = any(
isinstance(p, dict) and p.get("thought") is True for p in parts
)
if not has_thought:
try:
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
dummy_sig = DUMMY_THOUGHT_SIGNATURE
except Exception:
dummy_sig = "skip_thought_signature_validator"
dummy_part: dict[str, Any] = {
"text": "Thinking...",
"thought": True,
"thoughtSignature": dummy_sig,
}
content = dict(content)
content["parts"] = [dummy_part, *parts]
contents.append(content)
result: dict[str, Any] = {
"contents": contents,
@@ -1120,12 +1166,22 @@ class GeminiNormalizer(FormatNormalizer):
return blocks, dropped
def _internal_message_to_content(self, msg: InternalMessage) -> dict[str, Any]:
def _internal_message_to_content(
self,
msg: InternalMessage,
*,
target_variant: str | None = None,
model: str | None = None,
) -> dict[str, Any]:
role = "model" if msg.role == Role.ASSISTANT else "user"
parts: list[dict[str, Any]] = []
for b in msg.content:
if isinstance(b, UnknownBlock):
if str(target_variant or "").strip().lower() == "antigravity":
part = self._unknown_block_to_antigravity_part(b, model=str(model or ""))
if part is not None:
parts.append(part)
continue
if isinstance(b, TextBlock):
@@ -1166,6 +1222,94 @@ class GeminiNormalizer(FormatNormalizer):
return {"role": role, "parts": parts}
def _is_antigravity_thinking_enabled(self, internal: InternalRequest) -> bool:
"""Best-effort detection of Claude-style `thinking` flag for Antigravity conversions."""
try:
extra = internal.extra if isinstance(internal.extra, dict) else {}
claude_extra = extra.get("claude")
if not isinstance(claude_extra, dict):
return False
thinking = claude_extra.get("thinking")
if thinking is True:
return True
if isinstance(thinking, dict):
ttype = thinking.get("type")
if isinstance(ttype, str) and ttype.strip().lower() == "enabled":
return True
enabled = thinking.get("enabled")
if enabled is True:
return True
return False
except Exception:
return False
def _unknown_block_to_antigravity_part(
self,
block: UnknownBlock,
*,
model: str,
) -> dict[str, Any] | None:
"""Translate Claude thinking blocks into Gemini thought parts for Antigravity.
The internal representation stores Claude `thinking`/`redacted_thinking` as UnknownBlock.
Antigravity expects them as Gemini parts with `thought=true` and `thoughtSignature`.
"""
raw_type = str(getattr(block, "raw_type", "") or "").strip().lower()
if raw_type not in {"thinking", "redacted_thinking"}:
return None
payload = block.payload if isinstance(block.payload, dict) else {}
# Claude: thinking -> {type:"thinking", thinking:"...", signature?: "..."}
# Claude: redacted_thinking -> {type:"redacted_thinking", data:"..."}
if raw_type == "thinking":
text_val = payload.get("thinking")
else:
text_val = payload.get("data")
if text_val is None:
text_val = payload.get("text")
if not isinstance(text_val, str) or not text_val:
return None
payload_sig = (
payload.get("signature")
or payload.get("thoughtSignature")
or payload.get("thought_signature")
)
if not isinstance(payload_sig, str) or not payload_sig:
payload_sig = None
signature: str | None = None
try:
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.antigravity.signature_cache import signature_cache
cached_or_dummy = signature_cache.get_or_dummy(model, text_val)
# Prefer cached real signature > client-provided 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 payload_sig:
signature = payload_sig
elif isinstance(cached_or_dummy, str) and cached_or_dummy:
signature = cached_or_dummy
except Exception:
signature = payload_sig
# For non-gemini models, missing signature is likely to fail upstream validation.
if not signature:
return None
return {"text": text_val, "thought": True, "thoughtSignature": signature}
def _collapse_system_instruction(
self, system_instruction: Any
) -> tuple[str | None, dict[str, int]]:

View File

@@ -0,0 +1,262 @@
"""Sync<->stream bridge helpers for the conversion layer.
We already have:
- streaming conversion: source stream chunk -> internal events -> target stream chunk
- sync conversion: source response -> internal response -> target response
This module fills the missing link:
- aggregate internal stream events into a single InternalResponse (stream -> sync)
- expand an InternalResponse into internal stream events (sync -> stream)
Used by handler-layer upstream policies that force upstream streaming mode.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Iterable, Iterator
from .internal import (
ContentType,
ImageBlock,
InternalResponse,
StopReason,
TextBlock,
ToolUseBlock,
UsageInfo,
)
from .stream_events import (
ContentBlockStartEvent,
ContentBlockStopEvent,
ContentDeltaEvent,
InternalStreamEvent,
MessageStartEvent,
MessageStopEvent,
ToolCallDeltaEvent,
UsageEvent,
)
@dataclass
class _BlockBuilder:
block_type: ContentType
text: str = ""
tool_id: str | None = None
tool_name: str | None = None
tool_args_json: str = ""
image_data: str | None = None
image_media_type: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
def finalize(self) -> Any:
if self.block_type == ContentType.TEXT:
return TextBlock(text=self.text, extra=self.extra)
if self.block_type == ContentType.TOOL_USE:
tool_input: dict[str, Any] = {}
raw = self.tool_args_json.strip()
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
tool_input = parsed
except Exception:
tool_input = {}
return ToolUseBlock(
tool_id=str(self.tool_id or ""),
tool_name=str(self.tool_name or ""),
tool_input=tool_input,
extra=self.extra,
)
if self.block_type == ContentType.IMAGE:
return ImageBlock(
data=self.image_data,
media_type=self.image_media_type,
url=None,
extra=self.extra,
)
# Unknown block type: best-effort drop.
return TextBlock(text=self.text, extra=self.extra)
class InternalStreamAggregator:
"""Aggregate internal stream events into a single InternalResponse (best-effort)."""
def __init__(
self,
*,
fallback_id: str = "resp",
fallback_model: str = "",
) -> None:
self._fallback_id = fallback_id
self._fallback_model = fallback_model
self._id: str | None = None
self._model: str | None = None
self._stop_reason: StopReason | None = None
self._usage: UsageInfo | None = None
self._open: dict[int, _BlockBuilder] = {}
self._final: dict[int, Any] = {}
def feed(self, events: Iterable[InternalStreamEvent]) -> None:
for ev in events:
if isinstance(ev, MessageStartEvent):
if ev.message_id:
self._id = ev.message_id
if ev.model:
self._model = ev.model
if ev.usage:
self._usage = ev.usage
continue
if isinstance(ev, UsageEvent):
if ev.usage:
self._usage = ev.usage
continue
if isinstance(ev, ContentBlockStartEvent):
b = _BlockBuilder(block_type=ev.block_type, extra=dict(ev.extra or {}))
if ev.block_type == ContentType.TOOL_USE:
b.tool_id = ev.tool_id
b.tool_name = ev.tool_name
if ev.block_type == ContentType.IMAGE:
b.image_data = b.extra.get("image_data") or b.extra.get("data")
b.image_media_type = b.extra.get("image_media_type") or b.extra.get("mime_type")
self._open[int(ev.block_index)] = b
continue
if isinstance(ev, ContentDeltaEvent):
idx = int(ev.block_index)
b = self._open.get(idx)
if b is None:
b = _BlockBuilder(block_type=ContentType.TEXT)
self._open[idx] = b
if ev.text_delta:
b.text += ev.text_delta
continue
if isinstance(ev, ToolCallDeltaEvent):
idx = int(ev.block_index)
b = self._open.get(idx)
if b is None:
b = _BlockBuilder(block_type=ContentType.TOOL_USE)
self._open[idx] = b
if ev.input_delta:
b.tool_args_json += ev.input_delta
continue
if isinstance(ev, ContentBlockStopEvent):
idx = int(ev.block_index)
b = self._open.pop(idx, None)
if b is not None:
self._final.setdefault(idx, b.finalize())
continue
if isinstance(ev, MessageStopEvent):
self._stop_reason = ev.stop_reason
if ev.usage:
self._usage = ev.usage
# Flush remaining open blocks (best-effort).
for idx, b in list(self._open.items()):
self._final.setdefault(idx, b.finalize())
self._open.clear()
continue
def build(self) -> InternalResponse:
rid = self._id or self._fallback_id
model = self._model or self._fallback_model
content = [self._final[k] for k in sorted(self._final.keys())]
return InternalResponse(
id=str(rid or "resp"),
model=str(model or ""),
content=content,
stop_reason=self._stop_reason,
usage=self._usage,
)
def iter_internal_response_as_stream_events(
internal: InternalResponse,
*,
chunk_text: bool = False,
text_chunk_size: int = 200,
) -> Iterator[InternalStreamEvent]:
"""Expand an InternalResponse into internal stream events (best-effort).
This is used to simulate SSE when the upstream is forced to sync mode.
"""
msg_id = str(internal.id or "resp")
model = str(internal.model or "")
yield MessageStartEvent(message_id=msg_id, model=model)
block_index = 0
for block in internal.content or []:
# Text
if isinstance(block, TextBlock):
yield ContentBlockStartEvent(block_index=block_index, block_type=ContentType.TEXT)
text = str(block.text or "")
if not chunk_text or text_chunk_size <= 0:
if text:
yield ContentDeltaEvent(block_index=block_index, text_delta=text)
else:
for i in range(0, len(text), text_chunk_size):
part = text[i : i + text_chunk_size]
if part:
yield ContentDeltaEvent(block_index=block_index, text_delta=part)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Tool use
if isinstance(block, ToolUseBlock):
tool_id = block.tool_id or f"tool_{block_index}"
yield ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.TOOL_USE,
tool_id=tool_id,
tool_name=block.tool_name or None,
)
payload = {}
if isinstance(block.tool_input, dict):
payload = block.tool_input
yield ToolCallDeltaEvent(
block_index=block_index,
tool_id=str(tool_id),
input_delta=json.dumps(payload, ensure_ascii=False),
)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Image
if isinstance(block, ImageBlock):
yield ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.IMAGE,
extra={
"image_data": block.data,
"image_media_type": block.media_type,
},
)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Unknown blocks: ignore.
block_index += 1
yield MessageStopEvent(
stop_reason=internal.stop_reason or StopReason.END_TURN, usage=internal.usage
)
__all__ = [
"InternalStreamAggregator",
"iter_internal_response_as_stream_events",
]