Repository: stanfordnlp/dspy

Issue:
[Bug] forcing `additionalProperties` to false in JSONAdapter breaks caching for gemini
### What happened?

When using JSONAdapter, the `additionalProperties: false` parameter is always added to pydantic schema of the response format parameter (either [here](https://github.com/stanfordnlp/dspy/blob/main/dspy/adapters/json_adapter.py#L222) or [here](https://github.com/stanfordnlp/dspy/blob/main/dspy/adapters/json_adapter.py#L233-L260)). 

This is fine when calling OpenAI API, but LiteLLM removes the property in the request when sending it to gemini, see [here](https://github.com/BerriAI/litellm/blob/main/litellm/utils.py#L2727-L2747).

Therefore the cache key is changed between `get` and `put` calls (since it recomputed) as LiteLLM.completion changes the model schema.

Couldn't the key be generated once instead of recomputed for both `get` and `put` calls [here](https://github.com/stanfordnlp/dspy/blob/main/dspy/clients/cache.py#L220-L237)?


### Steps to reproduce

You can see this by inspecting parameters sent to `cache.cache_key` before and after call, for instance 

Before you get 
```
'response_format': {'properties': {'reasoning': {'title': 'Reasoning', 'type': 'string'}, 'test': {'title': 'Test', 'type': 'string'}}, 'required': ['reasoning', 'test'], 'title': 'DSPyProgramOutputs', 'type': 'object', 'additionalProperties': False}
```

And after call to completion
```
'response_format': {'properties': {'reasoning': {'title': 'Reasoning', 'type': 'string'}, 'test': {'title': 'Test', 'type': 'string'}}, 'required': ['reasoning', 'test'], 'title': 'DSPyProgramOutputs', 'type': 'object'}
```

### DSPy version

latest


Discussion:

Patch:
diff --git a/dspy/clients/cache.py b/dspy/clients/cache.py
index bfa7dbde8b..20b507351c 100644
--- a/dspy/clients/cache.py
+++ b/dspy/clients/cache.py
@@ -30,7 +30,6 @@ def __init__(
         disk_cache_dir: str,
         disk_size_limit_bytes: Optional[int] = 1024 * 1024 * 10,
         memory_max_entries: Optional[int] = 1000000,
-
     ):
         """
         Args:
@@ -230,9 +229,11 @@ def sync_wrapper(*args, **kwargs):
                 return cached_result
 
             # Otherwise, compute and store the result
+            # Make a copy of the original request in case it's modified in place, e.g., deleting some fields
+            original_request = copy.deepcopy(modified_request)
             result = fn(*args, **kwargs)
             # `enable_memory_cache` can be provided at call time to avoid indefinite growth.
-            cache.put(modified_request, result, ignored_args_for_cache_key, enable_memory_cache)
+            cache.put(original_request, result, ignored_args_for_cache_key, enable_memory_cache)
 
             return result
 
@@ -249,8 +250,10 @@ async def async_wrapper(*args, **kwargs):
                 return cached_result
 
             # Otherwise, compute and store the result
+            # Make a copy of the original request in case it's modified in place, e.g., deleting some fields
+            original_request = copy.deepcopy(modified_request)
             result = await fn(*args, **kwargs)
-            cache.put(modified_request, result, ignored_args_for_cache_key, enable_memory_cache)
+            cache.put(original_request, result, ignored_args_for_cache_key, enable_memory_cache)
 
             return result
 


Test patch:
diff --git a/tests/clients/test_cache.py b/tests/clients/test_cache.py
index edff1485d0..42a1213957 100644
--- a/tests/clients/test_cache.py
+++ b/tests/clients/test_cache.py
@@ -280,3 +280,40 @@ async def test_function(prompt, model):
         # Call with different arguments should compute again
         result3 = await test_function(prompt="Different", model="openai/gpt-4o-mini")
         assert result3 == "Response for Different with openai/gpt-4o-mini"
+
+
+def test_cache_consistency_with_lm_call_modifies_the_request(cache):
+    """Test that the cache is consistent with the LM call that modifies the request."""
+    from dspy.clients.cache import request_cache
+
+    # Mock the dspy.cache attribute
+    with patch("dspy.cache", cache):
+        # Define a test function
+        @request_cache()
+        def test_function(**kwargs):
+            del kwargs["field_to_delete"]
+            return kwargs
+
+        # First call should compute the result
+        test_function(field_to_delete="delete", field_to_keep="keep")
+
+        # The cache key should use the original request, not the modified one
+        assert (
+            cache.get(
+                {
+                    "field_to_keep": "keep",
+                    "_fn_identifier": f"{test_function.__module__}.{test_function.__qualname__}",
+                }
+            )
+            is None
+        )
+        assert (
+            cache.get(
+                {
+                    "field_to_keep": "keep",
+                    "field_to_delete": "delete",
+                    "_fn_identifier": f"{test_function.__module__}.{test_function.__qualname__}",
+                }
+            )
+            is not None
+        )