Skip to content
Closed
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
40 changes: 38 additions & 2 deletions libs/core/langchain_core/prompts/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1304,9 +1304,45 @@ def save(self, file_path: Union[Path, str]) -> None:
"""Save prompt to file.

Args:
file_path: path to file.
file_path: Path to file to save prompt to.

Raises:
ValueError: If the prompt has partial variables.
ValueError: If the file path is not json or yaml.

Example:
.. code-block:: python

prompt.save(file_path="path/prompt.yaml")
"""
raise NotImplementedError
import json

import yaml

from langchain_core.load import dumpd

if self.partial_variables:
msg = "Cannot save prompt with partial variables."
raise ValueError(msg)

# Convert file to Path object.
save_path = Path(file_path)

directory_path = save_path.parent
directory_path.mkdir(parents=True, exist_ok=True)

# Use dumpd for proper serialization
prompt_dict = dumpd(self)

if save_path.suffix == ".json":
with save_path.open("w") as f:
json.dump(prompt_dict, f, indent=4)
elif save_path.suffix.endswith((".yaml", ".yml")):
with save_path.open("w") as f:
yaml.dump(prompt_dict, f, default_flow_style=False)
else:
msg = f"{save_path} must be json or yaml"
raise ValueError(msg)

@override
def pretty_repr(self, html: bool = False) -> str:
Expand Down
18 changes: 13 additions & 5 deletions libs/core/langchain_core/utils/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,17 @@ def _dict_int_op(
raise ValueError(msg)
combined: dict = {}
for k in set(left).union(right):
if isinstance(left.get(k, default), int) and isinstance(
right.get(k, default), int
):
combined[k] = op(left.get(k, default), right.get(k, default))
left_val = left.get(k, default)
right_val = right.get(k, default)

# Handle None values - treat as default (usually 0)
if left_val is None:
left_val = default
if right_val is None:
right_val = default

if isinstance(left_val, int) and isinstance(right_val, int):
combined[k] = op(left_val, right_val)
elif isinstance(left.get(k, {}), dict) and isinstance(right.get(k, {}), dict):
combined[k] = _dict_int_op(
left.get(k, {}),
Expand All @@ -33,7 +40,8 @@ def _dict_int_op(
else:
types = [type(d[k]) for d in (left, right) if k in d]
msg = (
f"Unknown value types: {types}. Only dict and int values are supported."
f"Unknown value types: {types}. "
"Only dict, int, and None values are supported."
)
raise ValueError(msg) # noqa: TRY004
return combined
11 changes: 11 additions & 0 deletions libs/core/tests/unit_tests/messages/test_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ def test_add_usage_with_details() -> None:
assert result["output_token_details"]["reasoning"] == 15


def test_add_usage_with_none_tokens() -> None:
# Test case for issue #26348 - handle None values in token counts
usage1 = {"input_tokens": 10, "output_tokens": None, "total_tokens": None}
usage2 = {"input_tokens": None, "output_tokens": 20, "total_tokens": 30}
# Cast to UsageMetadata to simulate the real scenario
result = add_usage(usage1, usage2) # type: ignore[arg-type]
assert result["input_tokens"] == 10
assert result["output_tokens"] == 20
assert result["total_tokens"] == 30


def test_subtract_usage_both_none() -> None:
result = subtract_usage(None, None)
assert result == UsageMetadata(input_tokens=0, output_tokens=0, total_tokens=0)
Expand Down
128 changes: 128 additions & 0 deletions libs/core/tests/unit_tests/prompts/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,3 +1232,131 @@ def test_dict_message_prompt_template_errors_on_jinja2() -> None:
_ = ChatPromptTemplate.from_messages(
[("human", [prompt])], template_format="jinja2"
)


def test_chat_prompt_template_save_load_json(tmp_path: Path) -> None:
"""Test saving and loading ChatPromptTemplate as JSON."""
import json

template = ChatPromptTemplate.from_messages(
[
("system", "You are a helpful AI assistant named {name}."),
("human", "Hello, how are you {user}?"),
("ai", "I'm doing well, thanks!"),
("human", "{input}"),
]
)

# Save to JSON
json_path = tmp_path / "prompt.json"
template.save(json_path)

# Verify file exists
assert json_path.exists()

# Load and verify
with json_path.open() as f:
data = json.load(f)
loaded_template = load(data)
assert loaded_template == template
assert loaded_template.format_messages(
name="Bob", user="Alice", input="What's your name?"
) == template.format_messages(name="Bob", user="Alice", input="What's your name?")


def test_chat_prompt_template_save_load_yaml(tmp_path: Path) -> None:
"""Test saving and loading ChatPromptTemplate as YAML."""
template = ChatPromptTemplate.from_messages(
[
SystemMessage(content="You are helpful"),
("human", "{question}"),
MessagesPlaceholder("history"),
]
)

# Save to YAML
yaml_path = tmp_path / "prompt.yaml"
template.save(yaml_path)

# Verify file exists
assert yaml_path.exists()

# Load and verify
import yaml

with yaml_path.open() as f:
loaded_data = yaml.safe_load(f)
loaded_template = load(loaded_data)
assert loaded_template == template


def test_chat_prompt_template_save_with_messages_placeholder(tmp_path: Path) -> None:
"""Test saving ChatPromptTemplate with MessagesPlaceholder."""
import json

# Use non-optional placeholder to avoid partial variables
template = ChatPromptTemplate.from_messages(
[
("system", "You are an assistant"),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
]
)

# Save to JSON
json_path = tmp_path / "prompt_with_placeholder.json"
template.save(json_path)

# Load and verify
with json_path.open() as f:
data = json.load(f)
loaded_template = load(data)
assert loaded_template == template

# Test formatting with the placeholder
assert loaded_template.format_messages(
input="Hello",
chat_history=[HumanMessage(content="Hi"), AIMessage(content="Hello!")],
) == [
SystemMessage(content="You are an assistant"),
HumanMessage(content="Hi"),
AIMessage(content="Hello!"),
HumanMessage(content="Hello"),
]


def test_chat_prompt_template_save_invalid_extension(tmp_path: Path) -> None:
"""Test that saving with invalid extension raises error."""
template = ChatPromptTemplate.from_messages([("human", "Hello {name}")])

# Try to save with invalid extension
invalid_path = tmp_path / "prompt.txt"
with pytest.raises(ValueError, match="must be json or yaml"):
template.save(invalid_path)


def test_chat_prompt_template_save_with_partial_variables(tmp_path: Path) -> None:
"""Test that saving with partial variables raises error."""
template = ChatPromptTemplate.from_messages(
[("human", "Hello {name}, today is {day}")]
)
# Add partial variables
template_with_partial = template.partial(day="Monday")

# Should raise error when trying to save with partial variables
json_path = tmp_path / "prompt.json"
with pytest.raises(ValueError, match="Cannot save prompt with partial variables"):
template_with_partial.save(json_path)


def test_chat_prompt_template_save_creates_directories(tmp_path: Path) -> None:
"""Test that save creates parent directories if they don't exist."""
template = ChatPromptTemplate.from_messages([("human", "Hello")])

# Save to nested path that doesn't exist
nested_path = tmp_path / "nested" / "dir" / "prompt.json"
template.save(nested_path)

# Verify file and directories were created
assert nested_path.exists()
assert nested_path.parent.exists()
18 changes: 17 additions & 1 deletion libs/core/tests/unit_tests/utils/test_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,27 @@ def test_dict_int_op_max_depth_exceeded() -> None:
_dict_int_op(left, right, operator.add, max_depth=2)


def test_dict_int_op_with_none_values() -> None:
# Test None values are treated as default (0)
left = {"a": 1, "b": None, "c": 3}
right = {"a": 2, "b": 3, "c": None}
result = _dict_int_op(left, right, operator.add)
assert result == {"a": 3, "b": 3, "c": 3}


def test_dict_int_op_nested_with_none() -> None:
# Test nested dicts with None values
left = {"a": 1, "b": {"c": None, "d": 3}}
right = {"a": None, "b": {"c": 2, "d": None}}
result = _dict_int_op(left, right, operator.add)
assert result == {"a": 1, "b": {"c": 2, "d": 3}}


def test_dict_int_op_invalid_types() -> None:
left = {"a": 1, "b": "string"}
right = {"a": 2, "b": 3}
with pytest.raises(
ValueError,
match="Only dict and int values are supported.",
match="Only dict, int, and None values are supported.",
):
_dict_int_op(left, right, operator.add)
4 changes: 3 additions & 1 deletion libs/partners/openai/langchain_openai/chat_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3875,7 +3875,9 @@ def _construct_lc_result_from_responses_api(
"annotations": [
annotation.model_dump()
for annotation in content.annotations
],
]
if isinstance(content.annotations, list)
else [],
"id": output.id,
}
content_blocks.append(block)
Expand Down