mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-12 03:42:08 +00:00
feat: provider modules — ProviderProfile ABC, 33 providers, fetch_models, transport single-path
Introduces providers/ package — single source of truth for every inference provider. Adding a simple api-key provider now requires one providers/<name>.py file with zero edits anywhere else. What this PR ships: - providers/ package (ProviderProfile ABC + 33 profiles across 4 api_modes) - ProviderProfile declarative fields: name, api_mode, aliases, display_name, env_vars, base_url, models_url, auth_type, fallback_models, hostname, default_headers, fixed_temperature, default_max_tokens, default_aux_model - 4 overridable hooks: prepare_messages, build_extra_body, build_api_kwargs_extras, fetch_models - chat_completions.build_kwargs: profile path via _build_kwargs_from_profile, legacy flag path retained for lmstudio/tencent-tokenhub (which have session-aware reasoning probing that doesn't map cleanly to hooks yet) - run_agent.py: profile path for all registered providers; legacy path variable scoping fixed (all flags defined before branching) - Auto-wires: auth.PROVIDER_REGISTRY, models.CANONICAL_PROVIDERS, doctor health checks, config.OPTIONAL_ENV_VARS, model_metadata._URL_TO_PROVIDER - GeminiProfile: thinking_config translation (native + openai-compat nested) - New tests/providers/ (79 tests covering profile declarations, transport parity, hook overrides, e2e kwargs assembly) Deltas vs original PR (salvaged onto current main): - Added profiles: alibaba-coding-plan, azure-foundry, minimax-oauth (were added to main since original PR) - Skipped profiles: lmstudio, tencent-tokenhub stay on legacy path (their reasoning_effort probing has no clean hook equivalent yet) - Removed lmstudio alias from custom profile (it's a separate provider now) - Skipped openrouter/custom from PROVIDER_REGISTRY auto-extension (resolve_provider special-cases them; adding breaks runtime resolution) - runtime_provider: profile.api_mode only as fallback when URL detection finds nothing (was breaking minimax /v1 override) - Preserved main's legacy-path improvements: deepseek reasoning_content preserve, gemini Gemma skip, OpenRouter response caching, Anthropic 1M beta recovery, etc. - Kept agent/copilot_acp_client.py in place (rejected PR's relocation — main has 7 fixes landed since; relocation would revert them) - _API_KEY_PROVIDER_AUX_MODELS alias kept for backward compat with existing test imports Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Closes #14418
This commit is contained in:
parent
2b500ed68a
commit
20a4f79ed1
57 changed files with 3149 additions and 177 deletions
|
|
@ -416,6 +416,40 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
|||
),
|
||||
}
|
||||
|
||||
# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
|
||||
# providers/ that is not already declared above. New providers only need a
|
||||
# providers/*.py file — no edits to this file required.
|
||||
try:
|
||||
from providers import list_providers as _list_providers_for_registry
|
||||
for _pp in _list_providers_for_registry():
|
||||
if _pp.name in PROVIDER_REGISTRY:
|
||||
continue
|
||||
if _pp.auth_type != "api_key" or not _pp.env_vars:
|
||||
continue
|
||||
# Skip providers that need custom token resolution or are special-cased
|
||||
# in resolve_provider() (copilot/kimi/zai have bespoke token refresh;
|
||||
# openrouter/custom are aggregator/user-supplied and handled outside
|
||||
# the registry — adding them here breaks runtime_provider resolution
|
||||
# that relies on `openrouter not in PROVIDER_REGISTRY`).
|
||||
if _pp.name in {"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"}:
|
||||
continue
|
||||
_api_key_vars = tuple(v for v in _pp.env_vars if not v.endswith("_BASE_URL") and not v.endswith("_URL"))
|
||||
_base_url_var = next((v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), None)
|
||||
PROVIDER_REGISTRY[_pp.name] = ProviderConfig(
|
||||
id=_pp.name,
|
||||
name=_pp.display_name or _pp.name,
|
||||
auth_type="api_key",
|
||||
inference_base_url=_pp.base_url,
|
||||
api_key_env_vars=_api_key_vars or _pp.env_vars,
|
||||
base_url_env_var=_base_url_var or "",
|
||||
)
|
||||
# Also register aliases so resolve_provider() resolves them
|
||||
for _alias in _pp.aliases:
|
||||
if _alias not in PROVIDER_REGISTRY:
|
||||
PROVIDER_REGISTRY[_alias] = PROVIDER_REGISTRY[_pp.name]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Anthropic Key Helper
|
||||
|
|
@ -1195,6 +1229,17 @@ def resolve_provider(
|
|||
"vllm": "custom", "llamacpp": "custom",
|
||||
"llama.cpp": "custom", "llama-cpp": "custom",
|
||||
}
|
||||
# Extend with aliases declared in providers/*.py that aren't already mapped.
|
||||
# This keeps providers/ as the single source for new aliases while the
|
||||
# hardcoded dict above remains authoritative for existing ones.
|
||||
try:
|
||||
from providers import list_providers as _lp
|
||||
for _pp in _lp():
|
||||
for _alias in _pp.aliases:
|
||||
if _alias not in _PROVIDER_ALIASES:
|
||||
_PROVIDER_ALIASES[_alias] = _pp.name
|
||||
except Exception:
|
||||
pass
|
||||
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
|
||||
|
||||
if normalized == "openrouter":
|
||||
|
|
|
|||
|
|
@ -4840,3 +4840,45 @@ def config_command(args):
|
|||
print(" hermes config path Show config file path")
|
||||
print(" hermes config env-path Show .env file path")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ── Profile-driven env var injection ─────────────────────────────────────────
|
||||
# Any provider registered in providers/ with auth_type="api_key" automatically
|
||||
# gets its env_vars exposed in OPTIONAL_ENV_VARS without editing this file.
|
||||
# Runs once at import time.
|
||||
|
||||
_profile_env_vars_injected = False
|
||||
|
||||
|
||||
def _inject_profile_env_vars() -> None:
|
||||
"""Populate OPTIONAL_ENV_VARS from provider profiles not already listed.
|
||||
|
||||
Called once at module load time. Idempotent — repeated calls are no-ops.
|
||||
"""
|
||||
global _profile_env_vars_injected
|
||||
if _profile_env_vars_injected:
|
||||
return
|
||||
_profile_env_vars_injected = True
|
||||
try:
|
||||
from providers import list_providers
|
||||
for _pp in list_providers():
|
||||
if _pp.auth_type not in ("api_key",):
|
||||
continue
|
||||
for _var in _pp.env_vars:
|
||||
if _var in OPTIONAL_ENV_VARS:
|
||||
continue
|
||||
_is_key = not _var.endswith("_BASE_URL") and not _var.endswith("_URL")
|
||||
OPTIONAL_ENV_VARS[_var] = {
|
||||
"description": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL override'}",
|
||||
"prompt": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL (leave empty for default)'}",
|
||||
"url": _pp.signup_url or None,
|
||||
"password": _is_key,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
|
||||
_inject_profile_env_vars()
|
||||
|
|
|
|||
|
|
@ -169,6 +169,85 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
|
|||
check_warn("Could not verify systemd linger", f"({linger_detail})")
|
||||
|
||||
|
||||
_APIKEY_PROVIDERS_CACHE: list | None = None
|
||||
|
||||
|
||||
def _build_apikey_providers_list() -> list:
|
||||
"""Build the API-key provider health-check list once and cache it.
|
||||
|
||||
Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)
|
||||
Base list augmented with any ProviderProfile with auth_type="api_key" not
|
||||
already present — adding providers/*.py is sufficient to get into doctor.
|
||||
"""
|
||||
_static = [
|
||||
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
|
||||
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
|
||||
("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
|
||||
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
|
||||
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
|
||||
("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
|
||||
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
|
||||
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
|
||||
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
|
||||
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
|
||||
# MiniMax global: /v1 endpoint supports /models.
|
||||
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
|
||||
# MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
|
||||
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
|
||||
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
|
||||
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
|
||||
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
|
||||
# OpenCode Go has no shared /models endpoint; skip the health check.
|
||||
("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False),
|
||||
]
|
||||
_known_names = {t[0] for t in _static}
|
||||
# Also index by profile canonical name so profiles without display_name
|
||||
# don't create duplicate entries for providers already in the static list.
|
||||
_known_canonical: set[str] = set()
|
||||
_name_to_canonical = {
|
||||
"Z.AI / GLM": "zai", "Kimi / Moonshot": "kimi-coding",
|
||||
"StepFun Step Plan": "stepfun", "Kimi / Moonshot (China)": "kimi-coding-cn",
|
||||
"Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek",
|
||||
"Hugging Face": "huggingface", "NVIDIA NIM": "nvidia",
|
||||
"Alibaba/DashScope": "alibaba", "MiniMax": "minimax",
|
||||
"MiniMax (China)": "minimax-cn", "Vercel AI Gateway": "ai-gateway",
|
||||
"Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen",
|
||||
"OpenCode Go": "opencode-go",
|
||||
}
|
||||
for _label, _canonical in _name_to_canonical.items():
|
||||
_known_canonical.add(_canonical)
|
||||
try:
|
||||
from providers import list_providers
|
||||
from providers.base import ProviderProfile as _PP
|
||||
for _pp in list_providers():
|
||||
if not isinstance(_pp, _PP) or _pp.auth_type != "api_key" or not _pp.env_vars:
|
||||
continue
|
||||
_label = _pp.display_name or _pp.name
|
||||
if _label in _known_names or _pp.name in _known_canonical:
|
||||
continue
|
||||
# Separate API-key vars from base-URL override vars — the health-check
|
||||
# loop sends the first found value as Authorization: Bearer, so a URL
|
||||
# string must never be picked.
|
||||
_key_vars = tuple(
|
||||
v for v in _pp.env_vars
|
||||
if not v.endswith("_BASE_URL") and not v.endswith("_URL")
|
||||
)
|
||||
_base_var = next(
|
||||
(v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")),
|
||||
None,
|
||||
)
|
||||
if not _key_vars:
|
||||
continue
|
||||
_models_url = (
|
||||
(_pp.models_url or (_pp.base_url.rstrip("/") + "/models"))
|
||||
if _pp.base_url else None
|
||||
)
|
||||
_static.append((_label, _key_vars, _models_url, _base_var, True))
|
||||
except Exception:
|
||||
pass
|
||||
return _static
|
||||
|
||||
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
|
|
@ -1081,27 +1160,11 @@ def run_doctor(args):
|
|||
# -- API-key providers --
|
||||
# Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)
|
||||
# If supports_models_endpoint is False, we skip the health check and just show "configured"
|
||||
_apikey_providers = [
|
||||
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
|
||||
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
|
||||
("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
|
||||
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
|
||||
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
|
||||
("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
|
||||
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
|
||||
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
|
||||
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
|
||||
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
|
||||
# MiniMax global: /v1 endpoint supports /models.
|
||||
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
|
||||
# MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
|
||||
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
|
||||
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
|
||||
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
|
||||
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
|
||||
# OpenCode Go has no shared /models endpoint; skip the health check.
|
||||
("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False),
|
||||
]
|
||||
# Cached at module level after first build — profiles auto-extend it.
|
||||
global _APIKEY_PROVIDERS_CACHE
|
||||
if _APIKEY_PROVIDERS_CACHE is None:
|
||||
_APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()
|
||||
_apikey_providers = _APIKEY_PROVIDERS_CACHE
|
||||
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
|
||||
_key = ""
|
||||
for _ev in _env_vars:
|
||||
|
|
|
|||
|
|
@ -1611,6 +1611,21 @@ def cmd_model(args):
|
|||
select_provider_and_model(args=args)
|
||||
|
||||
|
||||
def _is_profile_api_key_provider(provider_id: str) -> bool:
|
||||
"""Return True when provider_id maps to a profile with auth_type='api_key'.
|
||||
|
||||
Used as a catch-all in select_provider_and_model() so that new providers
|
||||
declared in providers/*.py automatically dispatch to _model_flow_api_key_provider
|
||||
without requiring an explicit elif branch here.
|
||||
"""
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
_p = get_provider_profile(provider_id)
|
||||
return _p is not None and _p.auth_type == "api_key"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def select_provider_and_model(args=None):
|
||||
"""Core provider selection + model picking logic.
|
||||
|
||||
|
|
@ -1907,7 +1922,7 @@ def select_provider_and_model(args=None):
|
|||
"ollama-cloud",
|
||||
"tencent-tokenhub",
|
||||
"lmstudio",
|
||||
):
|
||||
) or _is_profile_api_key_provider(selected_provider):
|
||||
_model_flow_api_key_provider(config, selected_provider, current_model)
|
||||
|
||||
# ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
|
||||
|
|
@ -8215,6 +8230,22 @@ def cmd_logs(args):
|
|||
)
|
||||
|
||||
|
||||
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", "copilot-acp", "copilot",
|
||||
"anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry",
|
||||
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
|
||||
"stepfun", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee",
|
||||
"nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
|
|
|||
|
|
@ -806,6 +806,25 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
|||
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway"),
|
||||
]
|
||||
|
||||
# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/
|
||||
# that is not already in the list above. Adding providers/*.py is sufficient
|
||||
# to expose a new provider in the model picker, /model, and all downstream
|
||||
# consumers — no edits to this file needed.
|
||||
_canonical_slugs = {p.slug for p in CANONICAL_PROVIDERS}
|
||||
try:
|
||||
from providers import list_providers as _list_providers_for_canonical
|
||||
for _pp in _list_providers_for_canonical():
|
||||
if _pp.name in _canonical_slugs:
|
||||
continue
|
||||
if _pp.auth_type in ("oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot"):
|
||||
continue # non-api-key flows need bespoke picker UX; skip auto-inject
|
||||
_label = _pp.display_name or _pp.name
|
||||
_desc = _pp.description or f"{_label} (direct API)"
|
||||
CANONICAL_PROVIDERS.append(ProviderEntry(_pp.name, _label, _desc))
|
||||
_canonical_slugs.add(_pp.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Derived dicts — used throughout the codebase
|
||||
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
|
||||
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
|
||||
|
|
@ -2023,6 +2042,34 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
|||
return ids
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Profile-based generic live fetch (all simple api-key providers) ──
|
||||
# Handles any provider registered in providers/ with auth_type="api_key".
|
||||
# Replaces per-provider copy-paste blocks (stepfun, gmi, zai, etc.).
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
|
||||
_p = get_provider_profile(normalized)
|
||||
if _p and _p.auth_type == "api_key" and _p.base_url:
|
||||
try:
|
||||
creds = resolve_api_key_provider_credentials(normalized)
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
base_url = str(creds.get("base_url") or "").strip()
|
||||
except Exception:
|
||||
api_key, base_url = "", _p.base_url
|
||||
if not base_url:
|
||||
base_url = _p.base_url
|
||||
if api_key:
|
||||
live = _p.fetch_models(api_key=api_key)
|
||||
if live:
|
||||
return live
|
||||
# Use profile's fallback_models if defined
|
||||
if _p.fallback_models:
|
||||
return list(_p.fallback_models)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
curated_static = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
if normalized in _MODELS_DEV_PREFERRED:
|
||||
return _merge_with_models_dev(normalized, curated_static)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue