Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@
# Optional base URL override (default: Google's OpenAI-compatible endpoint)
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai

# =============================================================================
# LLM PROVIDER (Google Cloud Vertex AI — Express Mode)
# =============================================================================
# Vertex AI Gemini via plain API key (no service account, no token refresh).
# 90-day free trial + GCP-grade SLA. Use this if you want GCP billing /
# compliance / regional routing without the full Vertex auth dance.
# Sign up at: https://console.cloud.google.com/expressmode
# VERTEX_API_KEY=your_vertex_express_mode_key_here
# Fallback env var names (used if VERTEX_API_KEY is unset):
# GOOGLE_VERTEX_API_KEY=...
# GOOGLE_CLOUD_API_KEY=...
# Optional base URL override (default: Vertex express-mode endpoint)
# VERTEX_BASE_URL=https://aiplatform.googleapis.com/v1beta1/publishers/google

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERTEX_BASE_URL is a non-secret behavioral setting. Keep user-facing routing configuration in config.yaml, as the current Vertex provider does for vertex.project_id and vertex.region.


# =============================================================================
# LLM PROVIDER (Ollama Cloud)
# =============================================================================
Expand Down
8 changes: 6 additions & 2 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1228,8 +1228,12 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
agent._client_log_context(),
)
return client
if agent.provider == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
from agent.gemini_native_adapter import is_gemini_native_provider
if is_gemini_native_provider(agent.provider):
from agent.gemini_native_adapter import (
GeminiNativeClient,
is_native_gemini_base_url,
)

base_url = str(client_kwargs.get("base_url", "") or "")
if is_native_gemini_base_url(base_url):
Expand Down
24 changes: 18 additions & 6 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1430,8 +1430,12 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
if provider_id == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
from agent.gemini_native_adapter import is_gemini_native_provider
if is_gemini_native_provider(provider_id):
from agent.gemini_native_adapter import (
GeminiNativeClient,
is_native_gemini_base_url,
)

if is_native_gemini_base_url(base_url):
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
Expand Down Expand Up @@ -1467,8 +1471,12 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
if provider_id == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
from agent.gemini_native_adapter import is_gemini_native_provider
if is_gemini_native_provider(provider_id):
from agent.gemini_native_adapter import (
GeminiNativeClient,
is_native_gemini_base_url,
)

if is_native_gemini_base_url(base_url):
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
Expand Down Expand Up @@ -3523,8 +3531,12 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
default_model = _get_aux_model_for_provider(provider)
final_model = _normalize_resolved_model(model or default_model, provider)

if provider == "gemini":
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
from agent.gemini_native_adapter import is_gemini_native_provider
if is_gemini_native_provider(provider):
from agent.gemini_native_adapter import (
GeminiNativeClient,
is_native_gemini_base_url,
)

if is_native_gemini_base_url(base_url):
client = GeminiNativeClient(api_key=api_key, base_url=base_url)
Expand Down
112 changes: 99 additions & 13 deletions agent/gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,49 @@


def is_native_gemini_base_url(base_url: str) -> bool:
"""Return True when the endpoint speaks Gemini's native REST API."""
"""Return True when the endpoint speaks Gemini's native REST API.

Recognizes both:
- generativelanguage.googleapis.com (Google AI Studio API-key endpoint)
- aiplatform.googleapis.com (Vertex AI express-mode API-key endpoint)

Returns False for ``/openai`` subpath (OpenAI-compat shim) on AI Studio,
since that path uses the standard OpenAI transport instead.
"""
normalized = str(base_url or "").strip().rstrip("/").lower()
if not normalized:
return False
if "generativelanguage.googleapis.com" not in normalized:
if "generativelanguage.googleapis.com" in normalized:
return not normalized.endswith("/openai")
if "aiplatform.googleapis.com" in normalized:
# Vertex express mode — same native REST shape, no /openai subpath
return True
return False


# Provider IDs whose default base_url routes through GeminiNativeClient.
# Extend this set when adding a new ProviderProfile that targets one of
# the URLs accepted by ``is_native_gemini_base_url``. Keeping the list
# here (instead of hardcoding ``provider == "gemini"`` checks in core)
# lets the gemini plugin own its own routing surface.
NATIVE_GEMINI_PROVIDERS: frozenset[str] = frozenset({
"gemini", # Google AI Studio (API key)
"gemini-vertex", # Vertex AI Express Mode (API key)
})


