| """ |
| Antigravity API Client - Handles communication with Google's Antigravity API |
| 处理与 Google Antigravity API 的通信 |
| """ |
|
|
| import asyncio |
| import json |
| import uuid |
| from datetime import datetime, timezone |
| from typing import Any, Dict, List, Optional |
|
|
| from fastapi import Response |
| from config import ( |
| get_antigravity_api_url, |
| get_antigravity_stream2nostream, |
| get_auto_ban_error_codes, |
| ) |
| from log import log |
|
|
| from src.credential_manager import CredentialManager |
| from src.httpx_client import stream_post_async, post_async |
| from src.models import Model, model_to_dict |
| from src.utils import ANTIGRAVITY_USER_AGENT |
|
|
| |
| from src.api.utils import ( |
| handle_error_with_retry, |
| get_retry_config, |
| record_api_call_success, |
| record_api_call_error, |
| parse_and_log_cooldown, |
| collect_streaming_response, |
| ) |
|
|
| |
|
|
| |
| _credential_manager: Optional[CredentialManager] = None |
|
|
|
|
| async def _get_credential_manager() -> CredentialManager: |
| """ |
| 获取全局凭证管理器实例 |
| |
| Returns: |
| CredentialManager实例 |
| """ |
| global _credential_manager |
| if not _credential_manager: |
| _credential_manager = CredentialManager() |
| await _credential_manager.initialize() |
| return _credential_manager |
|
|
|
|
| |
|
|
| def build_antigravity_headers(access_token: str, model_name: str = "") -> Dict[str, str]: |
| """ |
| 构建 Antigravity API 请求头 |
| |
| Args: |
| access_token: 访问令牌 |
| model_name: 模型名称,用于判断 request_type |
| |
| Returns: |
| 请求头字典 |
| """ |
| headers = { |
| 'User-Agent': ANTIGRAVITY_USER_AGENT, |
| 'Authorization': f'Bearer {access_token}', |
| 'Content-Type': 'application/json', |
| 'Accept-Encoding': 'gzip', |
| 'requestId': f"req-{uuid.uuid4()}" |
| } |
|
|
| |
| if model_name: |
| request_type = "image_gen" if "image" in model_name.lower() else "agent" |
| headers['requestType'] = request_type |
|
|
| return headers |
|
|
|
|
| |
|
|
| async def stream_request( |
| body: Dict[str, Any], |
| native: bool = False, |
| headers: Optional[Dict[str, str]] = None, |
| ): |
| """ |
| 流式请求函数 |
| |
| Args: |
| body: 请求体 |
| native: 是否返回原生bytes流,False则返回str流 |
| headers: 额外的请求头 |
| |
| Yields: |
| Response对象(错误时)或 bytes流/str流(成功时) |
| """ |
| |
| credential_manager = await _get_credential_manager() |
|
|
| model_name = body.get("model", "") |
|
|
| |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_key=model_name |
| ) |
|
|
| if not cred_result: |
| |
| log.error("[ANTIGRAVITY STREAM] 当前无可用凭证") |
| yield Response( |
| content=json.dumps({"error": "当前无可用凭证"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| return |
|
|
| current_file, credential_data = cred_result |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
|
|
| if not access_token: |
| log.error(f"[ANTIGRAVITY STREAM] No access token in credential: {current_file}") |
| yield Response( |
| content=json.dumps({"error": "凭证中没有访问令牌"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| return |
|
|
| |
| antigravity_url = await get_antigravity_api_url() |
| target_url = f"{antigravity_url}/v1internal:streamGenerateContent?alt=sse" |
|
|
| auth_headers = build_antigravity_headers(access_token, model_name) |
|
|
| |
| if headers: |
| auth_headers.update(headers) |
|
|
| |
| retry_config = await get_retry_config() |
| max_retries = retry_config["max_retries"] |
| retry_interval = retry_config["retry_interval"] |
|
|
| DISABLE_ERROR_CODES = await get_auto_ban_error_codes() |
| last_error_response = None |
| |
| |
| async def refresh_credential(): |
| nonlocal current_file, access_token, auth_headers |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_key=model_name |
| ) |
| if not cred_result: |
| return None |
| current_file, credential_data = cred_result |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
| if not access_token: |
| return None |
| auth_headers = build_antigravity_headers(access_token, model_name) |
| if headers: |
| auth_headers.update(headers) |
| return True |
|
|
| for attempt in range(max_retries + 1): |
| success_recorded = False |
| need_retry = False |
|
|
| try: |
| async for chunk in stream_post_async( |
| url=target_url, |
| body=body, |
| native=native, |
| headers=auth_headers |
| ): |
| |
| if isinstance(chunk, Response): |
| status_code = chunk.status_code |
| last_error_response = chunk |
|
|
| |
| if status_code == 429 or status_code not in DISABLE_ERROR_CODES: |
| |
| try: |
| error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body) |
| log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500]}") |
| except Exception: |
| log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}") |
|
|
| |
| cooldown_until = None |
| if status_code == 429: |
| |
| try: |
| error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body) |
| cooldown_until = await parse_and_log_cooldown(error_body, mode="antigravity") |
| except Exception: |
| pass |
|
|
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| cooldown_until, mode="antigravity", model_key=model_name |
| ) |
|
|
| |
| should_retry = await handle_error_with_retry( |
| credential_manager, status_code, current_file, |
| retry_config["retry_enabled"], attempt, max_retries, retry_interval, |
| mode="antigravity" |
| ) |
|
|
| if should_retry and attempt < max_retries: |
| need_retry = True |
| break |
| else: |
| |
| log.error(f"[ANTIGRAVITY STREAM] 达到最大重试次数或不应重试,返回原始错误") |
| yield chunk |
| return |
| else: |
| |
| try: |
| error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body) |
| log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500]}") |
| except Exception: |
| log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}") |
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| None, mode="antigravity", model_key=model_name |
| ) |
| yield chunk |
| return |
| else: |
| |
| |
| if not success_recorded: |
| await record_api_call_success( |
| credential_manager, current_file, mode="antigravity", model_key=model_name |
| ) |
| success_recorded = True |
| log.info(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}") |
|
|
| |
| if isinstance(chunk, bytes): |
| log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}") |
| else: |
| log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}") |
|
|
| yield chunk |
|
|
| |
| if success_recorded: |
| log.info(f"[ANTIGRAVITY STREAM] 流式响应完成,模型: {model_name}") |
| return |
| elif not need_retry: |
| |
| log.warning(f"[ANTIGRAVITY STREAM] 收到空回复,无任何内容,凭证: {current_file}") |
| await record_api_call_error( |
| credential_manager, current_file, 200, |
| None, mode="antigravity", model_key=model_name |
| ) |
| |
| if attempt < max_retries: |
| need_retry = True |
| else: |
| log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数") |
| yield Response( |
| content=json.dumps({"error": "服务返回空回复"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| return |
| |
| |
| if need_retry: |
| log.info(f"[ANTIGRAVITY STREAM] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") |
| await asyncio.sleep(retry_interval) |
| |
| if not await refresh_credential(): |
| log.error("[ANTIGRAVITY STREAM] 重试时无可用凭证或令牌") |
| yield Response( |
| content=json.dumps({"error": "当前无可用凭证"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| return |
| continue |
|
|
| except Exception as e: |
| log.error(f"[ANTIGRAVITY STREAM] 流式请求异常: {e}, 凭证: {current_file}") |
| if attempt < max_retries: |
| log.info(f"[ANTIGRAVITY STREAM] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...") |
| await asyncio.sleep(retry_interval) |
| continue |
| else: |
| |
| log.error(f"[ANTIGRAVITY STREAM] 所有重试均失败,最后异常: {e}") |
| yield last_error_response |
|
|
|
|
| async def non_stream_request( |
| body: Dict[str, Any], |
| headers: Optional[Dict[str, str]] = None, |
| ) -> Response: |
| """ |
| 非流式请求函数 |
| |
| Args: |
| body: 请求体 |
| headers: 额外的请求头 |
| |
| Returns: |
| Response对象 |
| """ |
| |
| if await get_antigravity_stream2nostream(): |
| log.info("[ANTIGRAVITY] 使用流式收集模式实现非流式请求") |
|
|
| |
| stream = stream_request(body=body, native=False, headers=headers) |
|
|
| |
| |
| |
| return await collect_streaming_response(stream) |
|
|
| |
| log.info("[ANTIGRAVITY] 使用传统非流式模式") |
|
|
| |
| credential_manager = await _get_credential_manager() |
|
|
| model_name = body.get("model", "") |
|
|
| |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_key=model_name |
| ) |
|
|
| if not cred_result: |
| |
| log.error("[ANTIGRAVITY] 当前无可用凭证") |
| return Response( |
| content=json.dumps({"error": "当前无可用凭证"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
|
|
| current_file, credential_data = cred_result |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
|
|
| if not access_token: |
| log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}") |
| return Response( |
| content=json.dumps({"error": "凭证中没有访问令牌"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
|
|
| |
| antigravity_url = await get_antigravity_api_url() |
| target_url = f"{antigravity_url}/v1internal:generateContent" |
|
|
| auth_headers = build_antigravity_headers(access_token, model_name) |
|
|
| |
| if headers: |
| auth_headers.update(headers) |
|
|
| |
| retry_config = await get_retry_config() |
| max_retries = retry_config["max_retries"] |
| retry_interval = retry_config["retry_interval"] |
|
|
| DISABLE_ERROR_CODES = await get_auto_ban_error_codes() |
| last_error_response = None |
| |
| |
| async def refresh_credential(): |
| nonlocal current_file, access_token, auth_headers |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_key=model_name |
| ) |
| if not cred_result: |
| return None |
| current_file, credential_data = cred_result |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
| if not access_token: |
| return None |
| auth_headers = build_antigravity_headers(access_token, model_name) |
| if headers: |
| auth_headers.update(headers) |
| return True |
|
|
| for attempt in range(max_retries + 1): |
| need_retry = False |
| |
| try: |
| response = await post_async( |
| url=target_url, |
| json=body, |
| headers=auth_headers, |
| timeout=300.0 |
| ) |
|
|
| status_code = response.status_code |
|
|
| |
| if status_code == 200: |
| |
| if not response.content or len(response.content) == 0: |
| log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}") |
| |
| |
| await record_api_call_error( |
| credential_manager, current_file, 200, |
| None, mode="antigravity", model_key=model_name |
| ) |
| |
| if attempt < max_retries: |
| need_retry = True |
| else: |
| log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数") |
| return Response( |
| content=json.dumps({"error": "服务返回空回复"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| else: |
| |
| await record_api_call_success( |
| credential_manager, current_file, mode="antigravity", model_key=model_name |
| ) |
| return Response( |
| content=response.content, |
| status_code=200, |
| headers=dict(response.headers) |
| ) |
|
|
| |
| if status_code != 200: |
| last_error_response = Response( |
| content=response.content, |
| status_code=status_code, |
| headers=dict(response.headers) |
| ) |
|
|
| |
| if status_code == 429 or status_code not in DISABLE_ERROR_CODES: |
| try: |
| error_text = response.text |
| log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500]}") |
| except Exception: |
| log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}") |
|
|
| |
| cooldown_until = None |
| if status_code == 429: |
| |
| try: |
| error_text = response.text |
| cooldown_until = await parse_and_log_cooldown(error_text, mode="antigravity") |
| except Exception: |
| pass |
|
|
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| cooldown_until, mode="antigravity", model_key=model_name |
| ) |
|
|
| |
| should_retry = await handle_error_with_retry( |
| credential_manager, status_code, current_file, |
| retry_config["retry_enabled"], attempt, max_retries, retry_interval, |
| mode="antigravity" |
| ) |
|
|
| if should_retry and attempt < max_retries: |
| need_retry = True |
| else: |
| |
| log.error(f"[ANTIGRAVITY] 达到最大重试次数或不应重试,返回原始错误") |
| return last_error_response |
| else: |
| |
| try: |
| error_text = response.text |
| log.error(f"[ANTIGRAVITY] 非流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500]}") |
| except Exception: |
| log.error(f"[ANTIGRAVITY] 非流式请求失败,禁用错误码 (status={status_code}), 凭证: {current_file}") |
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| None, mode="antigravity", model_key=model_name |
| ) |
| return last_error_response |
| |
| |
| if need_retry: |
| log.info(f"[ANTIGRAVITY] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") |
| await asyncio.sleep(retry_interval) |
| |
| if not await refresh_credential(): |
| log.error("[ANTIGRAVITY] 重试时无可用凭证或令牌") |
| return Response( |
| content=json.dumps({"error": "当前无可用凭证"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| continue |
|
|
| except Exception as e: |
| log.error(f"[ANTIGRAVITY] 非流式请求异常: {e}, 凭证: {current_file}") |
| if attempt < max_retries: |
| log.info(f"[ANTIGRAVITY] 异常后重试 (attempt {attempt + 2}/{max_retries + 1})...") |
| await asyncio.sleep(retry_interval) |
| continue |
| else: |
| |
| log.error(f"[ANTIGRAVITY] 所有重试均失败,最后异常: {e}") |
| return last_error_response |
|
|
| |
| log.error("[ANTIGRAVITY] 所有重试均失败") |
| return last_error_response |
|
|
|
|
| |
|
|
| async def fetch_available_models() -> List[Dict[str, Any]]: |
| """ |
| 获取可用模型列表,返回符合 OpenAI API 规范的格式 |
| |
| Returns: |
| 模型列表,格式为字典列表(用于兼容现有代码) |
| |
| Raises: |
| 返回空列表如果获取失败 |
| """ |
| |
| credential_manager = await _get_credential_manager() |
| cred_result = await credential_manager.get_valid_credential(mode="antigravity") |
| if not cred_result: |
| log.error("[ANTIGRAVITY] No valid credentials available for fetching models") |
| return [] |
|
|
| current_file, credential_data = cred_result |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
|
|
| if not access_token: |
| log.error(f"[ANTIGRAVITY] No access token in credential: {current_file}") |
| return [] |
|
|
| |
| headers = build_antigravity_headers(access_token) |
|
|
| try: |
| |
| antigravity_url = await get_antigravity_api_url() |
|
|
| response = await post_async( |
| url=f"{antigravity_url}/v1internal:fetchAvailableModels", |
| json={}, |
| headers=headers |
| ) |
|
|
| if response.status_code == 200: |
| data = response.json() |
| log.debug(f"[ANTIGRAVITY] Raw models response: {json.dumps(data, ensure_ascii=False)[:500]}") |
|
|
| |
| model_list = [] |
| current_timestamp = int(datetime.now(timezone.utc).timestamp()) |
|
|
| if 'models' in data and isinstance(data['models'], dict): |
| |
| for model_id in data['models'].keys(): |
| model = Model( |
| id=model_id, |
| object='model', |
| created=current_timestamp, |
| owned_by='google' |
| ) |
| model_list.append(model_to_dict(model)) |
|
|
| |
| claude_opus_model = Model( |
| id='claude-opus-4-5', |
| object='model', |
| created=current_timestamp, |
| owned_by='google' |
| ) |
| model_list.append(model_to_dict(claude_opus_model)) |
|
|
| log.info(f"[ANTIGRAVITY] Fetched {len(model_list)} available models") |
| return model_list |
| else: |
| log.error(f"[ANTIGRAVITY] Failed to fetch models ({response.status_code}): {response.text[:500]}") |
| return [] |
|
|
| except Exception as e: |
| import traceback |
| log.error(f"[ANTIGRAVITY] Failed to fetch models: {e}") |
| log.error(f"[ANTIGRAVITY] Traceback: {traceback.format_exc()}") |
| return [] |
|
|
|
|
| async def fetch_quota_info(access_token: str) -> Dict[str, Any]: |
| """ |
| 获取指定凭证的额度信息 |
| |
| Args: |
| access_token: Antigravity 访问令牌 |
| |
| Returns: |
| 包含额度信息的字典,格式为: |
| { |
| "success": True/False, |
| "models": { |
| "model_name": { |
| "remaining": 0.95, |
| "resetTime": "12-20 10:30", |
| "resetTimeRaw": "2025-12-20T02:30:00Z" |
| } |
| }, |
| "error": "错误信息" (仅在失败时) |
| } |
| """ |
|
|
| headers = build_antigravity_headers(access_token) |
|
|
| try: |
| antigravity_url = await get_antigravity_api_url() |
|
|
| response = await post_async( |
| url=f"{antigravity_url}/v1internal:fetchAvailableModels", |
| json={}, |
| headers=headers, |
| timeout=30.0 |
| ) |
|
|
| if response.status_code == 200: |
| data = response.json() |
| log.debug(f"[ANTIGRAVITY QUOTA] Raw response: {json.dumps(data, ensure_ascii=False)[:500]}") |
|
|
| quota_info = {} |
|
|
| if 'models' in data and isinstance(data['models'], dict): |
| for model_id, model_data in data['models'].items(): |
| if isinstance(model_data, dict) and 'quotaInfo' in model_data: |
| quota = model_data['quotaInfo'] |
| remaining = quota.get('remainingFraction', 0) |
| reset_time_raw = quota.get('resetTime', '') |
|
|
| |
| reset_time_beijing = 'N/A' |
| if reset_time_raw: |
| try: |
| utc_date = datetime.fromisoformat(reset_time_raw.replace('Z', '+00:00')) |
| |
| from datetime import timedelta |
| beijing_date = utc_date + timedelta(hours=8) |
| reset_time_beijing = beijing_date.strftime('%m-%d %H:%M') |
| except Exception as e: |
| log.warning(f"[ANTIGRAVITY QUOTA] Failed to parse reset time: {e}") |
|
|
| quota_info[model_id] = { |
| "remaining": remaining, |
| "resetTime": reset_time_beijing, |
| "resetTimeRaw": reset_time_raw |
| } |
|
|
| return { |
| "success": True, |
| "models": quota_info |
| } |
| else: |
| log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota ({response.status_code}): {response.text[:500]}") |
| return { |
| "success": False, |
| "error": f"API返回错误: {response.status_code}" |
| } |
|
|
| except Exception as e: |
| import traceback |
| log.error(f"[ANTIGRAVITY QUOTA] Failed to fetch quota: {e}") |
| log.error(f"[ANTIGRAVITY QUOTA] Traceback: {traceback.format_exc()}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |