diff --git a/config.researchclaw.example.yaml b/config.researchclaw.example.yaml index 16bf8fd9f..91096710f 100644 --- a/config.researchclaw.example.yaml +++ b/config.researchclaw.example.yaml @@ -40,6 +40,11 @@ llm: wire_api: "chat_completions" # Set to "responses" for Responses-only gateways api_key_env: "OPENAI_API_KEY" api_key: "" + # Reasoning effort for reasoning models (o-series, gpt-5.x) reached through + # OpenAI or OpenRouter: xhigh | high | medium | low | minimal | none. + # Empty = provider default. Models that are not reasoning models never see + # the parameter, so this is safe to leave set when switching models. + reasoning_effort: "" primary_model: "gpt-4o" fallback_models: - "gpt-4.1" diff --git a/researchclaw/config.py b/researchclaw/config.py index 04bd73097..2638df9dc 100644 --- a/researchclaw/config.py +++ b/researchclaw/config.py @@ -215,6 +215,10 @@ class LlmConfig: reviewer_base_url: str = "" reviewer_api_key: str = "" reviewer_api_key_env: str = "" + # Reasoning effort for reasoning models (o-series, gpt-5.x) reached through + # OpenAI or OpenRouter: xhigh | high | medium | low | minimal | none. + # Empty = provider default. Non-reasoning models never see the parameter. + reasoning_effort: str = "" # Multi-model debate engine. Opt-in; the debate panel reuses existing models # (primary_model + reviewer_model + fallback_models, deduped), each role # bound to a different model. The judge reuses reviewer_model. @@ -1195,6 +1199,7 @@ def _parse_llm_config(data: dict[str, Any]) -> LlmConfig: reviewer_provider=data.get("reviewer_provider", ""), reviewer_base_url=data.get("reviewer_base_url", ""), reviewer_api_key=data.get("reviewer_api_key", ""), + reasoning_effort=str(data.get("reasoning_effort", "") or ""), reviewer_api_key_env=data.get("reviewer_api_key_env", ""), debate_enabled=bool(data.get("debate_enabled", False)), debate_rounds=_safe_int(data.get("debate_rounds"), 1), diff --git a/researchclaw/llm/client.py b/researchclaw/llm/client.py index 17337a0fa..14c392cd5 100644 --- a/researchclaw/llm/client.py +++ b/researchclaw/llm/client.py @@ -44,6 +44,23 @@ } ) + +def _strip_provider(model: str) -> str: + """Strip an OpenRouter-style provider prefix: 'openai/gpt-5.4' -> 'gpt-5.4'.""" + return model.split("/", 1)[1] if model and "/" in model else (model or "") + + +def _supports_reasoning_effort(model: str) -> bool: + """True for models that accept a ``reasoning.effort`` parameter. + + Reuses ``_NEW_PARAM_MODELS`` rather than keeping a second list: the models + that need ``max_completion_tokens`` are the same reasoning family. Tolerates + a provider prefix so the check also works through OpenRouter. + """ + m = _strip_provider(model).lower() + return any(m.startswith(prefix) for prefix in _NEW_PARAM_MODELS) + + _DEFAULT_USER_AGENT = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" @@ -83,6 +100,8 @@ class LLMConfig: retry_base_delay: float = 2.0 timeout_sec: int = 300 user_agent: str = _DEFAULT_USER_AGENT + # Reasoning effort for reasoning models; empty = provider default. + reasoning_effort: str = "" # MetaClaw bridge: extra headers for proxy requests extra_headers: dict[str, str] = field(default_factory=dict) # MetaClaw bridge: fallback URL if primary (proxy) is unreachable @@ -163,6 +182,7 @@ def from_rc_config(cls, rc_config: Any) -> LLMClient: fallback_url=fallback_url, fallback_api_key=fallback_api_key, timeout_sec=getattr(rc_config.llm, "timeout_sec", 600), + reasoning_effort=getattr(rc_config.llm, "reasoning_effort", "") or "", ) client = cls(config) @@ -225,6 +245,7 @@ def reviewer_from_rc_config(cls, rc_config: Any) -> "LLMClient | None": primary_model=reviewer_model, fallback_models=[], timeout_sec=getattr(llm, "timeout_sec", 600), + reasoning_effort=getattr(llm, "reasoning_effort", "") or "", ) client = cls(config) @@ -499,6 +520,13 @@ def _raw_call( else: body["max_tokens"] = max_tokens + # Pass reasoning effort through to reasoning models when it is + # configured. Gated on the model so non-reasoning models, which + # reject an unknown `reasoning` field with HTTP 400, never see it. + _effort = (self.config.reasoning_effort or "").strip() + if _effort and _supports_reasoning_effort(model): + body["reasoning"] = {"effort": _effort} + if json_mode: # Many OpenAI-compatible providers don't support the # response_format parameter and return HTTP 400.