def is_gemini_native_provider(provider_id: Optional[str]) -> bool:
"""Return True when the given provider routes through GeminiNativeClient.

This is the canonical check used by ``agent_runtime_helpers`` and
``auxiliary_client`` to decide whether to instantiate the native
transport instead of the default OpenAI client. It keeps the routing
decision in one place so plugins can extend the gemini family
without touching core.
"""
if not provider_id:
return False
return not normalized.endswith("/openai")
return str(provider_id).lower() in NATIVE_GEMINI_PROVIDERS


def probe_gemini_tier(
Expand Down Expand Up @@ -273,11 +309,47 @@ def _translate_tool_result_to_gemini(
}


def _collect_matched_tool_call_ids(messages: List[Dict[str, Any]]) -> set[str]:
"""Return tool_call_ids that have BOTH an assistant tool_call and a tool response.

Gemini rejects requests where function_call parts and function_response
parts don't match 1:1 (HTTP 400 INVALID_ARGUMENT). This happens after
mid-session model switches: history contains tool calls from a prior
provider, and Hermes hasn't paired them yet, or the user typed `/new`
in a way that severed pairs.

We pre-scan the message list to build the set of "complete" pairs and
later drop any orphan call or orphan response during translation.
"""
call_ids: set[str] = set()
response_ids: set[str] = set()
for msg in messages:
if not isinstance(msg, dict):
continue
role = str(msg.get("role") or "")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
cid = str(tc.get("id") or tc.get("call_id") or "")
if cid:
call_ids.add(cid)
elif role in {"tool", "function"}:
cid = str(msg.get("tool_call_id") or "")
if cid:
response_ids.add(cid)
return call_ids & response_ids


def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
system_text_parts: List[str] = []
contents: List[Dict[str, Any]] = []
tool_name_by_call_id: Dict[str, str] = {}

# Gemini requires exact 1:1 between functionCall and functionResponse parts.
# Drop orphans before translation so a mid-session provider switch doesn't
# poison the request with calls that never got their response (or vice versa).
matched_ids = _collect_matched_tool_call_ids(messages)

for msg in messages:
if not isinstance(msg, dict):
continue
Expand All @@ -288,17 +360,31 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
continue

if role in {"tool", "function"}:
contents.append(
{
"role": "user",
"parts": [
_translate_tool_result_to_gemini(
msg,
tool_name_by_call_id=tool_name_by_call_id,
)
],
}
tcid = str(msg.get("tool_call_id") or "")
if tcid and tcid not in matched_ids:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tcid == "" bypasses this guard and emits a functionResponse with no possible matching functionCall. Reject missing IDs too (if not tcid or tcid not in matched_ids) and cover that malformed-history case.

# Orphan response — no matching upstream tool_call. Skip.
continue
translated = _translate_tool_result_to_gemini(
msg,
tool_name_by_call_id=tool_name_by_call_id,
)
# Gemini requires N functionCall parts in a model turn to be
# followed by exactly N functionResponse parts in a SINGLE user
# turn — not N separate user turns. Coalesce consecutive tool
# responses into the most recent user turn that already holds
# functionResponse parts; otherwise start a new one.
if (
contents
and contents[-1].get("role") == "user"
and contents[-1].get("parts")
and all(
isinstance(p, dict) and "functionResponse" in p
for p in contents[-1]["parts"]
)
):
contents[-1]["parts"].append(translated)
else:
contents.append({"role": "user", "parts": [translated]})
continue

gemini_role = "model" if role == "assistant" else "user"
Expand Down
3 changes: 2 additions & 1 deletion agent/transports/chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,8 @@ def build_kwargs(
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}

if provider_name == "gemini":
from agent.gemini_native_adapter import is_gemini_native_provider
if is_gemini_native_provider(provider_name):
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
if _is_gemini_openai_compat_base_url(base_url):
thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config)
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,17 @@ class ProviderConfig:
api_key_env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
base_url_env_var="GEMINI_BASE_URL",
),
"gemini-vertex": ProviderConfig(
id="gemini-vertex",
name="Google Cloud Vertex AI (Express Mode)",
auth_type="api_key",
# Vertex express-mode endpoint — API key in x-goog-api-key header,
# no project/location prefix needed. Sign up at:
# https://console.cloud.google.com/expressmode
inference_base_url="https://aiplatform.googleapis.com/v1beta1/publishers/google",
api_key_env_vars=("VERTEX_API_KEY", "GOOGLE_VERTEX_API_KEY", "GOOGLE_CLOUD_API_KEY"),
base_url_env_var="VERTEX_BASE_URL",
),
"zai": ProviderConfig(
id="zai",
name="Z.AI / GLM",
Expand Down
15 changes: 15 additions & 0 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,27 @@ def _xai_curated_models() -> list[str]:
"gemini-2.5-pro",
],
"gemini": [
"gemini-3.5-flash",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
],
"google-gemini-cli": [
"gemini-3.5-flash",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
],
"gemini-vertex": [
"gemini-3.5-flash",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
],
"zai": [
"glm-5.1",
"glm-5",
Expand Down Expand Up @@ -997,6 +1008,10 @@ class ProviderEntry(NamedTuple):
"google": "gemini",
"google-gemini": "gemini",
"google-ai-studio": "gemini",
"vertex": "gemini-vertex",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main now uses vertex for the OAuth2 Vertex provider and maps google-vertex/vertex-ai to it (hermes_cli/models.py:1233-1236). Reassigning these aliases would change existing users' credential and transport path; reserve them and use an Express-specific name.

"vertex-ai": "gemini-vertex",
"google-vertex": "gemini-vertex",
"vertex-express": "gemini-vertex",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn",
Expand Down
36 changes: 36 additions & 0 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@
# -- Hermes overlay ----------------------------------------------------------
# Hermes-specific metadata that models.dev doesn't provide.

# Maps ProviderProfile.api_mode → ProviderDef.transport. Used by the plugin
# fallback in get_provider() so that plugin-only profiles register with the
# right wire format. Keep in sync with agent/transports/.
_API_MODE_TO_TRANSPORT: Dict[str, str] = {
"chat_completions": "openai_chat",
"anthropic_messages": "anthropic_messages",
"codex_responses": "codex_responses",
"bedrock_converse": "bedrock_converse",
}


@dataclass(frozen=True)
class HermesOverlay:
"""Hermes-specific provider metadata layered on top of models.dev."""
Expand Down Expand Up @@ -473,6 +484,31 @@ def get_provider(name: str) -> Optional[ProviderDef]:
source="hermes",
)

