mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Antigravity integration package."""
|
||||
@@ -0,0 +1,627 @@
|
||||
"""Antigravity API 客户端(与 Antigravity-Manager 对齐)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
from src.core.logger import logger
|
||||
from src.services.provider.adapters.antigravity.constants import (
|
||||
DAILY_BASE_URL,
|
||||
PROD_BASE_URL,
|
||||
SANDBOX_BASE_URL,
|
||||
VERSION_FETCH_URL,
|
||||
get_v1internal_extra_headers,
|
||||
parse_version_string,
|
||||
update_user_agent_version,
|
||||
)
|
||||
from src.services.provider.adapters.antigravity.rust_http import (
|
||||
execute_antigravity_rust_http_request,
|
||||
)
|
||||
from src.services.provider.adapters.antigravity.url_availability import url_availability
|
||||
|
||||
# loadCodeAssist 请求体 metadata
|
||||
_CODE_ASSIST_METADATA = {
|
||||
"ideType": "ANTIGRAVITY",
|
||||
}
|
||||
|
||||
# Duration 解析正则(与 AM 的 retry.rs 对齐)
|
||||
_DURATION_RE = re.compile(r"([\d.]+)\s*(ms|s|m|h)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry-After / Duration 解析工具(对齐 AM upstream/retry.rs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_duration_ms(duration_str: str) -> int | None:
|
||||
"""解析 Duration 字符串 (e.g. '1.5s', '200ms', '1h16m0.667s'),返回毫秒。"""
|
||||
total_ms = 0.0
|
||||
matched = False
|
||||
for m in _DURATION_RE.finditer(duration_str):
|
||||
matched = True
|
||||
value = float(m.group(1))
|
||||
unit = m.group(2)
|
||||
if unit == "ms":
|
||||
total_ms += value
|
||||
elif unit == "s":
|
||||
total_ms += value * 1000
|
||||
elif unit == "m":
|
||||
total_ms += value * 60_000
|
||||
elif unit == "h":
|
||||
total_ms += value * 3_600_000
|
||||
return round(total_ms) if matched else None
|
||||
|
||||
|
||||
def parse_retry_delay(error_body: str | bytes | dict[str, Any]) -> float | None:
|
||||
"""从 429 错误响应体中提取 retry delay(秒)。
|
||||
|
||||
支持两种格式(与 AM 对齐):
|
||||
1. error.details[].@type="...RetryInfo" → retryDelay
|
||||
2. error.details[].metadata.quotaResetDelay
|
||||
"""
|
||||
try:
|
||||
data: dict[str, Any]
|
||||
if isinstance(error_body, (str, bytes)):
|
||||
data = json.loads(error_body)
|
||||
elif isinstance(error_body, dict):
|
||||
data = error_body
|
||||
else:
|
||||
return None
|
||||
|
||||
details = data.get("error", {}).get("details", [])
|
||||
if not isinstance(details, list):
|
||||
return None
|
||||
|
||||
# 方式 1: RetryInfo.retryDelay
|
||||
for detail in details:
|
||||
if not isinstance(detail, dict):
|
||||
continue
|
||||
type_str = detail.get("@type", "")
|
||||
if isinstance(type_str, str) and "RetryInfo" in type_str:
|
||||
retry_delay = detail.get("retryDelay")
|
||||
if isinstance(retry_delay, str):
|
||||
ms = _parse_duration_ms(retry_delay)
|
||||
if ms is not None:
|
||||
return min((ms + 200) / 1000.0, 30.0)
|
||||
|
||||
# 方式 2: metadata.quotaResetDelay
|
||||
for detail in details:
|
||||
if not isinstance(detail, dict):
|
||||
continue
|
||||
metadata = detail.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
quota_delay = metadata.get("quotaResetDelay")
|
||||
if isinstance(quota_delay, str):
|
||||
ms = _parse_duration_ms(quota_delay)
|
||||
if ms is not None:
|
||||
return min((ms + 200) / 1000.0, 30.0)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback 判断(对齐 AM upstream/client.rs: should_try_next_endpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _should_fallback_status(status_code: int) -> bool:
|
||||
"""判断是否应该 fallback 到下一个端点。
|
||||
|
||||
与 AM 对齐:429 + 408(超时) + 404 + 所有 5xx。
|
||||
4xx 客户端错误(400/401/403 等)不 fallback——换 URL 也不会成功。
|
||||
"""
|
||||
if status_code == 429:
|
||||
return True
|
||||
if status_code in (404, 408):
|
||||
return True
|
||||
if 500 <= status_code < 600: # noqa: PLR2004
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 账户封禁异常(对齐 AM: is_forbidden 标志)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AntigravityAccountForbiddenException(Exception):
|
||||
"""Antigravity 账户被封禁/禁止访问异常。
|
||||
|
||||
当 API 返回 403 Forbidden 时抛出,表示账户权限被撤销。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "账户访问被禁止",
|
||||
status_code: int = 403,
|
||||
reason: str | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _extract_forbidden_reason(response_text: str) -> str | None:
|
||||
"""从 403 响应体中提取封禁原因。
|
||||
|
||||
尝试解析 JSON 响应中的 error.message 字段。
|
||||
"""
|
||||
if not response_text:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(response_text)
|
||||
if isinstance(data, dict):
|
||||
error = data.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
# 直接在顶层查找 message
|
||||
message = data.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
except Exception:
|
||||
pass
|
||||
# 如果无法解析,返回原始文本的前 100 个字符
|
||||
if len(response_text) > 100:
|
||||
return response_text[:100] + "..."
|
||||
return response_text if response_text.strip() else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 从 loadCodeAssist 响应中提取信息
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_tier_id(data: dict[str, Any]) -> str:
|
||||
"""从 loadCodeAssist 响应中提取 tier ID。
|
||||
|
||||
优先选 allowedTiers 中 isDefault=true 的,fallback 到第一个,最终 fallback 到 "LEGACY"。
|
||||
"""
|
||||
allowed_tiers = data.get("allowedTiers")
|
||||
if not isinstance(allowed_tiers, list):
|
||||
return "LEGACY"
|
||||
|
||||
# 第一轮:找 isDefault
|
||||
for tier in allowed_tiers:
|
||||
if isinstance(tier, dict) and tier.get("isDefault") is True:
|
||||
tier_id = tier.get("id", "")
|
||||
if isinstance(tier_id, str) and tier_id.strip():
|
||||
return tier_id.strip()
|
||||
|
||||
# 第二轮:取第一个有 id 的
|
||||
for tier in allowed_tiers:
|
||||
if isinstance(tier, dict):
|
||||
tier_id = tier.get("id", "")
|
||||
if isinstance(tier_id, str) and tier_id.strip():
|
||||
return tier_id.strip()
|
||||
|
||||
return "LEGACY"
|
||||
|
||||
|
||||
def extract_project_id(data: dict[str, Any]) -> str:
|
||||
"""从响应中提取 project_id,兼容 string 和 {"id": "..."} 两种格式。"""
|
||||
raw = data.get("cloudaicompanionProject")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
if isinstance(raw, dict):
|
||||
pid = raw.get("id", "")
|
||||
if isinstance(pid, str) and pid.strip():
|
||||
return pid.strip()
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 核心 API 客户端函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def load_code_assist(
|
||||
access_token: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> dict[str, Any]:
|
||||
"""调用 /v1internal:loadCodeAssist 获取账户信息。
|
||||
|
||||
对齐 AM project_resolver.rs:Sandbox 优先,避免 Prod 429。
|
||||
|
||||
注意:
|
||||
- email 需通过 Google userinfo API 获取(由 enrich_auth_config 复用已有逻辑)
|
||||
- 这里仅负责 project_id / tier 等信息
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("missing access_token")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
**get_v1internal_extra_headers(),
|
||||
}
|
||||
body = {"metadata": _CODE_ASSIST_METADATA}
|
||||
|
||||
# Sandbox 优先(与 AM 对齐:避免 Prod 429)
|
||||
urls = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
if not urls:
|
||||
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
|
||||
last_exc: Exception | None = None
|
||||
|
||||
for base_url in urls:
|
||||
try:
|
||||
url = f"{base_url}/v1internal:loadCodeAssist"
|
||||
resp = await execute_antigravity_rust_http_request(
|
||||
method="POST",
|
||||
url=url,
|
||||
headers=headers,
|
||||
body=body,
|
||||
proxy_config=proxy_config,
|
||||
request_id=f"antigravity:load-code-assist:{base_url}",
|
||||
provider_api_format="antigravity:load_code_assist",
|
||||
timeout_seconds=timeout_seconds,
|
||||
content_type="application/json",
|
||||
)
|
||||
if resp is None:
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
if 200 <= resp.status_code < 300:
|
||||
url_availability.mark_success(base_url)
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
# 可 fallback 的错误:标记不可用,尝试下一个 URL
|
||||
if _should_fallback_status(resp.status_code):
|
||||
url_availability.mark_unavailable(base_url)
|
||||
|
||||
# 429:尝试解析 Retry-After 并等待
|
||||
if resp.status_code == 429:
|
||||
delay = parse_retry_delay(resp.text)
|
||||
if delay and delay > 0:
|
||||
logger.debug(
|
||||
"[antigravity] loadCodeAssist 429, waiting {:.1f}s before fallback",
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(min(delay, 5.0))
|
||||
|
||||
last_exc = RuntimeError(
|
||||
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 不可 fallback 的 4xx(400/401/403 等):直接抛出,换 URL 也不会成功
|
||||
raise RuntimeError(
|
||||
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url} "
|
||||
f"body={resp.text[:200] if resp.text else ''}"
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
url_availability.mark_unavailable(base_url)
|
||||
last_exc = e
|
||||
continue
|
||||
|
||||
raise last_exc or RuntimeError("loadCodeAssist failed: all endpoints exhausted")
|
||||
|
||||
|
||||
async def onboard_user(
|
||||
access_token: str,
|
||||
tier_id: str = "LEGACY",
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
*,
|
||||
max_attempts: int = 5,
|
||||
poll_interval: float = 2.0,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> str:
|
||||
"""调用 /v1internal:onboardUser 激活账号并获取 project_id。
|
||||
|
||||
当 loadCodeAssist 返回 allowedTiers 但没有 cloudaicompanionProject 时,
|
||||
需要先通过 onboardUser 选择 tier 来分配 project。
|
||||
|
||||
改进(对齐 AM):
|
||||
- 使用 url_availability 做多 URL fallback
|
||||
- 轮询期间的临时网络错误会 continue 而非直接终止
|
||||
|
||||
Args:
|
||||
access_token: OAuth access token
|
||||
tier_id: 要选择的 tier ID(从 allowedTiers 中提取)
|
||||
proxy_config: 代理配置
|
||||
max_attempts: 最大轮询次数(onboardUser 是异步操作)
|
||||
poll_interval: 轮询间隔(秒)
|
||||
timeout_seconds: 单次请求超时
|
||||
|
||||
Returns:
|
||||
project_id
|
||||
|
||||
Raises:
|
||||
RuntimeError: 激活失败
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("missing access_token")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
**get_v1internal_extra_headers(),
|
||||
}
|
||||
body = {
|
||||
"tierId": tier_id,
|
||||
"metadata": _CODE_ASSIST_METADATA,
|
||||
}
|
||||
|
||||
# 使用 url_availability 选择端点(与其他函数一致)
|
||||
urls = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
if not urls:
|
||||
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
|
||||
base_url = urls[0]
|
||||
|
||||
logger.info("[antigravity] onboardUser: 开始激活账号, tier={}, endpoint={}", tier_id, base_url)
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
url = f"{base_url}/v1internal:onboardUser"
|
||||
resp = await execute_antigravity_rust_http_request(
|
||||
method="POST",
|
||||
url=url,
|
||||
headers=headers,
|
||||
body=body,
|
||||
proxy_config=proxy_config,
|
||||
request_id=f"antigravity:onboard-user:{tier_id}:{attempt}",
|
||||
provider_api_format="antigravity:onboard_user",
|
||||
timeout_seconds=timeout_seconds,
|
||||
content_type="application/json",
|
||||
)
|
||||
if resp is None:
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
# 标记可用性
|
||||
if _should_fallback_status(resp.status_code):
|
||||
url_availability.mark_unavailable(base_url)
|
||||
raise RuntimeError(
|
||||
f"onboardUser failed: status={resp.status_code}, "
|
||||
f"body={resp.text[:200] if resp.text else ''}"
|
||||
)
|
||||
|
||||
url_availability.mark_success(base_url)
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"onboardUser: unexpected response type: {type(data)}")
|
||||
|
||||
done = data.get("done")
|
||||
if done is True:
|
||||
# 从 response.cloudaicompanionProject 提取 project_id
|
||||
response_data = data.get("response")
|
||||
if isinstance(response_data, dict):
|
||||
project_id = extract_project_id(response_data)
|
||||
if project_id:
|
||||
logger.info(
|
||||
"[antigravity] onboardUser: 激活成功, project_id={}",
|
||||
project_id[:8] + "...",
|
||||
)
|
||||
return project_id
|
||||
|
||||
# done=true 但 project_id 为空(常见:cloudaicompanionProject: {})
|
||||
# 返回空串,由调用方 fallback 到随机 project_id
|
||||
logger.debug("[antigravity] onboardUser: done=true 但 project_id 为空")
|
||||
return ""
|
||||
|
||||
# done != true,继续轮询
|
||||
logger.debug(
|
||||
"[antigravity] onboardUser: 轮询 {}/{}, 等待完成...",
|
||||
attempt,
|
||||
max_attempts,
|
||||
)
|
||||
if attempt < max_attempts:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
# 临时网络错误:记录并继续轮询(而非直接终止)
|
||||
logger.warning(
|
||||
"[antigravity] onboardUser: 轮询 {}/{} 网络错误: {}, 继续重试...",
|
||||
attempt,
|
||||
max_attempts,
|
||||
e,
|
||||
)
|
||||
if attempt < max_attempts:
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"onboardUser request failed after {max_attempts} attempts: {e}"
|
||||
) from e
|
||||
|
||||
raise RuntimeError(f"onboardUser: 超时,已轮询 {max_attempts} 次仍未完成")
|
||||
|
||||
|
||||
async def fetch_available_models(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> dict[str, Any]:
|
||||
"""调用 /v1internal:fetchAvailableModels 获取可用模型(包含配额信息)。
|
||||
|
||||
Antigravity 的此接口会返回类似:
|
||||
{"models": {"claude-sonnet-4": {"displayName": "...", "quotaInfo": {...}}, ...}}
|
||||
|
||||
对齐 AM:Sandbox 优先 + 正确的 fallback 逻辑。
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("missing access_token")
|
||||
if not project_id:
|
||||
raise ValueError("missing project_id")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
**get_v1internal_extra_headers(),
|
||||
}
|
||||
body = {"project": project_id}
|
||||
|
||||
# Sandbox 优先
|
||||
urls = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
if not urls:
|
||||
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
|
||||
last_exc: Exception | None = None
|
||||
|
||||
for base_url in urls:
|
||||
try:
|
||||
url = f"{base_url}/v1internal:fetchAvailableModels"
|
||||
resp = await execute_antigravity_rust_http_request(
|
||||
method="POST",
|
||||
url=url,
|
||||
headers=headers,
|
||||
body=body,
|
||||
proxy_config=proxy_config,
|
||||
request_id=f"antigravity:fetch-available-models:{base_url}",
|
||||
provider_api_format="antigravity:fetch_available_models",
|
||||
timeout_seconds=timeout_seconds,
|
||||
content_type="application/json",
|
||||
)
|
||||
if resp is None:
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
if 200 <= resp.status_code < 300:
|
||||
url_availability.mark_success(base_url)
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
# 可 fallback 的错误
|
||||
if _should_fallback_status(resp.status_code):
|
||||
url_availability.mark_unavailable(base_url)
|
||||
|
||||
# 429:尝试等待
|
||||
if resp.status_code == 429:
|
||||
delay = parse_retry_delay(resp.text)
|
||||
if delay and delay > 0:
|
||||
logger.debug(
|
||||
"[antigravity] fetchAvailableModels 429, waiting {:.1f}s",
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(min(delay, 5.0))
|
||||
|
||||
last_exc = RuntimeError(
|
||||
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 403 Forbidden:账户权限被禁止(对齐 AM is_forbidden 标志)
|
||||
if resp.status_code == 403:
|
||||
reason = _extract_forbidden_reason(resp.text)
|
||||
logger.warning(
|
||||
"[antigravity] fetchAvailableModels 403 Forbidden: {}",
|
||||
reason or "unknown",
|
||||
)
|
||||
raise AntigravityAccountForbiddenException(
|
||||
message="账户访问被禁止",
|
||||
status_code=403,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# 不可 fallback 的 4xx:直接抛出
|
||||
raise RuntimeError(
|
||||
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url} "
|
||||
f"body={resp.text[:200] if resp.text else ''}"
|
||||
)
|
||||
except (RuntimeError, AntigravityAccountForbiddenException):
|
||||
raise
|
||||
except Exception as e:
|
||||
url_availability.mark_unavailable(base_url)
|
||||
last_exc = e
|
||||
continue
|
||||
|
||||
raise last_exc or RuntimeError("fetchAvailableModels failed: all endpoints exhausted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-Agent 版本号动态更新
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def refresh_user_agent(
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout_seconds: float = 5.0,
|
||||
) -> str | None:
|
||||
"""从远程 API 获取最新 Antigravity 版本号并更新 User-Agent。
|
||||
|
||||
对齐 AM constants.rs:
|
||||
1. 尝试 VERSION_FETCH_URL
|
||||
2. Fallback 到 changelog 页面
|
||||
3. 最终 fallback 到 _FALLBACK_VERSION
|
||||
|
||||
Returns:
|
||||
更新后的版本号,或 None(如果获取失败但 fallback 到默认版本)。
|
||||
"""
|
||||
try:
|
||||
client = await HTTPClientPool.get_proxy_client(proxy_config)
|
||||
resp = await client.get(VERSION_FETCH_URL, timeout=timeout_seconds)
|
||||
if 200 <= resp.status_code < 300:
|
||||
version = parse_version_string(resp.text)
|
||||
if version:
|
||||
update_user_agent_version(version)
|
||||
logger.info("[antigravity] User-Agent 版本已更新: {}", version)
|
||||
return version
|
||||
except Exception as e:
|
||||
logger.debug("[antigravity] 获取远程版本失败: {}", e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback Project ID 生成
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ADJECTIVES = ("useful", "bright", "swift", "calm", "bold")
|
||||
_NOUNS = ("fuze", "wave", "spark", "flow", "core")
|
||||
|
||||
|
||||
def generate_fallback_project_id() -> str:
|
||||
"""生成随机 project_id(与 AM project_resolver.rs 的 generateProjectID 一致)。
|
||||
|
||||
格式: {adjective}-{noun}-{5位随机字符(base36)}
|
||||
Antigravity API 不严格校验 project 字段,当所有正常获取途径都失败时用作 fallback。
|
||||
"""
|
||||
adj = random.choice(_ADJECTIVES) # noqa: S311
|
||||
noun = random.choice(_NOUNS) # noqa: S311
|
||||
chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
rand_part = "".join(random.choice(chars) for _ in range(5)) # noqa: S311
|
||||
return f"{adj}-{noun}-{rand_part}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AntigravityAccountForbiddenException",
|
||||
"extract_project_id",
|
||||
"extract_tier_id",
|
||||
"fetch_available_models",
|
||||
"generate_fallback_project_id",
|
||||
"load_code_assist",
|
||||
"onboard_user",
|
||||
"parse_retry_delay",
|
||||
"refresh_user_agent",
|
||||
]
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Antigravity 全局常量定义。
|
||||
|
||||
注意:这里的 PROVIDER_TYPE 指的是 Provider.provider_type(用于路由与特判),
|
||||
不是 endpoint signature(family:kind)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
# ============== API 端点 ==============
|
||||
# 唯一定义在 core 层,此处 re-export 保持向后兼容
|
||||
from src.core.provider_templates.fixed_providers import ANTIGRAVITY_PROD_URL as PROD_BASE_URL
|
||||
|
||||
DAILY_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"
|
||||
SANDBOX_BASE_URL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
|
||||
# ============== User-Agent ==============
|
||||
VERSION_FETCH_URL = "https://antigravity-auto-updater-974169037036.us-central1.run.app"
|
||||
_FALLBACK_VERSION = "1.18.4"
|
||||
_FALLBACK_CHROME = "132.0.6834.160"
|
||||
_FALLBACK_ELECTRON = "39.2.3"
|
||||
_VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
def _detect_platform_info() -> str:
|
||||
"""检测当前运行平台,格式对齐 AM constants.rs 的 Electron UA。"""
|
||||
os_name = platform.system().lower()
|
||||
if os_name == "darwin":
|
||||
return "Macintosh; Intel Mac OS X 10_15_7"
|
||||
elif os_name == "windows":
|
||||
return "Windows NT 10.0; Win64; x64"
|
||||
else:
|
||||
return "X11; Linux x86_64"
|
||||
|
||||
|
||||
_PLATFORM_INFO = _detect_platform_info()
|
||||
|
||||
|
||||
def _build_antigravity_http_user_agent(
|
||||
*,
|
||||
platform_token: str,
|
||||
version: str,
|
||||
chrome_version: str,
|
||||
electron_version: str,
|
||||
) -> str:
|
||||
return (
|
||||
f"Mozilla/5.0 ({platform_token}) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
f"Antigravity/{version} Chrome/{chrome_version} "
|
||||
f"Electron/{electron_version} Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
# HTTP Header User-Agent(对齐 AM constants.rs: 完整 Electron 浏览器格式)
|
||||
HTTP_USER_AGENT = _build_antigravity_http_user_agent(
|
||||
platform_token=_PLATFORM_INFO,
|
||||
version=_FALLBACK_VERSION,
|
||||
chrome_version=_FALLBACK_CHROME,
|
||||
electron_version=_FALLBACK_ELECTRON,
|
||||
)
|
||||
|
||||
# V1InternalRequest.userAgent 字段(固定值)
|
||||
REQUEST_USER_AGENT = "antigravity"
|
||||
|
||||
# --- 动态 User-Agent 支持 ---
|
||||
_ua_lock = threading.Lock()
|
||||
_ua_version: str = _FALLBACK_VERSION
|
||||
|
||||
|
||||
def get_http_user_agent() -> str:
|
||||
"""返回当前 HTTP User-Agent 字符串(对齐 AM Electron UA 格式)。"""
|
||||
with _ua_lock:
|
||||
return _build_antigravity_http_user_agent(
|
||||
platform_token=_PLATFORM_INFO,
|
||||
version=_ua_version,
|
||||
chrome_version=_FALLBACK_CHROME,
|
||||
electron_version=_FALLBACK_ELECTRON,
|
||||
)
|
||||
|
||||
|
||||
def update_user_agent_version(version: str) -> None:
|
||||
"""更新 User-Agent 中的版本号(由 refresh_user_agent 调用)。"""
|
||||
global HTTP_USER_AGENT, _ua_version # noqa: PLW0603
|
||||
version = str(version or "").strip()
|
||||
if not version:
|
||||
return
|
||||
with _ua_lock:
|
||||
_ua_version = version
|
||||
HTTP_USER_AGENT = _build_antigravity_http_user_agent(
|
||||
platform_token=_PLATFORM_INFO,
|
||||
version=_ua_version,
|
||||
chrome_version=_FALLBACK_CHROME,
|
||||
electron_version=_FALLBACK_ELECTRON,
|
||||
)
|
||||
|
||||
|
||||
def parse_version_string(text: str) -> str | None:
|
||||
"""从任意文本中提取 X.Y.Z 格式的版本号。"""
|
||||
m = _VERSION_RE.search(text)
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
# ============== URL 可用性 ==============
|
||||
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
|
||||
|
||||
# ============== AM Client Identity Headers ==============
|
||||
# 对齐 AM upstream/client.rs: 伪装为官方 Antigravity 客户端
|
||||
# 缺少这些 header 会导致新模型(如 gemini-3.1-pro-preview)返回 404
|
||||
_SESSION_ID = uuid.uuid4().hex # 每次进程启动生成一个固定 session ID
|
||||
|
||||
|
||||
def get_v1internal_extra_headers() -> dict[str, str]:
|
||||
"""构建 v1internal 请求需要的额外 header(对齐 AM upstream/client.rs)。"""
|
||||
with _ua_lock:
|
||||
version = _ua_version
|
||||
|
||||
return {
|
||||
"User-Agent": get_http_user_agent(),
|
||||
"x-client-name": "antigravity",
|
||||
"x-client-version": version,
|
||||
"x-vscode-sessionid": _SESSION_ID,
|
||||
"x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x",
|
||||
}
|
||||
|
||||
|
||||
# ============== Thinking Signature ==============
|
||||
# 统一从 core 层导入,避免多处定义
|
||||
from src.core.api_format.conversion.constants import DUMMY_THOUGHT_SIGNATURE # noqa: E402
|
||||
from src.core.api_format.conversion.thinking_cache import MIN_SIGNATURE_LENGTH # noqa: E402, F401
|
||||
|
||||
# ============== Thinking Budget ==============
|
||||
THINKING_BUDGET_AUTO_CAP = 24576
|
||||
THINKING_BUDGET_DEFAULT_INJECT = 24576 # 对齐 AM wrapper.rs (was 16000)
|
||||
# 给输出留的空间(对齐 Antigravity-Manager:普通模型 8192,图像模型 2048)
|
||||
OUTPUT_OVERHEAD = 8192
|
||||
OUTPUT_OVERHEAD_IMAGE = 2048
|
||||
# 模型最大输出限制(防止超限)
|
||||
MODEL_MAX_OUTPUT_LIMIT = 65536
|
||||
# 包含这些关键字的模型会自动注入 thinkingConfig(如果缺失)
|
||||
THINKING_MODELS_AUTO_INJECT_KEYWORDS = (
|
||||
"thinking",
|
||||
"gemini-2.0-pro",
|
||||
"gemini-3-pro",
|
||||
"gemini-3.1-pro",
|
||||
)
|
||||
|
||||
# ============== Google Search (Grounding) ==============
|
||||
# 对齐 AM common_utils.rs: 仅 gemini-2.5-flash 支持 googleSearch tool
|
||||
WEB_SEARCH_MODEL = "gemini-2.5-flash"
|
||||
# 联网工具检测关键字(对齐 AM detects_networking_tool)
|
||||
NETWORKING_TOOL_KEYWORDS = frozenset(
|
||||
{
|
||||
"web_search",
|
||||
"google_search",
|
||||
"web_search_20250305",
|
||||
"google_search_retrieval",
|
||||
}
|
||||
)
|
||||
|
||||
# ============== Image Generation ==============
|
||||
# 上游图像生成模型的固定名称
|
||||
IMAGE_GEN_UPSTREAM_MODEL = "gemini-3-pro-image"
|
||||
# 模型后缀 → 宽高比映射
|
||||
IMAGE_ASPECT_RATIO_SUFFIXES: dict[str, str] = {
|
||||
"-21x9": "21:9",
|
||||
"-21-9": "21:9",
|
||||
"-16x9": "16:9",
|
||||
"-16-9": "16:9",
|
||||
"-9x16": "9:16",
|
||||
"-9-16": "9:16",
|
||||
"-4x3": "4:3",
|
||||
"-4-3": "4:3",
|
||||
"-3x4": "3:4",
|
||||
"-3-4": "3:4",
|
||||
"-3x2": "3:2",
|
||||
"-3-2": "3:2",
|
||||
"-2x3": "2:3",
|
||||
"-2-3": "2:3",
|
||||
"-5x4": "5:4",
|
||||
"-5-4": "5:4",
|
||||
"-4x5": "4:5",
|
||||
"-4-5": "4:5",
|
||||
"-1x1": "1:1",
|
||||
"-1-1": "1:1",
|
||||
}
|
||||
# 标准宽高比字符串(用于直接匹配 size 参数)
|
||||
STANDARD_ASPECT_RATIOS = frozenset(
|
||||
{
|
||||
"21:9",
|
||||
"16:9",
|
||||
"9:16",
|
||||
"4:3",
|
||||
"3:4",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"5:4",
|
||||
"4:5",
|
||||
"1:1",
|
||||
}
|
||||
)
|
||||
# 宽高比容差匹配表:(ratio, label)
|
||||
ASPECT_RATIO_TABLE: tuple[tuple[float, str], ...] = (
|
||||
(21.0 / 9.0, "21:9"),
|
||||
(16.0 / 9.0, "16:9"),
|
||||
(4.0 / 3.0, "4:3"),
|
||||
(3.0 / 4.0, "3:4"),
|
||||
(9.0 / 16.0, "9:16"),
|
||||
(3.0 / 2.0, "3:2"),
|
||||
(2.0 / 3.0, "2:3"),
|
||||
(5.0 / 4.0, "5:4"),
|
||||
(4.0 / 5.0, "4:5"),
|
||||
(1.0, "1:1"),
|
||||
)
|
||||
|
||||
# ============== Retry ==============
|
||||
RETRY_429_BASE_SECONDS = 5.0
|
||||
RETRY_503_BASE_SECONDS = 10.0
|
||||
RETRY_503_MAX_SECONDS = 60.0
|
||||
RETRY_500_BASE_SECONDS = 3.0
|
||||
|
||||
# ============== v1internal 路径 ==============
|
||||
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
|
||||
|
||||
# ============== Signature 错误关键字(用于 400 错误检测) ==============
|
||||
SIGNATURE_ERROR_KEYWORDS = (
|
||||
"Invalid `signature`",
|
||||
"thinking.signature",
|
||||
"thinking.thinking",
|
||||
"Corrupted thought signature",
|
||||
)
|
||||
|
||||
# ============== Antigravity System Instruction ==============
|
||||
ANTIGRAVITY_SYSTEM_INSTRUCTION = (
|
||||
"You are Antigravity, a powerful agentic AI coding assistant designed by the "
|
||||
"Google Deepmind team working on Advanced Agentic Coding.\n"
|
||||
"You are pair programming with a USER to solve their coding task. The task may "
|
||||
"require creating a new codebase, modifying or debugging an existing codebase, "
|
||||
"or simply answering a question.\n"
|
||||
"**Absolute paths only**\n"
|
||||
"**Proactiveness**"
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ANTIGRAVITY_SYSTEM_INSTRUCTION",
|
||||
"ASPECT_RATIO_TABLE",
|
||||
"DAILY_BASE_URL",
|
||||
"DUMMY_THOUGHT_SIGNATURE",
|
||||
"HTTP_USER_AGENT",
|
||||
"IMAGE_ASPECT_RATIO_SUFFIXES",
|
||||
"IMAGE_GEN_UPSTREAM_MODEL",
|
||||
"MIN_SIGNATURE_LENGTH",
|
||||
"MODEL_MAX_OUTPUT_LIMIT",
|
||||
"NETWORKING_TOOL_KEYWORDS",
|
||||
"OUTPUT_OVERHEAD",
|
||||
"OUTPUT_OVERHEAD_IMAGE",
|
||||
"PROD_BASE_URL",
|
||||
"REQUEST_USER_AGENT",
|
||||
"RETRY_429_BASE_SECONDS",
|
||||
"RETRY_500_BASE_SECONDS",
|
||||
"RETRY_503_BASE_SECONDS",
|
||||
"RETRY_503_MAX_SECONDS",
|
||||
"SANDBOX_BASE_URL",
|
||||
"SIGNATURE_ERROR_KEYWORDS",
|
||||
"STANDARD_ASPECT_RATIOS",
|
||||
"THINKING_BUDGET_AUTO_CAP",
|
||||
"THINKING_BUDGET_DEFAULT_INJECT",
|
||||
"THINKING_MODELS_AUTO_INJECT_KEYWORDS",
|
||||
"URL_UNAVAILABLE_TTL_SECONDS",
|
||||
"V1INTERNAL_PATH_TEMPLATE",
|
||||
"VERSION_FETCH_URL",
|
||||
"WEB_SEARCH_MODEL",
|
||||
"get_http_user_agent",
|
||||
"get_v1internal_extra_headers",
|
||||
"parse_version_string",
|
||||
"update_user_agent_version",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
"""Antigravity provider plugin — 统一注册入口。
|
||||
|
||||
将 Antigravity 对各通用 registry / capability registry 的注册集中在一个文件中:
|
||||
- Envelope (v1internal 信封)
|
||||
- Transport Hook (URL 构建)
|
||||
- Auth Enricher (OAuth enrichment)
|
||||
- Model Fetcher (模型获取)
|
||||
- Provider Format Capability(跨格式变体)
|
||||
|
||||
新增 provider 时参照此文件创建对应的 plugin.py 即可。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider.adapters.antigravity.constants import V1INTERNAL_PATH_TEMPLATE
|
||||
from src.services.provider.adapters.antigravity.url_availability import url_availability
|
||||
from src.services.provider.request_context import set_selected_base_url
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport Hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_antigravity_url(
|
||||
endpoint: Any,
|
||||
*,
|
||||
is_stream: bool,
|
||||
effective_query_params: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
"""构建 Antigravity v1internal URL。
|
||||
|
||||
使用 url_availability 选择最优端点,构建 v1internal:generateContent 或
|
||||
v1internal:streamGenerateContent URL。
|
||||
"""
|
||||
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
|
||||
base_url = ordered_urls[0] if ordered_urls else endpoint.base_url
|
||||
|
||||
# 存入 contextvars(供后续 Handler 层 envelope 获取)
|
||||
set_selected_base_url(str(base_url) if base_url is not None else None)
|
||||
|
||||
action = "streamGenerateContent" if is_stream else "generateContent"
|
||||
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
|
||||
|
||||
query_params = dict(effective_query_params)
|
||||
|
||||
# v1internal 流式请求同样支持 ?alt=sse
|
||||
if is_stream:
|
||||
query_params.setdefault("alt", "sse")
|
||||
|
||||
# 移除 v1internal 不支持的查询参数
|
||||
query_params.pop("beta", None)
|
||||
|
||||
url = f"{str(base_url).rstrip('/')}{path}"
|
||||
if query_params:
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
if query_string:
|
||||
url = f"{url}?{query_string}"
|
||||
|
||||
return url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Enricher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def enrich_antigravity(
|
||||
auth_config: dict[str, Any],
|
||||
token_response: dict[str, Any],
|
||||
access_token: str,
|
||||
proxy_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Antigravity auth_config enrichment.
|
||||
|
||||
1. 通过 Google userinfo API 获取 email
|
||||
2. 通过 loadCodeAssist 获取 project_id / tier
|
||||
3. 未激活账号尝试 onboardUser
|
||||
4. fallback 到随机 project_id
|
||||
"""
|
||||
from src.core.provider_oauth_utils import fetch_google_email
|
||||
from src.services.provider.adapters.antigravity.client import (
|
||||
extract_project_id,
|
||||
extract_tier_id,
|
||||
generate_fallback_project_id,
|
||||
load_code_assist,
|
||||
onboard_user,
|
||||
)
|
||||
|
||||
# Email(仅在缺失时获取)
|
||||
if not auth_config.get("email"):
|
||||
email = await fetch_google_email(
|
||||
access_token,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=10.0,
|
||||
)
|
||||
if email:
|
||||
auth_config["email"] = email
|
||||
|
||||
# Project ID + Tier(需要 loadCodeAssist 时一起获取)
|
||||
need_project = not auth_config.get("project_id")
|
||||
# Tier 每次 enrich 都重新获取(确保归一化为 Free/Pro/Ultra)
|
||||
need_tier = True
|
||||
|
||||
if need_project or need_tier:
|
||||
project_id = auth_config.get("project_id", "")
|
||||
try:
|
||||
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
|
||||
|
||||
# 提取 tier 信息(对齐 sub2api:优先 paidTier,fallback currentTier)
|
||||
tier_str = _extract_tier_from_code_assist(code_assist)
|
||||
if tier_str:
|
||||
auth_config["tier"] = tier_str
|
||||
logger.info("[enrich] Antigravity tier: {}", tier_str)
|
||||
|
||||
if need_project:
|
||||
project_id = extract_project_id(code_assist)
|
||||
|
||||
# 未激活:尝试 onboardUser
|
||||
if not project_id and code_assist.get("allowedTiers"):
|
||||
tier_id = extract_tier_id(code_assist)
|
||||
logger.info("[enrich] Antigravity onboardUser tier={}", tier_id)
|
||||
project_id = await onboard_user(
|
||||
access_token,
|
||||
tier_id=tier_id,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[enrich] Antigravity loadCodeAssist/onboardUser 失败: {}", e)
|
||||
|
||||
if need_project:
|
||||
if project_id:
|
||||
auth_config["project_id"] = project_id
|
||||
logger.info("[enrich] Antigravity project_id: {}", project_id[:8] + "...")
|
||||
else:
|
||||
fallback = generate_fallback_project_id()
|
||||
auth_config["project_id"] = fallback
|
||||
logger.info("[enrich] Antigravity 随机 project_id fallback: {}", fallback)
|
||||
|
||||
return auth_config
|
||||
|
||||
|
||||
def _extract_tier_from_code_assist(code_assist: dict[str, Any]) -> str:
|
||||
"""从 loadCodeAssist 响应中提取用户层级,返回 Free/Pro/Ultra。
|
||||
|
||||
对齐 sub2api/CLIProxyAPI:优先 paidTier,fallback currentTier。
|
||||
兼容 tier 为字符串或 {"id": "...", "tierType": "..."} 两种格式。
|
||||
"""
|
||||
# 优先 paidTier(付费订阅级别)
|
||||
paid_tier = code_assist.get("paidTier")
|
||||
tier = _normalize_tier(_extract_tier_raw(paid_tier))
|
||||
if tier:
|
||||
return tier
|
||||
|
||||
# fallback currentTier
|
||||
current_tier = code_assist.get("currentTier")
|
||||
tier = _normalize_tier(_extract_tier_raw(current_tier))
|
||||
if tier:
|
||||
return tier
|
||||
|
||||
return "Free"
|
||||
|
||||
|
||||
def _extract_tier_raw(tier_obj: Any) -> str:
|
||||
"""从 tier 对象中提取原始标识,兼容字符串和字典两种格式。"""
|
||||
if isinstance(tier_obj, str) and tier_obj.strip():
|
||||
return tier_obj.strip()
|
||||
if isinstance(tier_obj, dict):
|
||||
for key in ("id", "tierType"):
|
||||
val = tier_obj.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_tier(raw: str) -> str:
|
||||
"""将上游 tier 标识归一化为 Free/Pro/Ultra。
|
||||
|
||||
上游格式示例:
|
||||
- id: "free-tier", "g1-pro-tier", "g1-ultra-tier"
|
||||
- tierType: "FREE", "PAID"
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
lower = raw.lower()
|
||||
if "ultra" in lower:
|
||||
return "Ultra"
|
||||
if "pro" in lower or "paid" in lower:
|
||||
return "Pro"
|
||||
if "free" in lower or "legacy" in lower:
|
||||
return "Free"
|
||||
return raw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model Fetcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Antigravity 内部测试/调试模型,不应暴露给用户
|
||||
BLOCKED_MODELS: frozenset[str] = frozenset({"chat_23310", "chat_20706"})
|
||||
|
||||
|
||||
async def fetch_models_antigravity(
|
||||
ctx: Any,
|
||||
timeout_seconds: float,
|
||||
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
|
||||
"""Antigravity 模型获取策略。
|
||||
|
||||
调用 v1internal:fetchAvailableModels 获取可用模型列表,
|
||||
解析配额信息,过滤黑名单模型。
|
||||
"""
|
||||
from src.services.provider.adapters.antigravity.client import (
|
||||
AntigravityAccountForbiddenException,
|
||||
fetch_available_models,
|
||||
)
|
||||
|
||||
auth_config = ctx.auth_config or {}
|
||||
project_id = auth_config.get("project_id")
|
||||
if not isinstance(project_id, str) or not project_id.strip():
|
||||
return [], ["antigravity: missing auth_config.project_id (please re-auth)"], False, None
|
||||
|
||||
try:
|
||||
data = await fetch_available_models(
|
||||
ctx.api_key_value,
|
||||
project_id=project_id.strip(),
|
||||
proxy_config=ctx.proxy_config,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except AntigravityAccountForbiddenException:
|
||||
# 重新抛出,由上层(keys.py)处理自动停用逻辑
|
||||
raise
|
||||
except Exception as e:
|
||||
return [], [f"antigravity: fetchAvailableModels error: {e}"], False, None
|
||||
|
||||
raw_models = data.get("models")
|
||||
if not isinstance(raw_models, dict):
|
||||
return [], ["antigravity: invalid response (missing models)"], False, None
|
||||
|
||||
models: list[dict] = []
|
||||
quota_by_model: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for model_id, model_data in raw_models.items():
|
||||
if not isinstance(model_id, str) or not model_id.strip():
|
||||
continue
|
||||
if model_id.strip() in BLOCKED_MODELS:
|
||||
continue
|
||||
if not isinstance(model_data, dict):
|
||||
model_data = {}
|
||||
|
||||
display_name = model_data.get("displayName")
|
||||
if not isinstance(display_name, str) or not display_name:
|
||||
display_name = model_id
|
||||
|
||||
models.append(
|
||||
{
|
||||
"id": model_id,
|
||||
"owned_by": "antigravity",
|
||||
"display_name": display_name,
|
||||
"api_format": "gemini:chat",
|
||||
}
|
||||
)
|
||||
|
||||
quota_info = model_data.get("quotaInfo")
|
||||
if not isinstance(quota_info, dict):
|
||||
# 没有 quotaInfo 视为配额耗尽
|
||||
quota_by_model[model_id] = {
|
||||
"remaining_fraction": 0.0,
|
||||
"used_percent": 100.0,
|
||||
}
|
||||
continue
|
||||
|
||||
remaining = quota_info.get("remainingFraction")
|
||||
reset_time = quota_info.get("resetTime")
|
||||
|
||||
remaining_fraction: float | None = None
|
||||
try:
|
||||
if remaining is not None:
|
||||
remaining_fraction = float(remaining)
|
||||
except Exception:
|
||||
remaining_fraction = None
|
||||
|
||||
if remaining_fraction is None:
|
||||
# remainingFraction 缺失视为配额耗尽
|
||||
payload: dict[str, Any] = {
|
||||
"remaining_fraction": 0.0,
|
||||
"used_percent": 100.0,
|
||||
}
|
||||
if isinstance(reset_time, str) and reset_time.strip():
|
||||
payload["reset_time"] = reset_time.strip()
|
||||
quota_by_model[model_id] = payload
|
||||
continue
|
||||
|
||||
used_percent = (1.0 - remaining_fraction) * 100.0
|
||||
if used_percent < 0:
|
||||
used_percent = 0.0
|
||||
if used_percent > 100: # noqa: PLR2004
|
||||
used_percent = 100.0
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"remaining_fraction": remaining_fraction,
|
||||
"used_percent": used_percent,
|
||||
}
|
||||
if isinstance(reset_time, str) and reset_time.strip():
|
||||
payload["reset_time"] = reset_time.strip()
|
||||
quota_by_model[model_id] = payload
|
||||
|
||||
upstream_metadata: dict[str, Any] | None = None
|
||||
if quota_by_model:
|
||||
upstream_metadata = {
|
||||
"antigravity": {
|
||||
"updated_at": int(time.time()),
|
||||
"quota_by_model": quota_by_model,
|
||||
}
|
||||
}
|
||||
|
||||
return models, [], True, upstream_metadata
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AG_SKIP_KEYS = frozenset(
|
||||
{
|
||||
"access_token",
|
||||
"expires_at",
|
||||
"updated_at",
|
||||
"token_type",
|
||||
"scope",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def antigravity_export_builder(
|
||||
auth_config: dict[str, Any],
|
||||
upstream_metadata: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Antigravity 导出:保留 refresh_token / email / project_id / tier。"""
|
||||
return {
|
||||
k: v for k, v in auth_config.items() if k not in _AG_SKIP_KEYS and v is not None and v != ""
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_all() -> None:
|
||||
"""一次性注册 Antigravity 的所有 hooks 到各通用 registry。"""
|
||||
from src.core.api_format.capabilities import register_provider_behavior_variant
|
||||
from src.core.provider_oauth_utils import register_auth_enricher
|
||||
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
|
||||
from src.services.provider.adapters.antigravity.envelope import antigravity_v1internal_envelope
|
||||
from src.services.provider.envelope import register_envelope
|
||||
from src.services.provider.export import register_export_builder
|
||||
from src.services.provider.transport import register_transport_hook
|
||||
|
||||
# Envelope
|
||||
register_envelope("antigravity", "gemini:chat", antigravity_v1internal_envelope)
|
||||
# Backward compat: allow existing endpoints that still use the old signature.
|
||||
register_envelope("antigravity", "gemini:cli", antigravity_v1internal_envelope)
|
||||
register_envelope("antigravity", "", antigravity_v1internal_envelope)
|
||||
|
||||
# Transport
|
||||
register_transport_hook("antigravity", "gemini:chat", build_antigravity_url)
|
||||
# Backward compat: allow existing endpoints that still use the old signature.
|
||||
register_transport_hook("antigravity", "gemini:cli", build_antigravity_url)
|
||||
|
||||
# Auth
|
||||
register_auth_enricher("antigravity", enrich_antigravity)
|
||||
|
||||
# Export
|
||||
register_export_builder("antigravity", antigravity_export_builder)
|
||||
|
||||
# Model Fetcher
|
||||
UpstreamModelsFetcherRegistry.register(
|
||||
provider_types=["antigravity"],
|
||||
fetcher=fetch_models_antigravity,
|
||||
)
|
||||
|
||||
# Provider Format Capability
|
||||
register_provider_behavior_variant("antigravity", cross_format=True)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Shared Rust executor HTTP helper for Antigravity side calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.config.settings import config
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
async def execute_antigravity_rust_http_request(
|
||||
*,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: Any,
|
||||
proxy_config: dict[str, Any] | None,
|
||||
request_id: str,
|
||||
provider_api_format: str,
|
||||
timeout_seconds: float,
|
||||
content_type: str | None = None,
|
||||
) -> httpx.Response | None:
|
||||
from src.services.request.execution_runtime_plan import (
|
||||
ExecutionPlan,
|
||||
ExecutionPlanBody,
|
||||
ExecutionPlanTimeouts,
|
||||
build_execution_plan_body,
|
||||
build_proxy_snapshot,
|
||||
)
|
||||
from src.services.request.execution_runtime_client import (
|
||||
ExecutionRuntimeClient,
|
||||
ExecutionRuntimeClientError,
|
||||
)
|
||||
|
||||
if config.execution_runtime_backend != "rust":
|
||||
return None
|
||||
|
||||
final_headers = dict(headers)
|
||||
if (
|
||||
body is not None
|
||||
and content_type
|
||||
and not any(str(key).lower() == "content-type" for key in final_headers)
|
||||
):
|
||||
final_headers["content-type"] = content_type
|
||||
|
||||
timeout_ms = max(int(timeout_seconds * 1000), 1_000)
|
||||
|
||||
try:
|
||||
proxy_snapshot = await build_proxy_snapshot(proxy_config, label="Antigravity")
|
||||
result = await ExecutionRuntimeClient().execute_sync_json(
|
||||
ExecutionPlan(
|
||||
request_id=request_id,
|
||||
candidate_id=None,
|
||||
provider_name="antigravity",
|
||||
provider_id="",
|
||||
endpoint_id="",
|
||||
key_id="",
|
||||
method=method,
|
||||
url=url,
|
||||
headers=final_headers,
|
||||
body=(
|
||||
build_execution_plan_body(body, content_type=content_type)
|
||||
if body is not None
|
||||
else ExecutionPlanBody()
|
||||
),
|
||||
stream=False,
|
||||
provider_api_format=provider_api_format,
|
||||
client_api_format=provider_api_format,
|
||||
model_name="antigravity",
|
||||
content_type=content_type,
|
||||
proxy=proxy_snapshot,
|
||||
timeouts=ExecutionPlanTimeouts(
|
||||
connect_ms=timeout_ms,
|
||||
read_ms=timeout_ms,
|
||||
write_ms=timeout_ms,
|
||||
pool_ms=timeout_ms,
|
||||
total_ms=timeout_ms,
|
||||
),
|
||||
)
|
||||
)
|
||||
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Antigravity Rust HTTP fallback {} {}: {}", method, url, exc)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("Antigravity Rust HTTP unexpected fallback {} {}: {}", method, url, exc)
|
||||
return None
|
||||
|
||||
response_headers = dict(result.headers)
|
||||
if result.response_json is not None:
|
||||
response_headers.setdefault("content-type", "application/json")
|
||||
response_body = json.dumps(result.response_json, ensure_ascii=False).encode("utf-8")
|
||||
elif result.response_body_bytes is not None:
|
||||
response_body = result.response_body_bytes
|
||||
else:
|
||||
response_body = b""
|
||||
|
||||
return httpx.Response(
|
||||
status_code=result.status_code,
|
||||
request=httpx.Request(method, url, headers=final_headers),
|
||||
headers=response_headers,
|
||||
content=response_body,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["execute_antigravity_rust_http_request"]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Backward-compatible re-export for Antigravity thinking signature cache.
|
||||
|
||||
The implementation moved to `src.core.api_format.conversion.thinking_cache` to eliminate
|
||||
core → services reverse dependencies.
|
||||
"""
|
||||
|
||||
from src.core.api_format.conversion.thinking_cache import (
|
||||
MIN_SIGNATURE_LENGTH,
|
||||
ThinkingSignatureCache,
|
||||
signature_cache,
|
||||
)
|
||||
|
||||
__all__ = ["MIN_SIGNATURE_LENGTH", "ThinkingSignatureCache", "signature_cache"]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Antigravity URL 可用性管理(带 TTL 自动恢复)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from src.services.provider.adapters.antigravity.constants import (
|
||||
DAILY_BASE_URL,
|
||||
PROD_BASE_URL,
|
||||
SANDBOX_BASE_URL,
|
||||
URL_UNAVAILABLE_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
class URLAvailability:
|
||||
"""管理 Antigravity API 端点可用性(进程内)。"""
|
||||
|
||||
_instance: "URLAvailability | None" = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls) -> "URLAvailability":
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._init()
|
||||
return cls._instance
|
||||
|
||||
def _init(self) -> None:
|
||||
self._unavailable: dict[str, float] = {} # url -> recover_at(ts)
|
||||
self._last_success: str | None = None
|
||||
self._mu = threading.RLock()
|
||||
|
||||
def _prune(self, now: float | None = None) -> None:
|
||||
now_ts = time.time() if now is None else now
|
||||
self._unavailable = {u: t for u, t in self._unavailable.items() if t > now_ts}
|
||||
|
||||
def is_available(self, url: str) -> bool:
|
||||
with self._mu:
|
||||
self._prune()
|
||||
return url not in self._unavailable
|
||||
|
||||
def get_ordered_urls(self, *, prefer_daily: bool = True) -> list[str]:
|
||||
"""返回优先级排序的可用 URL 列表。
|
||||
|
||||
- 默认 daily 优先(通常限流更宽松)
|
||||
- 最近成功的 URL 会被提升到最前
|
||||
- 若全部被标记不可用,则返回 base_order(允许继续尝试,等待 TTL 自动恢复)
|
||||
"""
|
||||
with self._mu:
|
||||
self._prune()
|
||||
|
||||
# Antigravity-Manager 顺序:sandbox → daily → prod
|
||||
base_order = (
|
||||
[SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
|
||||
if prefer_daily
|
||||
else [PROD_BASE_URL, SANDBOX_BASE_URL, DAILY_BASE_URL]
|
||||
)
|
||||
|
||||
if self._last_success and self._last_success in base_order:
|
||||
base_order.remove(self._last_success)
|
||||
base_order.insert(0, self._last_success)
|
||||
|
||||
available = [u for u in base_order if u not in self._unavailable]
|
||||
return available if available else base_order
|
||||
|
||||
def mark_success(self, url: str) -> None:
|
||||
with self._mu:
|
||||
self._last_success = url
|
||||
self._unavailable.pop(url, None)
|
||||
|
||||
def mark_unavailable(self, url: str) -> None:
|
||||
with self._mu:
|
||||
self._unavailable[url] = time.time() + URL_UNAVAILABLE_TTL_SECONDS
|
||||
if self._last_success == url:
|
||||
self._last_success = None
|
||||
|
||||
|
||||
url_availability = URLAvailability()
|
||||
|
||||
__all__ = ["URLAvailability", "url_availability"]
|
||||
Reference in New Issue
Block a user