mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
feat(cli): add native Antigravity OAuth provider
This commit is contained in:
parent
29176ffecf
commit
8baa4e9976
25 changed files with 2371 additions and 18 deletions
|
|
@ -1394,6 +1394,21 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
|
|||
agent._client_log_context(),
|
||||
)
|
||||
return client
|
||||
if agent.provider == "google-antigravity" or str(client_kwargs.get("base_url", "")).startswith("antigravity-pa://"):
|
||||
from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient
|
||||
|
||||
safe_kwargs = {
|
||||
k: v for k, v in client_kwargs.items()
|
||||
if k in {"api_key", "base_url", "default_headers", "project_id", "timeout"}
|
||||
}
|
||||
client = AntigravityCloudCodeClient(**safe_kwargs)
|
||||
_ra().logger.info(
|
||||
"Antigravity Code Assist client created (%s, shared=%s) %s",
|
||||
reason,
|
||||
shared,
|
||||
agent._client_log_context(),
|
||||
)
|
||||
return client
|
||||
if agent.provider == "gemini":
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
|
||||
|
||||
|
|
|
|||
164
agent/antigravity_cloudcode_adapter.py
Normal file
164
agent/antigravity_cloudcode_adapter.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""OpenAI-compatible facade for Antigravity native OAuth inference."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent import antigravity_oauth
|
||||
from agent.antigravity_code_assist import (
|
||||
ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
|
||||
CodeAssistError,
|
||||
ProjectContext,
|
||||
build_headers,
|
||||
resolve_project_context,
|
||||
)
|
||||
from agent.gemini_cloudcode_adapter import (
|
||||
GeminiCloudCodeClient,
|
||||
_GeminiStreamChunk,
|
||||
_gemini_http_error,
|
||||
_iter_sse_events,
|
||||
_translate_gemini_response,
|
||||
_translate_stream_event,
|
||||
build_gemini_request,
|
||||
wrap_code_assist_request,
|
||||
)
|
||||
|
||||
MARKER_BASE_URL = "antigravity-pa://google"
|
||||
|
||||
|
||||
class AntigravityCloudCodeClient(GeminiCloudCodeClient):
|
||||
"""Minimal OpenAI-SDK-compatible facade over Antigravity Code Assist."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
default_headers: Optional[Dict[str, str]] = None,
|
||||
project_id: str = "",
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(
|
||||
api_key=api_key or "antigravity-oauth",
|
||||
base_url=base_url or MARKER_BASE_URL,
|
||||
default_headers=default_headers,
|
||||
project_id=project_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:
|
||||
if self._project_context is not None:
|
||||
return self._project_context # type: ignore[return-value]
|
||||
|
||||
env_project = antigravity_oauth.resolve_project_id_from_env()
|
||||
creds = antigravity_oauth.load_credentials()
|
||||
stored_project = creds.project_id if creds else ""
|
||||
if stored_project:
|
||||
self._project_context = ProjectContext(
|
||||
project_id=stored_project,
|
||||
managed_project_id=creds.managed_project_id if creds else "",
|
||||
source="stored",
|
||||
)
|
||||
return self._project_context
|
||||
|
||||
ctx = resolve_project_context(
|
||||
access_token,
|
||||
configured_project_id=self._configured_project_id,
|
||||
env_project_id=env_project,
|
||||
)
|
||||
if ctx.project_id or ctx.managed_project_id:
|
||||
antigravity_oauth.update_project_ids(
|
||||
project_id=ctx.project_id,
|
||||
managed_project_id=ctx.managed_project_id,
|
||||
)
|
||||
self._project_context = ctx
|
||||
return ctx
|
||||
|
||||
def _create_chat_completion(
|
||||
self,
|
||||
*,
|
||||
model: str = "gemini-3-flash-agent",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = False,
|
||||
tools: Any = None,
|
||||
tool_choice: Any = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
top_p: Optional[float] = None,
|
||||
stop: Any = None,
|
||||
extra_body: Optional[Dict[str, Any]] = None,
|
||||
timeout: Any = None,
|
||||
**_: Any,
|
||||
) -> Any:
|
||||
access_token = antigravity_oauth.get_valid_access_token()
|
||||
ctx = self._ensure_project_context(access_token, model)
|
||||
|
||||
thinking_config = None
|
||||
if isinstance(extra_body, dict):
|
||||
thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
|
||||
|
||||
inner = build_gemini_request(
|
||||
messages=messages or [],
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
stop=stop,
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
wrapped = wrap_code_assist_request(
|
||||
project_id=ctx.project_id,
|
||||
model=model,
|
||||
inner_request=inner,
|
||||
)
|
||||
|
||||
headers = build_headers(access_token)
|
||||
headers.update(self._default_headers)
|
||||
|
||||
if stream:
|
||||
return self._stream_completion(model=model, wrapped=wrapped, headers=headers)
|
||||
|
||||
url = f"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:generateContent"
|
||||
response = self._http.post(url, json=wrapped, headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise _gemini_http_error(response)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise CodeAssistError(
|
||||
f"Invalid JSON from Antigravity Code Assist: {exc}",
|
||||
code="antigravity_code_assist_invalid_json",
|
||||
) from exc
|
||||
return _translate_gemini_response(payload, model=model)
|
||||
|
||||
def _stream_completion(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
wrapped: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
) -> Iterator[_GeminiStreamChunk]:
|
||||
url = f"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse"
|
||||
stream_headers = dict(headers)
|
||||
stream_headers["Accept"] = "text/event-stream"
|
||||
|
||||
def _generator() -> Iterator[_GeminiStreamChunk]:
|
||||
try:
|
||||
with self._http.stream("POST", url, json=wrapped, headers=stream_headers) as response:
|
||||
if response.status_code != 200:
|
||||
response.read()
|
||||
raise _gemini_http_error(response)
|
||||
tool_call_counter: List[int] = [0]
|
||||
for event in _iter_sse_events(response):
|
||||
for chunk in _translate_stream_event(event, model, tool_call_counter):
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
raise CodeAssistError(
|
||||
f"Antigravity streaming request failed: {exc}",
|
||||
code="antigravity_code_assist_stream_error",
|
||||
) from exc
|
||||
|
||||
return _generator()
|
||||
276
agent/antigravity_code_assist.py
Normal file
276
agent/antigravity_code_assist.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""Antigravity Code Assist control-plane helpers.
|
||||
|
||||
The new Antigravity CLI uses the same v1internal Code Assist family as
|
||||
gemini-cli, but with Antigravity OAuth scopes, metadata and model catalog. This
|
||||
module keeps that provider-specific surface separate from
|
||||
``agent.google_code_assist``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from agent.google_code_assist import CodeAssistError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ANTIGRAVITY_CODE_ASSIST_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
ANTIGRAVITY_MODEL_ENDPOINTS = [
|
||||
ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||
]
|
||||
|
||||
ANTIGRAVITY_CLIENT_METADATA = {
|
||||
"ideType": "ANTIGRAVITY",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
ANTIGRAVITY_USER_AGENT = "antigravity/1.0.0 windows/amd64"
|
||||
ANTIGRAVITY_X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1"
|
||||
|
||||
DEFAULT_AGENT_MODEL_IDS = [
|
||||
"gemini-3-flash-agent",
|
||||
"gemini-3.5-flash-low",
|
||||
"gemini-pro-agent",
|
||||
"gemini-3.1-pro-low",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-6-thinking",
|
||||
"gpt-oss-120b-medium",
|
||||
]
|
||||
|
||||
DEPRECATED_MODEL_REPLACEMENTS = {
|
||||
"gemini-3.1-pro-high": "gemini-pro-agent",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AntigravityProjectInfo:
|
||||
project_id: str = ""
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectContext:
|
||||
project_id: str = ""
|
||||
managed_project_id: str = ""
|
||||
tier_id: str = ""
|
||||
source: str = ""
|
||||
|
||||
|
||||
def _client_metadata() -> Dict[str, str]:
|
||||
return dict(ANTIGRAVITY_CLIENT_METADATA)
|
||||
|
||||
|
||||
def build_headers(access_token: str, *, accept: str = "application/json") -> Dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": accept,
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": ANTIGRAVITY_USER_AGENT,
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_X_GOOG_API_CLIENT,
|
||||
"Client-Metadata": json.dumps(_client_metadata(), separators=(",", ":")),
|
||||
"x-activity-request-id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
|
||||
def _post_json(
|
||||
url: str,
|
||||
body: Dict[str, Any],
|
||||
access_token: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
) -> Dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers=build_headers(access_token),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
raise CodeAssistError(
|
||||
f"Antigravity Code Assist HTTP {exc.code}: {detail or exc.reason}",
|
||||
code=f"antigravity_code_assist_http_{exc.code}",
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise CodeAssistError(
|
||||
f"Antigravity Code Assist request failed: {exc}",
|
||||
code="antigravity_code_assist_network_error",
|
||||
) from exc
|
||||
|
||||
|
||||
def load_code_assist(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
|
||||
) -> AntigravityProjectInfo:
|
||||
metadata = _client_metadata()
|
||||
if project_id:
|
||||
metadata["duetProject"] = project_id
|
||||
body: Dict[str, Any] = {"metadata": metadata}
|
||||
if project_id:
|
||||
body["cloudaicompanionProject"] = project_id
|
||||
resp = _post_json(f"{endpoint}/v1internal:loadCodeAssist", body, access_token)
|
||||
project = (
|
||||
str(resp.get("cloudaicompanionProject") or "").strip()
|
||||
or str(resp.get("project") or "").strip()
|
||||
)
|
||||
return AntigravityProjectInfo(project_id=project, raw=resp)
|
||||
|
||||
|
||||
def resolve_project_context(
|
||||
access_token: str,
|
||||
*,
|
||||
configured_project_id: str = "",
|
||||
env_project_id: str = "",
|
||||
) -> ProjectContext:
|
||||
if configured_project_id:
|
||||
return ProjectContext(project_id=configured_project_id, source="config")
|
||||
if env_project_id:
|
||||
return ProjectContext(project_id=env_project_id, source="env")
|
||||
info = load_code_assist(access_token)
|
||||
return ProjectContext(
|
||||
project_id=info.project_id,
|
||||
managed_project_id=info.project_id,
|
||||
source="discovered" if info.project_id else "unknown",
|
||||
)
|
||||
|
||||
|
||||
def fetch_available_models(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
endpoint: str = ANTIGRAVITY_CODE_ASSIST_ENDPOINT,
|
||||
) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {}
|
||||
if project_id:
|
||||
body["project"] = project_id
|
||||
return _post_json(f"{endpoint}/v1internal:fetchAvailableModels", body, access_token)
|
||||
|
||||
|
||||
def fetch_available_models_with_fallbacks(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
endpoints: Optional[Iterable[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
last_err: Optional[Exception] = None
|
||||
for endpoint in endpoints or ANTIGRAVITY_MODEL_ENDPOINTS:
|
||||
try:
|
||||
return fetch_available_models(
|
||||
access_token,
|
||||
project_id=project_id,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
logger.debug("Antigravity fetchAvailableModels failed on %s: %s", endpoint, exc)
|
||||
if last_err:
|
||||
raise last_err
|
||||
return {}
|
||||
|
||||
|
||||
def _model_id_from_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, dict):
|
||||
for key in ("modelId", "model_id", "id", "name"):
|
||||
candidate = str(value.get(key) or "").strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _ids_from_sort(sort: Dict[str, Any]) -> List[str]:
|
||||
ids: List[str] = []
|
||||
for key in ("modelIds", "model_ids", "models", "modelSorts"):
|
||||
value = sort.get(key)
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
mid = _model_id_from_value(item)
|
||||
if mid:
|
||||
ids.append(mid)
|
||||
elif isinstance(value, dict):
|
||||
mid = _model_id_from_value(value)
|
||||
if mid:
|
||||
ids.append(mid)
|
||||
return ids
|
||||
|
||||
|
||||
def _is_recommended_sort(sort: Dict[str, Any]) -> bool:
|
||||
label = " ".join(
|
||||
str(sort.get(key) or "")
|
||||
for key in ("name", "displayName", "title", "category", "group")
|
||||
).lower()
|
||||
return "recommended" in label
|
||||
|
||||
|
||||
def _raw_model_ids(payload: Dict[str, Any]) -> List[str]:
|
||||
ids: List[str] = []
|
||||
models = payload.get("models")
|
||||
if isinstance(models, list):
|
||||
for item in models:
|
||||
mid = _model_id_from_value(item)
|
||||
if mid:
|
||||
ids.append(mid)
|
||||
return ids
|
||||
|
||||
|
||||
def filter_agent_model_ids(ids: Iterable[str]) -> List[str]:
|
||||
seen: set[str] = set()
|
||||
filtered: List[str] = []
|
||||
raw = [str(mid).strip() for mid in ids if str(mid).strip()]
|
||||
replacements = set(DEPRECATED_MODEL_REPLACEMENTS.values())
|
||||
for mid in raw:
|
||||
if mid in seen:
|
||||
continue
|
||||
if mid.startswith(("chat_", "tab_")):
|
||||
continue
|
||||
if mid in DEPRECATED_MODEL_REPLACEMENTS and DEPRECATED_MODEL_REPLACEMENTS[mid] in raw:
|
||||
continue
|
||||
if mid in replacements and mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
filtered.append(mid)
|
||||
return filtered
|
||||
|
||||
|
||||
def parse_agent_model_ids(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Return the user-facing Antigravity agent model list in display order."""
|
||||
sorts = payload.get("agentModelSorts")
|
||||
ordered: List[str] = []
|
||||
if isinstance(sorts, list):
|
||||
recommended = [s for s in sorts if isinstance(s, dict) and _is_recommended_sort(s)]
|
||||
rest = [s for s in sorts if isinstance(s, dict) and not _is_recommended_sort(s)]
|
||||
for sort in recommended + rest:
|
||||
ordered.extend(_ids_from_sort(sort))
|
||||
|
||||
if not ordered:
|
||||
default_id = str(payload.get("defaultAgentModelId") or "").strip()
|
||||
if default_id:
|
||||
ordered.append(default_id)
|
||||
for mid in DEFAULT_AGENT_MODEL_IDS:
|
||||
ordered.append(mid)
|
||||
ordered.extend(_raw_model_ids(payload))
|
||||
|
||||
filtered = filter_agent_model_ids(ordered)
|
||||
if filtered:
|
||||
return filtered
|
||||
return list(DEFAULT_AGENT_MODEL_IDS)
|
||||
872
agent/antigravity_oauth.py
Normal file
872
agent/antigravity_oauth.py
Normal file
|
|
@ -0,0 +1,872 @@
|
|||
"""Google OAuth PKCE flow for the Antigravity (google-antigravity) provider.
|
||||
|
||||
Tokens are stored separately from the existing ``google-gemini-cli`` provider so
|
||||
development and production credentials do not accidentally bleed across:
|
||||
|
||||
~/.hermes/auth/antigravity_oauth.json
|
||||
|
||||
The on-disk schema matches ``agent.google_oauth`` so the runtime resolver can
|
||||
share the same refresh/project-id packing convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENV_CLIENT_ID = "HERMES_ANTIGRAVITY_CLIENT_ID"
|
||||
ENV_CLIENT_SECRET = "HERMES_ANTIGRAVITY_CLIENT_SECRET"
|
||||
ENV_CLI_PATH = "HERMES_ANTIGRAVITY_CLI_PATH"
|
||||
|
||||
_CLIENT_ID_PATTERN = re.compile(
|
||||
r"([0-9]{8,}-[a-z0-9]{20,}\.apps\.googleusercontent\.com)"
|
||||
)
|
||||
_CLIENT_SECRET_PATTERN = re.compile(r"(GOCSPX-[A-Za-z0-9_-]{20,80})")
|
||||
_DISCOVERY_MAX_FILE_BYTES = 25 * 1024 * 1024
|
||||
_DISCOVERY_MAX_AGY_BINARY_BYTES = 220 * 1024 * 1024
|
||||
_DISCOVERY_MAX_FILES = 600
|
||||
_DISCOVERY_EXTENSIONS = {
|
||||
"",
|
||||
".cjs",
|
||||
".exe",
|
||||
".js",
|
||||
".json",
|
||||
".mjs",
|
||||
".node",
|
||||
".ts",
|
||||
}
|
||||
_DISCOVERY_SKIP_DIRS = {
|
||||
".system_generated",
|
||||
"brain",
|
||||
"conversations",
|
||||
"log",
|
||||
"logs",
|
||||
"scratch",
|
||||
}
|
||||
|
||||
AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
|
||||
USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
|
||||
OAUTH_SCOPES = (
|
||||
"https://www.googleapis.com/auth/cloud-platform "
|
||||
"https://www.googleapis.com/auth/userinfo.email "
|
||||
"https://www.googleapis.com/auth/userinfo.profile "
|
||||
"https://www.googleapis.com/auth/cclog "
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs"
|
||||
)
|
||||
|
||||
DEFAULT_REDIRECT_PORT = 51121
|
||||
REDIRECT_HOST = "localhost"
|
||||
CALLBACK_PATH = "/oauth-callback"
|
||||
REFRESH_SKEW_SECONDS = 60
|
||||
TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0
|
||||
CALLBACK_WAIT_SECONDS = 300
|
||||
LOCK_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
class AntigravityOAuthError(RuntimeError):
|
||||
def __init__(self, message: str, *, code: str = "antigravity_oauth_error") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _credentials_path() -> Path:
|
||||
return get_hermes_home() / "auth" / "antigravity_oauth.json"
|
||||
|
||||
|
||||
def _lock_path() -> Path:
|
||||
return _credentials_path().with_suffix(".json.lock")
|
||||
|
||||
|
||||
_lock_state = threading.local()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _credentials_lock(timeout_seconds: float = LOCK_TIMEOUT_SECONDS):
|
||||
depth = getattr(_lock_state, "depth", 0)
|
||||
if depth > 0:
|
||||
_lock_state.depth = depth + 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_lock_state.depth -= 1
|
||||
return
|
||||
|
||||
lock_file_path = _lock_path()
|
||||
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lock_file_path), os.O_CREAT | os.O_RDWR, 0o600)
|
||||
acquired = False
|
||||
try:
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None
|
||||
|
||||
if fcntl is not None:
|
||||
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
acquired = True
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"Timed out acquiring Antigravity OAuth credentials lock at {lock_file_path}."
|
||||
)
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
try:
|
||||
import msvcrt # type: ignore[import-not-found]
|
||||
|
||||
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
|
||||
while True:
|
||||
try:
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
||||
acquired = True
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"Timed out acquiring Antigravity OAuth credentials lock at {lock_file_path}."
|
||||
)
|
||||
time.sleep(0.05)
|
||||
except ImportError:
|
||||
acquired = True
|
||||
|
||||
_lock_state.depth = 1
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
if acquired:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except ImportError:
|
||||
try:
|
||||
import msvcrt # type: ignore[import-not-found]
|
||||
|
||||
try:
|
||||
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
||||
except OSError:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
_lock_state.depth = 0
|
||||
|
||||
|
||||
_discovered_creds_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _secret_candidates(raw: str) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
for length in (35, 34, 36, 33, 37, 38, 39, 40, 41, 42):
|
||||
if len(raw) >= length:
|
||||
candidates.append(raw[:length])
|
||||
candidates.append(raw)
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _candidate_discovery_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
|
||||
explicit = (os.getenv(ENV_CLI_PATH) or "").strip()
|
||||
if explicit:
|
||||
roots.append(Path(explicit))
|
||||
|
||||
for command in ("agy", "agy.exe", "antigravity", "antigravity.exe"):
|
||||
found = shutil.which(command)
|
||||
if found:
|
||||
roots.append(Path(found))
|
||||
|
||||
for env_key in ("LOCALAPPDATA", "APPDATA", "ProgramFiles", "ProgramFiles(x86)"):
|
||||
base = os.getenv(env_key)
|
||||
if not base:
|
||||
continue
|
||||
base_path = Path(base)
|
||||
roots.extend((
|
||||
base_path / "agy",
|
||||
base_path / "agy" / "bin" / "agy.exe",
|
||||
base_path / "Programs" / "Antigravity",
|
||||
base_path / "Programs" / "Antigravity CLI",
|
||||
base_path / "Google" / "Antigravity",
|
||||
base_path / "Google" / "Antigravity CLI",
|
||||
))
|
||||
|
||||
home = Path.home()
|
||||
for root in (
|
||||
home / ".gemini" / "antigravity-cli",
|
||||
home / ".antigravitycli",
|
||||
home / ".antigravity",
|
||||
):
|
||||
roots.append(root)
|
||||
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
try:
|
||||
key = str(root.expanduser().resolve())
|
||||
except OSError:
|
||||
key = str(root.expanduser())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(root)
|
||||
return unique
|
||||
|
||||
|
||||
def _iter_discovery_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(path: Path) -> None:
|
||||
if len(files) >= _DISCOVERY_MAX_FILES:
|
||||
return
|
||||
if path.suffix.lower() not in _DISCOVERY_EXTENSIONS:
|
||||
return
|
||||
try:
|
||||
stat_info = path.stat()
|
||||
max_bytes = (
|
||||
_DISCOVERY_MAX_AGY_BINARY_BYTES
|
||||
if path.name.lower() in {"agy", "agy.exe", "antigravity", "antigravity.exe"}
|
||||
else _DISCOVERY_MAX_FILE_BYTES
|
||||
)
|
||||
if not path.is_file() or stat_info.st_size > max_bytes:
|
||||
return
|
||||
key = str(path.resolve())
|
||||
except OSError:
|
||||
return
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
files.append(path)
|
||||
|
||||
for root in _candidate_discovery_roots():
|
||||
if len(files) >= _DISCOVERY_MAX_FILES:
|
||||
break
|
||||
try:
|
||||
if root.is_file():
|
||||
add(root)
|
||||
continue
|
||||
if not root.is_dir():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in _DISCOVERY_SKIP_DIRS and not d.startswith(".git")
|
||||
]
|
||||
for filename in filenames:
|
||||
add(Path(dirpath) / filename)
|
||||
if len(files) >= _DISCOVERY_MAX_FILES:
|
||||
break
|
||||
if len(files) >= _DISCOVERY_MAX_FILES:
|
||||
break
|
||||
return files
|
||||
|
||||
|
||||
def _extract_client_credential_candidates_from_text(content: str) -> list[Tuple[str, str]]:
|
||||
client_ids = list(dict.fromkeys(match.group(1) for match in _CLIENT_ID_PATTERN.finditer(content)))
|
||||
secrets: list[str] = []
|
||||
for match in _CLIENT_SECRET_PATTERN.finditer(content):
|
||||
secrets.extend(_secret_candidates(match.group(1)))
|
||||
secrets = list(dict.fromkeys(secrets))
|
||||
return [(client_id, secret) for client_id in client_ids for secret in secrets]
|
||||
|
||||
|
||||
def _discover_client_credentials() -> Tuple[str, str]:
|
||||
if _discovered_creds_cache.get("resolved"):
|
||||
return (
|
||||
_discovered_creds_cache.get("client_id", ""),
|
||||
_discovered_creds_cache.get("client_secret", ""),
|
||||
)
|
||||
|
||||
for path in _iter_discovery_files():
|
||||
try:
|
||||
content = path.read_bytes().decode("utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
candidates = _extract_client_credential_candidates_from_text(content)
|
||||
if candidates:
|
||||
client_id, client_secret = candidates[0]
|
||||
_discovered_creds_cache.update({
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"candidates": candidates,
|
||||
"resolved": "1",
|
||||
})
|
||||
logger.info("Discovered Antigravity OAuth client credentials from %s", path)
|
||||
return client_id, client_secret
|
||||
|
||||
_discovered_creds_cache["resolved"] = "1"
|
||||
return "", ""
|
||||
|
||||
|
||||
def _get_client_id() -> str:
|
||||
env_val = (os.getenv(ENV_CLIENT_ID) or "").strip()
|
||||
if env_val:
|
||||
return env_val
|
||||
discovered, _ = _discover_client_credentials()
|
||||
return discovered
|
||||
|
||||
|
||||
def _get_client_secret() -> str:
|
||||
env_val = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
|
||||
if env_val:
|
||||
return env_val
|
||||
_, discovered = _discover_client_credentials()
|
||||
return discovered
|
||||
|
||||
|
||||
def _iter_client_credential_candidates() -> list[Tuple[str, str]]:
|
||||
env_id = (os.getenv(ENV_CLIENT_ID) or "").strip()
|
||||
env_secret = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
|
||||
if env_id and env_secret:
|
||||
return [(env_id, env_secret)]
|
||||
|
||||
_discover_client_credentials()
|
||||
cached = _discovered_creds_cache.get("candidates")
|
||||
if isinstance(cached, list):
|
||||
return [
|
||||
(str(client_id), str(client_secret))
|
||||
for client_id, client_secret in cached
|
||||
if client_id and client_secret
|
||||
]
|
||||
client_id = str(_discovered_creds_cache.get("client_id") or "")
|
||||
client_secret = str(_discovered_creds_cache.get("client_secret") or "")
|
||||
return [(client_id, client_secret)] if client_id and client_secret else []
|
||||
|
||||
|
||||
def _require_client_id() -> str:
|
||||
client_id = _get_client_id()
|
||||
if not client_id:
|
||||
raise AntigravityOAuthError(
|
||||
"Antigravity OAuth client ID is not available. Install Antigravity CLI "
|
||||
"so Hermes can discover its desktop OAuth client, set "
|
||||
f"{ENV_CLI_PATH} to the agy executable, or set {ENV_CLIENT_ID} and "
|
||||
f"{ENV_CLIENT_SECRET} in ~/.hermes/.env.",
|
||||
code="antigravity_oauth_client_id_missing",
|
||||
)
|
||||
return client_id
|
||||
|
||||
|
||||
def _require_client_secret() -> str:
|
||||
client_secret = _get_client_secret()
|
||||
if not client_secret:
|
||||
raise AntigravityOAuthError(
|
||||
"Antigravity OAuth client secret is not available. Install Antigravity CLI "
|
||||
"so Hermes can discover its desktop OAuth client, set "
|
||||
f"{ENV_CLI_PATH} to the agy executable, or set {ENV_CLIENT_ID} and "
|
||||
f"{ENV_CLIENT_SECRET} in ~/.hermes/.env.",
|
||||
code="antigravity_oauth_client_secret_missing",
|
||||
)
|
||||
return client_secret
|
||||
|
||||
|
||||
def _require_client_credentials() -> Tuple[str, str]:
|
||||
candidates = _iter_client_credential_candidates()
|
||||
if not candidates:
|
||||
_require_client_id()
|
||||
_require_client_secret()
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _generate_pkce_pair() -> Tuple[str, str]:
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefreshParts:
|
||||
refresh_token: str
|
||||
project_id: str = ""
|
||||
managed_project_id: str = ""
|
||||
|
||||
@classmethod
|
||||
def parse(cls, packed: str) -> "RefreshParts":
|
||||
if not packed:
|
||||
return cls(refresh_token="")
|
||||
parts = packed.split("|", 2)
|
||||
return cls(
|
||||
refresh_token=parts[0],
|
||||
project_id=parts[1] if len(parts) > 1 else "",
|
||||
managed_project_id=parts[2] if len(parts) > 2 else "",
|
||||
)
|
||||
|
||||
def format(self) -> str:
|
||||
if not self.refresh_token:
|
||||
return ""
|
||||
if not self.project_id and not self.managed_project_id:
|
||||
return self.refresh_token
|
||||
return f"{self.refresh_token}|{self.project_id}|{self.managed_project_id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AntigravityCredentials:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_ms: int
|
||||
email: str = ""
|
||||
project_id: str = ""
|
||||
managed_project_id: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"refresh": RefreshParts(
|
||||
refresh_token=self.refresh_token,
|
||||
project_id=self.project_id,
|
||||
managed_project_id=self.managed_project_id,
|
||||
).format(),
|
||||
"access": self.access_token,
|
||||
"expires": int(self.expires_ms),
|
||||
"email": self.email,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AntigravityCredentials":
|
||||
parts = RefreshParts.parse(str(data.get("refresh", "") or ""))
|
||||
return cls(
|
||||
access_token=str(data.get("access", "") or ""),
|
||||
refresh_token=parts.refresh_token,
|
||||
expires_ms=int(data.get("expires", 0) or 0),
|
||||
email=str(data.get("email", "") or ""),
|
||||
project_id=parts.project_id,
|
||||
managed_project_id=parts.managed_project_id,
|
||||
)
|
||||
|
||||
def access_token_expired(self, skew_seconds: int = REFRESH_SKEW_SECONDS) -> bool:
|
||||
if not self.access_token or not self.expires_ms:
|
||||
return True
|
||||
return (time.time() + max(0, skew_seconds)) * 1000 >= self.expires_ms
|
||||
|
||||
|
||||
def load_credentials() -> Optional[AntigravityCredentials]:
|
||||
path = _credentials_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with _credentials_lock():
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, OSError, IOError) as exc:
|
||||
logger.warning("Failed to read Antigravity OAuth credentials at %s: %s", path, exc)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
creds = AntigravityCredentials.from_dict(data)
|
||||
if not creds.access_token:
|
||||
return None
|
||||
return creds
|
||||
|
||||
|
||||
def save_credentials(creds: AntigravityCredentials) -> Path:
|
||||
path = _credentials_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + "\n"
|
||||
with _credentials_lock():
|
||||
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
||||
try:
|
||||
fd = os.open(
|
||||
str(tmp_path),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
atomic_replace(tmp_path, path)
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def clear_credentials() -> None:
|
||||
path = _credentials_path()
|
||||
with _credentials_lock():
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to remove Antigravity OAuth credentials at %s: %s", path, exc)
|
||||
|
||||
|
||||
def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]:
|
||||
body = urllib.parse.urlencode(data).encode("ascii")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
code = "antigravity_oauth_token_http_error"
|
||||
if "invalid_grant" in detail.lower():
|
||||
code = "antigravity_oauth_invalid_grant"
|
||||
elif "invalid_client" in detail.lower():
|
||||
code = "antigravity_oauth_invalid_client"
|
||||
raise AntigravityOAuthError(
|
||||
f"Antigravity OAuth token endpoint returned HTTP {exc.code}: {detail or exc.reason}",
|
||||
code=code,
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise AntigravityOAuthError(
|
||||
f"Antigravity OAuth token request failed: {exc}",
|
||||
code="antigravity_oauth_token_network_error",
|
||||
) from exc
|
||||
|
||||
|
||||
def exchange_code(
|
||||
code: str,
|
||||
verifier: str,
|
||||
redirect_uri: str,
|
||||
*,
|
||||
timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
|
||||
) -> Dict[str, Any]:
|
||||
last_error: Optional[AntigravityOAuthError] = None
|
||||
candidates = _iter_client_credential_candidates()
|
||||
if not candidates:
|
||||
candidates = [_require_client_credentials()]
|
||||
for client_id, client_secret in candidates:
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
try:
|
||||
return _post_form(TOKEN_ENDPOINT, data, timeout)
|
||||
except AntigravityOAuthError as exc:
|
||||
last_error = exc
|
||||
if exc.code != "antigravity_oauth_invalid_client":
|
||||
raise
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise AntigravityOAuthError(
|
||||
"Antigravity OAuth client credentials are unavailable.",
|
||||
code="antigravity_oauth_client_missing",
|
||||
)
|
||||
|
||||
|
||||
def refresh_access_token(
|
||||
refresh_token: str,
|
||||
*,
|
||||
timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS,
|
||||
) -> Dict[str, Any]:
|
||||
if not refresh_token:
|
||||
raise AntigravityOAuthError(
|
||||
"Cannot refresh: refresh_token is empty. Re-run OAuth login.",
|
||||
code="antigravity_oauth_refresh_token_missing",
|
||||
)
|
||||
last_error: Optional[AntigravityOAuthError] = None
|
||||
candidates = _iter_client_credential_candidates()
|
||||
if not candidates:
|
||||
candidates = [_require_client_credentials()]
|
||||
for client_id, client_secret in candidates:
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}
|
||||
try:
|
||||
return _post_form(TOKEN_ENDPOINT, data, timeout)
|
||||
except AntigravityOAuthError as exc:
|
||||
last_error = exc
|
||||
if exc.code not in {
|
||||
"antigravity_oauth_invalid_client",
|
||||
"antigravity_oauth_invalid_grant",
|
||||
}:
|
||||
raise
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise AntigravityOAuthError(
|
||||
"Antigravity OAuth client credentials are unavailable.",
|
||||
code="antigravity_oauth_client_missing",
|
||||
)
|
||||
|
||||
|
||||
def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str:
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
USERINFO_ENDPOINT + "?alt=json",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
data = json.loads(raw)
|
||||
return str(data.get("email", "") or "")
|
||||
except Exception as exc:
|
||||
logger.debug("Antigravity userinfo fetch failed (non-fatal): %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
_refresh_inflight: Dict[str, threading.Event] = {}
|
||||
_refresh_inflight_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_valid_access_token(*, force_refresh: bool = False) -> str:
|
||||
creds = load_credentials()
|
||||
if creds is None:
|
||||
raise AntigravityOAuthError(
|
||||
"No Antigravity OAuth credentials found. Run `hermes login --provider google-antigravity` first.",
|
||||
code="antigravity_oauth_not_logged_in",
|
||||
)
|
||||
if not force_refresh and not creds.access_token_expired():
|
||||
return creds.access_token
|
||||
|
||||
rt = creds.refresh_token
|
||||
with _refresh_inflight_lock:
|
||||
event = _refresh_inflight.get(rt)
|
||||
if event is None:
|
||||
event = threading.Event()
|
||||
_refresh_inflight[rt] = event
|
||||
owner = True
|
||||
else:
|
||||
owner = False
|
||||
|
||||
if not owner:
|
||||
event.wait(timeout=LOCK_TIMEOUT_SECONDS)
|
||||
fresh = load_credentials()
|
||||
if fresh is not None and not fresh.access_token_expired():
|
||||
return fresh.access_token
|
||||
|
||||
try:
|
||||
try:
|
||||
resp = refresh_access_token(rt)
|
||||
except AntigravityOAuthError as exc:
|
||||
if exc.code == "antigravity_oauth_invalid_grant":
|
||||
clear_credentials()
|
||||
raise
|
||||
new_access = str(resp.get("access_token", "") or "").strip()
|
||||
if not new_access:
|
||||
raise AntigravityOAuthError(
|
||||
"Refresh response did not include an access_token.",
|
||||
code="antigravity_oauth_refresh_empty",
|
||||
)
|
||||
creds.access_token = new_access
|
||||
creds.refresh_token = str(resp.get("refresh_token", "") or "").strip() or creds.refresh_token
|
||||
expires_in = int(resp.get("expires_in", 0) or 0)
|
||||
creds.expires_ms = int((time.time() + max(60, expires_in)) * 1000)
|
||||
save_credentials(creds)
|
||||
return creds.access_token
|
||||
finally:
|
||||
if owner:
|
||||
with _refresh_inflight_lock:
|
||||
_refresh_inflight.pop(rt, None)
|
||||
event.set()
|
||||
|
||||
|
||||
def update_project_ids(project_id: str = "", managed_project_id: str = "") -> None:
|
||||
creds = load_credentials()
|
||||
if creds is None:
|
||||
return
|
||||
if project_id:
|
||||
creds.project_id = project_id
|
||||
if managed_project_id:
|
||||
creds.managed_project_id = managed_project_id
|
||||
save_credentials(creds)
|
||||
|
||||
|
||||
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
expected_state: str = ""
|
||||
captured_code: Optional[str] = None
|
||||
captured_error: Optional[str] = None
|
||||
ready: Optional[threading.Event] = None
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002, N802
|
||||
logger.debug("Antigravity OAuth callback: " + format, *args)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path != CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
state = (params.get("state") or [""])[0]
|
||||
error = (params.get("error") or [""])[0]
|
||||
code = (params.get("code") or [""])[0]
|
||||
|
||||
handler_cls = type(self)
|
||||
if state != self.expected_state:
|
||||
handler_cls.captured_error = "OAuth state mismatch."
|
||||
elif error:
|
||||
handler_cls.captured_error = error
|
||||
elif not code:
|
||||
handler_cls.captured_error = "OAuth callback did not include a code."
|
||||
else:
|
||||
handler_cls.captured_code = code
|
||||
|
||||
ok = not handler_cls.captured_error
|
||||
self.send_response(200 if ok else 400)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
msg = "Antigravity OAuth complete. You can return to Hermes." if ok else handler_cls.captured_error
|
||||
self.wfile.write(f"<html><body><p>{msg}</p></body></html>".encode("utf-8"))
|
||||
if handler_cls.ready is not None:
|
||||
handler_cls.ready.set()
|
||||
|
||||
|
||||
class _ReusableHTTPServer(http.server.HTTPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
def resolve_project_id_from_env() -> str:
|
||||
for key in ("HERMES_ANTIGRAVITY_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT_ID"):
|
||||
value = (os.getenv(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def start_oauth_flow(
|
||||
*,
|
||||
force_relogin: bool = False,
|
||||
open_browser: bool = True,
|
||||
port: int = DEFAULT_REDIRECT_PORT,
|
||||
project_id: str = "",
|
||||
) -> AntigravityCredentials:
|
||||
if not force_relogin:
|
||||
existing = load_credentials()
|
||||
if existing and not existing.access_token_expired():
|
||||
return existing
|
||||
|
||||
verifier, challenge = _generate_pkce_pair()
|
||||
state = secrets.token_urlsafe(24)
|
||||
client_id, _ = _require_client_credentials()
|
||||
|
||||
ready = threading.Event()
|
||||
handler_cls = type("AntigravityOAuthCallbackHandler", (_OAuthCallbackHandler,), {})
|
||||
handler_cls.expected_state = state
|
||||
handler_cls.captured_code = None
|
||||
handler_cls.captured_error = None
|
||||
handler_cls.ready = ready
|
||||
|
||||
try:
|
||||
server = _ReusableHTTPServer((REDIRECT_HOST, int(port)), handler_cls)
|
||||
except OSError:
|
||||
server = _ReusableHTTPServer((REDIRECT_HOST, 0), handler_cls)
|
||||
actual_port = int(server.server_address[1])
|
||||
redirect_uri = f"http://{REDIRECT_HOST}:{actual_port}{CALLBACK_PATH}"
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": OAUTH_SCOPES,
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
|
||||
print("Open this URL to authorize Antigravity OAuth:")
|
||||
print(auth_url)
|
||||
if open_browser:
|
||||
webbrowser.open(auth_url)
|
||||
if not ready.wait(timeout=CALLBACK_WAIT_SECONDS):
|
||||
raise AntigravityOAuthError(
|
||||
"Timed out waiting for Antigravity OAuth callback.",
|
||||
code="antigravity_oauth_callback_timeout",
|
||||
)
|
||||
if handler_cls.captured_error:
|
||||
raise AntigravityOAuthError(
|
||||
handler_cls.captured_error,
|
||||
code="antigravity_oauth_callback_error",
|
||||
)
|
||||
code = handler_cls.captured_code or ""
|
||||
token = exchange_code(code, verifier, redirect_uri)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
access_token = str(token.get("access_token", "") or "").strip()
|
||||
refresh_token = str(token.get("refresh_token", "") or "").strip()
|
||||
if not access_token or not refresh_token:
|
||||
raise AntigravityOAuthError(
|
||||
"Antigravity OAuth response did not include both access_token and refresh_token.",
|
||||
code="antigravity_oauth_missing_token",
|
||||
)
|
||||
expires_in = int(token.get("expires_in", 0) or 0)
|
||||
creds = AntigravityCredentials(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_ms=int((time.time() + max(60, expires_in)) * 1000),
|
||||
email=_fetch_user_email(access_token),
|
||||
project_id=project_id,
|
||||
)
|
||||
save_credentials(creds)
|
||||
return creds
|
||||
|
||||
|
||||
def run_antigravity_oauth_login_pure() -> Dict[str, Any]:
|
||||
creds = start_oauth_flow(
|
||||
force_relogin=True,
|
||||
project_id=resolve_project_id_from_env(),
|
||||
)
|
||||
return {
|
||||
"access_token": creds.access_token,
|
||||
"refresh_token": creds.refresh_token,
|
||||
"expires_at_ms": creds.expires_ms,
|
||||
"email": creds.email,
|
||||
"project_id": creds.project_id,
|
||||
}
|
||||
|
|
@ -93,11 +93,14 @@ def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
|||
args = {"_raw": args_raw}
|
||||
if not isinstance(args, dict):
|
||||
args = {"_value": args}
|
||||
function_call = {
|
||||
"name": fn.get("name") or "",
|
||||
"args": args,
|
||||
}
|
||||
if tool_call.get("id"):
|
||||
function_call["id"] = str(tool_call["id"])
|
||||
return {
|
||||
"functionCall": {
|
||||
"name": fn.get("name") or "",
|
||||
"args": args,
|
||||
},
|
||||
"functionCall": function_call,
|
||||
# Sentinel signature — matches opencode-gemini-auth's approach.
|
||||
# Without this, Code Assist rejects function calls that originated
|
||||
# outside its own chain.
|
||||
|
|
@ -122,12 +125,13 @@ def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:
|
|||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
response = parsed if isinstance(parsed, dict) else {"output": content}
|
||||
return {
|
||||
"functionResponse": {
|
||||
"name": name,
|
||||
"response": response,
|
||||
},
|
||||
function_response = {
|
||||
"name": name,
|
||||
"response": response,
|
||||
}
|
||||
if message.get("tool_call_id"):
|
||||
function_response["id"] = str(message["tool_call_id"])
|
||||
return {"functionResponse": function_response}
|
||||
|
||||
|
||||
def _build_gemini_contents(
|
||||
|
|
@ -358,8 +362,9 @@ def _translate_gemini_response(
|
|||
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
args_str = "{}"
|
||||
call_id = str(fc.get("id") or "").strip() or f"call_{uuid.uuid4().hex[:12]}"
|
||||
tool_calls.append(SimpleNamespace(
|
||||
id=f"call_{uuid.uuid4().hex[:12]}",
|
||||
id=call_id,
|
||||
type="function",
|
||||
index=i,
|
||||
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
|
||||
|
|
@ -554,6 +559,7 @@ def _translate_stream_event(
|
|||
model=model,
|
||||
tool_call_delta={
|
||||
"index": idx,
|
||||
"id": str(fc.get("id") or "").strip(),
|
||||
"name": name,
|
||||
"arguments": args_str,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
extra_body["extra_body"] = openai_compat_extra
|
||||
elif raw_thinking_config:
|
||||
extra_body["thinking_config"] = raw_thinking_config
|
||||
elif provider_name == "google-gemini-cli":
|
||||
elif provider_name in {"google-gemini-cli", "google-antigravity"}:
|
||||
thinking_config = _build_gemini_thinking_config(model, reasoning_config)
|
||||
if thinking_config:
|
||||
extra_body["thinking_config"] = thinking_config
|
||||
|
|
|
|||
|
|
@ -142,6 +142,9 @@ SERVICE_PROVIDER_NAMES: Dict[str, str] = {
|
|||
DEFAULT_GEMINI_CLOUDCODE_BASE_URL = "cloudcode-pa://google"
|
||||
GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 # refresh 60s before expiry
|
||||
|
||||
# Google Antigravity OAuth (Antigravity Code Assist backend)
|
||||
DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL = "antigravity-pa://google"
|
||||
|
||||
# LM Studio's default no-auth mode still requires *some* non-empty bearer for
|
||||
# the API-key code paths (auxiliary_client, runtime resolver) to treat the
|
||||
# provider as configured. This sentinel is sent only to LM Studio, never to
|
||||
|
|
@ -212,6 +215,12 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
|||
auth_type="oauth_external",
|
||||
inference_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
|
||||
),
|
||||
"google-antigravity": ProviderConfig(
|
||||
id="google-antigravity",
|
||||
name="Google Antigravity (OAuth)",
|
||||
auth_type="oauth_external",
|
||||
inference_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
|
||||
),
|
||||
"lmstudio": ProviderConfig(
|
||||
id="lmstudio",
|
||||
name="LM Studio",
|
||||
|
|
@ -1530,6 +1539,7 @@ def resolve_provider(
|
|||
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
|
||||
"opencode": "opencode-zen", "zen": "opencode-zen",
|
||||
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli",
|
||||
"google-antigravity": "google-antigravity", "google-antigravity-oauth": "google-antigravity", "antigravity": "google-antigravity", "antigravity-oauth": "google-antigravity", "antigravity-cli": "google-antigravity", "agy": "google-antigravity", "agy-cli": "google-antigravity",
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
||||
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
|
||||
|
|
@ -2246,6 +2256,72 @@ def get_gemini_oauth_auth_status() -> Dict[str, Any]:
|
|||
"email": creds.email,
|
||||
"project_id": creds.project_id,
|
||||
}
|
||||
|
||||
|
||||
def resolve_antigravity_oauth_runtime_credentials(
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve runtime OAuth creds for google-antigravity."""
|
||||
try:
|
||||
from agent.antigravity_oauth import (
|
||||
AntigravityOAuthError,
|
||||
_credentials_path,
|
||||
get_valid_access_token,
|
||||
load_credentials,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise AuthError(
|
||||
f"agent.antigravity_oauth is not importable: {exc}",
|
||||
provider="google-antigravity",
|
||||
code="antigravity_oauth_module_missing",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
access_token = get_valid_access_token(force_refresh=force_refresh)
|
||||
except AntigravityOAuthError as exc:
|
||||
raise AuthError(
|
||||
str(exc),
|
||||
provider="google-antigravity",
|
||||
code=exc.code,
|
||||
) from exc
|
||||
|
||||
creds = load_credentials()
|
||||
return {
|
||||
"provider": "google-antigravity",
|
||||
"base_url": DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
|
||||
"api_key": access_token,
|
||||
"source": "antigravity-oauth",
|
||||
"expires_at_ms": (creds.expires_ms if creds else None),
|
||||
"auth_file": str(_credentials_path()),
|
||||
"email": (creds.email if creds else "") or "",
|
||||
"project_id": (creds.project_id if creds else "") or "",
|
||||
}
|
||||
|
||||
|
||||
def get_antigravity_oauth_auth_status() -> Dict[str, Any]:
|
||||
"""Return a status dict for `hermes auth list` / `hermes status`."""
|
||||
try:
|
||||
from agent.antigravity_oauth import _credentials_path, load_credentials
|
||||
except ImportError:
|
||||
return {"logged_in": False, "error": "agent.antigravity_oauth unavailable"}
|
||||
auth_path = _credentials_path()
|
||||
creds = load_credentials()
|
||||
if creds is None or not creds.access_token:
|
||||
return {
|
||||
"logged_in": False,
|
||||
"auth_file": str(auth_path),
|
||||
"error": "not logged in",
|
||||
}
|
||||
return {
|
||||
"logged_in": True,
|
||||
"auth_file": str(auth_path),
|
||||
"source": "antigravity-oauth",
|
||||
"api_key": creds.access_token,
|
||||
"expires_at_ms": creds.expires_ms,
|
||||
"email": creds.email,
|
||||
"project_id": creds.project_id,
|
||||
}
|
||||
# Spotify auth — PKCE tokens stored in ~/.hermes/auth.json
|
||||
# =============================================================================
|
||||
|
||||
|
|
@ -6191,6 +6267,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
|||
return get_qwen_auth_status()
|
||||
if target == "google-gemini-cli":
|
||||
return get_gemini_oauth_auth_status()
|
||||
if target == "google-antigravity":
|
||||
return get_antigravity_oauth_auth_status()
|
||||
if target == "minimax-oauth":
|
||||
return get_minimax_oauth_auth_status()
|
||||
if target == "copilot-acp":
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from hermes_cli.secret_prompt import masked_secret_prompt
|
|||
|
||||
|
||||
# Providers that support OAuth login in addition to API keys.
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "minimax-oauth"}
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "google-antigravity", "minimax-oauth"}
|
||||
|
||||
|
||||
def _get_custom_provider_names() -> list:
|
||||
|
|
@ -386,6 +386,27 @@ def auth_add_command(args) -> None:
|
|||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "google-antigravity":
|
||||
from agent.antigravity_oauth import run_antigravity_oauth_login_pure
|
||||
|
||||
creds = run_antigravity_oauth_login_pure()
|
||||
label = (getattr(args, "label", None) or "").strip() or (
|
||||
creds.get("email") or _oauth_default_label(provider, len(pool.entries()) + 1)
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:antigravity_pkce",
|
||||
access_token=creds["access_token"],
|
||||
refresh_token=creds.get("refresh_token"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "qwen-oauth":
|
||||
creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
auth_mod._mark_qwen_oauth_active(creds)
|
||||
|
|
|
|||
|
|
@ -3100,6 +3100,38 @@ OPTIONAL_ENV_VARS = {
|
|||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HERMES_ANTIGRAVITY_CLIENT_ID": {
|
||||
"description": "Google OAuth client ID for google-antigravity (optional; discovered from agy when omitted)",
|
||||
"prompt": "Antigravity OAuth client ID (optional — leave empty to discover from agy)",
|
||||
"url": "https://console.cloud.google.com/apis/credentials",
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HERMES_ANTIGRAVITY_CLIENT_SECRET": {
|
||||
"description": "Google OAuth client secret for google-antigravity (optional)",
|
||||
"prompt": "Antigravity OAuth client secret (optional)",
|
||||
"url": "https://console.cloud.google.com/apis/credentials",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HERMES_ANTIGRAVITY_CLI_PATH": {
|
||||
"description": "Path to agy/Antigravity CLI for OAuth client credential discovery",
|
||||
"prompt": "Antigravity CLI path (leave empty to search PATH/default locations)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HERMES_ANTIGRAVITY_PROJECT_ID": {
|
||||
"description": "GCP project ID for Antigravity OAuth (auto-discovered when omitted)",
|
||||
"prompt": "GCP project ID for Antigravity OAuth (leave empty to auto-discover)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"OPENCODE_ZEN_API_KEY": {
|
||||
"description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
|
||||
"prompt": "OpenCode Zen API key",
|
||||
|
|
|
|||
|
|
@ -3074,6 +3074,8 @@ def select_provider_and_model(args=None):
|
|||
_model_flow_minimax_oauth(config, current_model, args=args)
|
||||
elif selected_provider == "google-gemini-cli":
|
||||
_model_flow_google_gemini_cli(config, current_model)
|
||||
elif selected_provider == "google-antigravity":
|
||||
_model_flow_google_antigravity(config, current_model)
|
||||
elif selected_provider == "copilot-acp":
|
||||
_model_flow_copilot_acp(config, current_model)
|
||||
elif selected_provider == "copilot":
|
||||
|
|
@ -3609,6 +3611,271 @@ _DEFAULT_QWEN_PORTAL_MODELS = [
|
|||
|
||||
|
||||
|
||||
def _model_flow_google_antigravity(_config, current_model=""):
|
||||
"""Google Antigravity OAuth via Antigravity Code Assist."""
|
||||
from hermes_cli.auth import (
|
||||
DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
|
||||
get_antigravity_oauth_auth_status,
|
||||
resolve_antigravity_oauth_runtime_credentials,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
_update_config_for_provider,
|
||||
)
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
status = get_antigravity_oauth_auth_status()
|
||||
if not status.get("logged_in"):
|
||||
try:
|
||||
from agent.antigravity_oauth import resolve_project_id_from_env, start_oauth_flow
|
||||
|
||||
env_project = resolve_project_id_from_env()
|
||||
start_oauth_flow(force_relogin=True, project_id=env_project)
|
||||
except Exception as exc:
|
||||
print(f"OAuth login failed: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=False)
|
||||
project_id = creds.get("project_id", "")
|
||||
if project_id:
|
||||
print(f" Using Antigravity project: {project_id}")
|
||||
except Exception as exc:
|
||||
print(f"Failed to resolve Antigravity credentials: {exc}")
|
||||
return
|
||||
|
||||
models = provider_model_ids("google-antigravity")
|
||||
default = current_model or (models[0] if models else "gemini-3-flash-agent")
|
||||
selected = _prompt_model_selection(models, current_model=default)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider(
|
||||
"google-antigravity", DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL
|
||||
)
|
||||
print(
|
||||
f"Default model set to: {selected} (via Google Antigravity OAuth / Code Assist)"
|
||||
)
|
||||
else:
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_custom(config):
|
||||
"""Custom endpoint: collect URL, API key, and model name.
|
||||
|
||||
Automatically saves the endpoint to ``custom_providers`` in config.yaml
|
||||
so it appears in the provider menu on subsequent runs.
|
||||
"""
|
||||
from hermes_cli.auth import _save_model_choice, deactivate_provider
|
||||
from hermes_cli.config import get_env_value, load_config, save_config
|
||||
|
||||
current_url = get_env_value("OPENAI_BASE_URL") or ""
|
||||
current_key = get_env_value("OPENAI_API_KEY") or ""
|
||||
|
||||
print("Custom OpenAI-compatible endpoint configuration:")
|
||||
if current_url:
|
||||
print(f" Current URL: {current_url}")
|
||||
if current_key:
|
||||
print(f" Current key: {current_key[:8]}...")
|
||||
print()
|
||||
|
||||
try:
|
||||
base_url = input(
|
||||
f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: "
|
||||
).strip()
|
||||
import getpass
|
||||
|
||||
api_key = getpass.getpass(
|
||||
f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: "
|
||||
).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
|
||||
if not base_url and not current_url:
|
||||
print("No URL provided. Cancelled.")
|
||||
return
|
||||
|
||||
# Validate URL format
|
||||
effective_url = base_url or current_url
|
||||
if not effective_url.startswith(("http://", "https://")):
|
||||
print(f"Invalid URL: {effective_url} (must start with http:// or https://)")
|
||||
return
|
||||
|
||||
effective_key = api_key or current_key
|
||||
|
||||
# Hint: most local model servers (Ollama, vLLM, llama.cpp) require /v1
|
||||
# in the base URL for OpenAI-compatible chat completions. Prompt the
|
||||
# user if the URL looks like a local server without /v1.
|
||||
_url_lower = effective_url.rstrip("/").lower()
|
||||
_looks_local = any(
|
||||
h in _url_lower
|
||||
for h in ("localhost", "127.0.0.1", "0.0.0.0", ":11434", ":8080", ":5000")
|
||||
)
|
||||
if _looks_local and not _url_lower.endswith("/v1"):
|
||||
print()
|
||||
print(f" Hint: Did you mean to add /v1 at the end?")
|
||||
print(f" Most local model servers (Ollama, vLLM, llama.cpp) require it.")
|
||||
print(f" e.g. {effective_url.rstrip('/')}/v1")
|
||||
try:
|
||||
_add_v1 = input(" Add /v1? [Y/n]: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
_add_v1 = "n"
|
||||
if _add_v1 in {"", "y", "yes"}:
|
||||
effective_url = effective_url.rstrip("/") + "/v1"
|
||||
if base_url:
|
||||
base_url = effective_url
|
||||
print(f" Updated URL: {effective_url}")
|
||||
print()
|
||||
|
||||
from hermes_cli.models import probe_api_models
|
||||
|
||||
probe = probe_api_models(effective_key, effective_url)
|
||||
if probe.get("used_fallback") and probe.get("resolved_base_url"):
|
||||
print(
|
||||
f"Warning: endpoint verification worked at {probe['resolved_base_url']}/models, "
|
||||
f"not the exact URL you entered. Saving the working base URL instead."
|
||||
)
|
||||
effective_url = probe["resolved_base_url"]
|
||||
if base_url:
|
||||
base_url = effective_url
|
||||
elif probe.get("models") is not None:
|
||||
print(
|
||||
f"Verified endpoint via {probe.get('probed_url')} "
|
||||
f"({len(probe.get('models') or [])} model(s) visible)"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Warning: could not verify this endpoint via {probe.get('probed_url')}. "
|
||||
f"Hermes will still save it."
|
||||
)
|
||||
if probe.get("suggested_base_url"):
|
||||
suggested = probe["suggested_base_url"]
|
||||
if suggested.endswith("/v1"):
|
||||
print(
|
||||
f" If this server expects /v1 in the path, try base URL: {suggested}"
|
||||
)
|
||||
else:
|
||||
print(f" If /v1 should not be in the base URL, try: {suggested}")
|
||||
|
||||
# Prompt for API compatibility mode explicitly so codex-compatible custom
|
||||
# providers don't silently fall back to chat_completions.
|
||||
current_model_cfg = config.get("model")
|
||||
current_api_mode = ""
|
||||
if isinstance(current_model_cfg, dict):
|
||||
current_api_mode = str(current_model_cfg.get("api_mode") or "").strip()
|
||||
api_mode = _prompt_custom_api_mode_selection(
|
||||
effective_url,
|
||||
current_api_mode=current_api_mode,
|
||||
)
|
||||
if api_mode:
|
||||
print(f" API mode: {api_mode}")
|
||||
else:
|
||||
print(" API mode: auto-detect")
|
||||
|
||||
# Select model — use probe results when available, fall back to manual input
|
||||
model_name = ""
|
||||
detected_models = probe.get("models") or []
|
||||
try:
|
||||
if len(detected_models) == 1:
|
||||
print(f" Detected model: {detected_models[0]}")
|
||||
confirm = input(" Use this model? [Y/n]: ").strip().lower()
|
||||
if confirm in {"", "y", "yes"}:
|
||||
model_name = detected_models[0]
|
||||
else:
|
||||
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
|
||||
elif len(detected_models) > 1:
|
||||
print(" Available models:")
|
||||
for i, m in enumerate(detected_models, 1):
|
||||
print(f" {i}. {m}")
|
||||
pick = input(
|
||||
f" Select model [1-{len(detected_models)}] or type name: "
|
||||
).strip()
|
||||
if pick.isdigit() and 1 <= int(pick) <= len(detected_models):
|
||||
model_name = detected_models[int(pick) - 1]
|
||||
elif pick:
|
||||
model_name = pick
|
||||
else:
|
||||
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
|
||||
|
||||
context_length_str = input(
|
||||
"Context length in tokens [leave blank for auto-detect]: "
|
||||
).strip()
|
||||
|
||||
# Prompt for a display name — shown in the provider menu on future runs
|
||||
default_name = _auto_provider_name(effective_url)
|
||||
display_name = input(f"Display name [{default_name}]: ").strip() or default_name
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
|
||||
context_length = None
|
||||
if context_length_str:
|
||||
try:
|
||||
context_length = int(
|
||||
context_length_str.replace(",", "")
|
||||
.replace("k", "000")
|
||||
.replace("K", "000")
|
||||
)
|
||||
if context_length <= 0:
|
||||
context_length = None
|
||||
except ValueError:
|
||||
print(f"Invalid context length: {context_length_str} — will auto-detect.")
|
||||
context_length = None
|
||||
|
||||
if model_name:
|
||||
_save_model_choice(model_name)
|
||||
|
||||
# Update config and deactivate any OAuth provider
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if not isinstance(model, dict):
|
||||
model = {"default": model} if model else {}
|
||||
cfg["model"] = model
|
||||
model["provider"] = "custom"
|
||||
model["base_url"] = effective_url
|
||||
if effective_key:
|
||||
model["api_key"] = effective_key
|
||||
if api_mode:
|
||||
model["api_mode"] = api_mode
|
||||
else:
|
||||
model.pop("api_mode", None)
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
# Sync the caller's config dict so the setup wizard's final
|
||||
# save_config(config) preserves our model settings. Without
|
||||
# this, the wizard overwrites model.provider/base_url with
|
||||
# the stale values from its own config dict (#4172).
|
||||
config["model"] = dict(model)
|
||||
|
||||
print(f"Default model set to: {model_name} (via {effective_url})")
|
||||
else:
|
||||
if base_url or api_key:
|
||||
deactivate_provider()
|
||||
# Even without a model name, persist the custom endpoint on the
|
||||
# caller's config dict so the setup wizard doesn't lose it.
|
||||
_caller_model = config.get("model")
|
||||
if not isinstance(_caller_model, dict):
|
||||
_caller_model = {"default": _caller_model} if _caller_model else {}
|
||||
_caller_model["provider"] = "custom"
|
||||
_caller_model["base_url"] = effective_url
|
||||
if effective_key:
|
||||
_caller_model["api_key"] = effective_key
|
||||
if api_mode:
|
||||
_caller_model["api_mode"] = api_mode
|
||||
else:
|
||||
_caller_model.pop("api_mode", None)
|
||||
config["model"] = _caller_model
|
||||
print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.")
|
||||
|
||||
# Auto-save to custom_providers so it appears in the menu next time
|
||||
_save_custom_provider(
|
||||
effective_url,
|
||||
effective_key,
|
||||
model_name or "",
|
||||
context_length=context_length,
|
||||
name=display_name,
|
||||
api_mode=api_mode,
|
||||
)
|
||||
|
||||
|
||||
def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
|
||||
|
|
@ -11248,6 +11515,24 @@ def cmd_logs(args):
|
|||
since=getattr(args, "since", None),
|
||||
component=getattr(args, "component", None),
|
||||
)
|
||||
|
||||
|
||||
def _build_provider_choices() -> list[str]:
|
||||
"""Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'."""
|
||||
try:
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS as _cp
|
||||
return ["auto"] + [p.slug for p in _cp]
|
||||
except Exception:
|
||||
# Fallback: static list guarantees the CLI always works
|
||||
return [
|
||||
"auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot",
|
||||
"anthropic", "gemini", "google-gemini-cli", "google-antigravity", "xai", "bedrock", "azure-foundry",
|
||||
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
|
||||
"stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee",
|
||||
"nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
|
||||
]
|
||||
|
||||
|
||||
# Top-level subcommands that argparse knows about WITHOUT running plugin
|
||||
# discovery. Used to short-circuit eager plugin imports (which can take
|
||||
# 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the
|
||||
|
|
|
|||
|
|
@ -276,6 +276,15 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
|||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
],
|
||||
"google-antigravity": [
|
||||
"gemini-3-flash-agent",
|
||||
"gemini-3.5-flash-low",
|
||||
"gemini-pro-agent",
|
||||
"gemini-3.1-pro-low",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-6-thinking",
|
||||
"gpt-oss-120b-medium",
|
||||
],
|
||||
"zai": [
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
|
|
@ -1029,6 +1038,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"),
|
||||
ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (Code Assist OAuth flow)"),
|
||||
ProviderEntry("google-antigravity", "Google Antigravity (OAuth)", "Google Antigravity via OAuth + Code Assist (Gemini 3.5/3.1, Claude, GPT-OSS where entitled)"),
|
||||
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"),
|
||||
ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"),
|
||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"),
|
||||
|
|
@ -1222,6 +1232,12 @@ _PROVIDER_ALIASES = {
|
|||
"qwen-portal": "qwen-oauth",
|
||||
"gemini-cli": "google-gemini-cli",
|
||||
"gemini-oauth": "google-gemini-cli",
|
||||
"antigravity": "google-antigravity",
|
||||
"antigravity-oauth": "google-antigravity",
|
||||
"antigravity-cli": "google-antigravity",
|
||||
"google-antigravity-oauth": "google-antigravity",
|
||||
"agy": "google-antigravity",
|
||||
"agy-cli": "google-antigravity",
|
||||
"hf": "huggingface",
|
||||
"hugging-face": "huggingface",
|
||||
"huggingface-hub": "huggingface",
|
||||
|
|
@ -2192,6 +2208,32 @@ def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
|
|||
return merged
|
||||
|
||||
|
||||
def _fetch_antigravity_models(*, force_refresh: bool = False) -> list[str]:
|
||||
try:
|
||||
from agent import antigravity_oauth
|
||||
from agent.antigravity_code_assist import (
|
||||
fetch_available_models_with_fallbacks,
|
||||
load_code_assist,
|
||||
parse_agent_model_ids,
|
||||
)
|
||||
from hermes_cli.auth import resolve_antigravity_oauth_runtime_credentials
|
||||
|
||||
creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=force_refresh)
|
||||
access_token = str(creds.get("api_key") or "").strip()
|
||||
project_id = str(creds.get("project_id") or "").strip()
|
||||
if not access_token:
|
||||
return []
|
||||
if not project_id:
|
||||
info = load_code_assist(access_token)
|
||||
project_id = info.project_id
|
||||
if project_id:
|
||||
antigravity_oauth.update_project_ids(project_id=project_id, managed_project_id=project_id)
|
||||
payload = fetch_available_models_with_fallbacks(access_token, project_id=project_id)
|
||||
return parse_agent_model_ids(payload)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
|
||||
"""Return the best known model catalog for a provider.
|
||||
|
||||
|
|
@ -2222,6 +2264,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
|||
return get_codex_model_ids(access_token=access_token)
|
||||
if normalized == "xai-oauth":
|
||||
return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", [])))
|
||||
if normalized == "google-antigravity":
|
||||
live = _fetch_antigravity_models(force_refresh=force_refresh)
|
||||
if live:
|
||||
return live
|
||||
if normalized in {"copilot", "copilot-acp"}:
|
||||
try:
|
||||
live = _fetch_github_models(_resolve_copilot_catalog_api_key())
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
|||
auth_type="oauth_external",
|
||||
base_url_override="cloudcode-pa://google",
|
||||
),
|
||||
"google-antigravity": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
auth_type="oauth_external",
|
||||
base_url_override="antigravity-pa://google",
|
||||
),
|
||||
"lmstudio": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
auth_type="api_key",
|
||||
|
|
@ -314,6 +319,13 @@ ALIASES: Dict[str, str] = {
|
|||
"gemini-cli": "google-gemini-cli",
|
||||
"gemini-oauth": "google-gemini-cli",
|
||||
|
||||
# google-antigravity (OAuth + Antigravity Code Assist)
|
||||
"antigravity": "google-antigravity",
|
||||
"antigravity-oauth": "google-antigravity",
|
||||
"antigravity-cli": "google-antigravity",
|
||||
"google-antigravity-oauth": "google-antigravity",
|
||||
"agy": "google-antigravity",
|
||||
"agy-cli": "google-antigravity",
|
||||
|
||||
# huggingface
|
||||
"hf": "huggingface",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from hermes_cli.auth import (
|
|||
resolve_xai_oauth_runtime_credentials,
|
||||
resolve_qwen_runtime_credentials,
|
||||
resolve_gemini_oauth_runtime_credentials,
|
||||
resolve_antigravity_oauth_runtime_credentials,
|
||||
resolve_api_key_provider_credentials,
|
||||
resolve_external_process_provider_credentials,
|
||||
has_usable_secret,
|
||||
|
|
@ -334,6 +335,9 @@ def _resolve_runtime_from_pool_entry(
|
|||
elif provider == "google-gemini-cli":
|
||||
api_mode = "chat_completions"
|
||||
base_url = base_url or "cloudcode-pa://google"
|
||||
elif provider == "google-antigravity":
|
||||
api_mode = "chat_completions"
|
||||
base_url = base_url or "antigravity-pa://google"
|
||||
elif provider == "minimax-oauth":
|
||||
# MiniMax OAuth tokens are valid only against the Anthropic Messages
|
||||
# compatible endpoint. Do not honor stale model.api_mode values from a
|
||||
|
|
@ -1634,6 +1638,26 @@ def resolve_runtime_provider(
|
|||
logger.info("Google Gemini OAuth credentials failed; "
|
||||
"falling through to next provider.")
|
||||
|
||||
if provider == "google-antigravity":
|
||||
try:
|
||||
creds = resolve_antigravity_oauth_runtime_credentials()
|
||||
return {
|
||||
"provider": "google-antigravity",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": creds.get("base_url", ""),
|
||||
"api_key": creds.get("api_key", ""),
|
||||
"source": creds.get("source", "antigravity-oauth"),
|
||||
"expires_at_ms": creds.get("expires_at_ms"),
|
||||
"email": creds.get("email", ""),
|
||||
"project_id": creds.get("project_id", ""),
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
except AuthError:
|
||||
if requested_provider != "auto":
|
||||
raise
|
||||
logger.info("Google Antigravity OAuth credentials failed; "
|
||||
"falling through to next provider.")
|
||||
|
||||
if provider == "copilot-acp":
|
||||
creds = resolve_external_process_provider_credentials(provider)
|
||||
return {
|
||||
|
|
|
|||
392
tests/agent/test_antigravity_cloudcode.py
Normal file
392
tests/agent/test_antigravity_cloudcode.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""Tests for the google-antigravity OAuth + Antigravity Code Assist provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
import threading
|
||||
import urllib.parse
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for key in (
|
||||
"HERMES_ANTIGRAVITY_CLIENT_ID",
|
||||
"HERMES_ANTIGRAVITY_CLIENT_SECRET",
|
||||
"HERMES_ANTIGRAVITY_CLI_PATH",
|
||||
"HERMES_ANTIGRAVITY_PROJECT_ID",
|
||||
"GOOGLE_CLOUD_PROJECT",
|
||||
"GOOGLE_CLOUD_PROJECT_ID",
|
||||
"LOCALAPPDATA",
|
||||
"APPDATA",
|
||||
"ProgramFiles",
|
||||
"ProgramFiles(x86)",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setattr("shutil.which", lambda _: None)
|
||||
try:
|
||||
from agent import antigravity_oauth
|
||||
|
||||
antigravity_oauth._discovered_creds_cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
return home
|
||||
|
||||
|
||||
class TestAntigravityCredentials:
|
||||
def test_save_load_uses_separate_file_and_0600_permissions(self):
|
||||
from agent.antigravity_oauth import (
|
||||
AntigravityCredentials,
|
||||
_credentials_path,
|
||||
load_credentials,
|
||||
save_credentials,
|
||||
)
|
||||
|
||||
save_credentials(AntigravityCredentials(
|
||||
access_token="at",
|
||||
refresh_token="rt",
|
||||
expires_ms=int((time.time() + 3600) * 1000),
|
||||
email="user@example.com",
|
||||
project_id="proj-123",
|
||||
))
|
||||
|
||||
assert _credentials_path().name == "antigravity_oauth.json"
|
||||
loaded = load_credentials()
|
||||
assert loaded is not None
|
||||
assert loaded.refresh_token == "rt"
|
||||
assert loaded.project_id == "proj-123"
|
||||
if os.name != "nt":
|
||||
assert stat.S_IMODE(_credentials_path().stat().st_mode) == 0o600
|
||||
|
||||
def test_env_override_client_id(self, monkeypatch):
|
||||
from agent.antigravity_oauth import _get_client_id
|
||||
|
||||
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_ID", "custom.apps.googleusercontent.com")
|
||||
assert _get_client_id() == "custom.apps.googleusercontent.com"
|
||||
|
||||
def test_env_override_client_secret(self, monkeypatch):
|
||||
from agent.antigravity_oauth import _get_client_secret
|
||||
|
||||
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", "custom-secret")
|
||||
assert _get_client_secret() == "custom-secret"
|
||||
|
||||
def test_discovers_client_credentials_from_configured_agy_path(self, tmp_path, monkeypatch):
|
||||
from agent import antigravity_oauth
|
||||
|
||||
fake_client_id = (
|
||||
"1071006060591-"
|
||||
+ "fakefakefakefakefakefakefake"
|
||||
+ ".apps.google"
|
||||
+ "usercontent.com"
|
||||
)
|
||||
fake_client_secret = "GOC" + "SPX-" + "fake-secret-value-placeholde"
|
||||
fake_agy = tmp_path / "agy.exe"
|
||||
fake_agy.write_text(
|
||||
f'oauthClientId="{fake_client_id}";\n'
|
||||
f'oauthClientSecret="{fake_client_secret}";\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLI_PATH", str(fake_agy))
|
||||
antigravity_oauth._discovered_creds_cache.clear()
|
||||
|
||||
assert antigravity_oauth._get_client_id().startswith("1071006060591-")
|
||||
assert antigravity_oauth._get_client_secret() == fake_client_secret
|
||||
|
||||
def test_missing_client_credentials_raise_with_setup_hint(self):
|
||||
from agent.antigravity_oauth import AntigravityOAuthError, _require_client_id
|
||||
|
||||
with pytest.raises(AntigravityOAuthError) as exc_info:
|
||||
_require_client_id()
|
||||
assert exc_info.value.code == "antigravity_oauth_client_id_missing"
|
||||
assert "HERMES_ANTIGRAVITY_CLI_PATH" in str(exc_info.value)
|
||||
|
||||
def test_pkce_challenge_is_s256(self):
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from agent.antigravity_oauth import _generate_pkce_pair
|
||||
|
||||
verifier, challenge = _generate_pkce_pair()
|
||||
expected = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
).rstrip(b"=").decode("ascii")
|
||||
assert challenge == expected
|
||||
assert 43 <= len(verifier) <= 128
|
||||
|
||||
def test_exchange_code_posts_pkce_payload(self, monkeypatch):
|
||||
from agent import antigravity_oauth
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, data, timeout):
|
||||
captured.update({"url": url, "data": data, "timeout": timeout})
|
||||
return {"access_token": "at"}
|
||||
|
||||
monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post)
|
||||
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_ID", "client.apps.googleusercontent.com")
|
||||
monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", "secret")
|
||||
|
||||
assert antigravity_oauth.exchange_code("code", "verifier", "http://localhost/cb") == {
|
||||
"access_token": "at"
|
||||
}
|
||||
assert captured["url"] == antigravity_oauth.TOKEN_ENDPOINT
|
||||
assert captured["data"]["grant_type"] == "authorization_code"
|
||||
assert captured["data"]["code_verifier"] == "verifier"
|
||||
assert captured["data"]["redirect_uri"] == "http://localhost/cb"
|
||||
assert captured["data"]["client_id"] == "client.apps.googleusercontent.com"
|
||||
assert captured["data"]["client_secret"] == "secret"
|
||||
|
||||
def test_refresh_tries_discovered_client_secret_candidates(self, monkeypatch):
|
||||
from agent import antigravity_oauth
|
||||
from agent.antigravity_oauth import AntigravityOAuthError
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
antigravity_oauth,
|
||||
"_iter_client_credential_candidates",
|
||||
lambda: [
|
||||
("client.apps.googleusercontent.com", "wrong-secret"),
|
||||
("client.apps.googleusercontent.com", "right-secret"),
|
||||
],
|
||||
)
|
||||
|
||||
def fake_post(url, data, timeout):
|
||||
calls.append(data["client_secret"])
|
||||
if data["client_secret"] == "wrong-secret":
|
||||
raise AntigravityOAuthError(
|
||||
"invalid client",
|
||||
code="antigravity_oauth_invalid_client",
|
||||
)
|
||||
return {"access_token": "new-token", "expires_in": 3600}
|
||||
|
||||
monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post)
|
||||
|
||||
assert antigravity_oauth.refresh_access_token("refresh-token")["access_token"] == "new-token"
|
||||
assert calls == ["wrong-secret", "right-secret"]
|
||||
|
||||
def test_invalid_grant_refresh_clears_credentials(self, monkeypatch):
|
||||
from agent import antigravity_oauth
|
||||
from agent.antigravity_oauth import (
|
||||
AntigravityCredentials,
|
||||
AntigravityOAuthError,
|
||||
load_credentials,
|
||||
save_credentials,
|
||||
)
|
||||
|
||||
save_credentials(AntigravityCredentials(
|
||||
access_token="expired",
|
||||
refresh_token="rt",
|
||||
expires_ms=int((time.time() - 3600) * 1000),
|
||||
))
|
||||
|
||||
def invalid_grant(_refresh_token):
|
||||
raise AntigravityOAuthError("revoked", code="antigravity_oauth_invalid_grant")
|
||||
|
||||
monkeypatch.setattr(antigravity_oauth, "refresh_access_token", invalid_grant)
|
||||
with pytest.raises(AntigravityOAuthError, match="revoked"):
|
||||
antigravity_oauth.get_valid_access_token()
|
||||
assert load_credentials() is None
|
||||
|
||||
def test_callback_handler_captures_code_on_handler_class(self):
|
||||
from agent.antigravity_oauth import CALLBACK_PATH, _OAuthCallbackHandler
|
||||
|
||||
handler_cls = type("TestAntigravityOAuthCallbackHandler", (_OAuthCallbackHandler,), {})
|
||||
handler_cls.expected_state = "state-123"
|
||||
handler_cls.captured_code = None
|
||||
handler_cls.captured_error = None
|
||||
handler_cls.ready = threading.Event()
|
||||
|
||||
handler = handler_cls.__new__(handler_cls)
|
||||
handler.path = CALLBACK_PATH + "?" + urllib.parse.urlencode({
|
||||
"state": "state-123",
|
||||
"code": "auth-code",
|
||||
})
|
||||
handler.wfile = BytesIO()
|
||||
responses = []
|
||||
headers = []
|
||||
handler.send_response = lambda code: responses.append(code)
|
||||
handler.send_header = lambda key, value: headers.append((key, value))
|
||||
handler.end_headers = lambda: None
|
||||
|
||||
handler.do_GET()
|
||||
|
||||
assert responses == [200]
|
||||
assert handler_cls.captured_code == "auth-code"
|
||||
assert handler_cls.captured_error is None
|
||||
assert handler_cls.ready.is_set()
|
||||
assert "captured_code" not in handler.__dict__
|
||||
|
||||
|
||||
class TestAntigravityModelCatalog:
|
||||
def test_parse_agent_model_ids_prefers_recommended_group(self):
|
||||
from agent.antigravity_code_assist import parse_agent_model_ids
|
||||
|
||||
payload = {
|
||||
"defaultAgentModelId": "gemini-3-flash-agent",
|
||||
"agentModelSorts": [
|
||||
{
|
||||
"displayName": "Experimental",
|
||||
"modelIds": ["tab_flash_lite_preview", "chat_23310"],
|
||||
},
|
||||
{
|
||||
"displayName": "Recommended",
|
||||
"modelIds": [
|
||||
"gemini-3-flash-agent",
|
||||
"gemini-3.5-flash-low",
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-pro-agent",
|
||||
"claude-sonnet-4-6",
|
||||
],
|
||||
},
|
||||
],
|
||||
"models": [{"id": "gpt-oss-120b-medium"}],
|
||||
}
|
||||
|
||||
assert parse_agent_model_ids(payload) == [
|
||||
"gemini-3-flash-agent",
|
||||
"gemini-3.5-flash-low",
|
||||
"gemini-pro-agent",
|
||||
"claude-sonnet-4-6",
|
||||
]
|
||||
|
||||
def test_headers_include_antigravity_metadata(self):
|
||||
from agent.antigravity_code_assist import build_headers
|
||||
|
||||
headers = build_headers("tok")
|
||||
assert headers["Authorization"] == "Bearer tok"
|
||||
assert headers["User-Agent"].startswith("antigravity/")
|
||||
assert headers["X-Goog-Api-Client"] == "google-cloud-sdk vscode_cloudshelleditor/0.1"
|
||||
metadata = json.loads(headers["Client-Metadata"])
|
||||
assert metadata["ideType"] == "ANTIGRAVITY"
|
||||
assert metadata["platform"] == "PLATFORM_UNSPECIFIED"
|
||||
|
||||
|
||||
class TestAntigravityClient:
|
||||
def test_client_exposes_openai_interface(self):
|
||||
from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient
|
||||
|
||||
client = AntigravityCloudCodeClient(api_key="dummy")
|
||||
try:
|
||||
assert hasattr(client, "chat")
|
||||
assert hasattr(client.chat, "completions")
|
||||
assert callable(client.chat.completions.create)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_create_uses_antigravity_endpoint_and_headers(self, monkeypatch):
|
||||
from agent import antigravity_oauth
|
||||
from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient
|
||||
from agent.antigravity_code_assist import ANTIGRAVITY_CODE_ASSIST_ENDPOINT
|
||||
|
||||
monkeypatch.setattr(antigravity_oauth, "get_valid_access_token", lambda: "live-token")
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"response": {
|
||||
"candidates": [{
|
||||
"content": {"parts": [{"text": "ok"}]},
|
||||
"finishReason": "STOP",
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
class _Http:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, json=None, headers=None):
|
||||
self.calls.append((url, json, headers))
|
||||
return _Response()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
client = AntigravityCloudCodeClient(project_id="proj-123")
|
||||
client._http = _Http()
|
||||
try:
|
||||
result = client.chat.completions.create(
|
||||
model="gemini-3-flash-agent",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert result.choices[0].message.content == "ok"
|
||||
url, body, headers = client._http.calls[0]
|
||||
assert url == f"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:generateContent"
|
||||
assert body["project"] == "proj-123"
|
||||
assert body["model"] == "gemini-3-flash-agent"
|
||||
assert headers["Authorization"] == "Bearer live-token"
|
||||
assert json.loads(headers["Client-Metadata"])["ideType"] == "ANTIGRAVITY"
|
||||
|
||||
|
||||
class TestAntigravityRegistration:
|
||||
def test_registry_entry_and_aliases(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider
|
||||
|
||||
assert "google-antigravity" in PROVIDER_REGISTRY
|
||||
assert PROVIDER_REGISTRY["google-antigravity"].auth_type == "oauth_external"
|
||||
assert resolve_provider("antigravity") == "google-antigravity"
|
||||
assert resolve_provider("antigravity-oauth") == "google-antigravity"
|
||||
assert resolve_provider("google-antigravity-oauth") == "google-antigravity"
|
||||
assert resolve_provider("agy") == "google-antigravity"
|
||||
|
||||
def test_runtime_provider_raises_when_not_logged_in(self):
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
resolve_runtime_provider(requested="google-antigravity")
|
||||
assert exc_info.value.code == "antigravity_oauth_not_logged_in"
|
||||
|
||||
def test_runtime_provider_returns_correct_shape_when_logged_in(self):
|
||||
from agent.antigravity_oauth import AntigravityCredentials, save_credentials
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
save_credentials(AntigravityCredentials(
|
||||
access_token="live-tok",
|
||||
refresh_token="rt",
|
||||
expires_ms=int((time.time() + 3600) * 1000),
|
||||
project_id="my-proj",
|
||||
email="t@e.com",
|
||||
))
|
||||
|
||||
result = resolve_runtime_provider(requested="google-antigravity")
|
||||
assert result["provider"] == "google-antigravity"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "live-tok"
|
||||
assert result["base_url"] == "antigravity-pa://google"
|
||||
assert result["project_id"] == "my-proj"
|
||||
assert result["email"] == "t@e.com"
|
||||
|
||||
def test_provider_model_ids_uses_live_antigravity_catalog(self, monkeypatch):
|
||||
from hermes_cli import models
|
||||
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"_fetch_antigravity_models",
|
||||
lambda force_refresh=False: ["gemini-3-flash-agent", "claude-sonnet-4-6"],
|
||||
)
|
||||
|
||||
assert models.provider_model_ids("agy") == [
|
||||
"gemini-3-flash-agent",
|
||||
"claude-sonnet-4-6",
|
||||
]
|
||||
|
||||
def test_oauth_capable_set_includes_antigravity(self):
|
||||
from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS
|
||||
|
||||
assert "google-antigravity" in _OAUTH_CAPABLE_PROVIDERS
|
||||
|
|
@ -610,6 +610,7 @@ class TestBuildGeminiRequest:
|
|||
fc_part = next(p for p in model_turn["parts"] if "functionCall" in p)
|
||||
assert fc_part["functionCall"]["name"] == "get_weather"
|
||||
assert fc_part["functionCall"]["args"] == {"city": "SF"}
|
||||
assert fc_part["functionCall"]["id"] == "call_1"
|
||||
|
||||
def test_tool_result_translation(self):
|
||||
from agent.gemini_cloudcode_adapter import build_gemini_request
|
||||
|
|
@ -632,6 +633,7 @@ class TestBuildGeminiRequest:
|
|||
fr_part = next(p for p in last["parts"] if "functionResponse" in p)
|
||||
assert fr_part["functionResponse"]["name"] == "get_weather"
|
||||
assert fr_part["functionResponse"]["response"] == {"temp": 72}
|
||||
assert fr_part["functionResponse"]["id"] == "c1"
|
||||
|
||||
def test_tools_translated_to_function_declarations(self):
|
||||
from agent.gemini_cloudcode_adapter import build_gemini_request
|
||||
|
|
@ -790,7 +792,7 @@ class TestTranslateGeminiResponse:
|
|||
"response": {
|
||||
"candidates": [{
|
||||
"content": {"parts": [{
|
||||
"functionCall": {"name": "lookup", "args": {"q": "weather"}},
|
||||
"functionCall": {"name": "lookup", "args": {"q": "weather"}, "id": "provider-call-1"},
|
||||
}]},
|
||||
"finishReason": "STOP",
|
||||
}],
|
||||
|
|
@ -798,6 +800,7 @@ class TestTranslateGeminiResponse:
|
|||
}
|
||||
result = _translate_gemini_response(resp, model="gemini-2.5-flash")
|
||||
tc = result.choices[0].message.tool_calls[0]
|
||||
assert tc.id == "provider-call-1"
|
||||
assert tc.function.name == "lookup"
|
||||
assert json.loads(tc.function.arguments) == {"q": "weather"}
|
||||
assert result.choices[0].finish_reason == "tool_calls"
|
||||
|
|
|
|||
|
|
@ -418,6 +418,20 @@ class TestChatCompletionsBuildKwargs:
|
|||
}
|
||||
assert "google" not in kw["extra_body"]
|
||||
|
||||
def test_google_antigravity_keeps_top_level_thinking_config(self, transport):
|
||||
msgs = [{"role": "user", "content": "Hi"}]
|
||||
kw = transport.build_kwargs(
|
||||
model="gemini-3-flash-agent",
|
||||
messages=msgs,
|
||||
provider_name="google-antigravity",
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
)
|
||||
assert kw["extra_body"]["thinking_config"] == {
|
||||
"includeThoughts": True,
|
||||
"thinkingLevel": "high",
|
||||
}
|
||||
assert "google" not in kw["extra_body"]
|
||||
|
||||
def test_gemini_flash_minimal_clamps_to_low(self, transport):
|
||||
# Gemini 3 Flash documents low/medium/high; "minimal" isn't accepted,
|
||||
# so clamp it down to "low" rather than forwarding it verbatim.
|
||||
|
|
|
|||
|
|
@ -316,6 +316,41 @@ class TestProviderPersistsAfterModelSave:
|
|||
assert model.get("default") == "minimax-m2.5"
|
||||
assert model.get("api_mode") == "anthropic_messages"
|
||||
|
||||
def test_antigravity_oauth_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_google_antigravity should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_google_antigravity
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.get_antigravity_oauth_auth_status",
|
||||
return_value={"logged_in": True, "email": "user@example.com"},
|
||||
), patch(
|
||||
"hermes_cli.auth.resolve_antigravity_oauth_runtime_credentials",
|
||||
return_value={
|
||||
"provider": "google-antigravity",
|
||||
"api_key": "tok",
|
||||
"base_url": "antigravity-pa://google",
|
||||
"project_id": "proj-123",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.models.provider_model_ids",
|
||||
return_value=["gemini-3-flash-agent", "claude-sonnet-4-6"],
|
||||
), patch(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
return_value="claude-sonnet-4-6",
|
||||
):
|
||||
_model_flow_google_antigravity(load_config(), "old-model")
|
||||
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict), f"model should be dict, got {type(model)}"
|
||||
assert model.get("provider") == "google-antigravity"
|
||||
assert model.get("base_url") == "antigravity-pa://google"
|
||||
assert model.get("default") == "claude-sonnet-4-6"
|
||||
assert "api_mode" not in model
|
||||
|
||||
|
||||
|
||||
class TestBaseUrlValidation:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Current provider families include (see `plugins/model-providers/` for the comple
|
|||
- OpenAI Codex
|
||||
- Copilot / Copilot ACP
|
||||
- Anthropic (native)
|
||||
- Google / Gemini (`gemini`, `google-gemini-cli`)
|
||||
- Google / Gemini (`gemini`, `google-gemini-cli`, `google-antigravity`)
|
||||
- Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`)
|
||||
- DeepSeek
|
||||
- Z.AI
|
||||
|
|
|
|||
|
|
@ -111,6 +111,19 @@ hermes model
|
|||
|
||||
This uses browser PKCE login and the Cloud Code Assist backend. It can be useful for users who want Gemini CLI-style OAuth, but Hermes shows an explicit warning because Google may treat use of the Gemini CLI OAuth client from third-party software as a policy violation. For production or lowest-risk usage, prefer the API-key provider above.
|
||||
|
||||
Hermes also supports `google-antigravity` for Antigravity Code Assist:
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
# → Choose "Google Antigravity (OAuth)"
|
||||
```
|
||||
|
||||
That provider uses a separate Antigravity OAuth login and stores separate
|
||||
credentials at `~/.hermes/auth/antigravity_oauth.json`. Its model picker uses
|
||||
live Antigravity model discovery, so the list reflects the signed-in account's
|
||||
subscription and can include Antigravity-only Gemini agent models plus other
|
||||
entitled model families.
|
||||
|
||||
## Available Models
|
||||
|
||||
The `hermes model` picker shows Gemini models maintained in Hermes' provider registry. Common choices include:
|
||||
|
|
@ -193,6 +206,7 @@ The doctor checks:
|
|||
|
||||
- Whether `GOOGLE_API_KEY` or `GEMINI_API_KEY` is available
|
||||
- Whether Gemini OAuth credentials exist for `google-gemini-cli`
|
||||
- Whether Antigravity OAuth credentials exist for `google-antigravity`
|
||||
- Whether configured provider credentials can be resolved
|
||||
|
||||
For OAuth quota usage, run this inside a Hermes session:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
|||
| **Qwen OAuth** | `hermes model` → "Qwen OAuth" (provider: `qwen-oauth`; browser PKCE login) |
|
||||
| **MiniMax OAuth** | `hermes model` → "MiniMax (OAuth)" (provider: `minimax-oauth`; browser PKCE login) |
|
||||
| **StepFun** | `STEPFUN_API_KEY` in `~/.hermes/.env` (provider: `stepfun`) |
|
||||
| **Google Antigravity (OAuth)** | `hermes model` → "Google Antigravity (OAuth)" (provider: `google-antigravity`, aliases: `antigravity`, `antigravity-oauth`, `agy`) |
|
||||
| **LM Studio** | `hermes model` → "LM Studio" (provider: `lmstudio`, optional `LM_API_KEY`) |
|
||||
| **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) |
|
||||
|
||||
|
|
@ -78,6 +79,64 @@ Don't have a subscription yet? Get one at [portal.nousresearch.com/manage-subscr
|
|||
**JWT auth (automatic).** Hermes prefers scoped `inference:invoke` JWTs for Portal requests with the legacy opaque session-key path as a fallback. No configuration is required — credentials are managed by the OAuth flow and rotate transparently. Revoked refresh tokens are quarantined to avoid replay loops.
|
||||
|
||||
|
||||
### Google Antigravity via OAuth (`google-antigravity`)
|
||||
|
||||
The `google-antigravity` provider uses Antigravity's Code Assist backend and
|
||||
Antigravity OAuth scopes. It is a native Hermes integration: Hermes runs its
|
||||
own browser PKCE login, stores credentials under
|
||||
`~/.hermes/auth/antigravity_oauth.json`, and talks directly to the Antigravity
|
||||
Code Assist endpoints. It does not shell out to `agy` for inference, and it
|
||||
does not depend on the Antigravity CLI's local token storage.
|
||||
|
||||
**Quick start:**
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
# -> pick "Google Antigravity (OAuth)"
|
||||
# -> browser opens to accounts.google.com, sign in
|
||||
# -> pick one of the models available to your Antigravity account
|
||||
```
|
||||
|
||||
Hermes discovers Antigravity models from `fetchAvailableModels` after login.
|
||||
The visible list depends on the authenticated account and subscription, and can
|
||||
include Antigravity-only Gemini agent models plus Claude and GPT-OSS entries
|
||||
when the account is entitled. If live discovery fails, Hermes falls back to a
|
||||
small curated list so the provider remains selectable.
|
||||
|
||||
Supported aliases:
|
||||
|
||||
```text
|
||||
google-antigravity
|
||||
google-antigravity-oauth
|
||||
antigravity
|
||||
antigravity-oauth
|
||||
antigravity-cli
|
||||
agy
|
||||
agy-cli
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
```bash
|
||||
HERMES_ANTIGRAVITY_CLIENT_ID=your-client.apps.googleusercontent.com
|
||||
HERMES_ANTIGRAVITY_CLIENT_SECRET=...
|
||||
HERMES_ANTIGRAVITY_CLI_PATH=/path/to/agy
|
||||
HERMES_ANTIGRAVITY_PROJECT_ID=your-project
|
||||
```
|
||||
|
||||
If the client ID/secret are not set explicitly, Hermes tries to discover the
|
||||
desktop OAuth client credentials from the installed Antigravity CLI (`agy`) on
|
||||
`PATH`, `HERMES_ANTIGRAVITY_CLI_PATH`, or common Antigravity install/cache
|
||||
locations. Those client credentials are used only to start and refresh Hermes'
|
||||
own OAuth session; Hermes still keeps its access/refresh tokens in `~/.hermes`.
|
||||
|
||||
:::note Windows credential storage
|
||||
The Antigravity CLI may keep its own login in platform-specific storage such as
|
||||
Windows Credential Manager. Hermes intentionally keeps separate credentials in
|
||||
`~/.hermes` so development profiles and production Hermes profiles do not share
|
||||
tokens accidentally.
|
||||
:::
|
||||
|
||||
:::info Codex Note
|
||||
The OpenAI Codex provider authenticates via device code (open a URL, enter a code). Hermes stores the resulting credentials in its own auth store under `~/.hermes/auth.json` and can import existing Codex CLI credentials from `~/.codex/auth.json` when present. No Codex CLI installation is required.
|
||||
|
||||
|
|
@ -1532,7 +1591,7 @@ fallback_model:
|
|||
|
||||
When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session.
|
||||
|
||||
Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
|
||||
Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`.
|
||||
|
||||
:::tip
|
||||
Fallback is configured exclusively through `config.yaml` — or interactively via `hermes fallback`. For full details on when it triggers, how the chain advances, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/user-guide/features/fallback-providers).
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ Common options:
|
|||
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
|
||||
| `-m`, `--model <model>` | Override the model for this run. |
|
||||
| `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
|
||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
|
||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity` (aliases: `antigravity`, `antigravity-oauth`, `agy`), `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). |
|
||||
| `-s`, `--skills <name>` | Preload one or more skills for the session (can be repeated or comma-separated). |
|
||||
| `-v`, `--verbose` | Verbose output. |
|
||||
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ Hermes reads environment variables from the process environment and, for user-ma
|
|||
| `HERMES_GEMINI_CLIENT_ID` | OAuth client ID for `google-gemini-cli` PKCE login (optional; defaults to Google's public gemini-cli client) |
|
||||
| `HERMES_GEMINI_CLIENT_SECRET` | OAuth client secret for `google-gemini-cli` (optional) |
|
||||
| `HERMES_GEMINI_PROJECT_ID` | GCP project ID for paid Gemini tiers (free tier auto-provisions) |
|
||||
| `HERMES_ANTIGRAVITY_CLIENT_ID` | OAuth client ID for `google-antigravity` PKCE login (optional; discovered from installed `agy` when omitted) |
|
||||
| `HERMES_ANTIGRAVITY_CLIENT_SECRET` | OAuth client secret for `google-antigravity` (optional; discovered from installed `agy` when omitted) |
|
||||
| `HERMES_ANTIGRAVITY_CLI_PATH` | Path to the `agy` executable or install file used for Antigravity OAuth client credential discovery |
|
||||
| `HERMES_ANTIGRAVITY_PROJECT_ID` | GCP project ID for Antigravity Code Assist when you want to pin one explicitly |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) |
|
||||
| `ANTHROPIC_BASE_URL` | Override the Anthropic API base URL |
|
||||
| `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override |
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Hermes Agent works with any OpenAI-compatible API. Supported providers include:
|
|||
- **[Nous Portal](/integrations/nous-portal)** — Nous Research's subscription gateway — 300+ models plus web/image/TTS/browser through one OAuth login (recommended for newcomers)
|
||||
- **OpenAI** — GPT-5.4, GPT-5-codex, GPT-4.1, GPT-4o, etc.
|
||||
- **Anthropic** — Claude models (direct API, OAuth via `hermes auth add anthropic`, OpenRouter, or any compatible proxy)
|
||||
- **Google** — Gemini models (direct API via `gemini` provider, the `google-gemini-cli` OAuth provider, OpenRouter, or compatible proxy)
|
||||
- **Google** — Gemini models (direct API via `gemini` provider, the `google-gemini-cli` OAuth provider, the `google-antigravity` OAuth provider, OpenRouter, or compatible proxy)
|
||||
- **z.ai / ZhipuAI** — GLM models
|
||||
- **Kimi / Moonshot AI** — Kimi models
|
||||
- **MiniMax** — global and China endpoints
|
||||
|
|
|
|||
|
|
@ -959,7 +959,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t
|
|||
|
||||
When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL.
|
||||
|
||||
Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`).
|
||||
Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`).
|
||||
|
||||
:::tip MiniMax OAuth
|
||||
`minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md).
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ Each entry requires both `provider` and `model`. Entries missing either field ar
|
|||
| StepFun | `stepfun` | `STEPFUN_API_KEY` (optional: `STEPFUN_BASE_URL`) |
|
||||
| Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |
|
||||
| Google Gemini (OAuth) | `google-gemini-cli` | `hermes model` (Google OAuth; optional: `HERMES_GEMINI_PROJECT_ID`) |
|
||||
| Google Antigravity (OAuth) | `google-antigravity` | `hermes model` (Antigravity OAuth; optional: `HERMES_ANTIGRAVITY_PROJECT_ID`) |
|
||||
| Google AI Studio | `gemini` | `GOOGLE_API_KEY` (alias: `GEMINI_API_KEY`) |
|
||||
| xAI (Grok) | `xai` (alias `grok`) | `XAI_API_KEY` (optional: `XAI_BASE_URL`) |
|
||||
| xAI Grok OAuth (SuperGrok) | `xai-oauth` (alias `grok-oauth`) | `hermes model` → xAI Grok OAuth (browser login; SuperGrok subscription) |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue