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

@@ -0,0 +1,201 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.services.antigravity.envelope import (
unwrap_v1internal_response,
wrap_v1internal_request,
)
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.gemini_cli.handler import GeminiCliMessageHandler
def _make_handler() -> GeminiCliMessageHandler:
return GeminiCliMessageHandler(
db=MagicMock(),
user=SimpleNamespace(id=1),
api_key=SimpleNamespace(id=1),
request_id="req_1",
client_ip="127.0.0.1",
user_agent="pytest",
start_time=0.0,
)
def test_wrap_v1internal_request_removes_inner_model() -> None:
gemini_request = {
"model": "gemini-2.0-flash",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
}
wrapped = wrap_v1internal_request(
gemini_request,
project_id="project-123",
model="gemini-2.0-flash",
)
assert wrapped["project"] == "project-123"
assert wrapped["model"] == "gemini-2.0-flash"
assert "request" in wrapped
assert "model" not in wrapped["request"]
def test_unwrap_v1internal_response() -> None:
v1_resp = {
"response": {"candidates": [{"content": {"parts": [{"text": "Hello"}]}}]},
"responseId": "resp-123",
}
unwrapped = unwrap_v1internal_response(v1_resp)
assert "response" not in unwrapped
assert "candidates" in unwrapped
assert unwrapped["_v1internal_response_id"] == "resp-123"
def test_convert_sse_line_unwraps_before_convert_stream_chunk() -> None:
handler = _make_handler()
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
ctx.provider_type = "antigravity"
ctx.provider_api_format = "gemini:cli"
ctx.client_api_format = "gemini:cli"
v1_line = (
'data: {"response": {"candidates": [{"content": {"parts": [{"text": "Hi"}]}}]},'
' "responseId": "123"}'
)
seen: dict[str, object] = {}
class _DummyRegistry:
def convert_stream_chunk(self, data_obj, *_args, **_kwargs): # noqa: ANN001
seen["data_obj"] = data_obj
return []
with patch(
"src.api.handlers.base.cli_handler_base.get_format_converter_registry",
return_value=_DummyRegistry(),
):
_lines, _events = handler._convert_sse_line(ctx, v1_line, [])
assert isinstance(seen.get("data_obj"), dict)
assert "response" not in seen["data_obj"] # 已解包
assert "_v1internal_response_id" in seen["data_obj"]
def test_handle_sse_event_unwraps_for_antigravity() -> None:
handler = _make_handler()
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
ctx.provider_type = "antigravity"
v1_data = {
"response": {
"candidates": [
{
"content": {"parts": [{"text": "Hello"}], "role": "model"},
"finishReason": "STOP",
}
],
"modelVersion": "gemini-2.0-flash-001",
},
"responseId": "123",
}
with patch.object(handler, "_process_event_data") as mock_process:
handler._handle_sse_event(ctx, None, json.dumps(v1_data), record_chunk=False)
assert mock_process.call_count == 1
passed_data = mock_process.call_args[0][2]
assert isinstance(passed_data, dict)
assert "response" not in passed_data
assert "candidates" in passed_data
def test_handle_sse_event_caches_thought_signature_for_antigravity() -> None:
from src.services.antigravity.signature_cache import signature_cache
signature_cache._cache.clear() # type: ignore[attr-defined]
handler = _make_handler()
ctx = StreamContext(model="claude-sonnet-4-5", api_format="gemini:cli")
ctx.provider_type = "antigravity"
payload = {
"candidates": [
{
"content": {
"parts": [
{
"text": "t1",
"thought": True,
"thoughtSignature": "sig-abc",
}
]
}
}
]
}
with patch.object(handler, "_process_event_data") as _mock_process:
handler._handle_sse_event(ctx, None, json.dumps(payload), record_chunk=False)
assert signature_cache.get_or_dummy("claude-sonnet-4-5", "t1") == "sig-abc"
def test_provider_type_drives_antigravity_usage_path() -> None:
handler = _make_handler()
event = {"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 2}}
usage = handler._extract_usage_from_event(event, provider_type="antigravity")
assert usage["input_tokens"] == 10
assert usage["output_tokens"] == 2
@pytest.mark.asyncio
async def test_antigravity_forces_conversion_path_in_stream_with_prefetch() -> None:
handler = _make_handler()
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
ctx.provider_type = "antigravity"
ctx.needs_conversion = False # same-format case normally would passthrough
class _AsyncIter:
def __init__(self, items): # noqa: ANN001
self._it = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._it)
except StopIteration as e:
raise StopAsyncIteration from e
prefetched = [
b'data: {"response": {"candidates": []}, "responseId": "1"}\n',
]
byte_iter = _AsyncIter([]) # no more bytes after prefetch
response_ctx = SimpleNamespace(__aexit__=AsyncMock(return_value=None))
http_client = SimpleNamespace(aclose=AsyncMock(return_value=None))
with patch.object(
handler,
"_convert_sse_line",
return_value=(["data: {}"], []),
) as mock_convert:
out = []
async for chunk in handler._create_response_stream_with_prefetch(
ctx, byte_iter, response_ctx, http_client, prefetched
):
out.append(chunk)
assert mock_convert.call_count >= 1
assert any(b"data: {}" in c for c in out)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from src.core.provider_oauth_utils import enrich_auth_config
@pytest.mark.asyncio
async def test_enrich_auth_config_antigravity_adds_project_id_and_email() -> None:
auth_config: dict[str, object] = {}
token_response: dict[str, object] = {}
with (
patch("src.core.provider_oauth_utils.fetch_google_email", AsyncMock(return_value="u@example.com")),
patch(
"src.services.antigravity.client.load_code_assist",
AsyncMock(
return_value={
"cloudaicompanionProject": "project-1",
"currentTier": {"tierType": "PAID"},
}
),
),
):
out = await enrich_auth_config(
provider_type="antigravity",
auth_config=auth_config, # in-place
token_response=token_response,
access_token="tok",
proxy_config=None,
)
assert out["email"] == "u@example.com"
assert out["project_id"] == "project-1"
assert out["tier"] == "PAID"

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)

