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