{"repo":"NousResearch/hermes-agent","public_id":"60355","display_label":"OpenAI API parameter compatibility bugs and fixes","summary":"These issues and fixes concern incorrect API parameters being sent to OpenAI and compatible models, specifically around max_tokens vs max_completion_tokens selection, unsupported reasoning/include parameters, and model capability detection logic that breaks on custom or non-standard endpoints.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65029,"merged_at":"2026-07-15T14:39:17+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-15T14:39:17+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":35566,"pr_number":65029,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-15T14:39:17+00:00"},{"issue_number":60355,"pr_number":65029,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-15T14:39:17+00:00"}],"members":[{"kind":"issue","number":60355,"title":"Auxiliary LLM calls fail with `max_tokens` on gpt-4o/o-series/gpt-5 models (kanban decompose, specify, goals, profile describe)","url":null,"state":"closed"},{"kind":"pr","number":60356,"title":"fix: use max_completion_tokens (not max_tokens) for OpenAI models in auxiliary LLM calls","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65029","number":65029},{"kind":"pr","value":"#60355","number":60355}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":65029,"title":"fix(auxiliary): route direct-create aux callers through call_llm","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/goals.py","hermes_cli/kanban_decompose.py","hermes_cli/kanban_specify.py","hermes_cli/profile_describer.py","tests/hermes_cli/test_goals.py","tests/hermes_cli/test_kanban_cli.py","tests/hermes_cli/test_kanban_decompose.py","tests/hermes_cli/test_kanban_specify.py","tests/hermes_cli/test_profile_describer.py","tests/plugins/test_kanban_dashboard_plugin.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -1700,6 +1700,18 @@ def _ensure_hermes_home_managed(home: Path):\n             \"extra_body\": {},\n             \"reasoning_effort\": \"\",  # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)\n         },\n+        # Goal judge — evaluates whether a /goal run's latest response\n+        # satisfies the goal/contract, and drafts goal contracts. Short\n+        # structured-JSON calls; a fast cheap model is fine.\n+        \"goal_judge\": {\n+            \"provider\": \"auto\",\n+            \"model\": \"\",\n+            \"base_url\": \"\",\n+            \"api_key\": \"\",\n+            \"timeout\": 60,\n+            \"extra_body\": {},\n+            \"reasoning_effort\": \"\",  # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)\n+        },\n         # Curator — skill-usage review fork. Timeout is generous because the\n         # review pass can take several minutes on reasoning models (umbrella\n         # building over hundreds of candidate skills). \"auto\" = use main chat"]},{"file":"hermes_cli/goals.py","hunks":["@@ -878,20 +878,11 @@ def judge_goal(\n         return \"continue\", \"empty response (nothing to evaluate)\", False, None\n \n     try:\n-        from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client\n+        from agent.auxiliary_client import call_llm\n     except Exception as exc:\n         logger.debug(\"goal judge: auxiliary client import failed: %s\", exc)\n         return \"continue\", \"auxiliary client unavailable\", False, None\n \n-    try:\n-        client, model = get_text_auxiliary_client(\"goal_judge\")\n-    except Exception as exc:\n-        logger.debug(\"goal judge: get_text_auxiliary_client failed: %s\", exc)\n-        return \"continue\", \"auxiliary client unavailable\", False, None\n-\n-    if client is None or not model:\n-        return \"continue\", \"no auxiliary client configured\", False, None\n-\n     # Build the prompt. Priority: contract > subgoals > plain. When both a\n     # contract and subgoals exist, the subgoals are appended into the\n     # contract block as extra criteria so the judge sees a single source of","@@ -935,8 +926,11 @@ def judge_goal(\n         )\n \n     try:\n-        resp = client.chat.completions.create(\n-            model=model,\n+        # Route through call_llm so auxiliary.goal_judge.* config\n+        # (provider/model/base_url, extra_body, reasoning_effort, retries)\n+        # all apply — the direct-create path dropped extra_body (#35566).\n+        resp = call_llm(\n+            task=\"goal_judge\",\n             messages=[\n                 {\"role\": \"system\", \"content\": JUDGE_SYSTEM_PROMPT},\n                 {\"role\": \"user\", \"content\": prompt},","@@ -944,7 +938,6 @@ def judge_goal(\n             temperature=0,\n             max_tokens=_goal_judge_max_tokens(),\n             timeout=timeout,\n-            extra_body=get_auxiliary_extra_body() or None,\n         )\n     except Exception as exc:\n         logger.info(\"goal judge: API call failed (%s) — falling through to continue\", exc)","@@ -999,23 +992,15 @@ def draft_contract(objective: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT) ->\n         return None\n \n     try:\n-        from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client\n+        from agent.auxiliary_client import call_llm\n     except Exception as exc:\n         logger.debug(\"goal draft: auxiliary client import failed: %s\", exc)\n         return None\n \n     try:\n-        client, model = get_text_auxiliary_client(\"goal_judge\")\n-    except Exception as exc:\n-        logger.debug(\"goal draft: get_text_auxiliary_client failed: %s\", exc)\n-        return None\n-\n-    if client is None or not model:\n-        return None\n-\n-    try:\n-        resp = client.chat.completions.create(\n-            model=model,\n+        # Route through call_llm — same #35566 fix as the judge call above.\n+        resp = call_llm(\n+            task=\"goal_judge\",\n             messages=[\n                 {\"role\": \"system\", \"content\": DRAFT_CONTRACT_SYSTEM_PROMPT},\n                 {\"role\": \"user\", \"content\": f\"Objective:\\n{_truncate(objective, 4000)}\"},","@@ -1023,7 +1008,6 @@ def draft_contract(objective: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT) ->\n             temperature=0,\n             max_tokens=_goal_judge_max_tokens(),\n             timeout=timeout,\n-            extra_body=get_auxiliary_extra_body() or None,\n         )\n     except Exception as exc:\n         logger.info(\"goal draft: API call failed (%s)\", exc)"]},{"file":"hermes_cli/kanban_decompose.py","hunks":["@@ -298,23 +298,11 @@ def decompose_task(\n     roster, valid_names = _build_roster()\n \n     try:\n-        from agent.auxiliary_client import (  # type: ignore\n-            get_auxiliary_extra_body,\n-            get_text_auxiliary_client,\n-        )\n+        from agent.auxiliary_client import call_llm  # type: ignore\n     except Exception as exc:\n         logger.debug(\"decompose: auxiliary client import failed: %s\", exc)\n         return DecomposeOutcome(task_id, False, \"auxiliary client unavailable\")\n \n-    try:\n-        client, model = get_text_auxiliary_client(\"kanban_decomposer\")\n-    except Exception as exc:\n-        logger.debug(\"decompose: get_text_auxiliary_client failed: %s\", exc)\n-        return DecomposeOutcome(task_id, False, \"auxiliary client unavailable\")\n-\n-    if client is None or not model:\n-        return DecomposeOutcome(task_id, False, \"no auxiliary client configured\")\n-\n     user_msg = _USER_TEMPLATE.format(\n         task_id=task.id,\n         title=_truncate(task.title or \"\", 400),","@@ -324,8 +312,12 @@ def decompose_task(\n     )\n \n     try:\n-        resp = client.chat.completions.create(\n-            model=model,\n+        # Route through call_llm so auxiliary.kanban_decomposer.* config\n+        # (provider/model/base_url, extra_body, reasoning_effort, retries)\n+        # all apply — the previous direct client.chat.completions.create()\n+        # path dropped auxiliary.<task>.extra_body entirely (#35566).\n+        resp = call_llm(\n+            task=\"kanban_decomposer\",\n             messages=[\n                 {\"role\": \"system\", \"content\": _SYSTEM_PROMPT},\n                 {\"role\": \"user\", \"content\": user_msg},","@@ -333,7 +325,6 @@ def decompose_task(\n             temperature=0.3,\n             max_tokens=4000,\n             timeout=timeout or 180,\n-            extra_body=get_auxiliary_extra_body() or None,\n         )\n     except Exception as exc:\n         logger.info("]},{"file":"hermes_cli/kanban_specify.py","hunks":["@@ -162,22 +162,11 @@ def specify_task(\n         )\n \n     try:\n-        from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client\n+        from agent.auxiliary_client import call_llm\n     except Exception as exc:  # pragma: no cover — import smoke test\n         logger.debug(\"specify: auxiliary client import failed: %s\", exc)\n         return SpecifyOutcome(task_id, False, \"auxiliary client unavailable\")\n \n-    try:\n-        client, model = get_text_auxiliary_client(\"triage_specifier\")\n-    except Exception as exc:\n-        logger.debug(\"specify: get_text_auxiliary_client failed: %s\", exc)\n-        return SpecifyOutcome(task_id, False, \"auxiliary client unavailable\")\n-\n-    if client is None or not model:\n-        return SpecifyOutcome(\n-            task_id, False, \"no auxiliary client configured\"\n-        )\n-\n     user_msg = _USER_TEMPLATE.format(\n         task_id=task.id,\n         title=_truncate(task.title or \"\", 400),","@@ -185,8 +174,11 @@ def specify_task(\n     )\n \n     try:\n-        resp = client.chat.completions.create(\n-            model=model,\n+        # Route through call_llm so auxiliary.triage_specifier.* config\n+        # (provider/model/base_url, extra_body, reasoning_effort, retries)\n+        # all apply — the direct-create path dropped extra_body (#35566).\n+        resp = call_llm(\n+            task=\"triage_specifier\",\n             messages=[\n                 {\"role\": \"system\", \"content\": _SYSTEM_PROMPT},\n                 {\"role\": \"user\", \"content\": user_msg},","@@ -194,7 +186,6 @@ def specify_task(\n             temperature=0.3,\n             max_tokens=[redacted:secret-assignment]\n             timeout=timeout or 120,\n-            extra_body=get_auxiliary_extra_body() or None,\n         )\n     except Exception as exc:\n         logger.info("]},{"file":"hermes_cli/profile_describer.py","hunks":["@@ -210,23 +210,11 @@ def describe_profile(\n         model, provider = None, None\n \n     try:\n-        from agent.auxiliary_client import (  # type: ignore\n-            get_auxiliary_extra_body,\n-            get_text_auxiliary_client,\n-        )\n+        from agent.auxiliary_client import call_llm  # type: ignore\n     except Exception as exc:\n         logger.debug(\"describe: auxiliary client import failed: %s\", exc)\n         return DescribeOutcome(canon, False, \"auxiliary client unavailable\")\n \n-    try:\n-        client, aux_model = get_text_auxiliary_client(\"profile_describer\")\n-    except Exception as exc:\n-        logger.debug(\"describe: get_text_auxiliary_client failed: %s\", exc)\n-        return DescribeOutcome(canon, False, \"auxiliary client unavailable\")\n-\n-    if client is None or not aux_model:\n-        return DescribeOutcome(canon, False, \"no auxiliary client configured\")\n-\n     user_msg = _USER_TEMPLATE.format(\n         name=canon,\n         model=(model or \"(unset)\"),","@@ -237,8 +225,11 @@ def describe_profile(\n     )\n \n     try:\n-        resp = client.chat.completions.create(\n-            model=aux_model,\n+        # Route through call_llm so auxiliary.profile_describer.* config\n+        # (provider/model/base_url, extra_body, reasoning_effort, retries)\n+        # all apply — the direct-create path dropped extra_body (#35566).\n+        resp = call_llm(\n+            task=\"profile_describer\",\n             messages=[\n                 {\"role\": \"system\", \"content\": _SYSTEM_PROMPT},\n                 {\"role\": \"user\", \"content\": user_msg},","@@ -246,7 +237,6 @@ def describe_profile(\n             temperature=0.3,\n             max_tokens=400,\n             timeout=timeout or 60,\n-            extra_body=get_auxiliary_extra_body() or None,\n         )\n     except Exception as exc:\n         logger.info(\"describe: API call failed for %s (%s)\", canon, exc)"]},{"file":"tests/hermes_cli/test_goals.py","hunks":["@@ -166,8 +166,8 @@ def test_no_aux_client_continues(self):\n         from hermes_cli import goals\n \n         with patch(\n-            \"agent.auxiliary_client.get_text_auxiliary_client\",\n-            return_value=(None, None),\n+            \"agent.auxiliary_client.call_llm\",\n+            side_effect=RuntimeError(\"No LLM provider configured\"),\n         ):\n             verdict, _, _, _wd = goals.judge_goal(\"my goal\", \"my response\")\n         assert verdict == \"continue\"","@@ -176,11 +176,9 @@ def test_api_error_continues(self):\n         \"\"\"Judge exception → fail-open continue (don't wedge progress on judge bugs).\"\"\"\n         from hermes_cli import goals\n \n-        fake_client = MagicMock()\n-        fake_client.chat.completions.create.side_effect = RuntimeError(\"boom\")\n         with patch(\n-            \"agent.auxiliary_client.get_text_auxiliary_client\",\n-            return_value=(fake_client, \"judge-model\"),\n+            \"agent.auxiliary_client.call_llm\",\n+            side_effect=RuntimeError(\"boom\"),\n         ):\n             verdict, reason, _, _wd = goals.judge_goal(\"goal\", \"response\")\n         assert verdict == \"continue\"","@@ -189,17 +187,11 @@ def test_api_error_continues(self):\n     def test_judge_says_done(self):\n         from hermes_cli import goals\n \n-        fake_client = MagicMock()\n-        fake_client.chat.completions.create.return_value = MagicMock(\n-            choices=[\n-                MagicMock(\n-                    message=MagicMock(content='{\"done\": true, \"reason\": \"achieved\"}')\n-                )\n-            ]\n-        )\n         with patch(\n-            \"agent.auxiliary_client.get_text_auxiliary_client\",\n-            return_value=(fake_client, \"judge-model\"),\n+            \"agent.auxiliary_client.call_llm\",\n+            return_value=MagicMock(\n+                choices=[MagicMock(message=MagicMock(content='{\"done\": true, \"reason\": \"achieved\"}'))]\n+            ),\n         ):\n             verdict, reason, _, _wd = goals.judge_goal(\"goal\", \"agent response\")\n         assert verdict == \"done\"","@@ -208,17 +200,11 @@ def test_judge_says_done(self):\n     def test_judge_says_continue(self):\n         from hermes_cli import goals\n \n-        fake_client = MagicMock()\n-        fake_client.chat.completions.create.return_value = MagicMock(\n-            choices=[\n-                MagicMock(\n-                    message=MagicMock(content='{\"done\": false, \"reason\": \"not yet\"}')\n-                )\n-            ]\n-        )\n         with patch(\n-            \"agent.auxiliary_client.get_text_auxiliary_client\",\n-            return_value=(fake_client, \"judge-model\"),\n+            \"agent.auxiliary_client.call_llm\",\n+            return_value=MagicMock(\n+                choices=[MagicMock(message=MagicMock(content='{\"done\": false, \"reason\": \"not yet\"}'))]\n+            ),\n         ):\n             verdict, reason, _, _wd = goals.judge_goal(\"goal\", \"agent response\")\n         assert verdict == \"continue\"","@@ -448,11 +434,9 @@ def test_api_error_does_not_count_as_parse_failure(self):\n         \"\"\"Transient network/API errors must not trip the auto-pause guard.\"\"\"\n         from hermes_cli import goals\n \n-        fake_client = MagicMock()\n-        fake_client.chat.completions.create.side_effect = RuntimeError(\"connection reset\")\n         with patch(\n-            \"agent.auxiliary_client.get_text_auxiliary_client\",\n-            return_value=(fake_client, \"judge-model\"),\n+            \"agent.auxiliary_client.call_llm\",\n+            side_effect=RuntimeError(\"connection reset\"),\n         ):\n             verdict, _, parse_failed, _wd = goals.judge_goal(\"goal\", \"response\")\n         assert verdict == \"continue\"","@@ -462,13 +446,9 @@ def test_empty_judge_reply_flagged_as_parse_failure(self):\n         \"\"\"End-to-end: judge returns empty content → parse_failed=True.\"\"\"\n         from hermes_cli import goals\n \n-        fake_client = MagicMock()\n-        fake_client.chat.completions.create.return_value = MagicMock(\n-            choices=[MagicMock(message=MagicMock(content=\"\"))]\n-        )\n         with patch(\n-            \"agent.auxiliary_client.get_text_auxiliary_client\",\n-            return_value=(fake_client, \"judge-model\"),\n+            \"agent.auxiliary_client.call_llm\",\n+            return_value=MagicMock(choices=[MagicMock(message=MagicMock(content=\"\"))]),\n         ):\n             verdict, _, parse_failed, _wd = goals.judge_goal(\"goal\", \"response\")\n         assert verdict == \"continue\"","@@ -747,22 +727,11 @@ class _FakeChoice:\n             message = _FakeMsg()\n         class _FakeResp:\n             choices = [_FakeChoice()]\n-        class _FakeClient:\n-            class chat:\n-                class completions:\n-                    @staticmethod\n-                    def create(**kwargs):\n-                        captured.update(kwargs)\n-                        return _FakeResp()\n-\n-        with patch.object(goals, \"get_text_auxiliary_client\",\n-                          return_value=(_FakeClient, \"fake-model\"), create=True), \\\n-             patch.object(goals, \"get_auxiliary_extra_body\",\n-                          return_value=None, create=True), \\\n-             patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(_FakeClient, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\",\n-                   return_value=None):\n+        def _fake_call_llm(**kwargs):\n+            captured.update(kwargs)\n+            return _FakeResp()\n+\n+        with patch(\"agent.auxiliary_client.call_llm\", side_effect=_fake_call_llm):\n             verdict, reason, parse_failed, _wd = goals.judge_goal(\n                 \"ship the feature\",\n                 \"ok shipped\",","@@ -790,18 +759,11 @@ class _FakeChoice:\n             message = _FakeMsg()\n         class _FakeResp:\n             choices = [_FakeChoice()]\n-        class _FakeClient:\n-            class chat:\n-                class completions:\n-                    @staticmethod\n-                    def create(**kwargs):\n-                        captured.update(kwargs)\n-                        return _FakeResp()\n-\n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(_FakeClient, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\",\n-                   return_value=None):\n+        def _fake_call_llm(**kwargs):\n+            captured.update(kwargs)\n+            return _FakeResp()\n+\n+        with patch(\"agent.auxiliary_client.call_llm\", side_effect=_fake_call_llm):\n             goals.judge_goal(\"ship it\", \"done\", subgoals=None)\n \n         sent_messages = captured.get(\"messages\") or []","@@ -1363,7 +1325,8 @@ def test_persistence_roundtrip(self, hermes_home):\n \n \n class TestJudgeWithContract:\n-    def _fake_client(self, captured, content='{\"done\": false, \"reason\": \"more\"}'):\n+    def _fake_call_llm(self, captured, content='{\"done\": false, \"reason\": \"more\"}'):\n+        \"\"\"judge_goal routes through call_llm (#35566) — capture its kwargs.\"\"\"\n         class _FakeMsg:\n             pass\n         _FakeMsg.content = content","@@ -1371,14 +1334,11 @@ class _FakeChoice:\n             message = _FakeMsg()\n         class _FakeResp:\n             choices = [_FakeChoice()]\n-        class _FakeClient:\n-            class chat:\n-                class completions:\n-                    @staticmethod\n-                    def create(**kwargs):\n-                        captured.update(kwargs)\n-                        return _FakeResp()\n-        return _FakeClient\n+\n+        def _fake(**kwargs):\n+            captured.update(kwargs)\n+            return _FakeResp()\n+        return _fake\n \n     def test_judge_uses_contract_template(self, hermes_home):\n         from unittest.mock import patch","@@ -1386,10 +1346,8 @@ def test_judge_uses_contract_template(self, hermes_home):\n         from hermes_cli.goals import GoalContract\n \n         captured = {}\n-        client = self._fake_client(captured)\n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(client, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\", return_value=None):\n+        with patch(\"agent.auxiliary_client.call_llm\",\n+                   side_effect=self._fake_call_llm(captured)):\n             goals.judge_goal(\n                 \"ship it\", \"I think it's done\",\n                 contract=GoalContract(verification=\"pytest -q passes\"),","@@ -1407,10 +1365,8 @@ def test_contract_plus_subgoals_combine(self, hermes_home):\n         from hermes_cli.goals import GoalContract\n \n         captured = {}\n-        client = self._fake_client(captured)\n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(client, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\", return_value=None):\n+        with patch(\"agent.auxiliary_client.call_llm\",\n+                   side_effect=self._fake_call_llm(captured)):\n             goals.judge_goal(\n                 \"ship it\", \"done\",\n                 subgoals=[\"write changelog\"],","@@ -1438,16 +1394,8 @@ class _FakeChoice:\n             message = _FakeMsg()\n         class _FakeResp:\n             choices = [_FakeChoice()]\n-        class _FakeClient:\n-            class chat:\n-                class completions:\n-                    @staticmethod\n-                    def create(**kwargs):\n-                        return _FakeResp()\n-\n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(_FakeClient, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\", return_value=None):\n+        with patch(\"agent.auxiliary_client.call_llm\",\n+                   return_value=_FakeResp()):\n             contract = goals.draft_contract(\"Migrate auth to JWT\")\n         assert contract is not None\n         assert contract.outcome == \"auth on JWT\"","@@ -1464,24 +1412,16 @@ class _FakeChoice:\n             message = _FakeMsg()\n         class _FakeResp:\n             choices = [_FakeChoice()]\n-        class _FakeClient:\n-            class chat:\n-                class completions:\n-                    @staticmethod\n-                    def create(**kwargs):\n-                        return _FakeResp()\n-\n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(_FakeClient, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\", return_value=None):\n+        with patch(\"agent.auxiliary_client.call_llm\",\n+                   return_value=_FakeResp()):\n             assert goals.draft_contract(\"anything\") is None\n \n     def test_draft_returns_none_when_no_client(self, hermes_home):\n         from unittest.mock import patch\n         from hermes_cli import goals\n \n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(None, None)):\n+        with patch(\"agent.auxiliary_client.call_llm\",\n+                   side_effect=RuntimeError(\"No LLM provider configured\")):\n             assert goals.draft_contract(\"anything\") is None\n \n ","@@ -1495,7 +1435,8 @@ class TestContractAndBackgroundCompose:\n     the contract block and the background-process list to the judge, so it\n     can return either done (evidence met) or wait (parked on the poller).\"\"\"\n \n-    def _capture_client(self, captured, content='{\"verdict\": \"wait\", \"wait_on_pid\": 4242, \"reason\": \"CI still running\"}'):\n+    def _capture_call_llm(self, captured, content='{\"verdict\": \"wait\", \"wait_on_pid\": 4242, \"reason\": \"CI still running\"}'):\n+        \"\"\"judge_goal routes through call_llm (#35566) — capture its kwargs.\"\"\"\n         class _FakeMsg:\n             pass\n         _FakeMsg.content = content","@@ -1503,14 +1444,11 @@ class _FakeChoice:\n             message = _FakeMsg()\n         class _FakeResp:\n             choices = [_FakeChoice()]\n-        class _FakeClient:\n-            class chat:\n-                class completions:\n-                    @staticmethod\n-                    def create(**kwargs):\n-                        captured.update(kwargs)\n-                        return _FakeResp()\n-        return _FakeClient\n+\n+        def _fake(**kwargs):\n+            captured.update(kwargs)\n+            return _FakeResp()\n+        return _fake\n \n     def test_judge_prompt_carries_contract_and_background(self, hermes_home):\n         from unittest.mock import patch","@@ -1518,14 +1456,12 @@ def test_judge_prompt_carries_contract_and_background(self, hermes_home):\n         from hermes_cli.goals import GoalContract\n \n         captured = {}\n-        client = self._capture_client(captured)\n         bg = [{\n             \"session_id\": \"ci-watch\", \"pid\": 4242, \"status\": \"running\",\n             \"command\": \"wait_for_pr_green.sh 50501\", \"trigger\": \"exit\",\n         }]\n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(client, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\", return_value=None):\n+        with patch(\"agent.auxiliary_client.call_llm\",\n+                   side_effect=self._capture_call_llm(captured)):\n             verdict, reason, parse_failed, wait_directive = goals.judge_goal(\n                 \"ship the PR\",\n                 \"I pushed and started the CI watcher; waiting on it now.\",","@@ -1550,14 +1486,12 @@ def test_contract_goal_can_still_complete_on_evidence(self, hermes_home):\n         from hermes_cli.goals import GoalContract\n \n         captured = {}\n-        client = self._capture_client(\n-            captured,\n-            content='{\"verdict\": \"done\", \"reason\": \"CI is green, evidence shown\"}',\n-        )\n         bg = [{\"session_id\": \"ci\", \"pid\": 4242, \"status\": \"running\", \"command\": \"ci\", \"trigger\": \"exit\"}]\n-        with patch(\"agent.auxiliary_client.get_text_auxiliary_client\",\n-                   return_value=(client, \"fake-model\")), \\\n-             patch(\"agent.auxiliary_client.get_auxiliary_extra_body\", return_value=None):\n+        with patch(\"agent.auxiliary_client.call_llm\",\n+                   side_effect=self._capture_call_llm(\n+                       captured,\n+                       content='{\"verdict\": \"done\", \"reason\": \"CI is green, evidence shown\"}',\n+                   )):\n             verdict, reason, parse_failed, wait_directive = goals.judge_goal(\n                 \"ship the PR\",\n                 \"CI finished: 30 passed, 0 failed. Done.\","]},{"file":"tests/hermes_cli/test_kanban_cli.py","hunks":["@@ -465,11 +465,10 @@ def test_run_slash_specify_end_to_end(kanban_home, monkeypatch):\n     resp.choices[0].message.content = (\n         '{\"title\": \"Spec: rough idea\", \"body\": \"**Goal**\\\\nShip it.\"}'\n     )\n-    fake_client = MagicMock()\n-    fake_client.chat.completions.create = MagicMock(return_value=resp)\n+    # specify_task routes through call_llm now (#35566) — mock it directly.\n     monkeypatch.setattr(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        lambda *a, **kw: (fake_client, \"test-model\"),\n+        \"agent.auxiliary_client.call_llm\",\n+        MagicMock(return_value=resp),\n     )\n \n     # Specify via slash."]},{"file":"tests/hermes_cli/test_kanban_decompose.py","hunks":["@@ -41,18 +41,19 @@ def _mock_client_returning(content: str):\n \n \n def _patch_aux_client(content: str, *, model: str = \"test-model\"):\n-    client = _mock_client_returning(content)\n+    # decompose_task now routes through call_llm (see #35566) — mock it at\n+    # the source module so task config, extra_body, and retries stay out of\n+    # unit-test scope.\n     return patch(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        return_value=(client, model),\n+        \"agent.auxiliary_client.call_llm\",\n+        return_value=_fake_aux_response(content),\n     )\n \n \n def _patch_extra_body():\n-    return patch(\n-        \"agent.auxiliary_client.get_auxiliary_extra_body\",\n-        return_value={},\n-    )\n+    # No-op shim retained for call-site compatibility: extra_body plumbing\n+    # now lives inside call_llm, which _patch_aux_client already mocks.\n+    return patch(\"agent.auxiliary_client.get_auxiliary_extra_body\", return_value={})\n \n \n def _patch_list_profiles(names: list[str]):","@@ -334,9 +335,11 @@ def test_decompose_no_aux_client_configured(kanban_home):\n     for p in patches:\n         p.start()\n     try:\n+        # call_llm raises RuntimeError when no provider is configured; the\n+        # decomposer must convert that into a failed outcome, not a crash.\n         with patch(\n-            \"agent.auxiliary_client.get_text_auxiliary_client\",\n-            return_value=(None, \"\"),\n+            \"agent.auxiliary_client.call_llm\",\n+            side_effect=RuntimeError(\"No LLM provider configured\"),\n         ):\n             outcome = decomp.decompose_task(tid, author=\"me\")\n     finally:","@@ -344,4 +347,5 @@ def test_decompose_no_aux_client_configured(kanban_home):\n             p.stop()\n \n     assert outcome.ok is False\n-    assert \"no auxiliary client\" in outcome.reason\n+    # call_llm's no-provider RuntimeError surfaces via the LLM-error branch.\n+    assert \"LLM error\" in outcome.reason"]},{"file":"tests/hermes_cli/test_kanban_specify.py","hunks":["@@ -48,15 +48,12 @@ def _mock_client_returning(content: str):\n \n \n def _patch_aux_client(content: str, *, model: str = \"test-model\"):\n-    \"\"\"Patch get_text_auxiliary_client at its source + at the module that\n-    imported it lazily inside specify_task. Both patches are needed\n-    because kanban_specify imports the function inside the function body.\n+    \"\"\"Patch call_llm at its source module — specify_task now routes through\n+    it (#35566) instead of building a raw client. Returns (patcher, mock) so\n+    callers can still assert on the call.\n     \"\"\"\n-    client = _mock_client_returning(content)\n-    return patch(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        return_value=(client, model),\n-    ), client\n+    mock_fn = MagicMock(return_value=_fake_aux_response(content))\n+    return patch(\"agent.auxiliary_client.call_llm\", mock_fn), mock_fn\n \n \n # ---------------------------------------------------------------------------","@@ -159,13 +156,14 @@ def test_specify_task_no_aux_client_configured(kanban_home):\n         tid = kb.create_task(conn, title=\"rough\", triage=True)\n \n     with patch(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        return_value=(None, \"\"),\n+        \"agent.auxiliary_client.call_llm\",\n+        side_effect=RuntimeError(\"No LLM provider configured\"),\n     ):\n         outcome = spec.specify_task(tid)\n \n     assert outcome.ok is False\n-    assert \"auxiliary client\" in outcome.reason\n+    # call_llm's no-provider RuntimeError surfaces via the LLM-error branch.\n+    assert \"LLM error\" in outcome.reason\n     # Task must stay in triage — we never touched it.\n     with kb.connect() as conn:\n         assert kb.get_task(conn, tid).status == \"triage\"","@@ -176,10 +174,9 @@ def test_specify_task_llm_api_error_keeps_task_in_triage(kanban_home):\n         tid = kb.create_task(conn, title=\"rough\", triage=True)\n \n     client = MagicMock()\n-    client.chat.completions.create = MagicMock(side_effect=RuntimeError(\"429 rate limited\"))\n     with patch(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        return_value=(client, \"test-model\"),\n+        \"agent.auxiliary_client.call_llm\",\n+        side_effect=RuntimeError(\"429 rate limited\"),\n     ):\n         outcome = spec.specify_task(tid)\n ","@@ -288,8 +285,8 @@ def test_cli_specify_all_returns_1_when_every_task_fails(kanban_home, capsys):\n         kb.create_task(conn, title=\"b\", triage=True)\n \n     with patch(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        return_value=(None, \"\"),  # no aux client → every task fails\n+        \"agent.auxiliary_client.call_llm\",\n+        side_effect=RuntimeError(\"No LLM provider configured\"),  # every task fails\n     ):\n         rc = _run_cli(\"specify\", \"--all\")\n "]},{"file":"tests/hermes_cli/test_profile_describer.py","hunks":["@@ -79,11 +79,11 @@ def _fake_aux_response(content: str):\n \n \n def _patch_aux_client(content: str):\n-    client = MagicMock()\n-    client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content))\n+    # describe_profile now routes through call_llm (#35566) — mock it at the\n+    # source module.\n     return patch(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        return_value=(client, \"test-model\"),\n+        \"agent.auxiliary_client.call_llm\",\n+        return_value=_fake_aux_response(content),\n     )\n \n "]},{"file":"tests/plugins/test_kanban_dashboard_plugin.py","hunks":["@@ -2182,13 +2182,10 @@ def _patch_specifier_response(monkeypatch, *, content, model=\"test-model\"):\n     resp = MagicMock()\n     resp.choices = [MagicMock()]\n     resp.choices[0].message.content = content\n-    fake_client = MagicMock()\n-    fake_client.chat.completions.create = MagicMock(return_value=resp)\n-    monkeypatch.setattr(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        lambda *a, **kw: (fake_client, model),\n-    )\n-    return fake_client\n+    # specify_task routes through call_llm now (#35566) — mock it directly.\n+    fake_call = MagicMock(return_value=resp)\n+    monkeypatch.setattr(\"agent.auxiliary_client.call_llm\", fake_call)\n+    return fake_call\n \n \n def test_specify_happy_path(client, monkeypatch):","@@ -2250,11 +2247,11 @@ def test_specify_no_aux_client_surfaces_reason(client, monkeypatch):\n         json={\"title\": \"rough\", \"triage\": True},\n     ).json()[\"task\"]\n \n-    # Simulate \"no auxiliary client configured\".\n-    monkeypatch.setattr(\n-        \"agent.auxiliary_client.get_text_auxiliary_client\",\n-        lambda *a, **kw: (None, \"\"),\n-    )\n+    # Simulate \"no auxiliary client configured\" — call_llm raises when\n+    # no provider resolves (#35566 routing).\n+    def _no_provider(**kwargs):\n+        raise RuntimeError(\"No LLM provider configured\")\n+    monkeypatch.setattr(\"agent.auxiliary_client.call_llm\", _no_provider)\n \n     r = client.post(\n         f\"/api/plugins/kanban/tasks/{t['id']}/specify\",","@@ -2263,7 +2260,8 @@ def test_specify_no_aux_client_surfaces_reason(client, monkeypatch):\n     assert r.status_code == 200\n     body = r.json()\n     assert body[\"ok\"] is False\n-    assert \"auxiliary client\" in body[\"reason\"]\n+    # call_llm's no-provider RuntimeError surfaces via the LLM-error branch.\n+    assert \"LLM error\" in body[\"reason\"]\n \n     # Task must stay in triage — nothing was touched.\n     detail = client.get(f\"/api/plugins/kanban/tasks/{t['id']}\").json()[\"task\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}