Files
ttstd-infinite-canvas/backend/internal/app/provider_gemini_cache.go

471 lines
17 KiB
Go

package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"yingce/backend/internal/model"
"yingce/backend/internal/protocol"
"gorm.io/gorm"
)
const officialGeminiAgentInterface = "gemini-generate-content"
const geminiAgentCacheTTL = "3600s"
const geminiAgentCacheFormatVersion = "v2"
const (
// Cache creation is an optimization and must never consume the model request's
// full timeout. If the cache service is slow, the Agent uses the uncached body.
geminiCacheCreateTimeout = 3 * time.Second
geminiCacheDeleteTimeout = 2 * time.Second
geminiCacheLockTimeout = 750 * time.Millisecond
geminiCacheLeaseTTL = 10 * time.Second
)
// geminiCacheKeyLock serializes cache creation for one stable prefix inside a
// process. The reference count lets idle entries leave the map instead of
// growing with every distinct model/prompt identity.
type geminiCacheKeyLock struct {
token chan struct{}
refs int
}
// prepareOfficialGeminiAgentCache creates or reuses Google's explicit
// CachedContent for the stable system/tool prefix. It is deliberately gated by
// the official interface ID: custom manifests must never receive a provider-
// specific cachedContent field that their upstream may not understand.
//
// The bool reports whether the returned spec actually uses cachedContent. Any
// error is cache-path-only; callers must log it and continue with the returned
// uncached spec rather than turning an optional optimization into an Agent
// failure.
func prepareOfficialGeminiAgentCache(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec) (protocol.RequestSpec, bool, error) {
return prepareOfficialGeminiAgentCacheMode(ctx, input, spec, false)
}
func prepareOfficialGeminiAgentCacheMode(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec, forceRebuild bool) (protocol.RequestSpec, bool, error) {
baseSpec, err := cloneProtocolRequestSpec(spec)
if err != nil {
return spec, false, err
}
if strings.TrimSpace(input.Config.InterfaceType) != officialGeminiAgentInterface {
return baseSpec, false, nil
}
body := protocolBodyObject(baseSpec.Body)
if body == nil {
return baseSpec, false, errors.New("Gemini Agent 请求体必须是 JSON 对象")
}
stable, stableBytes, ok, err := geminiStableAgentPrefix(body)
if err != nil {
return baseSpec, false, err
}
if !ok {
return baseSpec, false, nil
}
// Explicit caching has a provider-defined minimum context size. Do not
// manufacture a cache request for a small prefix that Gemini will reject.
if estimateGeminiCacheTokens(stableBytes) < geminiAgentCacheMinimumTokens(input.Config.Model) {
return baseSpec, false, nil
}
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil || metadata.Service.repo == nil || strings.TrimSpace(metadata.UserID) == "" {
return baseSpec, false, nil
}
cacheKey, credentialHash, err := geminiAgentCacheKey(input, metadata.UserID, stable)
if err != nil {
return baseSpec, false, err
}
if !forceRebuild {
cache, lookupErr := metadata.Service.repo.GeminiCacheByKey(metadata.UserID, cacheKey)
if lookupErr == nil && geminiCacheUsable(cache) {
return applyGeminiCachedContent(baseSpec, cache.ResourceName), true, nil
}
if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
return baseSpec, false, fmt.Errorf("读取 Gemini 缓存失败:%w", lookupErr)
}
}
lockCtx, cancel := context.WithTimeout(ctx, geminiCacheLockTimeout)
defer cancel()
release, acquired := metadata.Service.acquireGeminiCacheLock(lockCtx, cacheKey)
if !acquired {
// Another worker is creating the same cache. Waiting longer would add
// latency to the first model response; that worker will persist the
// cache for a later Agent step.
log.Printf("gemini agent prompt cache skipped: model=%s cache_key=%s reason=lock_unavailable", safeProviderModel(input.Config.Model), cacheKey)
return baseSpec, false, nil
}
defer release()
// Recheck after taking both the process-local and optional Redis lock. This
// check is intentionally unconditional: a forced rebuild must never delete
// a fresh cache created by a concurrent request while it was waiting.
cache, lookupErr := metadata.Service.repo.GeminiCacheByKey(metadata.UserID, cacheKey)
if lookupErr == nil && geminiCacheUsable(cache) {
return applyGeminiCachedContent(baseSpec, cache.ResourceName), true, nil
}
if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
return baseSpec, false, fmt.Errorf("读取 Gemini 缓存失败:%w", lookupErr)
}
// Expired records must release their remote CachedContent before the local
// row is replaced. Remote cleanup is best effort because it is not allowed
// to block the model request.
if cache, lookupErr := metadata.Service.repo.GeminiCacheByKey(metadata.UserID, cacheKey); lookupErr == nil {
deleteGeminiCachedContentBestEffort(ctx, input.Config, cache.ResourceName, cacheKey)
deleted, err := metadata.Service.repo.DeleteGeminiCacheIfResourceMatches(metadata.UserID, cacheKey, cache.ResourceName)
if err != nil {
return baseSpec, false, fmt.Errorf("清理过期 Gemini 缓存记录失败:%w", err)
}
if !deleted {
if latest, latestErr := metadata.Service.repo.GeminiCacheByKey(metadata.UserID, cacheKey); latestErr == nil && geminiCacheUsable(latest) {
return applyGeminiCachedContent(baseSpec, latest.ResourceName), true, nil
}
}
} else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
return baseSpec, false, fmt.Errorf("读取 Gemini 缓存失败:%w", lookupErr)
}
cacheBody := map[string]any{
"model": "models/" + canonicalGeminiModelName(input.Config.Model),
"ttl": geminiAgentCacheTTL,
}
for key, value := range stable {
cacheBody[key] = value
}
var response struct {
Name string `json:"name"`
ExpireTime string `json:"expireTime"`
}
createCtx, createCancel := context.WithTimeout(withProviderRequestKind(ctx, "cache-create"), geminiCacheCreateTimeout)
defer createCancel()
if err := postGeminiJSON(createCtx, input.Config, "/cachedContents", cacheBody, &response); err != nil {
return baseSpec, false, fmt.Errorf("创建 Gemini 官方 Prompt Cache 失败:%w", err)
}
resourceName := strings.TrimSpace(response.Name)
if err := validateGeminiCachedContentName(resourceName); err != nil {
return baseSpec, false, err
}
expireTime, err := time.Parse(time.RFC3339, strings.TrimSpace(response.ExpireTime))
if err != nil {
deleteGeminiCachedContentBestEffort(ctx, input.Config, resourceName, cacheKey)
return baseSpec, false, fmt.Errorf("Gemini 官方 Prompt Cache 过期时间无效:%w", err)
}
stored := &model.CloudAgentGeminiCache{
ID: "gcache_" + cacheKey,
UserID: metadata.UserID,
CacheKey: cacheKey,
BaseURL: strings.TrimRight(strings.TrimSpace(input.Config.BaseURL), "/"),
Model: canonicalGeminiModelName(input.Config.Model),
CredentialHash: credentialHash,
ResourceName: resourceName,
ExpireTime: expireTime,
}
if err := metadata.Service.repo.UpsertGeminiCache(stored); err != nil {
deleteGeminiCachedContentBestEffort(ctx, input.Config, resourceName, cacheKey)
return baseSpec, false, fmt.Errorf("保存 Gemini 官方 Prompt Cache 失败:%w", err)
}
return applyGeminiCachedContent(baseSpec, resourceName), true, nil
}
func (s *Service) acquireGeminiCacheLock(ctx context.Context, cacheKey string) (func(), bool) {
if s == nil || strings.TrimSpace(cacheKey) == "" {
return nil, false
}
s.geminiCacheLockMu.Lock()
if s.geminiCacheLocks == nil {
s.geminiCacheLocks = make(map[string]*geminiCacheKeyLock)
}
lock := s.geminiCacheLocks[cacheKey]
if lock == nil {
lock = &geminiCacheKeyLock{token: make(chan struct{}, 1)}
lock.token <- struct{}{}
s.geminiCacheLocks[cacheKey] = lock
}
lock.refs++
s.geminiCacheLockMu.Unlock()
select {
case <-lock.token:
var once sync.Once
localRelease := func() {
once.Do(func() {
lock.token <- struct{}{}
s.releaseGeminiCacheLock(cacheKey, lock)
})
}
if s.coordinator == nil {
return localRelease, true
}
distributedRelease, err := s.coordinator.AcquireWithWait(ctx, "gemini-agent-cache:"+cacheKey, 1, geminiCacheLeaseTTL)
if err != nil {
localRelease()
return nil, false
}
return func() {
distributedRelease()
localRelease()
}, true
case <-ctx.Done():
s.releaseGeminiCacheLock(cacheKey, lock)
return nil, false
}
}
func (s *Service) releaseGeminiCacheLock(cacheKey string, lock *geminiCacheKeyLock) {
if s == nil || lock == nil {
return
}
s.geminiCacheLockMu.Lock()
defer s.geminiCacheLockMu.Unlock()
lock.refs--
if lock.refs <= 0 && s.geminiCacheLocks[cacheKey] == lock {
delete(s.geminiCacheLocks, cacheKey)
}
}
func geminiCacheUsable(cache *model.CloudAgentGeminiCache) bool {
return cache != nil && validateGeminiCachedContentName(cache.ResourceName) == nil && cache.ExpireTime.After(time.Now().Add(30*time.Second))
}
func geminiStableAgentPrefix(body map[string]any) (map[string]any, []byte, bool, error) {
stable := make(map[string]any, 3)
for _, key := range []string{"systemInstruction", "tools", "toolConfig"} {
value, exists := body[key]
if !exists || value == nil {
continue
}
stable[key] = value
}
if len(stable) == 0 {
return nil, nil, false, nil
}
stableBytes, err := json.Marshal(stable)
if err != nil {
return nil, nil, false, fmt.Errorf("生成 Gemini 缓存身份失败:%w", err)
}
return stable, stableBytes, true, nil
}
func geminiAgentCacheKey(input canvasGenerationInput, userID string, stable map[string]any) (string, string, error) {
credentialHash := geminiCacheHash([]byte(input.Config.APIKey))
normalizedHeaders := make([]OutboundHeader, 0, len(input.Config.Headers))
for _, header := range input.Config.Headers {
normalizedHeaders = append(normalizedHeaders, OutboundHeader{Name: strings.ToLower(strings.TrimSpace(header.Name)), Value: strings.TrimSpace(header.Value)})
}
sort.Slice(normalizedHeaders, func(i, j int) bool {
if normalizedHeaders[i].Name == normalizedHeaders[j].Name {
return normalizedHeaders[i].Value < normalizedHeaders[j].Value
}
return normalizedHeaders[i].Name < normalizedHeaders[j].Name
})
identity := map[string]any{
"formatVersion": geminiAgentCacheFormatVersion,
"userID": userID,
"credentialHash": credentialHash,
"channelID": strings.TrimSpace(input.Config.ChannelID),
"model": canonicalGeminiModelName(input.Config.Model),
"baseURL": strings.TrimRight(strings.TrimSpace(input.Config.BaseURL), "/"),
"apiFormat": strings.TrimSpace(input.Config.APIFormat),
"headers": normalizedHeaders,
"stable": stable,
}
identityBytes, err := json.Marshal(identity)
if err != nil {
return "", "", fmt.Errorf("生成 Gemini 缓存键失败:%w", err)
}
return geminiCacheHash(identityBytes), credentialHash, nil
}
func canonicalGeminiModelName(value string) string {
return strings.TrimPrefix(strings.TrimSpace(value), "models/")
}
func applyGeminiCachedContent(spec protocol.RequestSpec, resourceName string) protocol.RequestSpec {
body := cloneStringAnyMap(protocolBodyObject(spec.Body))
delete(body, "systemInstruction")
delete(body, "tools")
delete(body, "toolConfig")
body["cachedContent"] = resourceName
spec.Body = body
return spec
}
// cloneProtocolRequestSpec keeps the cache path from mutating the adapter's
// original request. The body is recursively cloned so removing the stable
// prefix or adding cachedContent cannot leak into a retry or another caller.
func cloneProtocolRequestSpec(spec protocol.RequestSpec) (protocol.RequestSpec, error) {
cloned := spec
if spec.Headers != nil {
cloned.Headers = make(map[string]string, len(spec.Headers))
for key, value := range spec.Headers {
cloned.Headers[key] = value
}
}
if spec.Query != nil {
cloned.Query = make(map[string][]string, len(spec.Query))
for key, values := range spec.Query {
cloned.Query[key] = append([]string(nil), values...)
}
}
if spec.Files != nil {
cloned.Files = append([]protocol.RequestFilePart(nil), spec.Files...)
}
if spec.Body == nil {
return cloned, nil
}
data, err := json.Marshal(spec.Body)
if err != nil {
return spec, fmt.Errorf("复制 Gemini Agent 请求体失败:%w", err)
}
var body any
if err := json.Unmarshal(data, &body); err != nil {
return spec, fmt.Errorf("复制 Gemini Agent 请求体失败:%w", err)
}
cloned.Body = body
return cloned, nil
}
func geminiAgentCacheMinimumTokens(modelName string) int {
// Keep the gate conservative for unknown model revisions. Skipping an
// explicit cache is safe; creating one below Google's model-specific
// minimum makes the whole upstream request fail before generation starts.
lower := strings.ToLower(strings.TrimSpace(modelName))
if strings.Contains(lower, "gemini-2.5") {
return 2048
}
return 4096
}
func estimateGeminiCacheTokens(data []byte) int {
return (len(data) + 3) / 4
}
func geminiCacheHash(value []byte) string {
digest := sha256.Sum256(value)
return hex.EncodeToString(digest[:])
}
func geminiRequestUsesCachedContent(spec protocol.RequestSpec) bool {
body := protocolBodyObject(spec.Body)
value, _ := body["cachedContent"].(string)
return strings.TrimSpace(value) != ""
}
func isGeminiCachedContentNotFound(err error, resourceName string) bool {
var httpErr providerHTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 {
return false
}
resourceName = strings.ToLower(strings.TrimSpace(resourceName))
body := strings.ToLower(httpErr.Body)
if resourceName == "" || !strings.Contains(body, resourceName) {
return false
}
var payload struct {
Error struct {
Status string `json:"status"`
Code int `json:"code"`
} `json:"error"`
}
if json.Unmarshal([]byte(httpErr.Body), &payload) == nil && payload.Error.Code != 0 && payload.Error.Code != 404 {
return false
}
return payload.Error.Status == "" || strings.EqualFold(payload.Error.Status, "NOT_FOUND")
}
// invalidateOfficialGeminiAgentCache deletes both the local record and the
// remote CachedContent. Remote cleanup is deliberately best effort; deleting
// the local identity is what prevents an immediate retry from reusing a 404.
func invalidateOfficialGeminiAgentCache(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec, expectedResourceName string) error {
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
if !ok || metadata.Service == nil || metadata.Service.repo == nil || strings.TrimSpace(metadata.UserID) == "" {
return nil
}
baseSpec, err := cloneProtocolRequestSpec(spec)
if err != nil {
return err
}
body := protocolBodyObject(baseSpec.Body)
if body == nil {
return nil
}
stable, _, ok, err := geminiStableAgentPrefix(body)
if err != nil || !ok {
return err
}
cacheKey, _, err := geminiAgentCacheKey(input, metadata.UserID, stable)
if err != nil {
return err
}
lockCtx, cancel := context.WithTimeout(ctx, geminiCacheLockTimeout)
defer cancel()
release, acquired := metadata.Service.acquireGeminiCacheLock(lockCtx, cacheKey)
if !acquired {
return nil
}
defer release()
cache, lookupErr := metadata.Service.repo.GeminiCacheByKey(metadata.UserID, cacheKey)
if errors.Is(lookupErr, gorm.ErrRecordNotFound) {
return nil
}
if lookupErr != nil {
return lookupErr
}
if strings.TrimSpace(expectedResourceName) != "" && cache.ResourceName != expectedResourceName {
return nil
}
deleteGeminiCachedContentBestEffort(ctx, input.Config, cache.ResourceName, cacheKey)
_, err = metadata.Service.repo.DeleteGeminiCacheIfResourceMatches(metadata.UserID, cacheKey, cache.ResourceName)
return err
}
func deleteGeminiCachedContentBestEffort(ctx context.Context, config providerConfig, resourceName, cacheKey string) {
if strings.TrimSpace(resourceName) == "" {
return
}
deleteCtx, cancel := context.WithTimeout(withProviderRequestKind(ctx, "cache-delete"), geminiCacheDeleteTimeout)
defer cancel()
if err := deleteGeminiCachedContent(deleteCtx, config, resourceName); err != nil {
log.Printf("gemini agent prompt cache remote cleanup failed: model=%s cache_key=%s reason=%s", safeProviderModel(config.Model), cacheKey, safeProviderLogError(err))
}
}
func validateGeminiCachedContentName(resourceName string) error {
resourceName = strings.TrimSpace(resourceName)
if !strings.HasPrefix(resourceName, "cachedContents/") {
return errors.New("Gemini 官方 Prompt Cache 返回了无效资源名")
}
suffix := strings.TrimPrefix(resourceName, "cachedContents/")
if suffix == "" || suffix == "." || suffix == ".." {
return errors.New("Gemini 官方 Prompt Cache 返回了无效资源名")
}
for _, char := range suffix {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' {
continue
}
return errors.New("Gemini 官方 Prompt Cache 返回了无效资源名")
}
return nil
}
func safeProviderModel(modelName string) string {
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return "unknown"
}
return truncateRunes(modelName, 120)
}