mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: Antigravity 和 Codex 服务支持
- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理 - 新增 Codex 服务:信封处理、元数据收集器 - 重构 provider transport 支持新的服务架构 - 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应 - 优化 OAuth 工具函数 - 添加相关测试用例
This commit is contained in:
201
tests/api/handlers/base/test_antigravity_v1internal.py
Normal file
201
tests/api/handlers/base/test_antigravity_v1internal.py
Normal 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)
|
||||
38
tests/core/test_provider_oauth_utils_antigravity.py
Normal file
38
tests/core/test_provider_oauth_utils_antigravity.py
Normal 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"
|
||||
|
||||
36
tests/services/antigravity/test_client.py
Normal file
36
tests/services/antigravity/test_client.py
Normal 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)
|
||||
|
||||
35
tests/services/antigravity/test_signature_cache.py
Normal file
35
tests/services/antigravity/test_signature_cache.py
Normal 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
|
||||
|
||||
118
tests/services/antigravity/test_thinking_signature_conversion.py
Normal file
118
tests/services/antigravity/test_thinking_signature_conversion.py
Normal 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"
|
||||
|
||||
58
tests/services/antigravity/test_url_availability.py
Normal file
58
tests/services/antigravity/test_url_availability.py
Normal 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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
56
tests/services/test_provider_transport_antigravity.py
Normal file
56
tests/services/test_provider_transport_antigravity.py
Normal 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
|
||||
|
||||
47
tests/services/test_provider_transport_codex.py
Normal file
47
tests/services/test_provider_transport_codex.py
Normal 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"
|
||||
|
||||
46
tests/services/test_request_builder_oauth.py
Normal file
46
tests/services/test_request_builder_oauth.py
Normal 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
|
||||
|
||||
63
tests/services/test_upstream_stream_policy.py
Normal file
63
tests/services/test_upstream_stream_policy.py
Normal 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"
|
||||
|
||||
Reference in New Issue
Block a user