feat(providers): remove google-gemini-cli + google-antigravity OAuth providers (#50492)
* feat(providers): remove google-gemini-cli + google-antigravity OAuth providers Google now actively bans accounts for third-party tools that piggyback on Gemini CLI / Antigravity / Code Assist OAuth, and because abuse prevention sits at a backend layer the ban can extend to the entire Google account (Gmail/Drive), with a second violation being permanent. Ref: https://github.com/google-gemini/gemini-cli/discussions/20632 Removes both OAuth inference providers entirely (modules, provider profiles, auth/runtime/config/models wiring, the /gquota Code Assist quota command, the antigravity-cli optional skill, desktop + docs surface in en + zh-Hans). The API-key 'gemini' provider (GOOGLE_API_KEY/GEMINI_API_KEY against generativelanguage.googleapis.com) is unaffected and stays fully supported. * fix(skills): keep the antigravity-cli skill — only the OAuth provider is removed The antigravity-cli optional skill orchestrates the external `agy` binary as a coding-agent tool via the terminal tool — it does NOT wrap Hermes inference through the banned google-antigravity OAuth provider, so it carries none of the account-ban risk that motivated removing that provider. Restore the skill, its docs page, the sidebar entry, and the optional-skills catalog row. The google-antigravity / google-gemini-cli inference providers stay fully removed.fix/verification-admin-route-recovery
parent
5bf23ff251
commit
7130d60861
|
|
@ -1378,37 +1378,6 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
|
|||
agent._client_log_context(),
|
||||
)
|
||||
return client
|
||||
if agent.provider == "google-gemini-cli" or str(client_kwargs.get("base_url", "")).startswith("cloudcode-pa://"):
|
||||
from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient
|
||||
|
||||
# Strip OpenAI-specific kwargs the Gemini client doesn't accept
|
||||
safe_kwargs = {
|
||||
k: v for k, v in client_kwargs.items()
|
||||
if k in {"api_key", "base_url", "default_headers", "project_id", "timeout"}
|
||||
}
|
||||
client = GeminiCloudCodeClient(**safe_kwargs)
|
||||
_ra().logger.info(
|
||||
"Gemini Cloud Code Assist client created (%s, shared=%s) %s",
|
||||
reason,
|
||||
shared,
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
"""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()
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
"""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)
|
||||
if info.project_id:
|
||||
return ProjectContext(
|
||||
project_id=info.project_id,
|
||||
managed_project_id=info.project_id,
|
||||
source="discovered",
|
||||
)
|
||||
# Discovery returned no project (common on fresh consumer accounts that
|
||||
# haven't been onboarded). Fall back to the public default project so the
|
||||
# call chain still succeeds — mirrors the Antigravity CLI reference flow.
|
||||
from agent.antigravity_oauth import DEFAULT_PROJECT_ID
|
||||
return ProjectContext(
|
||||
project_id=DEFAULT_PROJECT_ID,
|
||||
managed_project_id=DEFAULT_PROJECT_ID,
|
||||
source="default",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -1,907 +0,0 @@
|
|||
"""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"
|
||||
|
||||
# Public Antigravity CLI desktop OAuth client. Like Google's gemini-cli
|
||||
# credentials (see agent/google_oauth.py), this is a DESKTOP OAuth client and
|
||||
# its "secret" is not confidential — installed-app clients have no
|
||||
# secret-keeping requirement (PKCE provides the security), and these creds are
|
||||
# baked into every copy of the Antigravity CLI. Shipping them as a fallback
|
||||
# lets users without `agy` installed authenticate directly. Split into parts
|
||||
# with explicit comments per the convention in google_oauth.py.
|
||||
_PUBLIC_CLIENT_ID_PROJECT_NUM = "1071006060591"
|
||||
_PUBLIC_CLIENT_ID_HASH = "tmhssin2h21lcre235vtolojh4g403ep"
|
||||
_PUBLIC_CLIENT_SECRET_SUFFIX = "K58FWR486LdLJ1mLB8sXC4z6qDAf"
|
||||
|
||||
_DEFAULT_CLIENT_ID = (
|
||||
f"{_PUBLIC_CLIENT_ID_PROJECT_NUM}-{_PUBLIC_CLIENT_ID_HASH}"
|
||||
".apps.googleusercontent.com"
|
||||
)
|
||||
_DEFAULT_CLIENT_SECRET = f"GOCSPX-{_PUBLIC_CLIENT_SECRET_SUFFIX}"
|
||||
|
||||
# Fallback project ID when Code Assist project discovery fails entirely.
|
||||
DEFAULT_PROJECT_ID = "rising-fact-p41fc"
|
||||
|
||||
_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()
|
||||
if discovered:
|
||||
return discovered
|
||||
return _DEFAULT_CLIENT_ID
|
||||
|
||||
|
||||
def _get_client_secret() -> str:
|
||||
env_val = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
|
||||
if env_val:
|
||||
return env_val
|
||||
_, discovered = _discover_client_credentials()
|
||||
if discovered:
|
||||
return discovered
|
||||
return _DEFAULT_CLIENT_SECRET
|
||||
|
||||
|
||||
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")
|
||||
candidates: list[Tuple[str, str]] = []
|
||||
if isinstance(cached, list):
|
||||
candidates = [
|
||||
(str(client_id), str(client_secret))
|
||||
for client_id, client_secret in cached
|
||||
if client_id and client_secret
|
||||
]
|
||||
else:
|
||||
client_id = str(_discovered_creds_cache.get("client_id") or "")
|
||||
client_secret = str(_discovered_creds_cache.get("client_secret") or "")
|
||||
if client_id and client_secret:
|
||||
candidates = [(client_id, client_secret)]
|
||||
|
||||
# Always include the public baked-in default as a last-resort candidate so
|
||||
# users without `agy` installed can still authenticate. De-dupe in case
|
||||
# discovery already surfaced the same client.
|
||||
default_pair = (_DEFAULT_CLIENT_ID, _DEFAULT_CLIENT_SECRET)
|
||||
if default_pair not in candidates:
|
||||
candidates.append(default_pair)
|
||||
return candidates
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
@ -1,915 +0,0 @@
|
|||
"""OpenAI-compatible facade that talks to Google's Cloud Code Assist backend.
|
||||
|
||||
This adapter lets Hermes use the ``google-gemini-cli`` provider as if it were
|
||||
a standard OpenAI-shaped chat completion endpoint, while the underlying HTTP
|
||||
traffic goes to ``cloudcode-pa.googleapis.com/v1internal:{generateContent,
|
||||
streamGenerateContent}`` with a Bearer access token obtained via OAuth PKCE.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
- ``GeminiCloudCodeClient`` exposes ``.chat.completions.create(**kwargs)``
|
||||
mirroring the subset of the OpenAI SDK that ``run_agent.py`` uses.
|
||||
- Incoming OpenAI ``messages[]`` / ``tools[]`` / ``tool_choice`` are translated
|
||||
to Gemini's native ``contents[]`` / ``tools[].functionDeclarations`` /
|
||||
``toolConfig`` / ``systemInstruction`` shape.
|
||||
- The request body is wrapped ``{project, model, user_prompt_id, request}``
|
||||
per Code Assist API expectations.
|
||||
- Responses (``candidates[].content.parts[]``) are converted back to
|
||||
OpenAI ``choices[0].message`` shape with ``content`` + ``tool_calls``.
|
||||
- Streaming uses SSE (``?alt=sse``) and yields OpenAI-shaped delta chunks.
|
||||
|
||||
Attribution
|
||||
-----------
|
||||
Translation semantics follow jenslys/opencode-gemini-auth (MIT) and the public
|
||||
Gemini API docs. Request envelope shape
|
||||
(``{project, model, user_prompt_id, request}``) is documented nowhere; it is
|
||||
reverse-engineered from the opencode-gemini-auth and clawdbot implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent import google_oauth
|
||||
from agent.gemini_schema import sanitize_gemini_tool_parameters
|
||||
from agent.google_code_assist import (
|
||||
CODE_ASSIST_ENDPOINT,
|
||||
CodeAssistError,
|
||||
ProjectContext,
|
||||
resolve_project_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request translation: OpenAI → Gemini
|
||||
# =============================================================================
|
||||
|
||||
_ROLE_MAP_OPENAI_TO_GEMINI = {
|
||||
"user": "user",
|
||||
"assistant": "model",
|
||||
"system": "user", # handled separately via systemInstruction
|
||||
"tool": "user", # functionResponse is wrapped in a user-role turn
|
||||
"function": "user",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_content_to_text(content: Any) -> str:
|
||||
"""OpenAI content may be str or a list of parts; reduce to plain text."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
pieces: List[str] = []
|
||||
for p in content:
|
||||
if isinstance(p, str):
|
||||
pieces.append(p)
|
||||
elif isinstance(p, dict):
|
||||
if p.get("type") == "text" and isinstance(p.get("text"), str):
|
||||
pieces.append(p["text"])
|
||||
# Multimodal (image_url, etc.) — stub for now; log and skip
|
||||
elif p.get("type") in {"image_url", "input_audio"}:
|
||||
logger.debug("Dropping multimodal part (not yet supported): %s", p.get("type"))
|
||||
return "\n".join(pieces)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""OpenAI tool_call -> Gemini functionCall part."""
|
||||
fn = tool_call.get("function") or {}
|
||||
args_raw = fn.get("arguments", "")
|
||||
try:
|
||||
args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
|
||||
except json.JSONDecodeError:
|
||||
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": function_call,
|
||||
# Sentinel signature — matches opencode-gemini-auth's approach.
|
||||
# Without this, Code Assist rejects function calls that originated
|
||||
# outside its own chain.
|
||||
"thoughtSignature": "skip_thought_signature_validator",
|
||||
}
|
||||
|
||||
|
||||
def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""OpenAI tool-role message -> Gemini functionResponse part.
|
||||
|
||||
The function name isn't in the OpenAI tool message directly; it must be
|
||||
passed via the assistant message that issued the call. For simplicity we
|
||||
look up ``name`` on the message (OpenAI SDK copies it there) or on the
|
||||
``tool_call_id`` cross-reference.
|
||||
"""
|
||||
name = str(message.get("name") or message.get("tool_call_id") or "tool")
|
||||
content = _coerce_content_to_text(message.get("content"))
|
||||
# Gemini expects the response as a dict under `response`. We wrap plain
|
||||
# text in {"output": "..."}.
|
||||
try:
|
||||
parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
response = parsed if isinstance(parsed, dict) else {"output": content}
|
||||
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(
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""Convert OpenAI messages[] to Gemini contents[] + systemInstruction."""
|
||||
system_text_parts: List[str] = []
|
||||
contents: List[Dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = str(msg.get("role") or "user")
|
||||
|
||||
if role == "system":
|
||||
system_text_parts.append(_coerce_content_to_text(msg.get("content")))
|
||||
continue
|
||||
|
||||
# Tool result message — emit a user-role turn with functionResponse
|
||||
if role == "tool" or role == "function":
|
||||
contents.append({
|
||||
"role": "user",
|
||||
"parts": [_translate_tool_result_to_gemini(msg)],
|
||||
})
|
||||
continue
|
||||
|
||||
gemini_role = _ROLE_MAP_OPENAI_TO_GEMINI.get(role, "user")
|
||||
parts: List[Dict[str, Any]] = []
|
||||
|
||||
text = _coerce_content_to_text(msg.get("content"))
|
||||
if text:
|
||||
parts.append({"text": text})
|
||||
|
||||
# Assistant messages can carry tool_calls
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
if isinstance(tc, dict):
|
||||
parts.append(_translate_tool_call_to_gemini(tc))
|
||||
|
||||
if not parts:
|
||||
# Gemini rejects empty parts; skip the turn entirely
|
||||
continue
|
||||
|
||||
contents.append({"role": gemini_role, "parts": parts})
|
||||
|
||||
system_instruction: Optional[Dict[str, Any]] = None
|
||||
joined_system = "\n".join(p for p in system_text_parts if p).strip()
|
||||
if joined_system:
|
||||
system_instruction = {
|
||||
"role": "system",
|
||||
"parts": [{"text": joined_system}],
|
||||
}
|
||||
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
|
||||
"""OpenAI tools[] -> Gemini tools[].functionDeclarations[]."""
|
||||
if not isinstance(tools, list) or not tools:
|
||||
return []
|
||||
declarations: List[Dict[str, Any]] = []
|
||||
for t in tools:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
fn = t.get("function") or {}
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
name = fn.get("name")
|
||||
if not name:
|
||||
continue
|
||||
decl = {"name": str(name)}
|
||||
if fn.get("description"):
|
||||
decl["description"] = str(fn["description"])
|
||||
params = fn.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
decl["parameters"] = sanitize_gemini_tool_parameters(params)
|
||||
declarations.append(decl)
|
||||
if not declarations:
|
||||
return []
|
||||
return [{"functionDeclarations": declarations}]
|
||||
|
||||
|
||||
def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:
|
||||
"""OpenAI tool_choice -> Gemini toolConfig.functionCallingConfig."""
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, str):
|
||||
if tool_choice == "auto":
|
||||
return {"functionCallingConfig": {"mode": "AUTO"}}
|
||||
if tool_choice == "required":
|
||||
return {"functionCallingConfig": {"mode": "ANY"}}
|
||||
if tool_choice == "none":
|
||||
return {"functionCallingConfig": {"mode": "NONE"}}
|
||||
if isinstance(tool_choice, dict):
|
||||
fn = tool_choice.get("function") or {}
|
||||
name = fn.get("name")
|
||||
if name:
|
||||
return {
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": [str(name)],
|
||||
},
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Accept thinkingBudget / thinkingLevel / includeThoughts (+ snake_case)."""
|
||||
if not isinstance(config, dict) or not config:
|
||||
return None
|
||||
budget = config.get("thinkingBudget", config.get("thinking_budget"))
|
||||
level = config.get("thinkingLevel", config.get("thinking_level"))
|
||||
include = config.get("includeThoughts", config.get("include_thoughts"))
|
||||
normalized: Dict[str, Any] = {}
|
||||
if isinstance(budget, (int, float)):
|
||||
normalized["thinkingBudget"] = int(budget)
|
||||
if isinstance(level, str) and level.strip():
|
||||
normalized["thinkingLevel"] = level.strip().lower()
|
||||
if isinstance(include, bool):
|
||||
normalized["includeThoughts"] = include
|
||||
return normalized or None
|
||||
|
||||
|
||||
def build_gemini_request(
|
||||
*,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Any = None,
|
||||
tool_choice: Any = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
top_p: Optional[float] = None,
|
||||
stop: Any = None,
|
||||
thinking_config: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the inner Gemini request body (goes inside ``request`` wrapper)."""
|
||||
contents, system_instruction = _build_gemini_contents(messages)
|
||||
|
||||
body: Dict[str, Any] = {"contents": contents}
|
||||
if system_instruction is not None:
|
||||
body["systemInstruction"] = system_instruction
|
||||
|
||||
gemini_tools = _translate_tools_to_gemini(tools)
|
||||
if gemini_tools:
|
||||
body["tools"] = gemini_tools
|
||||
tool_cfg = _translate_tool_choice_to_gemini(tool_choice)
|
||||
if tool_cfg is not None:
|
||||
body["toolConfig"] = tool_cfg
|
||||
|
||||
generation_config: Dict[str, Any] = {}
|
||||
if isinstance(temperature, (int, float)):
|
||||
generation_config["temperature"] = float(temperature)
|
||||
if isinstance(max_tokens, int) and max_tokens > 0:
|
||||
generation_config["maxOutputTokens"] = max_tokens
|
||||
if isinstance(top_p, (int, float)):
|
||||
generation_config["topP"] = float(top_p)
|
||||
if isinstance(stop, str) and stop:
|
||||
generation_config["stopSequences"] = [stop]
|
||||
elif isinstance(stop, list) and stop:
|
||||
generation_config["stopSequences"] = [str(s) for s in stop if s]
|
||||
normalized_thinking = _normalize_thinking_config(thinking_config)
|
||||
if normalized_thinking:
|
||||
generation_config["thinkingConfig"] = normalized_thinking
|
||||
if generation_config:
|
||||
body["generationConfig"] = generation_config
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def wrap_code_assist_request(
|
||||
*,
|
||||
project_id: str,
|
||||
model: str,
|
||||
inner_request: Dict[str, Any],
|
||||
user_prompt_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrap the inner Gemini request in the Code Assist envelope."""
|
||||
return {
|
||||
"project": project_id,
|
||||
"model": model,
|
||||
"user_prompt_id": user_prompt_id or str(uuid.uuid4()),
|
||||
"request": inner_request,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response translation: Gemini → OpenAI
|
||||
# =============================================================================
|
||||
|
||||
def _translate_gemini_response(
|
||||
resp: Dict[str, Any],
|
||||
model: str,
|
||||
) -> SimpleNamespace:
|
||||
"""Non-streaming Gemini response -> OpenAI-shaped SimpleNamespace.
|
||||
|
||||
Code Assist wraps the actual Gemini response inside ``response``, so we
|
||||
unwrap it first if present.
|
||||
"""
|
||||
inner = resp.get("response") if isinstance(resp.get("response"), dict) else resp
|
||||
|
||||
candidates = inner.get("candidates") or []
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
return _empty_response(model)
|
||||
|
||||
cand = candidates[0]
|
||||
content_obj = cand.get("content") if isinstance(cand, dict) else {}
|
||||
parts = content_obj.get("parts") if isinstance(content_obj, dict) else []
|
||||
|
||||
text_pieces: List[str] = []
|
||||
reasoning_pieces: List[str] = []
|
||||
tool_calls: List[SimpleNamespace] = []
|
||||
|
||||
for i, part in enumerate(parts or []):
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
# Thought parts are model's internal reasoning — surface as reasoning,
|
||||
# don't mix into content.
|
||||
if part.get("thought") is True:
|
||||
if isinstance(part.get("text"), str):
|
||||
reasoning_pieces.append(part["text"])
|
||||
continue
|
||||
if isinstance(part.get("text"), str):
|
||||
text_pieces.append(part["text"])
|
||||
continue
|
||||
fc = part.get("functionCall")
|
||||
if isinstance(fc, dict) and fc.get("name"):
|
||||
try:
|
||||
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=call_id,
|
||||
type="function",
|
||||
index=i,
|
||||
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
|
||||
))
|
||||
|
||||
finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(
|
||||
str(cand.get("finishReason") or "")
|
||||
)
|
||||
|
||||
usage_meta = inner.get("usageMetadata") or {}
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
|
||||
completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
|
||||
total_tokens=int(usage_meta.get("totalTokenCount") or 0),
|
||||
prompt_tokens_details=SimpleNamespace(
|
||||
cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
message = SimpleNamespace(
|
||||
role="assistant",
|
||||
content="".join(text_pieces) if text_pieces else None,
|
||||
tool_calls=tool_calls or None,
|
||||
reasoning="".join(reasoning_pieces) or None,
|
||||
reasoning_content="".join(reasoning_pieces) or None,
|
||||
reasoning_details=None,
|
||||
)
|
||||
choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=message,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def _empty_response(model: str) -> SimpleNamespace:
|
||||
message = SimpleNamespace(
|
||||
role="assistant", content="", tool_calls=None,
|
||||
reasoning=None, reasoning_content=None, reasoning_details=None,
|
||||
)
|
||||
choice = SimpleNamespace(index=0, message=message, finish_reason="stop")
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=0, completion_tokens=0, total_tokens=0,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def _map_gemini_finish_reason(reason: str) -> str:
|
||||
mapping = {
|
||||
"STOP": "stop",
|
||||
"MAX_TOKENS": "length",
|
||||
"SAFETY": "content_filter",
|
||||
"RECITATION": "content_filter",
|
||||
"OTHER": "stop",
|
||||
}
|
||||
return mapping.get(reason.upper(), "stop")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Streaming SSE iterator
|
||||
# =============================================================================
|
||||
|
||||
class _GeminiStreamChunk(SimpleNamespace):
|
||||
"""Mimics an OpenAI ChatCompletionChunk with .choices[0].delta."""
|
||||
pass
|
||||
|
||||
|
||||
def _make_stream_chunk(
|
||||
*,
|
||||
model: str,
|
||||
content: str = "",
|
||||
tool_call_delta: Optional[Dict[str, Any]] = None,
|
||||
finish_reason: Optional[str] = None,
|
||||
reasoning: str = "",
|
||||
) -> _GeminiStreamChunk:
|
||||
delta_kwargs: Dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": None,
|
||||
"reasoning": None,
|
||||
"reasoning_content": None,
|
||||
}
|
||||
if content:
|
||||
delta_kwargs["content"] = content
|
||||
if tool_call_delta is not None:
|
||||
delta_kwargs["tool_calls"] = [SimpleNamespace(
|
||||
index=tool_call_delta.get("index", 0),
|
||||
id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}",
|
||||
type="function",
|
||||
function=SimpleNamespace(
|
||||
name=tool_call_delta.get("name") or "",
|
||||
arguments=tool_call_delta.get("arguments") or "",
|
||||
),
|
||||
)]
|
||||
if reasoning:
|
||||
delta_kwargs["reasoning"] = reasoning
|
||||
delta_kwargs["reasoning_content"] = reasoning
|
||||
delta = SimpleNamespace(**delta_kwargs)
|
||||
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
|
||||
return _GeminiStreamChunk(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion.chunk",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
|
||||
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
|
||||
"""Parse Server-Sent Events from an httpx streaming response."""
|
||||
buffer = ""
|
||||
for chunk in response.iter_text():
|
||||
if not chunk:
|
||||
continue
|
||||
buffer += chunk
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.rstrip("\r")
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Non-JSON SSE line: %s", data[:200])
|
||||
|
||||
|
||||
def _translate_stream_event(
|
||||
event: Dict[str, Any],
|
||||
model: str,
|
||||
tool_call_counter: List[int],
|
||||
) -> List[_GeminiStreamChunk]:
|
||||
"""Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s).
|
||||
|
||||
``tool_call_counter`` is a single-element list used as a mutable counter
|
||||
across events in the same stream. Each ``functionCall`` part gets a
|
||||
fresh, unique OpenAI ``index`` — keying by function name would collide
|
||||
whenever the model issues parallel calls to the same tool (e.g. reading
|
||||
three files in one turn).
|
||||
"""
|
||||
inner = event.get("response") if isinstance(event.get("response"), dict) else event
|
||||
candidates = inner.get("candidates") or []
|
||||
if not candidates:
|
||||
return []
|
||||
cand = candidates[0]
|
||||
if not isinstance(cand, dict):
|
||||
return []
|
||||
|
||||
chunks: List[_GeminiStreamChunk] = []
|
||||
|
||||
content = cand.get("content") or {}
|
||||
parts = content.get("parts") if isinstance(content, dict) else []
|
||||
for part in parts or []:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("thought") is True and isinstance(part.get("text"), str):
|
||||
chunks.append(_make_stream_chunk(
|
||||
model=model, reasoning=part["text"],
|
||||
))
|
||||
continue
|
||||
if isinstance(part.get("text"), str) and part["text"]:
|
||||
chunks.append(_make_stream_chunk(model=model, content=part["text"]))
|
||||
fc = part.get("functionCall")
|
||||
if isinstance(fc, dict) and fc.get("name"):
|
||||
name = str(fc["name"])
|
||||
idx = tool_call_counter[0]
|
||||
tool_call_counter[0] += 1
|
||||
try:
|
||||
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
args_str = "{}"
|
||||
chunks.append(_make_stream_chunk(
|
||||
model=model,
|
||||
tool_call_delta={
|
||||
"index": idx,
|
||||
"id": str(fc.get("id") or "").strip(),
|
||||
"name": name,
|
||||
"arguments": args_str,
|
||||
},
|
||||
))
|
||||
|
||||
finish_reason_raw = str(cand.get("finishReason") or "")
|
||||
if finish_reason_raw:
|
||||
mapped = _map_gemini_finish_reason(finish_reason_raw)
|
||||
if tool_call_counter[0] > 0:
|
||||
mapped = "tool_calls"
|
||||
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
|
||||
return chunks
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GeminiCloudCodeClient — OpenAI-compatible facade
|
||||
# =============================================================================
|
||||
|
||||
MARKER_BASE_URL = "cloudcode-pa://google"
|
||||
|
||||
|
||||
class _GeminiChatCompletions:
|
||||
def __init__(self, client: "GeminiCloudCodeClient"):
|
||||
self._client = client
|
||||
|
||||
def create(self, **kwargs: Any) -> Any:
|
||||
return self._client._create_chat_completion(**kwargs)
|
||||
|
||||
|
||||
class _GeminiChatNamespace:
|
||||
def __init__(self, client: "GeminiCloudCodeClient"):
|
||||
self.completions = _GeminiChatCompletions(client)
|
||||
|
||||
|
||||
class GeminiCloudCodeClient:
|
||||
"""Minimal OpenAI-SDK-compatible facade over Code Assist v1internal."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
default_headers: Optional[Dict[str, str]] = None,
|
||||
project_id: str = "",
|
||||
**_: Any,
|
||||
):
|
||||
# `api_key` here is a dummy — real auth is the OAuth access token
|
||||
# fetched on every call via agent.google_oauth.get_valid_access_token().
|
||||
# We accept the kwarg for openai.OpenAI interface parity.
|
||||
self.api_key = api_key or "google-oauth"
|
||||
self.base_url = base_url or MARKER_BASE_URL
|
||||
self._default_headers = dict(default_headers or {})
|
||||
self._configured_project_id = project_id
|
||||
self._project_context: Optional[ProjectContext] = None
|
||||
self._project_context_lock = False # simple single-thread guard
|
||||
self.chat = _GeminiChatNamespace(self)
|
||||
self.is_closed = False
|
||||
self._http = httpx.Client(timeout=httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0))
|
||||
|
||||
def close(self) -> None:
|
||||
self.is_closed = True
|
||||
try:
|
||||
self._http.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Implement the OpenAI SDK's context-manager-ish closure check
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:
|
||||
"""Lazily resolve and cache the project context for this client."""
|
||||
if self._project_context is not None:
|
||||
return self._project_context
|
||||
|
||||
env_project = google_oauth.resolve_project_id_from_env()
|
||||
creds = google_oauth.load_credentials()
|
||||
stored_project = creds.project_id if creds else ""
|
||||
|
||||
# Prefer what's already baked into the creds
|
||||
if stored_project:
|
||||
self._project_context = ProjectContext(
|
||||
project_id=stored_project,
|
||||
managed_project_id=creds.managed_project_id if creds else "",
|
||||
tier_id="",
|
||||
source="stored",
|
||||
)
|
||||
return self._project_context
|
||||
|
||||
ctx = resolve_project_context(
|
||||
access_token,
|
||||
configured_project_id=self._configured_project_id,
|
||||
env_project_id=env_project,
|
||||
user_agent_model=model,
|
||||
)
|
||||
# Persist discovered project back to the creds file so the next
|
||||
# session doesn't re-run the discovery.
|
||||
if ctx.project_id or ctx.managed_project_id:
|
||||
google_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-2.5-flash",
|
||||
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 = google_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 = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": "hermes-agent (gemini-cli-compat)",
|
||||
"X-Goog-Api-Client": "gl-python/hermes",
|
||||
"x-activity-request-id": str(uuid.uuid4()),
|
||||
}
|
||||
headers.update(self._default_headers)
|
||||
|
||||
if stream:
|
||||
return self._stream_completion(model=model, wrapped=wrapped, headers=headers)
|
||||
|
||||
url = f"{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 Code Assist: {exc}",
|
||||
code="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]:
|
||||
"""Generator that yields OpenAI-shaped streaming chunks."""
|
||||
url = f"{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:
|
||||
# Materialize error body for better diagnostics
|
||||
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"Streaming request failed: {exc}",
|
||||
code="code_assist_stream_error",
|
||||
) from exc
|
||||
|
||||
return _generator()
|
||||
|
||||
|
||||
def _gemini_http_error(response: httpx.Response) -> CodeAssistError:
|
||||
"""Translate an httpx response into a CodeAssistError with rich metadata.
|
||||
|
||||
Parses Google's error envelope (``{"error": {"code", "message", "status",
|
||||
"details": [...]}}``) so the agent's error classifier can reason about
|
||||
the failure — ``status_code`` enables the rate_limit / auth classification
|
||||
paths, and ``response`` lets the main loop honor ``Retry-After`` just
|
||||
like it does for OpenAI SDK exceptions.
|
||||
|
||||
Also lifts a few recognizable Google conditions into human-readable
|
||||
messages so the user sees something better than a 500-char JSON dump:
|
||||
|
||||
MODEL_CAPACITY_EXHAUSTED → "Gemini model capacity exhausted for
|
||||
<model>. This is a Google-side throttle..."
|
||||
RESOURCE_EXHAUSTED w/o reason → quota-style message
|
||||
404 → "Model <name> not found at cloudcode-pa..."
|
||||
"""
|
||||
status = response.status_code
|
||||
|
||||
# Parse the body once, surviving any weird encodings.
|
||||
body_text = ""
|
||||
body_json: Dict[str, Any] = {}
|
||||
try:
|
||||
body_text = response.text
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if body_text:
|
||||
try:
|
||||
parsed = json.loads(body_text)
|
||||
if isinstance(parsed, dict):
|
||||
body_json = parsed
|
||||
except (ValueError, TypeError):
|
||||
body_json = {}
|
||||
|
||||
# Dig into Google's error envelope. Shape is:
|
||||
# {"error": {"code": 429, "message": "...", "status": "RESOURCE_EXHAUSTED",
|
||||
# "details": [{"@type": ".../ErrorInfo", "reason": "MODEL_CAPACITY_EXHAUSTED",
|
||||
# "metadata": {...}},
|
||||
# {"@type": ".../RetryInfo", "retryDelay": "30s"}]}}
|
||||
err_obj = body_json.get("error") if isinstance(body_json, dict) else None
|
||||
if not isinstance(err_obj, dict):
|
||||
err_obj = {}
|
||||
err_status = str(err_obj.get("status") or "").strip()
|
||||
err_message = str(err_obj.get("message") or "").strip()
|
||||
_raw_details = err_obj.get("details")
|
||||
err_details_list = _raw_details if isinstance(_raw_details, list) else []
|
||||
|
||||
# Extract google.rpc.ErrorInfo reason + metadata. There may be more
|
||||
# than one ErrorInfo (rare), so we pick the first one with a reason.
|
||||
error_reason = ""
|
||||
error_metadata: Dict[str, Any] = {}
|
||||
retry_delay_seconds: Optional[float] = None
|
||||
for detail in err_details_list:
|
||||
if not isinstance(detail, dict):
|
||||
continue
|
||||
type_url = str(detail.get("@type") or "")
|
||||
if not error_reason and type_url.endswith("/google.rpc.ErrorInfo"):
|
||||
reason = detail.get("reason")
|
||||
if isinstance(reason, str) and reason:
|
||||
error_reason = reason
|
||||
md = detail.get("metadata")
|
||||
if isinstance(md, dict):
|
||||
error_metadata = md
|
||||
elif retry_delay_seconds is None and type_url.endswith("/google.rpc.RetryInfo"):
|
||||
# retryDelay is a google.protobuf.Duration string like "30s" or "1.5s".
|
||||
delay_raw = detail.get("retryDelay")
|
||||
if isinstance(delay_raw, str) and delay_raw.endswith("s"):
|
||||
try:
|
||||
retry_delay_seconds = float(delay_raw[:-1])
|
||||
except ValueError:
|
||||
pass
|
||||
elif isinstance(delay_raw, (int, float)):
|
||||
retry_delay_seconds = float(delay_raw)
|
||||
|
||||
# Fall back to the Retry-After header if the body didn't include RetryInfo.
|
||||
if retry_delay_seconds is None:
|
||||
try:
|
||||
header_val = response.headers.get("Retry-After") or response.headers.get("retry-after")
|
||||
except Exception:
|
||||
header_val = None
|
||||
if header_val:
|
||||
try:
|
||||
retry_delay_seconds = float(header_val)
|
||||
except (TypeError, ValueError):
|
||||
retry_delay_seconds = None
|
||||
|
||||
# Classify the error code. ``code_assist_rate_limited`` stays the default
|
||||
# for 429s; a more specific reason tag helps downstream callers (e.g. tests,
|
||||
# logs) without changing the rate_limit classification path.
|
||||
code = f"code_assist_http_{status}"
|
||||
if status == 401:
|
||||
code = "code_assist_unauthorized"
|
||||
elif status == 429:
|
||||
code = "code_assist_rate_limited"
|
||||
if error_reason == "MODEL_CAPACITY_EXHAUSTED":
|
||||
code = "code_assist_capacity_exhausted"
|
||||
|
||||
# Build a human-readable message. Keep the status + a raw-body tail for
|
||||
# debugging, but lead with a friendlier summary when we recognize the
|
||||
# Google signal.
|
||||
model_hint = ""
|
||||
if isinstance(error_metadata, dict):
|
||||
model_hint = str(error_metadata.get("model") or error_metadata.get("modelId") or "").strip()
|
||||
|
||||
if status == 429 and error_reason == "MODEL_CAPACITY_EXHAUSTED":
|
||||
target = model_hint or "this Gemini model"
|
||||
message = (
|
||||
f"Gemini capacity exhausted for {target} (Google-side throttle, "
|
||||
f"not a Hermes issue). Try a different Gemini model or set a "
|
||||
f"fallback_providers entry to a non-Gemini provider."
|
||||
)
|
||||
if retry_delay_seconds is not None:
|
||||
message += f" Google suggests retrying in {retry_delay_seconds:g}s."
|
||||
elif status == 429 and err_status == "RESOURCE_EXHAUSTED":
|
||||
message = (
|
||||
f"Gemini quota exhausted ({err_message or 'RESOURCE_EXHAUSTED'}). "
|
||||
f"Check /gquota for remaining daily requests."
|
||||
)
|
||||
if retry_delay_seconds is not None:
|
||||
message += f" Retry suggested in {retry_delay_seconds:g}s."
|
||||
elif status == 404:
|
||||
# Google returns 404 when a model has been retired or renamed.
|
||||
target = model_hint or (err_message or "model")
|
||||
message = (
|
||||
f"Code Assist 404: {target} is not available at "
|
||||
f"cloudcode-pa.googleapis.com. It may have been renamed or "
|
||||
f"retired. Check hermes_cli/models.py for the current list."
|
||||
)
|
||||
elif err_message:
|
||||
# Generic fallback with the parsed message.
|
||||
message = f"Code Assist HTTP {status} ({err_status or 'error'}): {err_message}"
|
||||
else:
|
||||
# Last-ditch fallback — raw body snippet.
|
||||
message = f"Code Assist returned HTTP {status}: {body_text[:500]}"
|
||||
|
||||
return CodeAssistError(
|
||||
message,
|
||||
code=code,
|
||||
status_code=status,
|
||||
response=response,
|
||||
retry_after=retry_delay_seconds,
|
||||
details={
|
||||
"status": err_status,
|
||||
"reason": error_reason,
|
||||
"metadata": error_metadata,
|
||||
"message": err_message,
|
||||
},
|
||||
)
|
||||
|
|
@ -1,451 +0,0 @@
|
|||
"""Google Code Assist API client — project discovery, onboarding, quota.
|
||||
|
||||
The Code Assist API powers Google's official gemini-cli. It sits at
|
||||
``cloudcode-pa.googleapis.com`` and provides:
|
||||
|
||||
- Free tier access (generous daily quota) for personal Google accounts
|
||||
- Paid tier access via GCP projects with billing / Workspace / Standard / Enterprise
|
||||
|
||||
This module handles the control-plane dance needed before inference:
|
||||
|
||||
1. ``load_code_assist()`` — probe the user's account to learn what tier they're on
|
||||
and whether a ``cloudaicompanionProject`` is already assigned.
|
||||
2. ``onboard_user()`` — if the user hasn't been onboarded yet (new account, fresh
|
||||
free tier, etc.), call this with the chosen tier + project id. Supports LRO
|
||||
polling for slow provisioning.
|
||||
3. ``retrieve_user_quota()`` — fetch the ``buckets[]`` array showing remaining
|
||||
quota per model, used by the ``/gquota`` slash command.
|
||||
|
||||
VPC-SC handling: enterprise accounts under a VPC Service Controls perimeter
|
||||
will get ``SECURITY_POLICY_VIOLATED`` on ``load_code_assist``. We catch this
|
||||
and force the account to ``standard-tier`` so the call chain still succeeds.
|
||||
|
||||
Derived from opencode-gemini-auth (MIT) and clawdbot/extensions/google. The
|
||||
request/response shapes are specific to Google's internal Code Assist API,
|
||||
documented nowhere public — we copy them from the reference implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
|
||||
|
||||
# Fallback endpoints tried when prod returns an error during project discovery
|
||||
FALLBACK_ENDPOINTS = [
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||
]
|
||||
|
||||
# Tier identifiers that Google's API uses
|
||||
FREE_TIER_ID = "free-tier"
|
||||
LEGACY_TIER_ID = "legacy-tier"
|
||||
STANDARD_TIER_ID = "standard-tier"
|
||||
|
||||
# Default HTTP headers matching gemini-cli's fingerprint.
|
||||
# Google may reject unrecognized User-Agents on these internal endpoints.
|
||||
_GEMINI_CLI_USER_AGENT = "google-api-nodejs-client/9.15.1 (gzip)"
|
||||
_X_GOOG_API_CLIENT = "gl-node/24.0.0"
|
||||
_DEFAULT_REQUEST_TIMEOUT = 30.0
|
||||
_ONBOARDING_POLL_ATTEMPTS = 12
|
||||
_ONBOARDING_POLL_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
class CodeAssistError(RuntimeError):
|
||||
"""Exception raised by the Code Assist (``cloudcode-pa``) integration.
|
||||
|
||||
Carries HTTP status / response / retry-after metadata so the agent's
|
||||
``error_classifier._extract_status_code`` and the main loop's Retry-After
|
||||
handling (which walks ``error.response.headers``) pick up the right
|
||||
signals. Without these, 429s from the OAuth path look like opaque
|
||||
``RuntimeError`` and skip the rate-limit path.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "code_assist_error",
|
||||
status_code: Optional[int] = None,
|
||||
response: Any = None,
|
||||
retry_after: Optional[float] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
# ``status_code`` is picked up by ``agent.error_classifier._extract_status_code``
|
||||
# so a 429 from Code Assist classifies as FailoverReason.rate_limit and
|
||||
# triggers the main loop's fallback_providers chain the same way SDK
|
||||
# errors do.
|
||||
self.status_code = status_code
|
||||
# ``response`` is the underlying ``httpx.Response`` (or a shim with a
|
||||
# ``.headers`` mapping and ``.json()`` method). The main loop reads
|
||||
# ``error.response.headers["Retry-After"]`` to honor Google's retry
|
||||
# hints when the backend throttles us.
|
||||
self.response = response
|
||||
# Parsed ``Retry-After`` seconds (kept separately for convenience —
|
||||
# Google returns retry hints in both the header and the error body's
|
||||
# ``google.rpc.RetryInfo`` details, and we pick whichever we found).
|
||||
self.retry_after = retry_after
|
||||
# Parsed structured error details from the Google error envelope
|
||||
# (e.g. ``{"reason": "MODEL_CAPACITY_EXHAUSTED", "status": "RESOURCE_EXHAUSTED"}``).
|
||||
# Useful for logging and for tests that want to assert on specifics.
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class ProjectIdRequiredError(CodeAssistError):
|
||||
def __init__(self, message: str = "GCP project id required for this tier") -> None:
|
||||
super().__init__(message, code="code_assist_project_id_required")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP primitive (auth via Bearer token passed per-call)
|
||||
# =============================================================================
|
||||
|
||||
def _build_headers(access_token: str, *, user_agent_model: str = "") -> Dict[str, str]:
|
||||
ua = _GEMINI_CLI_USER_AGENT
|
||||
if user_agent_model:
|
||||
ua = f"{ua} model/{user_agent_model}"
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": ua,
|
||||
"X-Goog-Api-Client": _X_GOOG_API_CLIENT,
|
||||
"x-activity-request-id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
|
||||
def _client_metadata() -> Dict[str, str]:
|
||||
"""Match Google's gemini-cli exactly — unrecognized metadata may be rejected."""
|
||||
return {
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
|
||||
|
||||
def _post_json(
|
||||
url: str,
|
||||
body: Dict[str, Any],
|
||||
access_token: str,
|
||||
*,
|
||||
timeout: float = _DEFAULT_REQUEST_TIMEOUT,
|
||||
user_agent_model: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers=_build_headers(access_token, user_agent_model=user_agent_model),
|
||||
)
|
||||
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
|
||||
# Special case: VPC-SC violation should be distinguishable
|
||||
if _is_vpc_sc_violation(detail):
|
||||
raise CodeAssistError(
|
||||
f"VPC-SC policy violation: {detail}",
|
||||
code="code_assist_vpc_sc",
|
||||
) from exc
|
||||
raise CodeAssistError(
|
||||
f"Code Assist HTTP {exc.code}: {detail or exc.reason}",
|
||||
code=f"code_assist_http_{exc.code}",
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise CodeAssistError(
|
||||
f"Code Assist request failed: {exc}",
|
||||
code="code_assist_network_error",
|
||||
) from exc
|
||||
|
||||
|
||||
def _is_vpc_sc_violation(body: str) -> bool:
|
||||
"""Detect a VPC Service Controls violation from a response body."""
|
||||
if not body:
|
||||
return False
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return "SECURITY_POLICY_VIOLATED" in body
|
||||
# Walk the nested error structure Google uses
|
||||
error = parsed.get("error") if isinstance(parsed, dict) else None
|
||||
if not isinstance(error, dict):
|
||||
return False
|
||||
details = error.get("details") or []
|
||||
if isinstance(details, list):
|
||||
for item in details:
|
||||
if isinstance(item, dict):
|
||||
reason = item.get("reason") or ""
|
||||
if reason == "SECURITY_POLICY_VIOLATED":
|
||||
return True
|
||||
msg = str(error.get("message", ""))
|
||||
return "SECURITY_POLICY_VIOLATED" in msg
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# load_code_assist — discovers current tier + assigned project
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class CodeAssistProjectInfo:
|
||||
"""Result from ``load_code_assist``."""
|
||||
current_tier_id: str = ""
|
||||
cloudaicompanion_project: str = "" # Google-managed project (free tier)
|
||||
allowed_tiers: List[str] = field(default_factory=list)
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def load_code_assist(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> CodeAssistProjectInfo:
|
||||
"""Call ``POST /v1internal:loadCodeAssist`` with prod → sandbox fallback.
|
||||
|
||||
Returns whatever tier + project info Google reports. On VPC-SC violations,
|
||||
returns a synthetic ``standard-tier`` result so the chain can continue.
|
||||
"""
|
||||
body: Dict[str, Any] = {
|
||||
"metadata": {
|
||||
"duetProject": project_id,
|
||||
**_client_metadata(),
|
||||
},
|
||||
}
|
||||
if project_id:
|
||||
body["cloudaicompanionProject"] = project_id
|
||||
|
||||
endpoints = [CODE_ASSIST_ENDPOINT] + FALLBACK_ENDPOINTS
|
||||
last_err: Optional[Exception] = None
|
||||
for endpoint in endpoints:
|
||||
url = f"{endpoint}/v1internal:loadCodeAssist"
|
||||
try:
|
||||
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
|
||||
return _parse_load_response(resp)
|
||||
except CodeAssistError as exc:
|
||||
if exc.code == "code_assist_vpc_sc":
|
||||
logger.info("VPC-SC violation on %s — defaulting to standard-tier", endpoint)
|
||||
return CodeAssistProjectInfo(
|
||||
current_tier_id=STANDARD_TIER_ID,
|
||||
cloudaicompanion_project=project_id,
|
||||
)
|
||||
last_err = exc
|
||||
logger.warning("loadCodeAssist failed on %s: %s", endpoint, exc)
|
||||
continue
|
||||
if last_err:
|
||||
raise last_err
|
||||
return CodeAssistProjectInfo()
|
||||
|
||||
|
||||
def _parse_load_response(resp: Dict[str, Any]) -> CodeAssistProjectInfo:
|
||||
current_tier = resp.get("currentTier") or {}
|
||||
tier_id = str(current_tier.get("id") or "") if isinstance(current_tier, dict) else ""
|
||||
project = str(resp.get("cloudaicompanionProject") or "")
|
||||
allowed = resp.get("allowedTiers") or []
|
||||
allowed_ids: List[str] = []
|
||||
if isinstance(allowed, list):
|
||||
for t in allowed:
|
||||
if isinstance(t, dict):
|
||||
tid = str(t.get("id") or "")
|
||||
if tid:
|
||||
allowed_ids.append(tid)
|
||||
return CodeAssistProjectInfo(
|
||||
current_tier_id=tier_id,
|
||||
cloudaicompanion_project=project,
|
||||
allowed_tiers=allowed_ids,
|
||||
raw=resp,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# onboard_user — provisions a new user on a tier (with LRO polling)
|
||||
# =============================================================================
|
||||
|
||||
def onboard_user(
|
||||
access_token: str,
|
||||
*,
|
||||
tier_id: str,
|
||||
project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Call ``POST /v1internal:onboardUser`` to provision the user.
|
||||
|
||||
For paid tiers, ``project_id`` is REQUIRED (raises ProjectIdRequiredError).
|
||||
For free tiers, ``project_id`` is optional — Google will assign one.
|
||||
|
||||
Returns the final operation response. Polls ``/v1internal/<name>`` for up
|
||||
to ``_ONBOARDING_POLL_ATTEMPTS`` × ``_ONBOARDING_POLL_INTERVAL_SECONDS``
|
||||
(default: 12 × 5s = 1 min).
|
||||
"""
|
||||
if tier_id != FREE_TIER_ID and tier_id != LEGACY_TIER_ID and not project_id:
|
||||
raise ProjectIdRequiredError(
|
||||
f"Tier {tier_id!r} requires a GCP project id. "
|
||||
"Set HERMES_GEMINI_PROJECT_ID or GOOGLE_CLOUD_PROJECT."
|
||||
)
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"tierId": tier_id,
|
||||
"metadata": _client_metadata(),
|
||||
}
|
||||
if project_id:
|
||||
body["cloudaicompanionProject"] = project_id
|
||||
|
||||
endpoint = CODE_ASSIST_ENDPOINT
|
||||
url = f"{endpoint}/v1internal:onboardUser"
|
||||
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
|
||||
|
||||
# Poll if LRO (long-running operation)
|
||||
if not resp.get("done"):
|
||||
op_name = resp.get("name", "")
|
||||
if not op_name:
|
||||
return resp
|
||||
for attempt in range(_ONBOARDING_POLL_ATTEMPTS):
|
||||
time.sleep(_ONBOARDING_POLL_INTERVAL_SECONDS)
|
||||
poll_url = f"{endpoint}/v1internal/{op_name}"
|
||||
try:
|
||||
poll_resp = _post_json(poll_url, {}, access_token, user_agent_model=user_agent_model)
|
||||
except CodeAssistError as exc:
|
||||
logger.warning("Onboarding poll attempt %d failed: %s", attempt + 1, exc)
|
||||
continue
|
||||
if poll_resp.get("done"):
|
||||
return poll_resp
|
||||
logger.warning("Onboarding did not complete within %d attempts", _ONBOARDING_POLL_ATTEMPTS)
|
||||
return resp
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# retrieve_user_quota — for /gquota
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class QuotaBucket:
|
||||
model_id: str
|
||||
token_type: str = ""
|
||||
remaining_fraction: float = 0.0
|
||||
reset_time_iso: str = ""
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def retrieve_user_quota(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> List[QuotaBucket]:
|
||||
"""Call ``POST /v1internal:retrieveUserQuota`` and parse ``buckets[]``."""
|
||||
body: Dict[str, Any] = {}
|
||||
if project_id:
|
||||
body["project"] = project_id
|
||||
url = f"{CODE_ASSIST_ENDPOINT}/v1internal:retrieveUserQuota"
|
||||
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
|
||||
raw_buckets = resp.get("buckets") or []
|
||||
buckets: List[QuotaBucket] = []
|
||||
if not isinstance(raw_buckets, list):
|
||||
return buckets
|
||||
for b in raw_buckets:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
buckets.append(QuotaBucket(
|
||||
model_id=str(b.get("modelId") or ""),
|
||||
token_type=str(b.get("tokenType") or ""),
|
||||
remaining_fraction=float(b.get("remainingFraction") or 0.0),
|
||||
reset_time_iso=str(b.get("resetTime") or ""),
|
||||
raw=b,
|
||||
))
|
||||
return buckets
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Project context resolution
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class ProjectContext:
|
||||
"""Resolved state for a given OAuth session."""
|
||||
project_id: str = "" # effective project id sent on requests
|
||||
managed_project_id: str = "" # Google-assigned project (free tier)
|
||||
tier_id: str = ""
|
||||
source: str = "" # "env", "config", "discovered", "onboarded"
|
||||
|
||||
|
||||
def resolve_project_context(
|
||||
access_token: str,
|
||||
*,
|
||||
configured_project_id: str = "",
|
||||
env_project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> ProjectContext:
|
||||
"""Figure out what project id + tier to use for requests.
|
||||
|
||||
Priority:
|
||||
1. If configured_project_id or env_project_id is set, use that directly
|
||||
and short-circuit (no discovery needed).
|
||||
2. Otherwise call loadCodeAssist to see what Google says.
|
||||
3. If no tier assigned yet, onboard the user (free tier default).
|
||||
"""
|
||||
# Short-circuit: caller provided a project id
|
||||
if configured_project_id:
|
||||
return ProjectContext(
|
||||
project_id=configured_project_id,
|
||||
tier_id=STANDARD_TIER_ID, # assume paid since they specified one
|
||||
source="config",
|
||||
)
|
||||
if env_project_id:
|
||||
return ProjectContext(
|
||||
project_id=env_project_id,
|
||||
tier_id=STANDARD_TIER_ID,
|
||||
source="env",
|
||||
)
|
||||
|
||||
# Discover via loadCodeAssist
|
||||
info = load_code_assist(access_token, user_agent_model=user_agent_model)
|
||||
|
||||
effective_project = info.cloudaicompanion_project
|
||||
tier = info.current_tier_id
|
||||
|
||||
if not tier:
|
||||
# User hasn't been onboarded — provision them on free tier
|
||||
onboard_resp = onboard_user(
|
||||
access_token,
|
||||
tier_id=FREE_TIER_ID,
|
||||
project_id="",
|
||||
user_agent_model=user_agent_model,
|
||||
)
|
||||
# Re-parse from the onboard response
|
||||
response_body = onboard_resp.get("response") or {}
|
||||
if isinstance(response_body, dict):
|
||||
effective_project = (
|
||||
effective_project
|
||||
or str(response_body.get("cloudaicompanionProject") or "")
|
||||
)
|
||||
tier = FREE_TIER_ID
|
||||
source = "onboarded"
|
||||
else:
|
||||
source = "discovered"
|
||||
|
||||
return ProjectContext(
|
||||
project_id=effective_project,
|
||||
managed_project_id=effective_project if tier == FREE_TIER_ID else "",
|
||||
tier_id=tier,
|
||||
source=source,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -437,10 +437,6 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||
extra_body["extra_body"] = openai_compat_extra
|
||||
elif raw_thinking_config:
|
||||
extra_body["thinking_config"] = raw_thinking_config
|
||||
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
|
||||
|
||||
# Merge any pre-built extra_body additions
|
||||
additions = params.get("extra_body_additions")
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [
|
|||
priority: 4
|
||||
},
|
||||
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{
|
||||
prefix: 'DEEPSEEK_',
|
||||
name: 'DeepSeek',
|
||||
|
|
|
|||
|
|
@ -132,9 +132,9 @@ describe('settings helpers', () => {
|
|||
// KIMI_CN_ likewise must beat KIMI_.
|
||||
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
|
||||
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
|
||||
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
|
||||
// HERMES_QWEN_ shares the HERMES_ stem with other integrations.
|
||||
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
|
||||
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
|
||||
expect(providerGroup('GEMINI_API_KEY')).toBe('Gemini')
|
||||
})
|
||||
|
||||
it('falls back to "Other" for un-grouped env vars', () => {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
|||
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
|
||||
terminal: [
|
||||
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
|
||||
'/exit', '/footer', '/gateway', '/history', '/image', '/indicator', '/logs',
|
||||
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
|
||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||
],
|
||||
|
|
|
|||
2
cli.py
2
cli.py
|
|
@ -7837,8 +7837,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
self._handle_model_switch(cmd_original)
|
||||
elif canonical == "codex-runtime":
|
||||
self._handle_codex_runtime(cmd_original)
|
||||
elif canonical == "gquota":
|
||||
self._handle_gquota_command(cmd_original)
|
||||
|
||||
elif canonical == "personality":
|
||||
# Use original case (handler lowercases the personality name itself)
|
||||
|
|
|
|||
|
|
@ -138,13 +138,6 @@ SERVICE_PROVIDER_NAMES: Dict[str, str] = {
|
|||
"spotify": "Spotify",
|
||||
}
|
||||
|
||||
# Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend)
|
||||
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
|
||||
|
|
@ -209,18 +202,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
|||
auth_type="oauth_external",
|
||||
inference_base_url=DEFAULT_QWEN_BASE_URL,
|
||||
),
|
||||
"google-gemini-cli": ProviderConfig(
|
||||
id="google-gemini-cli",
|
||||
name="Google Gemini (OAuth)",
|
||||
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",
|
||||
|
|
@ -1538,8 +1519,7 @@ def resolve_provider(
|
|||
"github-models": "copilot", "github-model": "copilot",
|
||||
"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",
|
||||
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth",
|
||||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||||
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
||||
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
|
||||
|
|
@ -2165,163 +2145,6 @@ def get_qwen_auth_status() -> Dict[str, Any]:
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# Google Gemini OAuth (google-gemini-cli) — PKCE flow + Cloud Code Assist.
|
||||
#
|
||||
# Tokens live in ~/.hermes/auth/google_oauth.json (managed by agent.google_oauth).
|
||||
# The `base_url` here is the marker "cloudcode-pa://google" that run_agent.py
|
||||
# uses to construct a GeminiCloudCodeClient instead of the default OpenAI SDK.
|
||||
# Actual HTTP traffic goes to https://cloudcode-pa.googleapis.com/v1internal:*.
|
||||
# =============================================================================
|
||||
|
||||
def _mark_google_gemini_cli_active(creds: Dict[str, Any]) -> None:
|
||||
"""Set active_provider to google-gemini-cli in auth.json.
|
||||
|
||||
The actual OAuth tokens live in the Google credential file managed by
|
||||
agent.google_oauth. This function only writes a minimal provider-state
|
||||
entry (email for display) and sets active_provider so that
|
||||
get_active_provider() and _model_section_has_credentials() detect the
|
||||
provider for the setup wizard and status commands.
|
||||
"""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
state: Dict[str, Any] = {}
|
||||
if creds.get("email"):
|
||||
state["email"] = str(creds["email"])
|
||||
_save_provider_state(auth_store, "google-gemini-cli", state)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def resolve_gemini_oauth_runtime_credentials(
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve runtime OAuth creds for google-gemini-cli."""
|
||||
try:
|
||||
from agent.google_oauth import (
|
||||
GoogleOAuthError,
|
||||
_credentials_path,
|
||||
get_valid_access_token,
|
||||
load_credentials,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise AuthError(
|
||||
f"agent.google_oauth is not importable: {exc}",
|
||||
provider="google-gemini-cli",
|
||||
code="google_oauth_module_missing",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
access_token = get_valid_access_token(force_refresh=force_refresh)
|
||||
except GoogleOAuthError as exc:
|
||||
raise AuthError(
|
||||
str(exc),
|
||||
provider="google-gemini-cli",
|
||||
code=exc.code,
|
||||
) from exc
|
||||
|
||||
creds = load_credentials()
|
||||
base_url = DEFAULT_GEMINI_CLOUDCODE_BASE_URL
|
||||
return {
|
||||
"provider": "google-gemini-cli",
|
||||
"base_url": base_url,
|
||||
"api_key": access_token,
|
||||
"source": "google-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_gemini_oauth_auth_status() -> Dict[str, Any]:
|
||||
"""Return a status dict for `hermes auth list` / `hermes status`."""
|
||||
try:
|
||||
from agent.google_oauth import _credentials_path, load_credentials
|
||||
except ImportError:
|
||||
return {"logged_in": False, "error": "agent.google_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": "google-oauth",
|
||||
"api_key": creds.access_token,
|
||||
"expires_at_ms": creds.expires_ms,
|
||||
"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
|
||||
# =============================================================================
|
||||
|
||||
|
|
@ -6265,10 +6088,6 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
|||
return get_xai_oauth_auth_status()
|
||||
if target == "qwen-oauth":
|
||||
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", "google-antigravity", "minimax-oauth"}
|
||||
_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth"}
|
||||
|
||||
|
||||
def _get_custom_provider_names() -> list:
|
||||
|
|
@ -314,7 +314,7 @@ def auth_add_command(args) -> None:
|
|||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
# Add a distinct, self-contained pool entry per account (matching the
|
||||
# xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of
|
||||
# xai-oauth / qwen-oauth patterns) instead of
|
||||
# routing through the singleton ``_save_codex_tokens`` save path.
|
||||
# The singleton round-trip collapsed every added account into the
|
||||
# latest login: a second ``hermes auth add openai-codex`` overwrote
|
||||
|
|
@ -364,49 +364,6 @@ def auth_add_command(args) -> None:
|
|||
print(f'Saved {provider} OAuth credentials: "{shown_label}"')
|
||||
return
|
||||
|
||||
if provider == "google-gemini-cli":
|
||||
from agent.google_oauth import run_gemini_oauth_login_pure
|
||||
|
||||
creds = run_gemini_oauth_login_pure()
|
||||
auth_mod._mark_google_gemini_cli_active(creds)
|
||||
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}:google_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 == "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)
|
||||
|
|
|
|||
|
|
@ -947,52 +947,6 @@ class CLICommandsMixin:
|
|||
_cprint(f" Original session: {parent_session_id}")
|
||||
_cprint(f" Branch session: {new_session_id}")
|
||||
|
||||
def _handle_gquota_command(self, cmd_original: str) -> None:
|
||||
"""Show Google Gemini Code Assist quota usage for the current OAuth account."""
|
||||
try:
|
||||
from agent.google_oauth import get_valid_access_token, GoogleOAuthError, load_credentials
|
||||
from agent.google_code_assist import retrieve_user_quota, CodeAssistError
|
||||
except ImportError as exc:
|
||||
self._console_print(f" [red]Gemini modules unavailable: {exc}[/]")
|
||||
return
|
||||
|
||||
try:
|
||||
access_token = get_valid_access_token()
|
||||
except GoogleOAuthError as exc:
|
||||
self._console_print(f" [yellow]{exc}[/]")
|
||||
self._console_print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.")
|
||||
return
|
||||
|
||||
creds = load_credentials()
|
||||
project_id = (creds.project_id if creds else "") or ""
|
||||
|
||||
try:
|
||||
buckets = retrieve_user_quota(access_token, project_id=project_id)
|
||||
except CodeAssistError as exc:
|
||||
self._console_print(f" [red]Quota lookup failed:[/] {exc}")
|
||||
return
|
||||
|
||||
if not buckets:
|
||||
self._console_print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]")
|
||||
return
|
||||
|
||||
# Sort for stable display, group by model
|
||||
buckets.sort(key=lambda b: (b.model_id, b.token_type))
|
||||
self._console_print()
|
||||
self._console_print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})")
|
||||
self._console_print()
|
||||
for b in buckets:
|
||||
pct = max(0.0, min(1.0, b.remaining_fraction))
|
||||
width = 20
|
||||
filled = int(round(pct * width))
|
||||
bar = "▓" * filled + "░" * (width - filled)
|
||||
pct_str = f"{int(pct * 100):3d}%"
|
||||
header = b.model_id
|
||||
if b.token_type:
|
||||
header += f" [{b.token_type}]"
|
||||
self._console_print(f" {header:40s} {bar} {pct_str}")
|
||||
self._console_print()
|
||||
|
||||
def _handle_personality_command(self, cmd: str):
|
||||
"""Handle the /personality command to set predefined personalities."""
|
||||
from cli import save_config_value
|
||||
|
|
|
|||
|
|
@ -128,8 +128,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||||
"Configuration", aliases=("codex_runtime",),
|
||||
args_hint="[auto|codex_app_server]"),
|
||||
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
|
||||
cli_only=True),
|
||||
|
||||
CommandDef("personality", "Set a predefined personality", "Configuration",
|
||||
args_hint="[name]"),
|
||||
|
|
|
|||
|
|
@ -169,8 +169,8 @@ _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|||
# the dashboard. ``config.yaml`` is the supported surface for these.
|
||||
#
|
||||
# IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate
|
||||
# integration credentials follow that prefix (HERMES_GEMINI_CLIENT_ID,
|
||||
# HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The
|
||||
# integration credentials follow that prefix (HERMES_LANGFUSE_PUBLIC_KEY,
|
||||
# HERMES_SPOTIFY_CLIENT_ID, ...). The
|
||||
# denylist is name-by-name on purpose so the gate stays narrow and
|
||||
# doesn't accidentally break provider setup wizards.
|
||||
#
|
||||
|
|
@ -3082,62 +3082,6 @@ OPTIONAL_ENV_VARS = {
|
|||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HERMES_GEMINI_CLIENT_ID": {
|
||||
"description": "Google OAuth client ID for google-gemini-cli (optional; defaults to Google's public gemini-cli client)",
|
||||
"prompt": "Google OAuth client ID (optional — leave empty to use the public default)",
|
||||
"url": "https://console.cloud.google.com/apis/credentials",
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HERMES_GEMINI_CLIENT_SECRET": {
|
||||
"description": "Google OAuth client secret for google-gemini-cli (optional)",
|
||||
"prompt": "Google OAuth client secret (optional)",
|
||||
"url": "https://console.cloud.google.com/apis/credentials",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"HERMES_GEMINI_PROJECT_ID": {
|
||||
"description": "GCP project ID for paid Gemini tiers (free tier auto-provisions)",
|
||||
"prompt": "GCP project ID for Gemini OAuth (leave empty for free tier)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -158,12 +158,6 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool
|
|||
that direct-key problem into the final blocking summary.
|
||||
"""
|
||||
normalized = (provider_label or "").strip().lower()
|
||||
if normalized in {"google / gemini", "gemini"}:
|
||||
try:
|
||||
from hermes_cli.auth import get_gemini_oauth_auth_status
|
||||
return bool((get_gemini_oauth_auth_status() or {}).get("logged_in"))
|
||||
except Exception:
|
||||
return False
|
||||
if normalized == "minimax":
|
||||
try:
|
||||
from hermes_cli.auth import get_minimax_oauth_auth_status
|
||||
|
|
@ -1077,7 +1071,6 @@ def run_doctor(args):
|
|||
from hermes_cli.auth import (
|
||||
get_nous_auth_status,
|
||||
get_codex_auth_status,
|
||||
get_gemini_oauth_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
|
||||
|
|
@ -1105,20 +1098,6 @@ def run_doctor(args):
|
|||
"from an existing Codex CLI login)"
|
||||
)
|
||||
|
||||
gemini_status = get_gemini_oauth_auth_status()
|
||||
if gemini_status.get("logged_in"):
|
||||
email = gemini_status.get("email") or ""
|
||||
project = gemini_status.get("project_id") or ""
|
||||
pieces = []
|
||||
if email:
|
||||
pieces.append(email)
|
||||
if project:
|
||||
pieces.append(f"project={project}")
|
||||
suffix = f" ({', '.join(pieces)})" if pieces else ""
|
||||
check_ok("Google Gemini OAuth", f"(logged in{suffix})")
|
||||
else:
|
||||
check_warn("Google Gemini OAuth", "(not logged in)")
|
||||
|
||||
minimax_status = get_minimax_oauth_auth_status()
|
||||
if minimax_status.get("logged_in"):
|
||||
region = minimax_status.get("region", "global")
|
||||
|
|
|
|||
|
|
@ -602,8 +602,6 @@ from hermes_cli.model_setup_flows import (
|
|||
_model_flow_xai_oauth,
|
||||
_model_flow_qwen_oauth,
|
||||
_model_flow_minimax_oauth,
|
||||
_model_flow_google_gemini_cli,
|
||||
_model_flow_google_antigravity,
|
||||
_model_flow_custom,
|
||||
_model_flow_azure_foundry,
|
||||
_model_flow_named_custom,
|
||||
|
|
@ -3073,10 +3071,6 @@ def select_provider_and_model(args=None):
|
|||
_model_flow_qwen_oauth(config, current_model)
|
||||
elif selected_provider == "minimax-oauth":
|
||||
_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":
|
||||
|
|
@ -11254,7 +11248,7 @@ def _build_provider_choices() -> list[str]:
|
|||
# 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",
|
||||
"anthropic", "gemini", "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",
|
||||
|
|
|
|||
|
|
@ -633,142 +633,6 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
|
|||
_update_config_for_provider("minimax-oauth", creds["base_url"])
|
||||
print(f"\u2713 Using MiniMax model: {selected}")
|
||||
|
||||
def _model_flow_google_gemini_cli(_config, current_model=""):
|
||||
"""Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers.
|
||||
|
||||
Flow:
|
||||
1. Show upfront warning about Google's ToS stance (per opencode-gemini-auth).
|
||||
2. If creds missing, run PKCE browser OAuth via agent.google_oauth.
|
||||
3. Resolve project context (env -> config -> auto-discover -> free tier).
|
||||
4. Prompt user to pick a model.
|
||||
5. Save to ~/.hermes/config.yaml.
|
||||
"""
|
||||
from hermes_cli.auth import (
|
||||
DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
|
||||
get_gemini_oauth_auth_status,
|
||||
resolve_gemini_oauth_runtime_credentials,
|
||||
_prompt_model_selection,
|
||||
_save_model_choice,
|
||||
_update_config_for_provider,
|
||||
)
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
print()
|
||||
print("⚠ Google considers using the Gemini CLI OAuth client with third-party")
|
||||
print(" software a policy violation. Some users have reported account")
|
||||
print(" restrictions. You can use your own API key via 'gemini' provider")
|
||||
print(" for the lowest-risk experience.")
|
||||
print()
|
||||
try:
|
||||
proceed = input("Continue with OAuth login? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("Cancelled.")
|
||||
return
|
||||
if proceed not in {"y", "yes"}:
|
||||
print("Cancelled.")
|
||||
return
|
||||
|
||||
status = get_gemini_oauth_auth_status()
|
||||
if not status.get("logged_in"):
|
||||
try:
|
||||
from agent.google_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
|
||||
|
||||
# Verify creds resolve + trigger project discovery
|
||||
try:
|
||||
creds = resolve_gemini_oauth_runtime_credentials(force_refresh=False)
|
||||
project_id = creds.get("project_id", "")
|
||||
if project_id:
|
||||
print(f" Using GCP project: {project_id}")
|
||||
else:
|
||||
print(
|
||||
" No GCP project configured — free tier will be auto-provisioned on first request."
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Failed to resolve Gemini credentials: {exc}")
|
||||
return
|
||||
|
||||
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
|
||||
default = current_model or (models[0] if models else "gemini-3-flash-preview")
|
||||
selected = _prompt_model_selection(
|
||||
models,
|
||||
current_model=default,
|
||||
confirm_provider="google-gemini-cli",
|
||||
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider(
|
||||
"google-gemini-cli", DEFAULT_GEMINI_CLOUDCODE_BASE_URL
|
||||
)
|
||||
print(
|
||||
f"Default model set to: {selected} (via Google Gemini OAuth / Code Assist)"
|
||||
)
|
||||
else:
|
||||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_google_antigravity(_config, current_model=""):
|
||||
"""Google Antigravity OAuth via Antigravity Code Assist.
|
||||
|
||||
Antigravity is Google's consumer successor to the Gemini CLI. It reuses the
|
||||
Code Assist backend with a distinct OAuth client + scopes. Leaves the
|
||||
`google-gemini-cli` provider (Enterprise Code Assist) untouched.
|
||||
"""
|
||||
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,
|
||||
confirm_provider="google-antigravity",
|
||||
confirm_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL,
|
||||
)
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -265,26 +265,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
|||
"gemini-3.5-flash",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
],
|
||||
"google-gemini-cli": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
# Code Assist serves two flash slugs with different access gates
|
||||
# (gemini-cli models.ts): gemini-3-flash-preview is the preview flash
|
||||
# that subscription/free-tier OAuth users actually reach, while
|
||||
# gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users
|
||||
# aren't stuck with a slug cloudcode-pa 404s for them.
|
||||
"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",
|
||||
|
|
@ -1037,8 +1017,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (Spawns copilot --acp --stdio)"),
|
||||
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)"),
|
||||
|
|
@ -1109,7 +1087,7 @@ PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = {
|
|||
"kimi": ("Kimi / Moonshot", "Coding Plan, Moonshot global & China endpoints", ["kimi-coding", "kimi-coding-cn"]),
|
||||
"minimax": ("MiniMax", "Global, OAuth Coding Plan & China endpoints", ["minimax", "minimax-oauth", "minimax-cn"]),
|
||||
"xai": ("xAI Grok", "Direct API or SuperGrok / Premium+ OAuth", ["xai", "xai-oauth"]),
|
||||
"google": ("Google Gemini", "AI Studio API or OAuth + Code Assist", ["gemini", "google-gemini-cli"]),
|
||||
"google": ("Google Gemini", "Google AI Studio (API key)", ["gemini"]),
|
||||
"openai": ("OpenAI", "Codex CLI or direct OpenAI API", ["openai-codex", "openai-api"]),
|
||||
"opencode": ("OpenCode", "Zen pay-as-you-go or Go subscription", ["opencode-zen", "opencode-go"]),
|
||||
"copilot": ("GitHub Copilot", "GitHub token API or copilot --acp process", ["copilot", "copilot-acp"]),
|
||||
|
|
@ -1230,14 +1208,6 @@ _PROVIDER_ALIASES = {
|
|||
"qwen": "alibaba",
|
||||
"alibaba-cloud": "alibaba",
|
||||
"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",
|
||||
|
|
@ -1805,13 +1775,10 @@ _AGGREGATOR_PROVIDERS = frozenset(
|
|||
)
|
||||
|
||||
# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models
|
||||
# (e.g. google-antigravity serves Claude / Gemini / GPT-OSS where the account
|
||||
# is entitled). For bare short-alias resolution (`sonnet`, `opus`, ...) these
|
||||
# must NOT hijack the alias away from the model's native vendor provider
|
||||
# (`anthropic`, `gemini`, ...). They're tried only as a last resort, after
|
||||
# every native-vendor catalog. They are NOT aggregators (an explicit switch TO
|
||||
# them is still valid), so they stay out of _AGGREGATOR_PROVIDERS.
|
||||
_BORROWED_MODEL_PROVIDERS = frozenset({"google-antigravity"})
|
||||
# would be listed here (tried only as a last resort for bare short-alias
|
||||
# resolution, after every native-vendor catalog, so they never hijack an alias
|
||||
# away from the model's native vendor). None are currently defined.
|
||||
_BORROWED_MODEL_PROVIDERS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _resolve_static_model_alias(
|
||||
|
|
@ -1863,9 +1830,9 @@ def _resolve_static_model_alias(
|
|||
if provider in current_keys and (matched := _match(provider)):
|
||||
return provider, matched
|
||||
|
||||
# Last resort: providers that re-expose other vendors' models (e.g.
|
||||
# google-antigravity serving Claude). Only reached when no native-vendor
|
||||
# catalog matched — so `sonnet` resolves to anthropic, not antigravity.
|
||||
# Last resort: providers that re-expose other vendors' models. Only reached
|
||||
# when no native-vendor catalog matched — so `sonnet` resolves to anthropic.
|
||||
# None are currently defined (_BORROWED_MODEL_PROVIDERS is empty).
|
||||
for provider in _BORROWED_MODEL_PROVIDERS:
|
||||
if provider in current_keys and (matched := _match(provider)):
|
||||
return provider, matched
|
||||
|
|
@ -2240,32 +2207,6 @@ 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.
|
||||
|
||||
|
|
@ -2296,10 +2237,6 @@ 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())
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ _ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset(
|
|||
class ProviderDescriptor:
|
||||
"""One provider, as seen by every surface (CLI picker + both GUI tabs)."""
|
||||
|
||||
slug: str # canonical id, e.g. "google-gemini-cli"
|
||||
slug: str # canonical id, e.g. "openai-codex"
|
||||
label: str # human display name
|
||||
description: str # one-line description
|
||||
auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk
|
||||
|
|
|
|||
|
|
@ -76,16 +76,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
|||
base_url_override="https://portal.qwen.ai/v1",
|
||||
base_url_env_var="HERMES_QWEN_BASE_URL",
|
||||
),
|
||||
"google-gemini-cli": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
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",
|
||||
|
|
@ -315,18 +305,6 @@ ALIASES: Dict[str, str] = {
|
|||
"alibaba-coding": "alibaba-coding-plan",
|
||||
"alibaba_coding_plan": "alibaba-coding-plan",
|
||||
|
||||
# google-gemini-cli (OAuth + Code Assist)
|
||||
"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",
|
||||
"hugging-face": "huggingface",
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ from hermes_cli.auth import (
|
|||
resolve_codex_runtime_credentials,
|
||||
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,
|
||||
|
|
@ -332,12 +330,6 @@ def _resolve_runtime_from_pool_entry(
|
|||
elif provider == "qwen-oauth":
|
||||
api_mode = "chat_completions"
|
||||
base_url = base_url or DEFAULT_QWEN_BASE_URL
|
||||
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
|
||||
|
|
@ -1618,46 +1610,6 @@ def resolve_runtime_provider(
|
|||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
if provider == "google-gemini-cli":
|
||||
try:
|
||||
creds = resolve_gemini_oauth_runtime_credentials()
|
||||
return {
|
||||
"provider": "google-gemini-cli",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": creds.get("base_url", ""),
|
||||
"api_key": creds.get("api_key", ""),
|
||||
"source": creds.get("source", "google-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 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 {
|
||||
|
|
|
|||
|
|
@ -420,7 +420,6 @@ TIPS = [
|
|||
'/platforms shows gateway and messaging-platform connection status right from inside chat.',
|
||||
'/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.',
|
||||
'/toolsets lists every available toolset so you know what -t/--toolsets accepts.',
|
||||
'/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.',
|
||||
'/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.',
|
||||
'/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.',
|
||||
'/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.',
|
||||
|
|
|
|||
|
|
@ -5640,23 +5640,6 @@ def _claude_code_only_status() -> Dict[str, Any]:
|
|||
return {"logged_in": False, "source": None}
|
||||
|
||||
|
||||
def _gemini_cli_status() -> Dict[str, Any]:
|
||||
"""Status for the google-gemini-cli OAuth provider (Code Assist login)."""
|
||||
try:
|
||||
from hermes_cli import auth as hauth
|
||||
raw = hauth.get_gemini_oauth_auth_status()
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or "google_oauth",
|
||||
"source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist",
|
||||
"token_preview": _truncate_token(raw.get("api_key")),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
|
||||
|
||||
def _copilot_acp_status() -> Dict[str, Any]:
|
||||
"""Status for copilot-acp — credentials are owned by the Copilot CLI.
|
||||
|
||||
|
|
@ -5736,14 +5719,6 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
|||
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
|
||||
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
|
||||
},
|
||||
{
|
||||
"id": "google-gemini-cli",
|
||||
"name": "Google Gemini (OAuth + Code Assist)",
|
||||
"flow": "external",
|
||||
"cli_command": "hermes auth add google-gemini-cli",
|
||||
"docs_url": "https://ai.google.dev/gemini-api/docs",
|
||||
"status_fn": _gemini_cli_status,
|
||||
},
|
||||
{
|
||||
"id": "copilot-acp",
|
||||
"name": "GitHub Copilot (ACP)",
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
# Gemini OAuth Provider — Implementation Plan
|
||||
|
||||
## Goal
|
||||
Add a first-class `gemini` provider that authenticates via Google OAuth, using the standard Gemini API (not Cloud Code Assist). Users who have a Google AI subscription or Gemini API access can authenticate through the browser without needing to manually copy API keys.
|
||||
|
||||
## Architecture Decision
|
||||
- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta`
|
||||
- **NOT Path B:** Cloud Code Assist (`cloudcode-pa.googleapis.com`) — rate-limited free tier, internal API, account ban risk
|
||||
- Standard `chat_completions` api_mode via OpenAI SDK — no new api_mode needed
|
||||
- Our own OAuth credentials — NOT sharing tokens with Gemini CLI
|
||||
|
||||
## OAuth Flow
|
||||
- **Type:** Authorization Code + PKCE (S256) — same pattern as clawdbot/pi-mono
|
||||
- **Auth URL:** `https://accounts.google.com/o/oauth2/v2/auth`
|
||||
- **Token URL:** `https://oauth2.googleapis.com/token`
|
||||
- **Redirect:** `http://localhost:8085/oauth2callback` (localhost callback server)
|
||||
- **Fallback:** Manual URL paste for remote/WSL/headless environments
|
||||
- **Scopes:** `https://www.googleapis.com/auth/cloud-platform`, `https://www.googleapis.com/auth/userinfo.email`
|
||||
- **PKCE:** S256 code challenge, 32-byte random verifier
|
||||
|
||||
## Client ID
|
||||
- Need to register a "Desktop app" OAuth client on a Nous Research GCP project
|
||||
- Ship client_id + client_secret in code (Google considers installed app secrets non-confidential)
|
||||
- Alternatively: accept user-provided client_id via env vars as override
|
||||
|
||||
## Token Lifecycle
|
||||
- Store at `~/.hermes/gemini_oauth.json` (NOT sharing with `~/.gemini/oauth_creds.json`)
|
||||
- Fields: `client_id`, `client_secret`, `refresh_token`, `access_token`, `expires_at`, `email`
|
||||
- File permissions: 0o600
|
||||
- Before each API call: check expiry, refresh if within 5 min of expiration
|
||||
- Refresh: POST to token URL with `grant_type=refresh_token`
|
||||
- File locking for concurrent access (multiple agent sessions)
|
||||
|
||||
## API Integration
|
||||
- Base URL: `https://generativelanguage.googleapis.com/v1beta`
|
||||
- Auth: native Gemini API authentication handled by the provider adapter
|
||||
- api_mode: `chat_completions` (standard facade over native transport)
|
||||
- Models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash, etc.
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New files
|
||||
1. `agent/google_oauth.py` — OAuth flow (PKCE, localhost server, token exchange, refresh)
|
||||
- `start_oauth_flow()` — opens browser, starts callback server
|
||||
- `exchange_code()` — code → tokens
|
||||
- `refresh_access_token()` — refresh flow
|
||||
- `load_credentials()` / `save_credentials()` — file I/O with locking
|
||||
- `get_valid_access_token()` — check expiry, refresh if needed
|
||||
- ~200 lines
|
||||
|
||||
### Existing files to modify
|
||||
2. `hermes_cli/auth.py` — Add ProviderConfig for "gemini" with auth_type="oauth_google"
|
||||
3. `hermes_cli/models.py` — Add Gemini model catalog
|
||||
4. `hermes_cli/runtime_provider.py` — Add gemini branch (read OAuth token, build OpenAI client)
|
||||
5. `hermes_cli/main.py` — Add `_model_flow_gemini()`, add to provider choices
|
||||
6. `hermes_cli/setup.py` — Add gemini auth flow (trigger browser OAuth)
|
||||
7. `run_agent.py` — Token refresh before API calls (like Copilot pattern)
|
||||
8. `agent/auxiliary_client.py` — Add gemini to aux resolution chain
|
||||
9. `agent/model_metadata.py` — Add Gemini model context lengths
|
||||
|
||||
### Tests
|
||||
10. `tests/agent/test_google_oauth.py` — OAuth flow unit tests
|
||||
11. `tests/test_api_key_providers.py` — Add gemini provider test
|
||||
|
||||
### Docs
|
||||
12. `website/docs/getting-started/quickstart.md` — Add gemini to provider table
|
||||
13. `website/docs/user-guide/configuration.md` — Gemini setup section
|
||||
14. `website/docs/reference/environment-variables.md` — New env vars
|
||||
|
||||
## Estimated scope
|
||||
~400 lines new code, ~150 lines modifications, ~100 lines tests, ~50 lines docs = ~700 lines total
|
||||
|
||||
## Prerequisites
|
||||
- Nous Research GCP project with Desktop OAuth client registered
|
||||
- OR: accept user-provided client_id via HERMES_GEMINI_CLIENT_ID env var
|
||||
|
||||
## Reference implementations
|
||||
- clawdbot: `extensions/google/oauth.flow.ts` (PKCE + localhost server)
|
||||
- pi-mono: `packages/ai/src/utils/oauth/google-gemini-cli.ts` (same flow)
|
||||
- hermes-agent Copilot OAuth: `hermes_cli/main.py` `_copilot_device_flow()` (different flow type but same lifecycle pattern)
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
"""Google Gemini provider profiles.
|
||||
|
||||
gemini: Google AI Studio (API key) — uses GeminiNativeClient
|
||||
google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient
|
||||
google-antigravity: Google Antigravity Code Assist (OAuth) — uses AntigravityCloudCodeClient
|
||||
|
||||
Both report api_mode="chat_completions" but use custom native clients
|
||||
that bypass the standard OpenAI transport. The profile captures auth
|
||||
Reports api_mode="chat_completions" but uses a custom native client
|
||||
that bypasses the standard OpenAI transport. The profile captures auth
|
||||
and endpoint metadata for auth.py / runtime_provider.py migration, and
|
||||
carries the thinking_config translation hook so the transport's profile
|
||||
path produces the same extra_body shape the legacy flag path did.
|
||||
|
|
@ -60,31 +58,4 @@ gemini = GeminiProfile(
|
|||
default_aux_model="gemini-3.5-flash",
|
||||
)
|
||||
|
||||
google_gemini_cli = GeminiProfile(
|
||||
name="google-gemini-cli",
|
||||
aliases=("gemini-cli", "gemini-oauth"),
|
||||
api_mode="chat_completions",
|
||||
env_vars=(), # OAuth — no API key
|
||||
base_url="cloudcode-pa://google", # Cloud Code Assist internal scheme
|
||||
auth_type="oauth_external",
|
||||
)
|
||||
|
||||
google_antigravity = GeminiProfile(
|
||||
name="google-antigravity",
|
||||
aliases=(
|
||||
"antigravity",
|
||||
"antigravity-oauth",
|
||||
"antigravity-cli",
|
||||
"google-antigravity-oauth",
|
||||
"agy",
|
||||
"agy-cli",
|
||||
),
|
||||
api_mode="chat_completions",
|
||||
env_vars=(), # OAuth — no API key
|
||||
base_url="antigravity-pa://google", # Antigravity Code Assist internal scheme
|
||||
auth_type="oauth_external",
|
||||
)
|
||||
|
||||
register_provider(gemini)
|
||||
register_provider(google_gemini_cli)
|
||||
register_provider(google_antigravity)
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ def _pool_may_recover_from_rate_limit(
|
|||
return False
|
||||
# CloudCode / Gemini CLI quotas are account-wide — all pool entries share
|
||||
# the same throttle window, so rotation can't recover. Prefer fallback.
|
||||
if provider == "google-gemini-cli" or str(base_url or "").startswith("cloudcode-pa://"):
|
||||
if str(base_url or "").startswith("cloudcode-pa://"):
|
||||
return False
|
||||
return len(pool.entries()) > 1
|
||||
|
||||
|
|
@ -4093,8 +4093,7 @@ class AIAgent:
|
|||
if pool is None:
|
||||
return False
|
||||
if (
|
||||
self.provider == "google-gemini-cli"
|
||||
or str(getattr(self, "base_url", "")).startswith("cloudcode-pa://")
|
||||
str(getattr(self, "base_url", "")).startswith("cloudcode-pa://")
|
||||
):
|
||||
# CloudCode/Gemini quota windows are usually account-level throttles.
|
||||
# Prefer the configured fallback immediately instead of waiting out
|
||||
|
|
|
|||
|
|
@ -336,7 +336,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer
|
|||
/commands [page] Browse all commands (gateway)
|
||||
/usage Token usage
|
||||
/insights [days] Usage analytics
|
||||
/gquota Show Google Gemini Code Assist quota usage (CLI)
|
||||
/status Session info (gateway)
|
||||
/profile Active profile info
|
||||
/debug Upload debug report (system info + logs) and get shareable links
|
||||
|
|
|
|||
|
|
@ -1,405 +0,0 @@
|
|||
"""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_discovery_falls_back_to_public_default(self, monkeypatch):
|
||||
# With no env override and no discoverable agy install, the public
|
||||
# baked-in Antigravity desktop OAuth client is used as the floor so
|
||||
# users without `agy` installed can still authenticate (PKCE makes the
|
||||
# installed-app "secret" non-confidential, same as gemini-cli).
|
||||
from agent import antigravity_oauth
|
||||
from agent.antigravity_oauth import (
|
||||
_DEFAULT_CLIENT_ID,
|
||||
_DEFAULT_CLIENT_SECRET,
|
||||
_require_client_id,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", raising=False)
|
||||
monkeypatch.delenv("HERMES_ANTIGRAVITY_CLI_PATH", raising=False)
|
||||
antigravity_oauth._discovered_creds_cache.clear()
|
||||
|
||||
assert _require_client_id() == _DEFAULT_CLIENT_ID
|
||||
assert antigravity_oauth._get_client_secret() == _DEFAULT_CLIENT_SECRET
|
||||
assert _DEFAULT_CLIENT_ID.startswith("1071006060591-")
|
||||
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -22,7 +22,7 @@ def _pool(entries: int = 2):
|
|||
def test_cloudcode_provider_skips_pool_rotation():
|
||||
assert _pool_may_recover_from_rate_limit(
|
||||
_pool(entries=3),
|
||||
provider="google-gemini-cli",
|
||||
provider="auto",
|
||||
base_url="cloudcode-pa://google",
|
||||
) is False
|
||||
|
||||
|
|
|
|||
|
|
@ -404,34 +404,6 @@ class TestChatCompletionsBuildKwargs:
|
|||
)
|
||||
assert kw["extra_body"]["extra_body"]["google"]["thinking_config"]["thinking_level"] == "high"
|
||||
|
||||
def test_google_gemini_cli_keeps_top_level_thinking_config(self, transport):
|
||||
msgs = [{"role": "user", "content": "Hi"}]
|
||||
kw = transport.build_kwargs(
|
||||
model="gemini-3-flash-preview",
|
||||
messages=msgs,
|
||||
provider_name="google-gemini-cli",
|
||||
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_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.
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ class TestMaybeApplyCodexAppServerRuntime:
|
|||
"openrouter",
|
||||
"xai",
|
||||
"qwen-oauth",
|
||||
"google-gemini-cli",
|
||||
"opencode-zen",
|
||||
"bedrock",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_gquota_uses_chat_console_when_tui_is_live():
|
||||
from agent.google_oauth import GoogleOAuthError
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.console = MagicMock()
|
||||
cli._app = object()
|
||||
|
||||
live_console = MagicMock()
|
||||
|
||||
with patch("cli.ChatConsole", return_value=live_console), \
|
||||
patch("agent.google_oauth.get_valid_access_token", side_effect=GoogleOAuthError("No Google OAuth credentials found")), \
|
||||
patch("agent.google_oauth.load_credentials", return_value=None), \
|
||||
patch("agent.google_code_assist.retrieve_user_quota"):
|
||||
cli._handle_gquota_command("/gquota")
|
||||
|
||||
assert live_console.print.call_count == 2
|
||||
cli.console.print.assert_not_called()
|
||||
|
|
@ -129,51 +129,6 @@ def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
|||
assert entry["expires_at_ms"] == 1711234567000
|
||||
|
||||
|
||||
def test_auth_add_google_gemini_cli_sets_active_provider(tmp_path, monkeypatch):
|
||||
"""hermes auth add google-gemini-cli must set active_provider in auth.json.
|
||||
|
||||
Tokens are managed by agent.google_oauth (written to the Google credential
|
||||
file by start_oauth_flow). The auth.json entry must record active_provider
|
||||
so get_active_provider() and _model_section_has_credentials() detect the
|
||||
provider — without storing tokens that would become stale.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
monkeypatch.setattr(
|
||||
"agent.google_oauth.run_gemini_oauth_login_pure",
|
||||
lambda: {
|
||||
"access_token": "ya29.test-token",
|
||||
"refresh_token": "google-refresh",
|
||||
"email": "user@example.com",
|
||||
"expires_at_ms": 9999999999000,
|
||||
"project_id": "my-project",
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "google-gemini-cli"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
assert payload["active_provider"] == "google-gemini-cli"
|
||||
state = payload["providers"]["google-gemini-cli"]
|
||||
# Only email stored — no access_token/refresh_token (those live in
|
||||
# the Google OAuth credential file managed by agent.google_oauth).
|
||||
assert state.get("email") == "user@example.com"
|
||||
assert "access_token" not in state
|
||||
assert "refresh_token" not in state
|
||||
# pool entry from pool.add_entry() still present for hermes auth list
|
||||
entries = payload["credential_pool"]["google-gemini-cli"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:google_pkce")
|
||||
assert entry["access_token"] == "ya29.test-token"
|
||||
|
||||
|
||||
def test_auth_add_qwen_oauth_sets_active_provider(tmp_path, monkeypatch):
|
||||
"""hermes auth add qwen-oauth must set active_provider in auth.json.
|
||||
|
||||
|
|
|
|||
|
|
@ -1056,7 +1056,6 @@ class TestEnvWriteDenylist:
|
|||
@pytest.mark.parametrize(
|
||||
"allowed_key",
|
||||
[
|
||||
"HERMES_GEMINI_CLIENT_ID",
|
||||
"HERMES_LANGFUSE_PUBLIC_KEY",
|
||||
"HERMES_SPOTIFY_CLIENT_ID",
|
||||
"HERMES_QWEN_BASE_URL",
|
||||
|
|
|
|||
|
|
@ -473,7 +473,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
|
|||
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -915,7 +914,6 @@ def _run_doctor_with_healthy_oauth_fallback(
|
|||
env_key: str,
|
||||
bad_key: str,
|
||||
failing_host: str,
|
||||
gemini_oauth_status: dict,
|
||||
minimax_oauth_status: dict,
|
||||
xai_oauth_status: dict | None = None,
|
||||
) -> str:
|
||||
|
|
@ -952,7 +950,6 @@ def _run_doctor_with_healthy_oauth_fallback(
|
|||
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status)
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
|
||||
_xai_status = xai_oauth_status if xai_oauth_status is not None else {}
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status)
|
||||
|
|
@ -972,22 +969,12 @@ def _run_doctor_with_healthy_oauth_fallback(
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
|
||||
("env_key", "bad_key", "failing_host", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
|
||||
[
|
||||
(
|
||||
"GOOGLE_API_KEY",
|
||||
"bad-gemini-key",
|
||||
"googleapis.com",
|
||||
{"logged_in": True, "email": "user@example.com"},
|
||||
{},
|
||||
None,
|
||||
"Check GOOGLE_API_KEY in .env",
|
||||
),
|
||||
(
|
||||
"MINIMAX_API_KEY",
|
||||
"bad-minimax-key",
|
||||
"minimax.io",
|
||||
{},
|
||||
{"logged_in": True, "region": "global"},
|
||||
None,
|
||||
"Check MINIMAX_API_KEY in .env",
|
||||
|
|
@ -997,7 +984,6 @@ def _run_doctor_with_healthy_oauth_fallback(
|
|||
"bad-xai-key",
|
||||
"api.x.ai",
|
||||
{},
|
||||
{},
|
||||
{"logged_in": True, "auth_mode": "oauth_pkce"},
|
||||
"Check XAI_API_KEY in .env",
|
||||
),
|
||||
|
|
@ -1009,7 +995,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
|
|||
env_key,
|
||||
bad_key,
|
||||
failing_host,
|
||||
gemini_oauth_status,
|
||||
minimax_oauth_status,
|
||||
xai_oauth_status,
|
||||
unexpected_issue,
|
||||
|
|
@ -1020,7 +1005,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
|
|||
env_key=env_key,
|
||||
bad_key=bad_key,
|
||||
failing_host=failing_host,
|
||||
gemini_oauth_status=gemini_oauth_status,
|
||||
minimax_oauth_status=minimax_oauth_status,
|
||||
xai_oauth_status=xai_oauth_status,
|
||||
)
|
||||
|
|
@ -1062,16 +1046,6 @@ class TestHasHealthyOauthFallbackForXai:
|
|||
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
||||
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
|
||||
|
||||
def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch):
|
||||
import sys
|
||||
from hermes_cli import auth as _auth_mod
|
||||
# xAI function missing, but Gemini is healthy
|
||||
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
||||
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False)
|
||||
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
|
||||
assert _has_healthy_oauth_fallback_for_apikey_provider("gemini") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ◆ Auth Providers — xAI OAuth display in run_doctor()
|
||||
|
|
@ -1107,7 +1081,6 @@ class TestDoctorXaiOAuthStatus:
|
|||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn)
|
||||
|
||||
|
|
@ -1182,7 +1155,6 @@ class TestDoctorXaiOAuthStatus:
|
|||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
||||
|
||||
|
|
@ -1214,7 +1186,6 @@ class TestDoctorXaiOAuthStatus:
|
|||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
||||
|
||||
|
|
@ -1275,7 +1246,6 @@ class TestDoctorCodexCliHintPlacement:
|
|||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": codex_logged_in})
|
||||
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False})
|
||||
|
||||
|
|
@ -1317,12 +1287,16 @@ class TestDoctorCodexCliHintPlacement:
|
|||
|
||||
def test_hint_never_attaches_to_minimax_row(self, monkeypatch, tmp_path):
|
||||
out = self._run(monkeypatch, tmp_path, codex_logged_in=False, codex_cli_present=False)
|
||||
# The MiniMax OAuth row and the hint must not be adjacent — the hint
|
||||
# belongs to the Codex auth row directly above it.
|
||||
# The hint belongs to the Codex auth row that precedes it, never to the
|
||||
# MiniMax row that follows (#27975). The MiniMax row itself must not be
|
||||
# the hint line, and the hint must sit strictly above MiniMax.
|
||||
lines = [l for l in out.splitlines() if l.strip()]
|
||||
codex_idx = next(i for i, l in enumerate(lines) if "OpenAI Codex auth" in l)
|
||||
hint_idx = next(i for i, l in enumerate(lines) if self._hint_line() in l)
|
||||
minimax_idx = next(i for i, l in enumerate(lines) if "MiniMax OAuth" in l)
|
||||
assert self._hint_line() not in lines[minimax_idx - 1]
|
||||
assert minimax_idx + 1 >= len(lines) or self._hint_line() not in lines[minimax_idx + 1]
|
||||
# Hint sits under Codex and above MiniMax; the MiniMax row is not the hint.
|
||||
assert codex_idx < hint_idx < minimax_idx
|
||||
assert self._hint_line() not in lines[minimax_idx]
|
||||
|
||||
|
||||
class TestDoctorStaleMaxIterationsDrift:
|
||||
|
|
|
|||
|
|
@ -316,41 +316,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -62,8 +62,6 @@ def test_api_key_providers_route_to_keys_oauth_to_accounts():
|
|||
# api_key → keys
|
||||
assert by["kilocode"].tab == "keys"
|
||||
assert by["openai-api"].tab == "keys"
|
||||
# account / sign-in flows → accounts
|
||||
assert by["google-gemini-cli"].tab == "accounts"
|
||||
assert by["copilot-acp"].tab == "accounts"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -489,14 +489,13 @@ def test_accounts_offers_every_oauth_provider_from_catalog():
|
|||
)
|
||||
|
||||
|
||||
def test_gemini_cli_and_copilot_acp_now_in_accounts():
|
||||
"""Regression: google-gemini-cli and copilot-acp were canonical providers the
|
||||
CLI could configure, but had no Accounts card (the reported GUI/CLI drift).
|
||||
def test_copilot_acp_now_in_accounts():
|
||||
"""Regression: copilot-acp was a canonical provider the CLI could configure,
|
||||
but had no Accounts card (the reported GUI/CLI drift).
|
||||
"""
|
||||
resp = client.get("/api/providers/oauth", headers=HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
providers = {p["id"]: p for p in resp.json()["providers"]}
|
||||
assert "google-gemini-cli" in providers
|
||||
assert "copilot-acp" in providers
|
||||
# copilot-acp is managed by an external CLI: read-only card, not auto-removable.
|
||||
assert providers["copilot-acp"]["flow"] == "external"
|
||||
|
|
|
|||
|
|
@ -1,447 +0,0 @@
|
|||
"""Regression tests for Google Workspace OAuth setup.
|
||||
|
||||
These tests cover the headless/manual auth-code flow where the browser step and
|
||||
code exchange happen in separate process invocations.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/setup.py"
|
||||
)
|
||||
|
||||
|
||||
class FakeCredentials:
|
||||
def __init__(self, payload=None):
|
||||
self._payload = payload or {
|
||||
"token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents.readonly",
|
||||
],
|
||||
}
|
||||
|
||||
def to_json(self):
|
||||
return json.dumps(self._payload)
|
||||
|
||||
|
||||
class FakeFlow:
|
||||
created = []
|
||||
default_state = "generated-state"
|
||||
default_verifier = "generated-code-verifier"
|
||||
credentials_payload = None
|
||||
fetch_error = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_secrets_file,
|
||||
scopes,
|
||||
*,
|
||||
redirect_uri=None,
|
||||
state=None,
|
||||
code_verifier=None,
|
||||
autogenerate_code_verifier=False,
|
||||
):
|
||||
self.client_secrets_file = client_secrets_file
|
||||
self.scopes = scopes
|
||||
self.redirect_uri = redirect_uri
|
||||
self.state = state
|
||||
self.code_verifier = code_verifier
|
||||
self.autogenerate_code_verifier = autogenerate_code_verifier
|
||||
self.authorization_kwargs = None
|
||||
self.fetch_token_calls = []
|
||||
self.credentials = FakeCredentials(self.credentials_payload)
|
||||
|
||||
if autogenerate_code_verifier and not self.code_verifier:
|
||||
self.code_verifier = self.default_verifier
|
||||
if not self.state:
|
||||
self.state = self.default_state
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
cls.created = []
|
||||
cls.default_state = "generated-state"
|
||||
cls.default_verifier = "generated-code-verifier"
|
||||
cls.credentials_payload = None
|
||||
cls.fetch_error = None
|
||||
|
||||
@classmethod
|
||||
def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs):
|
||||
inst = cls(client_secrets_file, scopes, **kwargs)
|
||||
cls.created.append(inst)
|
||||
return inst
|
||||
|
||||
def authorization_url(self, **kwargs):
|
||||
self.authorization_kwargs = kwargs
|
||||
return f"https://auth.example/authorize?state={self.state}", self.state
|
||||
|
||||
def fetch_token(self, **kwargs):
|
||||
self.fetch_token_calls.append(kwargs)
|
||||
if self.fetch_error:
|
||||
raise self.fetch_error
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_module(monkeypatch, tmp_path):
|
||||
FakeFlow.reset()
|
||||
|
||||
google_auth_module = types.ModuleType("google_auth_oauthlib")
|
||||
flow_module = types.ModuleType("google_auth_oauthlib.flow")
|
||||
flow_module.Flow = FakeFlow
|
||||
google_auth_module.flow = flow_module
|
||||
monkeypatch.setitem(sys.modules, "google_auth_oauthlib", google_auth_module)
|
||||
monkeypatch.setitem(sys.modules, "google_auth_oauthlib.flow", flow_module)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("google_workspace_setup_test", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
monkeypatch.setattr(module, "_ensure_deps", lambda: None)
|
||||
monkeypatch.setattr(module, "CLIENT_SECRET_PATH", tmp_path / "google_client_secret.json")
|
||||
monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json")
|
||||
monkeypatch.setattr(module, "PENDING_AUTH_PATH", tmp_path / "google_oauth_pending.json", raising=False)
|
||||
|
||||
client_secret = {
|
||||
"installed": {
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
}
|
||||
module.CLIENT_SECRET_PATH.write_text(json.dumps(client_secret))
|
||||
return module
|
||||
|
||||
|
||||
class TestGetAuthUrl:
|
||||
def test_persists_state_and_code_verifier_for_later_exchange(self, setup_module, capsys):
|
||||
setup_module.get_auth_url()
|
||||
|
||||
out = capsys.readouterr().out.strip()
|
||||
assert out == "https://auth.example/authorize?state=generated-state"
|
||||
|
||||
saved = json.loads(setup_module.PENDING_AUTH_PATH.read_text())
|
||||
assert saved["state"] == "generated-state"
|
||||
assert saved["code_verifier"] == "generated-code-verifier"
|
||||
|
||||
flow = FakeFlow.created[-1]
|
||||
assert flow.autogenerate_code_verifier is True
|
||||
assert flow.authorization_kwargs == {"access_type": "offline", "prompt": "consent"}
|
||||
|
||||
|
||||
class TestExchangeAuthCode:
|
||||
def test_reuses_saved_pkce_material_for_plain_code(self, setup_module):
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
|
||||
flow = FakeFlow.created[-1]
|
||||
assert flow.state == "saved-state"
|
||||
assert flow.code_verifier == "saved-verifier"
|
||||
assert flow.fetch_token_calls == [{"code": "4/test-auth-code"}]
|
||||
saved = json.loads(setup_module.TOKEN_PATH.read_text())
|
||||
assert saved["token"] == "access-token"
|
||||
assert saved["type"] == "authorized_user"
|
||||
assert not setup_module.PENDING_AUTH_PATH.exists()
|
||||
|
||||
def test_extracts_code_from_redirect_url_and_checks_state(self, setup_module):
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
|
||||
setup_module.exchange_auth_code(
|
||||
"http://localhost:1/?code=4/extracted-code&state=saved-state&scope=gmail"
|
||||
)
|
||||
|
||||
flow = FakeFlow.created[-1]
|
||||
assert flow.fetch_token_calls == [{"code": "4/extracted-code"}]
|
||||
|
||||
def test_passes_scopes_from_redirect_url_to_flow(self, setup_module):
|
||||
"""Callback URL carries space-delimited scope list; Flow must receive it (not full SCOPES)."""
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
g1 = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
g2 = "https://www.googleapis.com/auth/calendar"
|
||||
from urllib.parse import quote
|
||||
|
||||
scope_q = quote(f"{g1} {g2}", safe="")
|
||||
setup_module.exchange_auth_code(
|
||||
f"http://localhost:1/?code=4/extracted-code&state=saved-state&scope={scope_q}"
|
||||
)
|
||||
flow = FakeFlow.created[-1]
|
||||
assert flow.scopes == [g1, g2]
|
||||
|
||||
def test_rejects_state_mismatch(self, setup_module, capsys):
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_module.exchange_auth_code(
|
||||
"http://localhost:1/?code=4/extracted-code&state=wrong-state"
|
||||
)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "state mismatch" in out.lower()
|
||||
assert not setup_module.TOKEN_PATH.exists()
|
||||
|
||||
def test_requires_pending_auth_session(self, setup_module, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "run --auth-url first" in out.lower()
|
||||
assert not setup_module.TOKEN_PATH.exists()
|
||||
|
||||
def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, capsys):
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
FakeFlow.fetch_error = Exception("invalid_grant: Missing code verifier")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "token exchange failed" in out.lower()
|
||||
assert setup_module.PENDING_AUTH_PATH.exists()
|
||||
assert not setup_module.TOKEN_PATH.exists()
|
||||
|
||||
def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys):
|
||||
"""Partial scopes are accepted with a warning (gws migration: v2.0)."""
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "***", "scopes": setup_module.SCOPES}))
|
||||
FakeFlow.credentials_payload = {
|
||||
"token": "***",
|
||||
"refresh_token": "***",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
],
|
||||
}
|
||||
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "warning" in out.lower()
|
||||
assert "missing" in out.lower()
|
||||
# Token is saved (partial scopes accepted)
|
||||
assert setup_module.TOKEN_PATH.exists()
|
||||
# Pending auth is cleaned up
|
||||
assert not setup_module.PENDING_AUTH_PATH.exists()
|
||||
|
||||
|
||||
class TestHermesConstantsFallback:
|
||||
"""Tests for _hermes_home.py fallback when hermes_constants is unavailable."""
|
||||
|
||||
HELPER_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/_hermes_home.py"
|
||||
)
|
||||
|
||||
def _load_helper(self, monkeypatch):
|
||||
"""Load _hermes_home.py with hermes_constants blocked."""
|
||||
monkeypatch.setitem(sys.modules, "hermes_constants", None)
|
||||
spec = importlib.util.spec_from_file_location("_hermes_home_test", self.HELPER_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_fallback_uses_hermes_home_env_var(self, monkeypatch, tmp_path):
|
||||
"""When hermes_constants is missing, HERMES_HOME comes from env var."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes"))
|
||||
module = self._load_helper(monkeypatch)
|
||||
assert module.get_hermes_home() == tmp_path / "custom-hermes"
|
||||
|
||||
def test_fallback_defaults_to_dot_hermes(self, monkeypatch):
|
||||
"""When hermes_constants is missing and HERMES_HOME unset, default to ~/.hermes."""
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
module = self._load_helper(monkeypatch)
|
||||
assert module.get_hermes_home() == Path.home() / ".hermes"
|
||||
|
||||
def test_fallback_ignores_empty_hermes_home(self, monkeypatch):
|
||||
"""Empty/whitespace HERMES_HOME is treated as unset."""
|
||||
monkeypatch.setenv("HERMES_HOME", " ")
|
||||
module = self._load_helper(monkeypatch)
|
||||
assert module.get_hermes_home() == Path.home() / ".hermes"
|
||||
|
||||
def test_fallback_display_hermes_home_shortens_path(self, monkeypatch):
|
||||
"""Fallback display_hermes_home() uses ~/ shorthand like the real one."""
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
module = self._load_helper(monkeypatch)
|
||||
assert module.display_hermes_home() == "~/.hermes"
|
||||
|
||||
def test_fallback_display_hermes_home_profile_path(self, monkeypatch):
|
||||
"""Fallback display_hermes_home() handles profile paths under ~/."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(Path.home() / ".hermes/profiles/coder"))
|
||||
module = self._load_helper(monkeypatch)
|
||||
assert module.display_hermes_home() == "~/.hermes/profiles/coder"
|
||||
|
||||
def test_fallback_display_hermes_home_custom_path(self, monkeypatch):
|
||||
"""Fallback display_hermes_home() returns full path for non-home locations."""
|
||||
monkeypatch.setenv("HERMES_HOME", "/opt/hermes-custom")
|
||||
module = self._load_helper(monkeypatch)
|
||||
assert module.display_hermes_home() == "/opt/hermes-custom"
|
||||
|
||||
def test_delegates_to_hermes_constants_when_available(self):
|
||||
"""When hermes_constants IS importable, _hermes_home delegates to it."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_hermes_home_happy", self.HELPER_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
import hermes_constants
|
||||
assert module.get_hermes_home is hermes_constants.get_hermes_home
|
||||
assert module.display_hermes_home is hermes_constants.display_hermes_home
|
||||
|
||||
|
||||
def _load_setup_module(monkeypatch):
|
||||
"""Load setup.py without stubbing _ensure_deps (for install_deps tests)."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"google_workspace_setup_installdeps_test", SCRIPT_PATH
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _force_deps_missing(monkeypatch):
|
||||
"""Make `import googleapiclient` / `import google_auth_oauthlib` fail so
|
||||
install_deps() proceeds past its early-return short-circuit."""
|
||||
for name in ("googleapiclient", "google_auth_oauthlib"):
|
||||
monkeypatch.setitem(sys.modules, name, None)
|
||||
|
||||
|
||||
class TestInstallDeps:
|
||||
"""Tests for install_deps() interpreter/installer selection.
|
||||
|
||||
Regression coverage for the Hermes Docker image, whose venv is built with
|
||||
`uv sync` and ships without pip — `sys.executable -m pip install` fails
|
||||
with `No module named pip`, so install_deps() must fall back to uv.
|
||||
"""
|
||||
|
||||
def test_returns_early_when_already_installed(self, monkeypatch):
|
||||
"""If both libs import, no installer subprocess runs at all."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
# Don't force-missing: real test env has the libs importable. Guard
|
||||
# against any subprocess being spawned.
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
module.subprocess, "check_call", lambda *a, **k: calls.append(a)
|
||||
)
|
||||
# google_auth_oauthlib may not be installed in the test env; only run
|
||||
# this assertion when the early-return path is actually reachable.
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("Google libs not installed in test env")
|
||||
assert module.install_deps() is True
|
||||
assert calls == []
|
||||
|
||||
def test_uses_pip_when_available(self, monkeypatch):
|
||||
"""When pip works, install_deps succeeds via pip and never calls uv."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
recorded = []
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
recorded.append(cmd)
|
||||
# pip path is the first attempt — succeed.
|
||||
return 0
|
||||
|
||||
which_calls = []
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(
|
||||
module.shutil, "which", lambda name: which_calls.append(name)
|
||||
)
|
||||
|
||||
assert module.install_deps() is True
|
||||
assert recorded[0][:3] == [module.sys.executable, "-m", "pip"]
|
||||
# Control: uv must NOT be consulted when pip succeeds.
|
||||
assert which_calls == []
|
||||
|
||||
def test_falls_back_to_uv_when_pip_missing(self, monkeypatch):
|
||||
"""No pip → uv pip install --python <interpreter> is used."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
recorded = []
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
recorded.append(cmd)
|
||||
if cmd[:3] == [module.sys.executable, "-m", "pip"]:
|
||||
raise module.subprocess.CalledProcessError(1, cmd)
|
||||
return 0 # uv invocation succeeds
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
|
||||
|
||||
assert module.install_deps() is True
|
||||
assert len(recorded) == 2
|
||||
uv_cmd = recorded[1]
|
||||
assert uv_cmd[0] == "/usr/local/bin/uv"
|
||||
assert uv_cmd[1:5] == ["pip", "install", "--python", module.sys.executable]
|
||||
for pkg in module.REQUIRED_PACKAGES:
|
||||
assert pkg in uv_cmd
|
||||
|
||||
def test_returns_false_when_no_pip_and_no_uv(self, monkeypatch, capsys):
|
||||
"""No pip AND no uv → failure, with the [google] extra hint printed."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
raise module.subprocess.CalledProcessError(1, cmd)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(module.shutil, "which", lambda name: None)
|
||||
|
||||
assert module.install_deps() is False
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes-agent[google]" in out
|
||||
|
||||
def test_returns_false_when_uv_fallback_also_fails(self, monkeypatch, capsys):
|
||||
"""uv present but its install fails → failure surfaced (not swallowed)."""
|
||||
module = _load_setup_module(monkeypatch)
|
||||
_force_deps_missing(monkeypatch)
|
||||
|
||||
def fake_check_call(cmd, **kwargs):
|
||||
raise module.subprocess.CalledProcessError(1, cmd)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "check_call", fake_check_call)
|
||||
monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv")
|
||||
|
||||
assert module.install_deps() is False
|
||||
out = capsys.readouterr().out
|
||||
assert "via uv" in out
|
||||
|
|
@ -127,7 +127,7 @@ See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a tem
|
|||
|
||||
Use the full checklist below when your provider needs any of the following:
|
||||
|
||||
- OAuth or token refresh (Nous Portal, Codex, Google Gemini, Qwen Portal, Copilot)
|
||||
- OAuth or token refresh (Nous Portal, Codex, Qwen Portal, Copilot)
|
||||
- A non-OpenAI API shape that requires a new adapter (Anthropic Messages, Codex Responses)
|
||||
- Custom endpoint detection or multi-region probing (z.ai, Kimi)
|
||||
- A curated static model catalog or live `/models` fetch
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ Set `profile.api_mode` to match the default your provider ships — it acts as a
|
|||
|---|---|---|
|
||||
| `api_key` | Single env var carries a static API key | Most providers |
|
||||
| `oauth_device_code` | Device-code OAuth flow | — |
|
||||
| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Gemini Cloud Code, Qwen Portal, Nous Portal |
|
||||
| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Qwen Portal, Nous Portal |
|
||||
| `copilot` | GitHub Copilot token refresh cycle | `copilot` plugin only |
|
||||
| `aws_sdk` | AWS SDK credential chain (IAM role, profile, env) | `bedrock` plugin only |
|
||||
| `external_process` | Auth handled by a subprocess the agent spawns | `copilot-acp` plugin only |
|
||||
|
|
|
|||
|
|
@ -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-antigravity`)
|
||||
- Google / Gemini (`gemini`)
|
||||
- Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`)
|
||||
- DeepSeek
|
||||
- Z.AI
|
||||
|
|
|
|||
|
|
@ -126,7 +126,6 @@ Good defaults:
|
|||
| **AWS Bedrock** | Claude, Nova, Llama, DeepSeek via native Converse API | IAM role or `aws configure` ([guide](../guides/aws-bedrock.md)) |
|
||||
| **Azure Foundry** | Azure AI Foundry-hosted models | Set `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` |
|
||||
| **Google AI Studio** | Gemini models via direct API | Set `GOOGLE_API_KEY` / `GEMINI_API_KEY` |
|
||||
| **Google Gemini (OAuth)** | Gemini via the `google-gemini-cli` OAuth flow — no key needed | `hermes model` → Google Gemini (OAuth) |
|
||||
| **xAI** | Grok models via direct API | Set `XAI_API_KEY` |
|
||||
| **xAI Grok OAuth** | SuperGrok / Premium+ subscription, no API key needed | `hermes model` → xAI Grok OAuth |
|
||||
| **NovitaAI** | Multi-model API gateway | Set `NOVITA_API_KEY` |
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
---
|
||||
sidebar_position: 16
|
||||
title: "Google Gemini"
|
||||
description: "Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, OAuth option, tool calling, streaming, and quota guidance"
|
||||
description: "Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, tool calling, streaming, and quota guidance"
|
||||
---
|
||||
|
||||
# Google Gemini
|
||||
|
||||
Hermes Agent supports Google Gemini as a native provider using the **Google AI Studio / Gemini API** — not the OpenAI-compatible endpoint. This lets Hermes translate its internal OpenAI-shaped message and tool loop into Gemini's native `generateContent` API while preserving tool calling, streaming, multimodal inputs, and Gemini-specific response metadata.
|
||||
|
||||
Hermes also supports a separate **Google Gemini (OAuth)** provider that uses the same Cloud Code Assist backend as Google's Gemini CLI. Use the API-key provider (`gemini`) for the lowest-risk official API path.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Google AI Studio API key** — create one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)
|
||||
|
|
@ -100,30 +98,6 @@ If you previously set `GEMINI_BASE_URL` to the `/openai` URL, remove it or chang
|
|||
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
```
|
||||
|
||||
### OAuth Provider
|
||||
|
||||
Hermes also has a `google-gemini-cli` provider:
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
# → Choose "Google Gemini (OAuth)"
|
||||
```
|
||||
|
||||
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:
|
||||
|
|
@ -205,18 +179,8 @@ hermes doctor
|
|||
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:
|
||||
|
||||
```text
|
||||
/gquota
|
||||
```
|
||||
|
||||
`/gquota` applies to the `google-gemini-cli` OAuth provider, not the AI Studio API-key provider.
|
||||
|
||||
## Gateway (Messaging Platforms)
|
||||
|
||||
Gemini works with all Hermes gateway platforms (Telegram, Discord, Slack, WhatsApp, LINE, Feishu, etc.). Configure Gemini as your provider, then start the gateway normally:
|
||||
|
|
@ -278,10 +242,6 @@ Change it to the native endpoint or remove the override:
|
|||
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
```
|
||||
|
||||
### OAuth login warning
|
||||
|
||||
The `google-gemini-cli` provider uses a Gemini CLI / Cloud Code Assist OAuth flow. Hermes warns before starting it because this is distinct from the official AI Studio API-key path. Use `provider: gemini` with `GOOGLE_API_KEY` for the official API-key integration.
|
||||
|
||||
### Tool calling fails with schema errors
|
||||
|
||||
Upgrade Hermes and rerun `hermes model`. The native Gemini adapter sanitizes tool schemas for Gemini's stricter function-declaration format; older builds or custom endpoints may not.
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
|||
| **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |
|
||||
| **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) |
|
||||
| **Google / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) |
|
||||
| **Google Gemini (OAuth)** | `hermes model` → "Google Gemini (OAuth)" (provider: `google-gemini-cli`, free tier supported, browser PKCE login) |
|
||||
| **OpenAI API (direct)** | `OPENAI_API_KEY` in `~/.hermes/.env` (provider: `openai-api`, optional `OPENAI_BASE_URL`) |
|
||||
| **Azure AI Foundry** | `hermes model` → "Azure AI Foundry" (provider: `azure-foundry`; uses Azure OpenAI / Foundry endpoint and key) |
|
||||
| **AWS Bedrock** | `hermes model` → "AWS Bedrock" (provider: `bedrock`; standard AWS credentials chain via boto3) |
|
||||
|
|
@ -49,7 +48,6 @@ 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`) |
|
||||
|
||||
|
|
@ -79,64 +77,6 @@ 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.
|
||||
|
||||
|
|
@ -592,91 +532,6 @@ You can append routing suffixes to model names: `:fastest` (default), `:cheapest
|
|||
|
||||
The base URL can be overridden with `HF_BASE_URL`.
|
||||
|
||||
### Google Gemini via OAuth (`google-gemini-cli`)
|
||||
|
||||
The `google-gemini-cli` provider uses Google's Cloud Code Assist backend — the
|
||||
same API that Google's own `gemini-cli` tool uses. This supports both the
|
||||
**free tier** (generous daily quota for personal accounts) and **paid tiers**
|
||||
(Standard/Enterprise via a GCP project).
|
||||
|
||||
**Quick start:**
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
# → pick "Google Gemini (OAuth)"
|
||||
# → see policy warning, confirm
|
||||
# → browser opens to accounts.google.com, sign in
|
||||
# → done — Hermes auto-provisions your free tier on first request
|
||||
```
|
||||
|
||||
Hermes ships Google's **public** `gemini-cli` desktop OAuth client by default —
|
||||
the same credentials Google includes in their open-source `gemini-cli`. Desktop
|
||||
OAuth clients are not confidential (PKCE provides the security). You do not
|
||||
need to install `gemini-cli` or register your own GCP OAuth client.
|
||||
|
||||
**How auth works:**
|
||||
- PKCE Authorization Code flow against `accounts.google.com`
|
||||
- Browser callback at `http://127.0.0.1:8085/oauth2callback` (with ephemeral-port fallback if busy)
|
||||
- Tokens stored at `~/.hermes/auth/google_oauth.json` (chmod 0600, atomic write, cross-process `fcntl` lock)
|
||||
- Automatic refresh 60 s before expiry
|
||||
- Headless environments (SSH, `HERMES_HEADLESS=1`) → paste-mode fallback
|
||||
- Inflight refresh deduplication — two concurrent requests won't double-refresh
|
||||
- `invalid_grant` (revoked refresh) → credential file wiped, user prompted to re-login
|
||||
|
||||
**How inference works:**
|
||||
- Traffic goes to `https://cloudcode-pa.googleapis.com/v1internal:generateContent`
|
||||
(or `:streamGenerateContent?alt=sse` for streaming), NOT the paid `v1beta/openai` endpoint
|
||||
- Request body wrapped `{project, model, user_prompt_id, request}`
|
||||
- OpenAI-shaped `messages[]`, `tools[]`, `tool_choice` are translated to Gemini's native
|
||||
`contents[]`, `tools[].functionDeclarations`, `toolConfig` shape
|
||||
- Responses translated back to OpenAI shape so the rest of Hermes works unchanged
|
||||
|
||||
**Tiers & project IDs:**
|
||||
|
||||
| Your situation | What to do |
|
||||
|---|---|
|
||||
| Personal Google account, want free tier | Nothing — sign in, start chatting |
|
||||
| Workspace / Standard / Enterprise account | Set `HERMES_GEMINI_PROJECT_ID` or `GOOGLE_CLOUD_PROJECT` to your GCP project ID |
|
||||
| VPC-SC-protected org | Hermes detects `SECURITY_POLICY_VIOLATED` and forces `standard-tier` automatically |
|
||||
|
||||
Free tier auto-provisions a Google-managed project on first use. No GCP setup required.
|
||||
|
||||
**Quota monitoring:**
|
||||
|
||||
```
|
||||
/gquota
|
||||
```
|
||||
|
||||
Shows remaining Code Assist quota per model with progress bars:
|
||||
|
||||
```
|
||||
Gemini Code Assist quota (project: 123-abc)
|
||||
|
||||
gemini-2.5-pro ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 85%
|
||||
gemini-2.5-flash [input] ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ 92%
|
||||
```
|
||||
|
||||
:::warning Policy risk
|
||||
Google considers using the Gemini CLI OAuth client with third-party software a
|
||||
policy violation. Some users have reported account restrictions. For the lowest-risk
|
||||
experience, use your own API key via the `gemini` provider instead. Hermes shows
|
||||
an upfront warning and requires explicit confirmation before OAuth begins.
|
||||
:::
|
||||
|
||||
**Custom OAuth client (optional):**
|
||||
|
||||
If you'd rather register your own Google OAuth client — e.g., to keep quota
|
||||
and consent scoped to your own GCP project — set:
|
||||
|
||||
```bash
|
||||
HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com
|
||||
HERMES_GEMINI_CLIENT_SECRET=... # optional for Desktop clients
|
||||
```
|
||||
|
||||
Register a **Desktop app** OAuth client at
|
||||
[console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
|
||||
with the Generative Language API enabled.
|
||||
|
||||
## Custom & Self-Hosted LLM Providers
|
||||
|
||||
Hermes Agent works with **any OpenAI-compatible API endpoint**. If a server implements `/v1/chat/completions`, you can point Hermes at it. This means you can use local models, GPU inference servers, multi-provider routers, or any third-party API.
|
||||
|
|
@ -1591,7 +1446,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`, `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`.
|
||||
Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `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`, `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`). |
|
||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `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. |
|
||||
|
|
|
|||
|
|
@ -67,13 +67,6 @@ Hermes reads environment variables from the process environment and, for user-ma
|
|||
| `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) |
|
||||
| `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` |
|
||||
| `GEMINI_BASE_URL` | Override Google AI Studio base URL |
|
||||
| `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, the `google-antigravity` OAuth provider, OpenRouter, or compatible proxy)
|
||||
- **Google** — Gemini models (direct API via `gemini` provider, OpenRouter, or compatible proxy)
|
||||
- **z.ai / ZhipuAI** — GLM models
|
||||
- **Kimi / Moonshot AI** — Kimi models
|
||||
- **MiniMax** — global and China endpoints
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
|||
| `/image <path>` | Attach a local image file for your next prompt. |
|
||||
| `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. |
|
||||
| `/profile` | Show active profile name and home directory |
|
||||
| `/gquota` | Show Google Gemini Code Assist quota usage with progress bars (only available when the `google-gemini-cli` provider is active). |
|
||||
|
||||
### Exit
|
||||
|
||||
|
|
@ -246,7 +245,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
|
||||
## Notes
|
||||
|
||||
- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
|
||||
- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands.
|
||||
- `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
|
||||
|
|
|
|||
|
|
@ -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`, `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"`).
|
||||
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`, `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).
|
||||
|
|
|
|||
|
|
@ -62,8 +62,6 @@ Each entry requires both `provider` and `model`. Entries missing either field ar
|
|||
| GMI Cloud | `gmi` | `GMI_API_KEY` (optional: `GMI_BASE_URL`) |
|
||||
| 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) |
|
||||
|
|
|
|||
|
|
@ -343,7 +343,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer
|
|||
/commands [page] Browse all commands (gateway)
|
||||
/usage Token usage
|
||||
/insights [days] Usage analytics
|
||||
/gquota Show Google Gemini Code Assist quota usage (CLI)
|
||||
/status Session info (gateway)
|
||||
/profile Active profile info
|
||||
/debug Upload debug report (system info + logs) and get shareable links
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ Hermes 已经可以通过自定义 provider 路径与任何 OpenAI 兼容的端
|
|||
|
||||
当你的 provider 需要以下任何内容时,使用下面的完整清单:
|
||||
|
||||
- OAuth 或 token 刷新(Nous Portal、Codex、Google Gemini、Qwen Portal、Copilot)
|
||||
- OAuth 或 token 刷新(Nous Portal、Codex、Qwen Portal、Copilot)
|
||||
- 需要新适配器的非 OpenAI API 格式(Anthropic Messages、Codex Responses)
|
||||
- 自定义端点检测或多区域探测(z.ai、Kimi)
|
||||
- 精选的静态模型目录或实时 `/models` 获取
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ register_provider(ProviderProfile(
|
|||
|---|---|---|
|
||||
| `api_key` | 单个环境变量携带静态 API key | 大多数提供商 |
|
||||
| `oauth_device_code` | 设备码 OAuth 流程 | — |
|
||||
| `oauth_external` | 用户在其他地方登录,token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Gemini Cloud Code、Qwen Portal、Nous Portal |
|
||||
| `oauth_external` | 用户在其他地方登录,token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Qwen Portal、Nous Portal |
|
||||
| `copilot` | GitHub Copilot token 刷新周期 | 仅 `copilot` 插件 |
|
||||
| `aws_sdk` | AWS SDK 凭据链(IAM role、profile、env) | 仅 `bedrock` 插件 |
|
||||
| `external_process` | 认证由 agent 启动的子进程处理 | 仅 `copilot-acp` 插件 |
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Hermes 拥有一个共享的 provider 运行时解析器,用于以下场景:
|
|||
- OpenAI Codex
|
||||
- Copilot / Copilot ACP
|
||||
- Anthropic(原生)
|
||||
- Google / Gemini(`gemini`、`google-gemini-cli`)
|
||||
- Google / Gemini(`gemini`)
|
||||
- Alibaba / DashScope(`alibaba`、`alibaba-coding-plan`)
|
||||
- DeepSeek
|
||||
- Z.AI
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
---
|
||||
sidebar_position: 16
|
||||
title: "Google Gemini"
|
||||
description: "将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、OAuth 选项、工具调用、流式传输及配额说明"
|
||||
description: "将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、工具调用、流式传输及配额说明"
|
||||
---
|
||||
|
||||
# Google Gemini
|
||||
|
||||
Hermes Agent 通过 **Google AI Studio / Gemini API** 原生支持 Google Gemini——而非 OpenAI 兼容端点。这使 Hermes 能够将其内部 OpenAI 格式的消息和工具循环转换为 Gemini 原生的 `generateContent` API,同时保留工具调用、流式传输、多模态输入以及 Gemini 特有的响应元数据。
|
||||
|
||||
Hermes 还支持独立的 **Google Gemini(OAuth)** provider,使用与 Google Gemini CLI 相同的 Cloud Code Assist 后端。如需最低风险的官方 API 路径,请使用 API 密钥 provider(`gemini`)。
|
||||
|
||||
## 前提条件
|
||||
|
||||
- **Google AI Studio API 密钥** — 在 [aistudio.google.com/apikey](https://aistudio.google.com/apikey) 创建
|
||||
|
|
@ -100,17 +98,6 @@ https://generativelanguage.googleapis.com/v1beta/openai/
|
|||
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
```
|
||||
|
||||
### OAuth Provider
|
||||
|
||||
Hermes 还提供 `google-gemini-cli` provider:
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
# → 选择 "Google Gemini (OAuth)"
|
||||
```
|
||||
|
||||
该方式使用浏览器 PKCE 登录和 Cloud Code Assist 后端。对于希望使用 Gemini CLI 风格 OAuth 的用户可能有用,但 Hermes 会显示明确警告,因为 Google 可能将第三方软件使用 Gemini CLI OAuth 客户端的行为视为违反政策。对于生产环境或最低风险使用场景,请优先使用上述 API 密钥 provider。
|
||||
|
||||
## 可用模型
|
||||
|
||||
`hermes model` 选择器显示 Hermes provider 注册表中维护的 Gemini 模型。常见选项包括:
|
||||
|
|
@ -192,17 +179,8 @@ hermes doctor
|
|||
doctor 命令检查:
|
||||
|
||||
- `GOOGLE_API_KEY` 或 `GEMINI_API_KEY` 是否可用
|
||||
- `google-gemini-cli` 的 Gemini OAuth 凭据是否存在
|
||||
- 已配置的 provider 凭据是否可以解析
|
||||
|
||||
如需查看 OAuth 配额使用情况,请在 Hermes 会话中运行:
|
||||
|
||||
```text
|
||||
/gquota
|
||||
```
|
||||
|
||||
`/gquota` 适用于 `google-gemini-cli` OAuth provider,不适用于 AI Studio API 密钥 provider。
|
||||
|
||||
## Gateway(消息平台)
|
||||
|
||||
Gemini 可与所有 Hermes gateway 平台配合使用(Telegram、Discord、Slack、WhatsApp、LINE、飞书等)。将 Gemini 配置为你的 provider,然后正常启动 gateway:
|
||||
|
|
@ -264,10 +242,6 @@ GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
|
|||
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||
```
|
||||
|
||||
### OAuth 登录警告
|
||||
|
||||
`google-gemini-cli` provider 使用 Gemini CLI / Cloud Code Assist OAuth 流程。Hermes 在启动前会发出警告,因为这与官方 AI Studio API 密钥路径不同。如需官方 API 密钥集成,请使用 `provider: gemini` 配合 `GOOGLE_API_KEY`。
|
||||
|
||||
### 工具调用因 schema 错误而失败
|
||||
|
||||
升级 Hermes 并重新运行 `hermes model`。原生 Gemini 适配器会针对 Gemini 更严格的函数声明格式对工具 schema 进行清理;旧版本或自定义端点可能不支持此功能。
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ sidebar_position: 1
|
|||
| **DeepSeek** | `~/.hermes/.env` 中的 `DEEPSEEK_API_KEY`(provider: `deepseek`) |
|
||||
| **Hugging Face** | `~/.hermes/.env` 中的 `HF_TOKEN`(provider: `huggingface`,别名:`hf`) |
|
||||
| **Google / Gemini** | `~/.hermes/.env` 中的 `GOOGLE_API_KEY`(或 `GEMINI_API_KEY`)(provider: `gemini`) |
|
||||
| **Google Gemini(OAuth)** | `hermes model` → "Google Gemini (OAuth)"(provider: `google-gemini-cli`,支持免费层,浏览器 PKCE 登录) |
|
||||
| **LM Studio** | `hermes model` → "LM Studio"(provider: `lmstudio`,可选 `LM_API_KEY`) |
|
||||
| **自定义端点** | `hermes model` → 选择"Custom endpoint"(保存在 `config.yaml`) |
|
||||
|
||||
|
|
@ -512,79 +511,6 @@ model:
|
|||
|
||||
基础 URL 可通过 `HF_BASE_URL` 覆盖。
|
||||
|
||||
### 通过 OAuth 使用 Google Gemini(`google-gemini-cli`)
|
||||
|
||||
`google-gemini-cli` 提供商使用 Google 的 Cloud Code Assist 后端——与 Google 自己的 `gemini-cli` 工具使用的 API 相同。支持**免费层**(个人账户每日配额充足)和**付费层**(通过 GCP 项目的 Standard/Enterprise)。
|
||||
|
||||
**快速开始:**
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
# → 选择"Google Gemini (OAuth)"
|
||||
# → 查看政策警告,确认
|
||||
# → 浏览器打开 accounts.google.com,登录
|
||||
# → 完成——Hermes 在首次请求时自动开通免费层
|
||||
```
|
||||
|
||||
Hermes 默认使用 Google 的**公开** `gemini-cli` 桌面 OAuth 客户端——与 Google 在其开源 `gemini-cli` 中包含的凭据相同。桌面 OAuth 客户端不是机密客户端(PKCE 提供安全保障)。你无需安装 `gemini-cli` 或注册自己的 GCP OAuth 客户端。
|
||||
|
||||
**认证工作原理:**
|
||||
- 针对 `accounts.google.com` 的 PKCE 授权码流程
|
||||
- 浏览器回调地址 `http://127.0.0.1:8085/oauth2callback`(端口占用时自动回退到临时端口)
|
||||
- Token 存储在 `~/.hermes/auth/google_oauth.json`(chmod 0600,原子写入,跨进程 `fcntl` 锁)
|
||||
- 到期前 60 秒自动刷新
|
||||
- 无头环境(SSH、`HERMES_HEADLESS=1`)→ 粘贴模式回退
|
||||
- 并发刷新去重——两个并发请求不会触发双重刷新
|
||||
- `invalid_grant`(刷新 token 被撤销)→ 凭据文件被清除,提示用户重新登录
|
||||
|
||||
**推理工作原理:**
|
||||
- 流量发送到 `https://cloudcode-pa.googleapis.com/v1internal:generateContent`
|
||||
(流式传输为 `:streamGenerateContent?alt=sse`),而非付费的 `v1beta/openai` 端点
|
||||
- 请求体封装为 `{project, model, user_prompt_id, request}`
|
||||
- OpenAI 格式的 `messages[]`、`tools[]`、`tool_choice` 被转换为 Gemini 原生的
|
||||
`contents[]`、`tools[].functionDeclarations`、`toolConfig` 格式
|
||||
- 响应转换回 OpenAI 格式,Hermes 其余部分无感知
|
||||
|
||||
**层级与项目 ID:**
|
||||
|
||||
| 你的情况 | 操作 |
|
||||
|---|---|
|
||||
| 个人 Google 账户,使用免费层 | 无需操作——登录即可开始聊天 |
|
||||
| Workspace / Standard / Enterprise 账户 | 将 `HERMES_GEMINI_PROJECT_ID` 或 `GOOGLE_CLOUD_PROJECT` 设置为你的 GCP 项目 ID |
|
||||
| VPC-SC 保护的组织 | Hermes 检测到 `SECURITY_POLICY_VIOLATED` 后自动强制使用 `standard-tier` |
|
||||
|
||||
免费层在首次使用时自动开通 Google 托管项目。无需 GCP 配置。
|
||||
|
||||
**配额监控:**
|
||||
|
||||
```
|
||||
/gquota
|
||||
```
|
||||
|
||||
以进度条显示每个模型的剩余 Code Assist 配额:
|
||||
|
||||
```
|
||||
Gemini Code Assist quota (project: 123-abc)
|
||||
|
||||
gemini-2.5-pro ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 85%
|
||||
gemini-2.5-flash [input] ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ 92%
|
||||
```
|
||||
|
||||
:::warning 政策风险
|
||||
Google 认为将 Gemini CLI OAuth 客户端用于第三方软件违反政策。部分用户反映账户受到限制。为降低风险,建议改用 `gemini` 提供商并通过 API key 访问。Hermes 会在 OAuth 开始前显示警告并要求明确确认。
|
||||
:::
|
||||
|
||||
**自定义 OAuth 客户端(可选):**
|
||||
|
||||
如果你希望注册自己的 Google OAuth 客户端——例如将配额和授权范围限定在自己的 GCP 项目内——请设置:
|
||||
|
||||
```bash
|
||||
HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com
|
||||
HERMES_GEMINI_CLIENT_SECRET=... # 桌面客户端可选
|
||||
```
|
||||
|
||||
在 [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) 注册一个**桌面应用** OAuth 客户端,并启用 Generative Language API。
|
||||
|
||||
## 自定义与自托管 LLM 提供商
|
||||
|
||||
Hermes Agent 可与**任何 OpenAI 兼容 API 端点**配合使用。只要服务器实现了 `/v1/chat/completions`,就可以将 Hermes 指向它。这意味着你可以使用本地模型、GPU 推理服务器、多提供商路由器或任何第三方 API。
|
||||
|
|
@ -1477,7 +1403,7 @@ fallback_model:
|
|||
|
||||
激活时,故障转移在不丢失对话的情况下中途切换模型和提供商。链按条目逐一尝试;每个会话激活一次。
|
||||
|
||||
支持的提供商:`openrouter`、`nous`、`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`。
|
||||
支持的提供商:`openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`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
|
||||
故障转移仅通过 `config.yaml` 配置——或通过 `hermes fallback` 交互式配置。有关触发时机、链推进方式以及与辅助任务和委托的交互,参见[故障转移提供商](/user-guide/features/fallback-providers)。
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ hermes chat [options]
|
|||
| `-q`, `--query "..."` | 单次非交互式 prompt。 |
|
||||
| `-m`, `--model <model>` | 覆盖本次运行的模型。 |
|
||||
| `-t`, `--toolsets <csv>` | 启用逗号分隔的 toolset 集合。 |
|
||||
| `--provider <provider>` | 强制指定 provider:`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`google-gemini-cli`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 |
|
||||
| `--provider <provider>` | 强制指定 provider:`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 |
|
||||
| `-s`, `--skills <name>` | 为会话预加载一个或多个 skill(可重复或逗号分隔)。 |
|
||||
| `-v`, `--verbose` | 详细输出。 |
|
||||
| `-Q`, `--quiet` | 程序化模式:抑制横幅/spinner/工具预览。 |
|
||||
|
|
|
|||
|
|
@ -63,9 +63,6 @@ description: "Hermes Agent 使用的所有环境变量完整参考"
|
|||
| `GOOGLE_API_KEY` | Google AI Studio API 密钥([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) |
|
||||
| `GEMINI_API_KEY` | `GOOGLE_API_KEY` 的别名 |
|
||||
| `GEMINI_BASE_URL` | 覆盖 Google AI Studio base URL |
|
||||
| `HERMES_GEMINI_CLIENT_ID` | `google-gemini-cli` PKCE 登录的 OAuth 客户端 ID(可选;默认使用 Google 公共 gemini-cli 客户端) |
|
||||
| `HERMES_GEMINI_CLIENT_SECRET` | `google-gemini-cli` 的 OAuth 客户端密钥(可选) |
|
||||
| `HERMES_GEMINI_PROJECT_ID` | 付费 Gemini 层级的 GCP 项目 ID(免费层级自动配置) |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic Console API 密钥([console.anthropic.com](https://console.anthropic.com/)) |
|
||||
| `ANTHROPIC_TOKEN` | 手动或旧版 Anthropic OAuth/setup-token 覆盖 |
|
||||
| `DASHSCOPE_API_KEY` | Qwen Cloud(阿里巴巴 DashScope)Qwen 模型 API 密钥([modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/)) |
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Hermes Agent 可与任何兼容 OpenAI 的 API 配合使用。支持的提供商
|
|||
- **Nous Portal** — Nous Research 自有推理端点
|
||||
- **OpenAI** — GPT-5.4、GPT-5-codex、GPT-4.1、GPT-4o 等
|
||||
- **Anthropic** — Claude 模型(直接 API、通过 `hermes auth add anthropic` 进行 OAuth、OpenRouter 或任何兼容代理)
|
||||
- **Google** — Gemini 模型(通过 `gemini` 提供商直接调用 API、`google-gemini-cli` OAuth 提供商、OpenRouter 或兼容代理)
|
||||
- **Google** — Gemini 模型(通过 `gemini` 提供商直接调用 API、OpenRouter 或兼容代理)
|
||||
- **z.ai / ZhipuAI** — GLM 模型
|
||||
- **Kimi / Moonshot AI** — Kimi 模型
|
||||
- **MiniMax** — 全球及中国区端点
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中
|
|||
| `/image <path>` | 为下一条 prompt 附加本地图片文件。 |
|
||||
| `/debug` | 上传调试报告(系统信息 + 日志)并获取可分享链接。消息平台中也可用。 |
|
||||
| `/profile` | 显示活动 profile 名称和主目录 |
|
||||
| `/gquota` | 以进度条形式显示 Google Gemini Code Assist 配额用量(仅在 `google-gemini-cli` 提供商激活时可用)。 |
|
||||
|
||||
### 退出
|
||||
|
||||
|
|
@ -246,7 +245,7 @@ hermes config set model.aliases.grok x-ai/grok-4
|
|||
|
||||
## 注意事项
|
||||
|
||||
- `/skin`、`/snapshot`、`/gquota`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。
|
||||
- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。
|
||||
- `/skills` **仅在搜索/浏览/安装时属于 CLI-only**;其写入审批子命令(`pending`、`approve`、`reject`、`diff`、`approval`)在 `skills.write_approval` 开启时也可在消息平台使用。`/memory` 可在**两个表面**使用。
|
||||
- `/verbose` **默认仅限 CLI**,但可通过在 `config.yaml` 中设置 `display.tool_progress_command: true` 为消息平台启用。启用后,它会循环切换 `display.tool_progress` 模式并保存到配置。
|
||||
- `/sethome`、`/update`、`/restart`、`/approve`、`/deny`、`/topic`、`/platform` 和 `/commands` 是**仅限消息平台**的命令。
|
||||
|
|
|
|||
|
|
@ -774,7 +774,7 @@ Hermes 中的每个模型槽位 —— 辅助任务、压缩、回退 —— 使
|
|||
|
||||
当设置 `base_url` 时,Hermes 忽略 provider 并直接调用该端点(使用 `api_key` 或 `OPENAI_API_KEY` 进行认证)。当仅设置 `provider` 时,Hermes 使用该 provider 的内置认证和基础 URL。
|
||||
|
||||
辅助任务的可用 providers:`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `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` —— 或您 `custom_providers` 列表中任何命名的自定义 provider(例如 `provider: "beans"`)。
|
||||
辅助任务的可用 providers:`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`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` —— 或您 `custom_providers` 列表中任何命名的自定义 provider(例如 `provider: "beans"`)。
|
||||
|
||||
:::tip MiniMax OAuth
|
||||
`minimax-oauth` 通过浏览器 OAuth 登录(无需 API 密钥)。运行 `hermes model` 并选择 **MiniMax (OAuth)** 进行认证。辅助任务自动使用 `MiniMax-M2.7-highspeed`。参阅 [MiniMax OAuth 指南](../guides/minimax-oauth.md)。
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ fallback_model:
|
|||
| GMI Cloud | `gmi` | `GMI_API_KEY`(可选:`GMI_BASE_URL`) |
|
||||
| StepFun | `stepfun` | `STEPFUN_API_KEY`(可选:`STEPFUN_BASE_URL`) |
|
||||
| Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |
|
||||
| Google Gemini(OAuth) | `google-gemini-cli` | `hermes model`(Google OAuth;可选:`HERMES_GEMINI_PROJECT_ID`) |
|
||||
| Google AI Studio | `gemini` | `GOOGLE_API_KEY`(别名:`GEMINI_API_KEY`) |
|
||||
| xAI(Grok) | `xai`(别名 `grok`) | `XAI_API_KEY`(可选:`XAI_BASE_URL`) |
|
||||
| xAI Grok OAuth(SuperGrok) | `xai-oauth`(别名 `grok-oauth`) | `hermes model` → xAI Grok OAuth(浏览器登录;需 SuperGrok 订阅) |
|
||||
|
|
|
|||
|
|
@ -332,7 +332,6 @@ hermes uninstall Uninstall Hermes
|
|||
/commands [page] Browse all commands (gateway)
|
||||
/usage Token usage
|
||||
/insights [days] Usage analytics
|
||||
/gquota Show Google Gemini Code Assist quota usage (CLI)
|
||||
/status Session info (gateway)
|
||||
/profile Active profile info
|
||||
/debug Upload debug report (system info + logs) and get shareable links
|
||||
|
|
|
|||
Loading…
Reference in New Issue