From 465f6a321b59f4b17c0b5e2f35fb386b9b1e6da7 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Mon, 12 Oct 2026 18:50:40 +0200 Subject: [PATCH 1/9] Apply selected reasoning effort in cloud Anton sessions. Co-Authored-By: Claude Opus 5.5 (1M context) --- anton/cloud_turn/contract.py | 8 ++++ anton/cloud_turn/session.py | 4 ++ tests/test_cloud_turn_contract.py | 27 +++++++++++++ tests/test_cloud_turn_session.py | 65 +++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/anton/cloud_turn/contract.py b/anton/cloud_turn/contract.py index 2bc9cc164..d420a5bcd 100644 --- a/anton/cloud_turn/contract.py +++ b/anton/cloud_turn/contract.py @@ -225,6 +225,10 @@ class TurnRequestV1: #: {engine: {"usage_notes": str}}. Rendered only for engines the turn's #: vault connects. Absent when no connected engine has notes. connectors: dict | None = None + #: Optional reasoning effort the user picked for this turn. Applied to the + #: planning model only; the name is passed to the provider uninterpreted. + #: Absent, null, or empty keeps the configured default. + reasoning_effort: str | None = None @staticmethod def from_json(raw: str) -> "TurnRequestV1": @@ -232,6 +236,9 @@ def from_json(raw: str) -> "TurnRequestV1": protocol_version = d["protocol_version"] if not _is_version(protocol_version, TURN_PROTOCOL_VERSION): raise ValueError("unsupported cloud turn protocol version") + reasoning_effort = d.get("reasoning_effort") + if reasoning_effort is not None and not isinstance(reasoning_effort, str): + raise ValueError("reasoning_effort must be a string") return TurnRequestV1( protocol_version=protocol_version, conversation_id=str(d["conversation_id"]), @@ -260,4 +267,5 @@ def from_json(raw: str) -> "TurnRequestV1": connectors=( d.get("connectors") if isinstance(d.get("connectors"), dict) else None ), + reasoning_effort=reasoning_effort or None, ) diff --git a/anton/cloud_turn/session.py b/anton/cloud_turn/session.py index 6f3190159..1a7d75f2c 100644 --- a/anton/cloud_turn/session.py +++ b/anton/cloud_turn/session.py @@ -773,6 +773,10 @@ def build_cloud_chat_session( settings.resolve_workspace(str(base)) if request.model: settings.planning_model = request.model + # Planning only: the router shares the coding provider, so changing coding + # effort would also change history summarization. + if request.reasoning_effort: + settings.planning_reasoning_effort = request.reasoning_effort # Mounted (_SKILLS_ROOT_ENV set): the organization's own live skills tree, # owned by cowork-server, read directly. Unmounted (desktop, CI): the # request's skills are staged outside the workspace instead, since that PVC diff --git a/tests/test_cloud_turn_contract.py b/tests/test_cloud_turn_contract.py index f0f90ebd1..f1cc2eb93 100644 --- a/tests/test_cloud_turn_contract.py +++ b/tests/test_cloud_turn_contract.py @@ -89,3 +89,30 @@ def test_a_missing_or_non_object_connectors_block_parses_to_none(value): if value is not None: body["connectors"] = value assert TurnRequestV1.from_json(json.dumps(body)).connectors is None + + +def _with_effort(**extra): + body = {"protocol_version": 1, "conversation_id": "c", "input": "hi"} + body.update(extra) + return TurnRequestV1.from_json(json.dumps(body)) + + +@pytest.mark.parametrize("effort", ["low", "none", "high", "some-future-level"]) +def test_reasoning_effort_is_passed_through_uninterpreted(effort): + assert _with_effort(reasoning_effort=effort).reasoning_effort == effort + + +@pytest.mark.parametrize("extra", [{}, {"reasoning_effort": None}, {"reasoning_effort": ""}]) +def test_absent_null_or_empty_reasoning_effort_is_unset(extra): + assert _with_effort(**extra).reasoning_effort is None + + +@pytest.mark.parametrize("effort", [1, True, ["high"], {"level": "high"}, 0.5]) +def test_a_non_string_reasoning_effort_is_rejected_without_echoing_it(effort): + raw = json.dumps({ + "protocol_version": 1, "conversation_id": "c", "input": "hi", + "llm": {"api_key": "sk-must-not-appear"}, "reasoning_effort": effort, + }) + with pytest.raises(ValueError) as err: + TurnRequestV1.from_json(raw) + assert str(err.value) == "reasoning_effort must be a string" diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index 94b6b6e90..96003c41a 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -1261,6 +1261,71 @@ def fake_minds_request(url, api_key, *, method="GET", payload=None, verify=True, assert "DS_GMAIL_PRIMARY__ACCESS_TOKEN" not in os.environ +# ── selected reasoning effort ──────────────────────────────────────────────── + +_MINDS_LLM = {"provider": "minds-cloud", "api_key": "mdb_k", "base_url": "https://api.mindshub.ai"} + + +def _build_real_client(tmp_path, monkeypatch, **req_overrides): + """Like _build, but keeps the real LLMClient so its providers can be inspected.""" + captured: dict = {} + real_from_settings = llm_client_mod.LLMClient.from_settings.__func__ + + def capture(cls, settings): + captured["client"] = real_from_settings(cls, settings) + return captured["client"] + + monkeypatch.setattr(session_mod, "ChatSession", lambda config: _FakeSession()) + monkeypatch.setattr(llm_client_mod.LLMClient, "from_settings", classmethod(capture)) + monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) + body = dict(protocol_version=1, conversation_id="conv_1", input="hello", llm=_MINDS_LLM) + body.update(req_overrides) + build_cloud_chat_session(TurnRequestV1(**body)) + return captured["client"] + + +def _request_reasoning(provider): + """The reasoning block the provider would put on a real request, or None.""" + kwargs = provider._build_responses_kwargs( + model="m", system="s", messages=[{"role": "user", "content": "hi"}], + tools=None, tool_choice=None, max_tokens=100, native_web_tools=None, + ) + return kwargs.get("reasoning") + + +@pytest.mark.parametrize("coding_model", [None, "claude-opus-4-6"]) +@pytest.mark.parametrize("router", [False, True]) +def test_selected_effort_reaches_only_the_planning_provider(tmp_path, monkeypatch, coding_model, router): + """The router shares the coding provider unless one is configured, so + coding effort must stay put or history summarization would change too.""" + if router: + monkeypatch.setenv("ANTON_ROUTER_PROVIDER", "openai-compatible") + monkeypatch.setenv("ANTON_CODING_REASONING_EFFORT", "medium") + llm = dict(_MINDS_LLM, coding_model=coding_model) if coding_model else _MINDS_LLM + client = _build_real_client( + tmp_path, monkeypatch, model="claude-opus-4-6", llm=llm, reasoning_effort="low", + ) + assert _request_reasoning(client.planning_provider)["effort"] == "low" + assert _request_reasoning(client.coding_provider)["effort"] == "medium" + if router: + assert client.router_provider is not client.coding_provider + assert _request_reasoning(client.router_provider) is None + else: + assert client.router_provider is client.coding_provider + + +def test_no_selected_effort_keeps_the_configured_planning_effort(tmp_path, monkeypatch): + monkeypatch.setenv("ANTON_PLANNING_REASONING_EFFORT", "high") + client = _build_real_client(tmp_path, monkeypatch, reasoning_effort=None) + assert _request_reasoning(client.planning_provider)["effort"] == "high" + assert _request_reasoning(client.coding_provider) is None + + +def test_no_selected_or_configured_effort_sends_none(tmp_path, monkeypatch): + client = _build_real_client(tmp_path, monkeypatch) + assert _request_reasoning(client.planning_provider) is None + assert _request_reasoning(client.coding_provider) is None + # ── ENG-1638: the pod injects a configured-LLM block ───────────────────────── def test_pod_injects_a_configured_llm_block_for_the_runtime_identity(tmp_path, monkeypatch): From 80960a669689fb9fa04c7b9e5b2540fd81f01faf Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 15:25:09 +0200 Subject: [PATCH 2/9] Fall back to the default effort when the request carries a non-string value. Co-Authored-By: Claude Opus 5.5 (1M context) --- anton/cloud_turn/contract.py | 9 ++++----- tests/test_cloud_turn_contract.py | 10 ++-------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/anton/cloud_turn/contract.py b/anton/cloud_turn/contract.py index d420a5bcd..596419911 100644 --- a/anton/cloud_turn/contract.py +++ b/anton/cloud_turn/contract.py @@ -227,7 +227,7 @@ class TurnRequestV1: connectors: dict | None = None #: Optional reasoning effort the user picked for this turn. Applied to the #: planning model only; the name is passed to the provider uninterpreted. - #: Absent, null, or empty keeps the configured default. + #: Absent, null, empty, or non-string keeps the configured default. reasoning_effort: str | None = None @staticmethod @@ -236,9 +236,6 @@ def from_json(raw: str) -> "TurnRequestV1": protocol_version = d["protocol_version"] if not _is_version(protocol_version, TURN_PROTOCOL_VERSION): raise ValueError("unsupported cloud turn protocol version") - reasoning_effort = d.get("reasoning_effort") - if reasoning_effort is not None and not isinstance(reasoning_effort, str): - raise ValueError("reasoning_effort must be a string") return TurnRequestV1( protocol_version=protocol_version, conversation_id=str(d["conversation_id"]), @@ -267,5 +264,7 @@ def from_json(raw: str) -> "TurnRequestV1": connectors=( d.get("connectors") if isinstance(d.get("connectors"), dict) else None ), - reasoning_effort=reasoning_effort or None, + reasoning_effort=( + d.get("reasoning_effort") if isinstance(d.get("reasoning_effort"), str) else None + ) or None, ) diff --git a/tests/test_cloud_turn_contract.py b/tests/test_cloud_turn_contract.py index f1cc2eb93..ee3313db9 100644 --- a/tests/test_cloud_turn_contract.py +++ b/tests/test_cloud_turn_contract.py @@ -108,11 +108,5 @@ def test_absent_null_or_empty_reasoning_effort_is_unset(extra): @pytest.mark.parametrize("effort", [1, True, ["high"], {"level": "high"}, 0.5]) -def test_a_non_string_reasoning_effort_is_rejected_without_echoing_it(effort): - raw = json.dumps({ - "protocol_version": 1, "conversation_id": "c", "input": "hi", - "llm": {"api_key": "sk-must-not-appear"}, "reasoning_effort": effort, - }) - with pytest.raises(ValueError) as err: - TurnRequestV1.from_json(raw) - assert str(err.value) == "reasoning_effort must be a string" +def test_a_non_string_reasoning_effort_falls_back_to_the_default(effort): + assert _with_effort(reasoning_effort=effort).reasoning_effort is None From ed4957c20d146074fb3eea61d477ceac1d821b25 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 16:06:50 +0200 Subject: [PATCH 3/9] Read the requested effort once and treat a blank value as unset. Co-Authored-By: Claude Opus 5.5 (1M context) --- anton/cloud_turn/contract.py | 9 +++++---- tests/test_cloud_turn_contract.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/anton/cloud_turn/contract.py b/anton/cloud_turn/contract.py index 596419911..7114e6c74 100644 --- a/anton/cloud_turn/contract.py +++ b/anton/cloud_turn/contract.py @@ -227,7 +227,7 @@ class TurnRequestV1: connectors: dict | None = None #: Optional reasoning effort the user picked for this turn. Applied to the #: planning model only; the name is passed to the provider uninterpreted. - #: Absent, null, empty, or non-string keeps the configured default. + #: Absent, null, blank, or non-string keeps the configured default. reasoning_effort: str | None = None @staticmethod @@ -236,6 +236,9 @@ def from_json(raw: str) -> "TurnRequestV1": protocol_version = d["protocol_version"] if not _is_version(protocol_version, TURN_PROTOCOL_VERSION): raise ValueError("unsupported cloud turn protocol version") + reasoning_effort = d.get("reasoning_effort") + if not isinstance(reasoning_effort, str): + reasoning_effort = "" return TurnRequestV1( protocol_version=protocol_version, conversation_id=str(d["conversation_id"]), @@ -264,7 +267,5 @@ def from_json(raw: str) -> "TurnRequestV1": connectors=( d.get("connectors") if isinstance(d.get("connectors"), dict) else None ), - reasoning_effort=( - d.get("reasoning_effort") if isinstance(d.get("reasoning_effort"), str) else None - ) or None, + reasoning_effort=reasoning_effort.strip() or None, ) diff --git a/tests/test_cloud_turn_contract.py b/tests/test_cloud_turn_contract.py index ee3313db9..200603d17 100644 --- a/tests/test_cloud_turn_contract.py +++ b/tests/test_cloud_turn_contract.py @@ -102,8 +102,8 @@ def test_reasoning_effort_is_passed_through_uninterpreted(effort): assert _with_effort(reasoning_effort=effort).reasoning_effort == effort -@pytest.mark.parametrize("extra", [{}, {"reasoning_effort": None}, {"reasoning_effort": ""}]) -def test_absent_null_or_empty_reasoning_effort_is_unset(extra): +@pytest.mark.parametrize("extra", [{}, {"reasoning_effort": None}, {"reasoning_effort": ""}, {"reasoning_effort": " "}]) +def test_absent_null_or_blank_reasoning_effort_is_unset(extra): assert _with_effort(**extra).reasoning_effort is None From 494adb0d24a4496e0d52bf5d798c57b50d84b891 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 16:50:43 +0200 Subject: [PATCH 4/9] Resend once at the configured effort when the provider refuses the selected one. Co-Authored-By: Claude Opus 5.5 (1M context) --- anton/cloud_turn/session.py | 10 +- anton/core/llm/client.py | 103 +++++++++++++++++++- tests/cloud_turn_fake_entry.py | 2 +- tests/test_cloud_turn_process.py | 2 +- tests/test_cloud_turn_session.py | 19 ++-- tests/test_cloud_turn_usage_notes.py | 2 +- tests/test_planning_effort_fallback.py | 128 +++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 21 deletions(-) create mode 100644 tests/test_planning_effort_fallback.py diff --git a/anton/cloud_turn/session.py b/anton/cloud_turn/session.py index 1a7d75f2c..d566eba27 100644 --- a/anton/cloud_turn/session.py +++ b/anton/cloud_turn/session.py @@ -773,10 +773,6 @@ def build_cloud_chat_session( settings.resolve_workspace(str(base)) if request.model: settings.planning_model = request.model - # Planning only: the router shares the coding provider, so changing coding - # effort would also change history summarization. - if request.reasoning_effort: - settings.planning_reasoning_effort = request.reasoning_effort # Mounted (_SKILLS_ROOT_ENV set): the organization's own live skills tree, # owned by cowork-server, read directly. Unmounted (desktop, CI): the # request's skills are staged outside the workspace instead, since that PVC @@ -800,7 +796,11 @@ def build_cloud_chat_session( # No apply_env_to_process(): loading workspace .env into the process env # would expose tenant secrets to cell code. - llm_client = LLMClient.from_settings(settings) + # Planning only: the router shares the coding provider, so changing coding + # effort would also change history summarization. + llm_client = LLMClient.from_settings( + settings, planning_effort_override=request.reasoning_effort + ) cortex = _build_cortex(request.memory, llm_client) diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index 371c78e61..5f1e68c8f 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -12,6 +12,8 @@ LLMResponse, ModelCallTimeoutError, ProviderAuthError, + ProviderConnectionInfo, + RequestRefusedError, StreamComplete, StreamEvent, ) @@ -108,6 +110,86 @@ def _resolve_openai_compatible_flavor(settings: AntonSettings) -> str: return OpenAIProvider.FLAVOR_OPENAI_COMPATIBLE_GENERIC +class _EffortFallbackProvider(LLMProvider): + """Planning provider for an effort the request picked, not the configuration. + + If the provider refuses that effort, the request switches to the same + provider at the configured effort and is sent once more; every later call + stays on the configured one. A refusal after a stream has produced events + is raised as is, since resending would repeat output. + """ + + def __init__(self, *, selected: LLMProvider, configured: LLMProvider) -> None: + self._selected = selected + self._configured = configured + self._fell_back = False + + @property + def active(self) -> LLMProvider: + """The provider the next call goes to.""" + return self._configured if self._fell_back else self._selected + + @property + def name(self) -> str: + return self.active.name + + @property + def trace_role(self) -> str | None: + return self.active.trace_role + + @trace_role.setter + def trace_role(self, role: str | None) -> None: + self._selected.trace_role = role + self._configured.trace_role = role + + def _should_fall_back(self, exc: RequestRefusedError) -> bool: + if self._fell_back or exc.code != "parameter_refused": + return False + logging.getLogger(__name__).warning( + "planning provider refused the selected reasoning effort; using the configured one" + ) + self._fell_back = True + return True + + def native_web_tools(self) -> set[str]: + return self.active.native_web_tools() + + def export_connection_info(self) -> ProviderConnectionInfo: + return self.active.export_connection_info() + + async def aclose(self) -> None: + """Close both providers' transports.""" + try: + await self._selected.aclose() + finally: + await self._configured.aclose() + + async def complete(self, **kwargs) -> LLMResponse: + """``complete`` on the active provider, resent once on a refused effort.""" + provider = self.active + try: + return await provider.complete(**kwargs) + except RequestRefusedError as exc: + if provider is not self._selected or not self._should_fall_back(exc): + raise + return await self._configured.complete(**kwargs) + + async def stream(self, **kwargs) -> AsyncIterator[StreamEvent]: + """``stream`` on the active provider, resent once on a refused effort.""" + provider = self.active + started = False + try: + async for event in provider.stream(**kwargs): + started = True + yield event + return + except RequestRefusedError as exc: + if started or provider is not self._selected or not self._should_fall_back(exc): + raise + async for event in self._configured.stream(**kwargs): + yield event + + class LLMClient: def __init__( self, @@ -748,7 +830,15 @@ async def generate_object_code( ) @classmethod - def from_settings(cls, settings: AntonSettings) -> LLMClient: + def from_settings( + cls, settings: AntonSettings, *, planning_effort_override: str | None = None + ) -> LLMClient: + """Build the client the settings describe. + + ``planning_effort_override`` is an effort picked for this turn rather + than configured. The planning provider uses it, and falls back to the + configured planning effort if the provider refuses it. + """ from .openai import OpenAIProvider def build_anthropic(effort: str | None) -> LLMProvider: @@ -816,10 +906,15 @@ def build_anthropic(effort: str | None) -> LLMProvider: raise ValueError(f"Unknown router provider: {router_provider_name}") router_provider = router_factory(None) + configured_effort = getattr(settings, "planning_reasoning_effort", None) + planning_provider = planning_factory(configured_effort) + if planning_effort_override and planning_effort_override != configured_effort: + planning_provider = _EffortFallbackProvider( + selected=planning_factory(planning_effort_override), + configured=planning_provider, + ) return cls( - planning_provider=planning_factory( - getattr(settings, "planning_reasoning_effort", None) - ), + planning_provider=planning_provider, planning_model=settings.planning_model, coding_provider=coding_factory( getattr(settings, "coding_reasoning_effort", None) diff --git a/tests/cloud_turn_fake_entry.py b/tests/cloud_turn_fake_entry.py index e057b2a78..aaab31dba 100644 --- a/tests/cloud_turn_fake_entry.py +++ b/tests/cloud_turn_fake_entry.py @@ -61,7 +61,7 @@ async def complete(self, *, model, system, messages, tools=None, def export_connection_info(self): return ProviderConnectionInfo(provider="fake", api_key="fake") - def _fake_from_settings(cls, settings): + def _fake_from_settings(cls, settings, **_kw): prov = _FakeProvider() return LLMClient( planning_provider=prov, planning_model="fake-model", diff --git a/tests/test_cloud_turn_process.py b/tests/test_cloud_turn_process.py index c75b005d8..bd1b11cef 100644 --- a/tests/test_cloud_turn_process.py +++ b/tests/test_cloud_turn_process.py @@ -161,7 +161,7 @@ async def _real_cloud_session(tmp_path, monkeypatch): monkeypatch.setenv("ANTON_CLOUD_WORKSPACE_PATH", str(tmp_path)) - def _mk(cls, settings): + def _mk(cls, settings, **_kw): llm = AsyncMock() llm.coding_provider = MagicMock() llm.coding_provider.export_connection_info = MagicMock( diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index 96003c41a..87b1833fa 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -51,7 +51,7 @@ def fake_chat_session(config): monkeypatch.setattr(session_mod, "ChatSession", fake_chat_session) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: object()), + classmethod(lambda cls, settings, **_kw: object()), ) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) @@ -567,7 +567,7 @@ def _real_cloud_session(tmp_path, monkeypatch, **req_overrides): monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: _mock_llm()), + classmethod(lambda cls, settings, **_kw: _mock_llm()), ) body = dict(protocol_version=1, conversation_id="conv_1", input="hi") body.update(req_overrides) @@ -644,7 +644,7 @@ def test_final_tool_set_equals_allowlist_after_real_build(tmp_path, monkeypatch) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: _mock_llm()), + classmethod(lambda cls, settings, **_kw: _mock_llm()), ) req = TurnRequestV1(protocol_version=1, conversation_id="c", input="hi") session = build_cloud_chat_session(req) # REAL ChatSession @@ -773,7 +773,7 @@ def _real_cloud_session(tmp_path, monkeypatch, **req_overrides): monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: _mock_llm()), + classmethod(lambda cls, settings, **_kw: _mock_llm()), ) body = dict(protocol_version=1, conversation_id="c", input="hi") body.update(req_overrides) @@ -1271,8 +1271,8 @@ def _build_real_client(tmp_path, monkeypatch, **req_overrides): captured: dict = {} real_from_settings = llm_client_mod.LLMClient.from_settings.__func__ - def capture(cls, settings): - captured["client"] = real_from_settings(cls, settings) + def capture(cls, settings, **kwargs): + captured["client"] = real_from_settings(cls, settings, **kwargs) return captured["client"] monkeypatch.setattr(session_mod, "ChatSession", lambda config: _FakeSession()) @@ -1285,7 +1285,8 @@ def capture(cls, settings): def _request_reasoning(provider): - """The reasoning block the provider would put on a real request, or None.""" + """The reasoning block the provider would put on its next real request, or None.""" + provider = getattr(provider, "active", provider) kwargs = provider._build_responses_kwargs( model="m", system="s", messages=[{"role": "user", "content": "hi"}], tools=None, tool_choice=None, max_tokens=100, native_web_tools=None, @@ -1481,7 +1482,7 @@ def fake_chat_session_raises(config): monkeypatch.setattr(session_mod, "ChatSession", fake_chat_session_raises) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: object()), + classmethod(lambda cls, settings, **_kw: object()), ) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) @@ -1506,7 +1507,7 @@ def fake_chat_session_raises(config): monkeypatch.setattr(session_mod, "ChatSession", fake_chat_session_raises) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: object()), + classmethod(lambda cls, settings, **_kw: object()), ) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) diff --git a/tests/test_cloud_turn_usage_notes.py b/tests/test_cloud_turn_usage_notes.py index 230844db5..9782b52f6 100644 --- a/tests/test_cloud_turn_usage_notes.py +++ b/tests/test_cloud_turn_usage_notes.py @@ -22,7 +22,7 @@ def _offline_turn(tmp_path, monkeypatch): monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings: make_mock_llm()), + classmethod(lambda cls, settings, **_kw: make_mock_llm()), ) # MCP discovery is not under test here; stubbing the module also keeps the # test runnable in an env that lacks the `mcp` runtime dependency. diff --git a/tests/test_planning_effort_fallback.py b/tests/test_planning_effort_fallback.py new file mode 100644 index 000000000..1142954dc --- /dev/null +++ b/tests/test_planning_effort_fallback.py @@ -0,0 +1,128 @@ +"""A reasoning effort picked for the turn, and refused by the provider, falls +back to the configured effort instead of failing the turn. + +Driven through `LLMClient.from_settings` and the real OpenAI SDK on a mock +transport, so the refusal comes from the provider's own 400 classification. +""" + +from __future__ import annotations + +import json + +import httpx2 as httpx +import openai +import pytest + +from anton.config.settings import AntonSettings +from anton.core.llm.client import LLMClient +from anton.core.llm.openai import OpenAIProvider +from anton.core.llm.provider import RequestRefusedError, StreamTextDelta +from tests.test_bad_request_reaches_the_classifier import ( + _MESSAGES, + _TOOLS, + _TOOLS_WITH_EFFORT_400, + _streamed_answer, +) + +_BASE_URL = "https://api.openai.com/v1" +_FLAVORS = [OpenAIProvider.FLAVOR_OPENAI_COMPATIBLE_GENERIC, OpenAIProvider.FLAVOR_OPENAI] +_PROMPT_BLOCKED_400 = {"error": { + "type": "invalid_request_error", "code": "content_filter", "param": None, + "message": "The prompt was blocked.", +}} + + +def _sent_effort(request: dict) -> str | None: + """The effort a request body carries, in either wire format.""" + return request.get("reasoning_effort") or (request.get("reasoning") or {}).get("effort") + + +def _client(monkeypatch, flavor, *, configured, override, refused_effort, body=_TOOLS_WITH_EFFORT_400): + """A real client whose transport refuses any request carrying `refused_effort`.""" + requests: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + sent = json.loads(request.content) + requests.append(sent) + if _sent_effort(sent) == refused_effort: + return httpx.Response(400, json=body) + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + content=_streamed_answer(flavor, "answered"), + ) + + real_async_openai = openai.AsyncOpenAI + + def mock_async_openai(**kwargs): + kwargs["http_client"] = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return real_async_openai(**{**kwargs, "max_retries": 0}) + + monkeypatch.setattr(openai, "AsyncOpenAI", mock_async_openai) + provider = "openai" if flavor == OpenAIProvider.FLAVOR_OPENAI else "openai-compatible" + settings = AntonSettings( + _env_file=None, + planning_provider=provider, coding_provider=provider, + planning_model="gpt-6-luna", coding_model="gpt-6-luna", + openai_api_key="test-key", openai_base_url=_BASE_URL, + planning_reasoning_effort=configured, + ) + if flavor != OpenAIProvider.FLAVOR_OPENAI: + monkeypatch.setattr("anton.core.llm.client._resolve_openai_compatible_flavor", lambda s: flavor) + return LLMClient.from_settings(settings, planning_effort_override=override), requests + + +async def _answer(client: LLMClient) -> str: + text = "" + async for event in client.plan_stream(system="s", messages=_MESSAGES, tools=_TOOLS): + if isinstance(event, StreamTextDelta): + text += event.text + return text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("flavor", _FLAVORS) +@pytest.mark.parametrize("configured", [None, "low"]) +async def test_a_refused_selected_effort_is_resent_once_at_the_configured_effort( + monkeypatch, flavor, configured, +): + client, requests = _client( + monkeypatch, flavor, configured=configured, override="max", refused_effort="max", + ) + try: + assert await _answer(client) == "answered" + assert [_sent_effort(r) for r in requests] == ["max", configured] + assert await _answer(client) == "answered" + assert len(requests) == 3, "later turns go straight to the configured effort" + finally: + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("flavor", _FLAVORS) +async def test_a_refused_configured_effort_still_fails_as_before(monkeypatch, flavor): + client, requests = _client( + monkeypatch, flavor, configured="max", override=None, refused_effort="max", + ) + try: + with pytest.raises(RequestRefusedError) as err: + await _answer(client) + assert err.value.code == "parameter_refused" + assert len(requests) == 1 + finally: + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("flavor", _FLAVORS) +async def test_a_blocked_prompt_is_not_resent(monkeypatch, flavor): + client, requests = _client( + monkeypatch, flavor, configured=None, override="max", refused_effort="max", + body=_PROMPT_BLOCKED_400, + ) + try: + with pytest.raises(RequestRefusedError) as err: + await _answer(client) + assert err.value.code == "content_filter" + assert len(requests) == 1 + finally: + await client.aclose() From 6fb32e1c19fc81fce8944a6696d9cdf45f2b2125 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 17:01:07 +0200 Subject: [PATCH 5/9] Document the effort fallback provider's methods. Co-Authored-By: Claude Opus 5.5 (1M context) --- anton/core/llm/client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index 5f1e68c8f..f8ed4d378 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -131,18 +131,26 @@ def active(self) -> LLMProvider: @property def name(self) -> str: + """The active provider's id.""" return self.active.name @property def trace_role(self) -> str | None: + """The role the active provider reports in traces.""" return self.active.trace_role @trace_role.setter def trace_role(self, role: str | None) -> None: + """Set the trace role on both providers, so a fallback keeps it.""" self._selected.trace_role = role self._configured.trace_role = role def _should_fall_back(self, exc: RequestRefusedError) -> bool: + """True when ``exc`` refuses the selected effort and no fallback happened yet. + + Side effect: on True, logs once and moves every later call on this + provider to the configured effort. + """ if self._fell_back or exc.code != "parameter_refused": return False logging.getLogger(__name__).warning( @@ -152,9 +160,11 @@ def _should_fall_back(self, exc: RequestRefusedError) -> bool: return True def native_web_tools(self) -> set[str]: + """The active provider's server-side web tools.""" return self.active.native_web_tools() def export_connection_info(self) -> ProviderConnectionInfo: + """The active provider's connection details.""" return self.active.export_connection_info() async def aclose(self) -> None: From ebbc255677714e49af39481233dafb99bac2f811 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 18:45:51 +0200 Subject: [PATCH 6/9] Replace the effort fallback provider with the planning effort setting Co-Authored-By: Claude Fable 5.1 --- anton/cloud_turn/session.py | 10 +- anton/core/llm/client.py | 114 +--------------------- tests/cloud_turn_fake_entry.py | 2 +- tests/test_cloud_turn_process.py | 2 +- tests/test_cloud_turn_session.py | 17 ++-- tests/test_cloud_turn_usage_notes.py | 2 +- tests/test_planning_effort_fallback.py | 128 ------------------------- 7 files changed, 21 insertions(+), 254 deletions(-) delete mode 100644 tests/test_planning_effort_fallback.py diff --git a/anton/cloud_turn/session.py b/anton/cloud_turn/session.py index d566eba27..1a7d75f2c 100644 --- a/anton/cloud_turn/session.py +++ b/anton/cloud_turn/session.py @@ -773,6 +773,10 @@ def build_cloud_chat_session( settings.resolve_workspace(str(base)) if request.model: settings.planning_model = request.model + # Planning only: the router shares the coding provider, so changing coding + # effort would also change history summarization. + if request.reasoning_effort: + settings.planning_reasoning_effort = request.reasoning_effort # Mounted (_SKILLS_ROOT_ENV set): the organization's own live skills tree, # owned by cowork-server, read directly. Unmounted (desktop, CI): the # request's skills are staged outside the workspace instead, since that PVC @@ -796,11 +800,7 @@ def build_cloud_chat_session( # No apply_env_to_process(): loading workspace .env into the process env # would expose tenant secrets to cell code. - # Planning only: the router shares the coding provider, so changing coding - # effort would also change history summarization. - llm_client = LLMClient.from_settings( - settings, planning_effort_override=request.reasoning_effort - ) + llm_client = LLMClient.from_settings(settings) cortex = _build_cortex(request.memory, llm_client) diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index f8ed4d378..0f4af4d79 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -12,8 +12,6 @@ LLMResponse, ModelCallTimeoutError, ProviderAuthError, - ProviderConnectionInfo, - RequestRefusedError, StreamComplete, StreamEvent, ) @@ -110,96 +108,6 @@ def _resolve_openai_compatible_flavor(settings: AntonSettings) -> str: return OpenAIProvider.FLAVOR_OPENAI_COMPATIBLE_GENERIC -class _EffortFallbackProvider(LLMProvider): - """Planning provider for an effort the request picked, not the configuration. - - If the provider refuses that effort, the request switches to the same - provider at the configured effort and is sent once more; every later call - stays on the configured one. A refusal after a stream has produced events - is raised as is, since resending would repeat output. - """ - - def __init__(self, *, selected: LLMProvider, configured: LLMProvider) -> None: - self._selected = selected - self._configured = configured - self._fell_back = False - - @property - def active(self) -> LLMProvider: - """The provider the next call goes to.""" - return self._configured if self._fell_back else self._selected - - @property - def name(self) -> str: - """The active provider's id.""" - return self.active.name - - @property - def trace_role(self) -> str | None: - """The role the active provider reports in traces.""" - return self.active.trace_role - - @trace_role.setter - def trace_role(self, role: str | None) -> None: - """Set the trace role on both providers, so a fallback keeps it.""" - self._selected.trace_role = role - self._configured.trace_role = role - - def _should_fall_back(self, exc: RequestRefusedError) -> bool: - """True when ``exc`` refuses the selected effort and no fallback happened yet. - - Side effect: on True, logs once and moves every later call on this - provider to the configured effort. - """ - if self._fell_back or exc.code != "parameter_refused": - return False - logging.getLogger(__name__).warning( - "planning provider refused the selected reasoning effort; using the configured one" - ) - self._fell_back = True - return True - - def native_web_tools(self) -> set[str]: - """The active provider's server-side web tools.""" - return self.active.native_web_tools() - - def export_connection_info(self) -> ProviderConnectionInfo: - """The active provider's connection details.""" - return self.active.export_connection_info() - - async def aclose(self) -> None: - """Close both providers' transports.""" - try: - await self._selected.aclose() - finally: - await self._configured.aclose() - - async def complete(self, **kwargs) -> LLMResponse: - """``complete`` on the active provider, resent once on a refused effort.""" - provider = self.active - try: - return await provider.complete(**kwargs) - except RequestRefusedError as exc: - if provider is not self._selected or not self._should_fall_back(exc): - raise - return await self._configured.complete(**kwargs) - - async def stream(self, **kwargs) -> AsyncIterator[StreamEvent]: - """``stream`` on the active provider, resent once on a refused effort.""" - provider = self.active - started = False - try: - async for event in provider.stream(**kwargs): - started = True - yield event - return - except RequestRefusedError as exc: - if started or provider is not self._selected or not self._should_fall_back(exc): - raise - async for event in self._configured.stream(**kwargs): - yield event - - class LLMClient: def __init__( self, @@ -840,15 +748,8 @@ async def generate_object_code( ) @classmethod - def from_settings( - cls, settings: AntonSettings, *, planning_effort_override: str | None = None - ) -> LLMClient: - """Build the client the settings describe. - - ``planning_effort_override`` is an effort picked for this turn rather - than configured. The planning provider uses it, and falls back to the - configured planning effort if the provider refuses it. - """ + def from_settings(cls, settings: AntonSettings) -> LLMClient: + """Build the client the settings describe, one provider per role.""" from .openai import OpenAIProvider def build_anthropic(effort: str | None) -> LLMProvider: @@ -916,15 +817,10 @@ def build_anthropic(effort: str | None) -> LLMProvider: raise ValueError(f"Unknown router provider: {router_provider_name}") router_provider = router_factory(None) - configured_effort = getattr(settings, "planning_reasoning_effort", None) - planning_provider = planning_factory(configured_effort) - if planning_effort_override and planning_effort_override != configured_effort: - planning_provider = _EffortFallbackProvider( - selected=planning_factory(planning_effort_override), - configured=planning_provider, - ) return cls( - planning_provider=planning_provider, + planning_provider=planning_factory( + getattr(settings, "planning_reasoning_effort", None) + ), planning_model=settings.planning_model, coding_provider=coding_factory( getattr(settings, "coding_reasoning_effort", None) diff --git a/tests/cloud_turn_fake_entry.py b/tests/cloud_turn_fake_entry.py index aaab31dba..e057b2a78 100644 --- a/tests/cloud_turn_fake_entry.py +++ b/tests/cloud_turn_fake_entry.py @@ -61,7 +61,7 @@ async def complete(self, *, model, system, messages, tools=None, def export_connection_info(self): return ProviderConnectionInfo(provider="fake", api_key="fake") - def _fake_from_settings(cls, settings, **_kw): + def _fake_from_settings(cls, settings): prov = _FakeProvider() return LLMClient( planning_provider=prov, planning_model="fake-model", diff --git a/tests/test_cloud_turn_process.py b/tests/test_cloud_turn_process.py index bd1b11cef..c75b005d8 100644 --- a/tests/test_cloud_turn_process.py +++ b/tests/test_cloud_turn_process.py @@ -161,7 +161,7 @@ async def _real_cloud_session(tmp_path, monkeypatch): monkeypatch.setenv("ANTON_CLOUD_WORKSPACE_PATH", str(tmp_path)) - def _mk(cls, settings, **_kw): + def _mk(cls, settings): llm = AsyncMock() llm.coding_provider = MagicMock() llm.coding_provider.export_connection_info = MagicMock( diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index 87b1833fa..4a5487952 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -51,7 +51,7 @@ def fake_chat_session(config): monkeypatch.setattr(session_mod, "ChatSession", fake_chat_session) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings, **_kw: object()), + classmethod(lambda cls, settings: object()), ) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) @@ -567,7 +567,7 @@ def _real_cloud_session(tmp_path, monkeypatch, **req_overrides): monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings, **_kw: _mock_llm()), + classmethod(lambda cls, settings: _mock_llm()), ) body = dict(protocol_version=1, conversation_id="conv_1", input="hi") body.update(req_overrides) @@ -644,7 +644,7 @@ def test_final_tool_set_equals_allowlist_after_real_build(tmp_path, monkeypatch) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings, **_kw: _mock_llm()), + classmethod(lambda cls, settings: _mock_llm()), ) req = TurnRequestV1(protocol_version=1, conversation_id="c", input="hi") session = build_cloud_chat_session(req) # REAL ChatSession @@ -773,7 +773,7 @@ def _real_cloud_session(tmp_path, monkeypatch, **req_overrides): monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings, **_kw: _mock_llm()), + classmethod(lambda cls, settings: _mock_llm()), ) body = dict(protocol_version=1, conversation_id="c", input="hi") body.update(req_overrides) @@ -1271,8 +1271,8 @@ def _build_real_client(tmp_path, monkeypatch, **req_overrides): captured: dict = {} real_from_settings = llm_client_mod.LLMClient.from_settings.__func__ - def capture(cls, settings, **kwargs): - captured["client"] = real_from_settings(cls, settings, **kwargs) + def capture(cls, settings): + captured["client"] = real_from_settings(cls, settings) return captured["client"] monkeypatch.setattr(session_mod, "ChatSession", lambda config: _FakeSession()) @@ -1286,7 +1286,6 @@ def capture(cls, settings, **kwargs): def _request_reasoning(provider): """The reasoning block the provider would put on its next real request, or None.""" - provider = getattr(provider, "active", provider) kwargs = provider._build_responses_kwargs( model="m", system="s", messages=[{"role": "user", "content": "hi"}], tools=None, tool_choice=None, max_tokens=100, native_web_tools=None, @@ -1482,7 +1481,7 @@ def fake_chat_session_raises(config): monkeypatch.setattr(session_mod, "ChatSession", fake_chat_session_raises) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings, **_kw: object()), + classmethod(lambda cls, settings: object()), ) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) @@ -1507,7 +1506,7 @@ def fake_chat_session_raises(config): monkeypatch.setattr(session_mod, "ChatSession", fake_chat_session_raises) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings, **_kw: object()), + classmethod(lambda cls, settings: object()), ) monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) diff --git a/tests/test_cloud_turn_usage_notes.py b/tests/test_cloud_turn_usage_notes.py index 9782b52f6..230844db5 100644 --- a/tests/test_cloud_turn_usage_notes.py +++ b/tests/test_cloud_turn_usage_notes.py @@ -22,7 +22,7 @@ def _offline_turn(tmp_path, monkeypatch): monkeypatch.setenv(_WORKSPACE_PATH_ENV, str(tmp_path)) monkeypatch.setattr( llm_client_mod.LLMClient, "from_settings", - classmethod(lambda cls, settings, **_kw: make_mock_llm()), + classmethod(lambda cls, settings: make_mock_llm()), ) # MCP discovery is not under test here; stubbing the module also keeps the # test runnable in an env that lacks the `mcp` runtime dependency. diff --git a/tests/test_planning_effort_fallback.py b/tests/test_planning_effort_fallback.py deleted file mode 100644 index 1142954dc..000000000 --- a/tests/test_planning_effort_fallback.py +++ /dev/null @@ -1,128 +0,0 @@ -"""A reasoning effort picked for the turn, and refused by the provider, falls -back to the configured effort instead of failing the turn. - -Driven through `LLMClient.from_settings` and the real OpenAI SDK on a mock -transport, so the refusal comes from the provider's own 400 classification. -""" - -from __future__ import annotations - -import json - -import httpx2 as httpx -import openai -import pytest - -from anton.config.settings import AntonSettings -from anton.core.llm.client import LLMClient -from anton.core.llm.openai import OpenAIProvider -from anton.core.llm.provider import RequestRefusedError, StreamTextDelta -from tests.test_bad_request_reaches_the_classifier import ( - _MESSAGES, - _TOOLS, - _TOOLS_WITH_EFFORT_400, - _streamed_answer, -) - -_BASE_URL = "https://api.openai.com/v1" -_FLAVORS = [OpenAIProvider.FLAVOR_OPENAI_COMPATIBLE_GENERIC, OpenAIProvider.FLAVOR_OPENAI] -_PROMPT_BLOCKED_400 = {"error": { - "type": "invalid_request_error", "code": "content_filter", "param": None, - "message": "The prompt was blocked.", -}} - - -def _sent_effort(request: dict) -> str | None: - """The effort a request body carries, in either wire format.""" - return request.get("reasoning_effort") or (request.get("reasoning") or {}).get("effort") - - -def _client(monkeypatch, flavor, *, configured, override, refused_effort, body=_TOOLS_WITH_EFFORT_400): - """A real client whose transport refuses any request carrying `refused_effort`.""" - requests: list[dict] = [] - - def handler(request: httpx.Request) -> httpx.Response: - sent = json.loads(request.content) - requests.append(sent) - if _sent_effort(sent) == refused_effort: - return httpx.Response(400, json=body) - return httpx.Response( - 200, headers={"content-type": "text/event-stream"}, - content=_streamed_answer(flavor, "answered"), - ) - - real_async_openai = openai.AsyncOpenAI - - def mock_async_openai(**kwargs): - kwargs["http_client"] = httpx.AsyncClient(transport=httpx.MockTransport(handler)) - return real_async_openai(**{**kwargs, "max_retries": 0}) - - monkeypatch.setattr(openai, "AsyncOpenAI", mock_async_openai) - provider = "openai" if flavor == OpenAIProvider.FLAVOR_OPENAI else "openai-compatible" - settings = AntonSettings( - _env_file=None, - planning_provider=provider, coding_provider=provider, - planning_model="gpt-6-luna", coding_model="gpt-6-luna", - openai_api_key="test-key", openai_base_url=_BASE_URL, - planning_reasoning_effort=configured, - ) - if flavor != OpenAIProvider.FLAVOR_OPENAI: - monkeypatch.setattr("anton.core.llm.client._resolve_openai_compatible_flavor", lambda s: flavor) - return LLMClient.from_settings(settings, planning_effort_override=override), requests - - -async def _answer(client: LLMClient) -> str: - text = "" - async for event in client.plan_stream(system="s", messages=_MESSAGES, tools=_TOOLS): - if isinstance(event, StreamTextDelta): - text += event.text - return text - - -@pytest.mark.asyncio -@pytest.mark.parametrize("flavor", _FLAVORS) -@pytest.mark.parametrize("configured", [None, "low"]) -async def test_a_refused_selected_effort_is_resent_once_at_the_configured_effort( - monkeypatch, flavor, configured, -): - client, requests = _client( - monkeypatch, flavor, configured=configured, override="max", refused_effort="max", - ) - try: - assert await _answer(client) == "answered" - assert [_sent_effort(r) for r in requests] == ["max", configured] - assert await _answer(client) == "answered" - assert len(requests) == 3, "later turns go straight to the configured effort" - finally: - await client.aclose() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("flavor", _FLAVORS) -async def test_a_refused_configured_effort_still_fails_as_before(monkeypatch, flavor): - client, requests = _client( - monkeypatch, flavor, configured="max", override=None, refused_effort="max", - ) - try: - with pytest.raises(RequestRefusedError) as err: - await _answer(client) - assert err.value.code == "parameter_refused" - assert len(requests) == 1 - finally: - await client.aclose() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("flavor", _FLAVORS) -async def test_a_blocked_prompt_is_not_resent(monkeypatch, flavor): - client, requests = _client( - monkeypatch, flavor, configured=None, override="max", refused_effort="max", - body=_PROMPT_BLOCKED_400, - ) - try: - with pytest.raises(RequestRefusedError) as err: - await _answer(client) - assert err.value.code == "content_filter" - assert len(requests) == 1 - finally: - await client.aclose() From 074718d7f253dfbcb8c7ed56b6b8d019b5e5cc6d Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 18:56:21 +0200 Subject: [PATCH 7/9] Assert the selected effort on the wire instead of a private builder Co-Authored-By: Claude Fable 5.1 --- tests/test_cloud_turn_session.py | 91 +++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index 4a5487952..52ea4f07f 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -20,6 +20,8 @@ # `_pin_today` later replaces on the module. from datetime import datetime as _stdlib_datetime +import httpx2 as httpx +import openai import pytest import anton.core.llm.client as llm_client_mod @@ -34,6 +36,8 @@ resolve_trusted_workspace_path, ) from anton.core.backends.local import local_scratchpad_runtime_factory +from anton.core.llm.openai import OpenAIProvider +from tests.test_bad_request_reaches_the_classifier import _streamed_answer class _FakeSession: @@ -1261,13 +1265,11 @@ def fake_minds_request(url, api_key, *, method="GET", payload=None, verify=True, assert "DS_GMAIL_PRIMARY__ACCESS_TOKEN" not in os.environ -# ── selected reasoning effort ──────────────────────────────────────────────── - _MINDS_LLM = {"provider": "minds-cloud", "api_key": "mdb_k", "base_url": "https://api.mindshub.ai"} def _build_real_client(tmp_path, monkeypatch, **req_overrides): - """Like _build, but keeps the real LLMClient so its providers can be inspected.""" + """The real LLMClient a cloud turn builds for this request, with ChatSession faked.""" captured: dict = {} real_from_settings = llm_client_mod.LLMClient.from_settings.__func__ @@ -1284,47 +1286,88 @@ def capture(cls, settings): return captured["client"] -def _request_reasoning(provider): - """The reasoning block the provider would put on its next real request, or None.""" - kwargs = provider._build_responses_kwargs( - model="m", system="s", messages=[{"role": "user", "content": "hi"}], - tools=None, tool_choice=None, max_tokens=100, native_web_tools=None, - ) - return kwargs.get("reasoning") +def _record_requests(monkeypatch) -> list[tuple[str, dict]]: + """Point every OpenAI SDK client built from now on at a transport that + records each request as (path, body) and answers it with a short completion.""" + requests: list[tuple[str, dict]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + sent = json.loads(request.content) + requests.append((request.url.path, sent)) + if sent.get("stream"): + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, + content=_streamed_answer(OpenAIProvider.FLAVOR_MINDS_PASSTHROUGH, "answered"), + ) + return httpx.Response(200, json={ + "id": "chatcmpl-1", "object": "chat.completion", "created": 0, "model": "m", + "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", "content": "answered"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) + + real_async_openai = openai.AsyncOpenAI + + def mock_async_openai(**kwargs): + kwargs["http_client"] = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return real_async_openai(**{**kwargs, "max_retries": 0}) + + monkeypatch.setattr(openai, "AsyncOpenAI", mock_async_openai) + return requests + + +async def _efforts_on_the_wire(client, requests) -> dict[str, str | None]: + """One call per role; the ``reasoning_effort`` each chat.completions request carried.""" + messages = [{"role": "user", "content": "hi"}] + async for _ in client.plan_stream(system="s", messages=messages): + pass + async for _ in client.code_stream(system="s", messages=messages): + pass + await client.summarize(system="s", messages=messages) + assert [path for path, _ in requests] == ["/v1/chat/completions"] * 3 + efforts = (sent.get("reasoning_effort") for _, sent in requests) + return dict(zip(("planning", "coding", "router"), efforts)) @pytest.mark.parametrize("coding_model", [None, "claude-opus-4-6"]) @pytest.mark.parametrize("router", [False, True]) -def test_selected_effort_reaches_only_the_planning_provider(tmp_path, monkeypatch, coding_model, router): +async def test_selected_effort_reaches_only_the_planning_request(tmp_path, monkeypatch, coding_model, router): """The router shares the coding provider unless one is configured, so coding effort must stay put or history summarization would change too.""" if router: monkeypatch.setenv("ANTON_ROUTER_PROVIDER", "openai-compatible") monkeypatch.setenv("ANTON_CODING_REASONING_EFFORT", "medium") + requests = _record_requests(monkeypatch) llm = dict(_MINDS_LLM, coding_model=coding_model) if coding_model else _MINDS_LLM client = _build_real_client( tmp_path, monkeypatch, model="claude-opus-4-6", llm=llm, reasoning_effort="low", ) - assert _request_reasoning(client.planning_provider)["effort"] == "low" - assert _request_reasoning(client.coding_provider)["effort"] == "medium" - if router: - assert client.router_provider is not client.coding_provider - assert _request_reasoning(client.router_provider) is None - else: - assert client.router_provider is client.coding_provider + try: + sent = await _efforts_on_the_wire(client, requests) + finally: + await client.aclose() + assert sent == {"planning": "low", "coding": "medium", "router": None if router else "medium"} -def test_no_selected_effort_keeps_the_configured_planning_effort(tmp_path, monkeypatch): +async def test_no_selected_effort_keeps_the_configured_planning_effort(tmp_path, monkeypatch): monkeypatch.setenv("ANTON_PLANNING_REASONING_EFFORT", "high") + requests = _record_requests(monkeypatch) client = _build_real_client(tmp_path, monkeypatch, reasoning_effort=None) - assert _request_reasoning(client.planning_provider)["effort"] == "high" - assert _request_reasoning(client.coding_provider) is None + try: + sent = await _efforts_on_the_wire(client, requests) + finally: + await client.aclose() + assert sent == {"planning": "high", "coding": None, "router": None} -def test_no_selected_or_configured_effort_sends_none(tmp_path, monkeypatch): +async def test_no_selected_or_configured_effort_sends_none(tmp_path, monkeypatch): + requests = _record_requests(monkeypatch) client = _build_real_client(tmp_path, monkeypatch) - assert _request_reasoning(client.planning_provider) is None - assert _request_reasoning(client.coding_provider) is None + try: + sent = await _efforts_on_the_wire(client, requests) + finally: + await client.aclose() + assert sent == {"planning": None, "coding": None, "router": None} # ── ENG-1638: the pod injects a configured-LLM block ───────────────────────── From c527871dd6db21d973b4414b711099971d3090d0 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 19:06:11 +0200 Subject: [PATCH 8/9] Drop the leftover docstring on the client factory Co-Authored-By: Claude Fable 5.1 --- anton/core/llm/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/anton/core/llm/client.py b/anton/core/llm/client.py index 0f4af4d79..371c78e61 100644 --- a/anton/core/llm/client.py +++ b/anton/core/llm/client.py @@ -749,7 +749,6 @@ async def generate_object_code( @classmethod def from_settings(cls, settings: AntonSettings) -> LLMClient: - """Build the client the settings describe, one provider per role.""" from .openai import OpenAIProvider def build_anthropic(effort: str | None) -> LLMProvider: From f0d750e0ad3d79cb70a8fd4428cbae6c9ee1d1b3 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 13 Oct 2026 19:06:11 +0200 Subject: [PATCH 9/9] Build the streamed answer in the session tests and document the effort helpers Co-Authored-By: Claude Fable 5.1 --- tests/test_cloud_turn_contract.py | 1 + tests/test_cloud_turn_session.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/test_cloud_turn_contract.py b/tests/test_cloud_turn_contract.py index 200603d17..c6b1f38f6 100644 --- a/tests/test_cloud_turn_contract.py +++ b/tests/test_cloud_turn_contract.py @@ -92,6 +92,7 @@ def test_a_missing_or_non_object_connectors_block_parses_to_none(value): def _with_effort(**extra): + """The parsed request for a minimal body carrying ``extra``.""" body = {"protocol_version": 1, "conversation_id": "c", "input": "hi"} body.update(extra) return TurnRequestV1.from_json(json.dumps(body)) diff --git a/tests/test_cloud_turn_session.py b/tests/test_cloud_turn_session.py index 52ea4f07f..71dace2ed 100644 --- a/tests/test_cloud_turn_session.py +++ b/tests/test_cloud_turn_session.py @@ -36,8 +36,6 @@ resolve_trusted_workspace_path, ) from anton.core.backends.local import local_scratchpad_runtime_factory -from anton.core.llm.openai import OpenAIProvider -from tests.test_bad_request_reaches_the_classifier import _streamed_answer class _FakeSession: @@ -1286,6 +1284,17 @@ def capture(cls, settings): return captured["client"] +def _chat_stream(text: str) -> bytes: + """``text`` as a streamed chat.completions answer, the format the minds passthrough reads.""" + chunk = {"id": "chatcmpl-1", "object": "chat.completion.chunk", "created": 0, "model": "m"} + chunks = [ + {**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": text}, + "finish_reason": None}]}, + {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}, + ] + return ("".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n").encode() + + def _record_requests(monkeypatch) -> list[tuple[str, dict]]: """Point every OpenAI SDK client built from now on at a transport that records each request as (path, body) and answers it with a short completion.""" @@ -1297,7 +1306,7 @@ def handler(request: httpx.Request) -> httpx.Response: if sent.get("stream"): return httpx.Response( 200, headers={"content-type": "text/event-stream"}, - content=_streamed_answer(OpenAIProvider.FLAVOR_MINDS_PASSTHROUGH, "answered"), + content=_chat_stream("answered"), ) return httpx.Response(200, json={ "id": "chatcmpl-1", "object": "chat.completion", "created": 0, "model": "m", @@ -1350,6 +1359,7 @@ async def test_selected_effort_reaches_only_the_planning_request(tmp_path, monke async def test_no_selected_effort_keeps_the_configured_planning_effort(tmp_path, monkeypatch): + """With nothing selected, planning carries the configured effort and the other roles none.""" monkeypatch.setenv("ANTON_PLANNING_REASONING_EFFORT", "high") requests = _record_requests(monkeypatch) client = _build_real_client(tmp_path, monkeypatch, reasoning_effort=None) @@ -1361,6 +1371,7 @@ async def test_no_selected_effort_keeps_the_configured_planning_effort(tmp_path, async def test_no_selected_or_configured_effort_sends_none(tmp_path, monkeypatch): + """With nothing selected or configured, no request carries an effort.""" requests = _record_requests(monkeypatch) client = _build_real_client(tmp_path, monkeypatch) try: @@ -1369,6 +1380,7 @@ async def test_no_selected_or_configured_effort_sends_none(tmp_path, monkeypatch await client.aclose() assert sent == {"planning": None, "coding": None, "router": None} + # ── ENG-1638: the pod injects a configured-LLM block ───────────────────────── def test_pod_injects_a_configured_llm_block_for_the_runtime_identity(tmp_path, monkeypatch):