diff --git a/grafi/tools/llms/impl/claude_tool.py b/grafi/tools/llms/impl/claude_tool.py index 54269f7..9f7b58f 100644 --- a/grafi/tools/llms/impl/claude_tool.py +++ b/grafi/tools/llms/impl/claude_tool.py @@ -48,8 +48,32 @@ class ClaudeTool(LLM): api_key: Optional[str] = Field( default_factory=lambda: os.getenv("ANTHROPIC_API_KEY") ) - max_tokens: int = Field(default=4096) - model: str = Field(default="claude-haiku-4-5-20251001") # or haiku, opus… + # Anthropic recommends not lowballing max_tokens; this is an upper bound, not + # a cost floor (you pay for what is generated). Opus 4.x supports up to 128K + # when streaming — raise this if you stream long outputs. + max_tokens: int = Field(default=16384) + # Default to the lowest-cost tier (Haiku). Upgrade for harder tasks, e.g. + # "claude-sonnet-4-6" or "claude-opus-4-8". + # NOTE: if you switch to Opus 4.7/4.8 they reject temperature/top_p/top_k/ + # budget_tokens — steer with `effort`/`thinking` instead of sampling params. + model: str = Field(default="claude-haiku-4-5") + thinking: Optional[Dict[str, Any]] = Field( + default=None, + description=( + "Extended/adaptive thinking config passed to the Messages API, e.g. " + "{'type': 'adaptive'} or {'type': 'adaptive', 'display': 'summarized'}. " + "Adaptive thinking is the supported mode on Claude 4.6+; " + "budget_tokens is rejected on Opus 4.7/4.8. Left off when None." + ), + ) + effort: Optional[str] = Field( + default=None, + description=( + "Output/reasoning effort sent inside output_config: " + "'low' | 'medium' | 'high' | 'xhigh' | 'max'. Supported on Opus 4.5+ " + "and Sonnet 4.6. Left off when None (API default is 'high')." + ), + ) @classmethod def builder(cls) -> "ClaudeToolBuilder": @@ -146,6 +170,42 @@ def _content_to_text(content: Any) -> str: return "" return json.dumps(content, default=str) + def _request_kwargs( + self, + system: Union[str, Omit], + messages: List[MessageParam], + tools: Union[List[ToolParam], Omit], + ) -> Dict[str, Any]: + """Assemble keyword arguments shared by the streaming and non-streaming + calls. + + ``thinking`` and ``effort`` are only included when set, so a tool that + doesn't use them keeps the API's defaults. ``effort`` is folded into + ``output_config`` (merged with any user-supplied ``output_config`` in + ``chat_params``). ``chat_params`` is applied last so an explicit caller + value always wins over the first-class fields. + """ + kwargs: Dict[str, Any] = { + "max_tokens": self.max_tokens, + "model": self.model, + "system": system, + "messages": messages, + "tools": tools, + } + + chat_params = dict(self.chat_params) + + if self.thinking is not None: + kwargs["thinking"] = self.thinking + + if self.effort is not None: + output_config = dict(chat_params.pop("output_config", {}) or {}) + output_config.setdefault("effort", self.effort) + kwargs["output_config"] = output_config + + kwargs.update(chat_params) + return kwargs + # ------------------------------------------------------------------ # # Async call # # ------------------------------------------------------------------ # @@ -156,29 +216,18 @@ async def invoke( input_data: Messages, ) -> MsgsAGen: system, messages, tools = self.prepare_api_input(input_data) + request_kwargs = self._request_kwargs(system, messages, tools) try: async with AsyncAnthropic(api_key=self.api_key) as client: if self.is_streaming: - async with client.messages.stream( - max_tokens=self.max_tokens, - model=self.model, - system=system, - messages=messages, - tools=tools, - **self.chat_params, - ) as stream: + async with client.messages.stream(**request_kwargs) as stream: async for event in stream: if event.type == "text": yield self.to_stream_messages(event.text) else: resp: AnthropicMessage = await client.messages.create( - max_tokens=self.max_tokens, - model=self.model, - system=system, - messages=messages, - tools=tools, - **self.chat_params, + **request_kwargs ) yield self.to_messages(resp) @@ -226,6 +275,16 @@ def to_messages(self, resp: AnthropicMessage) -> Messages: if len(tool_calls) > 0: message_args["content"] = "" + # A refused response (HTTP 200, stop_reason="refusal") carries empty or + # partial content; surface the reason on the message so callers can react + # instead of treating the blank text as a normal answer. + if getattr(resp, "stop_reason", None) == "refusal": + details = getattr(resp, "stop_details", None) + explanation = getattr(details, "explanation", None) if details else None + message_args["refusal"] = ( + explanation or "Request refused by the Anthropic safety system." + ) + return [Message.model_validate(message_args)] # ------------------------------------------------------------------ # @@ -235,6 +294,8 @@ def to_dict(self) -> Dict[str, Any]: return { **super().to_dict(), "max_tokens": self.max_tokens, + "thinking": self.thinking, + "effort": self.effort, } @classmethod @@ -261,8 +322,10 @@ async def from_dict(cls, data: Dict[str, Any]) -> "ClaudeTool": .is_streaming(data.get("is_streaming", False)) .system_message(data.get("system_message", "")) .api_key(os.getenv("ANTHROPIC_API_KEY")) - .model(data.get("model", "claude-haiku-4-5-20251001")) - .max_tokens(data.get("max_tokens", 4096)) + .model(data.get("model", "claude-haiku-4-5")) + .max_tokens(data.get("max_tokens", 16384)) + .thinking(data.get("thinking")) + .effort(data.get("effort")) .build() ) @@ -280,3 +343,13 @@ def api_key(self, api_key: Optional[str]) -> Self: def max_tokens(self, max_tokens: int) -> Self: self.kwargs["max_tokens"] = max_tokens return self + + def thinking(self, thinking: Optional[Dict[str, Any]]) -> Self: + """Set the extended/adaptive thinking config (e.g. {'type': 'adaptive'}).""" + self.kwargs["thinking"] = thinking + return self + + def effort(self, effort: Optional[str]) -> Self: + """Set output effort ('low' | 'medium' | 'high' | 'xhigh' | 'max').""" + self.kwargs["effort"] = effort + return self diff --git a/grafi/tools/llms/impl/gemini_tool.py b/grafi/tools/llms/impl/gemini_tool.py index 881d0af..65cd70e 100644 --- a/grafi/tools/llms/impl/gemini_tool.py +++ b/grafi/tools/llms/impl/gemini_tool.py @@ -57,7 +57,17 @@ class GeminiTool(LLM): name: str = Field(default="GeminiTool") type: str = Field(default="GeminiTool") api_key: Optional[str] = Field(default_factory=lambda: os.getenv("GEMINI_API_KEY")) + # Lowest-cost current Gemini tier; upgrade to "gemini-2.5-flash"/"gemini-2.5-pro" + # for harder tasks. model: str = Field(default="gemini-2.5-flash-lite") + thinking_budget: Optional[int] = Field( + default=None, + description=( + "Gemini 2.5 thinking budget in tokens. 0 disables thinking; -1 lets " + "the model decide dynamically. Mapped to types.ThinkingConfig and left " + "off when None (model default applies)." + ), + ) @classmethod def builder(cls) -> "GeminiToolBuilder": @@ -80,7 +90,7 @@ def _client(self) -> genai.Client: def prepare_api_input( self, input_data: Messages - ) -> tuple[ContentListUnion, Optional[Tool]]: + ) -> tuple[ContentListUnion, Optional[List[Tool]], Optional[str]]: """ Map grafi ``Message`` objects -> Gemini *contents* list. @@ -91,21 +101,24 @@ def prepare_api_input( {"role": "model", "parts": [{"text": "Hello!"}]}, ] - Function/tool declarations are passed via GenerateContentConfig, - so we simply return them for the caller to insert. + The system prompt (the instance ``system_message`` plus any ``system`` + role messages) is returned separately so the caller can pass it via the + config's ``system_instruction`` field — the SDK-native way to set a + system prompt — rather than faking it as a leading ``user`` turn. + Function/tool declarations are likewise returned for the caller to insert. """ contents: List[Content] = [] - # prepend system instruction in Gemini style if present + # Collect the system prompt; folded into config.system_instruction below. + system_parts: List[str] = [] if self.system_message: - contents.append( - Content( - role="user", - parts=[Part(text=self.system_message)], - ) - ) + system_parts.append(self.system_message) for m in input_data: + if m.role == "system": + if isinstance(m.content, str) and m.content: + system_parts.append(m.content) + continue # Gemini only needs role + parts; we ignore tool_call fields here if m.content is not None and isinstance(m.content, str) and m.content != "": contents.append( @@ -124,11 +137,14 @@ def prepare_api_input( ) function_declarations.append(function_declaration) - return contents, ( + tools = ( [Tool(function_declarations=function_declarations)] if function_declarations else None ) + system_instruction = "\n\n".join(system_parts) if system_parts else None + + return contents, tools, system_instruction # --------------------------------------------------------------------- # # Asynchronous (async/await) one‑shot call @@ -139,15 +155,25 @@ async def invoke( invoke_context: InvokeContext, input_data: Messages, ) -> MsgsAGen: # → async generator just like OpenAITool - contents, tools = self.prepare_api_input(input_data) + contents, tools, system_instruction = self.prepare_api_input(input_data) client = genai.Client(api_key=self.api_key) # same lightweight client - cfg = ( - types.GenerateContentConfig(tools=tools, **self.chat_params) # type: ignore[arg-type] - if tools - else None - ) + # Always build the config from chat_params (previously it was dropped + # entirely when no tools were present), then layer on tools, the system + # instruction, and the thinking budget when each is set. + config_kwargs: Dict[str, Any] = dict(self.chat_params) + if tools: + config_kwargs["tools"] = tools + if system_instruction: + config_kwargs.setdefault("system_instruction", system_instruction) + if self.thinking_budget is not None: + config_kwargs.setdefault( + "thinking_config", + types.ThinkingConfig(thinking_budget=self.thinking_budget), + ) + + cfg = types.GenerateContentConfig(**config_kwargs) if config_kwargs else None try: if self.is_streaming: @@ -238,6 +264,7 @@ def to_dict(self) -> Dict[str, Any]: "type": self.type, "api_key": "****************", "model": self.model, + "thinking_budget": self.thinking_budget, } @classmethod @@ -263,7 +290,8 @@ async def from_dict(cls, data: Dict[str, Any]) -> "GeminiTool": .is_streaming(data.get("is_streaming", False)) .system_message(data.get("system_message", "")) .api_key(os.getenv("GEMINI_API_KEY")) - .model(data.get("model", "gemini-2.0-flash-lite")) + .model(data.get("model", "gemini-2.5-flash-lite")) + .thinking_budget(data.get("thinking_budget")) .build() ) @@ -276,3 +304,8 @@ class GeminiToolBuilder(LLMBuilder[GeminiTool]): def api_key(self, api_key: Optional[str]) -> Self: self.kwargs["api_key"] = api_key return self + + def thinking_budget(self, thinking_budget: Optional[int]) -> Self: + """Set the Gemini 2.5 thinking budget in tokens (0 off, -1 dynamic).""" + self.kwargs["thinking_budget"] = thinking_budget + return self diff --git a/grafi/tools/llms/impl/openai_tool.py b/grafi/tools/llms/impl/openai_tool.py index d15f1b4..37f5e15 100644 --- a/grafi/tools/llms/impl/openai_tool.py +++ b/grafi/tools/llms/impl/openai_tool.py @@ -3,6 +3,7 @@ from typing import ClassVar from typing import Dict from typing import Optional +from typing import Self from pydantic import Field @@ -16,13 +17,33 @@ class OpenAITool(OpenAICompatibleTool): Attributes: api_key (str): The API key for authenticating with OpenAI. - model (str): The name of the OpenAI model to use (default is 'gpt-4o-mini'). + model (str): The name of the OpenAI model to use (default is 'gpt-4.1-nano'). + reasoning_effort (str): Reasoning depth for reasoning models (o-series, gpt-5). + verbosity (str): Output verbosity control for gpt-5-class models. """ name: str = Field(default="OpenAITool") type: str = Field(default="OpenAITool") api_key: Optional[str] = Field(default_factory=lambda: os.getenv("OPENAI_API_KEY")) - model: str = Field(default="gpt-4o-mini") + # Default to the lowest-cost current chat model. Upgrade to "gpt-4.1-mini"/ + # "gpt-4.1" for harder tasks. For reasoning models (e.g. "gpt-5", "gpt-5-mini", + # "o4-mini") set `reasoning_effort` and prefer `max_completion_tokens` over + # `temperature`/`max_tokens` via chat_params. + model: str = Field(default="gpt-4.1-nano") + reasoning_effort: Optional[str] = Field( + default=None, + description=( + "Reasoning effort for reasoning models: 'minimal' | 'low' | 'medium' " + "| 'high'. Only meaningful for o-series / gpt-5 models. Left off when None." + ), + ) + verbosity: Optional[str] = Field( + default=None, + description=( + "Output verbosity for gpt-5-class models: 'low' | 'medium' | 'high'. " + "Left off when None." + ), + ) _provider_label: ClassVar[str] = "OpenAI" @@ -31,6 +52,22 @@ def builder(cls) -> "OpenAIToolBuilder": """Return a builder for OpenAITool.""" return OpenAIToolBuilder(cls) + def _extra_create_kwargs(self) -> Dict[str, Any]: + """Attach OpenAI-only request params when set (chat_params still wins).""" + extra: Dict[str, Any] = {} + if self.reasoning_effort is not None: + extra["reasoning_effort"] = self.reasoning_effort + if self.verbosity is not None: + extra["verbosity"] = self.verbosity + return extra + + def to_dict(self) -> Dict[str, Any]: + return { + **super().to_dict(), + "reasoning_effort": self.reasoning_effort, + "verbosity": self.verbosity, + } + @classmethod async def from_dict(cls, data: Dict[str, Any]) -> "OpenAITool": """Create an OpenAITool instance from a dictionary representation.""" @@ -45,10 +82,22 @@ async def from_dict(cls, data: Dict[str, Any]) -> "OpenAITool": .is_streaming(data.get("is_streaming", False)) .system_message(data.get("system_message", "")) .api_key(os.getenv("OPENAI_API_KEY")) - .model(data.get("model", "gpt-4o-mini")) + .model(data.get("model", "gpt-4.1-nano")) + .reasoning_effort(data.get("reasoning_effort")) + .verbosity(data.get("verbosity")) .build() ) class OpenAIToolBuilder(OpenAICompatibleToolBuilder[OpenAITool]): """Builder for OpenAITool instances.""" + + def reasoning_effort(self, reasoning_effort: Optional[str]) -> Self: + """Set reasoning effort ('minimal' | 'low' | 'medium' | 'high').""" + self.kwargs["reasoning_effort"] = reasoning_effort + return self + + def verbosity(self, verbosity: Optional[str]) -> Self: + """Set output verbosity ('low' | 'medium' | 'high').""" + self.kwargs["verbosity"] = verbosity + return self diff --git a/tests/tools/llms/test_claude_tool.py b/tests/tools/llms/test_claude_tool.py index 557531f..72061f5 100644 --- a/tests/tools/llms/test_claude_tool.py +++ b/tests/tools/llms/test_claude_tool.py @@ -33,7 +33,7 @@ def claude_instance() -> ClaudeTool: system_message="dummy system message", name="ClaudeTool", api_key="test_api_key", - model="claude-haiku-4-5-20251001", + model="claude-haiku-4-5", max_tokens=2048, ) @@ -43,7 +43,7 @@ def claude_instance() -> ClaudeTool: # --------------------------------------------------------------------------- # def test_init(claude_instance): assert claude_instance.api_key == "test_api_key" - assert claude_instance.model == "claude-haiku-4-5-20251001" + assert claude_instance.model == "claude-haiku-4-5" assert claude_instance.system_message == "dummy system message" assert claude_instance.max_tokens == 2048 @@ -93,7 +93,7 @@ async def mock_aexit(self, *args): # verify create() called with right kwargs kwargs = mock_client.messages.create.call_args[1] - assert kwargs["model"] == "claude-haiku-4-5-20251001" + assert kwargs["model"] == "claude-haiku-4-5" assert kwargs["max_tokens"] == 2048 # system prompt must be the top-level `system=` param, NOT a message role assert kwargs["system"] == "dummy system message" @@ -254,7 +254,7 @@ def test_to_dict(claude_instance): assert d["name"] == "ClaudeTool" assert d["type"] == "ClaudeTool" assert d["api_key"] == "****************" - assert d["model"] == "claude-haiku-4-5-20251001" + assert d["model"] == "claude-haiku-4-5" # --------------------------------------------------------------------------- # @@ -270,7 +270,7 @@ async def test_from_dict(): "type": "ClaudeTool", "oi_span_type": "LLM", "system_message": "You are helpful", - "model": "claude-haiku-4-5-20251001", + "model": "claude-haiku-4-5", "max_tokens": 2048, "chat_params": {"temperature": 0.7}, "is_streaming": False, @@ -281,7 +281,7 @@ async def test_from_dict(): assert isinstance(tool, ClaudeTool) assert tool.name == "TestClaude" - assert tool.model == "claude-haiku-4-5-20251001" + assert tool.model == "claude-haiku-4-5" assert tool.max_tokens == 2048 assert tool.system_message == "You are helpful" assert tool.chat_params == {"temperature": 0.7} @@ -305,3 +305,46 @@ async def test_from_dict_roundtrip(claude_instance): assert restored.system_message == claude_instance.system_message assert restored.chat_params == claude_instance.chat_params assert restored.is_streaming == claude_instance.is_streaming + + +# --------------------------------------------------------------------------- # +# thinking / effort request assembly # +# --------------------------------------------------------------------------- # +def test_request_kwargs_thinking_and_effort(): + tool = ClaudeTool( + api_key="k", + model="claude-haiku-4-5", + max_tokens=1000, + thinking={"type": "adaptive"}, + effort="high", + chat_params={"output_config": {"foo": "bar"}, "service_tier": "auto"}, + ) + kwargs = tool._request_kwargs(omit, [], omit) + + assert kwargs["thinking"] == {"type": "adaptive"} + # effort is folded into output_config, merged with the caller-supplied one. + assert kwargs["output_config"]["effort"] == "high" + assert kwargs["output_config"]["foo"] == "bar" + # other chat_params still pass through. + assert kwargs["service_tier"] == "auto" + + +def test_request_kwargs_omits_unset_fields(): + tool = ClaudeTool(api_key="k", model="claude-haiku-4-5", max_tokens=1000) + kwargs = tool._request_kwargs(omit, [], omit) + assert "thinking" not in kwargs + assert "output_config" not in kwargs + + +# --------------------------------------------------------------------------- # +# refusal stop reason # +# --------------------------------------------------------------------------- # +def test_to_messages_refusal(claude_instance): + resp = Mock( + content=[], + stop_reason="refusal", + stop_details=Mock(explanation="declined for policy reasons"), + ) + messages = claude_instance.to_messages(resp) + assert messages[0].refusal == "declined for policy reasons" + assert messages[0].content == "" diff --git a/tests/tools/llms/test_gemini_tool.py b/tests/tools/llms/test_gemini_tool.py index 89b68f5..b6313a7 100644 --- a/tests/tools/llms/test_gemini_tool.py +++ b/tests/tools/llms/test_gemini_tool.py @@ -31,7 +31,7 @@ def gemini_instance() -> GeminiTool: system_message="dummy system message", name="GeminiTool", api_key="test_api_key", - model="gemini-2.0-flash-lite", + model="gemini-2.5-flash-lite", ) @@ -40,7 +40,7 @@ def gemini_instance() -> GeminiTool: # --------------------------------------------------------------------------- # def test_init(gemini_instance): assert gemini_instance.api_key == "test_api_key" - assert gemini_instance.model == "gemini-2.0-flash-lite" + assert gemini_instance.model == "gemini-2.5-flash-lite" assert gemini_instance.system_message == "dummy system message" @@ -77,11 +77,13 @@ async def test_invoke_simple_response(monkeypatch, gemini_instance, invoke_conte # Ensure generate_content called with correct args mock_client.aio.models.generate_content.assert_called_once() call_kwargs = mock_client.aio.models.generate_content.call_args[1] - assert call_kwargs["model"] == "gemini-2.0-flash-lite" + assert call_kwargs["model"] == "gemini-2.5-flash-lite" - # Contents must include system message + # System prompt is delivered via config.system_instruction (SDK-native), + # not faked as a leading user turn; contents holds only the real turns. + assert call_kwargs["config"].system_instruction == "dummy system message" assert call_kwargs["contents"][0].role == "user" - assert call_kwargs["contents"][0].parts[0].text == "dummy system message" + assert call_kwargs["contents"][0].parts[0].text == "Say hello" # --------------------------------------------------------------------------- # @@ -169,9 +171,13 @@ def test_prepare_api_input(gemini_instance): Message(role="assistant", content="Hi there."), ] - contents, tools = gemini_instance.prepare_api_input(msgs) + contents, tools, system_instruction = gemini_instance.prepare_api_input(msgs) - assert contents[0].role == "user" # dummy system msg added later + # System text (instance message + system-role message) is hoisted out of + # contents into system_instruction; contents holds only user/model turns. + assert system_instruction == "dummy system message\n\nYou are helpful." + assert contents[0].role == "user" + assert contents[0].parts[0].text == "Hello!" assert contents[-1].role == "model" assert tools == [] or tools is None @@ -184,7 +190,7 @@ def test_to_dict(gemini_instance): assert d["name"] == "GeminiTool" assert d["type"] == "GeminiTool" assert d["api_key"] == "****************" - assert d["model"] == "gemini-2.0-flash-lite" + assert d["model"] == "gemini-2.5-flash-lite" # --------------------------------------------------------------------------- # @@ -200,7 +206,7 @@ async def test_from_dict(): "type": "GeminiTool", "oi_span_type": "LLM", "system_message": "You are helpful", - "model": "gemini-2.0-flash-lite", + "model": "gemini-2.5-flash-lite", "chat_params": {"temperature": 0.7}, "is_streaming": False, "structured_output": False, @@ -210,7 +216,7 @@ async def test_from_dict(): assert isinstance(tool, GeminiTool) assert tool.name == "TestGemini" - assert tool.model == "gemini-2.0-flash-lite" + assert tool.model == "gemini-2.5-flash-lite" assert tool.system_message == "You are helpful" assert tool.chat_params == {"temperature": 0.7} @@ -228,3 +234,32 @@ async def test_from_dict_roundtrip(gemini_instance): assert restored.name == gemini_instance.name assert restored.model == gemini_instance.model assert restored.system_message == gemini_instance.system_message + + +# --------------------------------------------------------------------------- # +# thinking_budget mapped into GenerateContentConfig # +# --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_invoke_thinking_budget(monkeypatch, invoke_context): + import grafi.tools.llms.impl.gemini_tool as gm_module + + tool = GeminiTool( + api_key="test_api_key", model="gemini-2.5-flash-lite", thinking_budget=128 + ) + + mock_response = Mock() + mock_response.text = "hi" + mock_response.function_calls = None + + mock_client = MagicMock() + mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) + monkeypatch.setattr( + gm_module, "genai", MagicMock(Client=MagicMock(return_value=mock_client)) + ) + + async for _ in tool.invoke(invoke_context, [Message(role="user", content="hi")]): + pass + + cfg = mock_client.aio.models.generate_content.call_args[1]["config"] + assert cfg.thinking_config is not None + assert cfg.thinking_config.thinking_budget == 128 diff --git a/tests/tools/llms/test_openai_tool.py b/tests/tools/llms/test_openai_tool.py index c175452..a2c3ba6 100644 --- a/tests/tools/llms/test_openai_tool.py +++ b/tests/tools/llms/test_openai_tool.py @@ -36,13 +36,13 @@ def openai_instance(): system_message="dummy system message", name="OpenAITool", api_key="test_api_key", - model="gpt-4o-mini", + model="gpt-4.1-nano", ) def test_init(openai_instance): assert openai_instance.api_key == "test_api_key" - assert openai_instance.model == "gpt-4o-mini" + assert openai_instance.model == "gpt-4.1-nano" assert openai_instance.system_message == "dummy system message" @@ -185,7 +185,7 @@ def test_to_dict(openai_instance): assert result["name"] == "OpenAITool" assert result["type"] == "OpenAITool" assert result["api_key"] == "****************" - assert result["model"] == "gpt-4o-mini" + assert result["model"] == "gpt-4.1-nano" assert result["system_message"] == "dummy system message" assert result["oi_span_type"] == "LLM" @@ -200,7 +200,7 @@ async def test_from_dict(): "type": "OpenAITool", "oi_span_type": "LLM", "system_message": "You are helpful", - "model": "gpt-4o-mini", + "model": "gpt-4.1-nano", "chat_params": {"temperature": 0.7}, "is_streaming": False, "structured_output": False, @@ -210,7 +210,7 @@ async def test_from_dict(): assert isinstance(tool, OpenAITool) assert tool.name == "TestOpenAI" - assert tool.model == "gpt-4o-mini" + assert tool.model == "gpt-4.1-nano" assert tool.system_message == "You are helpful" assert tool.chat_params == {"temperature": 0.7} assert tool.is_streaming is False @@ -234,6 +234,39 @@ async def test_from_dict_roundtrip(openai_instance): assert restored.is_streaming == openai_instance.is_streaming +# --------------------------------------------------------------------------- # +# reasoning_effort / verbosity request params # +# --------------------------------------------------------------------------- # +def test_extra_create_kwargs_set(): + tool = OpenAITool(api_key="k", reasoning_effort="high", verbosity="low") + assert tool._extra_create_kwargs() == { + "reasoning_effort": "high", + "verbosity": "low", + } + + +def test_extra_create_kwargs_empty_by_default(): + tool = OpenAITool(api_key="k") + assert tool._extra_create_kwargs() == {} + + +@pytest.mark.asyncio +async def test_reasoning_verbosity_roundtrip(): + data = { + "name": "TestOpenAI", + "type": "OpenAITool", + "oi_span_type": "LLM", + "model": "gpt-5-mini", + "reasoning_effort": "medium", + "verbosity": "high", + } + tool = await OpenAITool.from_dict(data) + assert tool.reasoning_effort == "medium" + assert tool.verbosity == "high" + assert tool.to_dict()["reasoning_effort"] == "medium" + assert tool.to_dict()["verbosity"] == "high" + + def test_prepare_api_input(openai_instance): input_data = [ Message(role="system", content="You are a helpful assistant."),