feat: Antigravity 和 Codex 服务支持

- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理
- 新增 Codex 服务:信封处理、元数据收集器
- 重构 provider transport 支持新的服务架构
- 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应
- 优化 OAuth 工具函数
- 添加相关测试用例
This commit is contained in:
fawney19
2026-02-05 15:59:41 +08:00
parent ed2ff5c1d7
commit 440721368f
44 changed files with 3498 additions and 134 deletions
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from src.services.antigravity.client import load_code_assist
from src.services.antigravity.constants import DAILY_BASE_URL, PROD_BASE_URL
@pytest.mark.asyncio
async def test_load_code_assist_falls_back_to_daily() -> None:
resp1 = httpx.Response(500, json={"error": {"message": "boom"}})
resp2 = httpx.Response(200, json={"cloudaicompanionProject": "project-1"})
client = SimpleNamespace(post=AsyncMock(side_effect=[resp1, resp2]))
with patch(
"src.clients.http_client.HTTPClientPool.get_proxy_client",
AsyncMock(return_value=client),
):
data = await load_code_assist("tok", proxy_config=None, timeout_seconds=1.0)
assert data["cloudaicompanionProject"] == "project-1"
assert client.post.await_count == 2
assert client.post.call_args_list[0].args[0] == f"{PROD_BASE_URL}/v1internal:loadCodeAssist"
assert client.post.call_args_list[1].args[0] == f"{DAILY_BASE_URL}/v1internal:loadCodeAssist"
@pytest.mark.asyncio
async def test_load_code_assist_requires_token() -> None:
with pytest.raises(ValueError):
await load_code_assist("", proxy_config=None)
@@ -0,0 +1,35 @@
from __future__ import annotations
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.antigravity.signature_cache import ThinkingSignatureCache
def test_get_or_dummy_returns_dummy_for_gemini_models() -> None:
cache = ThinkingSignatureCache(maxsize=10)
assert cache.get_or_dummy("gemini-3-pro", "thinking...") == DUMMY_THOUGHT_SIGNATURE
def test_get_or_dummy_returns_none_for_non_gemini_models() -> None:
cache = ThinkingSignatureCache(maxsize=10)
assert cache.get_or_dummy("claude-sonnet", "thinking...") is None
def test_cached_signature_preferred() -> None:
cache = ThinkingSignatureCache(maxsize=10)
cache.cache("gemini-3-pro", "t", "sig-1")
assert cache.get_or_dummy("gemini-3-pro", "t") == "sig-1"
def test_eviction_fifo() -> None:
cache = ThinkingSignatureCache(maxsize=4)
cache.cache("gemini-3-pro", "t1", "s1")
cache.cache("gemini-3-pro", "t2", "s2")
cache.cache("gemini-3-pro", "t3", "s3")
cache.cache("gemini-3-pro", "t4", "s4")
# Trigger eviction (evict 1 key when maxsize=4)
cache.cache("gemini-3-pro", "t5", "s5")
# Oldest key should be gone
assert cache.get_or_dummy("gemini-3-pro", "t1") == DUMMY_THOUGHT_SIGNATURE
@@ -0,0 +1,118 @@
from __future__ import annotations
from src.api.handlers.base.utils import get_format_converter_registry
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.antigravity.signature_cache import signature_cache
def _reset_sig_cache() -> None:
# Module-global cache; tests must isolate state.
signature_cache._cache.clear() # type: ignore[attr-defined]
def test_antigravity_converts_claude_thinking_block_to_gemini_thought_part_prefers_payload_sig() -> None:
_reset_sig_cache()
req = {
"model": "gemini-3-pro",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "t1", "signature": "sig-1"},
{"type": "text", "text": "ok"},
],
},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
assert isinstance(out.get("contents"), list)
model_turn = out["contents"][1]
assert model_turn["role"] == "model"
parts = model_turn["parts"]
assert parts[0]["thought"] is True
assert parts[0]["text"] == "t1"
assert parts[0]["thoughtSignature"] == "sig-1"
def test_antigravity_uses_dummy_signature_for_gemini_when_missing() -> None:
_reset_sig_cache()
req = {
"model": "gemini-3-pro",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "t2"},
{"type": "text", "text": "ok"},
],
},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert parts[0]["thought"] is True
assert parts[0]["text"] == "t2"
assert parts[0]["thoughtSignature"] == DUMMY_THOUGHT_SIGNATURE
def test_antigravity_drops_unsigned_thinking_for_non_gemini_models() -> None:
_reset_sig_cache()
req = {
"model": "claude-sonnet-4-5",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "t3"},
{"type": "text", "text": "ok"},
],
},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert all(p.get("thought") is not True for p in parts)
def test_antigravity_inserts_dummy_thought_for_last_assistant_when_thinking_enabled() -> None:
_reset_sig_cache()
req = {
"model": "gemini-3-pro",
"max_tokens": 64,
"thinking": {"type": "enabled", "budget_tokens": 1000},
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{"role": "assistant", "content": [{"type": "text", "text": "prefill"}]},
],
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert parts[0]["thought"] is True
assert parts[0]["thoughtSignature"] == DUMMY_THOUGHT_SIGNATURE
assert parts[1]["text"] == "prefill"
@@ -0,0 +1,58 @@
from __future__ import annotations
import src.services.antigravity.url_availability as ua_mod
from src.services.antigravity.constants import (
DAILY_BASE_URL,
PROD_BASE_URL,
URL_UNAVAILABLE_TTL_SECONDS,
)
from src.services.antigravity.url_availability import url_availability
def _reset_state() -> None:
# Singleton: tests need to reset global state
with url_availability._mu: # type: ignore[attr-defined]
url_availability._unavailable.clear() # type: ignore[attr-defined]
url_availability._last_success = None # type: ignore[attr-defined]
def test_mark_success_priority() -> None:
_reset_state()
url_availability.mark_success(PROD_BASE_URL)
ordered = url_availability.get_ordered_urls(prefer_daily=True)
assert ordered[0] == PROD_BASE_URL
def test_mark_unavailable_filters_url() -> None:
_reset_state()
url_availability.mark_unavailable(DAILY_BASE_URL)
ordered = url_availability.get_ordered_urls(prefer_daily=True)
assert ordered[0] == PROD_BASE_URL
assert url_availability.is_available(DAILY_BASE_URL) is False
def test_ttl_recovery(monkeypatch) -> None:
_reset_state()
t0 = 1000.0
monkeypatch.setattr(ua_mod.time, "time", lambda: t0)
url_availability.mark_unavailable(PROD_BASE_URL)
assert url_availability.is_available(PROD_BASE_URL) is False
monkeypatch.setattr(ua_mod.time, "time", lambda: t0 + URL_UNAVAILABLE_TTL_SECONDS + 1)
assert url_availability.is_available(PROD_BASE_URL) is True
def test_all_unavailable_fallback_returns_base_order() -> None:
_reset_state()
url_availability.mark_unavailable(PROD_BASE_URL)
url_availability.mark_unavailable(DAILY_BASE_URL)
ordered = url_availability.get_ordered_urls(prefer_daily=True)
assert ordered == [DAILY_BASE_URL, PROD_BASE_URL]