from __future__ import annotations import time from pathlib import Path from types import SimpleNamespace from typing import Any from .antigravity_client import AntigravityClient from .cloudcode import load_or_onboard_project from .credentials import CredentialStore, load_agy_keychain_credentials from .errors import ProxyError, TokenExpired from .oauth import refresh_access_token from .openai_compat import ChatRequest, parse_chat_request, to_openai_completion from .transform import build_generate_content_request def load_antigravity_credentials(store: Any | None = None) -> dict[str, Any]: """Load, refresh, and persist credentials for plugin use.""" if store is None: store = CredentialStore.default() keychain = load_agy_keychain_credentials() from_keychain = bool(keychain) stored = {} if from_keychain else store.load() creds = {**stored, **keychain} dirty = False access = creds.get("access_token") or creds.get("access") or creds.get("token") refresh = creds.get("refresh_token") or creds.get("refresh") project = creds.get("project_id") or creds.get("projectId") if not access and not refresh: raise ProxyError( "Missing Antigravity credentials. Run `hermes agy login`.", status=401, error_type="invalid_request_error", ) expires = creds.get("expires_at") or creds.get("expires") if refresh and (not access or (isinstance(expires, (int, float)) and time.time() + 60 >= float(expires))): refreshed = refresh_access_token(str(refresh)) creds.update(refreshed) access = refreshed["access_token"] refresh = creds.get("refresh_token") or creds.get("refresh") dirty = not from_keychain if not project: if not access: raise ProxyError( "Missing access token for Antigravity project discovery", status=401, error_type="invalid_request_error", ) project = load_or_onboard_project(str(access)) creds["project_id"] = project dirty = not from_keychain if dirty: store.save(creds) return { "access_token": str(access or creds["access_token"]), "refresh_token": str(refresh or creds.get("refresh_token") or ""), "project_id": str(project), "source": "agy-keychain" if from_keychain else "store", } def build_upstream_body(request: ChatRequest, *, store: Any | None = None) -> tuple[dict[str, Any], dict[str, Any]]: creds = load_antigravity_credentials(store) body = build_generate_content_request( model=request.model, project_id=creds["project_id"], messages=request.messages, tools=request.tools, reasoning_effort=request.reasoning_effort, max_tokens=request.max_tokens, temperature=request.temperature, top_p=request.top_p, tool_choice=request.tool_choice, ) return body, creds def generate_chat_completion( payload: dict[str, Any], *, client: Any | None = None, store: Any | None = None, ) -> dict[str, Any]: """Execute one OpenAI-shaped chat request against Antigravity in-process.""" request = parse_chat_request(payload) if client is None: client = AntigravityClient() if store is None: store = CredentialStore.default() body, creds = build_upstream_body(request, store=store) try: upstream = client.generate(access_token=creds["access_token"], body=body) except TokenExpired: if not creds.get("refresh_token"): raise refreshed = refresh_access_token(creds["refresh_token"]) if creds.get("source") == "store": saved = store.load() saved.update(refreshed) store.save(saved) upstream = client.generate(access_token=refreshed["access_token"], body=body) return to_openai_completion(request.model, upstream) def _namespace(value: Any) -> Any: if isinstance(value, dict): return SimpleNamespace(**{k: _namespace(v) for k, v in value.items()}) if isinstance(value, list): return [_namespace(v) for v in value] return value def format_antigravity_error(err: Any) -> str: """Format an error message with 'Antigravity error: ' prefix without duplicate prefixes.""" msg = err.get("message") if isinstance(err, dict) else str(err or "unknown error") msg = msg.strip() prefixes_to_strip = [ "Antigravity error:", "Antigravity (agy) error:", "agy error:", "Antigravity error", "agy error", ] changed = True while changed: changed = False for p in prefixes_to_strip: if msg.lower().startswith(p.lower()): msg = msg[len(p):].strip(" :") changed = True break return f"Antigravity error: {msg}" if msg else "Antigravity error: unknown error" def openai_completion_object(completion: dict[str, Any]) -> SimpleNamespace: """Return an object compatible with Hermes' ChatCompletionsTransport.""" completion = dict(completion) choices = [] # Handle error payload without crashing choices[0] if "error" in completion and not completion.get("choices"): err_text = format_antigravity_error(completion.get("error")) choices.append({ "index": 0, "message": { "role": "assistant", "content": err_text, "tool_calls": None, }, "finish_reason": "stop", }) else: for raw_choice in completion.get("choices") or []: choice = dict(raw_choice) message = dict(choice.get("message") or {}) message.setdefault("content", None) message.setdefault("tool_calls", None) choice["message"] = message choices.append(choice) # Fallback to ensure choices is never empty if not choices: choices.append({ "index": 0, "message": { "role": "assistant", "content": "Empty or unrecognized completion response from provider.", "tool_calls": None, }, "finish_reason": "stop", }) completion["choices"] = choices completion.setdefault("usage", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}) return _namespace(completion) def ensure_provider_profile_files(root: Path | None = None) -> Path: """Install the tiny model-provider profile that makes `hermes model` see Antigravity.""" if root is None: try: from hermes_constants import get_hermes_home root = get_hermes_home() except Exception: root = Path.home() / ".hermes" plugin_dir = Path(root).expanduser() / "plugins" / "model-providers" / "antigravity" plugin_dir.mkdir(parents=True, exist_ok=True) (plugin_dir / "__init__.py").write_text( "from antigravity_provider.hermes_provider import register_provider_profile\n" "register_provider_profile()\n", encoding="utf-8", ) (plugin_dir / "plugin.yaml").write_text( "name: antigravity\n" "kind: model-provider\n" "version: 0.1.0\n" "description: Google Antigravity provider profile\n", encoding="utf-8", ) return plugin_dir