Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.researchclaw.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions researchclaw/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
28 changes: 28 additions & 0 deletions researchclaw/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down