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.
This commit is contained in:
Teknium 2026-06-21 15:51:37 -07:00
parent 37c37c9dc5
commit c768c4b71c
3 changed files with 93 additions and 275 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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