Compare commits

...

23 Commits

Author SHA1 Message Date
Mason Daugherty
ae94520a56 . 2025-08-08 20:31:57 -04:00
Mason Daugherty
5e059d48ff . 2025-08-08 20:26:42 -04:00
Mason Daugherty
e51bacf8a9 . 2025-08-08 20:16:31 -04:00
Mason Daugherty
3d026b803d . 2025-08-08 20:15:03 -04:00
Mason Daugherty
b508b32da7 Merge branch 'master' of github.com:langchain-ai/langchain into mdrxy/openai 2025-08-08 20:14:22 -04:00
Mason Daugherty
d88ef0160f Merge branch 'cc/custom_tools' into mdrxy/openai 2025-08-07 16:14:44 -04:00
Mason Daugherty
d6f39ccce5 remove custom tool logic 2025-08-07 16:13:44 -04:00
Chester Curme
7a2347ae4f update cassette 2025-08-07 16:12:46 -04:00
Chester Curme
0a9686f183 fix streaming case 2025-08-07 16:11:32 -04:00
Mason Daugherty
ce6e56859e remove duplicate line 2025-08-07 16:02:00 -04:00
Chester Curme
5143a7db9b docs nit 2025-08-07 15:59:05 -04:00
Mason Daugherty
d4a7878084 Merge branch 'master' of github.com:langchain-ai/langchain into mdrxy/openai 2025-08-07 15:58:27 -04:00
Chester Curme
9d6250798d add docs 2025-08-07 15:45:53 -04:00
ccurme
6408637f38 Merge branch 'master' into cc/custom_tools 2025-08-07 16:24:04 -03:00
Chester Curme
4ee0050641 x 2025-08-07 14:59:03 -04:00
Chester Curme
760a1ee2b3 x 2025-08-07 14:44:36 -04:00
Chester Curme
c9349bb089 x 2025-08-07 14:34:31 -04:00
Mason Daugherty
6aa192cf48 other tests and API changes 2025-08-01 19:35:20 -04:00
Mason Daugherty
ccf3e25884 CFG 2025-08-01 19:25:56 -04:00
Mason Daugherty
b325eac612 streaming custom tools 2025-08-01 19:06:48 -04:00
Mason Daugherty
551c2a6683 custom tools 2025-08-01 18:52:36 -04:00
Mason Daugherty
44ed735113 tests: 'minimal' reasoning effort support 2025-08-01 17:22:44 -04:00
Mason Daugherty
372048c569 update docstring for 'minimal' reasoning effort and lint 2025-08-01 17:19:13 -04:00
15 changed files with 1584 additions and 59 deletions

View File

