Skip to content

Commit d104d7a

Browse files
Alexey Panfilovclaude
andcommitted
feat(aa): persist full AA model data in kv_settings cache
Store AAModelInfo (score, coding/math index, speed, pricing) keyed by normalized OR-compatible IDs in kv_settings under aa.models.cache. Startup uses cache if < 24h old, skipping the live API call. Manual Refresh always re-fetches and updates the cache. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e61034f commit d104d7a

4 files changed

Lines changed: 143 additions & 53 deletions

File tree

cmd/agent/main.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ func main() {
301301

302302
if cfg.AdminAPI.Enabled {
303303
capStore, _ := s.(llm.CapabilityStore)
304-
adminSrv := adminapi.New(cfg.AdminAPI, router, capStore, cfg, logger)
304+
settingsStore, _ := s.(llm.SettingsStore)
305+
adminSrv := adminapi.New(cfg.AdminAPI, router, capStore, settingsStore, cfg, logger)
305306
if err := adminSrv.Start(); err != nil {
306307
logger.Error("admin API failed to start", "err", err)
307308
} else {
@@ -427,36 +428,54 @@ func hydrateOpenRouterCapabilities(cfg *config.Config, providers map[string]llm.
427428
}
428429
}
429430

430-
// hydrateArtificialAnalysisScores fetches Intelligence Index scores from
431-
// Artificial Analysis and merges them into the CapabilityStore. Requires
432-
// AA_API_KEY in config or environment. Silently skips when not configured.
431+
// hydrateArtificialAnalysisScores loads AA model data (from cache or live API)
432+
// and merges Intelligence Index scores into the CapabilityStore.
433+
// Silently skips when not configured.
433434
func hydrateArtificialAnalysisScores(cfg *config.Config, s store.Store, logger *slog.Logger) {
434435
apiKey := cfg.ArtificialAnalysisAPIKey
435436
if apiKey == "" {
436437
return
437438
}
438-
439439
capStore, ok := s.(llm.CapabilityStore)
440440
if !ok {
441441
return
442442
}
443+
settings, ok := s.(llm.SettingsStore)
444+
if !ok {
445+
return
446+
}
443447

444448
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
445449
defer cancel()
446450

447-
scores, err := llm.FetchArtificialAnalysisScores(ctx, apiKey)
451+
// Try cache first; fetch from API if absent or expired.
452+
var models map[string]llm.AAModelInfo
453+
cache, err := llm.LoadAACache(ctx, settings)
448454
if err != nil {
449-
logger.Warn("artificialanalysis scores fetch failed", "err", err)
450-
return
455+
logger.Warn("aa cache load failed", "err", err)
456+
}
457+
if cache != nil {
458+
models = cache.Models
459+
logger.Info("aa cache loaded", "models", len(models), "age", time.Since(cache.FetchedAt).Round(time.Minute))
460+
} else {
461+
models, err = llm.FetchArtificialAnalysisData(ctx, apiKey)
462+
if err != nil {
463+
logger.Warn("artificialanalysis fetch failed", "err", err)
464+
return
465+
}
466+
if storeErr := llm.StoreAACache(ctx, settings, models); storeErr != nil {
467+
logger.Warn("aa cache store failed", "err", storeErr)
468+
} else {
469+
logger.Info("aa cache stored", "models", len(models))
470+
}
451471
}
452472

453-
// Load cached caps, overlay scores, write back.
454473
caps, err := capStore.GetAllCapabilities(ctx, "openrouter")
455474
if err != nil {
456-
logger.Warn("failed to load cached capabilities for AA merge", "err", err)
475+
logger.Warn("failed to load capabilities for AA merge", "err", err)
457476
return
458477
}
459-
llm.MergeAAScores(caps, scores)
478+
llm.MergeAAScores(caps, models)
460479
updated := 0
461480
for id, c := range caps {
462481
if c.Score == 0 {
@@ -468,5 +487,5 @@ func hydrateArtificialAnalysisScores(cfg *config.Config, s store.Store, logger *
468487
updated++
469488
}
470489
}
471-
logger.Info("artificialanalysis scores merged", "total", len(scores), "updated", updated)
490+
logger.Info("artificialanalysis scores merged", "models", len(models), "updated", updated)
472491
}

internal/adminapi/handlers.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,19 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
178178
return
179179
}
180180

181-
// Overlay AA Intelligence Index scores if configured.
181+
// Overlay AA Intelligence Index scores if configured — always re-fetch on
182+
// manual Refresh (bypass cache) and update the stored cache.
182183
if aaKey := s.cfgRef.ArtificialAnalysisAPIKey; aaKey != "" {
183-
if scores, aaErr := llm.FetchArtificialAnalysisScores(ctx, aaKey); aaErr != nil {
184-
s.logger.Warn("AA scores refresh failed", "err", aaErr)
184+
if models, aaErr := llm.FetchArtificialAnalysisData(ctx, aaKey); aaErr != nil {
185+
s.logger.Warn("AA data refresh failed", "err", aaErr)
185186
} else {
186-
llm.MergeAAScores(caps, scores)
187-
s.logger.Info("AA scores refreshed", "count", len(scores))
187+
llm.MergeAAScores(caps, models)
188+
s.logger.Info("AA data refreshed", "models", len(models))
189+
if s.settings != nil {
190+
if storeErr := llm.StoreAACache(ctx, s.settings, models); storeErr != nil {
191+
s.logger.Warn("AA cache store failed", "err", storeErr)
192+
}
193+
}
188194
}
189195
}
190196

internal/adminapi/server.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,24 @@ type Server struct {
2222
cfg config.AdminAPIConfig
2323
router *llm.Router
2424
capStore llm.CapabilityStore
25-
cfgRef *config.Config // needed for enumerating OpenRouter slots
25+
settings llm.SettingsStore // for AA cache persistence; may be nil
26+
cfgRef *config.Config // needed for enumerating OpenRouter slots
2627
logger *slog.Logger
2728

2829
httpSrv *http.Server
2930
}
3031

3132
// New constructs the admin API server but does not start it. Call Start to
3233
// bind the listener.
33-
func New(cfg config.AdminAPIConfig, router *llm.Router, capStore llm.CapabilityStore, cfgRef *config.Config, logger *slog.Logger) *Server {
34+
func New(cfg config.AdminAPIConfig, router *llm.Router, capStore llm.CapabilityStore, settings llm.SettingsStore, cfgRef *config.Config, logger *slog.Logger) *Server {
3435
if cfg.ForwardAuthHeader == "" {
3536
cfg.ForwardAuthHeader = "X-authentik-username"
3637
}
3738
return &Server{
3839
cfg: cfg,
3940
router: router,
4041
capStore: capStore,
42+
settings: settings,
4143
cfgRef: cfgRef,
4244
logger: logger,
4345
}

internal/llm/artificialanalysis.go

Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,37 @@ import (
1010
"time"
1111
)
1212

13+
const aaSettingsKey = "aa.models.cache"
14+
const aaCacheTTL = 24 * time.Hour
15+
1316
var aaHTTPClient = &http.Client{Timeout: 20 * time.Second}
1417

15-
// FetchArtificialAnalysisScores fetches the Intelligence Index scores from the
16-
// Artificial Analysis API and returns a map[modelSlug]score. The slug format
17-
// used by AA differs from OpenRouter IDs, so callers should merge by AA slug
18-
// after mapping through the openrouter_slug field.
18+
// AAModelInfo holds normalized AA data for a single model, keyed by the
19+
// OR-compatible slug (dots replaced with dashes in version segments).
20+
type AAModelInfo struct {
21+
AASlug string `json:"aa_slug"`
22+
CreatorSlug string `json:"creator_slug"`
23+
Score float64 `json:"score,omitempty"` // Intelligence Index
24+
CodingIndex float64 `json:"coding_index,omitempty"` // Coding Index
25+
MathIndex float64 `json:"math_index,omitempty"` // Math Index
26+
SpeedTPS float64 `json:"speed_tps,omitempty"` // median output tokens/sec
27+
TTFT float64 `json:"ttft_s,omitempty"` // median time-to-first-token, seconds
28+
PriceInput float64 `json:"price_input_1m,omitempty"`
29+
PriceOutput float64 `json:"price_output_1m,omitempty"`
30+
}
31+
32+
// AACache is the kv_settings blob stored under aaSettingsKey.
33+
type AACache struct {
34+
FetchedAt time.Time `json:"fetched_at"`
35+
Models map[string]AAModelInfo `json:"models"` // key = normalized OR-compatible ID
36+
}
37+
38+
// FetchArtificialAnalysisData fetches full model data from the AA API and
39+
// returns it as a normalized map keyed by OR-compatible IDs.
1940
//
2041
// API docs: https://artificialanalysis.ai/api-reference
2142
// Free tier: 1000 req/day, attribution required.
22-
func FetchArtificialAnalysisScores(ctx context.Context, apiKey string) (map[string]float64, error) {
43+
func FetchArtificialAnalysisData(ctx context.Context, apiKey string) (map[string]AAModelInfo, error) {
2344
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://artificialanalysis.ai/api/v2/data/llms/models", nil)
2445
if err != nil {
2546
return nil, err
@@ -39,7 +60,7 @@ func FetchArtificialAnalysisScores(ctx context.Context, apiKey string) (map[stri
3960
if resp.StatusCode != 200 {
4061
return nil, fmt.Errorf("artificialanalysis /api/v2/data/llms/models: HTTP %d: %s", resp.StatusCode, string(body))
4162
}
42-
return parseAAModels(body)
63+
return parseAAData(body)
4364
}
4465

4566
type aaModelsResponse struct {
@@ -50,53 +71,95 @@ type aaModelsResponse struct {
5071
Slug string `json:"slug"`
5172
} `json:"model_creator"`
5273
Evaluations struct {
53-
ArtificialAnalysisIntelligenceIndex *float64 `json:"artificial_analysis_intelligence_index"`
74+
IntelligenceIndex *float64 `json:"artificial_analysis_intelligence_index"`
75+
CodingIndex *float64 `json:"artificial_analysis_coding_index"`
76+
MathIndex *float64 `json:"artificial_analysis_math_index"`
5477
} `json:"evaluations"`
78+
MedianOutputTPS float64 `json:"median_output_tokens_per_second"`
79+
MedianTTFT float64 `json:"median_time_to_first_token_seconds"`
80+
Pricing struct {
81+
Input float64 `json:"price_1m_input_tokens"`
82+
Output float64 `json:"price_1m_output_tokens"`
83+
} `json:"pricing"`
5584
} `json:"data"`
5685
}
5786

58-
func parseAAModels(body []byte) (map[string]float64, error) {
87+
// normalizeAAKey converts an AA {creator}/{slug} into an OR-compatible key
88+
// by replacing dots with dashes (AA uses dashes, OR uses dots for versions).
89+
func normalizeAAKey(creator, slug string) string {
90+
return strings.ReplaceAll(creator+"/"+slug, ".", "-")
91+
}
92+
93+
func parseAAData(body []byte) (map[string]AAModelInfo, error) {
5994
var resp aaModelsResponse
6095
if err := json.Unmarshal(body, &resp); err != nil {
6196
return nil, fmt.Errorf("artificialanalysis /data/llms/models: parse: %w", err)
6297
}
63-
out := make(map[string]float64, len(resp.Data))
98+
out := make(map[string]AAModelInfo, len(resp.Data))
6499
for _, m := range resp.Data {
65-
score := m.Evaluations.ArtificialAnalysisIntelligenceIndex
66-
if score == nil || *score == 0 {
100+
if m.ModelCreator.Slug == "" || m.Slug == "" {
67101
continue
68102
}
69-
// AA v2 uses {creator_slug}/{model_slug} format, which maps to OpenRouter IDs
70-
// after normalizing dots↔dashes (done in MergeAAScores).
71-
if m.ModelCreator.Slug != "" && m.Slug != "" {
72-
out[m.ModelCreator.Slug+"/"+m.Slug] = *score
103+
info := AAModelInfo{
104+
AASlug: m.Slug,
105+
CreatorSlug: m.ModelCreator.Slug,
106+
SpeedTPS: m.MedianOutputTPS,
107+
TTFT: m.MedianTTFT,
108+
PriceInput: m.Pricing.Input,
109+
PriceOutput: m.Pricing.Output,
73110
}
111+
if v := m.Evaluations.IntelligenceIndex; v != nil {
112+
info.Score = *v
113+
}
114+
if v := m.Evaluations.CodingIndex; v != nil {
115+
info.CodingIndex = *v
116+
}
117+
if v := m.Evaluations.MathIndex; v != nil {
118+
info.MathIndex = *v
119+
}
120+
out[normalizeAAKey(m.ModelCreator.Slug, m.Slug)] = info
74121
}
75122
return out, nil
76123
}
77124

78-
// MergeAAScores overlays AA Intelligence Index scores onto an existing
79-
// capabilities map (keyed by OpenRouter model ID). Models not found in scores
80-
// keep their existing Score value.
81-
//
82-
// AA slugs use dashes for version separators (gemini-2-5-pro) while OpenRouter
83-
// uses dots (gemini-2.5-pro), so we try both exact and dot→dash normalized match.
84-
func MergeAAScores(caps map[string]Capabilities, scores map[string]float64) {
85-
// Build a dot→dash normalized lookup so OR IDs like "google/gemini-2.5-pro"
86-
// match AA slugs like "google/gemini-2-5-pro".
87-
normed := make(map[string]float64, len(scores))
88-
for k, v := range scores {
89-
normed[strings.ReplaceAll(k, ".", "-")] = v
125+
// StoreAACache persists the AA model map into kv_settings.
126+
func StoreAACache(ctx context.Context, settings SettingsStore, models map[string]AAModelInfo) error {
127+
cache := AACache{FetchedAt: time.Now(), Models: models}
128+
data, err := json.Marshal(cache)
129+
if err != nil {
130+
return fmt.Errorf("aa cache marshal: %w", err)
90131
}
132+
return settings.PutSetting(ctx, aaSettingsKey, string(data))
133+
}
134+
135+
// LoadAACache reads the AA model cache from kv_settings.
136+
// Returns (nil, nil) when absent or expired.
137+
func LoadAACache(ctx context.Context, settings SettingsStore) (*AACache, error) {
138+
raw, ok, err := settings.GetSetting(ctx, aaSettingsKey)
139+
if err != nil || !ok {
140+
return nil, err
141+
}
142+
var cache AACache
143+
if err := json.Unmarshal([]byte(raw), &cache); err != nil {
144+
return nil, fmt.Errorf("aa cache unmarshal: %w", err)
145+
}
146+
if time.Since(cache.FetchedAt) > aaCacheTTL {
147+
return nil, nil
148+
}
149+
return &cache, nil
150+
}
91151

152+
// MergeAAScores overlays AA Intelligence Index scores onto an existing
153+
// capabilities map (keyed by OpenRouter model ID). Models not found in the
154+
// AA cache keep their existing Score value.
155+
//
156+
// OR IDs use dots for version separators (gemini-2.5-pro) while AA keys use
157+
// dashes (gemini-2-5-pro), so we normalize both sides before matching.
158+
func MergeAAScores(caps map[string]Capabilities, models map[string]AAModelInfo) {
92159
for id, c := range caps {
93-
if s, ok := scores[id]; ok {
94-
c.Score = s
95-
caps[id] = c
96-
continue
97-
}
98-
if s, ok := normed[strings.ReplaceAll(id, ".", "-")]; ok {
99-
c.Score = s
160+
key := strings.ReplaceAll(id, ".", "-")
161+
if info, ok := models[key]; ok && info.Score > 0 {
162+
c.Score = info.Score
100163
caps[id] = c
101164
}
102165
}

0 commit comments

Comments
 (0)