mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +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,5 @@
|
||||
"""GeminiCLI provider adapter package."""
|
||||
|
||||
from .plugin import register_all
|
||||
|
||||
__all__ = ["register_all"]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""GeminiCLI upstream client helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
from src.core.logger import logger
|
||||
from src.services.provider.adapters.gemini_cli.constants import (
|
||||
PROD_BASE_URL,
|
||||
get_v1internal_extra_headers,
|
||||
)
|
||||
from src.services.provider.adapters.gemini_cli.rust_http import (
|
||||
execute_gemini_cli_rust_http_request,
|
||||
)
|
||||
|
||||
_CODE_ASSIST_METADATA = {
|
||||
"ideType": "ANTIGRAVITY",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
|
||||
|
||||
def _extract_tier_raw(tier_obj: Any) -> str:
|
||||
"""Extract raw tier string from loadCodeAssist response objects."""
|
||||
if isinstance(tier_obj, str) and tier_obj.strip():
|
||||
return tier_obj.strip()
|
||||
if isinstance(tier_obj, dict):
|
||||
for key in ("id", "tierType"):
|
||||
value = tier_obj.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def extract_plan_type(data: dict[str, Any]) -> str | None:
|
||||
"""Best-effort normalized plan type for GeminiCLI OAuth accounts."""
|
||||
from src.core.oauth_plan import normalize_oauth_plan_type
|
||||
|
||||
for key in ("paidTier", "currentTier"):
|
||||
raw = _extract_tier_raw(data.get(key))
|
||||
normalized = normalize_oauth_plan_type(raw)
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def extract_project_id(data: dict[str, Any]) -> str:
|
||||
"""Extract project_id from loadCodeAssist/onboardUser responses."""
|
||||
raw = data.get("cloudaicompanionProject")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
if isinstance(raw, dict):
|
||||
project_id = raw.get("id", "")
|
||||
if isinstance(project_id, str) and project_id.strip():
|
||||
return project_id.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def extract_tier_id(data: dict[str, Any]) -> str:
|
||||
"""Choose a tier ID for onboarding when the account is not activated."""
|
||||
allowed_tiers = data.get("allowedTiers")
|
||||
if not isinstance(allowed_tiers, list):
|
||||
return ""
|
||||
|
||||
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()
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
async def load_code_assist(
|
||||
access_token: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Load GeminiCLI account metadata from v1internal:loadCodeAssist."""
|
||||
if not access_token:
|
||||
raise ValueError("missing access_token")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
**get_v1internal_extra_headers(),
|
||||
}
|
||||
url = f"{PROD_BASE_URL.rstrip('/')}/v1internal:loadCodeAssist"
|
||||
body = {"metadata": _CODE_ASSIST_METADATA}
|
||||
resp = await execute_gemini_cli_rust_http_request(
|
||||
method="POST",
|
||||
url=url,
|
||||
headers=headers,
|
||||
body=body,
|
||||
proxy_config=proxy_config,
|
||||
request_id="gemini-cli:load-code-assist",
|
||||
provider_api_format="gemini_cli: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:
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
raise RuntimeError(
|
||||
f"loadCodeAssist failed: status={resp.status_code} body={resp.text[:200] if resp.text else ''}"
|
||||
)
|
||||
|
||||
|
||||
async def onboard_user(
|
||||
access_token: str,
|
||||
*,
|
||||
tier_id: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
max_attempts: int = 5,
|
||||
poll_interval: float = 2.0,
|
||||
) -> str:
|
||||
"""Activate GeminiCLI user and fetch project_id via v1internal:onboardUser."""
|
||||
if not access_token:
|
||||
raise ValueError("missing access_token")
|
||||
if not tier_id:
|
||||
raise ValueError("missing tier_id")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
**get_v1internal_extra_headers(),
|
||||
}
|
||||
body = {
|
||||
"tierId": tier_id,
|
||||
"metadata": _CODE_ASSIST_METADATA,
|
||||
}
|
||||
url = f"{PROD_BASE_URL.rstrip('/')}/v1internal:onboardUser"
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
resp = await execute_gemini_cli_rust_http_request(
|
||||
method="POST",
|
||||
url=url,
|
||||
headers=headers,
|
||||
body=body,
|
||||
proxy_config=proxy_config,
|
||||
request_id=f"gemini-cli:onboard-user:{tier_id}:{attempt}",
|
||||
provider_api_format="gemini_cli: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 not (200 <= resp.status_code < 300):
|
||||
raise RuntimeError(
|
||||
f"onboardUser failed: status={resp.status_code} body={resp.text[:200] if resp.text else ''}"
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"onboardUser: unexpected response type: {type(data)}")
|
||||
|
||||
if data.get("done") is True:
|
||||
response_data = data.get("response")
|
||||
if isinstance(response_data, dict):
|
||||
return extract_project_id(response_data)
|
||||
return ""
|
||||
|
||||
if attempt < max_attempts:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
raise RuntimeError(f"onboardUser: timeout after {max_attempts} attempts")
|
||||
|
||||
|
||||
async def enrich_project_id(
|
||||
access_token: str,
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Best-effort project_id resolution for GeminiCLI OAuth keys."""
|
||||
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
|
||||
project_id = extract_project_id(code_assist)
|
||||
if project_id:
|
||||
return project_id
|
||||
|
||||
tier_id = extract_tier_id(code_assist)
|
||||
if tier_id:
|
||||
try:
|
||||
project_id = await onboard_user(
|
||||
access_token,
|
||||
tier_id=tier_id,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
if project_id:
|
||||
return project_id
|
||||
except Exception as exc:
|
||||
logger.warning("GeminiCLI onboardUser failed: {}", exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"enrich_project_id",
|
||||
"extract_plan_type",
|
||||
"extract_project_id",
|
||||
"extract_tier_id",
|
||||
"load_code_assist",
|
||||
"onboard_user",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""GeminiCLI provider constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.config.settings import config
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
from src.core.provider_templates.types import ProviderType
|
||||
|
||||
PROD_BASE_URL = FIXED_PROVIDERS[ProviderType.GEMINI_CLI].api_base_url
|
||||
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
|
||||
|
||||
|
||||
def get_v1internal_extra_headers() -> dict[str, str]:
|
||||
"""Headers required by GeminiCLI upstream requests."""
|
||||
return {
|
||||
"Accept-Encoding": "identity",
|
||||
"User-Agent": config.internal_user_agent_gemini_cli,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROD_BASE_URL",
|
||||
"V1INTERNAL_PATH_TEMPLATE",
|
||||
"get_v1internal_extra_headers",
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""GeminiCLI v1internal request envelope."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.services.provider.adapters.gemini_cli.constants import get_v1internal_extra_headers
|
||||
from src.services.provider.request_context import get_selected_base_url
|
||||
|
||||
|
||||
def wrap_v1internal_request(
|
||||
gemini_request: dict[str, Any],
|
||||
*,
|
||||
project_id: str,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Wrap a Gemini request into GeminiCLI v1internal format."""
|
||||
inner_request = dict(gemini_request)
|
||||
inner_request.pop("model", None)
|
||||
inner_request.pop("stream", None)
|
||||
return {
|
||||
"model": model,
|
||||
"project": project_id,
|
||||
"request": inner_request,
|
||||
}
|
||||
|
||||
|
||||
class GeminiCliV1InternalEnvelope:
|
||||
name = "gemini_cli:v1internal"
|
||||
|
||||
def extra_headers(self) -> dict[str, str] | None:
|
||||
return get_v1internal_extra_headers()
|
||||
|
||||
def wrap_request(
|
||||
self,
|
||||
request_body: dict[str, Any],
|
||||
*,
|
||||
model: str,
|
||||
url_model: str | None,
|
||||
decrypted_auth_config: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
_ = url_model
|
||||
project_id = (decrypted_auth_config or {}).get("project_id")
|
||||
if not isinstance(project_id, str) or not project_id:
|
||||
from src.core.exceptions import ProviderNotAvailableException
|
||||
|
||||
raise ProviderNotAvailableException(
|
||||
"GeminiCLI OAuth 配置缺少 project_id,请重新授权",
|
||||
provider_name="gemini_cli",
|
||||
upstream_response="missing auth_config.project_id",
|
||||
)
|
||||
|
||||
wrapped = wrap_v1internal_request(
|
||||
request_body,
|
||||
project_id=project_id,
|
||||
model=model,
|
||||
)
|
||||
return wrapped, None
|
||||
|
||||
def unwrap_response(self, data: Any) -> Any:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
response_obj = data.get("response")
|
||||
if "candidates" not in data and isinstance(response_obj, dict):
|
||||
return response_obj
|
||||
|
||||
return data
|
||||
|
||||
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
|
||||
_ = model, data
|
||||
return
|
||||
|
||||
def capture_selected_base_url(self) -> str | None:
|
||||
return get_selected_base_url()
|
||||
|
||||
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
|
||||
_ = base_url, status_code
|
||||
return
|
||||
|
||||
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None:
|
||||
_ = base_url, exc
|
||||
return
|
||||
|
||||
def force_stream_rewrite(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
gemini_cli_v1internal_envelope = GeminiCliV1InternalEnvelope()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GeminiCliV1InternalEnvelope",
|
||||
"gemini_cli_v1internal_envelope",
|
||||
"wrap_v1internal_request",
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""GeminiCLI provider plugin — unified registration entry."""
|
||||
|
||||
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.gemini_cli.constants import V1INTERNAL_PATH_TEMPLATE
|
||||
from src.services.provider.preset_models import get_preset_models
|
||||
from src.services.provider.request_context import set_selected_base_url
|
||||
|
||||
|
||||
async def fetch_models_gemini_cli(
|
||||
ctx: Any,
|
||||
timeout_seconds: float,
|
||||
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
|
||||
"""GeminiCLI model fetcher.
|
||||
|
||||
Upstream does not expose a stable public models endpoint for OAuth CLI access,
|
||||
so we currently return a curated preset model catalog and enrich account metadata
|
||||
from loadCodeAssist when possible.
|
||||
"""
|
||||
from src.services.provider.adapters.gemini_cli.client import (
|
||||
extract_plan_type,
|
||||
load_code_assist,
|
||||
)
|
||||
|
||||
models = get_preset_models("gemini_cli")
|
||||
upstream_metadata: dict[str, Any] | None = None
|
||||
|
||||
access_token = str(getattr(ctx, "api_key_value", "") or "").strip()
|
||||
if access_token:
|
||||
try:
|
||||
code_assist = await load_code_assist(
|
||||
access_token,
|
||||
proxy_config=getattr(ctx, "proxy_config", None),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
provider_meta: dict[str, Any] = {"updated_at": int(time.time())}
|
||||
plan_type = extract_plan_type(code_assist)
|
||||
if plan_type:
|
||||
provider_meta["plan_type"] = plan_type
|
||||
project_id = (getattr(ctx, "auth_config", None) or {}).get("project_id")
|
||||
if isinstance(project_id, str) and project_id:
|
||||
provider_meta["project_id"] = project_id
|
||||
upstream_metadata = {"gemini_cli": provider_meta}
|
||||
except Exception as exc:
|
||||
logger.debug("GeminiCLI model metadata fetch failed: {}", exc)
|
||||
|
||||
return models, [], True, upstream_metadata
|
||||
|
||||
|
||||
def build_gemini_cli_url(
|
||||
endpoint: Any,
|
||||
*,
|
||||
is_stream: bool,
|
||||
effective_query_params: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build GeminiCLI v1internal URL."""
|
||||
base_url = str(getattr(endpoint, "base_url", "") or "").rstrip("/")
|
||||
set_selected_base_url(base_url)
|
||||
|
||||
action = "streamGenerateContent" if is_stream else "generateContent"
|
||||
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
|
||||
url = f"{base_url}{path}"
|
||||
if is_stream:
|
||||
effective_query_params.setdefault("alt", "sse")
|
||||
if effective_query_params:
|
||||
query_string = urlencode(effective_query_params, doseq=True)
|
||||
if query_string:
|
||||
url = f"{url}?{query_string}"
|
||||
return url
|
||||
|
||||
|
||||
async def enrich_gemini_cli(
|
||||
auth_config: dict[str, Any],
|
||||
token_response: dict[str, Any],
|
||||
access_token: str,
|
||||
proxy_config: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""GeminiCLI auth_config enrichment: email + project_id."""
|
||||
from src.core.provider_oauth_utils import fetch_google_email
|
||||
from src.services.provider.adapters.gemini_cli.client import (
|
||||
enrich_project_id,
|
||||
extract_plan_type,
|
||||
load_code_assist,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
|
||||
except Exception as exc:
|
||||
code_assist = None
|
||||
logger.warning("[enrich] GeminiCLI loadCodeAssist failed: {}", exc)
|
||||
|
||||
if code_assist and not auth_config.get("tier"):
|
||||
plan_type = extract_plan_type(code_assist)
|
||||
if plan_type:
|
||||
auth_config["tier"] = plan_type
|
||||
|
||||
if not auth_config.get("project_id"):
|
||||
try:
|
||||
project_id = (code_assist and code_assist.get("cloudaicompanionProject")) or None
|
||||
if isinstance(project_id, dict):
|
||||
project_id = project_id.get("id")
|
||||
if isinstance(project_id, str) and project_id.strip():
|
||||
auth_config["project_id"] = project_id.strip()
|
||||
else:
|
||||
project_id = await enrich_project_id(access_token, proxy_config=proxy_config)
|
||||
if project_id:
|
||||
auth_config["project_id"] = project_id
|
||||
logger.info("[enrich] GeminiCLI project_id: {}", project_id[:8] + "...")
|
||||
except Exception as exc:
|
||||
logger.warning("[enrich] GeminiCLI project_id enrichment failed: {}", exc)
|
||||
|
||||
return auth_config
|
||||
|
||||
|
||||
def register_all() -> None:
|
||||
"""Register all GeminiCLI hooks into shared registries."""
|
||||
from src.core.provider_oauth_utils import register_auth_enricher
|
||||
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
|
||||
from src.services.provider.adapters.gemini_cli.envelope import gemini_cli_v1internal_envelope
|
||||
from src.services.provider.envelope import register_envelope
|
||||
from src.services.provider.transport import register_transport_hook
|
||||
|
||||
register_envelope("gemini_cli", "gemini:cli", gemini_cli_v1internal_envelope)
|
||||
register_envelope("gemini_cli", "gemini:chat", gemini_cli_v1internal_envelope)
|
||||
register_envelope("gemini_cli", "", gemini_cli_v1internal_envelope)
|
||||
|
||||
register_transport_hook("gemini_cli", "gemini:cli", build_gemini_cli_url)
|
||||
register_transport_hook("gemini_cli", "gemini:chat", build_gemini_cli_url)
|
||||
|
||||
register_auth_enricher("gemini_cli", enrich_gemini_cli)
|
||||
|
||||
UpstreamModelsFetcherRegistry.register(
|
||||
provider_types=["gemini_cli"],
|
||||
fetcher=fetch_models_gemini_cli,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_gemini_cli_url",
|
||||
"enrich_gemini_cli",
|
||||
"fetch_models_gemini_cli",
|
||||
"register_all",
|
||||
]
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Gemini CLI quota / RESOURCE_EXHAUSTED helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
_DURATION_TOKEN_RE = re.compile(r"(\d+(?:\.\d+)?)([dhms])")
|
||||
_RESET_AFTER_RE = re.compile(r"reset after\s+([^.,;]+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_json(error_text: str | None) -> dict[str, Any] | None:
|
||||
if not isinstance(error_text, str) or not error_text.strip():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(error_text)
|
||||
except Exception:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _parse_duration_seconds(raw: Any) -> int | None:
|
||||
if isinstance(raw, (int, float)):
|
||||
return max(1, int(raw))
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
text = raw.strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
total_seconds = 0.0
|
||||
matched = False
|
||||
for amount_text, unit in _DURATION_TOKEN_RE.findall(text):
|
||||
matched = True
|
||||
amount = float(amount_text)
|
||||
if unit == "d":
|
||||
total_seconds += amount * 86400
|
||||
elif unit == "h":
|
||||
total_seconds += amount * 3600
|
||||
elif unit == "m":
|
||||
total_seconds += amount * 60
|
||||
elif unit == "s":
|
||||
total_seconds += amount
|
||||
if not matched:
|
||||
return None
|
||||
return max(1, int(total_seconds))
|
||||
|
||||
|
||||
def _iter_error_details(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
error_obj = payload.get("error")
|
||||
if not isinstance(error_obj, dict):
|
||||
return []
|
||||
details = error_obj.get("details")
|
||||
if not isinstance(details, list):
|
||||
return []
|
||||
return [item for item in details if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _error_status(payload: dict[str, Any]) -> str:
|
||||
error_obj = payload.get("error")
|
||||
if not isinstance(error_obj, dict):
|
||||
return ""
|
||||
status = error_obj.get("status")
|
||||
return status.strip() if isinstance(status, str) else ""
|
||||
|
||||
|
||||
def _error_message(payload: dict[str, Any]) -> str:
|
||||
error_obj = payload.get("error")
|
||||
if not isinstance(error_obj, dict):
|
||||
return ""
|
||||
message = error_obj.get("message")
|
||||
return message.strip() if isinstance(message, str) else ""
|
||||
|
||||
|
||||
def _error_reason(payload: dict[str, Any]) -> str:
|
||||
for detail in _iter_error_details(payload):
|
||||
reason = detail.get("reason")
|
||||
if isinstance(reason, str) and reason.strip():
|
||||
return reason.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _looks_like_uuid(value: str | None) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
UUID(text)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_resource_exhausted_error(error_text: str | None) -> bool:
|
||||
payload = _parse_json(error_text)
|
||||
if payload is None:
|
||||
return False
|
||||
|
||||
status = _error_status(payload).upper()
|
||||
reason = _error_reason(payload).upper()
|
||||
if status == "RESOURCE_EXHAUSTED" or reason == "QUOTA_EXHAUSTED":
|
||||
return True
|
||||
|
||||
message = _error_message(payload).lower()
|
||||
return ("exhausted your capacity" in message) or ("quota" in message and "exhaust" in message)
|
||||
|
||||
|
||||
def parse_quota_reset_timestamp(error_text: str | None) -> int | None:
|
||||
payload = _parse_json(error_text)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
for detail in _iter_error_details(payload):
|
||||
metadata = detail.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
raw = metadata.get("quotaResetTimeStamp") or metadata.get("quotaResetTimestamp")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
text = raw.strip()
|
||||
try:
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(text)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return int(parsed.timestamp())
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def parse_quota_reset_delay_seconds(error_text: str | None) -> int | None:
|
||||
payload = _parse_json(error_text)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
for detail in _iter_error_details(payload):
|
||||
metadata = detail.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
delay = metadata.get("quotaResetDelay")
|
||||
parsed = _parse_duration_seconds(delay)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def parse_quota_reset_message_seconds(error_text: str | None) -> int | None:
|
||||
payload = _parse_json(error_text)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
message = _error_message(payload)
|
||||
if not message:
|
||||
return None
|
||||
|
||||
matched = _RESET_AFTER_RE.search(message)
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
return _parse_duration_seconds(matched.group(1))
|
||||
|
||||
|
||||
def extract_error_model_name(error_text: str | None, *, fallback: str | None = None) -> str | None:
|
||||
payload = _parse_json(error_text)
|
||||
if payload is not None:
|
||||
for detail in _iter_error_details(payload):
|
||||
metadata = detail.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
model = metadata.get("model")
|
||||
if isinstance(model, str) and model.strip():
|
||||
return model.strip()
|
||||
|
||||
fallback_text = str(fallback or "").strip()
|
||||
if fallback_text and not _looks_like_uuid(fallback_text):
|
||||
return fallback_text
|
||||
return None
|
||||
|
||||
|
||||
def extract_quota_cooldown_seconds(
|
||||
error_text: str | None, *, now_ts: int | None = None
|
||||
) -> int | None:
|
||||
now = int(now_ts or time.time())
|
||||
|
||||
reset_at = parse_quota_reset_timestamp(error_text)
|
||||
if reset_at is not None:
|
||||
return max(1, reset_at - now)
|
||||
|
||||
delay = parse_quota_reset_delay_seconds(error_text)
|
||||
if delay is not None:
|
||||
return max(1, delay)
|
||||
|
||||
message_delay = parse_quota_reset_message_seconds(error_text)
|
||||
if message_delay is not None:
|
||||
return max(1, message_delay)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_quota_exhausted_metadata(
|
||||
*,
|
||||
model_name: str,
|
||||
error_text: str | None,
|
||||
current_namespace: dict[str, Any] | None = None,
|
||||
now_ts: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
normalized_model = str(model_name or "").strip()
|
||||
if not normalized_model:
|
||||
return None
|
||||
if not is_resource_exhausted_error(error_text):
|
||||
return None
|
||||
|
||||
now = int(now_ts or time.time())
|
||||
reset_at = parse_quota_reset_timestamp(error_text)
|
||||
if reset_at is None:
|
||||
delay = parse_quota_reset_delay_seconds(error_text)
|
||||
if delay is not None:
|
||||
reset_at = now + delay
|
||||
if reset_at is None:
|
||||
message_delay = parse_quota_reset_message_seconds(error_text)
|
||||
if message_delay is not None:
|
||||
reset_at = now + message_delay
|
||||
if reset_at is None:
|
||||
return None
|
||||
|
||||
payload = _parse_json(error_text) or {}
|
||||
namespace = dict(current_namespace) if isinstance(current_namespace, dict) else {}
|
||||
quota_by_model_raw = namespace.get("quota_by_model")
|
||||
quota_by_model = dict(quota_by_model_raw) if isinstance(quota_by_model_raw, dict) else {}
|
||||
model_entry_raw = quota_by_model.get(normalized_model)
|
||||
model_entry = dict(model_entry_raw) if isinstance(model_entry_raw, dict) else {}
|
||||
|
||||
model_entry["is_exhausted"] = True
|
||||
model_entry["remaining_fraction"] = 0.0
|
||||
model_entry["used_percent"] = 100.0
|
||||
model_entry["updated_at"] = now
|
||||
|
||||
model_entry["reset_at"] = reset_at
|
||||
model_entry["reset_time"] = datetime.fromtimestamp(reset_at, timezone.utc).isoformat()
|
||||
model_entry["reset_seconds"] = max(0, reset_at - now)
|
||||
|
||||
reason = _error_reason(payload) or _error_status(payload) or _error_message(payload)
|
||||
if reason:
|
||||
model_entry["reason"] = reason
|
||||
|
||||
quota_by_model[normalized_model] = model_entry
|
||||
namespace["quota_by_model"] = quota_by_model
|
||||
namespace["updated_at"] = now
|
||||
|
||||
status = _error_status(payload)
|
||||
if status:
|
||||
namespace["last_error_status"] = status
|
||||
if reason:
|
||||
namespace["last_error_reason"] = reason
|
||||
|
||||
return {"gemini_cli": namespace}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"extract_error_model_name",
|
||||
"build_quota_exhausted_metadata",
|
||||
"extract_quota_cooldown_seconds",
|
||||
"is_resource_exhausted_error",
|
||||
"parse_quota_reset_message_seconds",
|
||||
"parse_quota_reset_delay_seconds",
|
||||
"parse_quota_reset_timestamp",
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Shared Rust executor HTTP helper for Gemini CLI 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_gemini_cli_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="GeminiCLI")
|
||||
result = await ExecutionRuntimeClient().execute_sync_json(
|
||||
ExecutionPlan(
|
||||
request_id=request_id,
|
||||
candidate_id=None,
|
||||
provider_name="gemini_cli",
|
||||
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="gemini_cli",
|
||||
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("GeminiCLI Rust HTTP fallback {} {}: {}", method, url, exc)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("GeminiCLI 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_gemini_cli_rust_http_request"]
|
||||
Reference in New Issue
Block a user