View File

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

View File

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

View File

@@ -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]

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
from src.services.provider.codex import maybe_patch_request_for_codex, patch_openai_cli_request_for_codex
from src.services.provider.codex import (
maybe_patch_request_for_codex,
patch_openai_cli_request_for_codex,
)
def test_patch_openai_cli_request_for_codex_sets_store_and_instructions() -> None:
@@ -101,3 +104,13 @@ def test_maybe_patch_request_for_codex_patches_for_codex_openai_cli() -> None:
assert out["store"] is False
assert "instructions" in out
def test_codex_envelope_extra_headers_includes_sse_accept_and_session() -> None:
from src.services.codex.envelope import codex_oauth_envelope
headers = codex_oauth_envelope.extra_headers() or {}
assert headers.get("Accept") == "text/event-stream"
assert headers.get("x-oai-web-search-eligible") == "true"
assert headers.get("originator") == "codex_cli_rs"
assert isinstance(headers.get("session_id"), str)
assert headers.get("session_id")

View File

@@ -48,6 +48,14 @@ class TestThinkingErrorPatterns:
error = '{"error": {"message": "signature verification failed"}}'
assert classifier._is_thinking_error(error) is True
def test_detect_thought_signature_patterns(self, classifier: ErrorClassifier) -> None:
"""检测 Antigravity/Gemini thoughtSignature 相关错误"""
error = '{"error": {"message": "invalid thoughtSignature in thought part"}}'
assert classifier._is_thinking_error(error) is True
error2 = '{"error": {"message": "thought_signature verification failed"}}'
assert classifier._is_thinking_error(error2) is True
# === 结构错误测试 ===
def test_detect_must_start_with_thinking_block(self, classifier: ErrorClassifier) -> None:

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import patch
from src.services.antigravity.constants import PROD_BASE_URL
from src.services.provider.transport import build_provider_url, get_antigravity_base_url
@dataclass
class _DummyEndpoint:
base_url: str
api_format: str
custom_path: str | None = None
provider: object | None = None
def test_antigravity_uses_v1internal_path_and_sets_contextvar() -> None:
endpoint = _DummyEndpoint(
base_url="https://ignored.example.com",
api_format="gemini:cli",
provider=SimpleNamespace(provider_type="antigravity"),
)
with patch(
"src.services.provider.transport.url_availability.get_ordered_urls",
return_value=[PROD_BASE_URL],
):
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-2.0-flash"},
is_stream=True,
)
assert url.startswith(f"{PROD_BASE_URL}/v1internal:streamGenerateContent")
assert "alt=sse" in url
assert get_antigravity_base_url() == PROD_BASE_URL
def test_gemini_cli_non_antigravity_uses_v1beta_path_and_clears_contextvar() -> None:
endpoint = _DummyEndpoint(
base_url="https://generativelanguage.googleapis.com",
api_format="gemini:cli",
provider=SimpleNamespace(provider_type="gemini_cli"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-2.0-flash"},
is_stream=False,
)
assert "/v1beta/models/gemini-2.0-flash:generateContent" in url
assert get_antigravity_base_url() is None

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from src.services.provider.transport import build_provider_url
@dataclass
class _DummyEndpoint:
base_url: str
api_format: str
custom_path: str | None = None
provider: object | None = None
def test_codex_openai_cli_uses_responses_path_without_v1_prefix() -> None:
endpoint = _DummyEndpoint(
base_url="https://chatgpt.com/backend-api/codex",
api_format="openai:cli",
provider=SimpleNamespace(provider_type="codex"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "ignored"},
is_stream=True,
)
assert url == "https://chatgpt.com/backend-api/codex/responses"
def test_codex_openai_cli_does_not_duplicate_responses_suffix() -> None:
endpoint = _DummyEndpoint(
base_url="https://chatgpt.com/backend-api/codex/responses",
api_format="openai:cli",
provider=SimpleNamespace(provider_type="codex"),
)
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "ignored"},
is_stream=False,
)
assert url == "https://chatgpt.com/backend-api/codex/responses"

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
import json
import time
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from src.api.handlers.base.request_builder import get_provider_auth
@pytest.mark.asyncio
async def test_get_provider_auth_oauth_returns_decrypted_auth_config() -> None:
now = int(time.time())
token_meta = {
"provider_type": "antigravity",
"expires_at": now + 3600,
"refresh_token": "rt-1",
"project_id": "project-1",
}
endpoint = SimpleNamespace(api_format="gemini:cli")
key = SimpleNamespace(
id="k1",
auth_type="oauth",
api_key="enc_access",
auth_config="enc_cfg",
provider=None,
)
def _decrypt(v): # noqa: ANN001
if v == "enc_access":
return "access-token"
if v == "enc_cfg":
return json.dumps(token_meta)
return ""
with patch("src.api.handlers.base.request_builder.crypto_service.decrypt", side_effect=_decrypt):
auth = await get_provider_auth(endpoint, key) # type: ignore[arg-type]
assert auth is not None
assert auth.auth_header == "Authorization"
assert auth.auth_value == "Bearer access-token"
assert auth.decrypted_auth_config == token_meta

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from src.services.provider.stream_policy import (
UpstreamStreamPolicy,
enforce_stream_mode_for_upstream,
get_upstream_stream_policy,
)
@dataclass
class _DummyEndpoint:
api_format: str
config: dict | None = None
provider: object | None = None
def test_get_upstream_stream_policy_defaults_to_auto() -> None:
ep = _DummyEndpoint(api_format="openai:chat", config=None, provider=SimpleNamespace(provider_type="custom"))
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.AUTO
def test_get_upstream_stream_policy_codex_openai_cli_forces_stream() -> None:
ep = _DummyEndpoint(
api_format="openai:cli",
config=None,
provider=SimpleNamespace(provider_type="codex"),
)
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.FORCE_STREAM
def test_get_upstream_stream_policy_codex_ignores_force_non_stream_config() -> None:
ep = _DummyEndpoint(
api_format="openai:cli",
config={"upstream_stream_policy": "force_non_stream"},
provider=SimpleNamespace(provider_type="codex"),
)
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.FORCE_STREAM
def test_enforce_stream_mode_for_upstream_openai_chat_sets_stream_options_usage() -> None:
body = {"stream": False}
out = enforce_stream_mode_for_upstream(
body,
provider_api_format="openai:chat",
upstream_is_stream=True,
)
assert out["stream"] is True
assert out["stream_options"]["include_usage"] is True
def test_enforce_stream_mode_for_upstream_gemini_drops_stream_field() -> None:
body = {"stream": True, "foo": "bar"}
out = enforce_stream_mode_for_upstream(
body,
provider_api_format="gemini:chat",
upstream_is_stream=False,
)
assert "stream" not in out
assert out["foo"] == "bar"