From 88cb79d8b3e52d845c4b16f68361718e05dbfb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E8=B5=B7?= <3219378872@qq.com> Date: Fri, 28 Aug 2026 21:56:22 +0800 Subject: [PATCH] fix(grok): honor client prompt_cache_key over X-Grok-Conv-Id for cache identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grok-build CLI recap-style side-calls (turn-summary / title-refresh) send a fresh per-call X-Grok-Conv-Id label but set body prompt_cache_key to the parent session id, so a direct xAI connection shares the main turn's server-side prompt cache prefix. sub2api's explicitGrokCacheSeed preferred X-Grok-Conv-Id over the body key, which fragmented the cache identity on every side-call and forced a full-price replay of the whole conversation (~300K+ tokens each, ~2.3% cache hit rate observed over 44 side-calls / 4.9M input tokens in 7 days — about a third of all grok-4.6 input). Priority is now: CC session > generic session headers > body prompt_cache_key > X-Grok-Conv-Id > explicitKey > previous_response_id. IDE header-stability tests updated; new regression test locks in the side-call/parent key-sharing contract. --- .../service/openai_gateway_grok_cache.go | 15 ++++- .../service/openai_gateway_grok_cache_test.go | 61 ++++++++++++++++++- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index 321f2d0faf..143b173856 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -109,12 +109,21 @@ func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) stri if seed == "" { seed = explicitOpenAIHeaderSessionID(c) } - if seed == "" && c != nil { - seed = strings.TrimSpace(c.GetHeader(grokConversationIDHeader)) - } + // Client-declared prompt_cache_key outranks X-Grok-Conv-Id. The + // grok-build CLI sets this field on recap-style side-calls + // (turn-summary/title-refresh) to the *parent* session id so the + // side-call shares the main turn's server-side cache prefix. Its + // X-Grok-Conv-Id header, in contrast, carries a fresh per-call label + // ("turn-summary-"); preferring the header there fragments the + // cache identity per side-call and forces a full-price replay of the + // entire conversation (~300K+ tokens each time). The body field is the + // official xAI cache-routing signal — respect it when present. if seed == "" && len(body) > 0 { seed = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) } + if seed == "" && c != nil { + seed = strings.TrimSpace(c.GetHeader(grokConversationIDHeader)) + } if seed == "" { seed = strings.TrimSpace(explicitKey) } diff --git a/backend/internal/service/openai_gateway_grok_cache_test.go b/backend/internal/service/openai_gateway_grok_cache_test.go index 4cc3521c38..6b61f4e845 100644 --- a/backend/internal/service/openai_gateway_grok_cache_test.go +++ b/backend/internal/service/openai_gateway_grok_cache_test.go @@ -3,6 +3,7 @@ package service import ( + "fmt" "net/http" "net/http/httptest" "sync" @@ -192,12 +193,55 @@ func TestResolveGrokCacheIdentityIDEHeaderPriority(t *testing.T) { got := resolveGrokCacheIdentity(c, body, "explicit-argument", "grok-4.5") onlyCurrent := newGrokCacheTestContext(402) onlyCurrent.Request.Header.Set(header.name, header.value) - want := resolveGrokCacheIdentity(onlyCurrent, []byte(`{"model":"grok","input":"unrelated"}`), "", "grok-4.5") - require.Equal(t, want, got, header.name) + if header.name == grokConversationIDHeader { + // prompt_cache_key beats X-Grok-Conv-Id: verify the identity is + // derived from the body key, not the per-call conv label. + withOnlyBody := newGrokCacheTestContext(402) + want := resolveGrokCacheIdentity(withOnlyBody, body, "", "grok-4.5") + require.Equal(t, want, got, header.name) + withOnlyConv := newGrokCacheTestContext(402) + withOnlyConv.Request.Header.Set(header.name, header.value) + convOnly := resolveGrokCacheIdentity(withOnlyConv, []byte(`{"model":"grok","input":"unrelated"}`), "", "grok-4.5") + require.NotEqual(t, convOnly, got, "body key must not be shadowed by X-Grok-Conv-Id") + } else { + want := resolveGrokCacheIdentity(onlyCurrent, body, "", "grok-4.5") + require.Equal(t, want, got, header.name) + } c.Request.Header.Del(header.name) } } +// TestResolveGrokCacheIdentitySideCallSharesParentCacheKey locks in the +// grok-build side-call fix: recap-style side-calls (turn-summary / +// title-refresh) send a fresh X-Grok-Conv-Id label but the parent session id +// as body prompt_cache_key. The derived identity must follow the body key so +// side-calls share the main turn's server-side cache prefix instead of +// replaying the full conversation at full price on every call. +func TestResolveGrokCacheIdentitySideCallSharesParentCacheKey(t *testing.T) { + gin.SetMode(gin.TestMode) + parentSession := "6f1c2f46-0f5e-4f9d-9d4e-2f0f1c3d5b7a" + + mainTurn := newGrokCacheTestContext(910) + mainTurn.Request.Header.Set(grokConversationIDHeader, parentSession) + mainIdentity := resolveGrokCacheIdentity(mainTurn, []byte(`{"model":"grok-4.6","input":"main conversation"}`), "", "grok-4.6") + require.NotEmpty(t, mainIdentity) + + sideCall := newGrokCacheTestContext(910) + sideCall.Request.Header.Set(grokConversationIDHeader, "turn-summary-"+uuidNew()) + sideIdentity := resolveGrokCacheIdentity(sideCall, []byte(`{"model":"grok-4.6","prompt_cache_key":"`+parentSession+`","input":"summary replay"}`), "", "grok-4.6") + require.Equal(t, mainIdentity, sideIdentity, + "side-call with parent prompt_cache_key must share the main turn cache identity") + + titleRefresh := newGrokCacheTestContext(910) + titleRefresh.Request.Header.Set(grokConversationIDHeader, "title-refresh-"+uuidNew()) + titleIdentity := resolveGrokCacheIdentity(titleRefresh, []byte(`{"model":"grok-4.6","prompt_cache_key":"`+parentSession+`","input":"title replay"}`), "", "grok-4.6") + require.Equal(t, mainIdentity, titleIdentity) +} + +func uuidNew() string { + return fmt.Sprintf("%08x-%04x-4%03x-9%03x-%012x", 0x1234, 0x5678, 0x9abc, 0xdef0, 0x1234567890ab) +} + func TestExplicitGrokCacheSeedPriority(t *testing.T) { gin.SetMode(gin.TestMode) c := newGrokCacheTestContext(403) @@ -220,7 +264,18 @@ func TestExplicitGrokCacheSeedPriority(t *testing.T) { body := []byte(`{"model":"grok","prompt_cache_key":"body-key","input":"hi"}`) for _, header := range headers { - require.Equal(t, header.value, explicitGrokCacheSeed(c, body, "explicit-argument"), header.name) + var want string + switch header.name { + case grokConversationIDHeader: + // Client-declared prompt_cache_key outranks X-Grok-Conv-Id: the + // grok-build CLI pairs a fresh per-side-call conv label with the + // stable parent session id in the body field, and the body field + // is the official xAI cache-routing signal. + want = "body-key" + default: + want = header.value + } + require.Equal(t, want, explicitGrokCacheSeed(c, body, "explicit-argument"), header.name) c.Request.Header.Del(header.name) } require.Equal(t, "body-key", explicitGrokCacheSeed(c, body, "explicit-argument"))