| """ |
| Antigravity API Client - Handles communication with Google's Antigravity API |
| 处理与 Google Antigravity API 的通信 |
| """ |
|
|
| import asyncio |
| import hashlib |
| import json |
| import uuid |
| from datetime import datetime, timezone |
| from typing import Any, Dict, List, Optional, Callable, Tuple |
|
|
| from fastapi import Response |
| from config import ( |
| get_antigravity_api_url, |
| get_antigravity_stream2nostream, |
| get_antigravity_switch_credential_enabled, |
| get_auto_ban_error_codes, |
| ) |
| from log import log |
|
|
| from src.credential_manager import credential_manager |
| 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, |
| check_should_auto_ban, |
| get_retry_config, |
| record_api_call_success, |
| record_api_call_error, |
| parse_and_log_cooldown, |
| collect_streaming_response, |
| ) |
|
|
| |
|
|
| |
|
|
|
|
| |
|
|
| 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: |
| |
| if "image" in model_name.lower(): |
| request_type = "image_gen" |
| headers['requestType'] = request_type |
| else: |
| request_type = "agent" |
| headers['requestType'] = request_type |
|
|
| return headers |
|
|
|
|
| def _generate_stable_session_id(request_payload: Dict[str, Any]) -> str: |
| contents = request_payload.get("contents") |
| if isinstance(contents, list): |
| for content in contents: |
| if not isinstance(content, dict) or content.get("role") != "user": |
| continue |
| parts = content.get("parts") |
| if not isinstance(parts, list) or not parts: |
| continue |
| first_part = parts[0] |
| if not isinstance(first_part, dict): |
| continue |
| text = first_part.get("text") |
| if isinstance(text, str) and text: |
| digest = hashlib.sha256(text.encode("utf-8")).digest() |
| value = int.from_bytes(digest[:8], "big") & 0x7FFFFFFFFFFFFFFF |
| return f"-{value}" |
|
|
| value = uuid.uuid4().int % 9_000_000_000_000_000_000 |
| return f"-{value}" |
|
|
|
|
| def _ensure_antigravity_session_id(payload: Dict[str, Any], model_name: str) -> None: |
| if "image" in (model_name or "").lower(): |
| return |
|
|
| request_payload = payload.get("request") |
| if not isinstance(request_payload, dict): |
| return |
|
|
| if request_payload.get("sessionId"): |
| return |
|
|
| request_payload["sessionId"] = _generate_stable_session_id(request_payload) |
|
|
|
|
| def _empty_object_schema() -> Dict[str, Any]: |
| return {"type": "object", "properties": {}} |
|
|
|
|
| def _prepare_antigravity_tool(tool: Any, is_claude: bool) -> Any: |
| if not isinstance(tool, dict): |
| return tool |
|
|
| normalized_tool = tool.copy() |
|
|
| custom_tool = normalized_tool.get("custom") |
| if isinstance(custom_tool, dict): |
| normalized_custom = custom_tool.copy() |
| if "input_schema" not in normalized_custom: |
| schema = ( |
| normalized_custom.pop("parametersJsonSchema", None) |
| or normalized_custom.pop("parameters_json_schema", None) |
| or normalized_custom.get("parameters") |
| ) |
| normalized_custom["input_schema"] = schema or _empty_object_schema() |
| normalized_tool["custom"] = normalized_custom |
|
|
| declarations_key = None |
| declarations = None |
| if isinstance(normalized_tool.get("functionDeclarations"), list): |
| declarations_key = "functionDeclarations" |
| declarations = normalized_tool.get("functionDeclarations") |
| elif isinstance(normalized_tool.get("function_declarations"), list): |
| declarations_key = "function_declarations" |
| declarations = normalized_tool.get("function_declarations") |
|
|
| if isinstance(declarations, list) and declarations_key: |
| normalized_declarations = [] |
| for declaration in declarations: |
| if not isinstance(declaration, dict): |
| normalized_declarations.append(declaration) |
| continue |
|
|
| normalized_declaration = declaration.copy() |
| schema = None |
| if "parametersJsonSchema" in normalized_declaration: |
| schema = normalized_declaration.pop("parametersJsonSchema") |
| elif "parameters_json_schema" in normalized_declaration: |
| schema = normalized_declaration.pop("parameters_json_schema") |
| elif "parameters" in normalized_declaration: |
| schema = normalized_declaration.get("parameters") |
|
|
| if schema not in (None, {}, []): |
| normalized_declaration["parameters"] = schema |
| elif is_claude or "parameters" not in normalized_declaration: |
| normalized_declaration["parameters"] = _empty_object_schema() |
|
|
| normalized_declarations.append(normalized_declaration) |
|
|
| normalized_tool[declarations_key] = normalized_declarations |
|
|
| return normalized_tool |
|
|
|
|
| def _prepare_antigravity_payload(payload: Dict[str, Any], model_name: str) -> Dict[str, Any]: |
| """Match Antigravity's upstream payload quirks before the HTTP request.""" |
| payload["userAgent"] = "antigravity" |
| if "image" in (model_name or "").lower(): |
| payload["requestType"] = "image_gen" |
| payload.setdefault( |
| "requestId", |
| f"image_gen/{int(datetime.now(timezone.utc).timestamp() * 1000)}/{uuid.uuid4()}/12", |
| ) |
| else: |
| payload["requestType"] = "agent" |
| payload.setdefault("requestId", f"agent-{uuid.uuid4()}") |
|
|
| request_payload = payload.get("request") |
| if not isinstance(request_payload, dict): |
| return payload |
|
|
| _ensure_antigravity_session_id(payload, model_name) |
| request_payload.pop("safetySettings", None) |
|
|
| is_claude = "claude" in (model_name or "").lower() |
| tools = request_payload.get("tools") |
| if isinstance(tools, list): |
| request_payload["tools"] = [ |
| _prepare_antigravity_tool(tool, is_claude) |
| for tool in tools |
| ] |
|
|
| if is_claude: |
| tool_config = request_payload.get("toolConfig") |
| if not isinstance(tool_config, dict): |
| tool_config = {} |
| request_payload["toolConfig"] = tool_config |
|
|
| function_config = tool_config.get("functionCallingConfig") |
| if not isinstance(function_config, dict): |
| function_config = {} |
| tool_config["functionCallingConfig"] = function_config |
|
|
| function_config["mode"] = "VALIDATED" |
|
|
| return payload |
|
|
|
|
| def _is_retryable_status(status_code: int, disable_error_codes: List[int]) -> bool: |
| """统一判断是否属于可重试状态码。""" |
| return status_code in (429, 503) or status_code in disable_error_codes |
|
|
|
|
| async def _switch_credential_for_retry( |
| *, |
| next_cred_task: Optional[asyncio.Task], |
| retry_interval: float, |
| refresh_credential_fast: Callable[[], Any], |
| apply_cred_result: Callable[[Tuple[str, Dict[str, Any]]], bool], |
| log_prefix: str, |
| ) -> Tuple[bool, Optional[asyncio.Task]]: |
| """优先使用预热凭证,失败后退回同步刷新。""" |
| if next_cred_task is not None: |
| try: |
| cred_result = await next_cred_task |
| next_cred_task = None |
| if cred_result and apply_cred_result(cred_result): |
| await asyncio.sleep(retry_interval) |
| return True, next_cred_task |
| except Exception as e: |
| log.warning(f"{log_prefix} 预热凭证任务失败: {e}") |
| next_cred_task = None |
|
|
| await asyncio.sleep(retry_interval) |
| if await refresh_credential_fast(): |
| return True, next_cred_task |
|
|
| return False, next_cred_task |
|
|
|
|
| |
|
|
| 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流(成功时) |
| """ |
| model_name = body.get("model", "") |
| switch_credential_enabled = await get_antigravity_switch_credential_enabled() |
|
|
| |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_name=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") |
| project_id = credential_data.get("project_id", "") |
|
|
| 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) |
|
|
| |
| final_payload = { |
| "model": body.get("model"), |
| "project": project_id, |
| "request": body.get("request", {}), |
| } |
| _prepare_antigravity_payload(final_payload, model_name) |
|
|
| |
| def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: |
| if cred_data.get("enable_credit") is True: |
| final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"] |
| else: |
| final_payload.pop("enabledCreditTypes", None) |
|
|
| apply_enabled_credit_types(credential_data) |
|
|
| |
| 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 |
| next_cred_task = None |
|
|
| |
| async def refresh_credential_fast(): |
| nonlocal current_file, access_token, auth_headers, project_id, final_payload |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_name=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") |
| project_id = credential_data.get("project_id", "") |
| if not access_token: |
| return None |
| |
| auth_headers["Authorization"] = f"Bearer {access_token}" |
| final_payload["project"] = project_id |
| apply_enabled_credit_types(credential_data) |
| return True |
|
|
| def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: |
| nonlocal current_file, access_token, project_id, auth_headers, final_payload |
| current_file, credential_data = cred_result |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
| project_id = credential_data.get("project_id", "") |
| if not access_token or not project_id: |
| return False |
| auth_headers["Authorization"] = f"Bearer {access_token}" |
| final_payload["project"] = project_id |
| apply_enabled_credit_types(credential_data) |
| return True |
|
|
| for attempt in range(max_retries + 1): |
| success_recorded = False |
| need_retry = False |
| should_force_switch = False |
|
|
| try: |
| async for chunk in stream_post_async( |
| url=target_url, |
| body=final_payload, |
| native=native, |
| headers=auth_headers |
| ): |
| |
| if isinstance(chunk, Response): |
| status_code = chunk.status_code |
| last_error_response = chunk |
|
|
| |
| error_body = None |
| try: |
| error_body = chunk.body.decode('utf-8') if isinstance(chunk.body, bytes) else str(chunk.body) |
| except Exception: |
| error_body = "" |
|
|
| |
| if _is_retryable_status(status_code, DISABLE_ERROR_CODES): |
| log.warning(f"[ANTIGRAVITY STREAM] 流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}") |
|
|
| |
| cooldown_until = None |
| if (status_code == 429 or status_code == 503) and error_body: |
| try: |
| cooldown_until = await parse_and_log_cooldown(error_body, mode="antigravity") |
| except Exception: |
| pass |
|
|
| if cooldown_until is not None: |
| should_force_switch = True |
| elif await check_should_auto_ban(status_code): |
| should_force_switch = True |
|
|
| |
| if (switch_credential_enabled or should_force_switch) and next_cred_task is None and attempt < max_retries: |
| next_cred_task = asyncio.create_task( |
| credential_manager.get_valid_credential( |
| mode="antigravity", model_name=model_name |
| ) |
| ) |
|
|
| |
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| cooldown_until, mode="antigravity", model_name=model_name, |
| error_message=error_body |
| ) |
|
|
| |
| 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: |
| |
| log.error(f"[ANTIGRAVITY STREAM] 流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_body[:500] if error_body else '无'}") |
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| None, mode="antigravity", model_name=model_name, |
| error_message=error_body |
| ) |
| yield chunk |
| return |
| else: |
| |
| |
| if not success_recorded: |
| await record_api_call_success( |
| credential_manager, current_file, mode="antigravity", model_name=model_name |
| ) |
| success_recorded = True |
| log.debug(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.debug(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_name=model_name, |
| error_message="Empty response from API" |
| ) |
| |
| 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})...") |
|
|
| if switch_credential_enabled or should_force_switch: |
| switched, next_cred_task = await _switch_credential_for_retry( |
| next_cred_task=next_cred_task, |
| retry_interval=retry_interval, |
| refresh_credential_fast=refresh_credential_fast, |
| apply_cred_result=apply_cred_result, |
| log_prefix="[ANTIGRAVITY STREAM]", |
| ) |
| if not switched: |
| log.error("[ANTIGRAVITY STREAM] 重试时无可用凭证或令牌") |
| yield Response( |
| content=json.dumps({"error": "当前无可用凭证"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| return |
| else: |
| await asyncio.sleep(retry_interval) |
| 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}") |
| if last_error_response: |
| yield last_error_response |
| else: |
| |
| yield Response( |
| content=json.dumps({"error": f"流式请求异常: {str(e)}"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| return |
|
|
| |
| log.error("[ANTIGRAVITY STREAM] 所有重试均失败") |
| if last_error_response: |
| yield last_error_response |
| else: |
| yield Response( |
| content=json.dumps({"error": "请求失败,所有重试均已耗尽"}), |
| status_code=429, |
| media_type="application/json" |
| ) |
|
|
|
|
| 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.debug("[ANTIGRAVITY] 使用流式收集模式实现非流式请求") |
|
|
| |
| stream = stream_request(body=body, native=False, headers=headers) |
|
|
| |
| |
| |
| return await collect_streaming_response(stream) |
|
|
| |
| log.debug("[ANTIGRAVITY] 使用传统非流式模式") |
|
|
| model_name = body.get("model", "") |
| switch_credential_enabled = await get_antigravity_switch_credential_enabled() |
|
|
| |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_name=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") |
| project_id = credential_data.get("project_id", "") |
|
|
| 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) |
|
|
| |
| final_payload = { |
| "model": body.get("model"), |
| "project": project_id, |
| "request": body.get("request", {}), |
| } |
| _prepare_antigravity_payload(final_payload, model_name) |
|
|
| |
| def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: |
| if cred_data.get("enable_credit") is True: |
| final_payload["enabledCreditTypes"] = ["GOOGLE_ONE_AI"] |
| else: |
| final_payload.pop("enabledCreditTypes", None) |
|
|
| apply_enabled_credit_types(credential_data) |
|
|
| |
| 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 |
| next_cred_task = None |
|
|
| |
| async def refresh_credential_fast(): |
| nonlocal current_file, access_token, auth_headers, project_id, final_payload |
| cred_result = await credential_manager.get_valid_credential( |
| mode="antigravity", model_name=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") |
| project_id = credential_data.get("project_id", "") |
| if not access_token: |
| return None |
| |
| auth_headers["Authorization"] = f"Bearer {access_token}" |
| final_payload["project"] = project_id |
| apply_enabled_credit_types(credential_data) |
| return True |
|
|
| def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: |
| nonlocal current_file, access_token, project_id, auth_headers, final_payload |
| current_file, credential_data = cred_result |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
| project_id = credential_data.get("project_id", "") |
| if not access_token or not project_id: |
| return False |
| auth_headers["Authorization"] = f"Bearer {access_token}" |
| final_payload["project"] = project_id |
| apply_enabled_credit_types(credential_data) |
| return True |
|
|
| for attempt in range(max_retries + 1): |
| need_retry = False |
| should_force_switch = False |
| |
| try: |
| response = await post_async( |
| url=target_url, |
| json=final_payload, |
| 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_name=model_name, |
| error_message="Empty response from API" |
| ) |
| |
| 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_name=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) |
| ) |
|
|
| |
| |
| error_text = "" |
| try: |
| error_text = response.text |
| except Exception: |
| pass |
|
|
| if _is_retryable_status(status_code, DISABLE_ERROR_CODES): |
| log.warning(f"[ANTIGRAVITY] 非流式请求失败 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}") |
|
|
| |
| cooldown_until = None |
| if (status_code == 429 or status_code == 503) and error_text: |
| try: |
| cooldown_until = await parse_and_log_cooldown(error_text, mode="antigravity") |
| except Exception: |
| pass |
|
|
| if cooldown_until is not None: |
| should_force_switch = True |
| elif await check_should_auto_ban(status_code): |
| should_force_switch = True |
|
|
| |
| if (switch_credential_enabled or should_force_switch) and next_cred_task is None and attempt < max_retries: |
| next_cred_task = asyncio.create_task( |
| credential_manager.get_valid_credential( |
| mode="antigravity", model_name=model_name |
| ) |
| ) |
|
|
| |
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| cooldown_until, mode="antigravity", model_name=model_name, |
| error_message=error_text |
| ) |
|
|
| |
| 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: |
| |
| log.error(f"[ANTIGRAVITY] 非流式请求失败,非重试错误码 (status={status_code}), 凭证: {current_file}, 响应: {error_text[:500] if error_text else '无'}") |
| await record_api_call_error( |
| credential_manager, current_file, status_code, |
| None, mode="antigravity", model_name=model_name, |
| error_message=error_text |
| ) |
| return last_error_response |
| |
| |
| if need_retry: |
| log.info(f"[ANTIGRAVITY] 重试请求 (attempt {attempt + 2}/{max_retries + 1})...") |
|
|
| if switch_credential_enabled or should_force_switch: |
| switched, next_cred_task = await _switch_credential_for_retry( |
| next_cred_task=next_cred_task, |
| retry_interval=retry_interval, |
| refresh_credential_fast=refresh_credential_fast, |
| apply_cred_result=apply_cred_result, |
| log_prefix="[ANTIGRAVITY]", |
| ) |
| if not switched: |
| log.error("[ANTIGRAVITY] 重试时无可用凭证或令牌") |
| return Response( |
| content=json.dumps({"error": "当前无可用凭证"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
| else: |
| await asyncio.sleep(retry_interval) |
| 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}") |
| if last_error_response: |
| return last_error_response |
| else: |
| return Response( |
| content=json.dumps({"error": f"非流式请求异常: {str(e)}"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
|
|
| |
| log.error("[ANTIGRAVITY] 所有重试均失败") |
| if last_error_response: |
| return last_error_response |
| else: |
| return Response( |
| content=json.dumps({"error": "所有重试均失败"}), |
| status_code=500, |
| media_type="application/json" |
| ) |
|
|
|
|
| |
|
|
| async def fetch_available_models() -> List[Dict[str, Any]]: |
| """ |
| 获取可用模型列表,返回符合 OpenAI API 规范的格式 |
| |
| Returns: |
| 模型列表,格式为字典列表(用于兼容现有代码) |
| |
| Raises: |
| 返回空列表如果获取失败 |
| """ |
| |
| 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)) |
| |
| if "claude-sonnet-4-6" in data.get('models', {}): |
| model = Model( |
| id='claude-sonnet-4-6-thinking', |
| object='model', |
| created=current_timestamp, |
| owned_by='google' |
| ) |
| model_list.append(model_to_dict(model)) |
| |
| if "claude-opus-4-6-thinking" in data.get('models', {}): |
| claude_opus_model = Model( |
| id='claude-opus-4-6', |
| 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) |
| } |
|
|