66
77from __future__ import annotations
88
9+ import hashlib
10+ import json
911import logging
1012import threading
1113import time
1214from abc import ABC , abstractmethod
15+ from dataclasses import asdict
1316from functools import cached_property
17+ from pathlib import Path
1418from typing import TYPE_CHECKING , Any , Optional
1519
20+ import basilisk .config as config
1621from basilisk .consts import APP_NAME , APP_SOURCE_URL
1722from basilisk .conversation import Conversation , Message , MessageBlock
1823from basilisk .provider_ai_model import ProviderAIModel
1924from basilisk .provider_capability import ProviderCapability
2025from basilisk .provider_engine .dynamic_model_loader import load_models_from_url
26+ from basilisk .provider_engine .model_cache_registry import (
27+ get_models_cache_dir ,
28+ get_registry_filename ,
29+ prune_model_cache_registry ,
30+ register_model_cache_file ,
31+ remove_cache_file_from_registry ,
32+ write_json_atomic ,
33+ )
2134
2235if TYPE_CHECKING :
2336 from basilisk .config import Account
2437
2538log = logging .getLogger (__name__ )
39+ _MODELS_CACHE_PAYLOAD_VERSION = 1
40+ _CACHE_PRUNE_INTERVAL_SECONDS = 3600
41+ _CACHE_STALE_MULTIPLIER = 7
2642
2743
2844class BaseEngine (ABC ):
@@ -39,6 +55,8 @@ class BaseEngine(ABC):
3955 capabilities : set [ProviderCapability ] = set ()
4056 supported_attachment_formats : set [str ] = set ()
4157 MODELS_JSON_URL : str | None = None
58+ _last_cache_prune_at : float = 0.0
59+ _cache_prune_lock = threading .Lock ()
4260
4361 def __init__ (self , account : Account ) -> None :
4462 """Initializes the engine with the given account.
@@ -76,28 +94,200 @@ def _load_models(self) -> list[ProviderAIModel]:
7694
7795 def _get_models_cache_ttl_seconds (self ) -> int :
7896 """Return model-list cache TTL in seconds from configuration."""
79- import basilisk .config as config
80-
8197 return config .conf ().general .model_metadata_cache_ttl_seconds
8298
99+ def _get_models_cache_max_stale_seconds (self , ttl_seconds : int ) -> int :
100+ """Return max age allowed for stale cache fallback."""
101+ return ttl_seconds * _CACHE_STALE_MULTIPLIER
102+
103+ @cached_property
104+ def _models_cache_file_path (self ) -> Path :
105+ """Return persistent cache file path for this engine/account."""
106+ cache_key_payload = {
107+ "account_id" : str (self .account .id ),
108+ "provider_id" : str (self .account .provider .id ),
109+ "base_url" : (
110+ str (self .account .custom_base_url )
111+ if self .account .custom_base_url is not None
112+ else None
113+ ),
114+ "engine_cls" : self .__class__ .__name__ ,
115+ "models_json_url" : self .MODELS_JSON_URL ,
116+ }
117+ cache_key = hashlib .sha256 (
118+ json .dumps (cache_key_payload , sort_keys = True ).encode ("utf-8" )
119+ ).hexdigest ()
120+ cache_dir = get_models_cache_dir ()
121+ return cache_dir / f"{ cache_key } .json"
122+
123+ def _set_models_ram_cache (
124+ self , models : list [ProviderAIModel ], cached_at : float
125+ ) -> list [ProviderAIModel ]:
126+ """Store models in RAM cache and return the stored list."""
127+ self ._models_cache = models
128+ self ._models_cached_at = cached_at
129+ return self ._models_cache
130+
131+ def _write_models_disk_cache (
132+ self , models : list [ProviderAIModel ], cached_at : float
133+ ) -> None :
134+ """Persist model cache payload to disk."""
135+ cache_file = self ._models_cache_file_path
136+ payload = {
137+ "version" : _MODELS_CACHE_PAYLOAD_VERSION ,
138+ "cached_at" : cached_at ,
139+ "models" : [asdict (model ) for model in models ],
140+ }
141+ write_json_atomic (cache_file , payload )
142+ register_model_cache_file (str (self .account .id ), cache_file )
143+
144+ def _delete_cache_file (self , cache_file : Path ) -> None :
145+ """Delete one cache file, logging only on failure."""
146+ try :
147+ cache_file .unlink (missing_ok = True )
148+ except OSError :
149+ log .debug ("Could not delete models cache file %s" , cache_file )
150+ remove_cache_file_from_registry (cache_file .name )
151+
152+ def _read_models_disk_cache (
153+ self ,
154+ now : float ,
155+ ttl_seconds : int ,
156+ allow_stale : bool = False ,
157+ max_stale_seconds : int | None = None ,
158+ ) -> tuple [list [ProviderAIModel ], float ] | None :
159+ """Read model cache payload from disk when valid for current TTL."""
160+ cache_file = self ._models_cache_file_path
161+ if not cache_file .exists ():
162+ return None
163+ try :
164+ payload = json .loads (cache_file .read_text (encoding = "utf-8" ))
165+ if not isinstance (payload , dict ):
166+ raise TypeError ("invalid cache payload" )
167+ if payload .get ("version" ) != _MODELS_CACHE_PAYLOAD_VERSION :
168+ raise ValueError ("unsupported cache payload version" )
169+ cached_at = float (payload ["cached_at" ])
170+ cache_age_seconds = now - cached_at
171+ if not allow_stale and cache_age_seconds >= ttl_seconds :
172+ log .debug (
173+ "Models disk cache expired for %s (age=%.1fs, ttl=%ss)" ,
174+ self .__class__ .__name__ ,
175+ cache_age_seconds ,
176+ ttl_seconds ,
177+ )
178+ return None
179+ if (
180+ allow_stale
181+ and max_stale_seconds is not None
182+ and cache_age_seconds >= max_stale_seconds
183+ ):
184+ log .debug (
185+ "Models stale disk cache exceeded retention for %s (age=%.1fs, max_stale=%ss)" ,
186+ self .__class__ .__name__ ,
187+ cache_age_seconds ,
188+ max_stale_seconds ,
189+ )
190+ self ._delete_cache_file (cache_file )
191+ return None
192+ model_rows = payload .get ("models" )
193+ if not isinstance (model_rows , list ):
194+ raise TypeError ("invalid models cache payload" )
195+ models = [ProviderAIModel (** x ) for x in model_rows ]
196+ return models , cached_at
197+ except (json .JSONDecodeError , KeyError , TypeError , ValueError ) as exc :
198+ log .warning ("Failed reading models disk cache: %s" , exc )
199+ self ._delete_cache_file (cache_file )
200+ return None
201+
202+ def _prune_models_cache_dir (self , now : float , ttl_seconds : int ) -> None :
203+ """Periodically remove obsolete cache files to limit file growth."""
204+ last_prune_at = BaseEngine ._last_cache_prune_at
205+ if (
206+ last_prune_at > 0
207+ and now - last_prune_at < _CACHE_PRUNE_INTERVAL_SECONDS
208+ ):
209+ return
210+ with BaseEngine ._cache_prune_lock :
211+ last_prune_at = BaseEngine ._last_cache_prune_at
212+ if (
213+ last_prune_at > 0
214+ and now - last_prune_at < _CACHE_PRUNE_INTERVAL_SECONDS
215+ ):
216+ return
217+ BaseEngine ._last_cache_prune_at = now
218+ prune_model_cache_registry ()
219+ cache_dir = get_models_cache_dir ()
220+ if not cache_dir .exists ():
221+ return
222+ max_stale_seconds = self ._get_models_cache_max_stale_seconds (
223+ ttl_seconds
224+ )
225+ for cache_file in cache_dir .glob ("*.json" ):
226+ if cache_file .name == get_registry_filename ():
227+ continue
228+ try :
229+ payload = json .loads (cache_file .read_text (encoding = "utf-8" ))
230+ cached_at = float (payload ["cached_at" ])
231+ version = payload .get ("version" )
232+ if version != _MODELS_CACHE_PAYLOAD_VERSION :
233+ self ._delete_cache_file (cache_file )
234+ continue
235+ if now - cached_at >= max_stale_seconds :
236+ self ._delete_cache_file (cache_file )
237+ except (
238+ OSError ,
239+ json .JSONDecodeError ,
240+ KeyError ,
241+ TypeError ,
242+ ValueError ,
243+ ):
244+ self ._delete_cache_file (cache_file )
245+
83246 @property
84247 def models (self ) -> list [ProviderAIModel ]:
85248 """Get models available for the provider.
86249
87250 Returns:
88251 List of supported provider models with their configurations.
89252 """
90- now = time .monotonic ()
253+ now = time .time ()
91254 ttl_seconds = self ._get_models_cache_ttl_seconds ()
255+ max_stale_seconds = self ._get_models_cache_max_stale_seconds (
256+ ttl_seconds
257+ )
258+ self ._prune_models_cache_dir (now , ttl_seconds )
92259 with self ._models_cache_lock :
93260 if (
94261 self ._models_cache is not None
95262 and self ._models_cached_at is not None
96263 and now - self ._models_cached_at < ttl_seconds
97264 ):
265+ log .debug (
266+ "Using models from RAM cache for %s" ,
267+ self .__class__ .__name__ ,
268+ )
269+ return self ._models_cache
270+ disk_cache = self ._read_models_disk_cache (now , ttl_seconds )
271+ if disk_cache is not None :
272+ models , cached_at = disk_cache
273+ with self ._models_cache_lock :
274+ self ._set_models_ram_cache (models , cached_at )
275+ self ._models_last_error = None
276+ log .debug (
277+ "Using models from disk cache for %s" ,
278+ self .__class__ .__name__ ,
279+ )
98280 return self ._models_cache
99281 try :
282+ log .debug (
283+ "Loading models from provider source for %s" ,
284+ self .__class__ .__name__ ,
285+ )
100286 models = self ._load_models ()
287+ try :
288+ self ._write_models_disk_cache (models , now )
289+ except OSError as write_exc :
290+ log .warning ("Failed writing models disk cache: %s" , write_exc )
101291 except Exception as exc :
102292 log .warning (
103293 "Failed to refresh models for %s: %s" ,
@@ -108,10 +298,24 @@ def models(self) -> list[ProviderAIModel]:
108298 self ._models_last_error = str (exc )
109299 if self ._models_cache is not None :
110300 return self ._models_cache
301+ stale_disk_cache = self ._read_models_disk_cache (
302+ now ,
303+ ttl_seconds ,
304+ allow_stale = True ,
305+ max_stale_seconds = max_stale_seconds ,
306+ )
307+ if stale_disk_cache is not None :
308+ models , cached_at = stale_disk_cache
309+ with self ._models_cache_lock :
310+ self ._set_models_ram_cache (models , cached_at )
311+ log .debug (
312+ "Using stale models from disk cache fallback for %s" ,
313+ self .__class__ .__name__ ,
314+ )
315+ return self ._models_cache
111316 return []
112317 with self ._models_cache_lock :
113- self ._models_cache = models
114- self ._models_cached_at = now
318+ self ._set_models_ram_cache (models , now )
115319 self ._models_last_error = None
116320 return self ._models_cache
117321
@@ -144,6 +348,7 @@ def invalidate_models_cache(self) -> None:
144348 self ._models_cache = None
145349 self ._models_cached_at = None
146350 self ._models_last_error = None
351+ self ._delete_cache_file (self ._models_cache_file_path )
147352
148353 @abstractmethod
149354 def prepare_message_request (self , message : Message ) -> Any :
0 commit comments