1+ import { LRUCache } from "lru-cache" ;
2+ import { trail } from "agentcrumbs" ; // @crumbs
13import { getAdminClickhouse } from "~/services/clickhouse/clickhouseFactory.server" ;
24import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server" ;
35
6+ const crumb = trail ( "webapp" ) ; // @crumbs
7+
8+ const DEFAULT_LOOKBACK_HOURS = 24 ;
9+ const MAX_LOOKBACK_HOURS = 30 * 24 ;
10+ const MISSING_LLM_MODELS_CACHE_TTL_MS = 60_000 ;
11+ const MISSING_LLM_QUERY_SETTINGS = {
12+ // Stop server-side before the ClickHouse client's default 30-second request timeout.
13+ max_execution_time : 25 ,
14+ max_memory_usage : String ( 512 * 1024 * 1024 ) ,
15+ max_threads : 2 ,
16+ } ;
17+
418export type MissingLlmModel = {
519 model : string ;
620 system : string ;
721 count : number ;
822} ;
923
24+ const missingLlmModelsCache = new LRUCache < number , Promise < MissingLlmModel [ ] > > ( {
25+ max : 16 ,
26+ ttl : MISSING_LLM_MODELS_CACHE_TTL_MS ,
27+ } ) ;
28+
1029export async function getMissingLlmModels (
1130 opts : {
1231 lookbackHours ?: number ;
1332 } = { }
1433) : Promise < MissingLlmModel [ ] > {
15- const lookbackHours = opts . lookbackHours ?? 24 ;
16- const since = new Date ( Date . now ( ) - lookbackHours * 60 * 60 * 1000 ) ;
34+ const lookbackHours = validateLookbackHours ( opts . lookbackHours ) ;
35+ let candidatesPromise = missingLlmModelsCache . get ( lookbackHours ) ;
36+
37+ if ( candidatesPromise ) {
38+ crumb ( "missing LLM models cache hit" , { lookbackHours } ) ; // @crumbs
39+ } else {
40+ crumb ( "missing LLM models cache miss" , { lookbackHours } ) ; // @crumbs
41+ candidatesPromise = queryMissingLlmModels ( lookbackHours ) ;
42+ missingLlmModelsCache . set ( lookbackHours , candidatesPromise ) ;
43+ }
44+
45+ let candidates : MissingLlmModel [ ] ;
46+ try {
47+ candidates = await candidatesPromise ;
48+ } catch ( error ) {
49+ if ( missingLlmModelsCache . get ( lookbackHours ) === candidatesPromise ) {
50+ missingLlmModelsCache . delete ( lookbackHours ) ;
51+ }
52+ throw error ;
53+ }
54+
55+ // Filter out models that now have pricing in the database (added after spans were inserted).
56+ // The registry's match() handles prefix stripping for gateway/openrouter models.
57+ if ( ! llmPricingRegistry || ! llmPricingRegistry . isLoaded ) return candidates ;
58+ const registry = llmPricingRegistry ;
59+ return candidates . filter ( ( c ) => ! registry . match ( c . model ) ) ;
60+ }
1761
62+ async function queryMissingLlmModels ( lookbackHours : number ) : Promise < MissingLlmModel [ ] > {
63+ const since = new Date ( Date . now ( ) - lookbackHours * 60 * 60 * 1000 ) ;
1864 const adminClickhouse = getAdminClickhouse ( ) ;
1965
2066 // queryBuilderFast returns a factory function — call it to get the builder
@@ -36,13 +82,14 @@ export async function getMissingLlmModels(
3682 } ,
3783 { name : "cnt" , expression : "count()" } ,
3884 ] ,
85+ settings : MISSING_LLM_QUERY_SETTINGS ,
3986 } ) ;
4087 const qb = createBuilder ( ) ;
4188
42- // Partition pruning on inserted_at (partition key is toDate(inserted_at))
43- qb . where ( "inserted_at >= {since: DateTime64(3)}" , {
44- since : formatDateTime ( since ) ,
45- } ) ;
89+ // Read narrow filter columns before materializing and parsing attributes_text.
90+ qb . prewhere ( "inserted_at >= {since: DateTime64(3)}" , { since : formatDateTime ( since ) } ) ;
91+ qb . prewhere ( "kind = {kind: String}" , { kind : "SPAN" } ) ;
92+ qb . prewhere ( "status = {status: String}" , { status : "OK" } ) ;
4693
4794 // Only spans that have a model set
4895 qb . where ( "JSONExtractString(attributes_text, 'gen_ai', 'response', 'model') != {empty: String}" , {
@@ -55,10 +102,6 @@ export async function getMissingLlmModels(
55102 { }
56103 ) ;
57104
58- // Only completed spans
59- qb . where ( "kind = {kind: String}" , { kind : "SPAN" } ) ;
60- qb . where ( "status = {status: String}" , { status : "OK" } ) ;
61-
62105 qb . groupBy ( "model, system" ) ;
63106 qb . orderBy ( "cnt DESC" ) ;
64107 qb . limit ( 100 ) ;
@@ -81,13 +124,8 @@ export async function getMissingLlmModels(
81124 count : parseInt ( r . cnt , 10 ) ,
82125 } ) ) ;
83126
84- if ( candidates . length === 0 ) return [ ] ;
85-
86- // Filter out models that now have pricing in the database (added after spans were inserted).
87- // The registry's match() handles prefix stripping for gateway/openrouter models.
88- if ( ! llmPricingRegistry || ! llmPricingRegistry . isLoaded ) return candidates ;
89- const registry = llmPricingRegistry ;
90- return candidates . filter ( ( c ) => ! registry . match ( c . model ) ) ;
127+ crumb ( "missing LLM models query complete" , { lookbackHours, candidates : candidates . length } ) ; // @crumbs
128+ return candidates ;
91129}
92130
93131export type MissingModelSample = {
@@ -104,7 +142,7 @@ export async function getMissingModelSamples(opts: {
104142 lookbackHours ?: number ;
105143 limit ?: number ;
106144} ) : Promise < MissingModelSample [ ] > {
107- const lookbackHours = opts . lookbackHours ?? 24 ;
145+ const lookbackHours = validateLookbackHours ( opts . lookbackHours ) ;
108146 const limit = opts . limit ?? 10 ;
109147 const since = new Date ( Date . now ( ) - lookbackHours * 60 * 60 * 1000 ) ;
110148
@@ -114,19 +152,21 @@ export async function getMissingModelSamples(opts: {
114152 name : "missingModelSamples" ,
115153 table : "trigger_dev.task_events_v2" ,
116154 columns : [ "span_id" , "run_id" , "message" , "attributes_text" , "duration" , "start_time" ] ,
155+ settings : MISSING_LLM_QUERY_SETTINGS ,
117156 } ) ;
118157 const qb = createBuilder ( ) ;
119158
120- qb . where ( "inserted_at >= {since: DateTime64(3)}" , { since : formatDateTime ( since ) } ) ;
159+ // Read narrow filter columns before materializing and parsing attributes_text.
160+ qb . prewhere ( "inserted_at >= {since: DateTime64(3)}" , { since : formatDateTime ( since ) } ) ;
161+ qb . prewhere ( "kind = {kind: String}" , { kind : "SPAN" } ) ;
162+ qb . prewhere ( "status = {status: String}" , { status : "OK" } ) ;
121163 qb . where ( "JSONExtractString(attributes_text, 'gen_ai', 'response', 'model') = {model: String}" , {
122164 model : opts . model ,
123165 } ) ;
124166 qb . where (
125167 "JSONExtract(attributes_text, 'trigger', 'llm', 'total_cost', 'Nullable(Float64)') IS NULL" ,
126168 { }
127169 ) ;
128- qb . where ( "kind = {kind: String}" , { kind : "SPAN" } ) ;
129- qb . where ( "status = {status: String}" , { status : "OK" } ) ;
130170 qb . orderBy ( "start_time DESC" ) ;
131171 qb . limit ( limit ) ;
132172
@@ -139,6 +179,14 @@ export async function getMissingModelSamples(opts: {
139179 return rows ?? [ ] ;
140180}
141181
182+ function validateLookbackHours ( lookbackHours = DEFAULT_LOOKBACK_HOURS ) : number {
183+ if ( ! Number . isInteger ( lookbackHours ) || lookbackHours < 1 || lookbackHours > MAX_LOOKBACK_HOURS ) {
184+ throw new RangeError ( `lookbackHours must be between 1 and ${ MAX_LOOKBACK_HOURS } ` ) ;
185+ }
186+
187+ return lookbackHours ;
188+ }
189+
142190function formatDateTime ( date : Date ) : string {
143191 return date . toISOString ( ) . replace ( "T" , " " ) . replace ( "Z" , "" ) ;
144192}
0 commit comments