# Last resort: consult the plugin provider registry (providers/__init__.py).
# Plugin-only profiles (e.g. gemini-vertex registered by
# plugins/model-providers/gemini/__init__.py) live there and are not
# mirrored into HERMES_OVERLAYS, so without this fallback the
# ``--provider <plugin-name>`` flag handler can't resolve them even
# though the model picker can.
try:
from providers import get_provider_profile
profile = get_provider_profile(canonical)
if profile is not None:
transport = _API_MODE_TO_TRANSPORT.get(
profile.api_mode, "openai_chat"
)
return ProviderDef(
id=profile.name,
name=profile.display_name or profile.name,
transport=transport,
api_key_env_vars=tuple(profile.env_vars),
base_url=profile.base_url,
auth_type=profile.auth_type,
source="plugin",
)
except Exception as exc:
logger.debug("plugin registry lookup failed for %s: %s", canonical, exc)

return None


Expand Down
23 changes: 22 additions & 1 deletion plugins/model-providers/gemini/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

gemini: Google AI Studio (API key) — uses GeminiNativeClient
google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient
gemini-vertex: Google Cloud Vertex AI in express mode (API key) — uses GeminiNativeClient

Both report api_mode="chat_completions" but use custom native clients
All three report api_mode="chat_completions" but use custom native clients
that bypass 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.

Vertex express mode (added 2025) lets you authenticate with a plain API key
(no service account, no token refresh) at https://aiplatform.googleapis.com/.
Sign up at https://console.cloud.google.com/expressmode for 90 days free.
The URL shape is `{base}/models/{model}:generateContent` which matches what
GeminiNativeClient already builds, so no client changes are required.
"""

from typing import Any
Expand Down Expand Up @@ -68,5 +75,19 @@ def build_extra_body(
auth_type="oauth_external",
)

gemini_vertex = GeminiProfile(
name="gemini-vertex",
aliases=("vertex", "vertex-ai", "google-vertex", "vertex-express"),
display_name="Google Vertex AI (Express Mode)",
description="Vertex AI Gemini via plain API key — 90-day free trial, GCP-grade SLA",
signup_url="https://console.cloud.google.com/expressmode",
api_mode="chat_completions",
env_vars=("VERTEX_API_KEY", "GOOGLE_VERTEX_API_KEY", "GOOGLE_CLOUD_API_KEY"),
base_url="https://aiplatform.googleapis.com/v1beta1/publishers/google",
auth_type="api_key",
default_aux_model="gemini-3-flash-preview",
)

register_provider(gemini)
register_provider(google_gemini_cli)
register_provider(gemini_vertex)
4 changes: 2 additions & 2 deletions plugins/model-providers/gemini/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: gemini-provider
kind: model-provider
version: 1.0.0
description: Google Gemini (API key + Cloud Code OAuth)
version: 1.1.0
description: Google Gemini — AI Studio (API key) + Cloud Code (OAuth) + Vertex AI Express Mode (API key)
author: Nous Research
Loading