Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
72 changes: 72 additions & 0 deletions llm/transformer/openai/responses/cache_anchor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package responses

import (
"crypto/sha256"
"encoding/hex"

"github.com/looplj/axonhub/llm"
)

// anchorMaxBytes bounds how much of the conversation head is hashed.
const anchorMaxBytes = 8192

// conversationAnchor derives a stable fingerprint for the conversation a
// request belongs to: the contiguous leading system/developer messages plus
// the first user message. Later turns of the same conversation keep this head
// unchanged, while sibling conversations multiplexed over one client session
// (e.g. Claude Code subagents sharing a session_id) diverge in their first
// user message. Combining this anchor with the session ID therefore yields a
// per-conversation prompt_cache_key instead of a per-session one.
func conversationAnchor(messages []llm.Message) string {
h := sha256.New()
written := 0

write := func(s string) {
if written >= anchorMaxBytes {
return
}

if remaining := anchorMaxBytes - written; len(s) > remaining {
s = s[:remaining]
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

h.Write([]byte(s))
written += len(s)
}

hashed := false

for _, msg := range messages {
if msg.Role != "system" && msg.Role != "developer" && msg.Role != "user" {
break
}

write(msg.Role)
write("\x00")

if msg.Content.Content != nil {
write(*msg.Content.Content)
}

for _, part := range msg.Content.MultipleContent {
if part.Text != nil {
write(*part.Text)
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

write("\x1e")

hashed = true

// The first user message ends the stable conversation head.
if msg.Role == "user" || written >= anchorMaxBytes {
break
}
}

if !hashed {
return ""
}

return hex.EncodeToString(h.Sum(nil))[:16]
}
7 changes: 7 additions & 0 deletions llm/transformer/openai/responses/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,13 @@ func (t *OutboundTransformer) TransformRequest(ctx context.Context, llmReq *llm.

if lo.FromPtr(payload.PromptCacheKey) == "" {
if sessionID, ok := shared.GetSessionID(ctx); ok {
// A session may multiplex several concurrent conversations
// (e.g. Claude Code subagents); scope the cache key to the
// conversation so they do not evict each other upstream.
if anchor := conversationAnchor(llmReq.Messages); anchor != "" {
sessionID = sessionID + "-" + anchor
}

payload.PromptCacheKey = lo.ToPtr(sessionID)
}
}
Expand Down
47 changes: 46 additions & 1 deletion llm/transformer/openai/responses/outbound_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,52 @@ func TestOutboundTransformer_TransformRequest_UsesSharedSessionIDAsPromptCacheKe
err = json.Unmarshal(httpReq.Body, &payload)
require.NoError(t, err)
require.NotNil(t, payload.PromptCacheKey)
require.Equal(t, "shared-session-123", *payload.PromptCacheKey)
require.Equal(t, "shared-session-123-"+conversationAnchor(req.Messages), *payload.PromptCacheKey)
}

func TestOutboundTransformer_TransformRequest_PromptCacheKeyScopedPerConversation(t *testing.T) {
transformer, err := NewOutboundTransformer("https://api.openai.com", "test-api-key")
require.NoError(t, err)

ctx := shared.WithSessionID(context.Background(), "shared-session-123")

newReq := func(firstUser string, extraTurns ...llm.Message) *llm.Request {
messages := []llm.Message{
{Role: "system", Content: llm.MessageContent{Content: lo.ToPtr("You are an agent.")}},
{Role: "user", Content: llm.MessageContent{Content: lo.ToPtr(firstUser)}},
}
messages = append(messages, extraTurns...)

return &llm.Request{Model: "gpt-5.4", Messages: messages}
}

cacheKey := func(req *llm.Request) string {
httpReq, err := transformer.TransformRequest(ctx, req)
require.NoError(t, err)

var payload Request

require.NoError(t, json.Unmarshal(httpReq.Body, &payload))
require.NotNil(t, payload.PromptCacheKey)

return *payload.PromptCacheKey
}

// Later turns of the same conversation keep the same cache key.
turn1 := cacheKey(newReq("task A"))
turn2 := cacheKey(newReq("task A",
llm.Message{Role: "assistant", Content: llm.MessageContent{Content: lo.ToPtr("working")}},
llm.Message{Role: "user", Content: llm.MessageContent{Content: lo.ToPtr("continue")}},
))
require.Equal(t, turn1, turn2)

// Sibling conversations in the same session get distinct cache keys.
require.NotEqual(t, turn1, cacheKey(newReq("task B")))

// Client-provided keys are preserved untouched.
explicit := newReq("task A")
explicit.PromptCacheKey = lo.ToPtr("client-key")
require.Equal(t, "client-key", cacheKey(explicit))
}

func TestOutboundTransformer_TransformResponse(t *testing.T) {
Expand Down