mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: Antigravity 和 Codex 服务支持
- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理 - 新增 Codex 服务:信封处理、元数据收集器 - 重构 provider transport 支持新的服务架构 - 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应 - 优化 OAuth 工具函数 - 添加相关测试用例
This commit is contained in:
@@ -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]]:
|
||||
|
||||
262
src/core/api_format/conversion/stream_bridge.py
Normal file
262
src/core/api_format/conversion/stream_bridge.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user