With restore: true and the answer cache on, alice's "write to alice@example.com" and bob's "write to bob@example.com" redact to the same request, and bob was given the answer kept for alice, her address put back in it. A request with restore values is now never looked up in the cache nor kept in it. The similar-question cache compared the last message's text and blanked its parts: two pictures asked about in the same words were one question. A last message with any part that is not text is now reused only when the request is the same. Found in review; both tests fail without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03d3bcfd2d
commit
7a04248148
6 files changed
+87
-4
No files matched your search
@@ -37,6 +37,17 @@ APIs change between minors without a deprecation period.
|
||||
this version refuses those weights until its control plane can say
|
||||
`allow_pickle`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A reused answer could carry another caller's personal data.** With
|
||||
`restore: true` and the answer cache on, two callers' requests that
|
||||
redacted to the same text shared one answer — with the first caller's
|
||||
address put back in it. Such requests are now never answered from the
|
||||
cache nor kept in it.
|
||||
- **Similar questions about different pictures got each other's answer.**
|
||||
A question with a picture, a sound or a file in it is reused only when it
|
||||
is the same request.
|
||||
|
||||
### Added
|
||||
|
||||
- **And on a web page, `/admin/mcp`.** For administrators: the same list,
|
||||
|
||||
+6
-4
@@ -2155,8 +2155,10 @@ llmgw keys cache --project Acme/ml/train --off
|
||||
- **Policies still apply:** the limits are checked and the guardrails judge
|
||||
the request before the cache is looked in, and the output policy judges a
|
||||
reused answer as it would a new one. A request continuing a stored
|
||||
conversation, or under a `no_store` policy, is never answered from the
|
||||
cache nor kept in it.
|
||||
conversation, under a `no_store` policy, or whose redacted values are put
|
||||
back in its answer (`restore: true`) is never answered from the cache nor
|
||||
kept in it: redacted, two callers' requests read the same, and the answer
|
||||
has the first caller's values in it.
|
||||
- **What a hit costs:** nothing. It is recorded as a request — status 200,
|
||||
the model, the key, the rate limit's count — with no tokens and no cost,
|
||||
marked as a cache hit (`llmgw ctl request <id>` says so), so it spends no
|
||||
@@ -2196,8 +2198,8 @@ router:
|
||||
`router.cache.embedding_model`, against the questions already answered
|
||||
in the same conversation — the same system prompt, the same earlier turns,
|
||||
tools, `response_format` and sampling settings, the same model and
|
||||
project. A like question in another conversation, or after a tool's
|
||||
result, is not taken for this one. The exact cache is asked first, and an
|
||||
project. A like question in another conversation, after a tool's result,
|
||||
or with a picture, a sound or a file in it, is not taken for this one. The exact cache is asked first, and an
|
||||
exact hit embeds nothing.
|
||||
- **How alike:** the cosine similarity of the two embeddings, at least the
|
||||
project's `--similar`. 0.95 is strict; below 0.9 different questions start
|
||||
|
||||
@@ -313,6 +313,12 @@ func reusable(req *parsedRequest, anyAnswer bool) bool {
|
||||
if req.knowledge || req.Header.Get(knowledgeHeader) != "" {
|
||||
return false
|
||||
}
|
||||
// An answer with the caller's own values put back in it (restore: true)
|
||||
// is theirs alone: another caller's request, redacted, reads the same,
|
||||
// and would be given the first caller's address.
|
||||
if len(req.guardIn.Restore) > 0 {
|
||||
return false
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(req.Header.Get(cacheHeader))); v == "no" || v == "off" || v == "bypass" {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -108,3 +109,42 @@ func TestARestorerHoldsOnlyWhatCouldBeAPlaceholder(t *testing.T) {
|
||||
t.Errorf("OnEnd: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// anyEmailGuard redacts whichever address a request has to [EMAIL_1], and
|
||||
// asks for it back: two callers' requests then read the same.
|
||||
type anyEmailGuard struct{}
|
||||
|
||||
var anEmail = regexp.MustCompile(`[a-z]+@example\.com`)
|
||||
|
||||
func (anyEmailGuard) Input(_ context.Context, _ guard.Subject, body []byte) (guard.Verdict, error) {
|
||||
addr := anEmail.Find(body)
|
||||
if addr == nil {
|
||||
return guard.Verdict{}, nil
|
||||
}
|
||||
return guard.Verdict{Action: guard.Redact, Policy: "pii", Category: "email",
|
||||
Body: anEmail.ReplaceAll(body, []byte("[EMAIL_1]")),
|
||||
Restore: map[string]string{"[EMAIL_1]": string(addr)}}, nil
|
||||
}
|
||||
func (anyEmailGuard) Output(context.Context, guard.Subject) guard.OutputFilter { return nil }
|
||||
func (anyEmailGuard) Directives(guard.Subject) guard.Directives { return guard.Directives{} }
|
||||
|
||||
func TestAnAnswerWithACallersValuesPutBackIsNeverReusedForAnother(t *testing.T) {
|
||||
// Redacted, alice's request and bob's read the same; the answer alice
|
||||
// got has her address in it again, and must not be bob's.
|
||||
srv := echoEngine(t)
|
||||
rt, secret := cachingRouter(t, srv.URL, false)
|
||||
rt.SetGuard(anyEmailGuard{})
|
||||
hdr := map[string]string{"Authorization": "Bearer " + secret}
|
||||
|
||||
first := post(t, rt.Handler(), ask(0, "write to alice@example.com"), hdr)
|
||||
second := post(t, rt.Handler(), ask(0, "write to bob@example.com"), hdr)
|
||||
if strings.Contains(second.Body.String(), "alice") {
|
||||
t.Fatalf("bob was given alice's answer: %s", second.Body.String())
|
||||
}
|
||||
if !strings.Contains(second.Body.String(), "bob@example.com") {
|
||||
t.Errorf("bob's answer lacks his own address: %s", second.Body.String())
|
||||
}
|
||||
if got := first.Header().Get(cacheHeader) + "/" + second.Header().Get(cacheHeader); got != "bypass/bypass" {
|
||||
t.Errorf("x-llmgw-cache %s, want bypass for both", got)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,16 @@ func similarGroup(project, path, model string, body []byte) (group, question str
|
||||
if last == nil || last["role"] != "user" {
|
||||
return "", "", false
|
||||
}
|
||||
// A picture, a sound or a file is part of the question, and the text
|
||||
// beside it is not all of it: "what is in this picture?" asked of two
|
||||
// pictures is two questions. Reused only when the same, exactly.
|
||||
if parts, isList := last["content"].([]any); isList {
|
||||
for _, p := range parts {
|
||||
if part, _ := p.(map[string]any); part == nil || part["type"] != "text" {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
question = lastUserText(body)
|
||||
last["content"] = ""
|
||||
group, ok = keyOf(project, path+"#similar", model, m)
|
||||
|
||||
@@ -148,3 +148,17 @@ func TestTheConversationAQuestionIsAskedInIsAllButItsLastMessage(t *testing.T) {
|
||||
t.Error("a tool's result is taken for a question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAQuestionAboutAPictureIsNotTakenForAnotherPicture(t *testing.T) {
|
||||
pic := func(url string) []byte {
|
||||
b, _ := json.Marshal(map[string]any{"model": "m", "temperature": 0, "messages": []any{map[string]any{
|
||||
"role": "user", "content": []any{
|
||||
map[string]any{"type": "text", "text": "what is in this picture?"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}},
|
||||
}}}})
|
||||
return b
|
||||
}
|
||||
if _, _, ok := similarGroup("p", "/v1/chat/completions", "m", pic("data:image/png;base64,AAAA")); ok {
|
||||
t.Error("a question with a picture in it is compared by its text alone")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user