Provider transport and profile infrastructure refactor

NousResearch/hermes-agent · cluster 13473

Data as of

Nothing open on GitHub. Fix merged in #20324. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #20324. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #20324. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern an ongoing multi-cycle refactor introducing a ProviderProfile abstract base class, pluggable provider modules, and unified transport layers (ChatCompletions and ResponsesApi) across a set of AI providers. The work spans architecture design, tracking issues, and implementation across at least two refactor cycles.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #20324 feat(providers): ProviderProfile ABC + plugins/model-providers/ (salvage of #14424 + pluggable migration)

    merged to main

    files: agent/auxiliary_client.py, agent/model_metadata.py, agent/transports/__init__.py, agent/transports/chat_completions.py, agent/transports/types.py, hermes_cli/auth.py, hermes_cli/config.py, hermes_cli/doctor.py, hermes_cli/main.py, hermes_cli/models.py, hermes_cli/plugins.py, plugins/model-providers/README.md, plugins/model-providers/ai-gateway/__init__.py, plugins/model-providers/ai-gateway/plugin.yaml, plugins/model-providers/alibaba-coding-plan/__init__.py, plugins/model-providers/alibaba-coding-plan/plugin.yaml, plugins/model-providers/alibaba/__init__.py, plugins/model-providers/alibaba/plugin.yaml, plugins/model-providers/anthropic/__init__.py, plugins/model-providers/anthropic/plugin.yaml (+68 more)

    Show the diff
    • agent/auxiliary_client.py
      @@ -216,7 +216,26 @@ def _fixed_temperature_for_model(
           return None
       
       # Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
      -_API_KEY_PROVIDER_AUX_MODELS: [redacted:secret-assignment] str] = {
      +def _get_aux_model_for_provider(provider_id: str) -> str:
      +    """Return the cheap auxiliary model for a provider.
      +
      +    Reads from ProviderProfile.default_aux_model first, falling back to the
      +    legacy hardcoded dict for providers that predate the profiles system.
      +    """
      +    try:
      +        from providers import get_provider_profile
      +        _p = get_provider_profile(provider_id)
      +        if _p and _p.default_aux_model:
      +            return _p.default_aux_model
      +    except Exception:
      +        pass
      +    return _API_KEY_PROVIDER_AUX_MODELS_FALLBACK.get(provider_id, "")
      +
      +
      +# Fallback for providers not yet migrated to ProviderProfile.default_aux_model,
      +# plus providers we intentionally keep pinned here (e.g. Anthropic predates
      +# profiles). New providers should set default_aux_model on their profile instead.
      +_API_KEY_PROVIDER_AUX_MODELS_FALLBACK: [redacted:secret-assignment] str] = {
           "gemini": "gemini-3-flash-preview",
           "zai": "glm-4.5-flash",
           "kimi-coding": "kimi-k2-turbo-preview",
      @@ -235,6 +254,10 @@ def _fixed_temperature_for_model(
           "tencent-tokenhub": "hy3-preview",
       }
       
      +# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
      +# can still use this dict directly. Kept in sync with _FALLBACK above.
      +_API_KEY_PROVIDER_AUX_MODELS: [redacted:secret-assignment] str] = _API_KEY_PROVIDER_AUX_MODELS_FALLBACK
      +
       # Vision-specific model overrides for direct providers.
       # When the user's main provider has a dedicated vision/multimodal model that
       # differs from their main chat model, map it here.  The vision auto-detect
      @@ -1155,7 +1178,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
       
                   raw_base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
                   base_url = _to_openai_base_url(raw_base_url)
      -            model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
      +            model = _get_aux_model_for_provider(provider_id) or None
                   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)
      @@ -1171,6 +1194,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
                       from hermes_cli.models import copilot_default_headers
       
                       extra["default_headers"] = copilot_default_headers()
      +            else:
      +                try:
      +                    from providers import get_provider_profile as _gpf_aux
      +                    _ph_aux = _gpf_aux(provider_id)
      +                    if _ph_aux and _ph_aux.default_headers:
      +                        extra["default_headers"] = dict(_ph_aux.default_headers)
      +                except Exception:
      +                    pass
                   _client = OpenAI(api_key=[redacted:secret-assignment] base_url=base_url, **extra)
                   _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
                   return _client, model
      @@ -1182,7 +1213,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
       
               raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
               base_url = _to_openai_base_url(raw_base_url)
      -        model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
      +        model = _get_aux_model_for_provider(provider_id) or None
               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)
      @@ -1198,6 +1229,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
                   from hermes_cli.models import copilot_default_headers
       
                   extra["default_headers"] = copilot_default_headers()
      +        else:
      +            try:
      +                from providers import get_provider_profile as _gpf_aux2
      +                _ph_aux2 = _gpf_aux2(provider_id)
      +                if _ph_aux2 and _ph_aux2.default_headers:
      +                    extra["default_headers"] = dict(_ph_aux2.default_headers)
      +            except Exception:
      +                pass
               _client = OpenAI(api_key=[redacted:secret-assignment] base_url=base_url, **extra)
               _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
               return _client, model
      @@ -1570,7 +1609,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
       
           from agent.anthropic_adapter import _is_oauth_token
           is_oauth = _is_oauth_token(token)
      -    model = _API_KEY_PROVIDER_AUX_MODELS.get("anthropic", "claude-haiku-4-5-20251001")
      +    model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
           logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
           try:
               real_client = build_anthropic_client(token, base_url)
      @@ -2373,7 +2412,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
               if explicit_base_url:
                   base_url = _to_openai_base_url(explicit_base_url.strip().rstrip("/"))
       
      -        default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
      +        default_model = _get_aux_model_for_provider(provider)
               final_model = _normalize_resolved_model(model or default_model, provider)
       
               if provider == "gemini":
    • agent/model_metadata.py
      @@ -318,6 +318,17 @@ def _is_custom_endpoint(base_url: str) -> bool:
           "ollama.com": "ollama-cloud",
       }
       
      +# Auto-extend with hostnames derived from provider profiles.
      +# Any provider with a base_url not already in the map gets added automatically.
      +try:
      +    from providers import list_providers as _list_providers
      +    for _pp in _list_providers():
      +        _host = _pp.get_hostname()
      +        if _host and _host not in _URL_TO_PROVIDER:
      +            _URL_TO_PROVIDER[_host] = _pp.name
      +except Exception:
      +    pass
      +
       
       def _infer_provider_from_url(base_url: str) -> Optional[str]:
           """Infer the models.dev provider name from a base URL.
    • agent/transports/__init__.py
      @@ -6,9 +6,16 @@
           result = transport.normalize_response(raw_response)
       """
       
      -from agent.transports.types import NormalizedResponse, ToolCall, Usage, build_tool_call, map_finish_reason  # noqa: F401
      +from agent.transports.types import (
      +    NormalizedResponse,
      +    ToolCall,
      +    Usage,
      +    build_tool_call,
      +    map_finish_reason,
      +)  # noqa: F401
       
       _REGISTRY: dict = {}
      +_discovered: bool = False
       
       
       def register_transport(api_mode: str, transport_cls: type) -> None:
      @@ -23,6 +30,9 @@ def get_transport(api_mode: str):
           This allows gradual migration — call sites can check for None
           and fall back to the legacy code path.
           """
      +    global _discovered
      +    if not _discovered:
      +        _discover_transports()
           cls = _REGISTRY.get(api_mode)
           if cls is None:
               # The registry can be partially populated when a specific transport
      @@ -38,6 +48,8 @@ def get_transport(api_mode: str):
       
       def _discover_transports() -> None:
           """Import all transport modules to trigger auto-registration."""
      +    global _discovered
      +    _discovered = True
           try:
               import agent.transports.anthropic  # noqa: F401
           except ImportError:
    • agent/transports/chat_completions.py
      @@ -109,7 +109,9 @@ class ChatCompletionsTransport(ProviderTransport):
           def api_mode(self) -> str:
               return "chat_completions"
       
      -    def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
      +    def convert_messages(
      +        self, messages: list[dict[str, Any]], **kwargs
      +    ) -> list[dict[str, Any]]:
               """Messages are already in OpenAI format — sanitize Codex leaks only.
       
               Strips Codex Responses API fields (``codex_reasoning_items`` /
      @@ -126,7 +128,9 @@ def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> List[Dic
                   tool_calls = msg.get("tool_calls")
                   if isinstance(tool_calls, list):
                       for tc in tool_calls:
      -                    if isinstance(tc, dict) and ("call_id" in tc or "response_item_id" in tc):
      +                    if isinstance(tc, dict) and (
      +                        "call_id" in tc or "response_item_id" in tc
      +                    ):
                               needs_sanitize = True
                               break
                       if needs_sanitize:
      @@ -149,39 +153,41 @@ def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> List[Dic
                               tc.pop("response_item_id", None)
               return sanitized
       
      -    def convert_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
      +    def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
               """Tools are already in OpenAI format — identity."""
               return tools
       
           def build_kwargs(
               self,
               model: str,
      -        messages: List[Dict[str, Any]],
      -        tools: Optional[List[Dict[str, Any]]] = None,
      +        messages: list[dict[str, Any]],
      +        tools: list[dict[str, Any]] | None = None,
               **params,
      -    ) -> Dict[str, Any]:
      +    ) -> dict[str, Any]:
               """Build chat.completions.create() kwargs.
       
      -        This is the most complex transport method — it handles ~16 providers
      -        via params rather than subclasses.
      -
      -        params:
      +        params (all optional):
                   timeout: float — API call timeout
                   max_tokens: int | None — user-configured max tokens
      -            ephemeral_max_output_tokens: int | None — one-shot override (error recovery)
      +            ephemeral_max_output_tokens: int | None — one-shot override
                   max_tokens_param_fn: callable — returns {max_tokens: N} or {max_completion_tokens: N}
                   reasoning_config: dict | None
                   request_overrides: dict | None
                   session_id: str | None
      -            qwen_session_metadata: dict | None — {sessionId, promptId} precomputed
                   model_lower: str — lowercase model name for pattern matching
      -            # Provider detection flags (all optional, default False)
      +            # Provider profile path (all per-provider quirks live in providers/)
      +            provider_profile: ProviderProfile | None — when present, delegates to
      +                _build_kwargs_from_profile(); all flag params below are bypassed.
      +            # Legacy-path flags — only used when provider_profile is None
      +            # (i.e. custom / unregistered providers). Known providers all go
      +            # through provider_profile.
                   is_openrouter: bool
                   is_nous: bool
                   is_qwen_portal: bool
                   is_github_models: bool
                   is_nvidia_nim: bool
                   is_kimi: bool
      +            is_tokenhub: bool
                   is_lmstudio: bool
                   is_custom_provider: bool
                   ollama_num_ctx: int | None
      @@ -190,6 +196,7 @@ def build_kwargs(
                   # Qwen-specific
                   qwen_prepare_fn: callable | None — runs AFTER codex sanitization
                   qwen_prepare_inplace_fn: callable | None — in-place variant for deepcopied lists
      +            qwen_session_metadata: dict | None
                   # Temperature
                   fixed_temperature: Any — from _fixed_temperature_for_model()
                   omit_temperature: bool
      @@ -199,28 +206,21 @@ def build_kwargs(
                   lmstudio_reasoning_options: list[str] | None  # raw allowed_options from /api/v1/models
                   # Claude on OpenRouter/Nous max output
                   anthropic_max_output: int | None
      -            # Extra
      -            extra_body_additions: dict | None — pre-built extra_body entries
      +            extra_body_additions: dict | None
               """
               # Codex sanitization: drop reasoning_items / call_id / response_item_id
               sanitized = self.convert_messages(messages)
       
      -        # Qwen portal prep AFTER codex sanitization.  If sanitize already
      -        # deepcopied, reuse that copy via the in-place variant to avoid a
      -        # second deepcopy.
      -        is_qwen = params.get("is_qwen_portal", False)
      -        if is_qwen:
      -            qwen_prep = params.get("qwen_prepare_fn")
      -            qwen_prep_inplace = params.get("qwen_prepare_inplace_fn")
      -            if sanitized is messages:
      -                if qwen_prep is not None:
      -                    sanitized = qwen_prep(sanitized)
      -            else:
      -                # Already deepcopied — transform in place
      -                if qwen_prep_inplace is not None:
      -                    qwen_prep_inplace(sanitized)
      -                elif qwen_prep is not None:
      -                    sanitized = qwen_prep(sanitized)
      +        # ── Provider profile: single-path when present ──────────────────
      +        _profile = params.get("provider_profile")
      +        if _profile:
      +            return self._build_kwargs_from_profile(
      +                _profile, model, sanitized, tools, params
      +            )
      +
      +        # ── Legacy fallback (unregistered / unknown provider) ───────────
      +        # Reached only when get_provider_profile() returned None.
      +        # Known providers always go through the profile path above.
       
               # Developer role swap for GPT-5/Codex models
               model_lower = params.get("model_lower", (model or "").lower())
      @@ -233,7 +233,7 @@ def build_kwargs(
                   sanitized = list(sanitized)
                   sanitized[0] = {**sanitized[0], "role": "developer"}
       
      -        api_kwargs: Dict[str, Any] = {
      +        api_kwargs: dict[str, Any] = {
                   "model": model,
                   "messages": sanitized,
               }
      @@ -242,19 +242,6 @@ def build_kwargs(
               if timeout is not None:
                   api_kwargs["timeout"] = timeout
       
      -        # Temperature
      -        fixed_temp = params.get("fixed_temperature")
      -        omit_temp = params.get("omit_temperature", False)
      -        if omit_temp:
      -            api_kwargs.pop("temperature", None)
      -        elif fixed_temp is not None:
      -            api_kwargs["temperature"] = fixed_temp
      -
      -        # Qwen metadata (caller precomputes {sessionId, promptId})
      -        qwen_meta = params.get("qwen_session_metadata")
      -        if qwen_meta and is_qwen:
      -            api_kwargs["metadata"] = qwen_meta
      -
               # Tools
               if tools:
                   # Moonshot/Kimi uses a stricter flavored JSON Schema.  Rewriting
      @@ -278,13 +265,6 @@ def build_kwargs(
                   api_kwargs.update(max_tokens_fn(ephemeral))
               elif max_tokens is not None and max_tokens_fn:
                   [redacted:secret-assignment]
      -        elif is_nvidia_nim and max_tokens_fn:
      -            api_kwargs.update(max_tokens_fn(16384))
      -        elif is_qwen and max_tokens_fn:
      -            api_kwargs.update(max_tokens_fn(65536))
      -        elif is_kimi and max_tokens_fn:
      -            # Kimi/Moonshot: 32000 matches Kimi CLI's default
      -            api_kwargs.update(max_tokens_fn(32000))
               elif anthropic_max_out is not None:
                   api_kwargs["max_tokens"] = anthropic_max_out
       
      @@ -331,7 +311,7 @@ def build_kwargs(
                       api_kwargs["reasoning_effort"] = _lm_effort
       
               # extra_body assembly
      -        extra_body: Dict[str, Any] = {}
      +        extra_body: dict[str, Any] = {}
       
               is_openrouter = params.get("is_openrouter", False)
               is_nous = params.get("is_nous", False)
      @@ -361,35 +341,7 @@ def build_kwargs(
                       if gh_reasoning is not None:
                           extra_body["reasoning"] = gh_reasoning
                   else:
      -                if reasoning_config is not None:
      -                    rc = dict(reasoning_config)
      -                    if is_nous and rc.get("enabled") is False:
      -                        pass  # omit for Nous when disabled
      -                    else:
      -                        extra_body["reasoning"] = rc
      -                else:
      -                    extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
      -
      -        if is_nous:
      -            extra_body["tags"] = ["product=hermes-agent"]
      -
      -        # Ollama num_ctx
      -        ollama_ctx = params.get("ollama_num_ctx")
      -        if ollama_ctx:
      -            options = extra_body.get("options", {})
      -            options["num_ctx"] = ollama_ctx
      -            extra_body["options"] = options
      -
      -        # Ollama/custom think=false
      -        if params.get("is_custom_provider", False):
      -            if reasoning_config and isinstance(reasoning_config, dict):
      -                _effort = (reasoning_config.get("effort") or "").strip().lower()
      -                _enabled = reasoning_config.get("enabled", True)
      -                if _effort == "none" or _enabled is False:
      -                    extra_body["think"] = False
      -
      -        if is_qwen:
      -            extra_body["vl_high_resolution_images"] = True
      +                extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
       
               if provider_name == "gemini":
                   raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
      @@ -423,6 +375,120 @@ def build_kwargs(
       
               return api_kwargs
       
      +    def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
      +        """Build API kwargs using a ProviderProfile — single path, no legacy flags.
      +
      +        This method replaces the entire flag-based kwargs assembly when a
      +        provider_profile is passed. Every quirk comes from the profile object.
      +        """
      +        from providers.base import OMIT_TEMPERATURE
      +
      +        # Message preprocessing
      +        sanitized = profile.prepare_messages(sanitized)
      +
      +        # Developer role swap — model-name-based, applies to all providers
      +        _model_lower = (model or "").lower()
      +        if (
      +            sanitized
      +            and isinstance(sanitized[0], dict)
      +            and sanitized[0].get("role") == "system"
      +            and any(p in _model_lower for p in DEVELOPER_ROLE_MODELS)
      +        ):
      +            sanitized = list(sanitized)
      +            sanitized[0] = {**sanitized[0], "role": "developer"}
      +
      +        api_kwargs: dict[str, Any] = {
      +            "model": model,
      +            "messages": sanitized,
      +        }
      +
      +        # Temperature
      +        if profile.fixed_temperature is OMIT_TEMPERATURE:
      +            pass  # Don't include temperature at all
      +        elif profile.fixed_temperature is not None:
      +            api_kwargs["temperature"] = profile.fixed_temperature
      +        else:
      +            # Use caller's temperature if provided
      +            temp = params.get("temperature")
      +            if temp is not None:
      +                api_kwargs["temperature"] = temp
      +
      +        # Timeout
      +        timeout = params.get("timeout")
      +        if timeout is not None:
      +            api_kwargs["timeout"] = timeout
      +
      +        # Tools — apply Moonshot/Kimi schema sanitization regardless of path
      +        if tools:
      +            if is_moonshot_model(model):
      +                tools = sanitize_moonshot_tools(tools)
      +            api_kwargs["tools"] = tools
      +
      +        # max_tokens resolution — priority: ephemeral > user > profile default
      +        max_tokens_fn = [redacted:secret-assignment]"max_tokens_param_fn")
      +        ephemeral = params.get("ephemeral_max_output_tokens")
      +        user_max = params.get("max_tokens")
      +        anthropic_max = params.get("anthropic_max_output")
      +
      +        if ephemeral is not None and max_tokens_fn:
      +            api_kwargs.update(max_tokens_fn(ephemeral))
      +        elif user_max is not None and max_tokens_fn:
      +            api_kwargs.update(max_tokens_fn(user_max))
      +        elif profile.default_max_tokens and max_tokens_fn:
      +            api_kwargs.update(max_tokens_fn(profile.default_max_tokens))
      +        elif anthropic_max is not None:
      +            api_kwargs["max_tokens"] = anthropic_max
      +
      +        # Provider-specific api_kwargs extras (reasoning_effort, metadata, etc.)
      +        reasoning_config = params.get("reasoning_config")
      +        extra_body_from_profile, top_level_from_profile = (
      +            profile.build_api_kwargs_extras(
      +                reasoning_config=reasoning_config,
      +                supports_reasoning=params.get("supports_reasoning", False),
      +                qwen_session_metadata=params.get("qwen_session_metadata"),
      +                model=model,
      +                ollama_num_ctx=params.get("ollama_num_ctx"),
      +            )
      +        )
      +        api_kwargs.update(top_level_from_profile)
      +
      +        # extra_body assembly
      +        extra_body: dict[str, Any] = {}
      +
      +        # Profile's extra_body (tags, provider prefs, vl_high_resolution, etc.)
      +        profile_body = profile.build_extra_body(
      +            session_id=params.get("session_id"),
      +            provider_preferences=params.get("provider_preferences"),
      +            model=model,
      +            base_url=params.get("base_url"),
      +            reasoning_config=reasoning_config,
      +        )
      +        if profile_body:
      +            extra_body.update(profile_body)
      +
      +        # Profile's reasoning/thinking extra_body entries
      +        if extra_body_from_profile:
      +            extra_body.update(extra_body_from_profile)
      +
      +        # Merge any pre-built extra_body additions from the caller
      +        additions = params.get("extra_body_additions")
      +        if additions:
      +            extra_body.update(additions)
      +
      +        # Request overrides (user config)
      +        overrides = params.get("request_overrides")
      +        if overrides:
      +            for k, v in overrides.items():
      +                if k == "extra_body" and isinstance(v, dict):
      +                    extra_body.update(v)
      +                else:
      +                    api_kwargs[k] = v
      +
      +        if extra_body:
      +            api_kwargs["extra_body"] = extra_body
      +
      +        return api_kwargs
      +
           def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
               """Normalize OpenAI ChatCompletion to NormalizedResponse.
       
      @@ -444,7 +510,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
                       # Gemini 3 thinking models attach extra_content with
                       # thought_signature — without replay on the next turn the API
                       # rejects the request with 400.
      -                tc_provider_data: Dict[str, Any] = {}
      +                tc_provider_data: dict[str, Any] = {}
                       extra = getattr(tc, "extra_content", None)
                       if extra is None and hasattr(tc, "model_extra"):
                           extra = (tc.model_extra or {}).get("extra_content")
      @@ -455,12 +521,14 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
                               except Exception:
                                   pass
                           tc_provider_data["extra_content"] = extra
      -                tool_calls.append(ToolCall(
      -                    id=tc.id,
      -                    name=tc.function.name,
      -                    arguments=tc.function.arguments,
      -                    provider_data=tc_provider_data or None,
      -                ))
      +                tool_calls.append(
      +                    ToolCall(
      +                        id=tc.id,
      +                        name=tc.function.name,
      +                        arguments=tc.function.arguments,
      +                        provider_data=tc_provider_data or None,
      +                    )
      +                )
       
               usage = None
               if hasattr(response, "usage") and response.usage:
      @@ -508,7 +576,7 @@ def validate_response(self, response: Any) -> bool:
                   return False
               return True
       
      -    def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
      +    def extract_cache_stats(self, response: Any) -> dict[str, int] | None:
               """Extract OpenRouter/OpenAI cache stats from prompt_tokens_details."""
               usage = getattr(response, "usage", None)
               if usage is None:
    • agent/transports/types.py
      @@ -12,7 +12,7 @@
       
       import json
       from dataclasses import dataclass, field
      -from typing import Any, Dict, List, Optional
      +from typing import Any
       
       
       @dataclass
      @@ -32,10 +32,10 @@ class ToolCall:
           * Others: ``None``
           """
       
      -    id: Optional[str]
      +    id: str | None
           name: str
           arguments: str  # JSON string
      -    provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
      +    provider_data: dict[str, Any] | None = field(default=None, repr=False)
       
           # ── Backward compatibility ──────────────────────────────────
           # The agent loop reads tc.function.name / tc.function.arguments
      @@ -47,17 +47,17 @@ def type(self) -> str:
               return "function"
       
           @property
      -    def function(self) -> "ToolCall":
      +    def function(self) -> ToolCall:
               """Return self so tc.function.name / tc.function.arguments work."""
               return self
       
           @property
      -    def call_id(self) -> Optional[str]:
      +    def call_id(self) -> str | None:
               """Codex call_id from provider_data, accessed via getattr by _build_assistant_message."""
               return (self.provider_data or {}).get("call_id")
       
           @property
      -    def response_item_id(self) -> Optional[str]:
      +    def response_item_id(self) -> str | None:
               """Codex response_item_id from provider_data."""
               return (self.provider_data or {}).get("response_item_id")
       
      @@ -101,18 +101,18 @@ class NormalizedResponse:
           * Others: ``None``
           """
       
      -    content: Optional[str]
      -    tool_calls: Optional[List[ToolCall]]
      +    content: str | None
      +    tool_calls: list[ToolCall] | None
           finish_reason: str  # "stop", "tool_calls", "length", "content_filter"
      -    reasoning: Optional[str] = None
      -    usage: Optional[Usage] = None
      -    provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
      +    reasoning: str | None = None
      +    usage: Usage | None = None
      +    provider_data: dict[str, Any] | None = field(default=None, repr=False)
       
           # ── Backward compatibility ──────────────────────────────────
           # The shim _nr_to_assistant_message() mapped these from provider_data.
           # These properties let NormalizedResponse pass through directly.
           @property
      -    def reasoning_content(self) -> Optional[str]:
      +    def reasoning_content(self) -> str | None:
               pd = self.provider_data or {}
               return pd.get("reasoning_content")
       
      @@ -136,8 +136,9 @@ def codex_message_items(self):
       # Factory helpers
       # ---------------------------------------------------------------------------
       
      +
       def build_tool_call(
      -    id: Optional[str],
      +    id: str | None,
           name: str,
           arguments: Any,
           **provider_fields: Any,
      @@ -151,7 +152,7 @@ def build_tool_call(
           return ToolCall(id=id, name=name, arguments=args_str, provider_data=pd)
       
       
      -def map_finish_reason(reason: Optional[str], mapping: Dict[str, str]) -> str:
      +def map_finish_reason(reason: str | None, mapping: dict[str, str]) -> str:
           """Translate a provider-specific stop reason to the normalised set.
       
           Falls back to ``"stop"`` for unknown or ``None`` reasons.
    • hermes_cli/auth.py
      @@ -416,6 +416,40 @@ class 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 = [redacted:secret-assignment] 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=[redacted:secret-assignment] 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":
    • hermes_cli/config.py
      @@ -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()
    • hermes_cli/doctor.py
      @@ -175,6 +175,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)
      @@ -1087,27 +1166,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 = [redacted:secret-assignment]
      +    _apikey_providers = [redacted:secret-assignment]
           for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
               _key = ""
               for _ev in _env_vars:
    • hermes_cli/main.py
      @@ -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 _lookup_ref(name: str, provider_key: str, model: str) -> str:
               "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
    • hermes_cli/models.py
      @@ -806,6 +806,25 @@ class ProviderEntry(NamedTuple):
           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 = [redacted:secret-assignment]"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=[redacted:secret-assignment]
      +                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)
    • hermes_cli/plugins.py
      @@ -173,7 +173,7 @@ def _get_enabled_plugins() -> Optional[set]:
       # Data classes
       # ---------------------------------------------------------------------------
       
      -_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
      +_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"}
       
       
       @dataclass
      @@ -643,15 +643,17 @@ def discover_and_load(self, force: bool = False) -> None:
               #   - flat: ``plugins/disk-cleanup/plugin.yaml`` (standalone)
               #   - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
               #
      -        # ``memory/`` and ``context_engine/`` are skipped at the top level —
      -        # they have their own discovery systems. ``platforms/`` is a category
      -        # holding platform adapters (scanned one level deeper below).
      +        # ``memory/``, ``context_engine/``, and ``model-providers/`` are
      +        # skipped at the top level — they have their own discovery systems
      +        # (plugins/memory/__init__.py, providers/__init__.py). ``platforms/``
      +        # is a category holding platform adapters (scanned one level deeper
      +        # below).
               repo_plugins = get_bundled_plugins_dir()
               manifests.extend(
                   self._scan_directory(
                       repo_plugins,
                       source="bundled",
      -                skip_names={"memory", "context_engine", "platforms"},
      +                skip_names={"memory", "context_engine", "platforms", "model-providers"},
                   )
               )
               manifests.extend(
      @@ -709,6 +711,21 @@ def discover_and_load(self, force: bool = False) -> None:
                       )
                       continue
       
      +            # Model provider plugins are loaded by providers/__init__.py
      +            # (its own lazy discovery keyed off first get_provider_profile()
      +            # call). We record the manifest here for introspection but do
      +            # not import the module — a second import would create two
      +            # ProviderProfile instances and break the "last writer wins"
      +            # override semantics between bundled and user plugins.
      +            if manifest.kind == "model-provider":
      +                loaded = LoadedPlugin(manifest=manifest, enabled=True)
      +                self._plugins[lookup_key] = loaded
      +                logger.debug(
      +                    "Skipping '%s' (model-provider, handled by providers/ discovery)",
      +                    lookup_key,
      +                )
      +                continue
      +
                   # Built-in backends auto-load — they ship with hermes and must
                   # just work. Selection among them (e.g. which image_gen backend
                   # services calls) is driven by ``<category>.provider`` config,
      @@ -886,6 +903,19 @@ def _parse_manifest(
                                       "treating as kind='exclusive'",
                                       key,
                                   )
      +                        elif (
      +                            "register_provider" in source_text
      +                            and "ProviderProfile" in source_text
      +                        ):
      +                            # Model provider plugin (calls register_provider()
      +                            # from ``providers`` with a ProviderProfile). Route
      +                            # to providers/__init__.py discovery.
      +                            kind = "model-provider"
      +                            logger.debug(
      +                                "Plugin %s: detected model provider, "
      +                                "treating as kind='model-provider'",
      +                                key,
      +                            )
                           except Exception:
                               pass
       
    • plugins/model-providers/README.md
      @@ -0,0 +1,70 @@
      +# Model Provider Plugins
      +
      +Each subdirectory is a self-contained provider profile plugin. The
      +directory layout mirrors `plugins/platforms/`:
      +
      +```
      +plugins/model-providers/
      +├── openrouter/
      +│   ├── __init__.py      # registers the ProviderProfile
      +│   └── plugin.yaml      # manifest: name, kind, version, description
      +├── anthropic/
      +│   ├── __init__.py
      +│   └── plugin.yaml
      +└── ...
      +```
      +
      +## How discovery works
      +
      +`providers/__init__.py._discover_providers()` scans this directory (and
      +`$HERMES_HOME/plugins/model-providers/`) the first time anything calls
      +`get_provider_profile()` or `list_providers()`. Each `__init__.py` is
      +imported and expected to call `providers.register_provider(profile)`.
      +
      +User plugins at `$HERMES_HOME/plugins/model-providers/<name>/` override
      +bundled plugins of the same name — last-writer-wins in
      +`register_provider()`. Drop a file there to replace a built-in.
      +
      +## Adding a new provider
      +
      +1. Create `plugins/model-providers/<your_provider>/__init__.py`:
      +
      +   ```python
      +   from providers import register_provider
      +   from providers.base import ProviderProfile
      +
      +   my_provider = ProviderProfile(
      +       name="your-provider",
      +       aliases=("alias1", "alias2"),
      +       display_name="Your Provider",
      +       description="One-line description shown in the setup picker",
      +       signup_url="https://your-provider.example.com/keys",
      +       env_vars=("YOUR_PROVIDER_API_KEY", "YOUR_PROVIDER_BASE_URL"),
      +       base_url="https://api.your-provider.example.com/v1",
      +       default_aux_model="your-cheap-model",
      +   )
      +
      +   register_provider(my_provider)
      +   ```
      +
      +2. Create `plugins/model-providers/<your_provider>/plugin.yaml`:
      +
      +   ```yaml
      +   name: your-provider-profile
      +   kind: model-provider
      +   version: 1.0.0
      +   description: Short sentence about the provider
      +   author: Your Name
      +   ```
      +
      +Nothing else needs to change. `auth.py`, `config.py`, `models.py`,
      +`doctor.py`, `model_metadata.py`, `runtime_provider.py`, and the
      +chat_completions transport all auto-wire from the registry.
      +
      +## Non-trivial profiles
      +
      +Override the `ProviderProfile` hooks in a subclass for per-provider
      +quirks — see `plugins/model-providers/openrouter/__init__.py` for
      +`build_extra_body` and `build_api_kwargs_extras` examples, and
      +`plugins/model-providers/gemini/__init__.py` for `thinking_config`
      +translation.
    • plugins/model-providers/ai-gateway/__init__.py
      @@ -0,0 +1,43 @@
      +"""Vercel AI Gateway provider profile.
      +
      +AI Gateway routes to multiple backends. Hermes sends attribution
      +headers and full reasoning config passthrough.
      +"""
      +
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class VercelAIGatewayProfile(ProviderProfile):
      +    """Vercel AI Gateway — attribution headers + reasoning passthrough."""
      +
      +    def build_api_kwargs_extras(
      +        self,
      +        *,
      +        reasoning_config: dict | None = None,
      +        supports_reasoning: bool = True,
      +        **ctx: Any,
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        extra_body: dict[str, Any] = {}
      +        if supports_reasoning and reasoning_config is not None:
      +            extra_body["reasoning"] = dict(reasoning_config)
      +        elif supports_reasoning:
      +            extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
      +        return extra_body, {}
      +
      +
      +vercel = VercelAIGatewayProfile(
      +    name="ai-gateway",
      +    aliases=("vercel", "vercel-ai-gateway", "ai_gateway", "aigateway"),
      +    env_vars=("AI_GATEWAY_API_KEY",),
      +    base_url="https://ai-gateway.vercel.sh/v1",
      +    default_headers={
      +        "HTTP-Referer": "https://hermes-agent.nousresearch.com",
      +        "X-Title": "Hermes Agent",
      +    },
      +    default_aux_model="google/gemini-3-flash",
      +)
      +
      +register_provider(vercel)
    • plugins/model-providers/ai-gateway/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: ai-gateway-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Vercel AI Gateway
      +author: Nous Research
    • plugins/model-providers/alibaba-coding-plan/__init__.py
      @@ -0,0 +1,21 @@
      +"""Alibaba Cloud Coding Plan provider profile.
      +
      +Separate from the standard `alibaba` profile because it hits a different
      +endpoint (coding-intl.dashscope.aliyuncs.com) with a dedicated API key tier.
      +"""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +alibaba_coding_plan = ProviderProfile(
      +    name="alibaba-coding-plan",
      +    aliases=("alibaba_coding", "alibaba-coding", "dashscope-coding"),
      +    display_name="Alibaba Cloud (Coding Plan)",
      +    description="Alibaba Cloud Coding Plan — dedicated coding tier",
      +    signup_url="https://help.aliyun.com/zh/model-studio/",
      +    env_vars=("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY", "ALIBABA_CODING_PLAN_BASE_URL"),
      +    base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
      +    auth_type="api_key",
      +)
      +
      +register_provider(alibaba_coding_plan)
    • plugins/model-providers/alibaba-coding-plan/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: alibaba-coding-plan-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Alibaba Cloud Coding Plan
      +author: Nous Research
    • plugins/model-providers/alibaba/__init__.py
      @@ -0,0 +1,13 @@
      +"""Alibaba Cloud DashScope provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +alibaba = ProviderProfile(
      +    name="alibaba",
      +    aliases=("dashscope", "alibaba-cloud", "qwen-dashscope"),
      +    env_vars=("DASHSCOPE_API_KEY",),
      +    base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
      +)
      +
      +register_provider(alibaba)
    • plugins/model-providers/alibaba/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: alibaba-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Alibaba DashScope (international)
      +author: Nous Research
    • plugins/model-providers/anthropic/__init__.py
      @@ -0,0 +1,52 @@
      +"""Native Anthropic provider profile."""
      +
      +import json
      +import logging
      +import urllib.request
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +logger = logging.getLogger(__name__)
      +
      +
      +class AnthropicProfile(ProviderProfile):
      +    """Native Anthropic — uses x-api-key header, not Bearer."""
      +
      +    def fetch_models(
      +        self,
      +        *,
      +        api_key: str | None = None,
      +        timeout: float = 8.0,
      +    ) -> list[str] | None:
      +        """Anthropic uses x-api-key header and anthropic-version."""
      +        if not api_key:
      +            return None
      +        try:
      +            req = urllib.request.Request("https://api.anthropic.com/v1/models")
      +            req.add_header("x-api-key", api_key)
      +            req.add_header("anthropic-version", "2023-06-01")
      +            req.add_header("Accept", "application/json")
      +            with urllib.request.urlopen(req, timeout=timeout) as resp:
      +                data = json.loads(resp.read().decode())
      +            return [
      +                m["id"]
      +                for m in data.get("data", [])
      +                if isinstance(m, dict) and "id" in m
      +            ]
      +        except Exception as exc:
      +            logger.debug("fetch_models(anthropic): %s", exc)
      +            return None
      +
      +
      +anthropic = AnthropicProfile(
      +    name="anthropic",
      +    aliases=("claude", "claude-oauth", "claude-code"),
      +    api_mode="anthropic_messages",
      +    env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
      +    base_url="https://api.anthropic.com",
      +    auth_type="api_key",
      +    default_aux_model="claude-haiku-4-5-20251001",
      +)
      +
      +register_provider(anthropic)
    • plugins/model-providers/anthropic/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: anthropic-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Anthropic (Claude)
      +author: Nous Research
    • plugins/model-providers/arcee/__init__.py
      @@ -0,0 +1,13 @@
      +"""Arcee AI provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +arcee = ProviderProfile(
      +    name="arcee",
      +    aliases=("arcee-ai", "arceeai"),
      +    env_vars=("ARCEEAI_API_KEY",),
      +    base_url="https://api.arcee.ai/api/v1",
      +)
      +
      +register_provider(arcee)
    • plugins/model-providers/arcee/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: arcee-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Arcee AI
      +author: Nous Research
    • plugins/model-providers/azure-foundry/__init__.py
      @@ -0,0 +1,21 @@
      +"""Azure AI Foundry provider profile.
      +
      +Azure Foundry exposes an OpenAI-compatible endpoint; users supply their own
      +base URL at setup since endpoints are per-resource.
      +"""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +azure_foundry = ProviderProfile(
      +    name="azure-foundry",
      +    aliases=("azure", "azure-ai-foundry", "azure-ai"),
      +    display_name="Azure Foundry",
      +    description="Azure AI Foundry — OpenAI-compatible endpoint (user-supplied base URL)",
      +    signup_url="https://ai.azure.com/",
      +    env_vars=("AZURE_FOUNDRY_API_KEY", "AZURE_FOUNDRY_BASE_URL"),
      +    base_url="",  # per-resource; user provides at setup
      +    auth_type="api_key",
      +)
      +
      +register_provider(azure_foundry)
    • plugins/model-providers/azure-foundry/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: azure-foundry-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Azure AI Foundry
      +author: Nous Research
    • plugins/model-providers/bedrock/__init__.py
      @@ -0,0 +1,29 @@
      +"""AWS Bedrock provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class BedrockProfile(ProviderProfile):
      +    """AWS Bedrock — no REST /v1/models endpoint; uses AWS SDK."""
      +
      +    def fetch_models(
      +        self,
      +        *,
      +        api_key: str | None = None,
      +        timeout: float = 8.0,
      +    ) -> list[str] | None:
      +        """Bedrock model listing requires AWS SDK, not a REST call."""
      +        return None
      +
      +
      +bedrock = BedrockProfile(
      +    name="bedrock",
      +    aliases=("aws", "aws-bedrock", "amazon-bedrock", "amazon"),
      +    api_mode="bedrock_converse",
      +    env_vars=(),  # AWS SDK credentials — not env vars
      +    base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
      +    auth_type="aws_sdk",
      +)
      +
      +register_provider(bedrock)
    • plugins/model-providers/bedrock/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: bedrock-provider
      +kind: model-provider
      +version: 1.0.0
      +description: AWS Bedrock
      +author: Nous Research
    • plugins/model-providers/copilot-acp/__init__.py
      @@ -0,0 +1,34 @@
      +"""GitHub Copilot ACP provider profile.
      +
      +copilot-acp uses an external ACP subprocess — NOT the standard
      +transport. api_mode="copilot_acp" is handled separately in run_agent.py.
      +The profile captures auth + endpoint metadata for registry migration.
      +"""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class CopilotACPProfile(ProviderProfile):
      +    """GitHub Copilot ACP — external process, no REST models endpoint."""
      +
      +    def fetch_models(
      +        self,
      +        *,
      +        api_key: str | None = None,
      +        timeout: float = 8.0,
      +    ) -> list[str] | None:
      +        """Model listing is handled by the ACP subprocess."""
      +        return None
      +
      +
      +copilot_acp = CopilotACPProfile(
      +    name="copilot-acp",
      +    aliases=("github-copilot-acp", "copilot-acp-agent"),
      +    api_mode="chat_completions",  # ACP subprocess uses chat_completions routing
      +    env_vars=(),  # Managed by ACP subprocess
      +    base_url="acp://copilot",  # ACP internal scheme
      +    auth_type="external_process",
      +)
      +
      +register_provider(copilot_acp)
    • plugins/model-providers/copilot-acp/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: copilot-acp-provider
      +kind: model-provider
      +version: 1.0.0
      +description: GitHub Copilot via ACP subprocess
      +author: Nous Research
    • plugins/model-providers/copilot/__init__.py
      @@ -0,0 +1,58 @@
      +"""Copilot / GitHub Models provider profile.
      +
      +Copilot uses per-model api_mode routing:
      +  - GPT-5+ / Codex models → codex_responses
      +  - Claude models → anthropic_messages
      +  - Everything else → chat_completions (this profile covers that subset)
      +
      +Key quirks for the chat_completions subset:
      +  - Editor attribution headers (via copilot_default_headers())
      +  - GitHub Models reasoning extra_body (model-catalog gated)
      +"""
      +
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class CopilotProfile(ProviderProfile):
      +    """GitHub Copilot / GitHub Models — editor headers + reasoning."""
      +
      +    def build_api_kwargs_extras(
      +        self,
      +        *,
      +        model: str | None = None,
      +        reasoning_config: dict | None = None,
      +        supports_reasoning: bool = False,
      +        **ctx,
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        extra_body: dict[str, Any] = {}
      +        if supports_reasoning and model:
      +            try:
      +                from hermes_cli.models import github_model_reasoning_efforts
      +
      +                supported_efforts = github_model_reasoning_efforts(model)
      +                if supported_efforts and reasoning_config:
      +                    effort = reasoning_config.get("effort", "medium")
      +                    # Normalize non-standard effort levels to the nearest supported
      +                    if effort == "xhigh":
      +                        effort = "high"
      +                    if effort in supported_efforts:
      +                        extra_body["reasoning"] = {"effort": effort}
      +                elif supported_efforts:
      +                    extra_body["reasoning"] = {"effort": "medium"}
      +            except Exception:
      +                pass
      +        return extra_body, {}
      +
      +
      +copilot = CopilotProfile(
      +    name="copilot",
      +    aliases=("github-copilot", "github-models", "github-model", "github"),
      +    env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
      +    base_url="https://api.githubcopilot.com",
      +    auth_type="copilot",
      +)
      +
      +register_provider(copilot)
    • plugins/model-providers/copilot/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: copilot-provider
      +kind: model-provider
      +version: 1.0.0
      +description: GitHub Copilot
      +author: Nous Research
    • plugins/model-providers/custom/__init__.py
      @@ -0,0 +1,68 @@
      +"""Custom / Ollama (local) provider profile.
      +
      +Covers any endpoint registered as provider="custom", including local
      +Ollama instances. Key quirks:
      +  - ollama_num_ctx → extra_body.options.num_ctx (local context window)
      +  - reasoning_config disabled → extra_body.think = False
      +"""
      +
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class CustomProfile(ProviderProfile):
      +    """Custom/Ollama local provider — think=false and num_ctx support."""
      +
      +    def build_api_kwargs_extras(
      +        self,
      +        *,
      +        reasoning_config: dict | None = None,
      +        ollama_num_ctx: int | None = None,
      +        **ctx: Any,
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        extra_body: dict[str, Any] = {}
      +
      +        # Ollama context window
      +        if ollama_num_ctx:
      +            options = extra_body.get("options", {})
      +            options["num_ctx"] = ollama_num_ctx
      +            extra_body["options"] = options
      +
      +        # Disable thinking when reasoning is turned off
      +        if reasoning_config and isinstance(reasoning_config, dict):
      +            _effort = (reasoning_config.get("effort") or "").strip().lower()
      +            _enabled = reasoning_config.get("enabled", True)
      +            if _effort == "none" or _enabled is False:
      +                extra_body["think"] = False
      +
      +        return extra_body, {}
      +
      +    def fetch_models(
      +        self,
      +        *,
      +        api_key: str | None = None,
      +        timeout: float = 8.0,
      +    ) -> list[str] | None:
      +        """Custom/Ollama: base_url is user-configured; fetch if set."""
      +        if not self.base_url:
      +            return None
      +        return super().fetch_models(api_key=[redacted:secret-assignment] timeout=timeout)
      +
      +
      +custom = CustomProfile(
      +    name="custom",
      +    aliases=(
      +        "ollama",
      +        "local",
      +        "vllm",
      +        "llamacpp",
      +        "llama.cpp",
      +        "llama-cpp",
      +    ),
      +    env_vars=(),  # No fixed key — custom endpoint
      +    base_url="",  # User-configured
      +)
      +
      +register_provider(custom)
    • plugins/model-providers/custom/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: custom-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Custom / Ollama / local OpenAI-compatible endpoint
      +author: Nous Research
    • plugins/model-providers/deepseek/__init__.py
      @@ -0,0 +1,20 @@
      +"""DeepSeek provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +deepseek = ProviderProfile(
      +    name="deepseek",
      +    aliases=("deepseek-chat",),
      +    env_vars=("DEEPSEEK_API_KEY",),
      +    display_name="DeepSeek",
      +    description="DeepSeek — native DeepSeek API",
      +    signup_url="https://platform.deepseek.com/",
      +    fallback_models=(
      +        "deepseek-chat",
      +        "deepseek-reasoner",
      +    ),
      +    base_url="https://api.deepseek.com/v1",
      +)
      +
      +register_provider(deepseek)
    • plugins/model-providers/deepseek/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: deepseek-provider
      +kind: model-provider
      +version: 1.0.0
      +description: DeepSeek
      +author: Nous Research
    • plugins/model-providers/gemini/__init__.py
      @@ -0,0 +1,72 @@
      +"""Google Gemini provider profiles.
      +
      +gemini:            Google AI Studio (API key) — uses GeminiNativeClient
      +google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient
      +
      +Both 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.
      +"""
      +
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class GeminiProfile(ProviderProfile):
      +    """Gemini — translate reasoning_config to thinking_config in extra_body."""
      +
      +    def build_extra_body(
      +        self, *, session_id: str | None = None, **context: Any
      +    ) -> dict[str, Any]:
      +        """Emit extra_body.thinking_config (native) or extra_body.extra_body.google.thinking_config
      +        (OpenAI-compat /openai subpath), mirroring the legacy path's behavior.
      +        """
      +        from agent.transports.chat_completions import (
      +            _build_gemini_thinking_config,
      +            _is_gemini_openai_compat_base_url,
      +            _snake_case_gemini_thinking_config,
      +        )
      +
      +        model = context.get("model") or ""
      +        reasoning_config = context.get("reasoning_config")
      +        base_url = context.get("base_url") or self.base_url
      +
      +        raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
      +        if not raw_thinking_config:
      +            return {}
      +
      +        body: dict[str, Any] = {}
      +        if self.name == "gemini" and _is_gemini_openai_compat_base_url(base_url):
      +            thinking_config = _snake_case_gemini_thinking_config(raw_thinking_config)
      +            if thinking_config:
      +                body["extra_body"] = {"google": {"thinking_config": thinking_config}}
      +        else:
      +            body["thinking_config"] = raw_thinking_config
      +        return body
      +
      +
      +gemini = GeminiProfile(
      +    name="gemini",
      +    aliases=("google", "google-gemini", "google-ai-studio"),
      +    api_mode="chat_completions",
      +    env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
      +    base_url="https://generativelanguage.googleapis.com/v1beta",
      +    auth_type="api_key",
      +    default_aux_model="gemini-3-flash-preview",
      +)
      +
      +google_gemini_cli = GeminiProfile(
      +    name="google-gemini-cli",
      +    aliases=("gemini-cli", "gemini-oauth"),
      +    api_mode="chat_completions",
      +    env_vars=(),  # OAuth — no API key
      +    base_url="cloudcode-pa://google",  # Cloud Code Assist internal scheme
      +    auth_type="oauth_external",
      +)
      +
      +register_provider(gemini)
      +register_provider(google_gemini_cli)
    • plugins/model-providers/gemini/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: gemini-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Google Gemini (API key + Cloud Code OAuth)
      +author: Nous Research
    • plugins/model-providers/gmi/__init__.py
      @@ -0,0 +1,26 @@
      +"""GMI Cloud provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +gmi = ProviderProfile(
      +    name="gmi",
      +    aliases=("gmi-cloud", "gmicloud"),
      +    display_name="GMI Cloud",
      +    description="GMI Cloud — multi-model direct API (slash-form model IDs)",
      +    signup_url="https://www.gmicloud.ai/",
      +    env_vars=("GMI_API_KEY", "GMI_BASE_URL"),
      +    base_url="https://api.gmi-serving.com/v1",
      +    auth_type="api_key",
      +    default_aux_model="google/gemini-3.1-flash-lite-preview",
      +    fallback_models=(
      +        "zai-org/GLM-5.1-FP8",
      +        "deepseek-ai/DeepSeek-V3.2",
      +        "moonshotai/Kimi-K2.5",
      +        "google/gemini-3.1-flash-lite-preview",
      +        "anthropic/claude-sonnet-4.6",
      +        "openai/gpt-5.4",
      +    ),
      +)
      +
      +register_provider(gmi)
    • plugins/model-providers/gmi/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: gmi-provider
      +kind: model-provider
      +version: 1.0.0
      +description: GMI Cloud
      +author: Nous Research
    • plugins/model-providers/huggingface/__init__.py
      @@ -0,0 +1,20 @@
      +"""Hugging Face provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +huggingface = ProviderProfile(
      +    name="huggingface",
      +    aliases=("hf", "hugging-face", "huggingface-hub"),
      +    env_vars=("HF_TOKEN",),
      +    display_name="HuggingFace",
      +    description="HuggingFace Inference API",
      +    signup_url="https://huggingface.co/settings/tokens",
      +    fallback_models=(
      +        "Qwen/Qwen3.5-72B-Instruct",
      +        "deepseek-ai/DeepSeek-V3.2",
      +    ),
      +    base_url="https://router.huggingface.co/v1",
      +)
      +
      +register_provider(huggingface)
    • plugins/model-providers/huggingface/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: huggingface-provider
      +kind: model-provider
      +version: 1.0.0
      +description: HuggingFace Inference Providers
      +author: Nous Research
    • plugins/model-providers/kilocode/__init__.py
      @@ -0,0 +1,14 @@
      +"""Kilo Code provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +kilocode = ProviderProfile(
      +    name="kilocode",
      +    aliases=("kilo-code", "kilo", "kilo-gateway"),
      +    env_vars=("KILOCODE_API_KEY",),
      +    base_url="https://api.kilo.ai/api/gateway",
      +    default_aux_model="google/gemini-3-flash-preview",
      +)
      +
      +register_provider(kilocode)
    • plugins/model-providers/kilocode/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: kilocode-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Kilo Code
      +author: Nous Research
    • plugins/model-providers/kimi-coding/__init__.py
      @@ -0,0 +1,71 @@
      +"""Kimi / Moonshot provider profiles.
      +
      +Kimi has dual endpoints:
      +  - sk-kimi-* keys → api.kimi.com/coding (Anthropic Messages API)
      +  - legacy keys → api.moonshot.ai/v1 (OpenAI chat completions)
      +
      +This module covers the chat_completions path (/v1 endpoint).
      +"""
      +
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import OMIT_TEMPERATURE, ProviderProfile
      +
      +
      +class KimiProfile(ProviderProfile):
      +    """Kimi/Moonshot — temperature omitted, thinking + reasoning_effort."""
      +
      +    def build_api_kwargs_extras(
      +        self, *, reasoning_config: dict | None = None, **context
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        """Kimi uses extra_body.thinking + top-level reasoning_effort."""
      +        extra_body = {}
      +        top_level = {}
      +
      +        if not reasoning_config or not isinstance(reasoning_config, dict):
      +            # No config → thinking enabled, default effort
      +            extra_body["thinking"] = {"type": "enabled"}
      +            top_level["reasoning_effort"] = "medium"
      +            return extra_body, top_level
      +
      +        enabled = reasoning_config.get("enabled", True)
      +        if enabled is False:
      +            extra_body["thinking"] = {"type": "disabled"}
      +            return extra_body, top_level
      +
      +        # Enabled
      +        extra_body["thinking"] = {"type": "enabled"}
      +        effort = (reasoning_config.get("effort") or "").strip().lower()
      +        if effort in ("low", "medium", "high"):
      +            top_level["reasoning_effort"] = effort
      +        else:
      +            top_level["reasoning_effort"] = "medium"
      +
      +        return extra_body, top_level
      +
      +
      +kimi = KimiProfile(
      +    name="kimi-coding",
      +    aliases=("kimi", "moonshot", "kimi-for-coding"),
      +    env_vars=("KIMI_API_KEY", "KIMI_CODING_API_KEY"),
      +    base_url="https://api.moonshot.ai/v1",
      +    fixed_temperature=OMIT_TEMPERATURE,
      +    default_max_tokens=[redacted:secret-assignment]
      +    default_headers={"User-Agent": "hermes-agent/1.0"},
      +    default_aux_model="kimi-k2-turbo-preview",
      +)
      +
      +kimi_cn = KimiProfile(
      +    name="kimi-coding-cn",
      +    aliases=("kimi-cn", "moonshot-cn"),
      +    env_vars=("KIMI_CN_API_KEY",),
      +    base_url="https://api.moonshot.cn/v1",
      +    fixed_temperature=OMIT_TEMPERATURE,
      +    default_max_tokens=[redacted:secret-assignment]
      +    default_headers={"User-Agent": "hermes-agent/1.0"},
      +    default_aux_model="kimi-k2-turbo-preview",
      +)
      +
      +register_provider(kimi)
      +register_provider(kimi_cn)
    • plugins/model-providers/kimi-coding/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: kimi-coding-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Moonshot Kimi Coding (global + China)
      +author: Nous Research
    • plugins/model-providers/minimax/__init__.py
      @@ -0,0 +1,45 @@
      +"""MiniMax provider profiles (international + China).
      +
      +Both use anthropic_messages api_mode — their inference_base_url
      +ends with /anthropic which triggers auto-detection to anthropic_messages.
      +"""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +minimax = ProviderProfile(
      +    name="minimax",
      +    aliases=("mini-max",),
      +    api_mode="anthropic_messages",
      +    env_vars=("MINIMAX_API_KEY",),
      +    base_url="https://api.minimax.io/anthropic",
      +    auth_type="api_key",
      +    default_aux_model="MiniMax-M2.7",
      +)
      +
      +minimax_cn = ProviderProfile(
      +    name="minimax-cn",
      +    aliases=("minimax-china", "minimax_cn"),
      +    api_mode="anthropic_messages",
      +    env_vars=("MINIMAX_CN_API_KEY",),
      +    base_url="https://api.minimaxi.com/anthropic",
      +    auth_type="api_key",
      +    default_aux_model="MiniMax-M2.7",
      +)
      +
      +minimax_oauth = ProviderProfile(
      +    name="minimax-oauth",
      +    aliases=("minimax_oauth", "minimax-oauth-io"),
      +    api_mode="anthropic_messages",
      +    display_name="MiniMax (OAuth)",
      +    description="MiniMax via OAuth browser flow — no API key required",
      +    signup_url="https://api.minimax.io/",
      +    env_vars=(),  # OAuth — tokens in auth.json, not env
      +    base_url="https://api.minimax.io/anthropic",
      +    auth_type="oauth_external",
      +    default_aux_model="MiniMax-M2.7-highspeed",
      +)
      +
      +register_provider(minimax)
      +register_provider(minimax_cn)
      +register_provider(minimax_oauth)
    • plugins/model-providers/minimax/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: minimax-provider
      +kind: model-provider
      +version: 1.0.0
      +description: MiniMax M-series (global + China + OAuth)
      +author: Nous Research
    • plugins/model-providers/nous/__init__.py
      @@ -0,0 +1,53 @@
      +"""Nous Portal provider profile."""
      +
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class NousProfile(ProviderProfile):
      +    """Nous Portal — product tags, reasoning with Nous-specific omission."""
      +
      +    def build_extra_body(
      +        self, *, session_id: str | None = None, **context
      +    ) -> dict[str, Any]:
      +        return {"tags": ["product=hermes-agent"]}
      +
      +    def build_api_kwargs_extras(
      +        self,
      +        *,
      +        reasoning_config: dict | None = None,
      +        supports_reasoning: bool = False,
      +        **context,
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        """Nous: passes full reasoning_config, but OMITS when disabled."""
      +        extra_body = {}
      +        if supports_reasoning:
      +            if reasoning_config is not None:
      +                rc = dict(reasoning_config)
      +                if rc.get("enabled") is False:
      +                    pass  # Nous omits reasoning when disabled
      +                else:
      +                    extra_body["reasoning"] = rc
      +            else:
      +                extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
      +        return extra_body, {}
      +
      +
      +nous = NousProfile(
      +    name="nous",
      +    aliases=("nous-portal", "nousresearch"),
      +    env_vars=("NOUS_API_KEY",),
      +    display_name="Nous Research",
      +    description="Nous Research — Hermes model family",
      +    signup_url="https://nousresearch.com/",
      +    fallback_models=(
      +        "hermes-3-405b",
      +        "hermes-3-70b",
      +    ),
      +    base_url="https://inference.nousresearch.com/v1",
      +    auth_type="oauth_device_code",
      +)
      +
      +register_provider(nous)
    • plugins/model-providers/nous/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: nous-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Nous Research Portal
      +author: Nous Research
    • plugins/model-providers/nvidia/__init__.py
      @@ -0,0 +1,21 @@
      +"""NVIDIA NIM provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +nvidia = ProviderProfile(
      +    name="nvidia",
      +    aliases=("nvidia-nim",),
      +    env_vars=("NVIDIA_API_KEY",),
      +    display_name="NVIDIA NIM",
      +    description="NVIDIA NIM — accelerated inference",
      +    signup_url="https://build.nvidia.com/",
      +    fallback_models=(
      +        "nvidia/llama-3.1-nemotron-70b-instruct",
      +        "nvidia/llama-3.3-70b-instruct",
      +    ),
      +    base_url="https://integrate.api.nvidia.com/v1",
      +    default_max_tokens=[redacted:secret-assignment]
      +)
      +
      +register_provider(nvidia)
    • plugins/model-providers/nvidia/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: nvidia-provider
      +kind: model-provider
      +version: 1.0.0
      +description: NVIDIA NIM
      +author: Nous Research
    • plugins/model-providers/ollama-cloud/__init__.py
      @@ -0,0 +1,14 @@
      +"""Ollama Cloud provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +ollama_cloud = ProviderProfile(
      +    name="ollama-cloud",
      +    aliases=("ollama_cloud",),
      +    default_aux_model="nemotron-3-nano:30b",
      +    env_vars=("OLLAMA_API_KEY",),
      +    base_url="https://ollama.com/v1",
      +)
      +
      +register_provider(ollama_cloud)
    • plugins/model-providers/ollama-cloud/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: ollama-cloud-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Ollama Cloud
      +author: Nous Research
    • plugins/model-providers/openai-codex/__init__.py
      @@ -0,0 +1,15 @@
      +"""OpenAI Codex (Responses API) provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +openai_codex = ProviderProfile(
      +    name="openai-codex",
      +    aliases=("codex", "openai_codex"),
      +    api_mode="codex_responses",
      +    env_vars=(),  # OAuth external — no API key
      +    base_url="https://chatgpt.com/backend-api/codex",
      +    auth_type="oauth_external",
      +)
      +
      +register_provider(openai_codex)
    • plugins/model-providers/openai-codex/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: openai-codex-provider
      +kind: model-provider
      +version: 1.0.0
      +description: OpenAI Codex (Responses API)
      +author: Nous Research
    • plugins/model-providers/opencode-zen/__init__.py
      @@ -0,0 +1,30 @@
      +"""OpenCode provider profiles (Zen + Go).
      +
      +Both use per-model api_mode routing:
      +  - OpenCode Zen: Claude → anthropic_messages, GPT-5/Codex → codex_responses,
      +    everything else → chat_completions (this profile)
      +  - OpenCode Go: MiniMax → anthropic_messages, GLM/Kimi → chat_completions
      +    (this profile)
      +"""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +opencode_zen = ProviderProfile(
      +    name="opencode-zen",
      +    aliases=("opencode", "opencode_zen", "zen"),
      +    env_vars=("OPENCODE_ZEN_API_KEY",),
      +    base_url="https://opencode.ai/zen/v1",
      +    default_aux_model="gemini-3-flash",
      +)
      +
      +opencode_go = ProviderProfile(
      +    name="opencode-go",
      +    aliases=("opencode_go", "go", "opencode-go-sub"),
      +    env_vars=("OPENCODE_GO_API_KEY",),
      +    base_url="https://opencode.ai/zen/go/v1",
      +    default_aux_model="glm-5",
      +)
      +
      +register_provider(opencode_zen)
      +register_provider(opencode_go)
    • plugins/model-providers/opencode-zen/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: opencode-zen-provider
      +kind: model-provider
      +version: 1.0.0
      +description: OpenCode (Zen + Go)
      +author: Nous Research
    • plugins/model-providers/openrouter/__init__.py
      @@ -0,0 +1,86 @@
      +"""OpenRouter provider profile."""
      +
      +import logging
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +logger = logging.getLogger(__name__)
      +
      +_CACHE: list[str] | None = None
      +
      +
      +class OpenRouterProfile(ProviderProfile):
      +    """OpenRouter aggregator — provider preferences, reasoning config passthrough."""
      +
      +    def fetch_models(
      +        self,
      +        *,
      +        api_key: str | None = None,
      +        timeout: float = 8.0,
      +    ) -> list[str] | None:
      +        """Fetch from public OpenRouter catalog — no auth required.
      +
      +        Note: Tool-call capability filtering is applied by hermes_cli/models.py
      +        via fetch_openrouter_models() → _openrouter_model_supports_tools(), not
      +        here. The picker early-returns via the dedicated openrouter path before
      +        reaching this method, so filtering here would be unreachable.
      +        """
      +        global _CACHE  # noqa: PLW0603
      +        if _CACHE is not None:
      +            return _CACHE
      +        try:
      +            result = super().fetch_models(api_key=None, timeout=timeout)
      +            if result is not None:
      +                _CACHE = result
      +            return result
      +        except Exception as exc:
      +            logger.debug("fetch_models(openrouter): %s", exc)
      +            return None
      +
      +    def build_extra_body(
      +        self, *, session_id: str | None = None, **context: Any
      +    ) -> dict[str, Any]:
      +        body: dict[str, Any] = {}
      +        prefs = context.get("provider_preferences")
      +        if prefs:
      +            body["provider"] = prefs
      +        return body
      +
      +    def build_api_kwargs_extras(
      +        self,
      +        *,
      +        reasoning_config: dict | None = None,
      +        supports_reasoning: bool = False,
      +        **context: Any,
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        """OpenRouter passes the full reasoning_config dict as extra_body.reasoning."""
      +        extra_body: dict[str, Any] = {}
      +        if supports_reasoning:
      +            if reasoning_config is not None:
      +                extra_body["reasoning"] = dict(reasoning_config)
      +            else:
      +                extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
      +        return extra_body, {}
      +
      +
      +openrouter = OpenRouterProfile(
      +    name="openrouter",
      +    aliases=("or",),
      +    env_vars=("OPENROUTER_API_KEY",),
      +    display_name="OpenRouter",
      +    description="OpenRouter — unified API for 200+ models",
      +    signup_url="https://openrouter.ai/keys",
      +    base_url="https://openrouter.ai/api/v1",
      +    models_url="https://openrouter.ai/api/v1/models",
      +    fallback_models=(
      +        "anthropic/claude-sonnet-4.6",
      +        "openai/gpt-5.4",
      +        "deepseek/deepseek-chat",
      +        "google/gemini-3-flash-preview",
      +        "qwen/qwen3-plus",
      +    ),
      +)
      +
      +register_provider(openrouter)
    • plugins/model-providers/openrouter/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: openrouter-provider
      +kind: model-provider
      +version: 1.0.0
      +description: OpenRouter aggregator
      +author: Nous Research
    • plugins/model-providers/qwen-oauth/__init__.py
      @@ -0,0 +1,82 @@
      +"""Qwen Portal provider profile."""
      +
      +import copy
      +from typing import Any
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +
      +class QwenProfile(ProviderProfile):
      +    """Qwen Portal — message normalization, vl_high_resolution, metadata top-level."""
      +
      +    def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
      +        """Normalize content to list-of-dicts format.
      +
      +        Inject cache_control on system message.
      +
      +        Matches the behavior of run_agent.py:_qwen_prepare_chat_messages().
      +        """
      +        prepared = copy.deepcopy(messages)
      +        if not prepared:
      +            return prepared
      +
      +        for msg in prepared:
      +            if not isinstance(msg, dict):
      +                continue
      +            content = msg.get("content")
      +            if isinstance(content, str):
      +                msg["content"] = [{"type": "text", "text": content}]
      +            elif isinstance(content, list):
      +                normalized_parts = []
      +                for part in content:
      +                    if isinstance(part, str):
      +                        normalized_parts.append({"type": "text", "text": part})
      +                    elif isinstance(part, dict):
      +                        normalized_parts.append(part)
      +                if normalized_parts:
      +                    msg["content"] = normalized_parts
      +
      +        # Inject cache_control on the last part of the system message.
      +        for msg in prepared:
      +            if isinstance(msg, dict) and msg.get("role") == "system":
      +                content = msg.get("content")
      +                if (
      +                    isinstance(content, list)
      +                    and content
      +                    and isinstance(content[-1], dict)
      +                ):
      +                    content[-1]["cache_control"] = {"type": "ephemeral"}
      +                break
      +
      +        return prepared
      +
      +    def build_extra_body(
      +        self, *, session_id: str | None = None, **context
      +    ) -> dict[str, Any]:
      +        return {"vl_high_resolution_images": True}
      +
      +    def build_api_kwargs_extras(
      +        self,
      +        *,
      +        reasoning_config: dict | None = None,
      +        qwen_session_metadata: dict | None = None,
      +        **context,
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        """Qwen metadata goes to top-level api_kwargs, not extra_body."""
      +        top_level = {}
      +        if qwen_session_metadata:
      +            top_level["metadata"] = qwen_session_metadata
      +        return {}, top_level
      +
      +
      +qwen = QwenProfile(
      +    name="qwen-oauth",
      +    aliases=("qwen", "qwen-portal", "qwen-cli"),
      +    env_vars=("QWEN_API_KEY",),
      +    base_url="https://portal.qwen.ai/v1",
      +    auth_type="oauth_external",
      +    default_max_tokens=[redacted:secret-assignment]
      +)
      +
      +register_provider(qwen)
    • plugins/model-providers/qwen-oauth/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: qwen-oauth-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Qwen Portal (OAuth)
      +author: Nous Research
    • plugins/model-providers/stepfun/__init__.py
      @@ -0,0 +1,14 @@
      +"""StepFun provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +stepfun = ProviderProfile(
      +    name="stepfun",
      +    aliases=("step", "stepfun-coding-plan"),
      +    default_aux_model="step-3.5-flash",
      +    env_vars=("STEPFUN_API_KEY",),
      +    base_url="https://api.stepfun.ai/step_plan/v1",
      +)
      +
      +register_provider(stepfun)
    • plugins/model-providers/stepfun/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: stepfun-provider
      +kind: model-provider
      +version: 1.0.0
      +description: StepFun Step Plan
      +author: Nous Research
    • plugins/model-providers/xai/__init__.py
      @@ -0,0 +1,15 @@
      +"""xAI (Grok) provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +xai = ProviderProfile(
      +    name="xai",
      +    aliases=("grok", "x-ai", "x.ai"),
      +    api_mode="codex_responses",
      +    env_vars=("XAI_API_KEY",),
      +    base_url="https://api.x.ai/v1",
      +    auth_type="api_key",
      +)
      +
      +register_provider(xai)
    • plugins/model-providers/xai/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: xai-provider
      +kind: model-provider
      +version: 1.0.0
      +description: xAI Grok (Responses API)
      +author: Nous Research
    • plugins/model-providers/xiaomi/__init__.py
      @@ -0,0 +1,13 @@
      +"""Xiaomi MiMo provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +xiaomi = ProviderProfile(
      +    name="xiaomi",
      +    aliases=("mimo", "xiaomi-mimo"),
      +    env_vars=("XIAOMI_API_KEY",),
      +    base_url="https://api.xiaomimimo.com/v1",
      +)
      +
      +register_provider(xiaomi)
    • plugins/model-providers/xiaomi/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: xiaomi-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Xiaomi MiMo
      +author: Nous Research
    • plugins/model-providers/zai/__init__.py
      @@ -0,0 +1,21 @@
      +"""ZAI / GLM provider profile."""
      +
      +from providers import register_provider
      +from providers.base import ProviderProfile
      +
      +zai = ProviderProfile(
      +    name="zai",
      +    aliases=("glm", "z-ai", "z.ai", "zhipu"),
      +    env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
      +    display_name="Z.AI (GLM)",
      +    description="Z.AI / GLM — Zhipu AI models",
      +    signup_url="https://z.ai/",
      +    fallback_models=(
      +        "glm-5",
      +        "glm-4-9b",
      +    ),
      +    base_url="https://api.z.ai/api/paas/v4",
      +    default_aux_model="glm-4.5-flash",
      +)
      +
      +register_provider(zai)
    • plugins/model-providers/zai/plugin.yaml
      @@ -0,0 +1,5 @@
      +name: zai-provider
      +kind: model-provider
      +version: 1.0.0
      +description: Z.AI / GLM
      +author: Nous Research
    • providers/README.md
      @@ -0,0 +1,78 @@
      +# providers/
      +
      +Registry and ABC for every inference provider Hermes knows about.
      +
      +Each provider is declared once as a `ProviderProfile`. Every other layer —
      +auth resolution, transport kwargs, model listing, runtime routing — reads from
      +these profiles instead of maintaining its own parallel data.
      +
      +---
      +
      +## Layout
      +
      +```
      +providers/
      +├── base.py         ProviderProfile dataclass + OMIT_TEMPERATURE sentinel
      +├── __init__.py     Registry: register_provider(), get_provider_profile(), list_providers()
      +└── README.md       This file
      +```
      +
      +The **profiles themselves** live as plugins under
      +`plugins/model-providers/<name>/` (bundled in this repo) and
      +`$HERMES_HOME/plugins/model-providers/<name>/` (per-user overrides). The
      +registry in `providers/__init__.py` lazily discovers them the first time any
      +consumer calls `get_provider_profile()` or `list_providers()`. See
      +`plugins/model-providers/README.md` for the plugin contract and examples.
      +
      +---
      +
      +## How it wires in
      +
      +The registry is populated on first access. After that, every downstream
      +layer reads from it:
      +
      +- `hermes_cli/auth.py` extends `PROVIDER_REGISTRY` with every api-key
      +  profile it sees (skipping `copilot`, `kimi-coding`, `kimi-coding-cn`,
      +  `zai`, `openrouter`, `custom` — those need bespoke token resolution).
      +- `hermes_cli/models.py` extends `CANONICAL_PROVIDERS` and calls
      +  `profile.fetch_models()` inside `provider_model_ids()`.
      +- `hermes_cli/doctor.py` adds a `/models` health check for each
      +  `auth_type="api_key"` profile.
      +- `hermes_cli/config.py` injects every `env_var` into
      +  `OPTIONAL_ENV_VARS` so the setup wizard knows about it.
      +- `hermes_cli/runtime_provider.py` reads `profile.api_mode` as a fallback
      +  when URL detection finds nothing.
      +- `agent/model_metadata.py` maps hostname → provider via
      +  `profile.get_hostname()`.
      +- `agent/auxiliary_client.py` reads `profile.default_aux_model` first
      +  before falling back to the legacy hardcoded dict.
      +- `agent/transports/chat_completions.py::_build_kwargs_from_profile()`
      +  invokes `profile.prepare_messages()`, `profile.build_extra_body()`,
      +  and `profile.build_api_kwargs_extras()` on every call.
      +- `run_agent.py` passes `provider_profile=<ProviderProfile>` so the
      +  transport takes the profile path instead of the legacy flag path.
      +
      +---
      +
      +## Adding a provider
      +
      +See `plugins/model-providers/README.md` — drop a new directory there (or
      +under `$HERMES_HOME/plugins/model-providers/` for a private plugin).
      +
      +---
      +
      +## Hooks you can override on `ProviderProfile`
      +
      +| Hook | Purpose |
      +|------|---------|
      +| `get_hostname()` | URL-based detection — default derives from `base_url`. |
      +| `prepare_messages(msgs)` | Provider-specific message preprocessing (Qwen normalises to list-of-parts, injects `cache_control`). |
      +| `build_extra_body(**ctx)` | Provider-specific `extra_body` (OpenRouter provider prefs, Gemini `thinking_config`). |
      +| `build_api_kwargs_extras(**ctx)` | `(extra_body_additions, top_level_kwargs)` — Kimi puts reasoning_effort top-level, Qwen splits `enable_thinking`/`thinking_budget`. |
      +| `fetch_models(*, api_key)` | Live catalog fetch — default hits `{models_url or base_url}/models` with Bearer auth. Override for no-REST providers (Bedrock), OAuth catalogs (Anthropic), or public catalogs (OpenRouter). |
      +
      +---
      +
      +## Configuration fields
      +
      +Full reference in `providers/base.py` dataclass definition.
    • providers/__init__.py
      @@ -0,0 +1,191 @@
      +"""Provider module registry.
      +
      +Provider profiles can live in two places:
      +
      +1. Bundled plugins: ``plugins/model-providers/<name>/`` (shipped with hermes-agent)
      +2. User plugins: ``$HERMES_HOME/plugins/model-providers/<name>/``
      +
      +Each plugin directory contains:
      +  - ``__init__.py`` — calls ``register_provider(profile)`` at import
      +  - ``plugin.yaml`` — manifest (name, kind: model-provider, version, description)
      +
      +Discovery is lazy: the first call to ``get_provider_profile()`` or
      +``list_providers()`` scans both locations and imports every plugin. User
      +plugins override bundled plugins on name collision (last-writer-wins), so
      +third parties can monkey-patch or replace any built-in profile without
      +editing the repo.
      +
      +For backward compatibility, ``providers/*.py`` files (other than ``base.py``
      +and ``__init__.py``) are still discovered via ``pkgutil.iter_modules``.
      +This lets out-of-tree users drop a single-file profile into an editable
      +install without the plugin dir structure. New profiles should prefer the
      +plugin layout.
      +
      +Usage::
      +
      +    from providers import get_provider_profile
      +    profile = get_provider_profile("nvidia")   # ProviderProfile or None
      +    profile = get_provider_profile("kimi")     # checks name + aliases
      +"""
      +
      +from __future__ import annotations
      +
      +import importlib
      +import importlib.util
      +import logging
      +import sys
      +from pathlib import Path
      +
      +from providers.base import OMIT_TEMPERATURE, ProviderProfile  # noqa: F401
      +
      +logger = logging.getLogger(__name__)
      +
      +_REGISTRY: dict[str, ProviderProfile] = {}
      +_ALIASES: dict[str, str] = {}
      +_discovered = False
      +
      +# Repo-root ``plugins/model-providers/`` — populated at discovery time.
      +_BUNDLED_PLUGINS_DIR = (
      +    Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
      +)
      +
      +
      +def register_provider(profile: ProviderProfile) -> None:
      +    """Register a provider profile by name and aliases.
      +
      +    Later registrations with the same name replace earlier ones — so user
      +    plugins under ``$HERMES_HOME/plugins/model-providers/`` can override
      +    bundled profiles without editing repo code.
      +    """
      +    _REGISTRY[profile.name] = profile
      +    for alias in profile.aliases:
      +        _ALIASES[alias] = profile.name
      +
      +
      +def get_provider_profile(name: str) -> ProviderProfile | None:
      +    """Look up a provider profile by name or alias.
      +
      +    Returns None if the provider has no profile (falls back to generic).
      +    """
      +    if not _discovered:
      +        _discover_providers()
      +    canonical = _ALIASES.get(name, name)
      +    return _REGISTRY.get(canonical)
      +
      +
      +def list_providers() -> list[ProviderProfile]:
      +    """Return all registered provider profiles (one per canonical name)."""
      +    if not _discovered:
      +        _discover_providers()
      +    # Deduplicate: _REGISTRY has canonical names; _ALIASES points to same objects
      +    seen: set[int] = set()
      +    result: list[ProviderProfile] = []
      +    for profile in _REGISTRY.values():
      +        pid = id(profile)
      +        if pid not in seen:
      +            seen.add(pid)
      +            result.append(profile)
      +    return result
      +
      +
      +def _user_plugins_dir() -> Path | None:
      +    """Return ``$HERMES_HOME/plugins/model-providers/`` if it exists."""
      +    try:
      +        from hermes_constants import get_hermes_home
      +
      +        d = get_hermes_home() / "plugins" / "model-providers"
      +        return d if d.is_dir() else None
      +    except Exception:
      +        return None
      +
      +
      +def _import_plugin_dir(plugin_dir: Path, source: str) -> None:
      +    """Import a single plugin directory so it self-registers.
      +
      +    ``source`` is "bundled" or "user", used only for log messages.
      +    """
      +    init_file = plugin_dir / "__init__.py"
      +    if not init_file.exists():
      +        return
      +
      +    # Give bundled plugins a stable import path (``plugins.model_providers.<name>``)
      +    # so relative imports within the plugin work. User plugins load via
      +    # ``importlib.util.spec_from_file_location`` with a unique module name so
      +    # multiple HERMES_HOME profiles don't alias each other.
      +    safe_name = plugin_dir.name.replace("-", "_")
      +    if source == "bundled":
      +        module_name = f"plugins.model_providers.{safe_name}"
      +    else:
      +        module_name = f"_hermes_user_provider_{safe_name}"
      +
      +    if module_name in sys.modules:
      +        return  # already imported
      +
      +    try:
      +        spec = importlib.util.spec_from_file_location(
      +            module_name, init_file, submodule_search_locations=[str(plugin_dir)]
      +        )
      +        if spec is None or spec.loader is None:
      +            return
      +        module = importlib.util.module_from_spec(spec)
      +        sys.modules[module_name] = module
      +        spec.loader.exec_module(module)
      +    except Exception as exc:
      +        logger.warning(
      +            "Failed to load %s provider plugin %s: %s", source, plugin_dir.name, exc
      +        )
      +        sys.modules.pop(module_name, None)
      +
      +
      +def _discover_providers() -> None:
      +    """Populate the registry by importing every provider plugin.
      +
      +    Order:
      +      1. Bundled plugins at ``<repo>/plugins/model-providers/<name>/``
      +      2. User plugins at ``$HERMES_HOME/plugins/model-providers/<name>/``
      +      3. Legacy per-file modules at ``providers/<name>.py`` (back-compat)
      +
      +    Each step imports its plugins, which call ``register_provider()`` at
      +    module-level. Later steps win on name collision.
      +    """
      +    global _discovered
      +    if _discovered:
      +        return
      +    _discovered = True
      +
      +    # 1. Bundled plugins — shipped with hermes-agent.
      +    if _BUNDLED_PLUGINS_DIR.is_dir():
      +        for child in sorted(_BUNDLED_PLUGINS_DIR.iterdir()):
      +            if not child.is_dir() or child.name.startswith(("_", ".")):
      +                continue
      +            _import_plugin_dir(child, "bundled")
      +
      +    # 2. User plugins — under $HERMES_HOME/plugins/model-providers/<name>/.
      +    #    These can override any bundled profile of the same name (last-writer-wins
      +    #    in register_provider()).
      +    user_dir = _user_plugins_dir()
      +    if user_dir is not None:
      +        for child in sorted(user_dir.iterdir()):
      +            if not child.is_dir() or child.name.startswith(("_", ".")):
      +                continue
      +            _import_plugin_dir(child, "user")
      +
      +    # 3. Legacy single-file profiles at providers/<name>.py. Kept for
      +    #    back-compat — if someone drops a ``providers/foo.py`` into an
      +    #    editable install, it still works without the plugin layout.
      +    try:
      +        import pkgutil
      +
      +        import providers as _pkg
      +
      +        for _importer, modname, _ispkg in pkgutil.iter_modules(_pkg.__path__):
      +            if modname.startswith("_") or modname == "base":
      +                continue
      +            try:
      +                importlib.import_module(f"providers.{modname}")
      +            except ImportError as exc:
      +                logger.warning(
      +                    "Failed to import legacy provider module %s: %s", modname, exc
      +                )
      +    except Exception:
      +        pass
    • providers/base.py
      @@ -0,0 +1,165 @@
      +"""Provider profile base class.
      +
      +A ProviderProfile declares everything about an inference provider in one place:
      +auth, endpoints, client quirks, request-time quirks. The transport reads this
      +instead of receiving 20+ boolean flags.
      +
      +Provider profiles are DECLARATIVE — they describe the provider's behavior.
      +They do NOT own client construction, credential rotation, or streaming.
      +Those stay on AIAgent.
      +"""
      +
      +from __future__ import annotations
      +
      +import logging
      +from dataclasses import dataclass, field
      +from typing import Any
      +
      +logger = logging.getLogger(__name__)
      +
      +# Sentinel for "omit temperature entirely" (Kimi: server manages it)
      +OMIT_TEMPERATURE = object()
      +
      +
      +@dataclass
      +class ProviderProfile:
      +    """Base provider profile — subclass or instantiate with overrides."""
      +
      +    # ── Identity ─────────────────────────────────────────────
      +    name: str
      +    api_mode: str = "chat_completions"
      +    aliases: tuple = ()
      +
      +    # ── Human-readable metadata ───────────────────────────────
      +    display_name: str = ""       # e.g. "GMI Cloud" — shown in picker/labels
      +    description: str = ""        # e.g. "GMI Cloud (multi-model direct API)" — picker subtitle
      +    signup_url: str = ""         # e.g. "https://www.gmicloud.ai/" — shown during setup
      +
      +    # ── Auth & endpoints ─────────────────────────────────────
      +    env_vars: tuple = ()
      +    base_url: str = ""
      +    models_url: str = ""  # explicit models endpoint; falls back to {base_url}/models
      +    auth_type: str = "api_key"   # api_key|oauth_device_code|oauth_external|copilot|aws_sdk
      +
      +    # ── Model catalog ─────────────────────────────────────────
      +    # fallback_models: curated list shown in /model picker when live fetch fails.
      +    # Only agentic models that support tool calling should appear here.
      +    fallback_models: tuple = ()
      +
      +    # hostname: base hostname for URL→provider reverse-mapping in model_metadata.py
      +    # e.g. "api.gmi-serving.com". Derived from base_url when empty.
      +    hostname: str = ""
      +
      +    # ── Client-level quirks (set once at client construction) ─
      +    default_headers: dict[str, str] = field(default_factory=dict)
      +
      +    # ── Request-level quirks ─────────────────────────────────
      +    # Temperature: None = use caller's default, OMIT_TEMPERATURE = don't send
      +    fixed_temperature: Any = None
      +    default_max_tokens: int | None = None
      +    default_aux_model: str = (
      +        ""  # cheap model for auxiliary tasks (compression, vision, etc.)
      +    )
      +    # empty = use main model
      +
      +    # ── Hooks (override in subclass for complex providers) ───
      +
      +    def get_hostname(self) -> str:
      +        """Return the provider's base hostname for URL-based detection.
      +
      +        Uses self.hostname if set explicitly, otherwise derives it from base_url.
      +        e.g. 'https://api.gmi-serving.com/v1' → 'api.gmi-serving.com'
      +        """
      +        if self.hostname:
      +            return self.hostname
      +        if self.base_url:
      +            from urllib.parse import urlparse
      +            return urlparse(self.base_url).hostname or ""
      +        return ""
      +
      +    def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
      +        """Provider-specific message preprocessing.
      +
      +        Called AFTER codex field sanitization, BEFORE developer role swap.
      +        Default: pass-through.
      +        """
      +        return messages
      +
      +    def build_extra_body(
      +        self, *, session_id: str | None = None, **context: Any
      +    ) -> dict[str, Any]:
      +        """Provider-specific extra_body fields.
      +
      +        Merged into the API kwargs extra_body. Default: empty dict.
      +        """
      +        return {}
      +
      +    def build_api_kwargs_extras(
      +        self,
      +        *,
      +        reasoning_config: dict | None = None,
      +        **context: Any,
      +    ) -> tuple[dict[str, Any], dict[str, Any]]:
      +        """Provider-specific kwargs split between extra_body and top-level api_kwargs.
      +
      +        Returns (extra_body_additions, top_level_kwargs).
      +        The transport merges extra_body_additions into extra_body, and
      +        top_level_kwargs directly into api_kwargs.
      +
      +        This split exists because some providers put reasoning config in
      +        extra_body (OpenRouter: extra_body.reasoning) while others put it
      +        as top-level api_kwargs (Kimi: api_kwargs.reasoning_effort).
      +
      +        Default: ({}, {}).
      +        """
      +        return {}, {}
      +
      +    def fetch_models(
      +        self,
      +        *,
      +        api_key: str | None = None,
      +        timeout: float = 8.0,
      +    ) -> list[str] | None:
      +        """Fetch the live model list from the provider's models endpoint.
      +
      +        Returns a list of model ID strings, or None if the fetch failed or
      +        the provider does not support live model listing.
      +
      +        Resolution order for the endpoint URL:
      +          1. self.models_url  (explicit override — use when the models
      +             endpoint differs from the inference base URL, e.g. OpenRouter
      +             exposes a public catalog at /api/v1/models while inference is
      +             at /api/v1)
      +          2. self.base_url + "/models"  (standard OpenAI-compat fallback)
      +
      +        The default implementation sends Bearer auth when api_key is given
      +        and forwards self.default_headers. Override to customise auth, path,
      +        response shape, or to return None for providers with no REST catalog.
      +
      +        Callers must always fall back to the static _PROVIDER_MODELS list
      +        when this returns None.
      +        """
      +        url = (self.models_url or "").strip()
      +        if not url:
      +            if not self.base_url:
      +                return None
      +            url = self.base_url.rstrip("/") + "/models"
      +
      +        import json
      +        import urllib.request
      +
      +        req = urllib.request.Request(url)
      +        if api_key:
      +            req.add_header("Authorization", f"Bearer {api_key}")
      +        req.add_header("Accept", "application/json")
      +        for k, v in self.default_headers.items():
      +            req.add_header(k, v)
      +
      +        try:
      +            with urllib.request.urlopen(req, timeout=timeout) as resp:
      +                data = json.loads(resp.read().decode())
      +            items = data if isinstance(data, list) else data.get("data", [])
      +            return [m["id"] for m in items if isinstance(m, dict) and "id" in m]
      +        except Exception as exc:
      +            logger.debug("fetch_models(%s): %s", self.name, exc)
      +            return None
    • pyproject.toml
      @@ -142,7 +142,7 @@ hermes_cli = ["web_dist/**/*"]
       gateway = ["assets/**/*"]
       
       [tool.setuptools.packages.find]
      -include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
      +include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
       
       [tool.pytest.ini_options]
       testpaths = ["tests"]
    • run_agent.py
      @@ -1451,6 +1451,17 @@ def __init__(
                       elif base_url_host_matches(effective_base, "chatgpt.com"):
                           from agent.auxiliary_client import _codex_cloudflare_headers
                           client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key)
      +                elif "default_headers" not in client_kwargs:
      +                    # Fall back to profile.default_headers for providers that
      +                    # declare custom headers (e.g. Vercel AI Gateway attribution,
      +                    # Kimi User-Agent on non-kimi.com endpoints).
      +                    try:
      +                        from providers import get_provider_profile as _gpf
      +                        _ph = _gpf(self.provider)
      +                        if _ph and _ph.default_headers:
      +                            client_kwargs["default_headers"] = dict(_ph.default_headers)
      +                    except Exception:
      +                        pass
                   else:
                       # No explicit creds — use the centralized provider router
                       from agent.auxiliary_client import resolve_provider_client
      @@ -6251,7 +6262,19 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None:
                       self._client_kwargs.get("api_key", "")
                   )
               else:
      -            self._client_kwargs.pop("default_headers", None)
      +            # No URL-specific headers — check profile.default_headers before clearing.
      +            _ph_headers = None
      +            try:
      +                from providers import get_provider_profile as _gpf2
      +                _ph2 = _gpf2(self.provider)
      +                if _ph2 and _ph2.default_headers:
      +                    _ph_headers = dict(_ph2.default_headers)
      +            except Exception:
      +                pass
      +            if _ph_headers:
      +                self._client_kwargs["default_headers"] = _ph_headers
      +            else:
      +                self._client_kwargs.pop("default_headers", None)
       
           def _swap_credential(self, entry) -> None:
               runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
      @@ -8469,7 +8492,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
                   _omit_temp = False
                   _fixed_temp = None
       
      -        # Provider preferences (OpenRouter-specific)
      +        # Provider preferences (OpenRouter-style)
               _prefs: Dict[str, Any] = {}
               if self.providers_allowed:
                   _prefs["only"] = self.providers_allowed
      @@ -8484,16 +8507,16 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
               if self.provider_data_collection:
                   _prefs["data_collection"] = self.provider_data_collection
       
      -        # Anthropic max output for Claude on OpenRouter/Nous
      +        # Claude max-output override on aggregators
               _ant_max = None
               if (_is_or or _is_nous) and "claude" in (self.model or "").lower():
                   try:
                       from agent.anthropic_adapter import _get_anthropic_max_output
                       _ant_max = _get_anthropic_max_output(self.model)
                   except Exception:
      -                pass  # fail open — let the proxy pick its default
      +                pass
       
      -        # Qwen session metadata precomputed here (promptId is per-call random)
      +        # Qwen session metadata
               _qwen_meta = None
               if _is_qwen:
                   _qwen_meta = {
      @@ -8501,8 +8524,44 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
                       "promptId": str(uuid.uuid4()),
                   }
       
      -        # Ephemeral max output override — consume immediately so the next
      -        # turn doesn't inherit it.
      +        # ── Provider profile path (registered providers) ───────────────────
      +        # Profiles handle per-provider quirks via hooks. When a profile is
      +        # found, delegate fully; otherwise fall through to the legacy flag path.
      +        try:
      +            from providers import get_provider_profile
      +            _profile = get_provider_profile(self.provider)
      +        except Exception:
      +            _profile = None
      +
      +        if _profile:
      +            _ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None)
      +            if _ephemeral_out is not None:
      +                self._ephemeral_max_output_tokens = None
      +
      +            return _ct.build_kwargs(
      +                model=self.model,
      +                messages=api_messages,
      +                tools=self.tools,
      +                base_url=self.base_url,
      +                timeout=self._resolved_api_call_timeout(),
      +                max_tokens=[redacted:secret-assignment]
      +                ephemeral_max_output_tokens=[redacted:secret-assignment]
      +                max_tokens_param_fn=[redacted:secret-assignment]
      +                reasoning_config=self.reasoning_config,
      +                request_overrides=self.request_overrides,
      +                session_id=getattr(self, "session_id", None),
      +                provider_profile=_profile,
      +                ollama_num_ctx=self._ollama_num_ctx,
      +                # Context forwarded to profile hooks:
      +                provider_preferences=_prefs or None,
      +                anthropic_max_output=_ant_max,
      +                supports_reasoning=self._supports_reasoning_extra_body(),
      +                qwen_session_metadata=_qwen_meta,
      +            )
      +
      +        # ── Legacy flag path ────────────────────────────────────────────
      +        # Reached only when get_provider_profile() returns None — i.e. a
      +        # completely unknown provider not in providers/ registry.
               _ephemeral_out = getattr(self, "_ephemeral_max_output_tokens", None)
               if _ephemeral_out is not None:
                   self._ephemeral_max_output_tokens = None
    • tests/agent/test_minimax_provider.py
      @@ -71,17 +71,17 @@ def test_thinking_still_works_for_claude(self):
       
       
       class TestMinimaxAuxModel:
      -    """Verify auxiliary model is standard (not highspeed)."""
      +    """Verify auxiliary model is standard (not highspeed) — now reads from profiles."""
       
           def test_minimax_aux_is_standard(self):
      -        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
      -        assert _API_KEY_PROVIDER_AUX_MODELS["minimax"] == "MiniMax-M2.7"
      -        assert _API_KEY_PROVIDER_AUX_MODELS["minimax-cn"] == "MiniMax-M2.7"
      +        from agent.auxiliary_client import _get_aux_model_for_provider
      +        assert _get_aux_model_for_provider("minimax") == "MiniMax-M2.7"
      +        assert _get_aux_model_for_provider("minimax-cn") == "MiniMax-M2.7"
       
           def test_minimax_aux_not_highspeed(self):
      -        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
      -        assert "highspeed" not in _API_KEY_PROVIDER_AUX_MODELS["minimax"]
      -        assert "highspeed" not in _API_KEY_PROVIDER_AUX_MODELS["minimax-cn"]
      +        from agent.auxiliary_client import _get_aux_model_for_provider
      +        assert "highspeed" not in _get_aux_model_for_provider("minimax")
      +        assert "highspeed" not in _get_aux_model_for_provider("minimax-cn")
       
       
       class TestMinimaxBetaHeaders:
    • tests/agent/transports/test_chat_completions.py
      @@ -73,17 +73,21 @@ def test_tools_included(self, transport):
               assert kw["tools"] == tools
       
           def test_openrouter_provider_prefs(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("openrouter")
               msgs = [{"role": "user", "content": "Hi"}]
               kw = transport.build_kwargs(
                   model="gpt-4o", messages=msgs,
      -            is_openrouter=True,
      +            provider_profile=profile,
                   provider_preferences={"only": ["openai"]},
               )
               assert kw["extra_body"]["provider"] == {"only": ["openai"]}
       
           def test_nous_tags(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("nous")
               msgs = [{"role": "user", "content": "Hi"}]
      -        kw = transport.build_kwargs(model="gpt-4o", messages=msgs, is_nous=True)
      +        kw = transport.build_kwargs(model="gpt-4o", messages=msgs, provider_profile=profile)
               assert kw["extra_body"]["tags"] == ["product=hermes-agent"]
       
           def test_reasoning_default(self, transport):
      @@ -95,29 +99,36 @@ def test_reasoning_default(self, transport):
               assert kw["extra_body"]["reasoning"] == {"enabled": True, "effort": "medium"}
       
           def test_nous_omits_disabled_reasoning(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("nous")
               msgs = [{"role": "user", "content": "Hi"}]
               kw = transport.build_kwargs(
                   model="gpt-4o", messages=msgs,
      +            provider_profile=profile,
                   supports_reasoning=True,
      -            is_nous=True,
                   reasoning_config={"enabled": False},
               )
               # Nous rejects enabled=false; reasoning omitted entirely
               assert "reasoning" not in kw.get("extra_body", {})
       
           def test_ollama_num_ctx(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("custom")
               msgs = [{"role": "user", "content": "Hi"}]
               kw = transport.build_kwargs(
                   model="llama3", messages=msgs,
      +            provider_profile=profile,
                   ollama_num_ctx=32768,
               )
               assert kw["extra_body"]["options"]["num_ctx"] == 32768
       
           def test_custom_think_false(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("custom")
               msgs = [{"role": "user", "content": "Hi"}]
               kw = transport.build_kwargs(
                   model="qwen3", messages=msgs,
      -            is_custom_provider=True,
      +            provider_profile=profile,
                   reasoning_config={"effort": "none"},
               )
               assert kw["extra_body"]["think"] is False
      @@ -304,23 +315,29 @@ def test_ephemeral_overrides_max_tokens(self, transport):
               assert kw["max_tokens"] == 2048
       
           def test_nvidia_default_max_tokens(self, transport):
      +        """NVIDIA max_tokens=16384 is now set via ProviderProfile, not legacy flag."""
      +        from providers import get_provider_profile
      +
      +        profile = get_provider_profile("nvidia")
               msgs = [{"role": "user", "content": "Hi"}]
               kw = transport.build_kwargs(
      -            model="glm-4.7", messages=msgs,
      -            is_nvidia_nim=True,
      +            model="nvidia/llama-3.1-405b-instruct",
      +            messages=msgs,
                   max_tokens_param_fn=lambda n: {"max_tokens": n},
      +            provider_profile=profile,
               )
      -        # NVIDIA default: 16384
               assert kw["max_tokens"] == 16384
       
           def test_qwen_default_max_tokens(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("qwen-oauth")
               msgs = [{"role": "user", "content": "Hi"}]
               kw = transport.build_kwargs(
                   model="qwen3-coder-plus", messages=msgs,
      -            is_qwen_portal=True,
      +            provider_profile=profile,
                   max_tokens_param_fn=lambda n: {"max_tokens": n},
               )
      -        # Qwen default: 65536
      +        # Qwen default: 65536 from profile.default_max_tokens
               assert kw["max_tokens"] == 65536
       
           def test_anthropic_max_output_for_claude_on_aggregator(self, transport):
      @@ -343,14 +360,23 @@ def test_request_overrides_last(self, transport):
               assert kw["service_tier"] == "priority"
       
           def test_fixed_temperature(self, transport):
      +        """Fixed temperature is now set via ProviderProfile.fixed_temperature."""
      +        from providers.base import ProviderProfile
               msgs = [{"role": "user", "content": "Hi"}]
      -        kw = transport.build_kwargs(model="gpt-4o", messages=msgs, fixed_temperature=0.6)
      +        kw = transport.build_kwargs(
      +            model="gpt-4o", messages=msgs,
      +            provider_profile=ProviderProfile(name="_t", fixed_temperature=0.6),
      +        )
               assert kw["temperature"] == 0.6
       
           def test_omit_temperature(self, transport):
      +        """Omit temperature is set via ProviderProfile with OMIT_TEMPERATURE sentinel."""
      +        from providers.base import ProviderProfile, OMIT_TEMPERATURE
               msgs = [{"role": "user", "content": "Hi"}]
      -        kw = transport.build_kwargs(model="gpt-4o", messages=msgs, omit_temperature=True, fixed_temperature=0.5)
      -        # omit wins
      +        kw = transport.build_kwargs(
      +            model="gpt-4o", messages=msgs,
      +            provider_profile=ProviderProfile(name="_t", fixed_temperature=OMIT_TEMPERATURE),
      +        )
               assert "temperature" not in kw
       
       
      @@ -358,18 +384,22 @@ class TestChatCompletionsKimi:
           """Regression tests for the Kimi/Moonshot quirks migrated into the transport."""
       
           def test_kimi_max_tokens_default(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("kimi-coding")
               kw = transport.build_kwargs(
                   model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
      -            is_kimi=True,
      +            provider_profile=profile,
                   max_tokens_param_fn=lambda n: {"max_tokens": n},
               )
      -        # Kimi CLI default: 32000
      +        # Kimi CLI default: 32000 from KimiProfile.default_max_tokens
               assert kw["max_tokens"] == 32000
       
           def test_kimi_reasoning_effort_top_level(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("kimi-coding")
               kw = transport.build_kwargs(
                   model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
      -            is_kimi=True,
      +            provider_profile=profile,
                   reasoning_config={"effort": "high"},
                   max_tokens_param_fn=lambda n: {"max_tokens": n},
               )
      @@ -387,17 +417,21 @@ def test_kimi_reasoning_effort_omitted_when_thinking_disabled(self, transport):
               assert "reasoning_effort" not in kw
       
           def test_kimi_thinking_enabled_extra_body(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("kimi-coding")
               kw = transport.build_kwargs(
                   model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
      -            is_kimi=True,
      +            provider_profile=profile,
                   max_tokens_param_fn=lambda n: {"max_tokens": n},
               )
               assert kw["extra_body"]["thinking"] == {"type": "enabled"}
       
           def test_kimi_thinking_disabled_extra_body(self, transport):
      +        from providers import get_provider_profile
      +        profile = get_provider_profile("kimi-coding")
               kw = transport.build_kwargs(
                   model="kimi-k2", messages=[{"role": "user", "content": "Hi"}],
      -            is_kimi=True,
      +            provider_profile=profile,
                   reasoning_config={"enabled": False},
                   max_tokens_param_fn=lambda n: {"max_tokens": n},
               )
    • tests/hermes_cli/test_gmi_provider.py
      @@ -269,9 +269,9 @@ def test_known_gmi_endpoint_still_uses_endpoint_metadata(self):
       
       class TestGmiAuxiliary:
           def test_aux_default_model(self):
      -        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
      +        from agent.auxiliary_client import _get_aux_model_for_provider
       
      -        assert _API_KEY_PROVIDER_AUX_MODELS["gmi"] == "google/gemini-3.1-flash-lite-preview"
      +        assert _get_aux_model_for_provider("gmi") == "google/gemini-3.1-flash-lite-preview"
       
           def test_resolve_provider_client_uses_gmi_aux_default(self, monkeypatch):
               monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
    • tests/providers/test_e2e_wiring.py
      @@ -0,0 +1,118 @@
      +"""E2E tests: verify _build_kwargs_from_profile produces correct output.
      +
      +These tests call _build_kwargs_from_profile on the transport directly,
      +without importing run_agent (which would cause xdist worker contamination).
      +"""
      +
      +import pytest
      +from agent.transports.chat_completions import ChatCompletionsTransport
      +from providers import get_provider_profile
      +
      +
      [redacted:email]
      +def transport():
      +    return ChatCompletionsTransport()
      +
      +
      +def _msgs():
      +    return [{"role": "user", "content": "hi"}]
      +
      +
      +class TestNvidiaProfileWiring:
      +    def test_nvidia_gets_default_max_tokens(self, transport):
      +        profile = get_provider_profile("nvidia")
      +        kwargs = transport.build_kwargs(
      +            model="nvidia/llama-3.1-nemotron-70b-instruct",
      +            messages=_msgs(),
      +            tools=None,
      +            provider_profile=profile,
      +            max_tokens=None,
      +            max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
      +            timeout=300,
      +            reasoning_config=None,
      +            request_overrides=None,
      +            session_id="test",
      +            ollama_num_ctx=None,
      +        )
      +        # NVIDIA profile sets default_max_tokens=16384
      +        assert kwargs.get("max_tokens") == 16384
      +
      +    def test_nvidia_nim_alias(self, transport):
      +        profile = get_provider_profile("nvidia-nim")
      +        assert profile is not None
      +        assert profile.name == "nvidia"
      +        assert profile.default_max_tokens == 16384
      +
      +    def test_nvidia_model_passed(self, transport):
      +        profile = get_provider_profile("nvidia")
      +        kwargs = transport.build_kwargs(
      +            model="nvidia/test-model",
      +            messages=_msgs(),
      +            tools=None,
      +            provider_profile=profile,
      +            max_tokens=None,
      +            max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
      +            timeout=300,
      +            reasoning_config=None,
      +            request_overrides=None,
      +            session_id="test",
      +            ollama_num_ctx=None,
      +        )
      +        assert kwargs["model"] == "nvidia/test-model"
      +
      +    def test_nvidia_messages_passed(self, transport):
      +        profile = get_provider_profile("nvidia")
      +        msgs = _msgs()
      +        kwargs = transport.build_kwargs(
      +            model="nvidia/test",
      +            messages=msgs,
      +            tools=None,
      +            provider_profile=profile,
      +            max_tokens=None,
      +            max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
      +            timeout=300,
      +            reasoning_config=None,
      +            request_overrides=None,
      +            session_id="test",
      +            ollama_num_ctx=None,
      +        )
      +        assert kwargs["messages"] == msgs
      +
      +
      +class TestDeepSeekProfileWiring:
      +    def test_deepseek_no_forced_max_tokens(self, transport):
      +        profile = get_provider_profile("deepseek")
      +        kwargs = transport.build_kwargs(
      +            model="deepseek-chat",
      +            messages=_msgs(),
      +            tools=None,
      +            provider_profile=profile,
      +            max_tokens=None,
      +            max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
      +            timeout=300,
      +            reasoning_config=None,
      +            request_overrides=None,
      +            session_id="test",
      +            ollama_num_ctx=None,
      +        )
      +        # DeepSeek has no default_max_tokens
      +        assert kwargs["model"] == "deepseek-chat"
      +        assert kwargs.get("max_tokens") is None or "max_tokens" not in kwargs
      +
      +    def test_deepseek_messages_passed(self, transport):
      +        profile = get_provider_profile("deepseek")
      +        msgs = _msgs()
      +        kwargs = transport.build_kwargs(
      +            model="deepseek-chat",
      +            messages=msgs,
      +            tools=None,
      +            provider_profile=profile,
      +            max_tokens=None,
      +            max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {},
      +            timeout=300,
      +            reasoning_config=None,
      +            request_overrides=None,
      +            session_id="test",
      +            ollama_num_ctx=None,
      +        )
      +        assert kwargs["messages"] == msgs
    • tests/providers/test_plugin_discovery.py
      @@ -0,0 +1,145 @@
      +"""Tests for the model-providers plugin discovery system.
      +
      +Verifies that:
      + 1. All bundled providers at plugins/model-providers/<name>/ are discovered
      + 2. User plugins at $HERMES_HOME/plugins/model-providers/<name>/ override bundled
      + 3. plugin.yaml manifests with kind=model-provider are correctly categorized
      +"""
      +
      +from __future__ import annotations
      +
      +import importlib
      +import sys
      +from pathlib import Path
      +
      +import pytest
      +
      +
      +REPO_ROOT = Path(__file__).resolve().parents[2]
      +
      +
      +def _clear_provider_caches():
      +    """Force providers/__init__.py to re-discover on next list_providers()."""
      +    import providers as _pkg
      +    _pkg._REGISTRY.clear()
      +    _pkg._ALIASES.clear()
      +    _pkg._discovered = False
      +    # Evict any cached plugin modules so the next import re-executes.
      +    for mod in list(sys.modules.keys()):
      +        if (
      +            mod.startswith("plugins.model_providers")
      +            or mod.startswith("_hermes_user_provider")
      +        ):
      +            del sys.modules[mod]
      +
      +
      +def test_bundled_plugins_discovered():
      +    """Every plugins/model-providers/<name>/ should contain a plugin.yaml + __init__.py."""
      +    plugins_dir = REPO_ROOT / "plugins" / "model-providers"
      +    assert plugins_dir.is_dir(), f"Missing {plugins_dir}"
      +
      +    child_dirs = [c for c in plugins_dir.iterdir() if c.is_dir()]
      +    assert len(child_dirs) >= 28, f"Expected at least 28 provider plugins, found {len(child_dirs)}"
      +
      +    for child in child_dirs:
      +        assert (child / "__init__.py").exists(), f"{child.name} missing __init__.py"
      +        assert (child / "plugin.yaml").exists(), f"{child.name} missing plugin.yaml"
      +
      +
      +def test_all_33_profiles_register():
      +    """After discovery, the registry must contain exactly 33 distinct profiles."""
      +    _clear_provider_caches()
      +    from providers import list_providers
      +
      +    profiles = list_providers()
      +    names = sorted(p.name for p in profiles)
      +    assert len(names) == 33, f"Expected 33 profiles, got {len(names)}: {names}"
      +
      +    # Spot-check representative providers from different categories
      +    for required in (
      +        "openrouter", "anthropic", "custom", "bedrock", "openai-codex",
      +        "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan",
      +    ):
      +        assert required in names, f"Missing profile: {required}"
      +
      +
      +def test_user_plugin_overrides_bundled(tmp_path, monkeypatch):
      +    """A user plugin with the same name must override the bundled profile."""
      +    # Point HERMES_HOME at a fresh temp dir
      +    hermes_home = tmp_path / ".hermes"
      +    hermes_home.mkdir()
      +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +    # get_hermes_home() may be module-cached depending on codebase; ensure the
      +    # env var is the source of truth. Most code paths re-read it each call.
      +
      +    # Drop a user plugin that replaces 'gmi'
      +    user_gmi = hermes_home / "plugins" / "model-providers" / "gmi"
      +    user_gmi.mkdir(parents=True)
      +    (user_gmi / "__init__.py").write_text(
      +        "from providers import register_provider\n"
      +        "from providers.base import ProviderProfile\n"
      +        "\n"
      +        "custom_gmi = ProviderProfile(\n"
      +        '    name="gmi",\n'
      +        '    aliases=("gmi-user-override-test",),\n'
      +        '    env_vars=("GMI_API_KEY",),\n'
      +        '    base_url="https://user-override.example.com/v1",\n'
      +        '    auth_type="api_key",\n'
      +        ")\n"
      +        "register_provider(custom_gmi)\n"
      +    )
      +    (user_gmi / "plugin.yaml").write_text(
      +        "name: gmi-user-override\n"
      +        "kind: model-provider\n"
      +        "version: 0.0.1\n"
      +        "description: Test user override\n"
      +    )
      +
      +    _clear_provider_caches()
      +    from providers import get_provider_profile
      +
      +    gmi = get_provider_profile("gmi")
      +    assert gmi is not None
      +    assert gmi.base_url == "https://user-override.example.com/v1", (
      +        f"User override not applied; got base_url={gmi.base_url!r}"
      +    )
      +    assert "gmi-user-override-test" in gmi.aliases
      +
      +    # Clean up: reset discovery state so other tests see the bundled version
      +    _clear_provider_caches()
      +
      +
      +def test_general_plugin_manager_skips_model_provider_kind(tmp_path, monkeypatch):
      +    """The general PluginManager must NOT import model-provider plugins
      +    (providers/__init__.py handles them). It records the manifest only."""
      +    from hermes_cli import plugins as plugin_mod
      +
      +    hermes_home = tmp_path / ".hermes"
      +    hermes_home.mkdir()
      +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
      +
      +    # Create a user-installed plugin with an explicit kind: model-provider.
      +    user_plugin = hermes_home / "plugins" / "test-model-provider"
      +    user_plugin.mkdir(parents=True)
      +    (user_plugin / "plugin.yaml").write_text(
      +        "name: test-model-provider\n"
      +        "kind: model-provider\n"
      +        "version: 0.0.1\n"
      +    )
      +    (user_plugin / "__init__.py").write_text(
      +        # Intentionally broken import — if the general loader tries to
      +        # import this module, the test will fail with ImportError.
      +        "raise AssertionError('model-provider plugins must not be imported by PluginManager')\n"
      +    )
      +
      +    # Fresh manager
      +    manager = plugin_mod.PluginManager()
      +    manager.discover_and_load(force=True)
      +
      +    # The manifest should be recorded but not loaded
      +    loaded = manager._plugins.get("test-model-provider")
      +    assert loaded is not None
      +    assert loaded.manifest.kind == "model-provider"
      +    # No import means the module must NOT be in the plugins list as a loaded one.
      +    # We check that the general loader didn't crash and didn't raise from the
      +    # broken __init__.py.
    • tests/providers/test_profile_wiring.py
      @@ -0,0 +1,290 @@
      +"""Profile-path parity tests: verify profile path produces identical output to legacy flags.
      +
      +Each test calls build_kwargs twice — once with legacy flags, once with provider_profile —
      +and asserts the output is identical. This catches any behavioral drift between the two paths.
      +"""
      +
      +import pytest
      +from agent.transports.chat_completions import ChatCompletionsTransport
      +from providers import get_provider_profile
      +
      +
      [redacted:email]
      +def transport():
      +    return ChatCompletionsTransport()
      +
      +
      +def _msgs():
      +    return [{"role": "user", "content": "hello"}]
      +
      +
      +def _max_tokens_fn(n):
      +    return {"max_completion_tokens": n}
      +
      +
      +class TestNvidiaProfileParity:
      +    def test_max_tokens_match(self, transport):
      +        """NVIDIA profile sets max_tokens=[redacted:secret-assignment] legacy flag is removed."""
      +        profile = transport.build_kwargs(
      +            model="nvidia/nemotron", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("nvidia"),
      +            max_tokens_param_fn=[redacted:secret-assignment]
      +        )
      +        assert profile["max_completion_tokens"] == 16384
      +
      +
      +class TestKimiProfileParity:
      +    def test_temperature_omitted(self, transport):
      +        legacy = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"), omit_temperature=True,
      +        )
      +        profile = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi"),
      +        )
      +        assert "temperature" not in legacy
      +        assert "temperature" not in profile
      +
      +    def test_max_tokens(self, transport):
      +        legacy = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"), max_tokens_param_fn=[redacted:secret-assignment]
      +        )
      +        profile = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi"),
      +            max_tokens_param_fn=[redacted:secret-assignment]
      +        )
      +        assert profile["max_completion_tokens"] == legacy["max_completion_tokens"] == 32000
      +
      +    def test_thinking_enabled(self, transport):
      +        rc = {"enabled": True, "effort": "high"}
      +        legacy = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"), reasoning_config=rc,
      +        )
      +        profile = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi"),
      +            reasoning_config=rc,
      +        )
      +        assert profile["extra_body"]["thinking"] == legacy["extra_body"]["thinking"]
      +        assert profile["reasoning_effort"] == legacy["reasoning_effort"] == "high"
      +
      +    def test_thinking_disabled(self, transport):
      +        rc = {"enabled": False}
      +        legacy = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"), reasoning_config=rc,
      +        )
      +        profile = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi"),
      +            reasoning_config=rc,
      +        )
      +        assert profile["extra_body"]["thinking"] == legacy["extra_body"]["thinking"]
      +        assert profile["extra_body"]["thinking"]["type"] == "disabled"
      +        assert "reasoning_effort" not in profile
      +        assert "reasoning_effort" not in legacy
      +
      +    def test_reasoning_effort_default(self, transport):
      +        rc = {"enabled": True}
      +        legacy = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"), reasoning_config=rc,
      +        )
      +        profile = transport.build_kwargs(
      +            model="kimi-k2", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("kimi"),
      +            reasoning_config=rc,
      +        )
      +        assert profile["reasoning_effort"] == legacy["reasoning_effort"] == "medium"
      +
      +
      +class TestOpenRouterProfileParity:
      +    def test_provider_preferences(self, transport):
      +        prefs = {"allow": ["anthropic"]}
      +        legacy = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"), provider_preferences=prefs,
      +        )
      +        profile = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            provider_preferences=prefs,
      +        )
      +        assert profile["extra_body"]["provider"] == legacy["extra_body"]["provider"]
      +
      +    def test_reasoning_full_config(self, transport):
      +        rc = {"enabled": True, "effort": "high"}
      +        legacy = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"), supports_reasoning=True, reasoning_config=rc,
      +        )
      +        profile = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            supports_reasoning=True, reasoning_config=rc,
      +        )
      +        assert profile["extra_body"]["reasoning"] == legacy["extra_body"]["reasoning"]
      +
      +    def test_default_reasoning(self, transport):
      +        legacy = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"), supports_reasoning=True,
      +        )
      +        profile = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            supports_reasoning=True,
      +        )
      +        assert profile["extra_body"]["reasoning"] == legacy["extra_body"]["reasoning"]
      +
      +
      +class TestNousProfileParity:
      +    def test_tags(self, transport):
      +        legacy = transport.build_kwargs(
      +            model="hermes-3", messages=_msgs(), tools=None, provider_profile=get_provider_profile("nous"),
      +        )
      +        profile = transport.build_kwargs(
      +            model="hermes-3", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("nous"),
      +        )
      +        assert profile["extra_body"]["tags"] == legacy["extra_body"]["tags"]
      +
      +    def test_reasoning_omitted_when_disabled(self, transport):
      +        rc = {"enabled": False}
      +        legacy = transport.build_kwargs(
      +            model="hermes-3", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("nous"), supports_reasoning=True, reasoning_config=rc,
      +        )
      +        profile = transport.build_kwargs(
      +            model="hermes-3", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("nous"),
      +            supports_reasoning=True, reasoning_config=rc,
      +        )
      +        assert "reasoning" not in legacy.get("extra_body", {})
      +        assert "reasoning" not in profile.get("extra_body", {})
      +
      +
      +class TestQwenProfileParity:
      +    def test_max_tokens(self, transport):
      +        legacy = transport.build_kwargs(
      +            model="qwen3.5", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("qwen-oauth"), max_tokens_param_fn=[redacted:secret-assignment]
      +        )
      +        profile = transport.build_kwargs(
      +            model="qwen3.5", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("qwen"),
      +            max_tokens_param_fn=[redacted:secret-assignment]
      +        )
      +        assert profile["max_completion_tokens"] == legacy["max_completion_tokens"] == 65536
      +
      +    def test_vl_high_resolution(self, transport):
      +        legacy = transport.build_kwargs(
      +            model="qwen3.5", messages=_msgs(), tools=None, provider_profile=get_provider_profile("qwen-oauth"),
      +        )
      +        profile = transport.build_kwargs(
      +            model="qwen3.5", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("qwen"),
      +        )
      +        assert profile["extra_body"]["vl_high_resolution_images"] == legacy["extra_body"]["vl_high_resolution_images"]
      +
      +    def test_metadata_top_level(self, transport):
      +        meta = {"sessionId": "s123", "promptId": "p456"}
      +        legacy = transport.build_kwargs(
      +            model="qwen3.5", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("qwen-oauth"), qwen_session_metadata=meta,
      +        )
      +        profile = transport.build_kwargs(
      +            model="qwen3.5", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("qwen"),
      +            qwen_session_metadata=meta,
      +        )
      +        assert profile["metadata"] == legacy["metadata"] == meta
      +        assert "metadata" not in profile.get("extra_body", {})
      +
      +    def test_message_preprocessing(self, transport):
      +        """Qwen profile normalizes string content to list-of-parts."""
      +        msgs = [
      +            {"role": "system", "content": "You are helpful."},
      +            {"role": "user", "content": "hello"},
      +        ]
      +        profile = transport.build_kwargs(
      +            model="qwen3.5", messages=msgs, tools=None,
      +            provider_profile=get_provider_profile("qwen"),
      +        )
      +        out_msgs = profile["messages"]
      +        # System message content normalized + cache_control injected
      +        assert isinstance(out_msgs[0]["content"], list)
      +        assert out_msgs[0]["content"][0]["type"] == "text"
      +        assert "cache_control" in out_msgs[0]["content"][-1]
      +        # User message content normalized
      +        assert isinstance(out_msgs[1]["content"], list)
      +        assert out_msgs[1]["content"][0] == {"type": "text", "text": "hello"}
      +
      +
      +class TestDeveloperRoleParity:
      +    """Developer role swap must work on BOTH legacy and profile paths."""
      +
      +    def test_legacy_path_swaps_for_gpt5(self, transport):
      +        msgs = [{"role": "system", "content": "Be helpful"}, {"role": "user", "content": "hi"}]
      +        kw = transport.build_kwargs(
      +            model="gpt-5.4", messages=msgs, tools=None,
      +        )
      +        assert kw["messages"][0]["role"] == "developer"
      +
      +    def test_profile_path_swaps_for_gpt5(self, transport):
      +        msgs = [{"role": "system", "content": "Be helpful"}, {"role": "user", "content": "hi"}]
      +        kw = transport.build_kwargs(
      +            model="gpt-5.4", messages=msgs, tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +        )
      +        assert kw["messages"][0]["role"] == "developer"
      +
      +    def test_profile_path_no_swap_for_claude(self, transport):
      +        msgs = [{"role": "system", "content": "Be helpful"}, {"role": "user", "content": "hi"}]
      +        kw = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6", messages=msgs, tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +        )
      +        assert kw["messages"][0]["role"] == "system"
      +
      +
      +class TestRequestOverridesParity:
      +    """request_overrides with extra_body must merge identically on both paths."""
      +
      +    def test_extra_body_override_legacy(self, transport):
      +        kw = transport.build_kwargs(
      +            model="gpt-5.4", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            request_overrides={"extra_body": {"custom_key": "custom_val"}},
      +        )
      +        assert kw["extra_body"]["custom_key"] == "custom_val"
      +
      +    def test_extra_body_override_profile(self, transport):
      +        kw = transport.build_kwargs(
      +            model="gpt-5.4", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            request_overrides={"extra_body": {"custom_key": "custom_val"}},
      +        )
      +        assert kw["extra_body"]["custom_key"] == "custom_val"
      +
      +    def test_extra_body_override_merges_with_provider_body(self, transport):
      +        """Override extra_body merges WITH provider extra_body, not replaces."""
      +        kw = transport.build_kwargs(
      +            model="hermes-3", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("nous"),
      +            request_overrides={"extra_body": {"custom": True}},
      +        )
      +        assert kw["extra_body"]["tags"] == ["product=hermes-agent"]  # from profile
      +        assert kw["extra_body"]["custom"] is True  # from override
      +
      +    def test_top_level_override(self, transport):
      +        kw = transport.build_kwargs(
      +            model="gpt-5.4", messages=_msgs(), tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            request_overrides={"top_p": 0.9},
      +        )
      +        assert kw["top_p"] == 0.9
    • tests/providers/test_provider_profiles.py
      @@ -0,0 +1,203 @@
      +"""Tests for the provider module registry and profiles."""
      +
      +import pytest
      +from providers import get_provider_profile, _REGISTRY
      +from providers.base import ProviderProfile, OMIT_TEMPERATURE
      +
      +
      +class TestRegistry:
      +    def test_discovery_populates_registry(self):
      +        p = get_provider_profile("nvidia")
      +        assert p is not None
      +        assert p.name == "nvidia"
      +
      +    def test_alias_lookup(self):
      +        assert get_provider_profile("kimi").name == "kimi-coding"
      +        assert get_provider_profile("moonshot").name == "kimi-coding"
      +        assert get_provider_profile("kimi-coding-cn").name == "kimi-coding-cn"
      +        assert get_provider_profile("or").name == "openrouter"
      +        assert get_provider_profile("nous-portal").name == "nous"
      +        assert get_provider_profile("qwen").name == "qwen-oauth"
      +        assert get_provider_profile("qwen-portal").name == "qwen-oauth"
      +
      +    def test_unknown_provider_returns_none(self):
      +        assert get_provider_profile("nonexistent-provider") is None
      +
      +    def test_all_providers_have_name(self):
      +        get_provider_profile("nvidia")  # trigger discovery
      +        for name, profile in _REGISTRY.items():
      +            assert profile.name == name
      +
      +
      +class TestNvidiaProfile:
      +    def test_max_tokens(self):
      +        p = get_provider_profile("nvidia")
      +        assert p.default_max_tokens == 16384
      +
      +    def test_no_special_temperature(self):
      +        p = get_provider_profile("nvidia")
      +        assert p.fixed_temperature is None
      +
      +    def test_base_url(self):
      +        p = get_provider_profile("nvidia")
      +        assert "nvidia.com" in p.base_url
      +
      +
      +class TestKimiProfile:
      +    def test_temperature_omit(self):
      +        p = get_provider_profile("kimi")
      +        assert p.fixed_temperature is OMIT_TEMPERATURE
      +
      +    def test_max_tokens(self):
      +        p = get_provider_profile("kimi")
      +        assert p.default_max_tokens == 32000
      +
      +    def test_cn_separate_profile(self):
      +        p = get_provider_profile("kimi-coding-cn")
      +        assert p.name == "kimi-coding-cn"
      +        assert p.env_vars == ("KIMI_CN_API_KEY",)
      +        assert "moonshot.cn" in p.base_url
      +
      +    def test_cn_not_alias_of_kimi(self):
      +        kimi = get_provider_profile("kimi-coding")
      +        cn = get_provider_profile("kimi-coding-cn")
      +        assert kimi is not cn
      +        assert kimi.base_url != cn.base_url
      +
      +    def test_thinking_enabled(self):
      +        p = get_provider_profile("kimi")
      +        eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": True, "effort": "high"})
      +        assert eb["thinking"] == {"type": "enabled"}
      +        assert tl["reasoning_effort"] == "high"
      +
      +    def test_thinking_disabled(self):
      +        p = get_provider_profile("kimi")
      +        eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": False})
      +        assert eb["thinking"] == {"type": "disabled"}
      +        assert "reasoning_effort" not in tl
      +
      +    def test_reasoning_effort_default(self):
      +        p = get_provider_profile("kimi")
      +        eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": True})
      +        assert tl["reasoning_effort"] == "medium"
      +
      +    def test_no_config_defaults(self):
      +        p = get_provider_profile("kimi")
      +        eb, tl = p.build_api_kwargs_extras(reasoning_config=None)
      +        assert eb["thinking"] == {"type": "enabled"}
      +        assert tl["reasoning_effort"] == "medium"
      +
      +
      +class TestOpenRouterProfile:
      +    def test_extra_body_with_prefs(self):
      +        p = get_provider_profile("openrouter")
      +        body = p.build_extra_body(provider_preferences={"allow": ["anthropic"]})
      +        assert body["provider"] == {"allow": ["anthropic"]}
      +
      +    def test_extra_body_no_prefs(self):
      +        p = get_provider_profile("openrouter")
      +        body = p.build_extra_body()
      +        assert body == {}
      +
      +    def test_reasoning_full_config(self):
      +        p = get_provider_profile("openrouter")
      +        eb, _ = p.build_api_kwargs_extras(
      +            reasoning_config={"enabled": True, "effort": "high"},
      +            supports_reasoning=True,
      +        )
      +        assert eb["reasoning"] == {"enabled": True, "effort": "high"}
      +
      +    def test_reasoning_disabled_still_passes(self):
      +        """OpenRouter passes disabled reasoning through (unlike Nous)."""
      +        p = get_provider_profile("openrouter")
      +        eb, _ = p.build_api_kwargs_extras(
      +            reasoning_config={"enabled": False},
      +            supports_reasoning=True,
      +        )
      +        assert eb["reasoning"] == {"enabled": False}
      +
      +    def test_default_reasoning(self):
      +        p = get_provider_profile("openrouter")
      +        eb, _ = p.build_api_kwargs_extras(supports_reasoning=True)
      +        assert eb["reasoning"] == {"enabled": True, "effort": "medium"}
      +
      +
      +class TestNousProfile:
      +    def test_tags(self):
      +        p = get_provider_profile("nous")
      +        body = p.build_extra_body()
      +        assert body["tags"] == ["product=hermes-agent"]
      +
      +    def test_auth_type(self):
      +        p = get_provider_profile("nous")
      +        assert p.auth_type == "oauth_device_code"
      +
      +    def test_reasoning_enabled(self):
      +        p = get_provider_profile("nous")
      +        eb, _ = p.build_api_kwargs_extras(
      +            reasoning_config={"enabled": True, "effort": "medium"},
      +            supports_reasoning=True,
      +        )
      +        assert eb["reasoning"] == {"enabled": True, "effort": "medium"}
      +
      +    def test_reasoning_omitted_when_disabled(self):
      +        p = get_provider_profile("nous")
      +        eb, _ = p.build_api_kwargs_extras(
      +            reasoning_config={"enabled": False},
      +            supports_reasoning=True,
      +        )
      +        assert "reasoning" not in eb
      +
      +
      +class TestQwenProfile:
      +    def test_max_tokens(self):
      +        p = get_provider_profile("qwen-oauth")
      +        assert p.default_max_tokens == 65536
      +
      +    def test_auth_type(self):
      +        p = get_provider_profile("qwen-oauth")
      +        assert p.auth_type == "oauth_external"
      +
      +    def test_extra_body_vl(self):
      +        p = get_provider_profile("qwen-oauth")
      +        body = p.build_extra_body()
      +        assert body["vl_high_resolution_images"] is True
      +
      +    def test_prepare_messages_normalizes_content(self):
      +        p = get_provider_profile("qwen-oauth")
      +        msgs = [
      +            {"role": "system", "content": "Be helpful"},
      +            {"role": "user", "content": "hello"},
      +        ]
      +        result = p.prepare_messages(msgs)
      +        # System message: content normalized to list, cache_control on last part
      +        assert isinstance(result[0]["content"], list)
      +        assert result[0]["content"][-1].get("cache_control") == {"type": "ephemeral"}
      +        assert result[0]["content"][-1]["text"] == "Be helpful"
      +        # User message: content normalized to list
      +        assert isinstance(result[1]["content"], list)
      +        assert result[1]["content"][0]["text"] == "hello"
      +
      +    def test_metadata_top_level(self):
      +        p = get_provider_profile("qwen-oauth")
      +        meta = {"sessionId": "s123", "promptId": "p456"}
      +        eb, tl = p.build_api_kwargs_extras(qwen_session_metadata=meta)
      +        assert tl["metadata"] == meta
      +        assert "metadata" not in eb
      +
      +
      +class TestBaseProfile:
      +    def test_prepare_messages_passthrough(self):
      +        p = ProviderProfile(name="test")
      +        msgs = [{"role": "user", "content": "hi"}]
      +        assert p.prepare_messages(msgs) is msgs
      +
      +    def test_build_extra_body_empty(self):
      +        p = ProviderProfile(name="test")
      +        assert p.build_extra_body() == {}
      +
      +    def test_build_api_kwargs_extras_empty(self):
      +        p = ProviderProfile(name="test")
      +        eb, tl = p.build_api_kwargs_extras()
      +        assert eb == {}
      +        assert tl == {}
    • tests/providers/test_transport_parity.py
      @@ -0,0 +1,258 @@
      +"""Parity tests: pin the exact current transport behavior per provider.
      +
      +These tests document the flag-based contract between run_agent.py and
      +ChatCompletionsTransport.build_kwargs(). When the next PR wires profiles
      +to replace flags, every assertion here must still pass — any failure is
      +a behavioral regression.
      +"""
      +
      +import pytest
      +from agent.transports.chat_completions import ChatCompletionsTransport
      +from providers import get_provider_profile
      +
      +
      [redacted:email]
      +def transport():
      +    return ChatCompletionsTransport()
      +
      +
      +def _simple_messages():
      +    return [{"role": "user", "content": "hello"}]
      +
      +
      +def _max_tokens_fn(n):
      +    return {"max_completion_tokens": n}
      +
      +
      +class TestNvidiaParity:
      +    """NVIDIA NIM: default max_tokens=[redacted:secret-assignment]"""
      +
      +    def test_default_max_tokens(self, transport):
      +        """NVIDIA default max_tokens=16384 comes from profile, not legacy is_nvidia_nim flag."""
      +        from providers import get_provider_profile
      +
      +        profile = get_provider_profile("nvidia")
      +        kw = transport.build_kwargs(
      +            model="nvidia/llama-3.1-nemotron-70b-instruct",
      +            messages=_simple_messages(),
      +            tools=None,
      +            max_tokens_param_fn=[redacted:secret-assignment]
      +            provider_profile=profile,
      +        )
      +        assert kw["max_completion_tokens"] == 16384
      +
      +    def test_user_max_tokens_overrides(self, transport):
      +        from providers import get_provider_profile
      +
      +        profile = get_provider_profile("nvidia")
      +        kw = transport.build_kwargs(
      +            model="nvidia/llama-3.1-nemotron-70b-instruct",
      +            messages=_simple_messages(),
      +            tools=None,
      +            max_tokens=4096,
      +            max_tokens_param_fn=[redacted:secret-assignment]
      +            provider_profile=profile,
      +        )
      +        assert kw["max_completion_tokens"] == 4096  # user overrides default
      +
      +
      +class TestKimiParity:
      +    """Kimi: OMIT temperature, max_tokens=[redacted:secret-assignment] thinking + reasoning_effort."""
      +
      +    def test_temperature_omitted(self, transport):
      +        kw = transport.build_kwargs(
      +            model="kimi-k2",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"),
      +            omit_temperature=True,
      +        )
      +        assert "temperature" not in kw
      +
      +    def test_default_max_tokens(self, transport):
      +        kw = transport.build_kwargs(
      +            model="kimi-k2",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"),
      +            max_tokens_param_fn=[redacted:secret-assignment]
      +        )
      +        assert kw["max_completion_tokens"] == 32000
      +
      +    def test_thinking_enabled(self, transport):
      +        kw = transport.build_kwargs(
      +            model="kimi-k2",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"),
      +            reasoning_config={"enabled": True, "effort": "high"},
      +        )
      +        assert kw["extra_body"]["thinking"] == {"type": "enabled"}
      +
      +    def test_thinking_disabled(self, transport):
      +        kw = transport.build_kwargs(
      +            model="kimi-k2",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"),
      +            reasoning_config={"enabled": False},
      +        )
      +        assert kw["extra_body"]["thinking"] == {"type": "disabled"}
      +
      +    def test_reasoning_effort_top_level(self, transport):
      +        """Kimi reasoning_effort is a TOP-LEVEL api_kwargs key, NOT in extra_body."""
      +        kw = transport.build_kwargs(
      +            model="kimi-k2",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"),
      +            reasoning_config={"enabled": True, "effort": "high"},
      +        )
      +        assert kw.get("reasoning_effort") == "high"
      +        assert "reasoning_effort" not in kw.get("extra_body", {})
      +
      +    def test_reasoning_effort_default_medium(self, transport):
      +        kw = transport.build_kwargs(
      +            model="kimi-k2",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("kimi-coding"),
      +            reasoning_config={"enabled": True},
      +        )
      +        assert kw.get("reasoning_effort") == "medium"
      +
      +
      +class TestOpenRouterParity:
      +    """OpenRouter: provider preferences, reasoning in extra_body."""
      +
      +    def test_provider_preferences(self, transport):
      +        prefs = {"allow": ["anthropic"], "sort": "price"}
      +        kw = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            provider_preferences=prefs,
      +        )
      +        assert kw["extra_body"]["provider"] == prefs
      +
      +    def test_reasoning_passes_full_config(self, transport):
      +        """OpenRouter passes the FULL reasoning_config dict, not just effort."""
      +        rc = {"enabled": True, "effort": "high"}
      +        kw = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            supports_reasoning=True,
      +            reasoning_config=rc,
      +        )
      +        assert kw["extra_body"]["reasoning"] == rc
      +
      +    def test_default_reasoning_when_no_config(self, transport):
      +        """When supports_reasoning=True but no config, adds default."""
      +        kw = transport.build_kwargs(
      +            model="anthropic/claude-sonnet-4.6",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("openrouter"),
      +            supports_reasoning=True,
      +        )
      +        assert kw["extra_body"]["reasoning"] == {"enabled": True, "effort": "medium"}
      +
      +
      +class TestNousParity:
      +    """Nous: product tags, reasoning, omit when disabled."""
      +
      +    def test_tags(self, transport):
      +        kw = transport.build_kwargs(
      +            model="hermes-3-llama-3.1-405b",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("nous"),
      +        )
      +        assert kw["extra_body"]["tags"] == ["product=hermes-agent"]
      +
      +    def test_reasoning_omitted_when_disabled(self, transport):
      +        """Nous special case: reasoning omitted entirely when disabled."""
      +        kw = transport.build_kwargs(
      +            model="hermes-3-llama-3.1-405b",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("nous"),
      +            supports_reasoning=True,
      +            reasoning_config={"enabled": False},
      +        )
      +        assert "reasoning" not in kw.get("extra_body", {})
      +
      +    def test_reasoning_enabled(self, transport):
      +        rc = {"enabled": True, "effort": "high"}
      +        kw = transport.build_kwargs(
      +            model="hermes-3-llama-3.1-405b",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("nous"),
      +            supports_reasoning=True,
      +            reasoning_config=rc,
      +        )
      +        assert kw["extra_body"]["reasoning"] == rc
      +
      +
      +class TestQwenParity:
      +    """Qwen: max_tokens=[redacted:secret-assignment] vl_high_resolution, metadata top-level."""
      +
      +    def test_default_max_tokens(self, transport):
      +        kw = transport.build_kwargs(
      +            model="qwen3.5-plus",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("qwen-oauth"),
      +            max_tokens_param_fn=[redacted:secret-assignment]
      +        )
      +        assert kw["max_completion_tokens"] == 65536
      +
      +    def test_vl_high_resolution(self, transport):
      +        kw = transport.build_kwargs(
      +            model="qwen3.5-plus",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("qwen-oauth"),
      +        )
      +        assert kw["extra_body"]["vl_high_resolution_images"] is True
      +
      +    def test_metadata_top_level(self, transport):
      +        """Qwen metadata goes to top-level api_kwargs, NOT extra_body."""
      +        meta = {"sessionId": "s123", "promptId": "p456"}
      +        kw = transport.build_kwargs(
      +            model="qwen3.5-plus",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("qwen-oauth"),
      +            qwen_session_metadata=meta,
      +        )
      +        assert kw["metadata"] == meta
      +        assert "metadata" not in kw.get("extra_body", {})
      +
      +
      +class TestCustomOllamaParity:
      +    """Custom/Ollama: num_ctx, think=false — now tested via profile."""
      +
      +    def test_ollama_num_ctx(self, transport):
      +        kw = transport.build_kwargs(
      +            model="llama3.1",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("custom"),
      +            ollama_num_ctx=131072,
      +        )
      +        assert kw["extra_body"]["options"]["num_ctx"] == 131072
      +
      +    def test_think_false_when_disabled(self, transport):
      +        kw = transport.build_kwargs(
      +            model="qwen3:72b",
      +            messages=_simple_messages(),
      +            tools=None,
      +            provider_profile=get_provider_profile("custom"),
      +            reasoning_config={"enabled": False, "effort": "none"},
      +        )
      +        assert kw["extra_body"]["think"] is False
    • tests/run_agent/test_run_agent.py
      @@ -1117,6 +1117,7 @@ def test_public_moonshot_cn_kimi_k2_5_omits_temperature(self, agent):
               assert "temperature" not in kwargs
       
           def test_kimi_coding_endpoint_omits_temperature(self, agent):
      +        agent.provider = "kimi-coding"
               agent.base_url = "https://api.kimi.com/coding/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.model = "kimi-k2.5"
      @@ -1129,6 +1130,7 @@ def test_kimi_coding_endpoint_omits_temperature(self, agent):
           def test_kimi_coding_endpoint_sends_max_tokens_and_reasoning(self, agent):
               """Kimi endpoint should send max_tokens=32000 and reasoning_effort as
               top-level params, matching Kimi CLI's default behavior."""
      +        agent.provider = "kimi-coding"
               agent.base_url = "https://api.kimi.com/coding/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.model = "kimi-for-coding"
      @@ -1141,6 +1143,7 @@ def test_kimi_coding_endpoint_sends_max_tokens_and_reasoning(self, agent):
       
           def test_kimi_coding_endpoint_respects_custom_effort(self, agent):
               """reasoning_effort should reflect reasoning_config.effort when set."""
      +        agent.provider = "kimi-coding"
               agent.base_url = "https://api.kimi.com/coding/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.model = "kimi-for-coding"
      @@ -1154,6 +1157,7 @@ def test_kimi_coding_endpoint_respects_custom_effort(self, agent):
           def test_kimi_coding_endpoint_sends_thinking_extra_body(self, agent):
               """Kimi endpoint should send extra_body.thinking={"type":"enabled"}
               to activate reasoning mode, mirroring Kimi CLI's with_thinking()."""
      +        agent.provider = "kimi-coding"
               agent.base_url = "https://api.kimi.com/coding/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.model = "kimi-for-coding"
      @@ -1167,6 +1171,7 @@ def test_kimi_coding_endpoint_disables_thinking(self, agent):
               """When reasoning_config.enabled=False, thinking should be disabled
               and reasoning_effort should be omitted entirely — mirroring Kimi
               CLI's with_thinking("off") which maps to reasoning_effort=None."""
      +        agent.provider = "kimi-coding"
               agent.base_url = "https://api.kimi.com/coding/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.model = "kimi-for-coding"
      @@ -1180,6 +1185,7 @@ def test_kimi_coding_endpoint_disables_thinking(self, agent):
       
           def test_moonshot_endpoint_sends_max_tokens_and_reasoning(self, agent):
               """api.moonshot.ai should get the same Kimi-compatible params."""
      +        agent.provider = "kimi-coding"
               agent.base_url = "https://api.moonshot.ai/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.model = "kimi-k2.5"
      @@ -1193,6 +1199,7 @@ def test_moonshot_endpoint_sends_max_tokens_and_reasoning(self, agent):
       
           def test_moonshot_cn_endpoint_sends_max_tokens_and_reasoning(self, agent):
               """api.moonshot.cn (China endpoint) should get the same params."""
      +        agent.provider = "kimi-coding-cn"
               agent.base_url = "https://api.moonshot.cn/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.model = "kimi-k2.5"
      @@ -1205,6 +1212,7 @@ def test_moonshot_cn_endpoint_sends_max_tokens_and_reasoning(self, agent):
               assert kwargs["extra_body"]["thinking"] == {"type": "enabled"}
       
           def test_provider_preferences_injected(self, agent):
      +        agent.provider = "openrouter"
               agent.base_url = "https://openrouter.ai/api/v1"
               agent.providers_allowed = ["Anthropic"]
               messages = [{"role": "user", "content": "hi"}]
      @@ -1213,6 +1221,7 @@ def test_provider_preferences_injected(self, agent):
       
           def test_reasoning_config_default_openrouter(self, agent):
               """Default reasoning config for OpenRouter should be medium."""
      +        agent.provider = "openrouter"
               agent.base_url = "https://openrouter.ai/api/v1"
               agent.model = "anthropic/claude-sonnet-4-20250514"
               messages = [{"role": "user", "content": "hi"}]
      @@ -1222,6 +1231,7 @@ def test_reasoning_config_default_openrouter(self, agent):
               assert reasoning["effort"] == "medium"
       
           def test_reasoning_config_custom(self, agent):
      +        agent.provider = "openrouter"
               agent.base_url = "https://openrouter.ai/api/v1"
               agent.model = "anthropic/claude-sonnet-4-20250514"
               agent.reasoning_config = {"enabled": False}
      @@ -1237,6 +1247,7 @@ def test_reasoning_not_sent_for_unsupported_openrouter_model(self, agent):
               assert "reasoning" not in kwargs.get("extra_body", {})
       
           def test_reasoning_sent_for_supported_openrouter_model(self, agent):
      +        agent.provider = "openrouter"
               agent.base_url = "https://openrouter.ai/api/v1"
               agent.model = "qwen/qwen3.5-plus-02-15"
               messages = [{"role": "user", "content": "hi"}]
      @@ -1244,6 +1255,7 @@ def test_reasoning_sent_for_supported_openrouter_model(self, agent):
               assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
       
           def test_reasoning_sent_for_nous_route(self, agent):
      +        agent.provider = "nous"
               agent.base_url = "https://inference-api.nousresearch.com/v1"
               agent.model = "minimax/minimax-m2.5"
               messages = [{"role": "user", "content": "hi"}]
      @@ -1251,18 +1263,38 @@ def test_reasoning_sent_for_nous_route(self, agent):
               assert kwargs["extra_body"]["reasoning"]["effort"] == "medium"
       
           def test_reasoning_sent_for_copilot_gpt5(self, agent):
      -        agent.base_url = "https://api.githubcopilot.com"
      -        agent.model = "gpt-5.4"
      -        messages = [{"role": "user", "content": "hi"}]
      -        kwargs = agent._build_api_kwargs(messages)
      +        """Copilot/GitHub Models: GPT-5 reasoning goes in extra_body.reasoning."""
      +        from agent.transports import get_transport
      +        from providers import get_provider_profile
      +
      +        transport = get_transport("chat_completions")
      +        profile = get_provider_profile("copilot")
      +        msgs = [{"role": "user", "content": "hi"}]
      +        kwargs = transport.build_kwargs(
      +            model="gpt-5.4",
      +            messages=msgs,
      +            tools=None,
      +            supports_reasoning=True,
      +            provider_profile=profile,
      +        )
               assert kwargs["extra_body"]["reasoning"] == {"effort": "medium"}
       
           def test_reasoning_xhigh_normalized_for_copilot(self, agent):
      -        agent.base_url = "https://api.githubcopilot.com"
      -        agent.model = "gpt-5.4"
      -        agent.reasoning_config = {"enabled": True, "effort": "xhigh"}
      -        messages = [{"role": "user", "content": "hi"}]
      -        kwargs = agent._build_api_kwargs(messages)
      +        """xhigh effort should normalize to high for Copilot GitHub Models."""
      +        from agent.transports import get_transport
      +        from providers import get_provider_profile
      +
      +        transport = get_transport("chat_completions")
      +        profile = get_provider_profile("copilot")
      +        msgs = [{"role": "user", "content": "hi"}]
      +        kwargs = transport.build_kwargs(
      +            model="gpt-5.4",
      +            messages=msgs,
      +            tools=None,
      +            supports_reasoning=True,
      +            reasoning_config={"enabled": True, "effort": "xhigh"},
      +            provider_profile=profile,
      +        )
               assert kwargs["extra_body"]["reasoning"] == {"effort": "high"}
       
           def test_reasoning_omitted_for_non_reasoning_copilot_model(self, agent):
      @@ -1280,6 +1312,7 @@ def test_max_tokens_injected(self, agent):
       
       
           def test_qwen_portal_formats_messages_and_metadata(self, agent):
      +        agent.provider = "qwen-oauth"
               agent.base_url = "https://portal.qwen.ai/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.session_id = "sess-123"
      @@ -1296,6 +1329,7 @@ def test_qwen_portal_formats_messages_and_metadata(self, agent):
               assert kwargs["messages"][2]["content"][0]["text"] == "hi"
       
           def test_qwen_portal_normalizes_bare_string_content_parts(self, agent):
      +        agent.provider = "qwen-oauth"
               agent.base_url = "https://portal.qwen.ai/v1"
               agent._base_url_lower = agent.base_url.lower()
               messages = [
      @@ -1308,6 +1342,7 @@ def test_qwen_portal_normalizes_bare_string_content_parts(self, agent):
               assert user_content[1] == {"type": "text", "text": "world"}
       
           def test_qwen_portal_no_system_message(self, agent):
      +        agent.provider = "qwen-oauth"
               agent.base_url = "https://portal.qwen.ai/v1"
               agent._base_url_lower = agent.base_url.lower()
               messages = [{"role": "user", "content": "hi"}]
      @@ -1328,6 +1363,7 @@ def test_qwen_portal_sends_explicit_max_tokens(self, agent):
           def test_qwen_portal_default_max_tokens(self, agent):
               """When max_tokens is None, Qwen Portal gets a default of 65536
               to prevent reasoning models from exhausting their output budget."""
      +        agent.provider = "qwen-oauth"
               agent.base_url = "https://portal.qwen.ai/v1"
               agent._base_url_lower = agent.base_url.lower()
               agent.max_tokens = None
    • website/docs/developer-guide/adding-providers.md
      @@ -93,6 +93,46 @@ This path includes everything from Path A plus:
       11. `run_agent.py`
       12. `pyproject.toml` if a provider SDK is required
       
      +## Fast path: Simple API-key providers
      +
      +If your provider is just an OpenAI-compatible endpoint that authenticates with a single API key, you do not need to touch `auth.py`, `runtime_provider.py`, `main.py`, or any of the other files in the full checklist below.
      +
      +All you need is:
      +
      +1. A plugin directory under `plugins/model-providers/<your-provider>/` containing:
      +   - `__init__.py` — calls `register_provider(profile)` at module-level
      +   - `plugin.yaml` — manifest (name, kind: model-provider, version, description)
      +2. That's it. Provider plugins auto-load the first time anything calls `get_provider_profile()` or `list_providers()` — bundled plugins (this repo) and user plugins at `$HERMES_HOME/plugins/model-providers/` both get picked up.
      +
      +When you add a plugin and it calls `register_provider()`, the following wire up automatically:
      +
      +1. `PROVIDER_REGISTRY` entry in `auth.py` (credential resolution, env-var lookup)
      +2. `api_mode` set to `chat_completions`
      +3. `base_url` sourced from the config or the declared env var
      +4. `env_vars` checked in priority order for the API key
      +5. `fallback_models` list registered for the provider
      +6. `--provider` CLI flag accepts the provider id
      +7. `hermes model` menu includes the provider
      +8. `hermes setup` wizard delegates to `main.py` automatically
      +9. `provider:model` alias syntax works
      +10. Runtime resolver returns the correct `base_url` and `api_key`
      +11. `HERMES_INFERENCE_PROVIDER` env-var override accepts the provider id
      +12. Fallback model activation can switch into the provider cleanly
      +
      +User plugins at `$HERMES_HOME/plugins/model-providers/<name>/` override bundled plugins of the same name (last-writer-wins in `register_provider()`) — so third parties can monkey-patch or replace any built-in profile without editing the repo.
      +
      +See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a template, and `plugins/model-providers/README.md` for the full contract.
      +
      +## Full path: OAuth and complex providers
      +
      +Use the full checklist below when your provider needs any of the following:
      +
      +- OAuth or token refresh (Nous Portal, Codex, Google Gemini, Qwen Portal, Copilot)
      +- A non-OpenAI API shape that requires a new adapter (Anthropic Messages, Codex Responses)
      +- Custom endpoint detection or multi-region probing (z.ai, Kimi)
      +- A curated static model catalog or live `/models` fetch
      +- Provider-specific `hermes model` menu entries with bespoke auth flows
      +
       ## Step 1: Pick one canonical provider id
       
       Choose a single provider id and use it everywhere.
    • website/docs/developer-guide/provider-runtime.md
      @@ -20,6 +20,10 @@ Primary implementation:
       - `hermes_cli/auth.py` — provider registry, `resolve_provider()`
       - `hermes_cli/model_switch.py` — shared `/model` switch pipeline (CLI + gateway)
       - `agent/auxiliary_client.py` — auxiliary model routing
      +- `providers/` — ABC + registry entry points (`ProviderProfile`, `register_provider`, `get_provider_profile`, `list_providers`)
      +- `plugins/model-providers/<name>/` — per-provider plugins (bundled) that declare `api_mode`, `base_url`, `env_vars`, `fallback_models` and register themselves into the registry on first access. User plugins at `$HERMES_HOME/plugins/model-providers/<name>/` override bundled ones of the same name.
      +
      +`get_provider_profile()` in `providers/` returns a `ProviderProfile` for a given provider id. `runtime_provider.py` calls this at resolution time to get the canonical `base_url`, `env_vars` priority list, `api_mode`, and `fallback_models` without needing to duplicate that data in multiple files. Adding a new plugin under `plugins/model-providers/<your-provider>/` (or `$HERMES_HOME/plugins/model-providers/<your-provider>/`) that calls `register_provider()` is enough for `runtime_provider.py` to pick it up — no branch needed in the resolver itself.
       
       If you are trying to add a new first-class inference provider, read [Adding Providers](./adding-providers.md) alongside this page.
       
    • website/docs/integrations/providers.md
      @@ -480,6 +480,44 @@ model:
       For on-prem deployments (DGX Spark, local GPU), set `NVIDIA_BASE_URL=http://localhost:8000/v1`. NIM exposes the same OpenAI-compatible chat completions API as build.nvidia.com, so switching between cloud and local is a one-line env-var change.
       :::
       
      +### GMI Cloud
      +
      +Open and reasoning models via [GMI Cloud](https://inference.gmi.ai) — OpenAI-compatible API, API key authentication.
      +
      +```bash
      +# GMI Cloud
      +hermes chat --provider gmi --model deepseek-ai/DeepSeek-R1
      +# Requires: GMI_API_KEY in ~/.hermes/.env
      +```
      +
      +Or set it permanently in `config.yaml`:
      +```yaml
      +model:
      +  provider: "gmi"
      +  default: "deepseek-ai/DeepSeek-R1"
      +```
      +
      +The base URL can be overridden with `GMI_BASE_URL` (default: `https://api.gmi.ai/v1`).
      +
      +### StepFun
      +
      +Step-series models via [StepFun](https://platform.stepfun.com) — OpenAI-compatible API, API key authentication.
      +
      +```bash
      +# StepFun
      +hermes chat --provider stepfun --model step-3-mini
      +# Requires: STEPFUN_API_KEY in ~/.hermes/.env
      +```
      +
      +Or set it permanently in `config.yaml`:
      +```yaml
      +model:
      +  provider: "stepfun"
      +  default: "step-3-mini"
      +```
      +
      +The base URL can be overridden with `STEPFUN_BASE_URL` (default: `https://api.stepfun.com/v1`).
      +
       ### Hugging Face Inference Providers
       
       [Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers) routes to 20+ open models through a unified OpenAI-compatible endpoint (`router.huggingface.co/v1`). Requests are automatically routed to the fastest available backend (Groq, Together, SambaNova, etc.) with automatic failover.
      @@ -1239,7 +1277,7 @@ fallback_model:
       
       When activated, the fallback swaps the model and provider mid-session without losing your conversation. It fires **at most once** per session.
       
      -Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `tencent-tokenhub`, `custom`.
      +Supported providers: `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `ollama-cloud`, `bedrock`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `tencent-tokenhub`, `custom`.
       
       :::tip
       Fallback is configured exclusively through `config.yaml` — there are no environment variables for it. For full details on when it triggers, supported providers, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/docs/user-guide/features/fallback-providers).
    • website/docs/reference/environment-variables.md
      @@ -69,6 +69,10 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
       | `DEEPSEEK_BASE_URL` | Custom DeepSeek API base URL |
       | `NVIDIA_API_KEY` | NVIDIA NIM API key — Nemotron and open models ([build.nvidia.com](https://build.nvidia.com)) |
       | `NVIDIA_BASE_URL` | Override NVIDIA base URL (default: `https://integrate.api.nvidia.com/v1`; set to `http://localhost:8000/v1` for a local NIM endpoint) |
      +| `GMI_API_KEY` | GMI Cloud API key — open and reasoning models ([inference.gmi.ai](https://inference.gmi.ai)) |
      +| `GMI_BASE_URL` | Override GMI Cloud base URL (default: `https://api.gmi.ai/v1`) |
      +| `STEPFUN_API_KEY` | StepFun API key — Step-series models ([platform.stepfun.com](https://platform.stepfun.com)) |
      +| `STEPFUN_BASE_URL` | Override StepFun base URL (default: `https://api.stepfun.com/v1`) |
       | `OLLAMA_API_KEY` | Ollama Cloud API key — managed Ollama catalog without local GPU ([ollama.com/settings/keys](https://ollama.com/settings/keys)) |
       | `OLLAMA_BASE_URL` | Override Ollama Cloud base URL (default: `https://ollama.com/v1`) |
       | `XAI_API_KEY` | xAI (Grok) API key for chat + TTS ([console.x.ai](https://console.x.ai/)) |
      @@ -99,7 +103,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
       
       | Variable | Description |
       |----------|-------------|
      -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) |
      +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `custom`, `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `huggingface`, `gemini`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth` (browser OAuth login — no API key required; see [MiniMax OAuth guide](../guides/minimax-oauth.md)), `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `google-gemini-cli`, `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `ai-gateway`, `tencent-tokenhub` (default: `auto`) |
       | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
       | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
       | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
    • website/docs/user-guide/features/fallback-providers.md
      @@ -60,6 +60,8 @@ Both `provider` and `model` are **required**. If either is missing, the fallback
       | MiniMax (China) | `minimax-cn` | `MINIMAX_CN_API_KEY` |
       | DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` |
       | NVIDIA NIM | `nvidia` | `NVIDIA_API_KEY` (optional: `NVIDIA_BASE_URL`) |
      +| GMI Cloud | `gmi` | `GMI_API_KEY` (optional: `GMI_BASE_URL`) |
      +| StepFun | `stepfun` | `STEPFUN_API_KEY` (optional: `STEPFUN_BASE_URL`) |
       | Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` |
       | Google Gemini (OAuth) | `google-gemini-cli` | `hermes model` (Google OAuth; optional: `HERMES_GEMINI_PROJECT_ID`) |
       | Google AI Studio | `gemini` | `GOOGLE_API_KEY` (alias: `GEMINI_API_KEY`) |

1 PR closed without merging

From the close comments: other 1

  • #14424 feat: provider modules — ProviderProfile ABC, 29 providers, fetch_models, transport single-path · other → #14424
Closed issues · 2
  • Closed#13473tracking: provider transport refactor (agent/transports/)
  • Closed#14418tracking: provider modules refactor — Cycle 2 of transport/provider infrastructure
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.39: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#20324
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
5 May 2026

For your agent

The same facts, typed.