@@ -51,7 +51,7 @@
"\n",
"### Credentials\n",
"\n",
"Head to https://platform.openai.com to sign up to OpenAI and generate an API key. Once you've done this set the OPENAI_API_KEY environment variable:"
"Head to https://platform.openai.com to sign up to OpenAI and generate an API key. Once you've done this set the `OPENAI_API_KEY` environment variable:"
]
},
{
@@ -463,7 +463,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"id": "a47c809b-852f-46bd-8b9e-d9534c17213d",
"metadata": {},
"outputs": [

View File

@@ -8,11 +8,7 @@ from typing import Any, Literal, Optional, Union, cast
from pydantic import model_validator
from typing_extensions import NotRequired, Self, TypedDict, override
from langchain_core.messages.base import (
BaseMessage,
BaseMessageChunk,
merge_content,
)
from langchain_core.messages.base import BaseMessage, BaseMessageChunk, merge_content
from langchain_core.messages.tool import (
InvalidToolCall,
ToolCall,
@@ -20,15 +16,9 @@ from langchain_core.messages.tool import (
default_tool_chunk_parser,
default_tool_parser,
)
from langchain_core.messages.tool import (
invalid_tool_call as create_invalid_tool_call,
)
from langchain_core.messages.tool import (
tool_call as create_tool_call,
)
from langchain_core.messages.tool import (
tool_call_chunk as create_tool_call_chunk,
)
from langchain_core.messages.tool import invalid_tool_call as create_invalid_tool_call
from langchain_core.messages.tool import tool_call as create_tool_call
from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk
from langchain_core.utils._merge import merge_dicts, merge_lists
from langchain_core.utils.json import parse_partial_json
from langchain_core.utils.usage import _dict_int_op

View File

@@ -92,12 +92,12 @@ def tool(
positional argument.
description: Optional description for the tool.
Precedence for the tool description value is as follows:
- `description` argument
(used even if docstring and/or `args_schema` are provided)
- ``description`` argument
(used even if docstring and/or ``args_schema`` are provided)
- tool function docstring
(used even if `args_schema` is provided)
- `args_schema` description
(used only if `description` / docstring are not provided)
(used even if ``args_schema`` is provided)
- ``args_schema`` description
(used only if ``description`` / docstring are not provided)
*args: Extra positional arguments. Must be empty.
return_direct: Whether to return directly from the tool rather
than continuing the agent loop. Defaults to False.
@@ -105,13 +105,13 @@ def tool(
Defaults to None.
infer_schema: Whether to infer the schema of the arguments from
the function's signature. This also makes the resultant tool
accept a dictionary input to its `run()` function.
accept a dictionary input to its ``run()`` function.
Defaults to True.
response_format: The tool response format. If "content" then the output of
the tool is interpreted as the contents of a ToolMessage. If
"content_and_artifact" then the output is expected to be a two-tuple
corresponding to the (content, artifact) of a ToolMessage.
Defaults to "content".
response_format: The tool response format. If ``'content'`` then the output of
the tool is interpreted as the contents of a ``ToolMessage``. If
``'content_and_artifact'`` then the output is expected to be a two-tuple
corresponding to the (content, artifact) of a ``ToolMessage``.
Defaults to ``'content'``.
parse_docstring: if ``infer_schema`` and ``parse_docstring``, will attempt to
parse parameter descriptions from Google Style function docstrings.
Defaults to False.

View File

@@ -5,15 +5,7 @@ from __future__ import annotations
import textwrap
from collections.abc import Awaitable
from inspect import signature
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Callable,
Literal,
Optional,
Union,
)
from typing import TYPE_CHECKING, Annotated, Any, Callable, Literal, Optional, Union
from pydantic import Field, SkipValidation
from typing_extensions import override

2
libs/core/uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.13' and platform_python_implementation == 'PyPy'",

View File

@@ -568,16 +568,20 @@ class BaseChatOpenAI(BaseChatModel):
making requests to OpenAI compatible APIs, such as vLLM, LM Studio, or other
providers.
This is the recommended way to pass custom parameters that are specific to your
OpenAI-compatible API provider but not part of the standard OpenAI API.
Examples:
- LM Studio TTL parameter: ``extra_body={"ttl": 300}``
- vLLM custom parameters: ``extra_body={"use_beam_search": True}``
- Any other provider-specific parameters
.. note::
Do NOT use ``model_kwargs`` for custom parameters that are not part of the
standard OpenAI API, as this will cause errors when making API calls. Use
``extra_body`` instead.
@@ -593,10 +597,12 @@ class BaseChatOpenAI(BaseChatModel):
parameter and the value is either None, meaning that parameter should never be
used, or it's a list of disabled values for the parameter.
For example, older models may not support the ``'parallel_tool_calls'`` parameter at
all, in which case ``disabled_params={"parallel_tool_calls": None}`` can be passed
in.
If a parameter is disabled then it will not be used by default in any methods, e.g.
in :meth:`~langchain_openai.chat_models.base.ChatOpenAI.with_structured_output`.
However this does not prevent a user from directly passed in the parameter during
@@ -1731,7 +1737,26 @@ class BaseChatOpenAI(BaseChatModel):
elif isinstance(tool_choice, bool):
tool_choice = "required"
elif isinstance(tool_choice, dict):
pass
# Handle allowed_tools choice format
if tool_choice.get("type") == "allowed_tools":
allowed_config = tool_choice.get("allowed_tools", {})
mode = allowed_config.get("mode", "auto")
allowed_tools = allowed_config.get("tools", [])
if mode not in ["auto", "required"]:
raise ValueError(
f"allowed_tools mode must be 'auto' or 'required', "
f"got: {mode}"
)
# Convert allowed_tools to the expected format
tool_choice = {
"type": "allowed_tools",
"mode": mode,
"tools": allowed_tools,
}
else:
pass
else:
raise ValueError(
f"Unrecognized tool_choice type. Expected str, bool or dict. "
@@ -3585,6 +3610,14 @@ def _construct_responses_api_payload(
schema_dict = schema
if schema_dict == {"type": "json_object"}: # JSON mode
payload["text"] = {"format": {"type": "json_object"}}
elif schema_dict.get("type") == "grammar":
if "grammar" not in schema_dict:
raise ValueError("Grammar format requires 'grammar' field")
payload["text"] = {
"format": {"type": "grammar", "grammar": schema_dict["grammar"]}
}
elif schema_dict.get("type") == "python":
payload["text"] = {"format": {"type": "python"}}
elif (
(
response_format := _convert_to_openai_response_format(

View File

@@ -0,0 +1,121 @@
"""Context-Free Grammar validation for custom tools."""
from typing import Any, Optional
try:
from lark import Lark, LarkError
except ImportError:
Lark = None # type: ignore[misc,assignment]
LarkError = Exception # type: ignore[misc,assignment]
class CFGValidator:
"""Validates input text against a Context-Free Grammar using Lark parser.
This class provides grammar-constrained validation for custom tool outputs,
ensuring model outputs conform to specific syntax rules while maintaining
the flexibility of plaintext (non-JSON) outputs.
"""
def __init__(self, grammar: str) -> None:
"""Initialize the CFG validator.
Args:
grammar: The Lark grammar string defining the allowed syntax.
Raises:
ImportError: If lark package is not installed.
LarkError: If the grammar string is invalid.
"""
if Lark is None:
raise ImportError(
"The 'lark' package is required for CFG validation. "
"Install it with: pip install lark"
)
self.grammar = grammar
try:
self.parser = Lark(grammar, start="start")
except Exception as e:
raise LarkError(f"Invalid grammar definition: {e}") from e
def validate(self, text: str) -> bool:
"""Validate input text against the grammar.
Args:
text: The text to validate.
Returns:
True if the text matches the grammar, False otherwise.
"""
try:
self.parser.parse(text)
return True
except Exception:
return False
def parse(self, text: str) -> Any:
"""Parse input text according to the grammar.
Args:
text: The text to parse.
Returns:
The parse tree if successful.
Raises:
LarkError: If the text doesn't match the grammar.
"""
try:
return self.parser.parse(text)
except Exception as e:
raise LarkError(f"Grammar validation failed: {e}") from e
def validate_cfg_format(tool_format: dict[str, Any]) -> Optional[CFGValidator]:
"""Validate and create a CFG validator from tool format specification.
Args:
tool_format: The format specification dictionary containing grammar rules.
Returns:
CFGValidator instance if format type is 'grammar', None otherwise.
Raises:
ValueError: If format type is 'grammar' but grammar is missing or invalid.
"""
if not isinstance(tool_format, dict):
return None
if tool_format.get("type") != "grammar":
return None
grammar = tool_format.get("grammar")
if not grammar:
raise ValueError("Grammar format requires 'grammar' field")
if not isinstance(grammar, str):
raise ValueError("Grammar must be a string")
try:
return CFGValidator(grammar)
except (ImportError, LarkError) as e:
raise ValueError(f"Invalid grammar specification: {e}") from e
def validate_custom_tool_output(
output: str, cfg_validator: Optional[CFGValidator]
) -> bool:
"""Validate custom tool output against CFG if validator is provided.
Args:
output: The tool output text to validate.
cfg_validator: Optional CFG validator instance.
Returns:
True if validation passes or no validator is provided, False otherwise.
"""
if cfg_validator is None:
return True
return cfg_validator.validate(output)

View File

@@ -8,8 +8,9 @@ license = { text = "MIT" }
requires-python = ">=3.9"
dependencies = [
"langchain-core<1.0.0,>=0.3.74",
"openai<2.0.0,>=1.86.0",
"openai<2.0.0,>=1.99.5",
"tiktoken<1,>=0.7",
"lark>=1.1.0"
]
name = "langchain-openai"
version = "0.3.29"

View File

@@ -534,7 +534,7 @@ def test_disable_parallel_tool_calling() -> None:
assert len(result.tool_calls) == 1
@pytest.mark.parametrize("model", ["gpt-4o-mini", "o1", "gpt-4", "gpt-5-nano"])
@pytest.mark.parametrize("model", ["o1", "gpt-4", "gpt-5-nano"])
def test_openai_structured_output(model: str) -> None:
class MyModel(BaseModel):
"""A Person"""

View File

@@ -0,0 +1,260 @@
"""Integration tests for CFG-enabled custom tools."""
import pytest
from langchain_core.messages import AIMessage
from langchain_openai import ChatOpenAI, custom_tool
from langchain_openai.chat_models.cfg_grammar import (
validate_cfg_format,
validate_custom_tool_output,
)
@custom_tool
def execute_math_expression(expression: str) -> str:
"""Execute a mathematical expression with CFG validation.
Args:
expression: A mathematical expression to evaluate.
Returns:
The result of the mathematical expression or an error message.
"""
# Define grammar for arithmetic expressions
math_grammar = """
start: expr
expr: term (("+" | "-") term)*
term: factor (("*" | "/") factor)*
factor: NUMBER | "(" expr ")"
NUMBER: /[0-9]+(\\.[0-9]+)?/
%import common.WS
%ignore WS
"""
# Create CFG validator
tool_format = {"type": "grammar", "grammar": math_grammar}
validator = validate_cfg_format(tool_format)
# Validate input against grammar
if not validate_custom_tool_output(expression, validator):
return f"Grammar validation failed for expression: {expression}"
# If valid, evaluate safely (in practice, use a safer evaluator)
try:
# Simple evaluation - in production, use ast.literal_eval or similar
# for safety!
result = eval(expression) # noqa: S307
return f"Result: {result}"
except Exception as e:
return f"Execution error: {e}"
@custom_tool
def generate_sql_query(query: str) -> str:
"""Generate and validate SQL SELECT queries with CFG validation.
Args:
query: A SQL SELECT query to validate.
Returns:
Validation result and mock execution.
"""
# Define grammar for simple SELECT queries
sql_grammar = """
start: query
query: "SELECT" field_list "FROM" table_name where_clause?
field_list: field ("," field)*
field: IDENTIFIER | "*"
table_name: IDENTIFIER
where_clause: "WHERE" condition
condition: IDENTIFIER "=" STRING
IDENTIFIER: /[a-zA-Z_][a-zA-Z0-9_]*/
STRING: /"[^"]*"/ | /'[^']*'/
%import common.WS
%ignore WS
"""
# Create CFG validator
tool_format = {"type": "grammar", "grammar": sql_grammar}
validator = validate_cfg_format(tool_format)
# Validate input against grammar
if not validate_custom_tool_output(query, validator):
return f"SQL grammar validation failed for query: {query}"
# Mock execution of valid query
return f"Valid SQL query: {query}\nMock result: [Sample data rows]"
class TestCFGCustomToolsIntegration:
"""Integration tests for CFG-enabled custom tools."""
@pytest.mark.scheduled
def test_cfg_math_tool_with_valid_expressions(self) -> None:
"""Test CFG math tool with valid mathematical expressions."""
# Test the tool directly with valid expressions
result1 = execute_math_expression("5 + 3")
assert "Result: 8" in result1
result2 = execute_math_expression("(10 - 2) * 3")
assert "Result: 24" in result2
result3 = execute_math_expression("15 / 3")
assert "Result: 5" in result3
@pytest.mark.scheduled
def test_cfg_math_tool_with_invalid_expressions(self) -> None:
"""Test CFG math tool rejects invalid expressions."""
# Test with invalid expressions
result1 = execute_math_expression("hello world")
assert "Grammar validation failed" in result1
result2 = execute_math_expression("5 + + 3")
assert "Grammar validation failed" in result2
result3 = execute_math_expression("print('hello')")
assert "Grammar validation failed" in result3
@pytest.mark.scheduled
def test_cfg_sql_tool_with_valid_queries(self) -> None:
"""Test CFG SQL tool with valid SELECT queries."""
# Test with valid SQL queries
result1 = generate_sql_query("SELECT id FROM users")
assert "Valid SQL query" in result1
assert "Mock result" in result1
result2 = generate_sql_query("SELECT name, email FROM customers")
assert "Valid SQL query" in result2
result3 = generate_sql_query(
'SELECT * FROM products WHERE category = "electronics"'
)
assert "Valid SQL query" in result3
@pytest.mark.scheduled
def test_cfg_sql_tool_with_invalid_queries(self) -> None:
"""Test CFG SQL tool rejects invalid queries."""
# Test with invalid SQL queries
result1 = generate_sql_query("DELETE FROM users")
assert "SQL grammar validation failed" in result1
result2 = generate_sql_query("UPDATE users SET name = 'test'")
assert "SQL grammar validation failed" in result2
@pytest.mark.scheduled
def test_cfg_validator_error_handling(self) -> None:
"""Test CFG validator handles edge cases properly."""
# Test empty input
result1 = execute_math_expression("")
assert "Grammar validation failed" in result1
# Test whitespace-only input
result2 = execute_math_expression(" ")
assert "Grammar validation failed" in result2
# Test special characters
result3 = execute_math_expression("5 & 3")
assert "Grammar validation failed" in result3
@pytest.mark.scheduled
def test_cfg_validator_with_complex_valid_expressions(self) -> None:
"""Test CFG validator with complex but valid expressions."""
# Test nested parentheses
result1 = execute_math_expression("((5 + 3) * 2) - 1")
assert "Result: 15" in result1
# Test decimal numbers
result2 = execute_math_expression("3.14 * 2")
assert "Result: 6.28" in result2
# Test multiple operations
result3 = execute_math_expression("10 + 5 - 3 * 2")
assert "Result: 9" in result3 # Should follow operator precedence
@pytest.mark.scheduled
def test_cfg_integration_performance(self) -> None:
"""Test that CFG validation doesn't significantly impact performance."""
import time
expressions = [
"1 + 1",
"2 * 3",
"10 / 2",
"(4 + 6) * 2",
"100 - 50",
"3.14 * 2",
"25 / 5",
"7 + 8 - 3",
]
start_time = time.time()
for expr in expressions:
result = execute_math_expression(expr)
assert "Result:" in result # All should be valid
end_time = time.time()
duration = end_time - start_time
# Should complete all validations in reasonable time (< 1 second)
assert duration < 1.0, f"CFG validation took too long: {duration}s"
@pytest.mark.scheduled
def test_cfg_grammar_reusability(self) -> None:
"""Test that CFG grammars can be reused efficiently."""
math_grammar = """
start: expr
expr: term (("+" | "-") term)*
term: factor (("*" | "/") factor)*
factor: NUMBER | "(" expr ")"
NUMBER: /[0-9]+(\\.[0-9]+)?/
%import common.WS
%ignore WS
"""
tool_format = {"type": "grammar", "grammar": math_grammar}
validator = validate_cfg_format(tool_format)
# Reuse validator multiple times
test_cases = [
("5 + 3", True),
("hello", False),
("10 * 2", True),
("invalid syntax", False),
("(1 + 2) * 3", True),
]
for expression, should_be_valid in test_cases:
result = validate_custom_tool_output(expression, validator)
assert result == should_be_valid, f"Failed for expression: {expression}"
class TestCFGModelIntegration:
"""Integration tests for CFG validation with actual OpenAI models."""
@pytest.mark.skip(reason="CFG model integration not yet implemented")
@pytest.mark.scheduled
def test_model_with_cfg_tools_valid_output(self) -> None:
"""Test that model generates valid CFG-compliant outputs."""
llm = ChatOpenAI(model="gpt-5", temperature=0)
llm_with_cfg_tools = llm.bind_tools(
[execute_math_expression],
tool_format={
"type": "grammar",
"grammar": "start: expr\nexpr: NUMBER ('+' | '-' | '*' | '/') NUMBER\nNUMBER: /[0-9]+/", # noqa: E501
},
)
response = llm_with_cfg_tools.invoke(
"Calculate 5 + 3 using the math tool. Make sure to output a valid mathematical expression." # noqa: E501
)
assert isinstance(response, AIMessage)
assert response.tool_calls
@pytest.mark.skip(reason="CFG model integration not yet implemented")
@pytest.mark.scheduled
def test_model_cfg_validation_rejection(self) -> None:
"""Test that model tool calls are rejected if they don't match CFG."""
pass

View File

@@ -0,0 +1,214 @@
"""Integration tests for new OpenAI API features."""
import pytest
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI, custom_tool
class TestResponseFormatsIntegration:
"""Integration tests for new response format types."""
@pytest.mark.scheduled
def test_grammar_response_format_integration(self):
"""Test grammar response format with actual API."""
llm = ChatOpenAI(model="gpt-5", temperature=0)
grammar_format = {
"type": "grammar",
"grammar": """
start: expr
expr: NUMBER ("+" | "-" | "*" | "/") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
""",
}
bound_llm = llm.bind(response_format=grammar_format)
assert bound_llm is not None
@pytest.mark.scheduled
def test_python_response_format_integration(self):
"""Test python response format with actual API."""
llm = ChatOpenAI(model="gpt-5", temperature=0)
python_format = {"type": "python"}
bound_llm = llm.bind(response_format=python_format)
assert bound_llm is not None
class TestAllowedToolsChoiceIntegration:
"""Integration tests for allowed_tools tool choice."""
@pytest.mark.scheduled
def test_allowed_tools_integration(self):
"""Test allowed_tools choice with actual API."""
@tool
def get_weather(location: str) -> str:
"""Get weather for a location."""
return f"Weather in {location}: sunny"
@tool
def get_time() -> str:
"""Get current time."""
return "12:00 PM"
llm = ChatOpenAI(model="gpt-5", temperature=0)
allowed_tools_choice = {
"type": "allowed_tools",
"allowed_tools": {
"mode": "auto",
"tools": [
{"type": "function", "function": {"name": "get_weather"}},
{"type": "function", "function": {"name": "get_time"}},
],
},
}
bound_llm = llm.bind_tools(
[get_weather, get_time], tool_choice=allowed_tools_choice
)
# Test that it can be invoked without errors
response = bound_llm.invoke("What's the weather like in Paris?")
assert response is not None
class TestVerbosityParameterIntegration:
"""Integration tests for verbosity parameter."""
@pytest.mark.scheduled
def test_verbosity_integration(self):
"""Test verbosity parameter with actual API."""
llm = ChatOpenAI(model="gpt-5", verbosity="low", temperature=0)
response = llm.invoke("Tell me about artificial intelligence.")
assert response is not None
class TestCustomToolsIntegration:
"""Integration tests for custom tools functionality."""
@pytest.mark.scheduled
def test_custom_tools_with_cfg_validation(self):
"""Test custom tools with CFG validation."""
# Import from the CFG validation module
from langchain_openai.chat_models.cfg_grammar import (
validate_cfg_format,
validate_custom_tool_output,
)
# Test arithmetic expressions
grammar = """
start: expr
expr: term (("+" | "-") term)*
term: factor (("*" | "/") factor)*
factor: NUMBER | "(" expr ")"
NUMBER: /[0-9]+(\\.[0-9]+)?/
%import common.WS
%ignore WS
"""
tool_format = {"type": "grammar", "grammar": grammar}
validator = validate_cfg_format(tool_format)
assert validator is not None
# Test valid expressions
valid_expressions = ["5 + 3", "10 * 2", "(1 + 2) * 3"]
for expr in valid_expressions:
assert validate_custom_tool_output(expr, validator) is True
# Test invalid expressions
invalid_expressions = ["hello", "5 + +", "invalid"]
for expr in invalid_expressions:
assert validate_custom_tool_output(expr, validator) is False
class TestStreamingIntegration:
"""Integration tests for streaming with new features."""
@pytest.mark.scheduled
def test_streaming_with_verbosity(self):
"""Test streaming works with verbosity parameter."""
llm = ChatOpenAI(model="gpt-5", verbosity="medium", temperature=0)
chunks = []
for chunk in llm.stream("Count from 1 to 3"):
chunks.append(chunk)
assert len(chunks) > 0
@pytest.mark.scheduled
def test_streaming_with_custom_tools(self):
"""Test streaming works with custom tools."""
@custom_tool
def execute_code(code: str) -> str:
"""Execute Python code."""
return f"Executed: {code}"
llm = ChatOpenAI(model="gpt-5", temperature=0)
bound_llm = llm.bind_tools([execute_code])
chunks = []
for chunk in bound_llm.stream("Write a simple Python print statement"):
chunks.append(chunk)
assert len(chunks) > 0
class TestMinimalReasoningEffortIntegration:
"""Integration tests for minimal reasoning effort."""
@pytest.mark.scheduled
def test_minimal_reasoning_effort_integration(self):
"""Test minimal reasoning effort with reasoning models."""
llm = ChatOpenAI(model="gpt-5", reasoning_effort="minimal", temperature=0)
response = llm.invoke("What is 2 + 2?")
assert response is not None
class TestFullIntegration:
"""Test combinations of new features together."""
@pytest.mark.scheduled
def test_multiple_new_features_together(self):
"""Test using multiple new features in combination."""
@tool
def analyze_data(data: str) -> str:
"""Analyze data and return insights."""
return f"Analysis of {data}: positive trend"
llm = ChatOpenAI(
model="gpt-5", verbosity="medium", reasoning_effort="low", temperature=0
)
# Try with allowed tools and grammar response format
allowed_tools_choice = {
"type": "allowed_tools",
"allowed_tools": {
"mode": "auto",
"tools": [{"type": "function", "function": {"name": "analyze_data"}}],
},
}
grammar_format = {
"type": "grammar",
"grammar": "start: result\nresult: /[a-zA-Z0-9 ]+/",
}
bound_llm = llm.bind_tools(
[analyze_data], tool_choice=allowed_tools_choice
).bind(response_format=grammar_format)
# If this works, it means all features are compatible
response = bound_llm.invoke("Analyze this sales data")
assert response is not None

View File

@@ -0,0 +1,646 @@
"""Test custom tools functionality."""
import pytest
from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.messages.tool import tool_call
from langchain_core.tools import tool
from langchain_openai import custom_tool
from langchain_openai.chat_models.base import (
_convert_delta_to_message_chunk,
_convert_dict_to_message,
)
from langchain_openai.chat_models.cfg_grammar import (
CFGValidator,
validate_cfg_format,
validate_custom_tool_output,
)
def test_custom_tool_decorator():
"""Test that custom tools can be created with the `@tool` decorator."""
@custom_tool
def execute_code(code: str) -> str:
"""Execute arbitrary Python code."""
return f"Executed: {code}"
assert execute_code.custom_tool is True
result = execute_code.invoke({"text_input": "print('hello')"})
assert result == "Executed: print('hello')"
def test_regular_tool_not_custom():
"""Test that regular tools are not marked as custom."""
@tool
def get_weather(location: str) -> str:
"""Get weather for a location."""
return f"Weather in {location}: sunny"
assert get_weather.custom_tool is False
def test_tool_call_with_text_input():
"""Test creating tool calls with `text_input`."""
custom_call = tool_call(name="execute_code", id="call_123")
assert custom_call["name"] == "execute_code"
assert custom_call.get("text_input") == "print('hello world')"
assert "args" not in custom_call
assert custom_call["id"] == "call_123"
def test_tool_call_validation():
"""Test that `tool_call()` allows flexible creation."""
# Should allow both args and text_input (validation happens at execution time)
call_with_both = tool_call(
name="test", args={"x": 1}, text_input="some text", id="call_123"
)
assert call_with_both["name"] == "test"
assert call_with_both.get("args") == {"x": 1}
assert call_with_both.get("text_input") == "some text"
# Should allow empty args/text_input (backward compatibility)
call_empty = tool_call(name="test", id="call_123")
assert call_empty["name"] == "test"
assert call_empty.get("args", {}) == {}
def test_custom_tool_call_parsing():
"""Test parsing custom tool calls from OpenAI response format."""
# Simulate OpenAI custom tool call response
openai_response = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "custom",
"name": "execute_code",
"input": "print('hello world')",
"id": "call_abc123",
}
],
}
# Parse the message
message = _convert_dict_to_message(openai_response)
assert isinstance(message, AIMessage)
assert len(message.tool_calls) == 1
tool_call = message.tool_calls[0]
assert tool_call["name"] == "execute_code"
assert tool_call.get("text_input") == "print('hello world')"
assert "args" not in tool_call # Custom tools don't have an args field
assert tool_call["id"] == "call_abc123"
assert tool_call.get("type") == "tool_call"
def test_regular_tool_call_parsing_unchanged():
"""Test that regular tool call parsing still works."""
# Simulate regular OpenAI function tool call response
openai_response = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "Paris", "unit": "celsius"}',
},
"id": "call_def456",
}
],
}
# Parse the message
message = _convert_dict_to_message(openai_response)
assert isinstance(message, AIMessage)
assert len(message.tool_calls) == 1
tool_call = message.tool_calls[0]
assert tool_call["name"] == "get_weather"
assert tool_call.get("args") == {"location": "Paris", "unit": "celsius"}
assert "text_input" not in tool_call
assert tool_call["id"] == "call_def456"
def test_custom_tool_streaming_text_input():
"""Test streaming custom tool calls use `text_input` field."""
chunk1 = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "custom",
"name": "execute_code",
"input": "print('hello",
"id": "call_abc123",
"index": 0,
}
],
}
chunk2 = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "custom",
"name": None,
"input": " world')",
"id": None,
"index": 0,
}
],
}
message_chunk1 = _convert_delta_to_message_chunk(chunk1, AIMessageChunk)
message_chunk2 = _convert_delta_to_message_chunk(chunk2, AIMessageChunk)
# Verify first chunk
assert isinstance(message_chunk1, AIMessageChunk)
assert len(message_chunk1.tool_call_chunks) == 1
tool_call_chunk1 = message_chunk1.tool_call_chunks[0]
assert tool_call_chunk1["name"] == "execute_code"
assert tool_call_chunk1.get("text_input") == "print('hello"
assert tool_call_chunk1.get("args") == "" # Empty for custom tools
assert tool_call_chunk1["id"] == "call_abc123"
assert tool_call_chunk1["index"] == 0
# Verify second chunk
assert isinstance(message_chunk2, AIMessageChunk)
assert len(message_chunk2.tool_call_chunks) == 1
tool_call_chunk2 = message_chunk2.tool_call_chunks[0]
assert tool_call_chunk2["name"] is None
assert tool_call_chunk2.get("text_input") == " world')"
assert tool_call_chunk2.get("args") == "" # Empty for custom tools
assert tool_call_chunk2["id"] is None
assert tool_call_chunk2["index"] == 0
# Test chunk aggregation
combined = message_chunk1 + message_chunk2
assert isinstance(combined, AIMessageChunk)
assert len(combined.tool_call_chunks) == 1
combined_chunk = combined.tool_call_chunks[0]
assert combined_chunk["name"] == "execute_code"
assert combined_chunk.get("text_input") == "print('hello world')"
assert combined_chunk.get("args") == "" # Empty for custom tools
assert combined_chunk["id"] == "call_abc123"
def test_function_tool_streaming_args():
"""Test streaming function tool calls still use args field with JSON."""
chunk1 = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "function",
"function": {"name": "get_weather", "arguments": '{"location": "Par'},
"id": "call_def456",
"index": 0,
}
],
}
chunk2 = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "function",
"function": {"name": None, "arguments": 'is", "unit": "celsius"}'},
"id": None,
"index": 0,
}
],
}
# Parse the chunks
message_chunk1 = _convert_delta_to_message_chunk(chunk1, AIMessageChunk)
message_chunk2 = _convert_delta_to_message_chunk(chunk2, AIMessageChunk)
# Verify first chunk
assert isinstance(message_chunk1, AIMessageChunk)
assert len(message_chunk1.tool_call_chunks) == 1
tool_call_chunk1 = message_chunk1.tool_call_chunks[0]
assert tool_call_chunk1["name"] == "get_weather"
assert tool_call_chunk1.get("args") == '{"location": "Par'
assert "text_input" not in tool_call_chunk1
assert tool_call_chunk1["id"] == "call_def456"
assert tool_call_chunk1["index"] == 0
# Verify second chunk
assert isinstance(message_chunk2, AIMessageChunk)
assert len(message_chunk2.tool_call_chunks) == 1
tool_call_chunk2 = message_chunk2.tool_call_chunks[0]
assert tool_call_chunk2["name"] is None
assert tool_call_chunk2.get("args") == 'is", "unit": "celsius"}'
assert "text_input" not in tool_call_chunk2
assert tool_call_chunk2["id"] is None
assert tool_call_chunk2["index"] == 0
# Test chunk aggregation
combined = message_chunk1 + message_chunk2
assert isinstance(combined, AIMessageChunk)
assert len(combined.tool_call_chunks) == 1
combined_chunk = combined.tool_call_chunks[0]
assert combined_chunk["name"] == "get_weather"
assert combined_chunk.get("args") == '{"location": "Paris", "unit": "celsius"}'
assert "text_input" not in combined_chunk
assert combined_chunk["id"] == "call_def456"
def test_mixed_tool_streaming():
"""Test streaming with both custom and function tools in same response."""
# Simulate mixed tool streaming chunk from OpenAI
chunk = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"type": "custom",
"name": "execute_code",
"input": "x = 5",
"id": "call_custom_123",
"index": 0,
},
{
"type": "function",
"function": {"name": "get_weather", "arguments": '{"location": "NYC"}'},
"id": "call_func_456",
"index": 1,
},
],
}
# Parse the chunk
message_chunk = _convert_delta_to_message_chunk(chunk, AIMessageChunk)
assert isinstance(message_chunk, AIMessageChunk)
assert len(message_chunk.tool_call_chunks) == 2
# Verify custom tool chunk
custom_chunk = message_chunk.tool_call_chunks[0]
assert custom_chunk["name"] == "execute_code"
assert custom_chunk.get("text_input") == "x = 5"
assert custom_chunk.get("args") == "" # Empty for custom tools
assert custom_chunk["id"] == "call_custom_123"
assert custom_chunk["index"] == 0
# Verify function tool chunk
function_chunk = message_chunk.tool_call_chunks[1]
assert function_chunk["name"] == "get_weather"
assert function_chunk.get("args") == '{"location": "NYC"}'
assert "text_input" not in function_chunk
assert function_chunk["id"] == "call_func_456"
assert function_chunk["index"] == 1
# CFG Grammar Tests
class TestCFGValidator:
"""Test CFG validator functionality."""
def test_cfg_validator_initialization(self):
"""Test CFG validator can be initialized with valid grammar."""
grammar = """
start: expr
expr: NUMBER ("+" | "-" | "*") NUMBER
NUMBER: /[0-9]+/
"""
validator = CFGValidator(grammar)
assert validator.grammar == grammar
assert validator.parser is not None
def test_cfg_validator_initialization_invalid_grammar(self):
"""Test CFG validator raises error with invalid grammar."""
invalid_grammar = "invalid grammar string [["
with pytest.raises(Exception):
CFGValidator(invalid_grammar)
def test_cfg_validator_validate_valid_input(self):
"""Test CFG validator accepts valid input."""
grammar = """
start: expr
expr: NUMBER ("+" | "-" | "*") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
assert validator.validate("5 + 3") is True
assert validator.validate("10 * 2") is True
assert validator.validate("100 - 50") is True
def test_cfg_validator_validate_invalid_input(self):
"""Test CFG validator rejects invalid input."""
grammar = """
start: expr
expr: NUMBER ("+" | "-" | "*") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
assert validator.validate("hello") is False
assert validator.validate("5 + + 3") is False
assert validator.validate("5 + ") is False
assert validator.validate("+ 5") is False
def test_cfg_validator_parse_valid_input(self):
"""Test CFG validator can parse valid input."""
grammar = """
start: expr
expr: NUMBER ("+" | "-" | "*") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
tree = validator.parse("5 + 3")
assert tree is not None
assert str(tree.data) == "start"
def test_cfg_validator_parse_invalid_input(self):
"""Test CFG validator raises error when parsing invalid input."""
grammar = """
start: expr
expr: NUMBER ("+" | "-" | "*") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
with pytest.raises(Exception):
validator.parse("invalid input")
def test_cfg_validator_complex_grammar(self):
"""Test CFG validator with more complex grammar."""
# SQL-like grammar
grammar = """
start: query
query: "SELECT" field_list "FROM" table_name where_clause?
field_list: field ("," field)*
field: IDENTIFIER
table_name: IDENTIFIER
where_clause: "WHERE" condition
condition: IDENTIFIER "=" STRING
IDENTIFIER: /[a-zA-Z_][a-zA-Z0-9_]*/
STRING: /"[^"]*"/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
# Valid SQL queries
assert validator.validate("SELECT id FROM users") is True
assert (
validator.validate('SELECT id, name FROM users WHERE status = "active"')
is True
)
# Invalid SQL queries
assert validator.validate("SELECT FROM users") is False
assert validator.validate("INVALID QUERY") is False
def test_cfg_validator_python_code_grammar(self):
"""Test CFG validator with Python code grammar."""
# Simple Python expression grammar
grammar = """
start: statement
statement: assignment | expression
assignment: IDENTIFIER "=" expression
expression: term (("+" | "-") term)*
term: factor (("*" | "/") factor)*
factor: NUMBER | IDENTIFIER | "(" expression ")"
IDENTIFIER: /[a-zA-Z_][a-zA-Z0-9_]*/
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
# Valid Python expressions
assert validator.validate("x = 5") is True
assert validator.validate("result = a + b * c") is True
assert validator.validate("(x + y) / 2") is True
# Invalid Python expressions
assert validator.validate("x =") is False
assert validator.validate("+ + +") is False
class TestValidateCFGFormat:
"""Test validate_cfg_format function."""
def test_validate_cfg_format_valid_grammar_format(self):
"""Test validate_cfg_format with valid grammar format."""
tool_format = {
"type": "grammar",
"grammar": "start: expr\nexpr: NUMBER\nNUMBER: /[0-9]+/",
}
validator = validate_cfg_format(tool_format)
assert validator is not None
assert isinstance(validator, CFGValidator)
def test_validate_cfg_format_non_grammar_format(self):
"""Test validate_cfg_format with non-grammar format."""
tool_format = {"type": "json_schema", "schema": {}}
validator = validate_cfg_format(tool_format)
assert validator is None
def test_validate_cfg_format_missing_grammar(self):
"""Test validate_cfg_format with missing grammar field."""
tool_format = {"type": "grammar"}
with pytest.raises(ValueError, match="Grammar format requires 'grammar' field"):
validate_cfg_format(tool_format)
def test_validate_cfg_format_invalid_grammar_type(self):
"""Test validate_cfg_format with non-string grammar."""
tool_format = {"type": "grammar", "grammar": ["not", "a", "string"]}
with pytest.raises(ValueError, match="Grammar must be a string"):
validate_cfg_format(tool_format)
def test_validate_cfg_format_invalid_grammar_syntax(self):
"""Test validate_cfg_format with invalid grammar syntax."""
tool_format = {"type": "grammar", "grammar": "invalid grammar [[ syntax"}
with pytest.raises(ValueError, match="Invalid grammar specification"):
validate_cfg_format(tool_format)
def test_validate_cfg_format_non_dict_input(self):
"""Test validate_cfg_format with non-dict input."""
assert validate_cfg_format("not a dict") is None
assert validate_cfg_format(None) is None
assert validate_cfg_format([]) is None
class TestValidateCustomToolOutput:
"""Test validate_custom_tool_output function."""
def test_validate_custom_tool_output_with_validator(self):
"""Test validate_custom_tool_output with CFG validator."""
grammar = """
start: expr
expr: NUMBER ("+" | "-") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
# Valid outputs
assert validate_custom_tool_output("5 + 3", validator) is True
assert validate_custom_tool_output("10 - 7", validator) is True
# Invalid outputs
assert validate_custom_tool_output("hello", validator) is False
assert validate_custom_tool_output("5 + + 3", validator) is False
def test_validate_custom_tool_output_without_validator(self):
"""Test validate_custom_tool_output without CFG validator."""
# Should return True when no validator is provided
assert validate_custom_tool_output("any string", None) is True
assert validate_custom_tool_output("", None) is True
assert validate_custom_tool_output("invalid grammar", None) is True
class TestCFGIntegration:
"""Test CFG integration with custom tools."""
def test_custom_tool_call_with_cfg_validation(self):
"""Test that CFG validation can be integrated with custom tool calls."""
# Arithmetic expressions
grammar = """
start: expr
expr: NUMBER ("+" | "-" | "*" | "/") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
# Simulate custom tool definition with CFG format
tool_format = {"type": "grammar", "grammar": grammar}
validator = validate_cfg_format(tool_format)
assert validator is not None
# Test valid tool outputs
valid_outputs = ["5 + 3", "10 * 2", "100 / 5", "50 - 25"]
for output in valid_outputs:
assert validate_custom_tool_output(output, validator) is True
# Test invalid tool outputs
invalid_outputs = ["hello", "5 + + 3", "invalid", "5 +"]
for output in invalid_outputs:
assert validate_custom_tool_output(output, validator) is False
def test_sql_query_cfg_validation(self):
"""Test CFG validation for SQL-like queries."""
sql_grammar = """
start: query
query: "SELECT" field "FROM" table
field: IDENTIFIER
table: IDENTIFIER
IDENTIFIER: /[a-zA-Z_][a-zA-Z0-9_]*/
%import common.WS
%ignore WS
"""
tool_format = {"type": "grammar", "grammar": sql_grammar}
validator = validate_cfg_format(tool_format)
assert validator is not None
# Valid SQL queries
assert validate_custom_tool_output("SELECT id FROM users", validator) is True
assert (
validate_custom_tool_output("SELECT name FROM products", validator) is True
)
# Invalid SQL queries
assert validate_custom_tool_output("SELECT FROM users", validator) is False
assert validate_custom_tool_output("INVALID QUERY", validator) is False
def test_python_expression_cfg_validation(self):
"""Test CFG validation for Python expressions."""
python_grammar = """
start: assignment
assignment: IDENTIFIER "=" expression
expression: term (("+" | "-") term)*
term: factor (("*" | "/") factor)*
factor: NUMBER | IDENTIFIER
IDENTIFIER: /[a-zA-Z_][a-zA-Z0-9_]*/
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
"""
tool_format = {"type": "grammar", "grammar": python_grammar}
validator = validate_cfg_format(tool_format)
assert validator is not None
# Valid Python assignments
assert validate_custom_tool_output("x = 5", validator) is True
assert validate_custom_tool_output("result = a + b", validator) is True
assert (
validate_custom_tool_output("total = price * quantity", validator) is True
)
# Invalid Python assignments
assert validate_custom_tool_output("x =", validator) is False
assert validate_custom_tool_output("= 5", validator) is False
assert validate_custom_tool_output("hello world", validator) is False
@pytest.mark.skipif(CFGValidator is None, reason="lark package not available")
class TestCFGErrorHandling:
"""Test CFG error handling when lark is not available."""
def test_cfg_validator_import_error(self, monkeypatch):
"""Test CFG validator handles missing lark import gracefully."""
# Mock the import to fail
monkeypatch.setattr("langchain_openai.chat_models.cfg_grammar.Lark", None)
with pytest.raises(ImportError, match="The 'lark' package is required"):
CFGValidator("start: NUMBER\nNUMBER: /[0-9]+/")
def test_cfg_edge_cases(self):
"""Test CFG validator edge cases."""
grammar = """
start: item*
item: WORD
WORD: /\\w+/
%import common.WS
%ignore WS
"""
validator = CFGValidator(grammar)
# Empty string should be valid (zero items)
assert validator.validate("") is True
# Single word should be valid
assert validator.validate("hello") is True
# Multiple words should be valid
assert validator.validate("hello world test") is True

