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