mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
refactor: Antigravity/Codex 服务重构为插件化适配器架构
- 将 Antigravity 和 Codex 从独立模块迁移至 src/services/provider/adapters/ 插件体系 - 新增 provider_types 和 oauth_token 模块,移除 maintenance_scheduler 中的 OAuth 定时刷新 - 增强 admin API:扩展 keys 和 provider_query 端点,新增 dashboard 路由 - 大幅增强 ProviderDetailDrawer 组件,新增 AntigravityQuotaDialog - 改进 handler 基类(chat/cli)和错误分类器 - 优化 fetch_scheduler 和 upstream_fetcher - 前端 UI 组件清理和优化 - 更新测试以匹配新模块结构
This commit is contained in:
@@ -6,31 +6,154 @@ 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
|
||||
from src.services.provider.adapters.antigravity.client import (
|
||||
fetch_available_models,
|
||||
load_code_assist,
|
||||
parse_retry_delay,
|
||||
)
|
||||
from src.services.provider.adapters.antigravity.constants import (
|
||||
DAILY_BASE_URL,
|
||||
PROD_BASE_URL,
|
||||
SANDBOX_BASE_URL,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_code_assist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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"})
|
||||
async def test_load_code_assist_falls_back_on_500() -> None:
|
||||
"""500 时 fallback 到下一个 URL(Sandbox → Daily → Prod 顺序)。"""
|
||||
resp_fail = httpx.Response(500, json={"error": {"message": "boom"}})
|
||||
resp_ok = httpx.Response(200, json={"cloudaicompanionProject": "project-1"})
|
||||
|
||||
client = SimpleNamespace(post=AsyncMock(side_effect=[resp1, resp2]))
|
||||
client = SimpleNamespace(post=AsyncMock(side_effect=[resp_fail, resp_ok]))
|
||||
|
||||
with patch(
|
||||
"src.clients.http_client.HTTPClientPool.get_proxy_client",
|
||||
AsyncMock(return_value=client),
|
||||
with (
|
||||
patch(
|
||||
"src.clients.http_client.HTTPClientPool.get_proxy_client",
|
||||
AsyncMock(return_value=client),
|
||||
),
|
||||
patch(
|
||||
"src.services.provider.adapters.antigravity.client.url_availability.get_ordered_urls",
|
||||
return_value=[SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL],
|
||||
),
|
||||
):
|
||||
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[0].args[0] == f"{SANDBOX_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_4xx_does_not_fallback() -> None:
|
||||
"""401/403 等 4xx 客户端错误不应 fallback,直接抛出。"""
|
||||
resp_401 = httpx.Response(401, json={"error": "unauthorized"}, text="unauthorized")
|
||||
|
||||
client = SimpleNamespace(post=AsyncMock(return_value=resp_401))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.clients.http_client.HTTPClientPool.get_proxy_client",
|
||||
AsyncMock(return_value=client),
|
||||
),
|
||||
patch(
|
||||
"src.services.provider.adapters.antigravity.client.url_availability.get_ordered_urls",
|
||||
return_value=[SANDBOX_BASE_URL, DAILY_BASE_URL],
|
||||
),
|
||||
pytest.raises(RuntimeError, match="status=401"),
|
||||
):
|
||||
await load_code_assist("tok", proxy_config=None, timeout_seconds=1.0)
|
||||
|
||||
# 只调用了一次(没有 fallback 到第二个 URL)
|
||||
assert client.post.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_code_assist_requires_token() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
await load_code_assist("", proxy_config=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_available_models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_available_models_falls_back_on_500() -> None:
|
||||
resp_fail = httpx.Response(500, json={"error": {"message": "boom"}})
|
||||
resp_ok = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"models": {
|
||||
"claude-sonnet-4": {
|
||||
"displayName": "Claude Sonnet 4",
|
||||
"quotaInfo": {"remainingFraction": 0.75, "resetTime": "2024-01-15T12:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
client = SimpleNamespace(post=AsyncMock(side_effect=[resp_fail, resp_ok]))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.clients.http_client.HTTPClientPool.get_proxy_client",
|
||||
AsyncMock(return_value=client),
|
||||
),
|
||||
patch(
|
||||
"src.services.provider.adapters.antigravity.client.url_availability.get_ordered_urls",
|
||||
return_value=[DAILY_BASE_URL, PROD_BASE_URL],
|
||||
),
|
||||
):
|
||||
data = await fetch_available_models(
|
||||
"tok",
|
||||
project_id="project-1",
|
||||
proxy_config=None,
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
|
||||
assert "models" in data
|
||||
assert client.post.await_count == 2
|
||||
assert (
|
||||
client.post.call_args_list[0].args[0] == f"{DAILY_BASE_URL}/v1internal:fetchAvailableModels"
|
||||
)
|
||||
assert (
|
||||
client.post.call_args_list[1].args[0] == f"{PROD_BASE_URL}/v1internal:fetchAvailableModels"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_available_models_requires_project_id() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
await fetch_available_models("tok", project_id="", proxy_config=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_retry_delay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_retry_delay_from_retry_info() -> None:
|
||||
error_json = '{"error": {"details": [{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "1.5s"}]}}'
|
||||
delay = parse_retry_delay(error_json)
|
||||
assert delay is not None
|
||||
# 1500ms + 200ms buffer = 1700ms = 1.7s
|
||||
assert 1.5 < delay < 2.0
|
||||
|
||||
|
||||
def test_parse_retry_delay_from_quota_reset() -> None:
|
||||
error_json = '{"error": {"details": [{"metadata": {"quotaResetDelay": "200ms"}}]}}'
|
||||
delay = parse_retry_delay(error_json)
|
||||
assert delay is not None
|
||||
assert 0.3 < delay < 0.5
|
||||
|
||||
|
||||
def test_parse_retry_delay_invalid() -> None:
|
||||
assert parse_retry_delay("not json") is None
|
||||
assert parse_retry_delay("{}") is None
|
||||
assert parse_retry_delay('{"error": {}}') is None
|
||||
|
||||
@@ -1,35 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
|
||||
from src.services.antigravity.signature_cache import ThinkingSignatureCache
|
||||
from src.services.provider.adapters.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
|
||||
from src.services.provider.adapters.antigravity.signature_cache import ThinkingSignatureCache
|
||||
|
||||
# 测试用签名(需 >= MIN_SIGNATURE_LENGTH=50)
|
||||
_SIG_A = "a" * 60
|
||||
_SIG_B = "b" * 60
|
||||
_SIG_C = "c" * 60
|
||||
_SIG_D = "d" * 60
|
||||
_SIG_E = "e" * 60
|
||||
|
||||
|
||||
def test_get_or_dummy_returns_dummy_for_gemini_models() -> None:
|
||||
cache = ThinkingSignatureCache(maxsize=10)
|
||||
cache = ThinkingSignatureCache()
|
||||
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)
|
||||
cache = ThinkingSignatureCache()
|
||||
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"
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache("gemini-3-pro", "thinking-text", _SIG_A)
|
||||
assert cache.get_or_dummy("gemini-3-pro", "thinking-text") == _SIG_A
|
||||
|
||||
|
||||
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")
|
||||
def test_short_signature_ignored() -> None:
|
||||
"""短于 MIN_SIGNATURE_LENGTH 的签名不会被缓存。"""
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache("gemini-3-pro", "text", "short")
|
||||
# 未命中缓存,回退到 DUMMY
|
||||
assert cache.get_or_dummy("gemini-3-pro", "text") == DUMMY_THOUGHT_SIGNATURE
|
||||
|
||||
# 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
|
||||
# ===== Layer 1: Tool Signatures =====
|
||||
|
||||
|
||||
def test_tool_signature_cache() -> None:
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache_tool_signature("toolu_123", _SIG_A)
|
||||
assert cache.get_tool_signature("toolu_123") == _SIG_A
|
||||
assert cache.get_tool_signature("toolu_999") is None
|
||||
|
||||
|
||||
def test_tool_signature_short_ignored() -> None:
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache_tool_signature("toolu_123", "short")
|
||||
assert cache.get_tool_signature("toolu_123") is None
|
||||
|
||||
|
||||
# ===== Layer 2: Thinking Families =====
|
||||
|
||||
|
||||
def test_thinking_family_cache() -> None:
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache_thinking_family(_SIG_A, "claude-3-5-sonnet")
|
||||
assert cache.get_signature_family(_SIG_A) == "claude-3-5-sonnet"
|
||||
assert cache.get_signature_family(_SIG_B) is None
|
||||
|
||||
|
||||
# ===== Layer 3: Session Signatures =====
|
||||
|
||||
|
||||
def test_session_signature_basic() -> None:
|
||||
cache = ThinkingSignatureCache()
|
||||
assert cache.get_session_signature("sid-test") is None
|
||||
|
||||
cache.cache_session_signature("sid-test", _SIG_A, 5)
|
||||
assert cache.get_session_signature("sid-test") == _SIG_A
|
||||
|
||||
|
||||
def test_session_signature_longer_replaces_same_count() -> None:
|
||||
"""同一 message_count 下,更长的签名替换更短的。"""
|
||||
cache = ThinkingSignatureCache()
|
||||
sig_short = "x" * 60
|
||||
sig_long = "y" * 80
|
||||
|
||||
cache.cache_session_signature("sid-1", sig_short, 5)
|
||||
cache.cache_session_signature("sid-1", sig_long, 5)
|
||||
assert cache.get_session_signature("sid-1") == sig_long
|
||||
|
||||
# 更短的不会替换
|
||||
cache.cache_session_signature("sid-1", sig_short, 5)
|
||||
assert cache.get_session_signature("sid-1") == sig_long
|
||||
|
||||
|
||||
def test_session_signature_rewind_detection() -> None:
|
||||
"""Rewind: message_count 减少时强制更新签名。"""
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache_session_signature("sid-1", _SIG_A, 10)
|
||||
assert cache.get_session_signature("sid-1") == _SIG_A
|
||||
|
||||
# message_count 3 < 10 → rewind detected, force update
|
||||
cache.cache_session_signature("sid-1", _SIG_B, 3)
|
||||
assert cache.get_session_signature("sid-1") == _SIG_B
|
||||
|
||||
|
||||
def test_session_signature_short_ignored() -> None:
|
||||
"""短签名即使 rewind 也不会被缓存。"""
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache_session_signature("sid-1", _SIG_A, 5)
|
||||
cache.cache_session_signature("sid-1", "short", 1)
|
||||
assert cache.get_session_signature("sid-1") == _SIG_A
|
||||
|
||||
|
||||
def test_session_isolation() -> None:
|
||||
"""不同 session 之间互相隔离。"""
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache_session_signature("sid-1", _SIG_A, 1)
|
||||
assert cache.get_session_signature("sid-1") == _SIG_A
|
||||
assert cache.get_session_signature("sid-2") is None
|
||||
|
||||
|
||||
# ===== Clear =====
|
||||
|
||||
|
||||
def test_clear_all_layers() -> None:
|
||||
cache = ThinkingSignatureCache()
|
||||
cache.cache_tool_signature("tool-1", _SIG_A)
|
||||
cache.cache_thinking_family(_SIG_A, "claude")
|
||||
cache.cache_session_signature("sid-1", _SIG_B, 1)
|
||||
cache.cache("gemini-3-pro", "text", _SIG_C)
|
||||
|
||||
cache.clear()
|
||||
|
||||
assert cache.get_tool_signature("tool-1") is None
|
||||
assert cache.get_signature_family(_SIG_A) is None
|
||||
assert cache.get_session_signature("sid-1") is None
|
||||
# Legacy layer: 回退到 DUMMY
|
||||
assert cache.get_or_dummy("gemini-3-pro", "text") == DUMMY_THOUGHT_SIGNATURE
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
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
|
||||
from src.services.provider.adapters.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
|
||||
from src.services.provider.adapters.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]
|
||||
signature_cache.clear()
|
||||
|
||||
|
||||
def test_antigravity_converts_claude_thinking_block_to_gemini_thought_part_prefers_payload_sig() -> None:
|
||||
def test_antigravity_converts_claude_thinking_block_to_gemini_thought_part_prefers_payload_sig() -> (
|
||||
None
|
||||
):
|
||||
_reset_sig_cache()
|
||||
|
||||
req = {
|
||||
@@ -115,4 +117,3 @@ def test_antigravity_inserts_dummy_thought_for_last_assistant_when_thinking_enab
|
||||
assert parts[0]["thought"] is True
|
||||
assert parts[0]["thoughtSignature"] == DUMMY_THOUGHT_SIGNATURE
|
||||
assert parts[1]["text"] == "prefill"
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import src.services.antigravity.url_availability as ua_mod
|
||||
from src.services.antigravity.constants import (
|
||||
from typing import Any
|
||||
|
||||
import src.services.provider.adapters.antigravity.url_availability as ua_mod
|
||||
from src.services.provider.adapters.antigravity.constants import (
|
||||
DAILY_BASE_URL,
|
||||
PROD_BASE_URL,
|
||||
SANDBOX_BASE_URL,
|
||||
URL_UNAVAILABLE_TTL_SECONDS,
|
||||
)
|
||||
from src.services.antigravity.url_availability import url_availability
|
||||
from src.services.provider.adapters.antigravity.url_availability import url_availability
|
||||
|
||||
|
||||
def _reset_state() -> None:
|
||||
@@ -31,11 +34,13 @@ def test_mark_unavailable_filters_url() -> None:
|
||||
url_availability.mark_unavailable(DAILY_BASE_URL)
|
||||
ordered = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
|
||||
assert ordered[0] == PROD_BASE_URL
|
||||
# Sandbox → Prod(Daily 被标记为不可用,应被过滤掉)
|
||||
assert ordered[0] == SANDBOX_BASE_URL
|
||||
assert DAILY_BASE_URL not in ordered
|
||||
assert url_availability.is_available(DAILY_BASE_URL) is False
|
||||
|
||||
|
||||
def test_ttl_recovery(monkeypatch) -> None:
|
||||
def test_ttl_recovery(monkeypatch: Any) -> None:
|
||||
_reset_state()
|
||||
|
||||
t0 = 1000.0
|
||||
@@ -50,9 +55,10 @@ def test_ttl_recovery(monkeypatch) -> None:
|
||||
def test_all_unavailable_fallback_returns_base_order() -> None:
|
||||
_reset_state()
|
||||
|
||||
url_availability.mark_unavailable(PROD_BASE_URL)
|
||||
url_availability.mark_unavailable(SANDBOX_BASE_URL)
|
||||
url_availability.mark_unavailable(DAILY_BASE_URL)
|
||||
url_availability.mark_unavailable(PROD_BASE_URL)
|
||||
|
||||
ordered = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
assert ordered == [DAILY_BASE_URL, PROD_BASE_URL]
|
||||
|
||||
# 全部不可用时仍返回基础顺序(允许继续尝试,等 TTL 恢复)
|
||||
assert ordered == [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
|
||||
|
||||
Reference in New Issue
Block a user