From 05532a3e399ed0226583fc4a523dd4490a0c1839 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:51:37 -0700 Subject: [PATCH 1/2] fix(antigravity): move model flow to model_setup_flows + stop bare-alias hijack CI on the salvage caught two issues the stale PR base masked: 1. The model-setup flows were extracted from main.py into hermes_cli/model_setup_flows.py after @pmos69 forked. The cherry-pick re-introduced a stale _model_flow_custom into main.py (duplicating the one main.py now imports) and put _model_flow_google_antigravity there too. Move the antigravity flow into model_setup_flows.py alongside its siblings and drop the stale _model_flow_custom dup. Fixes the getpass/stdin OSError in tests/cli/test_cli_provider_resolution.py. 2. google-antigravity re-exposes Claude/Gemini/GPT-OSS models, so its catalog was hijacking bare short aliases (`sonnet` -> google-antigravity instead of anthropic) in detect_static_provider_for_model via dict insertion order. Add _BORROWED_MODEL_PROVIDERS and defer those providers to a last-resort pass so a model's native vendor always wins alias/direct-catalog detection. Fixes tests/hermes_cli/test_models.py::test_short_alias_resolves_to_static_model. --- hermes_cli/main.py | 9 +---- hermes_cli/model_setup_flows.py | 58 +++++++++++++++++++++++++++++++++ hermes_cli/models.py | 36 ++++++++++++++++++-- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1e5ef8fc3eab..85289ee6f46d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -593,6 +593,7 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None: _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, @@ -3523,14 +3524,6 @@ def _prompt_provider_choice(choices, *, default=0, title="Select provider:"): ] - - - - - - - - def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]: """Prompt for a custom provider API mode. diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 1af46ab40aa1..7aa78a7d4a63 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -705,6 +705,64 @@ def _model_flow_google_gemini_cli(_config, current_model=""): 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. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index dc605ab001f4..0c462e66f561 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1771,6 +1771,15 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool: {"nous", "openrouter", "copilot", "kilocode"} ) +# 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"}) + def _resolve_static_model_alias( name_lower: str, @@ -1808,7 +1817,11 @@ def _match(provider: str) -> Optional[str]: return provider, matched for provider in _PROVIDER_MODELS: - if provider in current_keys or provider in _AGGREGATOR_PROVIDERS: + if ( + provider in current_keys + or provider in _AGGREGATOR_PROVIDERS + or provider in _BORROWED_MODEL_PROVIDERS + ): continue if matched := _match(provider): return provider, matched @@ -1817,6 +1830,13 @@ def _match(provider: str) -> Optional[str]: 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. + for provider in _BORROWED_MODEL_PROVIDERS: + if provider in current_keys and (matched := _match(provider)): + return provider, matched + return None @@ -1863,11 +1883,23 @@ def detect_static_provider_for_model( # --- Step 1: check static provider catalogs for a direct match --- for pid, models in _PROVIDER_MODELS.items(): - if pid in current_keys or pid in _AGGREGATOR_PROVIDERS: + if ( + pid in current_keys + or pid in _AGGREGATOR_PROVIDERS + or pid in _BORROWED_MODEL_PROVIDERS + ): continue if any(name_lower == m.lower() for m in models): return (pid, name) + # Borrow-list providers (re-expose other vendors' models) only after every + # native-vendor catalog, and only when one is the current provider. + for pid in _BORROWED_MODEL_PROVIDERS: + if pid in current_keys: + continue + if any(name_lower == m.lower() for m in _PROVIDER_MODELS.get(pid, [])): + return (pid, name) + return None From 95d3d681aa7cb7aa62355cc5161cd1e22e1d0fbb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:53:27 -0700 Subject: [PATCH 2/2] feat(providers): remove google-gemini-cli + google-antigravity OAuth providers (#50492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- agent/agent_runtime_helpers.py | 16 - agent/gemini_cloudcode_adapter.py | 909 ------------ agent/google_code_assist.py | 451 ------ agent/google_oauth.py | 1067 -------------- agent/transports/chat_completions.py | 4 - apps/desktop/src/app/settings/constants.ts | 1 - apps/desktop/src/app/settings/helpers.test.ts | 4 +- .../desktop/src/lib/desktop-slash-commands.ts | 2 +- cli.py | 2 - hermes_cli/auth.py | 105 +- hermes_cli/auth_commands.py | 26 +- hermes_cli/cli_commands_mixin.py | 46 - hermes_cli/commands.py | 2 - hermes_cli/config.py | 28 +- hermes_cli/doctor.py | 21 - hermes_cli/main.py | 22 +- hermes_cli/model_setup_flows.py | 136 -- hermes_cli/models.py | 33 +- hermes_cli/providers.py | 10 - hermes_cli/runtime_provider.py | 24 - hermes_cli/tips.py | 1 - hermes_cli/web_server.py | 45 +- plans/gemini-oauth-provider.md | 80 -- plugins/model-providers/gemini/__init__.py | 15 +- run_agent.py | 5 +- .../hermes-agent/SKILL.md | 1 - tests/agent/test_gemini_cloudcode.py | 1225 ----------------- tests/agent/test_gemini_fast_fallback.py | 2 +- .../agent/transports/test_chat_completions.py | 14 - .../test_codex_app_server_runtime.py | 1 - tests/cli/test_gquota_command.py | 21 - tests/hermes_cli/test_auth_commands.py | 45 - tests/hermes_cli/test_config.py | 1 - tests/hermes_cli/test_doctor.py | 44 +- tests/hermes_cli/test_web_oauth_dispatch.py | 32 + tests/skills/test_google_oauth_setup.py | 447 ------ .../docs/developer-guide/adding-providers.md | 2 +- .../developer-guide/model-provider-plugin.md | 2 +- .../docs/developer-guide/provider-runtime.md | 2 +- website/docs/getting-started/quickstart.md | 1 - website/docs/guides/google-gemini.md | 28 +- website/docs/integrations/providers.md | 88 +- website/docs/reference/cli-commands.md | 2 +- .../docs/reference/environment-variables.md | 3 - website/docs/reference/faq.md | 2 +- website/docs/reference/slash-commands.md | 3 +- website/docs/user-guide/configuration.md | 2 +- .../user-guide/features/fallback-providers.md | 1 - .../autonomous-ai-agents-hermes-agent.md | 1 - .../developer-guide/adding-providers.md | 2 +- .../developer-guide/model-provider-plugin.md | 2 +- .../developer-guide/provider-runtime.md | 2 +- .../current/guides/google-gemini.md | 28 +- .../current/integrations/providers.md | 76 +- .../current/reference/cli-commands.md | 2 +- .../reference/environment-variables.md | 3 - .../current/reference/faq.md | 2 +- .../current/reference/slash-commands.md | 4 +- .../current/user-guide/configuration.md | 2 +- .../user-guide/features/fallback-providers.md | 1 - .../autonomous-ai-agents-hermes-agent.md | 1 - 61 files changed, 137 insertions(+), 5013 deletions(-) delete mode 100644 agent/gemini_cloudcode_adapter.py delete mode 100644 agent/google_code_assist.py delete mode 100644 agent/google_oauth.py delete mode 100644 plans/gemini-oauth-provider.md delete mode 100644 tests/agent/test_gemini_cloudcode.py delete mode 100644 tests/cli/test_gquota_command.py delete mode 100644 tests/skills/test_google_oauth_setup.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 884866dc1173..99209a1e13ce 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1373,22 +1373,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 == "gemini": from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py deleted file mode 100644 index 222327807be3..000000000000 --- a/agent/gemini_cloudcode_adapter.py +++ /dev/null @@ -1,909 +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} - return { - "functionCall": { - "name": fn.get("name") or "", - "args": args, - }, - # 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} - return { - "functionResponse": { - "name": name, - "response": 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 = "{}" - tool_calls.append(SimpleNamespace( - id=f"call_{uuid.uuid4().hex[:12]}", - 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, - "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 - . This is a Google-side throttle..." - RESOURCE_EXHAUSTED w/o reason → quota-style message - 404 → "Model 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, - }, - ) diff --git a/agent/google_code_assist.py b/agent/google_code_assist.py deleted file mode 100644 index eec6441f80e2..000000000000 --- a/agent/google_code_assist.py +++ /dev/null @@ -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/`` 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, - ) diff --git a/agent/google_oauth.py b/agent/google_oauth.py deleted file mode 100644 index 9eb55ec19dc3..000000000000 --- a/agent/google_oauth.py +++ /dev/null @@ -1,1067 +0,0 @@ -"""Google OAuth PKCE flow for the Gemini (google-gemini-cli) inference provider. - -This module implements Authorization Code + PKCE (S256) OAuth against Google's -accounts.google.com endpoints. The resulting access token is used by -``agent.gemini_cloudcode_adapter`` to talk to ``cloudcode-pa.googleapis.com`` -(Google's Code Assist backend that powers the Gemini CLI's free and paid tiers). - -Synthesized from: -- jenslys/opencode-gemini-auth (MIT) — overall flow shape, public OAuth creds, request format -- clawdbot/extensions/google/ — refresh-token rotation, VPC-SC handling reference -- PRs #10176 (@sliverp) and #10779 (@newarthur) — PKCE module structure, cross-process lock - -Storage (``~/.hermes/auth/google_oauth.json``, chmod 0o600): - - { - "refresh": "refreshToken|projectId|managedProjectId", - "access": "...", - "expires": 1744848000000, // unix MILLIseconds - "email": "user@example.com" - } - -The ``refresh`` field packs the refresh_token together with the resolved GCP -project IDs so subsequent sessions don't need to re-discover the project. -This matches opencode-gemini-auth's storage contract exactly. - -The packed format stays parseable even if no project IDs are present — just -a bare refresh_token is treated as "packed with empty IDs". - -Public client credentials -------------------------- -The client_id and client_secret below are Google's PUBLIC desktop OAuth client -for their own open-source gemini-cli. They are baked into every copy of the -gemini-cli npm package and are NOT confidential — desktop OAuth clients have -no secret-keeping requirement (PKCE provides the security). Shipping them here -is consistent with opencode-gemini-auth and the official Google gemini-cli. - -Policy note: Google considers using this OAuth client with third-party software -a policy violation. Users see an upfront warning with ``confirm(default=False)`` -before authorization begins. -""" - -from __future__ import annotations - -import base64 -import contextlib -import hashlib -import http.server -import json -import logging -import os -import secrets -import stat -import threading -import time -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, Optional, Tuple - -from hermes_constants import get_hermes_home, secure_parent_dir - -logger = logging.getLogger(__name__) - - -# ============================================================================= -# OAuth client credential resolution. -# -# Resolution order: -# 1. HERMES_GEMINI_CLIENT_ID / HERMES_GEMINI_CLIENT_SECRET env vars (power users) -# 2. Shipped defaults — Google's public gemini-cli desktop OAuth client -# (baked into every copy of Google's open-source gemini-cli; NOT -# confidential — desktop OAuth clients use PKCE, not client_secret, for -# security). Using these matches opencode-gemini-auth behavior. -# 3. Fallback: scrape from a locally installed gemini-cli binary (helps forks -# that deliberately wipe the shipped defaults). -# 4. Fail with a helpful error. -# ============================================================================= - -ENV_CLIENT_ID = "HERMES_GEMINI_CLIENT_ID" -ENV_CLIENT_SECRET = "HERMES_GEMINI_CLIENT_SECRET" - -# Public gemini-cli desktop OAuth client (shipped in Google's open-source -# gemini-cli MIT repo). Composed piecewise to keep the constants readable and -# to pair each piece with an explicit comment about why it is non-confidential. -# See: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts -_PUBLIC_CLIENT_ID_PROJECT_NUM = "681255809395" -_PUBLIC_CLIENT_ID_HASH = "oo8ft2oprdrnp9e3aqf6av3hmdib135j" -_PUBLIC_CLIENT_SECRET_SUFFIX = "4uHgMPm-1o7Sk-geV6Cu5clXFsxl" - -_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}" - -# Regex patterns for fallback scraping from an installed gemini-cli. -import re as _re -from utils import atomic_replace -_CLIENT_ID_PATTERN = _re.compile( - r"OAUTH_CLIENT_ID\s*=\s*['\"]([0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com)['\"]" -) -_CLIENT_SECRET_PATTERN = _re.compile( - r"OAUTH_CLIENT_SECRET\s*=\s*['\"](GOCSPX-[A-Za-z0-9_-]+)['\"]" -) -_CLIENT_ID_SHAPE = _re.compile(r"([0-9]{8,}-[a-z0-9]{20,}\.apps\.googleusercontent\.com)") -_CLIENT_SECRET_SHAPE = _re.compile(r"(GOCSPX-[A-Za-z0-9_-]{20,})") - - -# ============================================================================= -# Endpoints & constants -# ============================================================================= - -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" -) - -DEFAULT_REDIRECT_PORT = 8085 -REDIRECT_HOST = "127.0.0.1" -CALLBACK_PATH = "/oauth2callback" - -# 60-second clock skew buffer (matches opencode-gemini-auth). -REFRESH_SKEW_SECONDS = 60 - -TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0 -CALLBACK_WAIT_SECONDS = 300 -LOCK_TIMEOUT_SECONDS = 30.0 - -# Headless env detection -_HEADLESS_ENV_VARS = ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "HERMES_HEADLESS") - - -# ============================================================================= -# Error type -# ============================================================================= - -class GoogleOAuthError(RuntimeError): - """Raised for any failure in the Google OAuth flow.""" - - def __init__(self, message: str, *, code: str = "google_oauth_error") -> None: - super().__init__(message) - self.code = code - - -# ============================================================================= -# File paths & cross-process locking -# ============================================================================= - -def _credentials_path() -> Path: - return get_hermes_home() / "auth" / "google_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): - """Cross-process lock around the credentials file (fcntl POSIX / msvcrt Windows).""" - 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 Google 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 Google 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 - - -# ============================================================================= -# Client ID resolution -# ============================================================================= - -_scraped_creds_cache: Dict[str, str] = {} - - -def _locate_gemini_cli_oauth_js() -> Optional[Path]: - """Walk the user's gemini binary install to find its oauth2.js. - - Returns None if gemini isn't installed. Supports both the npm install - (``node_modules/@google/gemini-cli-core/dist/**/code_assist/oauth2.js``) - and the Homebrew ``bundle/`` layout. - """ - import shutil - - gemini = shutil.which("gemini") - if not gemini: - return None - - try: - real = Path(gemini).resolve() - except OSError: - return None - - # Walk up from the binary to find npm install root - search_dirs: list[Path] = [] - cur = real.parent - for _ in range(8): # don't walk too far - search_dirs.append(cur) - if (cur / "node_modules").exists(): - search_dirs.append(cur / "node_modules" / "@google" / "gemini-cli-core") - break - if cur.parent == cur: - break - cur = cur.parent - - for root in search_dirs: - if not root.exists(): - continue - # Common known paths - candidates = [ - root / "dist" / "src" / "code_assist" / "oauth2.js", - root / "dist" / "code_assist" / "oauth2.js", - root / "src" / "code_assist" / "oauth2.js", - ] - for c in candidates: - if c.exists(): - return c - # Recursive fallback: look for oauth2.js within 10 dirs deep - try: - for path in root.rglob("oauth2.js"): - return path - except (OSError, ValueError): - continue - - return None - - -def _scrape_client_credentials() -> Tuple[str, str]: - """Extract client_id + client_secret from the local gemini-cli install.""" - if _scraped_creds_cache.get("resolved"): - return _scraped_creds_cache.get("client_id", ""), _scraped_creds_cache.get("client_secret", "") - - oauth_js = _locate_gemini_cli_oauth_js() - if oauth_js is None: - _scraped_creds_cache["resolved"] = "1" # Don't retry on every call - return "", "" - - try: - content = oauth_js.read_text(encoding="utf-8", errors="replace") - except OSError as exc: - logger.debug("Failed to read oauth2.js at %s: %s", oauth_js, exc) - _scraped_creds_cache["resolved"] = "1" - return "", "" - - # Precise pattern first, then fallback shape match - cid_match = _CLIENT_ID_PATTERN.search(content) or _CLIENT_ID_SHAPE.search(content) - cs_match = _CLIENT_SECRET_PATTERN.search(content) or _CLIENT_SECRET_SHAPE.search(content) - - client_id = cid_match.group(1) if cid_match else "" - client_secret = cs_match.group(1) if cs_match else "" - - _scraped_creds_cache["client_id"] = client_id - _scraped_creds_cache["client_secret"] = client_secret - _scraped_creds_cache["resolved"] = "1" - - if client_id: - logger.info("Scraped Gemini OAuth client from %s", oauth_js) - - return client_id, client_secret - - -def _get_client_id() -> str: - env_val = (os.getenv(ENV_CLIENT_ID) or "").strip() - if env_val: - return env_val - if _DEFAULT_CLIENT_ID: - return _DEFAULT_CLIENT_ID - scraped, _ = _scrape_client_credentials() - return scraped - - -def _get_client_secret() -> str: - env_val = (os.getenv(ENV_CLIENT_SECRET) or "").strip() - if env_val: - return env_val - if _DEFAULT_CLIENT_SECRET: - return _DEFAULT_CLIENT_SECRET - _, scraped = _scrape_client_credentials() - return scraped - - -def _require_client_id() -> str: - cid = _get_client_id() - if not cid: - raise GoogleOAuthError( - "Google OAuth client ID is not available.\n" - "Hermes looks for a locally installed gemini-cli to source the OAuth client. " - "Either:\n" - " 1. Install it: npm install -g @google/gemini-cli (or brew install gemini-cli)\n" - " 2. Set HERMES_GEMINI_CLIENT_ID and HERMES_GEMINI_CLIENT_SECRET in ~/.hermes/.env\n" - "\n" - "Register a Desktop OAuth client at:\n" - " https://console.cloud.google.com/apis/credentials\n" - "(enable the Generative Language API on the project).", - code="google_oauth_client_id_missing", - ) - return cid - - -# ============================================================================= -# PKCE -# ============================================================================= - -def _generate_pkce_pair() -> Tuple[str, str]: - """Generate a (verifier, challenge) pair using S256.""" - verifier = secrets.token_urlsafe(64) - digest = hashlib.sha256(verifier.encode("ascii")).digest() - challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - return verifier, challenge - - -# ============================================================================= -# Packed refresh format: refresh_token[|project_id[|managed_project_id]] -# ============================================================================= - -@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}" - - -# ============================================================================= -# Credentials (dataclass wrapping the on-disk format) -# ============================================================================= - -@dataclass -class GoogleCredentials: - access_token: str - refresh_token: str - expires_ms: int # unix milliseconds - 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]) -> "GoogleCredentials": - refresh_packed = str(data.get("refresh", "") or "") - parts = RefreshParts.parse(refresh_packed) - 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 expires_unix_seconds(self) -> float: - return self.expires_ms / 1000.0 - - 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 - - -# ============================================================================= -# Credential I/O (atomic + locked) -# ============================================================================= - -def load_credentials() -> Optional[GoogleCredentials]: - """Load credentials from disk. Returns None if missing or corrupt.""" - 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 Google OAuth credentials at %s: %s", path, exc) - return None - if not isinstance(data, dict): - return None - creds = GoogleCredentials.from_dict(data) - if not creds.access_token: - return None - return creds - - -def save_credentials(creds: GoogleCredentials) -> Path: - """Atomically write creds to disk with 0o600 permissions.""" - path = _credentials_path() - path.parent.mkdir(parents=True, exist_ok=True) - # Tighten parent dir to 0o700 so siblings can't traverse to the creds file. - # On Windows this is a no-op (POSIX mode bits aren't enforced); ignore failures. - # secure_parent_dir refuses to chmod / or top-level dirs (#25821). - secure_parent_dir(path) - 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: - # Create with 0o600 atomically to close the TOCTOU window where the - # default umask (often 0o644) would briefly expose tokens to other - # local users between open() and chmod(). - 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: - """Remove the creds file. Idempotent.""" - path = _credentials_path() - with _credentials_lock(): - try: - path.unlink() - except FileNotFoundError: - pass - except OSError as exc: - logger.warning("Failed to remove Google OAuth credentials at %s: %s", path, exc) - - -# ============================================================================= -# HTTP helpers -# ============================================================================= - -def _post_form(url: str, data: Dict[str, str], timeout: float) -> Dict[str, Any]: - """POST x-www-form-urlencoded and return parsed JSON response.""" - 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 - # Detect invalid_grant to signal credential revocation - code = "google_oauth_token_http_error" - if "invalid_grant" in detail.lower(): - code = "google_oauth_invalid_grant" - raise GoogleOAuthError( - f"Google OAuth token endpoint returned HTTP {exc.code}: {detail or exc.reason}", - code=code, - ) from exc - except urllib.error.URLError as exc: - raise GoogleOAuthError( - f"Google OAuth token request failed: {exc}", - code="google_oauth_token_network_error", - ) from exc - - -def exchange_code( - code: str, - verifier: str, - redirect_uri: str, - *, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS, -) -> Dict[str, Any]: - """Exchange authorization code for access + refresh tokens.""" - cid = client_id if client_id is not None else _get_client_id() - csecret = client_secret if client_secret is not None else _get_client_secret() - data = { - "grant_type": "authorization_code", - "code": code, - "code_verifier": verifier, - "client_id": cid, - "redirect_uri": redirect_uri, - } - if csecret: - data["client_secret"] = csecret - return _post_form(TOKEN_ENDPOINT, data, timeout) - - -def refresh_access_token( - refresh_token: str, - *, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS, -) -> Dict[str, Any]: - """Refresh the access token.""" - if not refresh_token: - raise GoogleOAuthError( - "Cannot refresh: refresh_token is empty. Re-run OAuth login.", - code="google_oauth_refresh_token_missing", - ) - cid = client_id if client_id is not None else _get_client_id() - csecret = client_secret if client_secret is not None else _get_client_secret() - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": cid, - } - if csecret: - data["client_secret"] = csecret - return _post_form(TOKEN_ENDPOINT, data, timeout) - - -def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str: - """Best-effort userinfo fetch for display. Failures return empty string.""" - 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("Userinfo fetch failed (non-fatal): %s", exc) - return "" - - -# ============================================================================= -# In-flight refresh deduplication -# ============================================================================= - -_refresh_inflight: Dict[str, threading.Event] = {} -_refresh_inflight_lock = threading.Lock() - - -def get_valid_access_token(*, force_refresh: bool = False) -> str: - """Load creds, refreshing if near expiry, and return a valid bearer token. - - Dedupes concurrent refreshes by refresh_token. On ``invalid_grant``, the - credential file is wiped and a ``google_oauth_invalid_grant`` error is raised - (caller is expected to trigger a re-login flow). - """ - creds = load_credentials() - if creds is None: - raise GoogleOAuthError( - "No Google OAuth credentials found. Run `hermes auth add google-gemini-cli` first.", - code="google_oauth_not_logged_in", - ) - - if not force_refresh and not creds.access_token_expired(): - return creds.access_token - - # Dedupe concurrent refreshes by refresh_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: - # Another thread is refreshing — wait, then re-read from disk. - event.wait(timeout=LOCK_TIMEOUT_SECONDS) - fresh = load_credentials() - if fresh is not None and not fresh.access_token_expired(): - return fresh.access_token - # Fall through to do our own refresh if the other attempt failed - - try: - try: - resp = refresh_access_token(rt) - except GoogleOAuthError as exc: - if exc.code == "google_oauth_invalid_grant": - logger.warning( - "Google OAuth refresh token invalid (revoked/expired). " - "Clearing credentials at %s — user must re-login.", - _credentials_path(), - ) - clear_credentials() - raise - - new_access = str(resp.get("access_token", "") or "").strip() - if not new_access: - raise GoogleOAuthError( - "Refresh response did not include an access_token.", - code="google_oauth_refresh_empty", - ) - # Google sometimes rotates refresh_token; preserve existing if omitted. - new_refresh = str(resp.get("refresh_token", "") or "").strip() or creds.refresh_token - expires_in = int(resp.get("expires_in", 0) or 0) - - creds.access_token = new_access - creds.refresh_token = new_refresh - 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() - - -# ============================================================================= -# Update project IDs on stored creds -# ============================================================================= - -def update_project_ids(project_id: str = "", managed_project_id: str = "") -> None: - """Persist resolved/discovered project IDs back into the credential file.""" - 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) - - -# ============================================================================= -# Callback server -# ============================================================================= - -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("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] - - if state != type(self).expected_state: - type(self).captured_error = "state_mismatch" - self._respond_html(400, _ERROR_PAGE.format(message="State mismatch — aborting for safety.")) - elif error: - type(self).captured_error = error - # Simple HTML-escape of the error value - safe_err = ( - str(error) - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - ) - self._respond_html(400, _ERROR_PAGE.format(message=f"Authorization denied: {safe_err}")) - elif code: - type(self).captured_code = code - self._respond_html(200, _SUCCESS_PAGE) - else: - type(self).captured_error = "no_code" - self._respond_html(400, _ERROR_PAGE.format(message="Callback received no authorization code.")) - - if type(self).ready is not None: - type(self).ready.set() - - def _respond_html(self, status: int, body: str) -> None: - payload = body.encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - -_SUCCESS_PAGE = """ -Hermes — signed in - -

