@@ -10,16 +10,37 @@ import (
1010 "time"
1111)
1212
13+ const aaSettingsKey = "aa.models.cache"
14+ const aaCacheTTL = 24 * time .Hour
15+
1316var 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
4566type 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