View File

@@ -0,0 +1,255 @@
"""Test new OpenAI API features."""
import pytest
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
class TestResponseFormats:
"""Test new response format types."""
def test_grammar_response_format(self):
"""Test grammar response format configuration."""
llm = ChatOpenAI(model="gpt-5", temperature=0)
grammar_format = {
"type": "grammar",
"grammar": """
start: expr
expr: NUMBER ("+" | "-") NUMBER
NUMBER: /[0-9]+/
%import common.WS
%ignore WS
""",
}
# This should not raise an error during bind
bound_llm = llm.bind(response_format=grammar_format)
assert bound_llm is not None
def test_python_response_format(self):
"""Test python response format configuration."""
llm = ChatOpenAI(model="gpt-5", temperature=0)
python_format = {"type": "python"}
# This should not raise an error during bind
bound_llm = llm.bind(response_format=python_format)
assert bound_llm is not None
def test_grammar_format_validation(self):
"""Test that grammar format requires grammar field."""
llm = ChatOpenAI(model="gpt-5", temperature=0)
# Test missing grammar field
invalid_format = {"type": "grammar"}
bound_llm = llm.bind(response_format=invalid_format)
assert bound_llm is not None
class TestAllowedToolsChoice:
"""Test allowed_tools tool choice functionality."""
def test_allowed_tools_auto_mode(self):
"""Test allowed_tools with auto mode."""
@tool
def get_weather(location: str) -> str:
"""Get weather for location."""
return f"Weather in {location}: sunny"
@tool
def get_time() -> str:
"""Get current time."""
return "12:00 PM"
llm = ChatOpenAI(model="gpt-5", temperature=0)
allowed_tools_choice = {
"type": "allowed_tools",
"allowed_tools": {
"mode": "auto",
"tools": [
{"type": "function", "function": {"name": "get_weather"}},
{"type": "function", "function": {"name": "get_time"}},
],
},
}
bound_llm = llm.bind_tools(
[get_weather, get_time], tool_choice=allowed_tools_choice
)
assert bound_llm is not None
def test_allowed_tools_required_mode(self):
"""Test allowed_tools with required mode."""
@tool
def calculate(expression: str) -> str:
"""Calculate mathematical expression."""
return f"Result: {eval(expression)}" # noqa: S307
llm = ChatOpenAI(model="gpt-5", temperature=0)
allowed_tools_choice = {
"type": "allowed_tools",
"allowed_tools": {
"mode": "required",
"tools": [{"type": "function", "function": {"name": "calculate"}}],
},
}
bound_llm = llm.bind_tools([calculate], tool_choice=allowed_tools_choice)
assert bound_llm is not None
def test_allowed_tools_invalid_mode(self):
"""Test that invalid allowed_tools mode raises error."""
@tool
def test_tool() -> str:
"""Test tool."""
return "test"
llm = ChatOpenAI(model="gpt-5", temperature=0)
invalid_choice = {
"type": "allowed_tools",
"allowed_tools": {"mode": "invalid_mode", "tools": []},
}
with pytest.raises(ValueError, match="allowed_tools mode must be"):
llm.bind_tools([test_tool], tool_choice=invalid_choice)
class TestVerbosityParameter:
"""Test verbosity parameter functionality."""
def test_verbosity_parameter_low(self):
"""Test verbosity parameter with low value."""
llm = ChatOpenAI(model="gpt-5", verbosity="low")
assert llm.verbosity == "low"
assert "verbosity" in llm._default_params
assert llm._default_params["verbosity"] == "low"
def test_verbosity_parameter_medium(self):
"""Test verbosity parameter with medium value."""
llm = ChatOpenAI(model="gpt-5", verbosity="medium")
assert llm.verbosity == "medium"
assert llm._default_params["verbosity"] == "medium"
def test_verbosity_parameter_high(self):
"""Test verbosity parameter with high value."""
llm = ChatOpenAI(model="gpt-5", verbosity="high")
assert llm.verbosity == "high"
assert llm._default_params["verbosity"] == "high"
def test_verbosity_parameter_none(self):
"""Test verbosity parameter with None (default)."""
llm = ChatOpenAI(model="gpt-5")
assert llm.verbosity is None
# When verbosity is None, it may not be included in _default_params
# due to the exclude_if_none filtering
verbosity_param = llm._default_params.get("verbosity")
assert verbosity_param is None
class TestCustomToolStreamingSupport:
"""Test that custom tool streaming events are handled."""
def test_custom_tool_streaming_event_types(self):
"""Test that the new custom tool streaming event types are supported."""
# This test verifies that our code includes the necessary event handling
# The actual streaming event handling is tested in integration tests
# Import the base module to verify it loads without errors
import langchain_openai.chat_models.base as base_module
# Verify the module loaded successfully
assert base_module is not None
# Check that the module contains our custom tool streaming logic
# by looking for the event type strings in the source
import inspect
source = inspect.getsource(base_module)
# Verify our custom tool streaming events are handled
assert "response.custom_tool_call_input.delta" in source
assert "response.custom_tool_call_input.done" in source
class TestMinimalReasoningEffort:
"""Test that minimal reasoning effort is supported."""
def test_minimal_reasoning_effort(self):
"""Test reasoning_effort parameter supports 'minimal'."""
llm = ChatOpenAI(model="gpt-5", reasoning_effort="minimal")
assert llm.reasoning_effort == "minimal"
assert llm._default_params["reasoning_effort"] == "minimal"
def test_all_reasoning_effort_values(self):
"""Test all supported reasoning effort values."""
supported_values = ["minimal", "low", "medium", "high"]
for value in supported_values:
llm = ChatOpenAI(model="gpt-5", reasoning_effort=value)
assert llm.reasoning_effort == value
assert llm._default_params["reasoning_effort"] == value
class TestBackwardCompatibility:
"""Test that existing functionality still works."""
def test_existing_response_formats(self):
"""Test that existing response formats still work."""
llm = ChatOpenAI(model="gpt-5", temperature=0)
# JSON object format should still work
json_llm = llm.bind(response_format={"type": "json_object"})
assert json_llm is not None
# JSON schema format should still work
schema = {
"type": "json_schema",
"json_schema": {
"name": "test_schema",
"schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
"required": ["result"],
},
},
}
schema_llm = llm.bind(response_format=schema)
assert schema_llm is not None
def test_existing_tool_choice(self):
"""Test that existing tool_choice functionality still works."""
@tool
def test_tool(x: int) -> int:
"""Test tool."""
return x * 2
llm = ChatOpenAI(model="gpt-5", temperature=0)
# String tool choice should still work
bound_llm = llm.bind_tools([test_tool], tool_choice="test_tool")
assert bound_llm is not None
# Auto/none/required should still work
for choice in ["auto", "none", "required"]:
bound_llm = llm.bind_tools([test_tool], tool_choice=choice)
assert bound_llm is not None
# Boolean tool choice should still work
bound_llm = llm.bind_tools([test_tool], tool_choice=True)
assert bound_llm is not None

