Skip to content

Commit 74793bc

Browse files
authored
fix: strip provider-specific IDs from Responses API content blocks (#170)
The v0.3.8 fix (#168) only cleared additional_kwargs and response_metadata, but when using the OpenAI Responses API (use_responses_api=True), the rs_ IDs live inside content blocks: content=[{"type": "text", "text": "...", "id": "rs_abc123", ...}] langchain_openai extracts these block-level IDs and passes them as Responses API item IDs. When both original and cached messages share the same rs_ IDs in checkpoint state, the next LLM call fails with "Duplicate item found with id rs_...". Changes: - Add _strip_content_ids() helper to remove 'id' fields from content blocks when content is a list (semantic_cache.py) - Apply it during cache deserialization in _deserialize_response() - Add _sanitize_request() safety net in composition.py that strips provider IDs from all AIMessages before they reach the LLM handler, protecting against stale checkpoint data from before the fix - Wrap MiddlewareStack.awrap_model_call handler with sanitization - Add integration tests with realistic Responses API message format
1 parent cba5b70 commit 74793bc

4 files changed

Lines changed: 1017 additions & 3 deletions

File tree

langgraph/middleware/redis/composition.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
ModelRequest,
1616
ModelResponse,
1717
)
18+
from langchain_core.messages import AIMessage
1819
from langchain_core.messages import ToolMessage as LangChainToolMessage
1920
from langgraph.prebuilt.tool_node import ToolCallRequest
2021
from langgraph.types import Command
2122
from redis.asyncio import Redis as AsyncRedis
2223

2324
from .aio import AsyncRedisMiddleware
2425
from .conversation_memory import ConversationMemoryMiddleware
25-
from .semantic_cache import SemanticCacheMiddleware
26+
from .semantic_cache import SemanticCacheMiddleware, _strip_content_ids
2627
from .semantic_router import SemanticRouterMiddleware
2728
from .tool_cache import ToolResultCacheMiddleware
2829
from .types import (
@@ -36,6 +37,61 @@
3637
logger = logging.getLogger(__name__)
3738

3839

40+
def _sanitize_request(request: ModelRequest) -> ModelRequest:
41+
"""Strip provider-specific IDs from AIMessages before sending to LLM.
42+
43+
This is a safety net for stale checkpoint data: messages stored before
44+
the cache-deserialization fix may still carry provider IDs (rs_, msg_
45+
prefixes) in content blocks, additional_kwargs, or response_metadata.
46+
Cleaning them here prevents "Duplicate item found" errors from the
47+
OpenAI Responses API.
48+
"""
49+
if isinstance(request, dict):
50+
messages = request.get("messages")
51+
else:
52+
messages = getattr(request, "messages", None)
53+
54+
if not messages:
55+
return request
56+
57+
cleaned = []
58+
changed = False
59+
for msg in messages:
60+
if isinstance(msg, AIMessage):
61+
new_content = _strip_content_ids(msg.content)
62+
content_changed = new_content is not msg.content
63+
has_extra_kwargs = (
64+
msg.additional_kwargs
65+
and msg.additional_kwargs != {"cached": True}
66+
and set(msg.additional_kwargs.keys()) != {"cached"}
67+
)
68+
has_metadata = bool(msg.response_metadata)
69+
70+
if content_changed or has_extra_kwargs or has_metadata:
71+
# Preserve the cached marker if present
72+
new_kwargs = (
73+
{"cached": True} if msg.additional_kwargs.get("cached") else {}
74+
)
75+
msg = msg.model_copy(
76+
update={
77+
"content": new_content,
78+
"additional_kwargs": new_kwargs,
79+
"response_metadata": {},
80+
}
81+
)
82+
changed = True
83+
cleaned.append(msg)
84+
85+
if not changed:
86+
return request
87+
88+
if isinstance(request, dict):
89+
return {**request, "messages": cleaned}
90+
else:
91+
# ModelRequest is a dataclass — use its override() method
92+
return request.override(messages=cleaned)
93+
94+
3995
class MiddlewareStack(AgentMiddleware):
4096
"""A stack of middleware that chains calls through all middlewares.
4197
@@ -99,8 +155,14 @@ async def awrap_model_call(
99155
return await handler(request)
100156

101157
# Build the chain from inside out
102-
# Start with the final handler
103-
current_handler = handler
158+
# Wrap the final handler with request sanitization to strip
159+
# provider-specific IDs from AIMessages before they reach the LLM
160+
async def sanitized_handler(req: ModelRequest) -> ModelCallResult:
161+
return await handler(_sanitize_request(req))
162+
163+
current_handler: Callable[[ModelRequest], Awaitable[ModelResponse]] = (
164+
sanitized_handler
165+
)
104166

105167
# Wrap from last to first middleware
106168
for middleware in reversed(self._middlewares):

langgraph/middleware/redis/semantic_cache.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@
2929
_serializer = JsonPlusRedisSerializer()
3030

3131

32+
def _strip_content_ids(content: Any) -> Any:
33+
"""Strip provider-specific IDs from content blocks.
34+
35+
When using the OpenAI Responses API, content is a list of blocks with
36+
embedded item IDs (rs_, msg_ prefixes). These must be removed from cached
37+
messages to prevent duplicate ID errors.
38+
"""
39+
if not isinstance(content, list):
40+
return content
41+
stripped = []
42+
for block in content:
43+
if isinstance(block, dict) and "id" in block:
44+
stripped.append({k: v for k, v in block.items() if k != "id"})
45+
else:
46+
stripped.append(block)
47+
return stripped
48+
49+
3250
def _serialize_response(response: Any) -> str:
3351
"""Serialize a model response for cache storage.
3452
@@ -101,6 +119,7 @@ def _deserialize_response(cached_str: str) -> ModelResponse:
101119
cached_message = revived.model_copy(
102120
update={
103121
"id": new_message_id,
122+
"content": _strip_content_ids(revived.content),
104123
"additional_kwargs": {"cached": True},
105124
"response_metadata": {},
106125
}

0 commit comments

Comments
 (0)