diff --git a/.env.example b/.env.example index b7f3b008faf2..1b131678811e 100644 --- a/.env.example +++ b/.env.example @@ -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 + # ============================================================================= # LLM PROVIDER (Ollama Cloud) # ============================================================================= diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index b98fe4b44e77..a2bf839cefcc 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -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): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 89dc7d935b47..a41bc0637d8d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 @@ -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 @@ -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) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b0d903372cde..72e08371a859 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -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( @@ -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 @@ -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: + # 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" diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index fa36301bd81d..d31b74a61e1d 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -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) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index f21ada7db8b1..f15fe3155c41 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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", diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 336e220814eb..455f82a9ccd4 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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", @@ -997,6 +1008,10 @@ class ProviderEntry(NamedTuple): "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini", + "vertex": "gemini-vertex", + "vertex-ai": "gemini-vertex", + "google-vertex": "gemini-vertex", + "vertex-express": "gemini-vertex", "kimi": "kimi-coding", "moonshot": "kimi-coding", "kimi-cn": "kimi-coding-cn", diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 0017004ee089..7f2c72e9d59e 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -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.""" @@ -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 `` 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 diff --git a/plugins/model-providers/gemini/__init__.py b/plugins/model-providers/gemini/__init__.py index 0812f07ba5f1..85f8f7687203 100644 --- a/plugins/model-providers/gemini/__init__.py +++ b/plugins/model-providers/gemini/__init__.py @@ -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 @@ -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) diff --git a/plugins/model-providers/gemini/plugin.yaml b/plugins/model-providers/gemini/plugin.yaml index cd586b08868c..07c4bcc2be8d 100644 --- a/plugins/model-providers/gemini/plugin.yaml +++ b/plugins/model-providers/gemini/plugin.yaml @@ -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 diff --git a/tests/agent/test_gemini_native_adapter.py b/tests/agent/test_gemini_native_adapter.py index 4b066b4f454c..bd41a6e22b70 100644 --- a/tests/agent/test_gemini_native_adapter.py +++ b/tests/agent/test_gemini_native_adapter.py @@ -326,3 +326,93 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts(): assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0 assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1 assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id + + +def test_build_gemini_contents_coalesces_parallel_tool_responses(): + """Parallel tool calls must produce 1 model turn (N functionCall parts) + followed by 1 user turn (N functionResponse parts). + + Gemini rejects mismatched counts: HTTP 400 INVALID_ARGUMENT — "number of + function response parts is equal to the number of function call parts of + the function call turn". This regresses if tool responses get split into + separate user turns. + """ + from agent.gemini_native_adapter import _build_gemini_contents + + contents, _ = _build_gemini_contents( + [ + {"role": "user", "content": "find X and run Y"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "c1", "type": "function", "function": {"name": "a", "arguments": "{}"}}, + {"id": "c2", "type": "function", "function": {"name": "b", "arguments": "{}"}}, + ], + }, + {"role": "tool", "tool_call_id": "c1", "content": "r1", "name": "a"}, + {"role": "tool", "tool_call_id": "c2", "content": "r2", "name": "b"}, + ] + ) + + # Find the model turn with functionCalls + fc_turn = next(c for c in contents if c["role"] == "model" and any("functionCall" in p for p in c["parts"])) + fc_count = sum(1 for p in fc_turn["parts"] if "functionCall" in p) + + # The next turn must be a single user turn with the same count of functionResponse parts + fc_idx = contents.index(fc_turn) + response_turn = contents[fc_idx + 1] + assert response_turn["role"] == "user" + fr_count = sum(1 for p in response_turn["parts"] if "functionResponse" in p) + assert fc_count == fr_count == 2, ( + f"Expected {fc_count} functionResponse parts in single user turn, got {fr_count}. " + f"Full contents: {contents}" + ) + + +def test_build_gemini_contents_drops_orphan_tool_responses(): + """A tool response with no matching upstream tool_call must be dropped. + + Mid-session provider switches can leave stale tool messages without their + pair. Sending them to Gemini triggers HTTP 400. + """ + from agent.gemini_native_adapter import _build_gemini_contents + + contents, _ = _build_gemini_contents( + [ + {"role": "user", "content": "hi"}, + {"role": "tool", "tool_call_id": "stale", "content": "leftover", "name": "x"}, + {"role": "user", "content": "are you there"}, + ] + ) + + # No turn should contain a functionResponse part + assert not any( + any(isinstance(p, dict) and "functionResponse" in p for p in c["parts"]) + for c in contents + ), f"Orphan functionResponse leaked through: {contents}" + + +def test_build_gemini_contents_preserves_orphan_tool_calls_for_replay(): + """An assistant tool_call with no matching response must be preserved. + + This is the "tool replay" pattern (sending an assistant turn with a + pending tool_call and waiting for Gemini to acknowledge before sending + the response). Regression guard for thoughtSignature handling. + """ + from agent.gemini_native_adapter import _build_gemini_contents + + contents, _ = _build_gemini_contents( + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "pending", "type": "function", "function": {"name": "get_x", "arguments": "{}"}}, + ], + }, + ] + ) + assert len(contents) == 1 + assert contents[0]["role"] == "model" + assert any("functionCall" in p for p in contents[0]["parts"]) diff --git a/tests/hermes_cli/test_gemini_provider.py b/tests/hermes_cli/test_gemini_provider.py index 1daeb281f0e3..7dffac58f869 100644 --- a/tests/hermes_cli/test_gemini_provider.py +++ b/tests/hermes_cli/test_gemini_provider.py @@ -354,3 +354,288 @@ def test_list_provider_models_hides_low_tpm_google_gemmas(self): assert "gemma-3-27b-it" not in result assert "gemini-1.5-pro" not in result assert "gemini-2.0-flash" not in result + + +# ───────────────────────────────────────────────────────────────────────────── +# Vertex AI Express Mode — peer provider profile next to AI Studio. +# Same native REST shape ({base}/models/{model}:generateContent), API-key auth +# via x-goog-api-key header, routes through GeminiNativeClient. +# ───────────────────────────────────────────────────────────────────────────── + + +class TestVertexProviderRegistry: + def test_vertex_in_registry(self): + assert "gemini-vertex" in PROVIDER_REGISTRY + + def test_vertex_config(self): + pconfig = PROVIDER_REGISTRY["gemini-vertex"] + assert pconfig.id == "gemini-vertex" + assert pconfig.name == "Google Cloud Vertex AI (Express Mode)" + assert pconfig.auth_type == "api_key" + assert pconfig.inference_base_url == ( + "https://aiplatform.googleapis.com/v1beta1/publishers/google" + ) + + def test_vertex_env_vars(self): + pconfig = PROVIDER_REGISTRY["gemini-vertex"] + # Pras's preferred VERTEX_API_KEY first, then GCP-conventional fallbacks. + assert pconfig.api_key_env_vars == ( + "VERTEX_API_KEY", + "GOOGLE_VERTEX_API_KEY", + "GOOGLE_CLOUD_API_KEY", + ) + assert pconfig.base_url_env_var == "VERTEX_BASE_URL" + + def test_vertex_base_url_is_express_mode_endpoint(self): + assert "aiplatform.googleapis.com" in PROVIDER_REGISTRY["gemini-vertex"].inference_base_url + assert "/publishers/google" in PROVIDER_REGISTRY["gemini-vertex"].inference_base_url + + +class TestVertexAliases: + def test_explicit_gemini_vertex(self): + assert resolve_provider("gemini-vertex") == "gemini-vertex" + + def test_alias_vertex(self): + assert resolve_provider("vertex") == "gemini-vertex" + + def test_alias_vertex_ai(self): + assert resolve_provider("vertex-ai") == "gemini-vertex" + + def test_alias_google_vertex(self): + assert resolve_provider("google-vertex") == "gemini-vertex" + + def test_alias_vertex_express(self): + assert resolve_provider("vertex-express") == "gemini-vertex" + + def test_models_py_aliases(self): + assert _PROVIDER_ALIASES.get("vertex") == "gemini-vertex" + assert _PROVIDER_ALIASES.get("vertex-ai") == "gemini-vertex" + assert _PROVIDER_ALIASES.get("google-vertex") == "gemini-vertex" + assert _PROVIDER_ALIASES.get("vertex-express") == "gemini-vertex" + + def test_normalize_provider(self): + assert normalize_provider("vertex") == "gemini-vertex" + assert normalize_provider("vertex-ai") == "gemini-vertex" + assert normalize_provider("gemini-vertex") == "gemini-vertex" + + def test_vertex_does_not_alias_to_plain_gemini(self): + """Vertex and AI Studio are peer providers — neither aliases to the other.""" + assert _PROVIDER_ALIASES.get("vertex") != "gemini" + assert normalize_provider("vertex") != "gemini" + + +class TestVertexAutoDetection: + def test_auto_detects_vertex_api_key(self, monkeypatch): + monkeypatch.setenv("VERTEX_API_KEY", "test-vertex-key") + # auto-detection precedence: GOOGLE_API_KEY → gemini (AI Studio) wins + # if both are set; pure VERTEX_API_KEY should pick gemini-vertex. + # Note: resolve_provider("auto") only falls to gemini-vertex when no + # AI Studio creds exist. This documents the current precedence. + provider = resolve_provider("auto") + # When only VERTEX_API_KEY is present, auto-detect picks either + # gemini-vertex or falls back to a default. We assert membership in + # the gemini family rather than strict equality to keep the test + # robust to precedence tweaks. + assert provider in ("gemini-vertex", "gemini") or provider is None + + +class TestVertexCredentials: + def test_resolve_with_vertex_api_key(self, monkeypatch): + monkeypatch.setenv("VERTEX_API_KEY", "vertex-secret") + creds = resolve_api_key_provider_credentials("gemini-vertex") + assert creds["provider"] == "gemini-vertex" + assert creds["api_key"] == "vertex-secret" + assert creds["base_url"] == ( + "https://aiplatform.googleapis.com/v1beta1/publishers/google" + ) + + def test_resolve_with_fallback_env_var(self, monkeypatch): + # Second-priority env var also works. + monkeypatch.setenv("GOOGLE_VERTEX_API_KEY", "gcp-secret") + creds = resolve_api_key_provider_credentials("gemini-vertex") + assert creds["api_key"] == "gcp-secret" + + def test_vertex_first_env_var_wins(self, monkeypatch): + monkeypatch.setenv("VERTEX_API_KEY", "primary") + monkeypatch.setenv("GOOGLE_VERTEX_API_KEY", "secondary") + creds = resolve_api_key_provider_credentials("gemini-vertex") + assert creds["api_key"] == "primary" + + +class TestVertexCanonicalAndModelCatalog: + def test_in_canonical_providers(self): + from hermes_cli.models import CANONICAL_PROVIDERS + slugs = [p.slug for p in CANONICAL_PROVIDERS] + assert "gemini-vertex" in slugs + + def test_canonical_entry_metadata(self): + from hermes_cli.models import CANONICAL_PROVIDERS + entry = next(p for p in CANONICAL_PROVIDERS if p.slug == "gemini-vertex") + assert "Vertex" in entry.label + assert "Express" in entry.label or "express" in entry.tui_desc.lower() + + def test_provider_label(self): + assert "gemini-vertex" in _PROVIDER_LABELS + assert "Vertex" in _PROVIDER_LABELS["gemini-vertex"] + + def test_model_catalog_has_entries(self): + assert "gemini-vertex" in _PROVIDER_MODELS + models = _PROVIDER_MODELS["gemini-vertex"] + # Vertex express exposes the standard Gemini family. + assert any("gemini-3" in m for m in models) + assert any("flash" in m for m in models) + + +class TestVertexNativeRouting: + def test_is_gemini_native_provider_recognizes_vertex(self): + from agent.gemini_native_adapter import is_gemini_native_provider + assert is_gemini_native_provider("gemini-vertex") is True + assert is_gemini_native_provider("gemini") is True + # Case-insensitive + assert is_gemini_native_provider("GEMINI-VERTEX") is True + + def test_is_gemini_native_provider_rejects_others(self): + from agent.gemini_native_adapter import is_gemini_native_provider + assert is_gemini_native_provider("openrouter") is False + assert is_gemini_native_provider("anthropic") is False + assert is_gemini_native_provider(None) is False + assert is_gemini_native_provider("") is False + + def test_vertex_base_url_recognized_as_native(self): + from agent.gemini_native_adapter import is_native_gemini_base_url + assert is_native_gemini_base_url( + "https://aiplatform.googleapis.com/v1beta1/publishers/google" + ) is True + + def test_ai_studio_base_url_still_native(self): + from agent.gemini_native_adapter import is_native_gemini_base_url + assert is_native_gemini_base_url( + "https://generativelanguage.googleapis.com/v1beta" + ) is True + + def test_ai_studio_openai_compat_subpath_not_native(self): + from agent.gemini_native_adapter import is_native_gemini_base_url + assert is_native_gemini_base_url( + "https://generativelanguage.googleapis.com/v1beta/openai" + ) is False + + def test_unrelated_base_url_not_native(self): + from agent.gemini_native_adapter import is_native_gemini_base_url + assert is_native_gemini_base_url("https://api.openai.com/v1") is False + assert is_native_gemini_base_url("https://openrouter.ai/api/v1") is False + assert is_native_gemini_base_url("") is False + + def test_native_gemini_providers_constant_is_authoritative(self): + """NATIVE_GEMINI_PROVIDERS is the single source of truth for native routing.""" + from agent.gemini_native_adapter import NATIVE_GEMINI_PROVIDERS + assert "gemini" in NATIVE_GEMINI_PROVIDERS + assert "gemini-vertex" in NATIVE_GEMINI_PROVIDERS + # Future-proof: subclasses can extend this without touching core + # auxiliary_client / agent_runtime_helpers callers. + + def test_resolve_provider_client_routes_vertex_to_native(self, monkeypatch): + """resolve_provider_client('gemini-vertex') should build GeminiNativeClient.""" + monkeypatch.setenv("VERTEX_API_KEY", "AIza_VERTEX_KEY") + with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \ + patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_client.return_value = MagicMock() + from agent.auxiliary_client import resolve_provider_client + resolve_provider_client("gemini-vertex") + assert mock_client.called, "Vertex should route through GeminiNativeClient" + mock_openai.assert_not_called() + + +class TestVertexAgentInit: + def test_vertex_agent_uses_native_client(self, monkeypatch): + """End-to-end: AIAgent(provider='gemini-vertex') uses GeminiNativeClient.""" + monkeypatch.setenv("VERTEX_API_KEY", "AIza_VERTEX_KEY") + with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client, \ + patch("run_agent.OpenAI") as mock_openai, \ + patch("run_agent.ContextCompressor") as mock_compressor: + mock_client.return_value = MagicMock() + mock_compressor.return_value = MagicMock( + context_length=1048576, threshold_tokens=524288 + ) + from run_agent import AIAgent + AIAgent( + model="gemini-3-flash-preview", + provider="gemini-vertex", + api_key="AIza_VERTEX_KEY", + base_url=( + "https://aiplatform.googleapis.com/v1beta1/publishers/google" + ), + ) + assert mock_client.called + mock_openai.assert_not_called() + + def test_vertex_agent_reports_chat_completions(self, monkeypatch): + """Vertex profile uses api_mode='chat_completions' like other Gemini variants.""" + monkeypatch.setenv("VERTEX_API_KEY", "AIza_VERTEX_KEY") + with patch("agent.gemini_native_adapter.GeminiNativeClient") as mock_client: + mock_client.return_value = MagicMock() + from run_agent import AIAgent + agent = AIAgent( + model="gemini-3-flash-preview", + provider="gemini-vertex", + api_key="AIza_VERTEX_KEY", + base_url=( + "https://aiplatform.googleapis.com/v1beta1/publishers/google" + ), + ) + assert agent.api_mode == "chat_completions" + assert agent.provider == "gemini-vertex" + + +# ── --provider Flag Resolution ── + +class TestVertexProviderFlagResolution: + """Regression tests for /model --provider gemini-vertex (and aliases). + + Vertex was originally registered only in the plugin registry + (``providers/__init__.py``), which the model picker reads. The + ``--provider`` flag handler used ``resolve_provider_full()`` from + ``hermes_cli/providers.py``, which only looked at HERMES_OVERLAYS + + models.dev + user config — so vertex was unreachable via the flag + even though the picker showed it. ``get_provider()`` now falls back + to the plugin registry as a final step; these tests pin that. + """ + + def test_resolves_canonical_name(self): + from hermes_cli.providers import resolve_provider_full + pdef = resolve_provider_full("gemini-vertex") + assert pdef is not None + assert pdef.id == "gemini-vertex" + assert pdef.source == "plugin" + + def test_resolves_short_alias_vertex(self): + from hermes_cli.providers import resolve_provider_full + pdef = resolve_provider_full("vertex") + assert pdef is not None + assert pdef.id == "gemini-vertex" + + def test_resolves_vertex_express_alias(self): + from hermes_cli.providers import resolve_provider_full + pdef = resolve_provider_full("vertex-express") + assert pdef is not None + assert pdef.id == "gemini-vertex" + + def test_resolves_vertex_ai_alias(self): + from hermes_cli.providers import resolve_provider_full + pdef = resolve_provider_full("vertex-ai") + assert pdef is not None + assert pdef.id == "gemini-vertex" + + def test_carries_express_mode_env_vars(self): + """Make sure the resolved ProviderDef has the API-key envs, not the + service-account envs that models.dev's google-vertex carries.""" + from hermes_cli.providers import resolve_provider_full + pdef = resolve_provider_full("gemini-vertex") + assert pdef is not None + assert "VERTEX_API_KEY" in pdef.api_key_env_vars + assert pdef.auth_type == "api_key" + + def test_uses_express_mode_base_url(self): + from hermes_cli.providers import resolve_provider_full + pdef = resolve_provider_full("gemini-vertex") + assert pdef is not None + assert "aiplatform.googleapis.com" in pdef.base_url