From 02290b3798965902f8b0ff87b9b1cdf4a7a1960f Mon Sep 17 00:00:00 2001 From: cgycorey <4724788+cgycorey@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:05:58 +0100 Subject: [PATCH] feat(llm): forward configured reasoning_effort to native LLM calls Add an optional reasoning_effort field to LLMConfig (e.g. low/medium/high/max) and forward it to litellm.acompletion in both chat() and chat_stream() when set. Unset by default so non-OpenAI providers are unaffected. Callers can still override per-call via kwargs. --- opc/core/config.py | 1 + opc/llm/provider.py | 4 + tests/test_llm_provider_reasoning_effort.py | 103 ++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 tests/test_llm_provider_reasoning_effort.py diff --git a/opc/core/config.py b/opc/core/config.py index 001c4b4..f0f7269 100644 --- a/opc/core/config.py +++ b/opc/core/config.py @@ -273,6 +273,7 @@ class LLMConfig(BaseModel): fallback: dict[str, Any] = Field(default_factory=dict) temperature: float = 0.3 max_tokens: int = 32768 + reasoning_effort: str | None = None # Total input context window (tokens) for the active model. Set this when # the model is not mapped in litellm (e.g. proxy/self-hosted models like # doubao/minimax/glm), so the context-usage ring and compaction thresholds diff --git a/opc/llm/provider.py b/opc/llm/provider.py index 0bc5ee1..121d168 100644 --- a/opc/llm/provider.py +++ b/opc/llm/provider.py @@ -569,6 +569,8 @@ class LLMProvider: "max_tokens": max_tok, **kwargs, } + if self.config.reasoning_effort and "reasoning_effort" not in call_kwargs: + call_kwargs["reasoning_effort"] = self.config.reasoning_effort if self._api_base: call_kwargs["api_base"] = self._api_base if self._api_key: @@ -715,6 +717,8 @@ class LLMProvider: "stream": True, **kwargs, } + if self.config.reasoning_effort and "reasoning_effort" not in call_kwargs: + call_kwargs["reasoning_effort"] = self.config.reasoning_effort if self._api_base: call_kwargs["api_base"] = self._api_base if self._api_key: diff --git a/tests/test_llm_provider_reasoning_effort.py b/tests/test_llm_provider_reasoning_effort.py new file mode 100644 index 0000000..66c96c3 --- /dev/null +++ b/tests/test_llm_provider_reasoning_effort.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from opc.core.config import LLMConfig +from opc.llm.provider import LLMProvider + + +def _completion_response() -> SimpleNamespace: + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="ok", tool_calls=[]), + finish_reason="stop", + ) + ], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), + ) + + +async def _completion_stream(): + yield SimpleNamespace( + usage=None, + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content="ok", + reasoning=None, + reasoning_content=None, + thinking=None, + tool_calls=[], + ), + finish_reason="stop", + ) + ], + ) + + +class TestLLMProviderReasoningEffort(unittest.IsolatedAsyncioTestCase): + def test_config_retains_reasoning_effort(self) -> None: + config = LLMConfig.model_validate({ + "default_model": "openai/gpt-5.6-luna", + "reasoning_effort": "max", + }) + + assert config.reasoning_effort == "max" + + async def test_chat_forwards_configured_reasoning_effort(self) -> None: + provider = LLMProvider(LLMConfig( + default_model="openai/gpt-5.6-luna", + reasoning_effort="max", + )) + + with ( + patch("opc.llm.provider._clamp_max_tokens", return_value=128), + patch( + "opc.llm.provider.litellm.acompletion", + new=AsyncMock(return_value=_completion_response()), + ) as completion, + ): + await provider.chat([{"role": "user", "content": "hello"}]) + + assert completion.await_args.kwargs["reasoning_effort"] == "max" + + async def test_chat_stream_forwards_configured_reasoning_effort(self) -> None: + provider = LLMProvider(LLMConfig( + default_model="openai/gpt-5.6-luna", + reasoning_effort="max", + )) + + with ( + patch("opc.llm.provider._clamp_max_tokens", return_value=128), + patch( + "opc.llm.provider.litellm.acompletion", + new=AsyncMock(return_value=_completion_stream()), + ) as completion, + ): + events = [ + event + async for event in provider.chat_stream([ + {"role": "user", "content": "hello"}, + ]) + ] + + assert events + assert completion.await_args.kwargs["reasoning_effort"] == "max" + assert completion.await_args.kwargs["stream"] is True + + async def test_unset_reasoning_effort_is_not_added_to_requests(self) -> None: + provider = LLMProvider(LLMConfig(default_model="openai/gpt-5.6-luna")) + + with ( + patch("opc.llm.provider._clamp_max_tokens", return_value=128), + patch( + "opc.llm.provider.litellm.acompletion", + new=AsyncMock(return_value=_completion_response()), + ) as completion, + ): + await provider.chat([{"role": "user", "content": "hello"}]) + + assert "reasoning_effort" not in completion.await_args.kwargs