Files
sub2api/backend/internal/service/openai_gateway_grok_cache.go
T

150 lines
4.9 KiB
Go

package service
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
grokConversationIDHeader = "X-Grok-Conv-Id"
grokFreeCacheNativeToolsJSON = `[{"type":"web_search"},{"type":"x_search"}]`
grokFreeCacheDisabledToolChoice = "none"
)
// resolveGrokCacheIdentity derives one stable, tenant-isolated routing identity
// for xAI's server-side prompt cache. The returned value is safe to expose to
// the upstream: it never contains the client's raw session identifier.
//
// A valid downstream API key is required. This intentionally fails closed on
// internal probes and incomplete request contexts instead of creating a cache
// identity that could be shared by unrelated tenants.
func resolveGrokCacheIdentity(c *gin.Context, body []byte, explicitKey, upstreamModel string) string {
apiKeyID := getAPIKeyIDFromContext(c)
if apiKeyID <= 0 {
return ""
}
// /responses/compact rejects tool_choice and does not represent a normal
// conversation turn. Keep both cache identity and Free-tier routing
// augmentation out of this path.
if isOpenAIResponsesCompactPath(c) {
return ""
}
model := strings.ToLower(strings.TrimSpace(upstreamModel))
if model == "" {
return ""
}
seed := explicitGrokCacheSeed(c, body, explicitKey)
if seed == "" {
seed = deriveOpenAIContentSessionSeed(body)
}
if seed == "" {
return ""
}
// generateSessionUUID hashes the whole seed before formatting it as a UUID.
// Include a versioned namespace so this identity cannot collide with other
// upstream session identifiers derived by sub2api.
isolatedSeed := fmt.Sprintf("grok-prompt-cache:v1:%d:%s:%s", apiKeyID, model, seed)
return generateSessionUUID(isolatedSeed)
}
func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) string {
seed := ""
if c != nil {
seed = strings.TrimSpace(c.GetHeader("session_id"))
if seed == "" {
seed = strings.TrimSpace(c.GetHeader("conversation_id"))
}
if seed == "" {
seed = strings.TrimSpace(c.GetHeader(grokConversationIDHeader))
}
}
if seed == "" && len(body) > 0 {
seed = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
}
if seed == "" {
seed = strings.TrimSpace(explicitKey)
}
return seed
}
func isGrokRequestContext(c *gin.Context) bool {
if c == nil {
return false
}
v, exists := c.Get("api_key")
if !exists {
return false
}
apiKey, ok := v.(*APIKey)
return ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == PlatformGrok
}
// applyGrokResponsesCacheIdentity writes the cache routing identity into an
// xAI Responses request. Existing client values are deliberately replaced by
// the tenant-isolated value to prevent collisions on shared OAuth accounts.
//
// Free OAuth requests without native search tools are routed by xAI to the
// non-cacheable build-free model. For otherwise tool-free requests, add the
// native tools with tool_choice=none: this selects the cache-capable tier
// without allowing an actual search. Any explicit client tools or tool_choice
// disable this augmentation so client function-calling semantics stay intact.
func applyGrokResponsesCacheIdentity(body, intentSourceBody []byte, identity string, injectFreeTierTools bool) ([]byte, error) {
identity = strings.TrimSpace(identity)
if identity == "" {
if gjson.GetBytes(body, "prompt_cache_key").Exists() {
return sjson.DeleteBytes(body, "prompt_cache_key")
}
return body, nil
}
out, err := sjson.SetBytes(body, "prompt_cache_key", identity)
if err != nil {
return nil, err
}
if !injectFreeTierTools {
return out, nil
}
// Inspect the pre-sanitization source. patchGrokResponsesBody may remove an
// unsupported client tool and its tool_choice; that must not turn an
// explicit client tool intent into an eligible native-tool request.
if gjson.GetBytes(intentSourceBody, "tools").Exists() || gjson.GetBytes(intentSourceBody, "tool_choice").Exists() {
return out, nil
}
out, err = sjson.SetRawBytes(out, "tools", []byte(grokFreeCacheNativeToolsJSON))
if err != nil {
return nil, err
}
return sjson.SetBytes(out, "tool_choice", grokFreeCacheDisabledToolChoice)
}
// applyGrokCacheHeaders applies the documented Chat Completions conversation
// routing header. The request is built from a fresh header map, so client
// supplied x-grok headers cannot override this server-derived value.
func applyGrokCacheHeaders(headers http.Header, identity string) {
if headers == nil {
return
}
identity = strings.TrimSpace(identity)
if identity == "" {
headers.Del(grokConversationIDHeader)
return
}
headers.Set(grokConversationIDHeader, identity)
}
// stripGrokChatPromptCacheKey removes the Responses-only body field after it
// has been used as an identity seed. Chat Completions routes cache by header.
func stripGrokChatPromptCacheKey(body []byte) ([]byte, error) {
if !gjson.GetBytes(body, "prompt_cache_key").Exists() {
return body, nil
}
return sjson.DeleteBytes(body, "prompt_cache_key")
}