View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.13' and platform_python_implementation == 'PyPy'",
@@ -542,6 +542,7 @@ version = "0.3.29"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
{ name = "lark" },
{ name = "openai" },
{ name = "tiktoken" },
]
@@ -588,7 +589,8 @@ typing = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", editable = "../../core" },
{ name = "openai", specifier = ">=1.99.3,<2.0.0" },
{ name = "lark", specifier = ">=1.1.0" },
{ name = "openai", specifier = ">=1.99.5,<2.0.0" },
{ name = "tiktoken", specifier = ">=0.7,<1" },
]
@@ -688,6 +690,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/f4/c206c0888f8a506404cb4f16ad89593bdc2f70cf00de26a1a0a7a76ad7a3/langsmith-0.3.45-py3-none-any.whl", hash = "sha256:5b55f0518601fa65f3bb6b1a3100379a96aa7b3ed5e9380581615ba9c65ed8ed", size = 363002, upload-time = "2025-06-05T05:10:27.228Z" },
]
[[package]]
name = "lark"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/60/bc7622aefb2aee1c0b4ba23c1446d3e30225c8770b38d7aedbfb65ca9d5a/lark-1.2.2.tar.gz", hash = "sha256:ca807d0162cd16cef15a8feecb862d7319e7a09bdb13aef927968e45040fed80", size = 252132, upload-time = "2024-08-13T19:49:00.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/00/d90b10b962b4277f5e64a78b6609968859ff86889f5b898c1a778c06ec00/lark-1.2.2-py3-none-any.whl", hash = "sha256:c2276486b02f0f1b90be155f2c8ba4a8e194d42775786db622faccd652d8e80c", size = 111036, upload-time = "2024-08-13T19:48:58.603Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -995,7 +1006,7 @@ wheels = [
[[package]]
name = "openai"
version = "1.99.3"
version = "1.99.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1007,9 +1018,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/d3/c372420c8ca1c60e785fd8c19e536cea8f16b0cfdcdad6458e1d8884f2ea/openai-1.99.3.tar.gz", hash = "sha256:1a0e2910e4545d828c14218f2ac3276827c94a043f5353e43b9413b38b497897", size = 504932, upload-time = "2025-08-07T20:35:15.893Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/4a/16b1b6ee8a62cbfb59057f97f6d9b7bb5ce529047d80bc0b406f65dfdc48/openai-1.99.5.tar.gz", hash = "sha256:aa97ac3326cac7949c5e4ac0274c454c1d19c939760107ae0d3948fc26a924ca", size = 505144, upload-time = "2025-08-08T16:44:46.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/bc/e52f49940b4e320629da7db09c90a2407a48c612cff397b4b41b7e58cdf9/openai-1.99.3-py3-none-any.whl", hash = "sha256:c786a03f6cddadb5ee42c6d749aa4f6134fe14fdd7d69a667e5e7ce7fd29a719", size = 785776, upload-time = "2025-08-07T20:35:13.653Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f2/2472ae020f5156a994710bf926a76915c71bc7b5debf7b81a11506ec8414/openai-1.99.5-py3-none-any.whl", hash = "sha256:4e870f9501b7c36132e2be13313ce3c4d6915a837e7a299c483aab6a6d4412e9", size = 786246, upload-time = "2025-08-08T16:44:45.062Z" },
]
[[package]]