Signed in to Google.

-

You can close this tab and return to your terminal.

-""" - -_ERROR_PAGE = """ -Hermes — sign-in failed - -

Sign-in failed

{message}

-

Return to your terminal — Hermes will walk you through a manual paste fallback.

-""" - - -def _bind_callback_server(preferred_port: int = DEFAULT_REDIRECT_PORT) -> Tuple[http.server.HTTPServer, int]: - try: - server = http.server.HTTPServer((REDIRECT_HOST, preferred_port), _OAuthCallbackHandler) - return server, preferred_port - except OSError as exc: - logger.info( - "Preferred OAuth callback port %d unavailable (%s); requesting ephemeral port", - preferred_port, exc, - ) - server = http.server.HTTPServer((REDIRECT_HOST, 0), _OAuthCallbackHandler) - return server, server.server_address[1] - - -def _is_headless() -> bool: - return any(os.getenv(k) for k in _HEADLESS_ENV_VARS) - - -# ============================================================================= -# Main login flow -# ============================================================================= - -def start_oauth_flow( - *, - force_relogin: bool = False, - open_browser: bool = True, - callback_wait_seconds: float = CALLBACK_WAIT_SECONDS, - project_id: str = "", -) -> GoogleCredentials: - """Run the interactive browser OAuth flow and persist credentials. - - Args: - force_relogin: If False and valid creds already exist, return them. - open_browser: If False, skip webbrowser.open and print the URL only. - callback_wait_seconds: Max seconds to wait for the browser callback. - project_id: Initial GCP project ID to bake into the stored creds. - Can be discovered/updated later via update_project_ids(). - """ - if not force_relogin: - existing = load_credentials() - if existing and existing.access_token: - logger.info("Google OAuth credentials already present; skipping login.") - return existing - - client_id = _require_client_id() # raises GoogleOAuthError with install hints - client_secret = _get_client_secret() - - verifier, challenge = _generate_pkce_pair() - state = secrets.token_urlsafe(16) - - # If headless, skip the listener and go straight to paste mode - if _is_headless() and open_browser: - logger.info("Headless environment detected; using paste-mode OAuth fallback.") - return _paste_mode_login(verifier, challenge, state, client_id, client_secret, project_id) - - server, port = _bind_callback_server(DEFAULT_REDIRECT_PORT) - redirect_uri = f"http://{REDIRECT_HOST}:{port}{CALLBACK_PATH}" - - _OAuthCallbackHandler.expected_state = state - _OAuthCallbackHandler.captured_code = None - _OAuthCallbackHandler.captured_error = None - ready = threading.Event() - _OAuthCallbackHandler.ready = ready - - params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": OAUTH_SCOPES, - "state": state, - "code_challenge": challenge, - "code_challenge_method": "S256", - "access_type": "offline", - "prompt": "consent", - } - auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params) + "#hermes" - - server_thread = threading.Thread(target=server.serve_forever, daemon=True) - server_thread.start() - - print() - print("Opening your browser to sign in to Google…") - print(f"If it does not open automatically, visit:\n {auth_url}") - print() - - if open_browser: - try: - import webbrowser - - try: - from hermes_cli.auth import ( - _can_open_graphical_browser as _can_open_gui, - ) - except Exception: - _can_open_gui = lambda: True # noqa: E731 - - if _can_open_gui(): - webbrowser.open(auth_url, new=1, autoraise=True) - except Exception as exc: - logger.debug("webbrowser.open failed: %s", exc) - - code: Optional[str] = None - try: - if ready.wait(timeout=callback_wait_seconds): - code = _OAuthCallbackHandler.captured_code - error = _OAuthCallbackHandler.captured_error - if error: - raise GoogleOAuthError( - f"Authorization failed: {error}", - code="google_oauth_authorization_failed", - ) - else: - logger.info("Callback server timed out — offering manual paste fallback.") - code = _prompt_paste_fallback() - finally: - try: - server.shutdown() - except Exception: - pass - try: - server.server_close() - except Exception: - pass - server_thread.join(timeout=2.0) - - if not code: - raise GoogleOAuthError( - "No authorization code received. Aborting.", - code="google_oauth_no_code", - ) - - token_resp = exchange_code( - code, verifier, redirect_uri, - client_id=client_id, client_secret=client_secret, - ) - return _persist_token_response(token_resp, project_id=project_id) - - -def _paste_mode_login( - verifier: str, - challenge: str, - state: str, - client_id: str, - client_secret: str, - project_id: str, -) -> GoogleCredentials: - """Run OAuth flow without a local callback server.""" - # Use a placeholder redirect URI; user will paste the full URL back - redirect_uri = f"http://{REDIRECT_HOST}:{DEFAULT_REDIRECT_PORT}{CALLBACK_PATH}" - params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": OAUTH_SCOPES, - "state": state, - "code_challenge": challenge, - "code_challenge_method": "S256", - "access_type": "offline", - "prompt": "consent", - } - auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params) + "#hermes" - - print() - print("Open this URL in a browser on any device:") - print(f" {auth_url}") - print() - print("After signing in, Google will redirect to localhost (which won't load).") - print("Copy the full URL from your browser and paste it below.") - print() - - code = _prompt_paste_fallback() - if not code: - raise GoogleOAuthError("No authorization code provided.", code="google_oauth_no_code") - - token_resp = exchange_code( - code, verifier, redirect_uri, - client_id=client_id, client_secret=client_secret, - ) - return _persist_token_response(token_resp, project_id=project_id) - - -def _prompt_paste_fallback() -> Optional[str]: - print() - print("Paste the full redirect URL Google showed you, OR just the 'code=' parameter value.") - raw = input("Callback URL or code: ").strip() - if not raw: - return None - if raw.startswith("http://") or raw.startswith("https://"): - parsed = urllib.parse.urlparse(raw) - params = urllib.parse.parse_qs(parsed.query) - return (params.get("code") or [""])[0] or None - # Accept a bare query string as well - if raw.startswith("?"): - params = urllib.parse.parse_qs(raw[1:]) - return (params.get("code") or [""])[0] or None - return raw - - -def _persist_token_response( - token_resp: Dict[str, Any], - *, - project_id: str = "", -) -> GoogleCredentials: - access_token = str(token_resp.get("access_token", "") or "").strip() - refresh_token = str(token_resp.get("refresh_token", "") or "").strip() - expires_in = int(token_resp.get("expires_in", 0) or 0) - if not access_token or not refresh_token: - raise GoogleOAuthError( - "Google token response missing access_token or refresh_token.", - code="google_oauth_incomplete_token_response", - ) - creds = GoogleCredentials( - 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, - managed_project_id="", - ) - save_credentials(creds) - logger.info("Google OAuth credentials saved to %s", _credentials_path()) - return creds - - -# ============================================================================= -# Pool-compatible variant -# ============================================================================= - -def run_gemini_oauth_login_pure() -> Dict[str, Any]: - """Run the login flow and return a dict matching the credential pool shape.""" - creds = start_oauth_flow(force_relogin=True) - 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, - } - - -# ============================================================================= -# Project ID resolution -# ============================================================================= - -def resolve_project_id_from_env() -> str: - """Return a GCP project ID from env vars, in priority order.""" - for var in ( - "HERMES_GEMINI_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - ): - val = (os.getenv(var) or "").strip() - if val: - return val - return "" diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index c0b2a13d250f..f64632f185d1 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -435,10 +435,6 @@ def build_kwargs( extra_body["extra_body"] = openai_compat_extra elif raw_thinking_config: extra_body["thinking_config"] = raw_thinking_config - elif provider_name == "google-gemini-cli": - 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") diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 1cf7cf3ce165..92c3bb752ce2 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -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', diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index b65d63d3296b..719c7c2b3fb8 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -126,9 +126,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', () => { diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index f9ae934edf4e..7d24460f0469 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -150,7 +150,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ const NO_DESKTOP_SURFACE: Record = { 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' ], diff --git a/cli.py b/cli.py index 4ca07fa0bf51..5a43ff988880 100644 --- a/cli.py +++ b/cli.py @@ -7428,8 +7428,6 @@ def process_command(self, command: str) -> bool: 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) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 61c2bbed7865..1a16253303cc 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -137,10 +137,6 @@ "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 - # 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 @@ -205,12 +201,6 @@ class 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, - ), "lmstudio": ProviderConfig( id="lmstudio", name="LM Studio", @@ -1528,7 +1518,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", + "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", @@ -2154,97 +2144,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, - } # Spotify auth — PKCE tokens stored in ~/.hermes/auth.json # ============================================================================= @@ -6156,8 +6055,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 == "minimax-oauth": return get_minimax_oauth_auth_status() if target == "copilot-acp": diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index f1f87c7703ca..decf30dea0f1 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -34,7 +34,7 @@ # Providers that support OAuth login in addition to API keys. -_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "minimax-oauth"} +_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "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,28 +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 == "qwen-oauth": creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False) auth_mod._mark_qwen_oauth_active(creds) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 499f8e9a1a5a..a3e33ddb4931 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -947,52 +947,6 @@ def _handle_branch_command(self, cmd_original: str) -> None: _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 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f81d50eace91..f27328c00346 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -128,8 +128,6 @@ class 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]"), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 356839f9903d..d793c5d75d14 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -169,8 +169,8 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: # 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. # @@ -2869,30 +2869,6 @@ def _ensure_hermes_home_managed(home: Path): "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, - }, "OPENCODE_ZEN_API_KEY": { "description": "OpenCode Zen API key (pay-as-you-go access to curated models)", "prompt": "OpenCode Zen API key", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 127adefb39c4..59b2c7615f2e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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 @@ -1050,7 +1044,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, ) @@ -1078,20 +1071,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") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 85289ee6f46d..e75d65263df9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -592,8 +592,6 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None: _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, @@ -2993,8 +2991,6 @@ def _active_custom_key_from_base_url() -> str: _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 == "copilot-acp": _model_flow_copilot_acp(config, current_model) elif selected_provider == "copilot": @@ -11042,6 +11038,24 @@ def cmd_logs(args): since=getattr(args, "since", None), component=getattr(args, "component", None), ) + + +def _build_provider_choices() -> list[str]: + """Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'.""" + try: + from hermes_cli.models import CANONICAL_PROVIDERS as _cp + return ["auto"] + [p.slug for p in _cp] + except Exception: + # Fallback: static list guarantees the CLI always works + return [ + "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot", + "anthropic", "gemini", "xai", "bedrock", "azure-foundry", + "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", + "stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee", + "nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go", + ] + + # Top-level subcommands that argparse knows about WITHOUT running plugin # discovery. Used to short-circuit eager plugin imports (which can take # 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 7aa78a7d4a63..c2a691e5ec1f 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -626,142 +626,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. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 0c462e66f561..58142a7f980d 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -248,17 +248,6 @@ def _xai_curated_models() -> 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", - ], "zai": [ "glm-5.2", "glm-5.1", @@ -1011,7 +1000,6 @@ class ProviderEntry(NamedTuple): 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("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)"), @@ -1082,7 +1070,7 @@ class ProviderEntry(NamedTuple): "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"]), @@ -1203,8 +1191,6 @@ def group_providers(slugs): "qwen": "alibaba", "alibaba-cloud": "alibaba", "qwen-portal": "qwen-oauth", - "gemini-cli": "google-gemini-cli", - "gemini-oauth": "google-gemini-cli", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", @@ -1772,13 +1758,10 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool: ) # 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( @@ -1830,9 +1813,9 @@ def _match(provider: str) -> Optional[str]: 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 diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index efc3a8576ed1..44f1892d5de1 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -76,11 +76,6 @@ class 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", - ), "lmstudio": HermesOverlay( transport="openai_chat", auth_type="api_key", @@ -310,11 +305,6 @@ class ProviderDef: "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", - - # huggingface "hf": "huggingface", "hugging-face": "huggingface", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 909cbe07a080..e41347ebb3ed 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -25,7 +25,6 @@ resolve_codex_runtime_credentials, resolve_xai_oauth_runtime_credentials, resolve_qwen_runtime_credentials, - resolve_gemini_oauth_runtime_credentials, resolve_api_key_provider_credentials, resolve_external_process_provider_credentials, has_usable_secret, @@ -317,9 +316,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 == "minimax-oauth": # MiniMax OAuth tokens are valid only against the Anthropic Messages # compatible endpoint. Do not honor stale model.api_mode values from a @@ -1537,26 +1533,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 == "copilot-acp": creds = resolve_external_process_provider_credentials(provider) return { diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 1c446c817824..bac18131ee2f 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -420,7 +420,6 @@ '/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.', diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 70f39162cf87..c02f16ea7da3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5156,13 +5156,36 @@ def _claude_code_only_status() -> Dict[str, Any]: return {"logged_in": False, "source": None} -# Provider catalog. The order matters — it's how we render the UI list. -# ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard -# fallback while Phase 2 (in-browser flows) isn't built yet. -# ``flow`` describes the OAuth shape so the future modal can pick the -# right UI: ``pkce`` = open URL + paste callback code, ``device_code`` = -# show code + verification URL + poll, ``external`` = read-only (delegated -# to a third-party CLI like Claude Code or Qwen). +def _copilot_acp_status() -> Dict[str, Any]: + """Status for copilot-acp — credentials are owned by the Copilot CLI. + + There is no cheap programmatic credential probe for the ACP subprocess, so + this is a read-only "managed by the Copilot CLI" card (like claude-code): + Hermes never claims a login state it can't verify. + """ + return { + "logged_in": False, + "source": "copilot_cli", + "source_label": "Managed by the GitHub Copilot CLI", + "token_preview": None, + "expires_at": None, + "has_refresh_token": False, + } + + +# Explicit, hand-tuned OAuth/account provider cards. These carry the bits that +# can't be derived from the unified provider catalog: the OAuth ``flow`` shape, +# the per-provider ``status_fn``, the ``cli_command`` fallback, and curated +# display order. They are the OVERRIDE BASE for ``_build_oauth_catalog()``, +# which unions them with every accounts-tab provider in ``provider_catalog()`` +# so newly-added OAuth/external providers appear automatically (no hand edit). +# This tuple also still includes two entries that are NOT catalog providers but +# must show on the Accounts tab: the api-key Anthropic PKCE card and the +# synthetic ``claude-code`` subscription row. +# ``flow`` describes the OAuth shape so the modal can pick the right UI: +# ``pkce`` = open URL + paste callback code, ``device_code`` = show code + +# verification URL + poll, ``external`` = read-only (delegated to a third-party +# CLI like Claude Code or Qwen), ``loopback`` = 127.0.0.1 callback listener. _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( { "id": "nous", @@ -5212,6 +5235,14 @@ def _claude_code_only_status() -> 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": "copilot-acp", + "name": "GitHub Copilot (ACP)", + "flow": "external", + "cli_command": "copilot /login", + "docs_url": "https://docs.github.com/en/copilot", + "status_fn": _copilot_acp_status, + }, # ── Anthropic / Claude entries sit at the bottom: the API-key path # first, then the subscription OAuth path (which only works with extra # usage credits on top of a Claude Max plan — see disclaimer in name). diff --git a/plans/gemini-oauth-provider.md b/plans/gemini-oauth-provider.md deleted file mode 100644 index a466183e8056..000000000000 --- a/plans/gemini-oauth-provider.md +++ /dev/null @@ -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) diff --git a/plugins/model-providers/gemini/__init__.py b/plugins/model-providers/gemini/__init__.py index f7ae696154c6..94e8bba66c7c 100644 --- a/plugins/model-providers/gemini/__init__.py +++ b/plugins/model-providers/gemini/__init__.py @@ -1,10 +1,9 @@ """Google Gemini provider profiles. gemini: Google AI Studio (API key) — uses GeminiNativeClient -google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient -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. @@ -59,14 +58,4 @@ def build_extra_body( 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", -) - register_provider(gemini) -register_provider(google_gemini_cli) diff --git a/run_agent.py b/run_agent.py index bca3dd1e718d..478de64cae7f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -260,7 +260,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 @@ -4023,8 +4023,7 @@ def _credential_pool_may_recover_rate_limit(self) -> bool: 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 diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index d02ac7933cb3..ee970ae68d3e 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -326,7 +326,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 diff --git a/tests/agent/test_gemini_cloudcode.py b/tests/agent/test_gemini_cloudcode.py deleted file mode 100644 index 600a06ffe93f..000000000000 --- a/tests/agent/test_gemini_cloudcode.py +++ /dev/null @@ -1,1225 +0,0 @@ -"""Tests for the google-gemini-cli OAuth + Code Assist inference provider. - -Covers: -- agent/google_oauth.py — PKCE, credential I/O with packed refresh format, - token refresh dedup, invalid_grant handling, headless paste fallback -- agent/google_code_assist.py — project discovery, VPC-SC fallback, onboarding - with LRO polling, quota retrieval -- agent/gemini_cloudcode_adapter.py — OpenAI↔Gemini translation, request - envelope wrapping, response unwrapping, tool calls bidirectional, streaming -- Provider registration — registry entry, aliases, runtime dispatch, auth - status, _OAUTH_CAPABLE_PROVIDERS regression guard -""" -from __future__ import annotations - -import base64 -import hashlib -import json -import stat -import time -from pathlib import Path - -import pytest - - -# ============================================================================= -# Fixtures -# ============================================================================= - -@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_GEMINI_CLIENT_ID", - "HERMES_GEMINI_CLIENT_SECRET", - "HERMES_GEMINI_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - "SSH_CONNECTION", - "SSH_CLIENT", - "SSH_TTY", - "HERMES_HEADLESS", - ): - monkeypatch.delenv(key, raising=False) - return home - - -# ============================================================================= -# google_oauth.py — PKCE + packed refresh format -# ============================================================================= - -class TestPkce: - def test_verifier_and_challenge_s256_roundtrip(self): - from agent.google_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 - - -class TestRefreshParts: - def test_parse_bare_token(self): - from agent.google_oauth import RefreshParts - - p = RefreshParts.parse("abc-token") - assert p.refresh_token == "abc-token" - assert p.project_id == "" - assert p.managed_project_id == "" - - def test_parse_packed(self): - from agent.google_oauth import RefreshParts - - p = RefreshParts.parse("rt|proj-123|mgr-456") - assert p.refresh_token == "rt" - assert p.project_id == "proj-123" - assert p.managed_project_id == "mgr-456" - - def test_format_bare_token(self): - from agent.google_oauth import RefreshParts - - assert RefreshParts(refresh_token="rt").format() == "rt" - - def test_format_with_project(self): - from agent.google_oauth import RefreshParts - - packed = RefreshParts( - refresh_token="rt", project_id="p1", managed_project_id="m1", - ).format() - assert packed == "rt|p1|m1" - # Roundtrip - parsed = RefreshParts.parse(packed) - assert parsed.refresh_token == "rt" - assert parsed.project_id == "p1" - assert parsed.managed_project_id == "m1" - - def test_format_empty_refresh_token_returns_empty(self): - from agent.google_oauth import RefreshParts - - assert RefreshParts(refresh_token="").format() == "" - - -class TestClientCredResolution: - def test_env_override(self, monkeypatch): - from agent.google_oauth import _get_client_id - - monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "custom-id.apps.googleusercontent.com") - assert _get_client_id() == "custom-id.apps.googleusercontent.com" - - def test_shipped_default_used_when_no_env(self): - """Out of the box, the public gemini-cli desktop client is used.""" - from agent.google_oauth import _get_client_id, _DEFAULT_CLIENT_ID - - # Confirmed PUBLIC: baked into Google's open-source gemini-cli - assert _DEFAULT_CLIENT_ID.endswith(".apps.googleusercontent.com") - assert _DEFAULT_CLIENT_ID.startswith("681255809395-") - assert _get_client_id() == _DEFAULT_CLIENT_ID - - def test_shipped_default_secret_present(self): - from agent.google_oauth import _DEFAULT_CLIENT_SECRET, _get_client_secret - - assert _DEFAULT_CLIENT_SECRET.startswith("GOCSPX-") - assert len(_DEFAULT_CLIENT_SECRET) >= 20 - assert _get_client_secret() == _DEFAULT_CLIENT_SECRET - - def test_falls_back_to_scrape_when_defaults_wiped(self, tmp_path, monkeypatch): - """Forks that wipe the shipped defaults should still work with gemini-cli.""" - from agent import google_oauth - - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_ID", "") - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_SECRET", "") - - fake_bin = tmp_path / "bin" / "gemini" - fake_bin.parent.mkdir(parents=True) - fake_bin.write_text("#!/bin/sh\n") - oauth_dir = tmp_path / "node_modules" / "@google" / "gemini-cli-core" / "dist" / "src" / "code_assist" - oauth_dir.mkdir(parents=True) - (oauth_dir / "oauth2.js").write_text( - 'const OAUTH_CLIENT_ID = "99999-fakescrapedxyz.apps.googleusercontent.com";\n' - 'const OAUTH_CLIENT_SECRET = "GOCSPX-scraped-test-value-placeholder";\n' - ) - - monkeypatch.setattr("shutil.which", lambda _: str(fake_bin)) - google_oauth._scraped_creds_cache.clear() - - assert google_oauth._get_client_id().startswith("99999-") - - def test_missing_everything_raises_with_install_hint(self, monkeypatch): - """When env + defaults + scrape all fail, raise with install instructions.""" - from agent import google_oauth - - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_ID", "") - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_SECRET", "") - google_oauth._scraped_creds_cache.clear() - monkeypatch.setattr("shutil.which", lambda _: None) - - with pytest.raises(google_oauth.GoogleOAuthError) as exc_info: - google_oauth._require_client_id() - assert exc_info.value.code == "google_oauth_client_id_missing" - - def test_locate_gemini_cli_oauth_js_when_absent(self, monkeypatch): - from agent import google_oauth - - monkeypatch.setattr("shutil.which", lambda _: None) - assert google_oauth._locate_gemini_cli_oauth_js() is None - - def test_scrape_client_credentials_parses_id_and_secret(self, tmp_path, monkeypatch): - from agent import google_oauth - - # Create a fake gemini binary and oauth2.js - fake_gemini_bin = tmp_path / "bin" / "gemini" - fake_gemini_bin.parent.mkdir(parents=True) - fake_gemini_bin.write_text("#!/bin/sh\necho gemini\n") - - oauth_js_dir = tmp_path / "node_modules" / "@google" / "gemini-cli-core" / "dist" / "src" / "code_assist" - oauth_js_dir.mkdir(parents=True) - oauth_js = oauth_js_dir / "oauth2.js" - # Synthesize a harmless test fingerprint (valid shape, obvious test values) - oauth_js.write_text( - 'const OAUTH_CLIENT_ID = "12345678-testfakenotrealxyz.apps.googleusercontent.com";\n' - 'const OAUTH_CLIENT_SECRET = "GOCSPX-aaaaaaaaaaaaaaaaaaaaaaaa";\n' - ) - - monkeypatch.setattr("shutil.which", lambda _: str(fake_gemini_bin)) - google_oauth._scraped_creds_cache.clear() - - cid, cs = google_oauth._scrape_client_credentials() - assert cid == "12345678-testfakenotrealxyz.apps.googleusercontent.com" - assert cs.startswith("GOCSPX-") - - -class TestCredentialIo: - def _make(self): - from agent.google_oauth import GoogleCredentials - - return GoogleCredentials( - access_token="at-1", - refresh_token="rt-1", - expires_ms=int((time.time() + 3600) * 1000), - email="user@example.com", - project_id="proj-abc", - ) - - def test_save_and_load_packed_refresh(self): - from agent.google_oauth import load_credentials, save_credentials - - creds = self._make() - save_credentials(creds) - loaded = load_credentials() - assert loaded is not None - assert loaded.refresh_token == "rt-1" - assert loaded.project_id == "proj-abc" - - def test_save_uses_0600_permissions(self): - from agent.google_oauth import _credentials_path, save_credentials - - save_credentials(self._make()) - mode = stat.S_IMODE(_credentials_path().stat().st_mode) - assert mode == 0o600 - - def test_disk_format_is_packed(self): - from agent.google_oauth import _credentials_path, save_credentials - - save_credentials(self._make()) - data = json.loads(_credentials_path().read_text()) - # The refresh field on disk is the packed string, not a dict - assert data["refresh"] == "rt-1|proj-abc|" - - def test_update_project_ids(self): - from agent.google_oauth import ( - load_credentials, save_credentials, update_project_ids, - ) - from agent.google_oauth import GoogleCredentials - - save_credentials(GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - )) - update_project_ids(project_id="new-proj", managed_project_id="mgr-xyz") - - loaded = load_credentials() - assert loaded.project_id == "new-proj" - assert loaded.managed_project_id == "mgr-xyz" - - -class TestAccessTokenExpired: - def test_fresh_token_not_expired(self): - from agent.google_oauth import GoogleCredentials - - creds = GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - ) - assert creds.access_token_expired() is False - - def test_near_expiry_considered_expired(self): - """60s skew — a token with 30s left is considered expired.""" - from agent.google_oauth import GoogleCredentials - - creds = GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 30) * 1000), - ) - assert creds.access_token_expired() is True - - def test_no_token_is_expired(self): - from agent.google_oauth import GoogleCredentials - - creds = GoogleCredentials( - access_token="", refresh_token="rt", expires_ms=999999999, - ) - assert creds.access_token_expired() is True - - -class TestGetValidAccessToken: - def _save(self, **over): - from agent.google_oauth import GoogleCredentials, save_credentials - - defaults = { - "access_token": "at", - "refresh_token": "rt", - "expires_ms": int((time.time() + 3600) * 1000), - } - defaults.update(over) - save_credentials(GoogleCredentials(**defaults)) - - def test_returns_cached_when_fresh(self): - from agent.google_oauth import get_valid_access_token - - self._save(access_token="cached-token") - assert get_valid_access_token() == "cached-token" - - def test_refreshes_when_near_expiry(self, monkeypatch): - from agent import google_oauth - - self._save(expires_ms=int((time.time() + 30) * 1000)) - monkeypatch.setattr( - google_oauth, "_post_form", - lambda *a, **kw: {"access_token": "refreshed", "expires_in": 3600}, - ) - assert google_oauth.get_valid_access_token() == "refreshed" - - def test_invalid_grant_clears_credentials(self, monkeypatch): - from agent import google_oauth - - self._save(expires_ms=int((time.time() - 10) * 1000)) - - def boom(*a, **kw): - raise google_oauth.GoogleOAuthError( - "invalid_grant", code="google_oauth_invalid_grant", - ) - - monkeypatch.setattr(google_oauth, "_post_form", boom) - - with pytest.raises(google_oauth.GoogleOAuthError) as exc_info: - google_oauth.get_valid_access_token() - assert exc_info.value.code == "google_oauth_invalid_grant" - # Credentials should be wiped - assert google_oauth.load_credentials() is None - - def test_preserves_refresh_when_google_omits(self, monkeypatch): - from agent import google_oauth - - self._save(expires_ms=int((time.time() + 30) * 1000), refresh_token="original-rt") - monkeypatch.setattr( - google_oauth, "_post_form", - lambda *a, **kw: {"access_token": "new", "expires_in": 3600}, - ) - google_oauth.get_valid_access_token() - assert google_oauth.load_credentials().refresh_token == "original-rt" - - -class TestProjectIdResolution: - @pytest.mark.parametrize("env_var", [ - "HERMES_GEMINI_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - ]) - def test_env_vars_checked(self, monkeypatch, env_var): - from agent.google_oauth import resolve_project_id_from_env - - monkeypatch.setenv(env_var, "test-proj") - assert resolve_project_id_from_env() == "test-proj" - - def test_priority_order(self, monkeypatch): - from agent.google_oauth import resolve_project_id_from_env - - monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "lower-priority") - monkeypatch.setenv("HERMES_GEMINI_PROJECT_ID", "higher-priority") - assert resolve_project_id_from_env() == "higher-priority" - - def test_no_env_returns_empty(self): - from agent.google_oauth import resolve_project_id_from_env - - assert resolve_project_id_from_env() == "" - - -class TestHeadlessDetection: - def test_detects_ssh(self, monkeypatch): - from agent.google_oauth import _is_headless - - monkeypatch.setenv("SSH_CONNECTION", "1.2.3.4 22 5.6.7.8 9876") - assert _is_headless() is True - - def test_detects_hermes_headless(self, monkeypatch): - from agent.google_oauth import _is_headless - - monkeypatch.setenv("HERMES_HEADLESS", "1") - assert _is_headless() is True - - def test_default_not_headless(self): - from agent.google_oauth import _is_headless - - assert _is_headless() is False - - -# ============================================================================= -# google_code_assist.py — project discovery, onboarding, quota, VPC-SC -# ============================================================================= - -class TestCodeAssistVpcScDetection: - def test_detects_vpc_sc_in_json(self): - from agent.google_code_assist import _is_vpc_sc_violation - - body = json.dumps({ - "error": { - "details": [{"reason": "SECURITY_POLICY_VIOLATED"}], - "message": "blocked by policy", - } - }) - assert _is_vpc_sc_violation(body) is True - - def test_detects_vpc_sc_in_message(self): - from agent.google_code_assist import _is_vpc_sc_violation - - body = '{"error": {"message": "SECURITY_POLICY_VIOLATED"}}' - assert _is_vpc_sc_violation(body) is True - - def test_non_vpc_sc_returns_false(self): - from agent.google_code_assist import _is_vpc_sc_violation - - assert _is_vpc_sc_violation('{"error": {"message": "not found"}}') is False - assert _is_vpc_sc_violation("") is False - - -class TestLoadCodeAssist: - def test_parses_response(self, monkeypatch): - from agent import google_code_assist - - fake = { - "currentTier": {"id": "free-tier"}, - "cloudaicompanionProject": "proj-123", - "allowedTiers": [{"id": "free-tier"}, {"id": "standard-tier"}], - } - monkeypatch.setattr(google_code_assist, "_post_json", lambda *a, **kw: fake) - - info = google_code_assist.load_code_assist("access-token") - assert info.current_tier_id == "free-tier" - assert info.cloudaicompanion_project == "proj-123" - assert "free-tier" in info.allowed_tiers - assert "standard-tier" in info.allowed_tiers - - def test_vpc_sc_forces_standard_tier(self, monkeypatch): - from agent import google_code_assist - - def boom(*a, **kw): - raise google_code_assist.CodeAssistError( - "VPC-SC policy violation", code="code_assist_vpc_sc", - ) - - monkeypatch.setattr(google_code_assist, "_post_json", boom) - - info = google_code_assist.load_code_assist("access-token", project_id="corp-proj") - assert info.current_tier_id == "standard-tier" - assert info.cloudaicompanion_project == "corp-proj" - - -class TestOnboardUser: - def test_paid_tier_requires_project_id(self): - from agent import google_code_assist - - with pytest.raises(google_code_assist.ProjectIdRequiredError): - google_code_assist.onboard_user( - "at", tier_id="standard-tier", project_id="", - ) - - def test_free_tier_no_project_required(self, monkeypatch): - from agent import google_code_assist - - monkeypatch.setattr( - google_code_assist, "_post_json", - lambda *a, **kw: {"done": True, "response": {"cloudaicompanionProject": "gen-123"}}, - ) - resp = google_code_assist.onboard_user("at", tier_id="free-tier") - assert resp["done"] is True - - def test_lro_polling(self, monkeypatch): - """Simulate a long-running operation that completes on the second poll.""" - from agent import google_code_assist - - call_count = {"n": 0} - - def fake_post(url, body, token, **kw): - call_count["n"] += 1 - if call_count["n"] == 1: - return {"name": "operations/op-abc", "done": False} - return {"name": "operations/op-abc", "done": True, "response": {}} - - monkeypatch.setattr(google_code_assist, "_post_json", fake_post) - monkeypatch.setattr(google_code_assist.time, "sleep", lambda *_: None) - - resp = google_code_assist.onboard_user( - "at", tier_id="free-tier", - ) - assert resp["done"] is True - assert call_count["n"] >= 2 - - -class TestRetrieveUserQuota: - def test_parses_buckets(self, monkeypatch): - from agent import google_code_assist - - fake = { - "buckets": [ - { - "modelId": "gemini-2.5-pro", - "tokenType": "input", - "remainingFraction": 0.75, - "resetTime": "2026-04-17T00:00:00Z", - }, - { - "modelId": "gemini-2.5-flash", - "remainingFraction": 0.9, - }, - ] - } - monkeypatch.setattr(google_code_assist, "_post_json", lambda *a, **kw: fake) - - buckets = google_code_assist.retrieve_user_quota("at", project_id="p1") - assert len(buckets) == 2 - assert buckets[0].model_id == "gemini-2.5-pro" - assert buckets[0].remaining_fraction == 0.75 - assert buckets[1].remaining_fraction == 0.9 - - -class TestResolveProjectContext: - def test_configured_shortcircuits(self, monkeypatch): - from agent.google_code_assist import resolve_project_context - - # Should NOT call loadCodeAssist when configured_project_id is set - def should_not_be_called(*a, **kw): - raise AssertionError("should short-circuit") - - monkeypatch.setattr( - "agent.google_code_assist._post_json", should_not_be_called, - ) - ctx = resolve_project_context("at", configured_project_id="proj-abc") - assert ctx.project_id == "proj-abc" - assert ctx.source == "config" - - def test_env_shortcircuits(self, monkeypatch): - from agent.google_code_assist import resolve_project_context - - monkeypatch.setattr( - "agent.google_code_assist._post_json", - lambda *a, **kw: (_ for _ in ()).throw(AssertionError("nope")), - ) - ctx = resolve_project_context("at", env_project_id="env-proj") - assert ctx.project_id == "env-proj" - assert ctx.source == "env" - - def test_discovers_via_load_code_assist(self, monkeypatch): - from agent import google_code_assist - - monkeypatch.setattr( - google_code_assist, "_post_json", - lambda *a, **kw: { - "currentTier": {"id": "free-tier"}, - "cloudaicompanionProject": "discovered-proj", - }, - ) - ctx = google_code_assist.resolve_project_context("at") - assert ctx.project_id == "discovered-proj" - assert ctx.tier_id == "free-tier" - assert ctx.source == "discovered" - - -# ============================================================================= -# gemini_cloudcode_adapter.py — request/response translation -# ============================================================================= - -class TestBuildGeminiRequest: - def test_user_assistant_messages(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - ]) - assert req["contents"][0] == { - "role": "user", "parts": [{"text": "hi"}], - } - assert req["contents"][1] == { - "role": "model", "parts": [{"text": "hello"}], - } - - def test_system_instruction_separated(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "system", "content": "You are helpful"}, - {"role": "user", "content": "hi"}, - ]) - assert req["systemInstruction"]["parts"][0]["text"] == "You are helpful" - # System should NOT appear in contents - assert all(c["role"] != "system" for c in req["contents"]) - - def test_multiple_system_messages_joined(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "system", "content": "A"}, - {"role": "system", "content": "B"}, - {"role": "user", "content": "hi"}, - ]) - assert "A\nB" in req["systemInstruction"]["parts"][0]["text"] - - def test_tool_call_translation(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "user", "content": "what's the weather?"}, - { - "role": "assistant", - "content": None, - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": {"name": "get_weather", "arguments": '{"city": "SF"}'}, - }], - }, - ]) - # Assistant turn should have a functionCall part - model_turn = req["contents"][1] - assert model_turn["role"] == "model" - fc_part = next(p for p in model_turn["parts"] if "functionCall" in p) - assert fc_part["functionCall"]["name"] == "get_weather" - assert fc_part["functionCall"]["args"] == {"city": "SF"} - - def test_tool_result_translation(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "user", "content": "q"}, - {"role": "assistant", "tool_calls": [{ - "id": "c1", "type": "function", - "function": {"name": "get_weather", "arguments": "{}"}, - }]}, - { - "role": "tool", - "name": "get_weather", - "tool_call_id": "c1", - "content": '{"temp": 72}', - }, - ]) - # Last content turn should carry functionResponse - last = req["contents"][-1] - fr_part = next(p for p in last["parts"] if "functionResponse" in p) - assert fr_part["functionResponse"]["name"] == "get_weather" - assert fr_part["functionResponse"]["response"] == {"temp": 72} - - def test_tools_translated_to_function_declarations(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tools=[ - {"type": "function", "function": { - "name": "fn1", "description": "foo", - "parameters": {"type": "object"}, - }}, - ], - ) - decls = req["tools"][0]["functionDeclarations"] - assert decls[0]["name"] == "fn1" - assert decls[0]["description"] == "foo" - assert decls[0]["parameters"] == {"type": "object"} - - def test_tools_strip_json_schema_only_fields_from_parameters(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tools=[ - {"type": "function", "function": { - "name": "fn1", - "description": "foo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": False, - "properties": { - "city": { - "type": "string", - "$schema": "ignored", - "description": "City name", - "additionalProperties": False, - } - }, - "required": ["city"], - }, - }}, - ], - ) - params = req["tools"][0]["functionDeclarations"][0]["parameters"] - assert "$schema" not in params - assert "additionalProperties" not in params - assert params["type"] == "object" - assert params["required"] == ["city"] - assert params["properties"]["city"] == { - "type": "string", - "description": "City name", - } - - def test_tool_choice_auto(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tool_choice="auto", - ) - assert req["toolConfig"]["functionCallingConfig"]["mode"] == "AUTO" - - def test_tool_choice_required(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tool_choice="required", - ) - assert req["toolConfig"]["functionCallingConfig"]["mode"] == "ANY" - - def test_tool_choice_specific_function(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tool_choice={"type": "function", "function": {"name": "my_fn"}}, - ) - cfg = req["toolConfig"]["functionCallingConfig"] - assert cfg["mode"] == "ANY" - assert cfg["allowedFunctionNames"] == ["my_fn"] - - def test_generation_config_params(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - temperature=0.7, - max_tokens=512, - top_p=0.9, - stop=["###", "END"], - ) - gc = req["generationConfig"] - assert gc["temperature"] == 0.7 - assert gc["maxOutputTokens"] == 512 - assert gc["topP"] == 0.9 - assert gc["stopSequences"] == ["###", "END"] - - def test_thinking_config_normalization(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - thinking_config={"thinking_budget": 1024, "include_thoughts": True}, - ) - tc = req["generationConfig"]["thinkingConfig"] - assert tc["thinkingBudget"] == 1024 - assert tc["includeThoughts"] is True - - -class TestWrapCodeAssistRequest: - def test_envelope_shape(self): - from agent.gemini_cloudcode_adapter import wrap_code_assist_request - - inner = {"contents": [], "generationConfig": {}} - wrapped = wrap_code_assist_request( - project_id="p1", model="gemini-2.5-pro", inner_request=inner, - ) - assert wrapped["project"] == "p1" - assert wrapped["model"] == "gemini-2.5-pro" - assert wrapped["request"] is inner - assert "user_prompt_id" in wrapped - assert len(wrapped["user_prompt_id"]) > 10 - - -class TestTranslateGeminiResponse: - def test_text_response(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "response": { - "candidates": [{ - "content": {"parts": [{"text": "hello world"}]}, - "finishReason": "STOP", - }], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15, - }, - } - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - assert result.choices[0].message.content == "hello world" - assert result.choices[0].message.tool_calls is None - assert result.choices[0].finish_reason == "stop" - assert result.usage.prompt_tokens == 10 - assert result.usage.completion_tokens == 5 - assert result.usage.total_tokens == 15 - - def test_function_call_response(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "response": { - "candidates": [{ - "content": {"parts": [{ - "functionCall": {"name": "lookup", "args": {"q": "weather"}}, - }]}, - "finishReason": "STOP", - }], - } - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - tc = result.choices[0].message.tool_calls[0] - assert tc.function.name == "lookup" - assert json.loads(tc.function.arguments) == {"q": "weather"} - assert result.choices[0].finish_reason == "tool_calls" - - def test_thought_parts_go_to_reasoning(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "response": { - "candidates": [{ - "content": {"parts": [ - {"thought": True, "text": "let me think"}, - {"text": "final answer"}, - ]}, - }], - } - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - assert result.choices[0].message.content == "final answer" - assert result.choices[0].message.reasoning == "let me think" - - def test_unwraps_direct_format(self): - """If response is already at top level (no 'response' wrapper), still parse.""" - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "candidates": [{ - "content": {"parts": [{"text": "hi"}]}, - "finishReason": "STOP", - }], - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - assert result.choices[0].message.content == "hi" - - def test_empty_candidates(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - result = _translate_gemini_response({"response": {"candidates": []}}, model="gemini-2.5-flash") - assert result.choices[0].message.content == "" - assert result.choices[0].finish_reason == "stop" - - def test_finish_reason_mapping(self): - from agent.gemini_cloudcode_adapter import _map_gemini_finish_reason - - assert _map_gemini_finish_reason("STOP") == "stop" - assert _map_gemini_finish_reason("MAX_TOKENS") == "length" - assert _map_gemini_finish_reason("SAFETY") == "content_filter" - assert _map_gemini_finish_reason("RECITATION") == "content_filter" - - -class TestTranslateStreamEvent: - def test_parallel_calls_to_same_tool_get_unique_indices(self): - """Gemini may emit several functionCall parts with the same name in a - single turn (e.g. parallel file reads). Each must get its own OpenAI - ``index`` — otherwise downstream aggregators collapse them into one. - """ - from agent.gemini_cloudcode_adapter import _translate_stream_event - - event = { - "response": { - "candidates": [{ - "content": {"parts": [ - {"functionCall": {"name": "read_file", "args": {"path": "a"}}}, - {"functionCall": {"name": "read_file", "args": {"path": "b"}}}, - {"functionCall": {"name": "read_file", "args": {"path": "c"}}}, - ]}, - }], - } - } - counter = [0] - chunks = _translate_stream_event(event, model="gemini-2.5-flash", - tool_call_counter=counter) - indices = [c.choices[0].delta.tool_calls[0].index for c in chunks] - assert indices == [0, 1, 2] - assert counter[0] == 3 - - def test_counter_persists_across_events(self): - """Index assignment must continue across SSE events in the same stream.""" - from agent.gemini_cloudcode_adapter import _translate_stream_event - - def _event(name): - return {"response": {"candidates": [{ - "content": {"parts": [{"functionCall": {"name": name, "args": {}}}]}, - }]}} - - counter = [0] - chunks_a = _translate_stream_event(_event("foo"), model="m", tool_call_counter=counter) - chunks_b = _translate_stream_event(_event("bar"), model="m", tool_call_counter=counter) - chunks_c = _translate_stream_event(_event("foo"), model="m", tool_call_counter=counter) - - assert chunks_a[0].choices[0].delta.tool_calls[0].index == 0 - assert chunks_b[0].choices[0].delta.tool_calls[0].index == 1 - assert chunks_c[0].choices[0].delta.tool_calls[0].index == 2 - - def test_finish_reason_switches_to_tool_calls_when_any_seen(self): - from agent.gemini_cloudcode_adapter import _translate_stream_event - - counter = [0] - # First event emits one tool call. - _translate_stream_event( - {"response": {"candidates": [{ - "content": {"parts": [{"functionCall": {"name": "x", "args": {}}}]}, - }]}}, - model="m", tool_call_counter=counter, - ) - # Second event carries only the terminal finishReason. - chunks = _translate_stream_event( - {"response": {"candidates": [{"finishReason": "STOP"}]}}, - model="m", tool_call_counter=counter, - ) - assert chunks[-1].choices[0].finish_reason == "tool_calls" - - -class TestMakeStreamChunk: - def test_reasoning_only_chunk_has_content_none(self): - from agent.gemini_cloudcode_adapter import _make_stream_chunk - - chunk = _make_stream_chunk(model="m", reasoning="think") - delta = chunk.choices[0].delta - assert delta.content is None - assert delta.reasoning == "think" - - def test_content_only_chunk_has_reasoning_none(self): - from agent.gemini_cloudcode_adapter import _make_stream_chunk - - chunk = _make_stream_chunk(model="m", content="hello") - delta = chunk.choices[0].delta - assert delta.content == "hello" - assert delta.reasoning is None - assert delta.tool_calls is None - - def test_finish_only_chunk_has_all_fields_none(self): - from agent.gemini_cloudcode_adapter import _make_stream_chunk - - chunk = _make_stream_chunk(model="m", finish_reason="stop") - delta = chunk.choices[0].delta - assert delta.content is None - assert delta.reasoning is None - assert delta.tool_calls is None - assert chunk.choices[0].finish_reason == "stop" - - -class TestGeminiCloudCodeClient: - def test_client_exposes_openai_interface(self): - from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient - - client = GeminiCloudCodeClient(api_key="dummy") - try: - assert hasattr(client, "chat") - assert hasattr(client.chat, "completions") - assert callable(client.chat.completions.create) - finally: - client.close() - - -class TestGeminiHttpErrorParsing: - """Regression coverage for _gemini_http_error Google-envelope parsing. - - These are the paths that users actually hit during Google-side throttling - (April 2026: gemini-2.5-pro MODEL_CAPACITY_EXHAUSTED, gemma-4-26b-it - returning 404). The error needs to carry status_code + response so the - main loop's error_classifier and Retry-After logic work. - """ - - @staticmethod - def _fake_response(status: int, body: dict | str = "", headers=None): - """Minimal httpx.Response stand-in (duck-typed for _gemini_http_error).""" - class _FakeResponse: - def __init__(self): - self.status_code = status - if isinstance(body, dict): - self.text = json.dumps(body) - else: - self.text = body - self.headers = headers or {} - return _FakeResponse() - - def test_model_capacity_exhausted_produces_friendly_message(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - body = { - "error": { - "code": 429, - "message": "Resource has been exhausted (e.g. check quota).", - "status": "RESOURCE_EXHAUSTED", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "MODEL_CAPACITY_EXHAUSTED", - "domain": "googleapis.com", - "metadata": {"model": "gemini-2.5-pro"}, - }, - { - "@type": "type.googleapis.com/google.rpc.RetryInfo", - "retryDelay": "30s", - }, - ], - } - } - err = _gemini_http_error(self._fake_response(429, body)) - assert err.status_code == 429 - assert err.code == "code_assist_capacity_exhausted" - assert err.retry_after == 30.0 - assert err.details["reason"] == "MODEL_CAPACITY_EXHAUSTED" - # Message must be user-friendly, not a raw JSON dump. - message = str(err) - assert "gemini-2.5-pro" in message - assert "capacity exhausted" in message.lower() - assert "30s" in message - # response attr is preserved for run_agent's Retry-After header path. - assert err.response is not None - - def test_resource_exhausted_without_reason(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - body = { - "error": { - "code": 429, - "message": "Quota exceeded for requests per minute.", - "status": "RESOURCE_EXHAUSTED", - } - } - err = _gemini_http_error(self._fake_response(429, body)) - assert err.status_code == 429 - assert err.code == "code_assist_rate_limited" - message = str(err) - assert "quota" in message.lower() - - def test_404_model_not_found_produces_model_retired_message(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - body = { - "error": { - "code": 404, - "message": "models/gemma-4-26b-it is not found for API version v1internal", - "status": "NOT_FOUND", - } - } - err = _gemini_http_error(self._fake_response(404, body)) - assert err.status_code == 404 - message = str(err) - assert "not available" in message.lower() or "retired" in message.lower() - # Error message should reference the actual model text from Google. - assert "gemma-4-26b-it" in message - - def test_unauthorized_preserves_status_code(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - err = _gemini_http_error(self._fake_response( - 401, {"error": {"code": 401, "message": "Invalid token", "status": "UNAUTHENTICATED"}}, - )) - assert err.status_code == 401 - assert err.code == "code_assist_unauthorized" - - def test_retry_after_header_fallback(self): - """If the body has no RetryInfo detail, fall back to Retry-After header.""" - from agent.gemini_cloudcode_adapter import _gemini_http_error - - resp = self._fake_response( - 429, - {"error": {"code": 429, "message": "Rate limited", "status": "RESOURCE_EXHAUSTED"}}, - headers={"Retry-After": "45"}, - ) - err = _gemini_http_error(resp) - assert err.retry_after == 45.0 - - def test_malformed_body_still_produces_structured_error(self): - """Non-JSON body must not swallow status_code — we still want the classifier path.""" - from agent.gemini_cloudcode_adapter import _gemini_http_error - - err = _gemini_http_error(self._fake_response(500, "internal error")) - assert err.status_code == 500 - # Raw body snippet must still be there for debugging. - assert "500" in str(err) - - def test_status_code_flows_through_error_classifier(self): - """End-to-end: CodeAssistError from a 429 must classify as rate_limit. - - This is the whole point of adding status_code to CodeAssistError — - _extract_status_code must see it and FailoverReason.rate_limit must - fire, so the main loop triggers fallback_providers. - """ - from agent.gemini_cloudcode_adapter import _gemini_http_error - from agent.error_classifier import classify_api_error, FailoverReason - - body = { - "error": { - "code": 429, - "message": "Resource has been exhausted", - "status": "RESOURCE_EXHAUSTED", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "MODEL_CAPACITY_EXHAUSTED", - "metadata": {"model": "gemini-2.5-pro"}, - } - ], - } - } - err = _gemini_http_error(self._fake_response(429, body)) - - classified = classify_api_error( - err, provider="google-gemini-cli", model="gemini-2.5-pro", - ) - assert classified.status_code == 429 - assert classified.reason == FailoverReason.rate_limit - - -# ============================================================================= -# Provider registration -# ============================================================================= - -class TestProviderRegistration: - def test_registry_entry(self): - from hermes_cli.auth import PROVIDER_REGISTRY - - assert "google-gemini-cli" in PROVIDER_REGISTRY - assert PROVIDER_REGISTRY["google-gemini-cli"].auth_type == "oauth_external" - - def test_google_gemini_alias_still_goes_to_api_key_gemini(self): - """Regression guard: don't shadow the existing google-gemini → gemini alias.""" - from hermes_cli.auth import resolve_provider - - assert resolve_provider("google-gemini") == "gemini" - - 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-gemini-cli") - assert exc_info.value.code == "google_oauth_not_logged_in" - - def test_runtime_provider_returns_correct_shape_when_logged_in(self): - from agent.google_oauth import GoogleCredentials, save_credentials - from hermes_cli.runtime_provider import resolve_runtime_provider - - save_credentials(GoogleCredentials( - 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-gemini-cli") - assert result["provider"] == "google-gemini-cli" - assert result["api_mode"] == "chat_completions" - assert result["api_key"] == "live-tok" - assert result["base_url"] == "cloudcode-pa://google" - assert result["project_id"] == "my-proj" - assert result["email"] == "t@e.com" - - def test_determine_api_mode(self): - from hermes_cli.providers import determine_api_mode - - assert determine_api_mode("google-gemini-cli", "cloudcode-pa://google") == "chat_completions" - - def test_oauth_capable_set_preserves_existing(self): - from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS - - for required in ("anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"): - assert required in _OAUTH_CAPABLE_PROVIDERS - - def test_config_env_vars_registered(self): - from hermes_cli.config import OPTIONAL_ENV_VARS - - for key in ( - "HERMES_GEMINI_CLIENT_ID", - "HERMES_GEMINI_CLIENT_SECRET", - "HERMES_GEMINI_PROJECT_ID", - ): - assert key in OPTIONAL_ENV_VARS - - -class TestAuthStatus: - def test_not_logged_in(self): - from hermes_cli.auth import get_auth_status - - s = get_auth_status("google-gemini-cli") - assert s["logged_in"] is False - - def test_logged_in_reports_email_and_project(self): - from agent.google_oauth import GoogleCredentials, save_credentials - from hermes_cli.auth import get_auth_status - - save_credentials(GoogleCredentials( - access_token="tok", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - email="tek@nous.ai", - project_id="tek-proj", - )) - - s = get_auth_status("google-gemini-cli") - assert s["logged_in"] is True - assert s["email"] == "tek@nous.ai" - assert s["project_id"] == "tek-proj" - - -class TestGquotaCommand: - def test_gquota_registered(self): - from hermes_cli.commands import COMMANDS - - assert "/gquota" in COMMANDS - - -class TestRunGeminiOauthLoginPure: - def test_returns_pool_compatible_dict(self, monkeypatch): - from agent import google_oauth - - def fake_start(**kw): - return google_oauth.GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - email="u@e.com", project_id="p", - ) - - monkeypatch.setattr(google_oauth, "start_oauth_flow", fake_start) - - result = google_oauth.run_gemini_oauth_login_pure() - assert result["access_token"] == "at" - assert result["refresh_token"] == "rt" - assert result["email"] == "u@e.com" - assert result["project_id"] == "p" - assert isinstance(result["expires_at_ms"], int) diff --git a/tests/agent/test_gemini_fast_fallback.py b/tests/agent/test_gemini_fast_fallback.py index 41fafca8a50a..4439eec1e074 100644 --- a/tests/agent/test_gemini_fast_fallback.py +++ b/tests/agent/test_gemini_fast_fallback.py @@ -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 diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index da642e2ae17c..5fbf7377934a 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -379,20 +379,6 @@ def test_gemini_openai_compat_xhigh_clamps_to_high(self, transport): ) 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_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. diff --git a/tests/agent/transports/test_codex_app_server_runtime.py b/tests/agent/transports/test_codex_app_server_runtime.py index 55bbc8bc6d34..e965d921b764 100644 --- a/tests/agent/transports/test_codex_app_server_runtime.py +++ b/tests/agent/transports/test_codex_app_server_runtime.py @@ -85,7 +85,6 @@ def test_case_insensitive(self) -> None: "openrouter", "xai", "qwen-oauth", - "google-gemini-cli", "opencode-zen", "bedrock", "", diff --git a/tests/cli/test_gquota_command.py b/tests/cli/test_gquota_command.py deleted file mode 100644 index 0740e001262d..000000000000 --- a/tests/cli/test_gquota_command.py +++ /dev/null @@ -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() diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 949a936962b2..eba225a96b5b 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -129,51 +129,6 @@ class _Args: 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. diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 3e3144fdfea7..1ca6705841b8 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1045,7 +1045,6 @@ def test_denylisted_keys_rejected(self, denied_key): @pytest.mark.parametrize( "allowed_key", [ - "HERMES_GEMINI_CLIENT_ID", "HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_SPOTIFY_CLIENT_ID", "HERMES_QWEN_BASE_URL", diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index ba2032b8efa5..11b6033844fd 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -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 fake_get(url, headers=None, timeout=None): @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 fake_get(url, headers=None, timeout=None): "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 @@ def test_returns_false_when_xai_import_unavailable(self, monkeypatch): 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 @@ def _run(self, monkeypatch, tmp_path, *, xai_auth_fn) -> str: 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 @@ def test_import_failure_does_not_crash_doctor(self, monkeypatch, tmp_path): 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 @@ def test_import_failure_does_not_affect_other_providers(self, monkeypatch, tmp_p 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 @@ def _run(self, monkeypatch, tmp_path, *, codex_logged_in: bool, codex_cli_presen 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 @@ def test_hint_suppressed_when_codex_logged_in(self, monkeypatch, tmp_path): 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: diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 1d87573fe588..9d41fe2b25d5 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -470,6 +470,38 @@ def test_xai_oauth_listed_as_loopback_flow(): assert "grok" in providers["xai-oauth"]["name"].lower() +def test_accounts_offers_every_oauth_provider_from_catalog(): + """PARITY CONTRACT: every accounts-tab provider in the unified catalog (the + `hermes model` universe) must be offered by /api/providers/oauth. This keeps + the desktop Accounts tab in lockstep with the CLI picker — no provider the + CLI can sign into may be missing from the GUI. + """ + from hermes_cli.provider_catalog import provider_catalog + + resp = client.get("/api/providers/oauth", headers=HEADERS) + assert resp.status_code == 200, resp.text + offered = {p["id"] for p in resp.json()["providers"]} + for d in provider_catalog(): + if d.tab == "accounts": + assert d.slug in offered, ( + f"{d.slug} is an accounts-tab provider in `hermes model` but is " + f"missing from the desktop Accounts tab (/api/providers/oauth)" + ) + + +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 "copilot-acp" in providers + # copilot-acp is managed by an external CLI: read-only card, not auto-removable. + assert providers["copilot-acp"]["flow"] == "external" + assert providers["copilot-acp"]["disconnectable"] is False + + def test_oauth_catalog_marks_external_providers_not_disconnectable(): """External CLI credentials are visible in Accounts but cannot be removed by Hermes.""" resp = client.get("/api/providers/oauth", headers=HEADERS) diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py deleted file mode 100644 index 1b7b0e17d216..000000000000 --- a/tests/skills/test_google_oauth_setup.py +++ /dev/null @@ -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 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 diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md index f21b6341cf6a..0898d698ac8c 100644 --- a/website/docs/developer-guide/adding-providers.md +++ b/website/docs/developer-guide/adding-providers.md @@ -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 diff --git a/website/docs/developer-guide/model-provider-plugin.md b/website/docs/developer-guide/model-provider-plugin.md index 8df59f5781e2..f12ed3abf336 100644 --- a/website/docs/developer-guide/model-provider-plugin.md +++ b/website/docs/developer-guide/model-provider-plugin.md @@ -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 | diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md index b412ff479a3a..49f6ac2f5659 100644 --- a/website/docs/developer-guide/provider-runtime.md +++ b/website/docs/developer-guide/provider-runtime.md @@ -47,7 +47,7 @@ Current provider families include (see `plugins/model-providers/` for the comple - OpenAI Codex - Copilot / Copilot ACP - Anthropic (native) -- Google / Gemini (`gemini`, `google-gemini-cli`) +- Google / Gemini (`gemini`) - Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`) - DeepSeek - Z.AI diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 630df6e2938c..10f5737efb13 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -116,7 +116,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` | diff --git a/website/docs/guides/google-gemini.md b/website/docs/guides/google-gemini.md index 0994bb261027..7a00eabf8dff 100644 --- a/website/docs/guides/google-gemini.md +++ b/website/docs/guides/google-gemini.md @@ -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,17 +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. - ## Available Models The `hermes model` picker shows Gemini models maintained in Hermes' provider registry. Common choices include: @@ -192,17 +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 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: @@ -264,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. diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 6ab24d0a4216..e4ee48f36ceb 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -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) | @@ -533,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. @@ -1522,7 +1436,7 @@ fallback_model: When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session. -Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. +Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `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). diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 3071ac0e5fcc..9cf9bf7c7320 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -100,7 +100,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | +| `--provider ` | 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 ` | 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. | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 76ce863e6613..e658bb9e289e 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -67,9 +67,6 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `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) | | `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 | diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 75e49b2a292d..761b8920063d 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -20,7 +20,7 @@ Hermes Agent works with any OpenAI-compatible API. Supported providers include: - **[Nous Portal](/integrations/nous-portal)** — Nous Research's subscription gateway — 300+ models plus web/image/TTS/browser through one OAuth login (recommended for newcomers) - **OpenAI** — GPT-5.4, GPT-5-codex, GPT-4.1, GPT-4o, etc. - **Anthropic** — Claude models (direct API, OAuth via `hermes auth add anthropic`, OpenRouter, or any compatible proxy) -- **Google** — Gemini models (direct API via `gemini` provider, the `google-gemini-cli` OAuth provider, OpenRouter, or compatible proxy) +- **Google** — Gemini models (direct API via `gemini` provider, OpenRouter, or compatible proxy) - **z.ai / ZhipuAI** — GLM models - **Kimi / Moonshot AI** — Kimi models - **MiniMax** — global and China endpoints diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index a9951263d7ff..938f79440d46 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -112,7 +112,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/image ` | 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 @@ -239,7 +238,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`, 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`, and `/commands` are **messaging-only** commands. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 307ec5a2e454..691e1a0c4e39 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -946,7 +946,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL. -Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). +Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `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). diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index dbe431fc1ea4..05629af590fc 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -62,7 +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 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) | diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 77f81db14b6a..3db4bb3ad3f2 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md index 1165d1e8091e..04245b32e1cb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md @@ -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` 获取 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md index f2b136bb6e0c..e649fe5d23af 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md @@ -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` 插件 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md index beeae3f889b6..181c996c9e8f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md @@ -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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md index d45bbc8c1a1a..f1fa70f4dd6f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md @@ -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 进行清理;旧版本或自定义端点可能不支持此功能。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md index 35c28794b9bb..68d7d5d07675 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md @@ -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)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index 24e896253a65..0643d50a19ec 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -95,7 +95,7 @@ hermes chat [options] | `-q`, `--query "..."` | 单次非交互式 prompt。 | | `-m`, `--model ` | 覆盖本次运行的模型。 | | `-t`, `--toolsets ` | 启用逗号分隔的 toolset 集合。 | -| `--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:`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 ` | 为会话预加载一个或多个 skill(可重复或逗号分隔)。 | | `-v`, `--verbose` | 详细输出。 | | `-Q`, `--quiet` | 程序化模式:抑制横幅/spinner/工具预览。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md index 52ed671891bf..af3033a3c18e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md @@ -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/)) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md index f062651dcf9e..2294119f36bf 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md @@ -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** — 全球及中国区端点 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md index 9fb39a9f8bf7..3d111c0b09d1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md @@ -110,7 +110,6 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中 | `/image ` | 为下一条 prompt 附加本地图片文件。 | | `/debug` | 上传调试报告(系统信息 + 日志)并获取可分享链接。消息平台中也可用。 | | `/profile` | 显示活动 profile 名称和主目录 | -| `/gquota` | 以进度条形式显示 Google Gemini Code Assist 配额用量(仅在 `google-gemini-cli` 提供商激活时可用)。 | ### 退出 @@ -234,7 +233,8 @@ hermes config set model.aliases.grok x-ai/grok-4 ## 注意事项 -- `/skin`、`/snapshot`、`/gquota`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/skills`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff` 和 `/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` 和 `/commands` 是**仅限消息平台**的命令。 - `/status`、`/version`、`/background`、`/queue`、`/steer`、`/voice`、`/reload-mcp`、`/reload-skills`、`/rollback`、`/debug`、`/fast`、`/footer`、`/curator`、`/kanban`、`/sessions` 和 `/yolo` 在 **CLI 和消息 gateway 中均可使用**。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index 140057af1a97..a50c63c1c527 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -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)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md index 74eed1e3f9ca..6db4b6506b67 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.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 订阅) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index eee73a2b4aac..52e09c326047 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -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