Skip to content

Commit 1be3817

Browse files
authored
fix(antigravity): 修复 v1internal 模式检测逻辑并新增 E2E 测试; (#234)
* fix(antigravity): 修复 v1internal 模式 base_url 路径重复问题,重构模式检测逻辑; - 初始化时去除 base_url 的 /v1internal 后缀,避免 httpx 拼接时路径重复 - 新增 _v1internal_enabled 标志位,简化 v1internal 模式检测逻辑 - 修复端点路径为 /v1internal:generateContent 完整格式 - project_id 自动发现仅在标准 GLA 模式下触发,v1internal 模式跳过 - 同步更新单元测试断言,与重构后逻辑对齐 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com> * test(e2e): 新增 Antigravity 端到端测试,覆盖 Token、Vendor 和 HTTP 层; - 新增 tests/e2e/ 目录及 e2e pytest marker - 默认测试运行排除 e2e 标记(-m 'not e2e'),需显式指定运行 - test_e2e_token.py: Token 获取与刷新流程验证 - test_e2e_vendor.py: Vendor 初始化与消息发送验证 - test_e2e_http.py: HTTP 请求/响应全链路验证 - conftest.py: 共享 fixture(配置加载、客户端初始化) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com> * fix(antigravity): 修复 _prepare_request 中 v1internal 模式 project_id 发现守卫误判; 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com> * style(e2e): 删除 test_e2e_http.py 中无用的 if True: pass 代码块; 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com>
1 parent 31d2e06 commit 1be3817

8 files changed

Lines changed: 929 additions & 26 deletions

File tree

‎pyproject.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ docstring-code-format = true
8484
[tool.pytest.ini_options]
8585
asyncio_mode = "auto"
8686
testpaths = ["tests"]
87-
addopts = "-v --tb=short"
87+
addopts = "-v --tb=short -m 'not e2e'"
88+
markers = [
89+
"e2e: marks tests as end-to-end (deselect with '-m \"not e2e\"')",
90+
]
8891
filterwarnings = [
8992
"ignore::DeprecationWarning",
9093
]

‎src/coding/proxy/vendors/antigravity.py‎

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,22 @@ def __init__(
141141
config.refresh_token,
142142
)
143143
TokenBackendMixin.__init__(self, token_manager)
144-
BaseVendor.__init__(self, config.base_url, config.timeout_ms, failover_config)
144+
# v1internal 模式:base_url 需要去除 /v1internal 路径后缀,
145+
# 因为 endpoint 使用完整路径 /v1internal:generateContent(冒号格式)。
146+
# httpx 会将 base_url path 与 endpoint path 拼接,
147+
# 如果 base_url 含 /v1internal 会导致路径重复。
148+
init_base_url = config.base_url
149+
if init_base_url.rstrip("/").endswith("/v1internal"):
150+
init_base_url = init_base_url.rstrip("/").removesuffix("/v1internal")
151+
BaseVendor.__init__(self, init_base_url, config.timeout_ms, failover_config)
145152
self._model_endpoint = config.model_endpoint
146153
self._model_mapper = model_mapper
147154
self._default_model = config.model_endpoint.removeprefix("models/")
148155
self._last_request_adaptations: list[str] = []
149156
self._safety_settings = config.safety_settings
150157
# v1internal 协议字段
151158
self._project_id: str = config.project_id
159+
self._v1internal_enabled: bool = "v1internal" in config.base_url
152160
self._session_id: str = uuid.uuid4().hex[:16]
153161
self._message_count: int = 0
154162
# project_id 自动发现状态
@@ -159,8 +167,11 @@ def get_name(self) -> str:
159167
return "antigravity"
160168

161169
def _is_v1internal_mode(self) -> bool:
162-
"""检测是否启用 v1internal 协议模式(与 Antigravity-Manager 对齐)."""
163-
return bool(self._effective_project_id) and "v1internal" in self._base_url
170+
"""检测是否启用 v1internal 协议模式(与 Antigravity-Manager 对齐).
171+
172+
v1internal 协议由原始配置的 base_url 路径或 project_id 自动发现触发。
173+
"""
174+
return self._v1internal_enabled
164175

165176
@property
166177
def _effective_project_id(self) -> str:
@@ -229,7 +240,11 @@ async def _discover_project_id(self, access_token: str) -> str:
229240
return ""
230241

231242
# 发现成功:原子性切换到 v1internal 模式
232-
self._base_url = _V1INTERNAL_BASE_URL
243+
# base_url 只保留域名部分(去除 /v1internal 路径后缀)
244+
self._base_url = _V1INTERNAL_BASE_URL.rstrip("/").removesuffix(
245+
"/v1internal"
246+
)
247+
self._v1internal_enabled = True
233248
self._project_id_discovered = project_id
234249

235250
# 重建 HTTP 客户端(base_url 是初始化参数)
@@ -339,8 +354,13 @@ async def _prepare_request(
339354
self._last_request_adaptations = converted.adaptations
340355
token = await self._token_manager.get_token()
341356

342-
# 懒加载:未配置 project_id 时自动发现并切换 v1internal 模式
343-
if not self._project_id and not self._project_discovery_attempted:
357+
# 懒加载:未配置 project_id 时尝试自动发现(仅标准 GLA 模式需要)
358+
# v1internal 模式不依赖 project_id,跳过发现
359+
if (
360+
not self._project_id
361+
and not self._project_discovery_attempted
362+
and not self._v1internal_enabled
363+
):
344364
discovered = await self._discover_project_id(token)
345365
if discovered:
346366
logger.info(
@@ -450,11 +470,11 @@ async def send_message(
450470
body, prepared_headers = await self._prepare_request(request_body, headers)
451471
client = self._get_client()
452472
resolved_model = self._last_resolved_model
453-
endpoint = (
454-
":generateContent"
455-
if self._is_v1internal_mode()
456-
else f"/models/{resolved_model}:generateContent"
457-
)
473+
if self._is_v1internal_mode():
474+
# v1internal 端点需要完整路径(冒号格式)覆盖 base_url 的 path 部分
475+
endpoint = "/v1internal:generateContent"
476+
else:
477+
endpoint = f"/models/{resolved_model}:generateContent"
458478

459479
logger.debug("send_message: POST %s", endpoint)
460480
response = await client.post(endpoint, json=body, headers=prepared_headers)
@@ -496,11 +516,10 @@ async def send_message_stream(
496516
body, prepared_headers = await self._prepare_request(request_body, headers)
497517
client = self._get_client()
498518
resolved_model = self._last_resolved_model
499-
endpoint = (
500-
":streamGenerateContent?alt=sse"
501-
if self._is_v1internal_mode()
502-
else f"/models/{resolved_model}:streamGenerateContent?alt=sse"
503-
)
519+
if self._is_v1internal_mode():
520+
endpoint = "/v1internal:streamGenerateContent?alt=sse"
521+
else:
522+
endpoint = f"/models/{resolved_model}:streamGenerateContent?alt=sse"
504523

505524
logger.debug("send_message_stream: POST %s", endpoint)
506525

‎tests/e2e/__init__.py‎

Whitespace-only changes.

‎tests/e2e/conftest.py‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""E2E 集成测试共享 fixtures — Antigravity 真实凭证加载与测试对象构建."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from typing import Any
7+
8+
import pytest
9+
10+
# ── 模块级门控:未设置环境变量时跳过整个 e2e 包 ──
11+
12+
_SKIP_REASON = "Set RUN_ANTIGRAVITY_E2E=1 to enable Antigravity E2E tests"
13+
14+
15+
def pytest_configure(config: pytest.Config) -> None:
16+
config.addinivalue_line(
17+
"markers", "e2e: End-to-end tests requiring real Antigravity credentials"
18+
)
19+
20+
21+
def _load_real_credentials() -> dict[str, str] | None:
22+
"""从 ~/.coding-proxy/ 加载真实的 Google OAuth 凭证."""
23+
from coding.proxy.auth.providers.google import (
24+
_DEFAULT_CLIENT_ID,
25+
_DEFAULT_CLIENT_SECRET,
26+
)
27+
from coding.proxy.auth.store import TokenStoreManager
28+
from coding.proxy.config.loader import load_config
29+
30+
try:
31+
token_store = TokenStoreManager()
32+
token_store.load()
33+
google_tokens = token_store.get("google")
34+
if not google_tokens.refresh_token:
35+
return None
36+
37+
config = load_config()
38+
39+
# 从 vendors 列表查找 antigravity 配置
40+
client_id = ""
41+
client_secret = ""
42+
base_url = ""
43+
model_endpoint = "models/claude-sonnet-4-20250514"
44+
project_id = ""
45+
46+
for vc in config.vendors:
47+
if vc.vendor == "antigravity":
48+
client_id = vc.client_id or _DEFAULT_CLIENT_ID
49+
client_secret = vc.client_secret or _DEFAULT_CLIENT_SECRET
50+
base_url = (
51+
vc.base_url or "https://generativelanguage.googleapis.com/v1beta"
52+
)
53+
model_endpoint = vc.model_endpoint or model_endpoint
54+
break
55+
56+
# 优先使用 config.yaml 中的 refresh_token,否则使用 token store
57+
refresh_token = ""
58+
for vc in config.vendors:
59+
if vc.vendor == "antigravity" and vc.refresh_token:
60+
refresh_token = vc.refresh_token
61+
break
62+
if not refresh_token:
63+
refresh_token = google_tokens.refresh_token
64+
65+
return {
66+
"client_id": client_id,
67+
"client_secret": client_secret,
68+
"refresh_token": refresh_token,
69+
"base_url": base_url,
70+
"model_endpoint": model_endpoint,
71+
"project_id": project_id,
72+
}
73+
except Exception:
74+
return None
75+
76+
77+
# ── Fixtures ──
78+
79+
80+
@pytest.fixture(scope="session")
81+
def e2e_credentials() -> dict[str, str]:
82+
"""加载真实 Antigravity OAuth 凭证,失败则跳过."""
83+
if os.environ.get("RUN_ANTIGRAVITY_E2E") != "1":
84+
pytest.skip(_SKIP_REASON)
85+
creds = _load_real_credentials()
86+
if creds is None:
87+
pytest.skip("No valid Antigravity credentials found in ~/.coding-proxy/")
88+
return creds
89+
90+
91+
@pytest.fixture(scope="session")
92+
def antigravity_config(e2e_credentials: dict[str, str]) -> Any:
93+
"""构建标准 GLA 模式的 AntigravityConfig."""
94+
from coding.proxy.config.vendors import AntigravityConfig
95+
96+
return AntigravityConfig(
97+
enabled=True,
98+
client_id=e2e_credentials["client_id"],
99+
client_secret=e2e_credentials["client_secret"],
100+
refresh_token=e2e_credentials["refresh_token"],
101+
base_url=e2e_credentials["base_url"],
102+
model_endpoint=e2e_credentials["model_endpoint"],
103+
timeout_ms=60000,
104+
)
105+
106+
107+
@pytest.fixture(scope="session")
108+
def antigravity_config_v1internal(e2e_credentials: dict[str, str]) -> Any:
109+
"""构建 v1internal 模式的 AntigravityConfig(无 project_id,触发自动发现)."""
110+
from coding.proxy.config.vendors import AntigravityConfig
111+
112+
return AntigravityConfig(
113+
enabled=True,
114+
client_id=e2e_credentials["client_id"],
115+
client_secret=e2e_credentials["client_secret"],
116+
refresh_token=e2e_credentials["refresh_token"],
117+
base_url="https://cloudcode-pa.googleapis.com/v1internal",
118+
model_endpoint=e2e_credentials["model_endpoint"],
119+
timeout_ms=60000,
120+
)
121+
122+
123+
@pytest.fixture
124+
async def antigravity_vendor(antigravity_config: Any) -> Any:
125+
"""构建标准 GLA 模式的 AntigravityVendor(function scope,每次测试独立)."""
126+
from coding.proxy.config.schema import FailoverConfig
127+
from coding.proxy.routing.model_mapper import ModelMapper
128+
from coding.proxy.vendors.antigravity import AntigravityVendor
129+
130+
vendor = AntigravityVendor(antigravity_config, FailoverConfig(), ModelMapper([]))
131+
yield vendor
132+
await vendor.close()
133+
134+
135+
@pytest.fixture
136+
async def antigravity_vendor_v1internal(antigravity_config_v1internal: Any) -> Any:
137+
"""构建 v1internal 模式的 AntigravityVendor."""
138+
from coding.proxy.config.schema import FailoverConfig
139+
from coding.proxy.routing.model_mapper import ModelMapper
140+
from coding.proxy.vendors.antigravity import AntigravityVendor
141+
142+
vendor = AntigravityVendor(
143+
antigravity_config_v1internal, FailoverConfig(), ModelMapper([])
144+
)
145+
yield vendor
146+
await vendor.close()
147+
148+
149+
@pytest.fixture
150+
def minimal_request_body() -> dict[str, Any]:
151+
"""最小 Anthropic 格式请求体(用于最小化 token 消耗)."""
152+
return {
153+
"model": "claude-sonnet-4-20250514",
154+
"messages": [{"role": "user", "content": "Say exactly: pong"}],
155+
"max_tokens": 32,
156+
}
157+
158+
159+
@pytest.fixture(scope="session")
160+
def e2e_app(e2e_credentials: dict[str, str]) -> Any:
161+
"""构建仅启用 Antigravity 的 FastAPI 应用(临时 DB)."""
162+
import tempfile
163+
164+
from coding.proxy.config.schema import ProxyConfig
165+
from coding.proxy.server.app import create_app
166+
167+
tmpdir = tempfile.mkdtemp(prefix="e2e-antigravity-")
168+
db_path = os.path.join(tmpdir, "usage.db")
169+
compat_path = os.path.join(tmpdir, "compat.db")
170+
171+
config = ProxyConfig(
172+
vendors=[
173+
{
174+
"vendor": "antigravity",
175+
"enabled": True,
176+
"client_id": e2e_credentials["client_id"],
177+
"client_secret": e2e_credentials["client_secret"],
178+
"refresh_token": e2e_credentials["refresh_token"],
179+
"base_url": "https://cloudcode-pa.googleapis.com/v1internal",
180+
"model_endpoint": e2e_credentials["model_endpoint"],
181+
"timeout_ms": 60000,
182+
},
183+
],
184+
tiers=["antigravity"],
185+
database={"path": db_path, "compat_state_path": compat_path},
186+
)
187+
return create_app(config)
188+
189+
190+
@pytest.fixture
191+
async def e2e_client(e2e_app: Any) -> Any:
192+
"""构建异步 HTTP 客户端(支持 SSE 流式测试)."""
193+
import httpx
194+
195+
transport = httpx.ASGITransport(app=e2e_app)
196+
async with httpx.AsyncClient(
197+
transport=transport, base_url="http://test", timeout=60.0
198+
) as client:
199+
yield client

0 commit comments

Comments
 (0)