30
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.13' and platform_python_implementation == 'PyPy'",
@@ -181,7 +181,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.57.1"
version = "0.62.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -192,9 +192,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/75/6261a1a8d92aed47e27d2fcfb3a411af73b1435e6ae1186da02b760565d0/anthropic-0.57.1.tar.gz", hash = "sha256:7815dd92245a70d21f65f356f33fc80c5072eada87fb49437767ea2918b2c4b0", size = 423775, upload-time = "2025-07-03T16:57:35.932Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/89/d41aa785f724275ff2a3135d4a656ba42c786e7a140973cbd7315dd2d5d2/anthropic-0.62.0.tar.gz", hash = "sha256:d45389229db9e443ea1a877f8d63309947f134991473cf8e88efee322840d084", size = 427073, upload-time = "2025-08-08T13:28:54.411Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/cf/ca0ba77805aec6171629a8b665c7dc224dab374539c3d27005b5d8c100a0/anthropic-0.57.1-py3-none-any.whl", hash = "sha256:33afc1f395af207d07ff1bffc0a3d1caac53c371793792569c5d2f09283ea306", size = 292779, upload-time = "2025-07-03T16:57:34.636Z" },
{ url = "https://files.pythonhosted.org/packages/a1/2f/53d41ff5d8fee7c77030a7fbf3432d0c7db5b799596b7d8e581bcb9a377d/anthropic-0.62.0-py3-none-any.whl", hash = "sha256:adcf2af98aa2b11e3b7c71afb2e0cb0613f679ad4a18ef58c38f17784b3df72e", size = 296625, upload-time = "2025-08-08T13:28:53.042Z" },
]
[[package]]
@@ -2354,7 +2354,7 @@ typing = [
[[package]]
name = "langchain-anthropic"
version = "0.3.17"
version = "0.3.18"
source = { editable = "libs/partners/anthropic" }
dependencies = [
{ name = "anthropic" },
@@ -2364,7 +2364,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.57.0,<1" },
{ name = "anthropic", specifier = ">=0.60.0,<1" },
{ name = "langchain-core", editable = "libs/core" },
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
]
@@ -2483,7 +2483,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "0.3.72"
version = "0.3.74"
source = { editable = "libs/core" }
dependencies = [
{ name = "jsonpatch" },
@@ -2618,7 +2618,7 @@ dependencies = [
[[package]]
name = "langchain-groq"
version = "0.3.6"
version = "0.3.7"
source = { editable = "libs/partners/groq" }
dependencies = [
{ name = "groq" },
@@ -2627,7 +2627,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "groq", specifier = ">=0.29.0,<1" },
{ name = "groq", specifier = ">=0.30.0,<1" },
{ name = "langchain-core", editable = "libs/core" },
]
@@ -2819,10 +2819,11 @@ typing = []
[[package]]
name = "langchain-openai"
version = "0.3.28"
version = "0.3.29"
source = { editable = "libs/partners/openai" }
dependencies = [
{ name = "langchain-core" },
{ name = "lark" },
{ name = "openai" },
{ name = "tiktoken" },
]
@@ -2830,7 +2831,8 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "langchain-core", editable = "libs/core" },
{ name = "openai", specifier = ">=1.86.0,<2.0.0" },
{ name = "lark", specifier = ">=1.1.0" },
{ name = "openai", specifier = ">=1.99.5,<2.0.0" },
{ name = "tiktoken", specifier = ">=0.7,<1" },
]
@@ -3971,7 +3973,7 @@ wheels = [
[[package]]
name = "openai"
version = "1.88.0"
version = "1.99.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -3983,9 +3985,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/ea/bbeef604d1fe0f7e9111745bb8a81362973a95713b28855beb9a9832ab12/openai-1.88.0.tar.gz", hash = "sha256:122d35e42998255cf1fc84560f6ee49a844e65c054cd05d3e42fda506b832bb1", size = 470963, upload-time = "2025-06-17T05:04:45.856Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/4a/16b1b6ee8a62cbfb59057f97f6d9b7bb5ce529047d80bc0b406f65dfdc48/openai-1.99.5.tar.gz", hash = "sha256:aa97ac3326cac7949c5e4ac0274c454c1d19c939760107ae0d3948fc26a924ca", size = 505144, upload-time = "2025-08-08T16:44:46.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/03/ef68d77a38dd383cbed7fc898857d394d5a8b0520a35f054e7fe05dc3ac1/openai-1.88.0-py3-none-any.whl", hash = "sha256:7edd7826b3b83f5846562a6f310f040c79576278bf8e3687b30ba05bb5dff978", size = 734293, upload-time = "2025-06-17T05:04:43.858Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f2/2472ae020f5156a994710bf926a76915c71bc7b5debf7b81a11506ec8414/openai-1.99.5-py3-none-any.whl", hash = "sha256:4e870f9501b7c36132e2be13313ce3c4d6915a837e7a299c483aab6a6d4412e9", size = 786246, upload-time = "2025-08-08T16:44:45.062Z" },
]
[[package]]