Skip to content

Commit 050d062

Browse files
doramirdorNadirclaude
authored
fix(cache): include request-shaping parameters in the prompt cache key (#90) (#91)
The prompt cache key was sha256(model + [role, content]) while the request surface forwarded upstream also carries tools, tool_choice, response_format, reasoning_effort, thinking, max_tokens, temperature, top_p, n, and arbitrary provider extras. Two requests with the same messages therefore shared a cache entry, so a plain chat answer could be served to a later request that asked for structured output or tool calls, and a response generated under a high max_tokens could be served to a request that asked for a low one. The cache sits in front of the provider call, so this silently overrides the client's request contract. - request_cache_params() collects the declared response-shaping fields plus everything in model_extra except stream, and PromptCache.get/put take it as an optional params argument folded into the key. - Message normalization now keeps model_extra (tool_call_id, name, tool_calls), so tool-result turns with identical text no longer collide. - json.dumps uses default=str so a non-serializable extra degrades the key instead of raising. Both call sites in server.py pass the same params dict, so a lookup and the insert that follows it cannot drift. Co-authored-by: Nadir <info@getnadir.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a7ee9d8 commit 050d062

4 files changed

Lines changed: 137 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to NadirClaw will be documented in this file.
44

55
## [Unreleased]
66

7+
### Fixed
8+
- **The prompt cache ignored every request parameter except the model and message text, so it could answer a request with a response that does not satisfy it** (#90). The key was `sha256(model + [role, content])`, while the request surface forwarded upstream also carries `tools`, `tool_choice`, `response_format`, `reasoning_effort`, `thinking`, `max_tokens`, `temperature`, `top_p`, `n`, and arbitrary provider extras. Two requests with the same messages therefore shared a cache entry: a plain chat answer could be served to a later request asking for `response_format: {"type": "json_schema"}` or for `tool_calls`, and a response generated under a high `max_tokens` could be served to a request that asked for a low one — silently overriding the client's contract, since the cache sits in front of the provider call. The key now includes every response-shaping field (`request_cache_params()` collects the declared ones plus everything in `model_extra` except `stream`), and message normalization keeps `tool_call_id` / `name` / `tool_calls` so tool-result turns with identical text no longer collide. Non-serializable extras fall back to `repr` rather than raising.
9+
710
## [0.23.0] - 2026-08-31
811

912
### Added

nadirclaw/cache.py

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,53 @@ def _cache_max_size() -> int:
3131
return int(os.getenv("NADIRCLAW_CACHE_MAX_SIZE", "1000"))
3232

3333

34-
def _make_cache_key(model: str, messages: list) -> str:
35-
"""Build a deterministic cache key from model + messages (ignoring temperature/stream)."""
36-
# Normalize messages to just role + content
37-
normalized = []
38-
for m in messages:
39-
if hasattr(m, "role"):
40-
normalized.append({"role": m.role, "content": m.text_content() if hasattr(m, "text_content") else str(m.content)})
41-
elif isinstance(m, dict):
42-
normalized.append({"role": m.get("role", ""), "content": m.get("content", "")})
43-
else:
44-
normalized.append(str(m))
45-
46-
blob = json.dumps({"model": model or "", "messages": normalized}, sort_keys=True)
34+
# Declared request fields that change the upstream response and so must be part
35+
# of the cache key. Everything else the client sends (tools, tool_choice,
36+
# response_format, reasoning_effort, thinking, provider extras) arrives in
37+
# model_extra and is included wholesale -- see request_cache_params.
38+
_KEYED_FIELDS = ("temperature", "top_p", "max_tokens", "n")
39+
40+
41+
def request_cache_params(request: Any) -> Dict[str, Any]:
42+
"""Extract every request field that shapes the response, for the cache key.
43+
44+
Only ``stream`` is ignored: it changes the transport, not the content, and
45+
the cache is consulted on non-streaming requests only.
46+
"""
47+
params: Dict[str, Any] = {f: getattr(request, f, None) for f in _KEYED_FIELDS}
48+
extra = getattr(request, "model_extra", None) or {}
49+
params.update({k: v for k, v in extra.items() if k != "stream"})
50+
return {k: v for k, v in params.items() if v is not None}
51+
52+
53+
def _normalize_message(m: Any) -> Any:
54+
"""Reduce a message to the parts that affect the response."""
55+
if hasattr(m, "role"):
56+
norm = {
57+
"role": m.role,
58+
"content": m.text_content() if hasattr(m, "text_content") else str(m.content),
59+
}
60+
# tool_call_id / name / tool_calls ride along in model_extra
61+
norm.update(getattr(m, "model_extra", None) or {})
62+
return norm
63+
if isinstance(m, dict):
64+
norm = dict(m)
65+
norm["content"] = m.get("content", "")
66+
return norm
67+
return str(m)
68+
69+
70+
def _make_cache_key(model: str, messages: list, params: Optional[Dict[str, Any]] = None) -> str:
71+
"""Build a deterministic cache key from model + messages + response-shaping params."""
72+
blob = json.dumps(
73+
{
74+
"model": model or "",
75+
"messages": [_normalize_message(m) for m in messages],
76+
"params": params or {},
77+
},
78+
sort_keys=True,
79+
default=str,
80+
)
4781
return hashlib.sha256(blob.encode()).hexdigest()
4882

4983

@@ -58,9 +92,11 @@ def __init__(self, max_size: int | None = None, ttl: int | None = None):
5892
self._hits = 0
5993
self._misses = 0
6094

61-
def get(self, model: str, messages: list) -> Optional[Dict[str, Any]]:
95+
def get(
96+
self, model: str, messages: list, params: Optional[Dict[str, Any]] = None
97+
) -> Optional[Dict[str, Any]]:
6298
"""Look up a cached response. Returns None on miss or expiry."""
63-
key = _make_cache_key(model, messages)
99+
key = _make_cache_key(model, messages, params)
64100
with self._lock:
65101
if key in self._cache:
66102
ts, data = self._cache[key]
@@ -76,9 +112,15 @@ def get(self, model: str, messages: list) -> Optional[Dict[str, Any]]:
76112
self._misses += 1
77113
return None
78114

79-
def put(self, model: str, messages: list, response: Dict[str, Any]) -> None:
115+
def put(
116+
self,
117+
model: str,
118+
messages: list,
119+
response: Dict[str, Any],
120+
params: Optional[Dict[str, Any]] = None,
121+
) -> None:
80122
"""Store a response in the cache."""
81-
key = _make_cache_key(model, messages)
123+
key = _make_cache_key(model, messages, params)
82124
with self._lock:
83125
if key in self._cache:
84126
self._cache.move_to_end(key)

nadirclaw/server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,12 +1587,13 @@ async def chat_completions(
15871587
# ------------------------------------------------------------------
15881588
# Prompt cache — check before calling the model
15891589
# ------------------------------------------------------------------
1590-
from nadirclaw.cache import _cache_enabled, get_prompt_cache
1590+
from nadirclaw.cache import _cache_enabled, get_prompt_cache, request_cache_params
15911591

15921592
prompt_cache = get_prompt_cache()
1593+
cache_params = request_cache_params(request)
15931594
cache_hit = False
15941595
if _cache_enabled() and not request.stream:
1595-
cached_response = prompt_cache.get(selected_model, request.messages)
1596+
cached_response = prompt_cache.get(selected_model, request.messages, cache_params)
15961597
if cached_response is not None:
15971598
response_data = cached_response
15981599
cache_hit = True
@@ -1683,7 +1684,7 @@ async def _true_stream_wrapper():
16831684

16841685
# Store in prompt cache
16851686
if _cache_enabled():
1686-
prompt_cache.put(selected_model, request.messages, response_data)
1687+
prompt_cache.put(selected_model, request.messages, response_data, cache_params)
16871688
else:
16881689
elapsed_ms = int((time.time() - start_time) * 1000)
16891690
total_tokens = response_data["prompt_tokens"] + response_data["completion_tokens"]

tests/test_cache.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import time
44

5-
from nadirclaw.cache import PromptCache, _make_cache_key
5+
from nadirclaw.cache import PromptCache, _make_cache_key, request_cache_params
66

77

88
class TestMakeCacheKey:
@@ -28,6 +28,66 @@ def test_key_is_hex_string(self):
2828
assert isinstance(key, str)
2929
assert len(key) == 64 # sha256 hex
3030

31+
def test_response_shaping_params_change_key(self):
32+
"""Params that change the upstream response must change the key (issue #90)."""
33+
msgs = [{"role": "user", "content": "Return a city name"}]
34+
base = _make_cache_key("gpt-4", msgs)
35+
for params in (
36+
{"response_format": {"type": "json_object"}},
37+
{"tools": [{"type": "function", "function": {"name": "get_weather"}}]},
38+
{"tool_choice": "required"},
39+
{"max_tokens": 16},
40+
{"temperature": 0.9},
41+
{"top_p": 0.1},
42+
{"n": 3},
43+
{"reasoning_effort": "high"},
44+
{"thinking": {"type": "enabled", "budget_tokens": 1024}},
45+
):
46+
assert _make_cache_key("gpt-4", msgs, params) != base, params
47+
48+
def test_same_params_same_key(self):
49+
msgs = [{"role": "user", "content": "hello"}]
50+
params = {"max_tokens": 100, "tools": [{"type": "function"}]}
51+
assert _make_cache_key("gpt-4", msgs, params) == _make_cache_key("gpt-4", msgs, dict(params))
52+
53+
def test_unserializable_param_does_not_raise(self):
54+
msgs = [{"role": "user", "content": "hello"}]
55+
assert len(_make_cache_key("gpt-4", msgs, {"weird": object()})) == 64
56+
57+
def test_tool_call_id_changes_key(self):
58+
"""Tool-result messages with identical text must not collide."""
59+
k1 = _make_cache_key("gpt-4", [{"role": "tool", "content": "ok", "tool_call_id": "a"}])
60+
k2 = _make_cache_key("gpt-4", [{"role": "tool", "content": "ok", "tool_call_id": "b"}])
61+
assert k1 != k2
62+
63+
64+
class TestRequestCacheParams:
65+
def test_extracts_declared_and_extra_fields(self):
66+
class FakeRequest:
67+
temperature = 0.5
68+
top_p = None
69+
max_tokens = 128
70+
n = None
71+
model_extra = {
72+
"tools": [{"type": "function"}],
73+
"response_format": {"type": "json_object"},
74+
"stream": False,
75+
}
76+
77+
params = request_cache_params(FakeRequest())
78+
assert params == {
79+
"temperature": 0.5,
80+
"max_tokens": 128,
81+
"tools": [{"type": "function"}],
82+
"response_format": {"type": "json_object"},
83+
}
84+
85+
def test_missing_attributes_are_tolerated(self):
86+
class Bare:
87+
pass
88+
89+
assert request_cache_params(Bare()) == {}
90+
3191

3292
class TestPromptCache:
3393
def test_put_and_get(self):
@@ -39,6 +99,16 @@ def test_put_and_get(self):
3999
result = cache.get("gpt-4", msgs)
40100
assert result == response
41101

102+
def test_different_params_do_not_share_entry(self):
103+
"""A plain chat response must not be served to a structured-output request."""
104+
cache = PromptCache(max_size=10, ttl=60)
105+
msgs = [{"role": "user", "content": "Return a city name"}]
106+
response = {"content": "Paris", "finish_reason": "stop", "prompt_tokens": 5, "completion_tokens": 1}
107+
108+
cache.put("gpt-4", msgs, response, {})
109+
assert cache.get("gpt-4", msgs, {"response_format": {"type": "json_object"}}) is None
110+
assert cache.get("gpt-4", msgs, {}) == response
111+
42112
def test_miss_returns_none(self):
43113
cache = PromptCache(max_size=10, ttl=60)
44114
result = cache.get("gpt-4", [{"role": "user", "content": "hello"}])

0 commit comments

Comments
 (0)