Files
tutti/services/tuttid/service/agent/composer_live_model_cache.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

298 lines
9.4 KiB
Go

package agent
import (
"strings"
"sync"
"time"
"github.com/tutti-os/tutti/services/tuttid/biz/agentprovider"
)
const defaultLiveModelCacheTTL = 10 * time.Minute
type composerLiveModelCache struct {
mu sync.Mutex
entries map[string]composerLiveModelCacheEntry
targetCatalogs map[string]composerLiveModelCacheEntry
}
type composerLiveModelCacheEntry struct {
agentTargetID string
cachedAt time.Time
options []ComposerConfigOptionValue
provider string
runtimeContext map[string]any
}
func newComposerLiveModelCache() *composerLiveModelCache {
return &composerLiveModelCache{
entries: make(map[string]composerLiveModelCacheEntry),
targetCatalogs: make(map[string]composerLiveModelCacheEntry),
}
}
func (c *composerLiveModelCache) get(key string, now time.Time, ttl time.Duration) ([]ComposerConfigOptionValue, bool, bool) {
if c == nil {
return nil, false, false
}
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
if !ok {
return nil, false, false
}
// ttl <= 0 means the entry never expires (last-known-good). Claude Code uses
// this: a real session's model list is always better than the static
// fallback, and expiring it only decays the picker back to the static list
// with no way to re-probe (hidden discovery runs at most once per key).
if ttl > 0 && now.Sub(entry.cachedAt) > ttl {
delete(c.entries, key)
return nil, false, true
}
return cloneComposerConfigOptionValues(entry.options), true, false
}
func (c *composerLiveModelCache) set(scope composerLiveModelScope, now time.Time, options []ComposerConfigOptionValue) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
key := scope.key()
entry := c.entries[key]
entry.agentTargetID = strings.TrimSpace(scope.agentTargetID)
entry.cachedAt = now
entry.options = cloneComposerConfigOptionValues(options)
entry.provider = agentprovider.NormalizeOpen(scope.provider)
c.entries[key] = entry
if entry.agentTargetID != "" {
// The scoped entry serves composer presentation and may expire. Keep a
// separate last-known-good target catalog for sparse defaults patches,
// which carry no workspace/cwd and therefore cannot safely rediscover
// the menu's descriptor at mutation time.
c.targetCatalogs[key] = entry
}
}
func (c *composerLiveModelCache) optionsForTarget(
provider string,
agentTargetID string,
) ([]ComposerConfigOptionValue, bool) {
if c == nil {
return nil, false
}
provider = agentprovider.NormalizeOpen(provider)
agentTargetID = strings.TrimSpace(agentTargetID)
if provider == "" || agentTargetID == "" {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
result := make([]ComposerConfigOptionValue, 0)
seen := make(map[string]struct{})
for _, entry := range c.targetCatalogs {
if entry.provider != provider || entry.agentTargetID != agentTargetID {
continue
}
for _, option := range entry.options {
value := strings.TrimSpace(option.Value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, option)
}
}
return cloneComposerConfigOptionValues(result), len(result) > 0
}
func (c *composerLiveModelCache) getRuntimeContext(key string, now time.Time, ttl time.Duration) (map[string]any, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
if !ok || len(entry.runtimeContext) == 0 {
return nil, false
}
if ttl > 0 && now.Sub(entry.cachedAt) > ttl {
delete(c.entries, key)
return nil, false
}
return clonePayload(entry.runtimeContext), true
}
func (c *composerLiveModelCache) setRuntimeContext(key string, now time.Time, runtimeContext map[string]any) {
if c == nil || len(runtimeContext) == 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
entry := c.entries[key]
entry.cachedAt = now
entry.runtimeContext = clonePayload(runtimeContext)
c.entries[key] = entry
}
func (c *composerLiveModelCache) invalidateProvider(provider string) int {
if c == nil {
return 0
}
prefix := "live-model:" + agentprovider.NormalizeOpen(provider) + ":"
c.mu.Lock()
defer c.mu.Unlock()
deleted := 0
for key := range c.entries {
if strings.HasPrefix(key, prefix) {
delete(c.entries, key)
deleted++
}
}
for key := range c.targetCatalogs {
if strings.HasPrefix(key, prefix) {
delete(c.targetCatalogs, key)
deleted++
}
}
return deleted
}
// InvalidateLiveComposerModels drops the discovered model lists (and the
// once-per-key discovery attempt markers) for the given provider so composer
// options can re-discover models after the provider's auth or config files
// changed on disk.
func (s *Service) InvalidateLiveComposerModels(provider string) {
if s == nil {
return
}
normalized := agentprovider.NormalizeOpen(provider)
if normalized == "" {
return
}
nowUnixMS := time.Now().UnixMilli()
deletedCacheEntries := s.liveComposerModelCache().invalidateProvider(normalized)
prefix := "live-model:" + normalized + ":"
s.liveModelDiscoveryMu.Lock()
if s.liveModelInvalidatedAtUnixMS == nil {
s.liveModelInvalidatedAtUnixMS = make(map[string]int64)
}
s.liveModelInvalidatedAtUnixMS[normalized] = nowUnixMS
deletedAttemptMarkers := 0
for key := range s.liveModelDiscoveryAttempted {
if strings.HasPrefix(key, prefix) {
delete(s.liveModelDiscoveryAttempted, key)
deletedAttemptMarkers++
}
}
for key := range s.liveModelPersistedScanMissAtUnixMS {
if strings.HasPrefix(key, prefix) {
delete(s.liveModelPersistedScanMissAtUnixMS, key)
}
}
logClaudeModelCatalogInvalidationDebug("live_composer_models_invalidated", map[string]any{
"provider": normalized,
"deletedCacheEntries": deletedCacheEntries,
"deletedAttemptMarkers": deletedAttemptMarkers,
"occurredAtUnixMs": nowUnixMS,
})
s.liveModelDiscoveryMu.Unlock()
}
func (s *Service) liveModelCacheTTL(provider string) time.Duration {
if s.LiveModelCacheTTL != 0 {
return s.LiveModelCacheTTL
}
// Providers with a preserved cache keep their last-known-good catalog until
// an explicit invalidation or a running session advertises a fresher list.
if composerProfileFor(provider).Behavior.PreserveLiveModelCache {
return 0
}
return defaultLiveModelCacheTTL
}
func (s *Service) liveComposerModelCache() *composerLiveModelCache {
if s.liveModelCache == nil {
s.liveModelCache = newComposerLiveModelCache()
}
return s.liveModelCache
}
func (s *Service) getLiveComposerModelOptions(provider, workspaceID, cwd string, now time.Time) ([]ComposerConfigOptionValue, bool) {
return s.getLiveComposerModelOptionsForScope(newComposerLiveModelScope(provider, workspaceID, cwd, ""), now)
}
func (s *Service) setLiveComposerModelOptions(provider, workspaceID, cwd string, now time.Time, options []ComposerConfigOptionValue) {
if len(options) == 0 {
return
}
s.setLiveComposerModelOptionsForScope(newComposerLiveModelScope(provider, workspaceID, cwd, ""), now, options)
}
func (s *Service) getLiveComposerModelOptionsForScope(scope composerLiveModelScope, now time.Time) ([]ComposerConfigOptionValue, bool) {
options, ok, expired := s.liveComposerModelCache().get(scope.key(), now, s.liveModelCacheTTL(scope.provider))
if expired {
s.clearLiveModelDiscoveryAttempt(scope.key())
}
return options, ok
}
func (s *Service) setLiveComposerModelOptionsForScope(scope composerLiveModelScope, now time.Time, options []ComposerConfigOptionValue) {
if len(options) == 0 {
return
}
s.liveComposerModelCache().set(scope, now, options)
}
func (s *Service) liveComposerModelOptionsForTarget(
provider string,
agentTargetID string,
) ([]ComposerConfigOptionValue, bool) {
// Defaults are target-scoped rather than workspace/cwd-scoped. Union only
// last-known-good catalogs the daemon actually observed for this exact
// target. Display cache TTL does not expire this validation evidence because
// the sparse patch has no scope with which to rediscover it. Create still
// validates the selected default against its actual caller scope.
return s.liveComposerModelCache().optionsForTarget(
provider,
agentTargetID,
)
}
func (s *Service) getComposerRuntimeContextForScope(scope composerLiveModelScope, now time.Time) (map[string]any, bool) {
return s.liveComposerModelCache().getRuntimeContext(scope.key(), now, s.liveModelCacheTTL(scope.provider))
}
func (s *Service) setComposerRuntimeContextForScope(scope composerLiveModelScope, now time.Time, runtimeContext map[string]any) {
s.liveComposerModelCache().setRuntimeContext(scope.key(), now, runtimeContext)
}
// composerLiveModelCacheKey buckets the cache by provider, workspace, cwd scope,
// and (for auth-sensitive providers) an auth-context fingerprint. Providers
// with credential-scoped catalogs use one account-level workspace/cwd scope so
// UI project selection cannot duplicate hidden discovery. Other providers
// retain their caller scope. The same key also scopes the once-per-key hidden
// discovery guard.
func composerLiveModelCacheKey(provider, workspaceID, cwd, authScope string) string {
scope := newComposerLiveModelScope(provider, workspaceID, cwd, "")
scope.authScope = strings.TrimSpace(authScope)
return scope.key()
}
func cloneComposerConfigOptionValues(options []ComposerConfigOptionValue) []ComposerConfigOptionValue {
if len(options) == 0 {
return nil
}
result := append([]ComposerConfigOptionValue(nil), options...)
for index := range result {
result[index].ReasoningEfforts = append(
[]AgentModelReasoningEffortOption(nil),
options[index].ReasoningEfforts...,
)
}
return result
}