diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4968f738392..99c6c8d2695 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -603,6 +603,7 @@ from hermes_cli.model_setup_flows import ( _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, @@ -3605,279 +3606,6 @@ _DEFAULT_QWEN_PORTAL_MODELS = [ ] - - - - - - -def _model_flow_google_antigravity(_config, current_model=""): - """Google Antigravity OAuth via Antigravity Code Assist.""" - from hermes_cli.auth import ( - DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL, - get_antigravity_oauth_auth_status, - resolve_antigravity_oauth_runtime_credentials, - _prompt_model_selection, - _save_model_choice, - _update_config_for_provider, - ) - from hermes_cli.models import provider_model_ids - - status = get_antigravity_oauth_auth_status() - if not status.get("logged_in"): - try: - from agent.antigravity_oauth import resolve_project_id_from_env, start_oauth_flow - - env_project = resolve_project_id_from_env() - start_oauth_flow(force_relogin=True, project_id=env_project) - except Exception as exc: - print(f"OAuth login failed: {exc}") - return - - try: - creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=False) - project_id = creds.get("project_id", "") - if project_id: - print(f" Using Antigravity project: {project_id}") - except Exception as exc: - print(f"Failed to resolve Antigravity credentials: {exc}") - return - - models = provider_model_ids("google-antigravity") - default = current_model or (models[0] if models else "gemini-3-flash-agent") - selected = _prompt_model_selection(models, current_model=default) - if selected: - _save_model_choice(selected) - _update_config_for_provider( - "google-antigravity", DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL - ) - print( - f"Default model set to: {selected} (via Google Antigravity OAuth / Code Assist)" - ) - else: - print("No change.") - - -def _model_flow_custom(config): - """Custom endpoint: collect URL, API key, and model name. - - Automatically saves the endpoint to ``custom_providers`` in config.yaml - so it appears in the provider menu on subsequent runs. - """ - from hermes_cli.auth import _save_model_choice, deactivate_provider - from hermes_cli.config import get_env_value, load_config, save_config - - current_url = get_env_value("OPENAI_BASE_URL") or "" - current_key = get_env_value("OPENAI_API_KEY") or "" - - print("Custom OpenAI-compatible endpoint configuration:") - if current_url: - print(f" Current URL: {current_url}") - if current_key: - print(f" Current key: {current_key[:8]}...") - print() - - try: - base_url = input( - f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: " - ).strip() - import getpass - - api_key = getpass.getpass( - f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: " - ).strip() - except (KeyboardInterrupt, EOFError): - print("\nCancelled.") - return - - if not base_url and not current_url: - print("No URL provided. Cancelled.") - return - - # Validate URL format - effective_url = base_url or current_url - if not effective_url.startswith(("http://", "https://")): - print(f"Invalid URL: {effective_url} (must start with http:// or https://)") - return - - effective_key = api_key or current_key - - # Hint: most local model servers (Ollama, vLLM, llama.cpp) require /v1 - # in the base URL for OpenAI-compatible chat completions. Prompt the - # user if the URL looks like a local server without /v1. - _url_lower = effective_url.rstrip("/").lower() - _looks_local = any( - h in _url_lower - for h in ("localhost", "127.0.0.1", "0.0.0.0", ":11434", ":8080", ":5000") - ) - if _looks_local and not _url_lower.endswith("/v1"): - print() - print(f" Hint: Did you mean to add /v1 at the end?") - print(f" Most local model servers (Ollama, vLLM, llama.cpp) require it.") - print(f" e.g. {effective_url.rstrip('/')}/v1") - try: - _add_v1 = input(" Add /v1? [Y/n]: ").strip().lower() - except (KeyboardInterrupt, EOFError): - _add_v1 = "n" - if _add_v1 in {"", "y", "yes"}: - effective_url = effective_url.rstrip("/") + "/v1" - if base_url: - base_url = effective_url - print(f" Updated URL: {effective_url}") - print() - - from hermes_cli.models import probe_api_models - - probe = probe_api_models(effective_key, effective_url) - if probe.get("used_fallback") and probe.get("resolved_base_url"): - print( - f"Warning: endpoint verification worked at {probe['resolved_base_url']}/models, " - f"not the exact URL you entered. Saving the working base URL instead." - ) - effective_url = probe["resolved_base_url"] - if base_url: - base_url = effective_url - elif probe.get("models") is not None: - print( - f"Verified endpoint via {probe.get('probed_url')} " - f"({len(probe.get('models') or [])} model(s) visible)" - ) - else: - print( - f"Warning: could not verify this endpoint via {probe.get('probed_url')}. " - f"Hermes will still save it." - ) - if probe.get("suggested_base_url"): - suggested = probe["suggested_base_url"] - if suggested.endswith("/v1"): - print( - f" If this server expects /v1 in the path, try base URL: {suggested}" - ) - else: - print(f" If /v1 should not be in the base URL, try: {suggested}") - - # Prompt for API compatibility mode explicitly so codex-compatible custom - # providers don't silently fall back to chat_completions. - current_model_cfg = config.get("model") - current_api_mode = "" - if isinstance(current_model_cfg, dict): - current_api_mode = str(current_model_cfg.get("api_mode") or "").strip() - api_mode = _prompt_custom_api_mode_selection( - effective_url, - current_api_mode=current_api_mode, - ) - if api_mode: - print(f" API mode: {api_mode}") - else: - print(" API mode: auto-detect") - - # Select model — use probe results when available, fall back to manual input - model_name = "" - detected_models = probe.get("models") or [] - try: - if len(detected_models) == 1: - print(f" Detected model: {detected_models[0]}") - confirm = input(" Use this model? [Y/n]: ").strip().lower() - if confirm in {"", "y", "yes"}: - model_name = detected_models[0] - else: - model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() - elif len(detected_models) > 1: - print(" Available models:") - for i, m in enumerate(detected_models, 1): - print(f" {i}. {m}") - pick = input( - f" Select model [1-{len(detected_models)}] or type name: " - ).strip() - if pick.isdigit() and 1 <= int(pick) <= len(detected_models): - model_name = detected_models[int(pick) - 1] - elif pick: - model_name = pick - else: - model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() - - context_length_str = input( - "Context length in tokens [leave blank for auto-detect]: " - ).strip() - - # Prompt for a display name — shown in the provider menu on future runs - default_name = _auto_provider_name(effective_url) - display_name = input(f"Display name [{default_name}]: ").strip() or default_name - except (KeyboardInterrupt, EOFError): - print("\nCancelled.") - return - - context_length = None - if context_length_str: - try: - context_length = int( - context_length_str.replace(",", "") - .replace("k", "000") - .replace("K", "000") - ) - if context_length <= 0: - context_length = None - except ValueError: - print(f"Invalid context length: {context_length_str} — will auto-detect.") - context_length = None - - if model_name: - _save_model_choice(model_name) - - # Update config and deactivate any OAuth provider - cfg = load_config() - model = cfg.get("model") - if not isinstance(model, dict): - model = {"default": model} if model else {} - cfg["model"] = model - model["provider"] = "custom" - model["base_url"] = effective_url - if effective_key: - model["api_key"] = effective_key - if api_mode: - model["api_mode"] = api_mode - else: - model.pop("api_mode", None) - save_config(cfg) - deactivate_provider() - - # Sync the caller's config dict so the setup wizard's final - # save_config(config) preserves our model settings. Without - # this, the wizard overwrites model.provider/base_url with - # the stale values from its own config dict (#4172). - config["model"] = dict(model) - - print(f"Default model set to: {model_name} (via {effective_url})") - else: - if base_url or api_key: - deactivate_provider() - # Even without a model name, persist the custom endpoint on the - # caller's config dict so the setup wizard doesn't lose it. - _caller_model = config.get("model") - if not isinstance(_caller_model, dict): - _caller_model = {"default": _caller_model} if _caller_model else {} - _caller_model["provider"] = "custom" - _caller_model["base_url"] = effective_url - if effective_key: - _caller_model["api_key"] = effective_key - if api_mode: - _caller_model["api_mode"] = api_mode - else: - _caller_model.pop("api_mode", None) - config["model"] = _caller_model - print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") - - # Auto-save to custom_providers so it appears in the menu next time - _save_custom_provider( - effective_url, - effective_key, - model_name or "", - context_length=context_length, - name=display_name, - api_mode=api_mode, - ) - - def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]: """Prompt for a custom provider API mode. diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 8148abba0f0..29fcbe403a5 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -712,6 +712,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 a507b830387..e57ffa3da0b 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1804,6 +1804,15 @@ _AGGREGATOR_PROVIDERS = frozenset( {"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, @@ -1841,7 +1850,11 @@ def _resolve_static_model_alias( 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 @@ -1850,6 +1863,13 @@ def _resolve_static_model_alias( if provider in current_keys and (matched := _match(provider)): return provider, matched + # Last resort: providers that re-expose other vendors' models (e.g. + # google-antigravity serving Claude). Only reached when no native-vendor + # catalog matched — so `sonnet` resolves to anthropic, not antigravity. + for provider in _BORROWED_MODEL_PROVIDERS: + if provider in current_keys and (matched := _match(provider)): + return provider, matched + return None @@ -1896,11 +1916,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