@@ -70,8 +70,10 @@ const FALLBACK_MODELS: ModelInfo[] = [
7070] ;
7171
7272const MODELS_API_URL = "https://www.claudechrome.com/api/models" ;
73+ const STORAGE_KEY = "cachedModelList" ;
74+ const STORAGE_TIMESTAMP_KEY = "cachedModelListTimestamp" ;
75+ const MAX_MODELS = 200 ;
7376
74- // Convert API pricing to price level
7577function getPriceLevel (
7678 pricing : ApiModelPricing ,
7779) : "cheap" | "normal" | "expensive" {
@@ -81,7 +83,6 @@ function getPriceLevel(
8183 return "expensive" ;
8284}
8385
84- // Convert API model to internal ModelInfo
8586function convertApiModel ( apiModel : ApiModel ) : ModelInfo {
8687 return {
8788 id : apiModel . id ,
@@ -97,15 +98,13 @@ function convertApiModel(apiModel: ApiModel): ModelInfo {
9798 } ;
9899}
99100
100- // Validate that the API response matches the expected schema
101101function isValidApiResponse ( data : unknown ) : data is ApiResponse {
102102 if ( typeof data !== "object" || data === null ) return false ;
103103 const obj = data as Record < string , unknown > ;
104104 if ( typeof obj . success !== "boolean" ) return false ;
105105 if ( typeof obj . data !== "object" || obj . data === null ) return false ;
106106 const d = obj . data as Record < string , unknown > ;
107107 if ( ! Array . isArray ( d . models ) ) return false ;
108- // Validate first model shape if present
109108 if ( d . models . length > 0 ) {
110109 const first = d . models [ 0 ] as Record < string , unknown > ;
111110 if ( typeof first . id !== "string" || typeof first . name !== "string" ) {
@@ -115,53 +114,164 @@ function isValidApiResponse(data: unknown): data is ApiResponse {
115114 return true ;
116115}
117116
118- // Cache for models
117+ // --- Persistent storage helpers ---
118+
119+ async function loadFromStorage ( ) : Promise < ModelInfo [ ] | null > {
120+ try {
121+ if ( typeof chrome !== "undefined" && chrome . storage ?. local ) {
122+ const result = await chrome . storage . local . get ( [ STORAGE_KEY ] ) ;
123+ const models = result [ STORAGE_KEY ] ;
124+ if ( Array . isArray ( models ) && models . length > 0 ) {
125+ return models as ModelInfo [ ] ;
126+ }
127+ }
128+ } catch {
129+ // Storage not available (e.g. in tests)
130+ }
131+ return null ;
132+ }
133+
134+ async function saveToStorage (
135+ models : ModelInfo [ ] ,
136+ serverTimestamp : number ,
137+ ) : Promise < void > {
138+ try {
139+ if ( typeof chrome !== "undefined" && chrome . storage ?. local ) {
140+ await chrome . storage . local . set ( {
141+ [ STORAGE_KEY ] : models ,
142+ [ STORAGE_TIMESTAMP_KEY ] : serverTimestamp ,
143+ } ) ;
144+ }
145+ } catch {
146+ // Ignore storage errors
147+ }
148+ }
149+
150+ async function getStoredTimestamp ( ) : Promise < number > {
151+ try {
152+ if ( typeof chrome !== "undefined" && chrome . storage ?. local ) {
153+ const result = await chrome . storage . local . get ( [ STORAGE_TIMESTAMP_KEY ] ) ;
154+ const ts = result [ STORAGE_TIMESTAMP_KEY ] ;
155+ if ( typeof ts === "number" ) return ts ;
156+ }
157+ } catch {
158+ // Ignore
159+ }
160+ return 0 ;
161+ }
162+
163+ // --- In-memory cache (fast path) ---
164+
119165let cachedModels : ModelInfo [ ] | null = null ;
120- let lastFetchTime = 0 ;
121- const CACHE_DURATION = 5 * 60 * 1000 ; // 5 minutes
122- const MAX_MODELS = 200 ; // Safety cap on number of models
166+ let cachedServerTimestamp = 0 ;
167+ let storageLoaded = false ;
123168
124169/**
125- * Fetch models from the server API with caching and fallback.
126- * Returns cached result if still valid (5 min TTL).
127- * Falls back to FALLBACK_MODELS on any error.
170+ * Fetch models with a two-tier cache:
171+ * 1. In-memory cache (instant)
172+ * 2. chrome.storage.local (survives service worker restarts)
173+ *
174+ * On the first call, returns storage-cached models immediately.
175+ * A background fetch updates both caches when the server reports new data.
128176 */
129177export async function fetchModels ( ) : Promise < ModelInfo [ ] > {
130- // Return cached models if still valid
131- if ( cachedModels && Date . now ( ) - lastFetchTime < CACHE_DURATION ) {
178+ // 1. Fast path: in-memory cache
179+ if ( cachedModels ) {
180+ // Trigger background refresh (fire-and-forget)
181+ void refreshFromServer ( ) ;
132182 return cachedModels ;
133183 }
134184
185+ // 2. Try loading from persistent storage
186+ if ( ! storageLoaded ) {
187+ storageLoaded = true ;
188+ const stored = await loadFromStorage ( ) ;
189+ if ( stored ) {
190+ cachedModels = stored ;
191+ cachedServerTimestamp = await getStoredTimestamp ( ) ;
192+ // Trigger background refresh
193+ void refreshFromServer ( ) ;
194+ return cachedModels ;
195+ }
196+ }
197+
198+ // 3. Nothing cached: fetch synchronously and return
199+ return await fetchFromServer ( ) ;
200+ }
201+
202+ let refreshInFlight = false ;
203+
204+ async function refreshFromServer ( ) : Promise < void > {
205+ if ( refreshInFlight ) return ;
206+ refreshInFlight = true ;
135207 try {
136- const response = await fetch ( MODELS_API_URL ) ;
137- console . log ( "response" , response ) ;
208+ await fetchFromServer ( ) ;
209+ } finally {
210+ refreshInFlight = false ;
211+ }
212+ }
138213
214+ async function fetchFromServer ( ) : Promise < ModelInfo [ ] > {
215+ try {
216+ const response = await fetch ( MODELS_API_URL ) ;
139217 if ( ! response . ok ) {
140218 throw new Error ( `API request failed: ${ response . status } ` ) ;
141219 }
142220
143221 const data : unknown = await response . json ( ) ;
144- console . log ( "data" , data ) ;
145222
146223 if ( ! isValidApiResponse ( data ) ) {
147224 throw new Error ( "Invalid API response structure" ) ;
148225 }
149226
150227 if ( data . success && data . data . models . length > 0 ) {
151- // Apply safety cap
152- const models = data . data . models . slice ( 0 , MAX_MODELS ) . map ( convertApiModel ) ;
153- cachedModels = models ;
154- lastFetchTime = Date . now ( ) ;
155- return cachedModels ;
228+ const serverTimestamp = data . data . cache ?. lastUpdate ?? Date . now ( ) ;
229+
230+ // Only update if the server data is newer
231+ if ( serverTimestamp > cachedServerTimestamp ) {
232+ const models = data . data . models
233+ . slice ( 0 , MAX_MODELS )
234+ . map ( convertApiModel ) ;
235+ cachedModels = models ;
236+ cachedServerTimestamp = serverTimestamp ;
237+ await saveToStorage ( models , serverTimestamp ) ;
238+ // Notify listeners that models changed
239+ notifyModelChange ( models ) ;
240+ }
241+
242+ return cachedModels ?? FALLBACK_MODELS ;
156243 }
157244
158245 throw new Error ( "Empty model list from API" ) ;
159- } catch ( _error ) {
160- // Return fallback - do not log sensitive details
161- return FALLBACK_MODELS ;
246+ } catch {
247+ return cachedModels ?? FALLBACK_MODELS ;
162248 }
163249}
164250
251+ // --- Change notification for components ---
252+
253+ type ModelChangeListener = ( models : ModelInfo [ ] ) => void ;
254+ const modelChangeListeners = new Set < ModelChangeListener > ( ) ;
255+
256+ function notifyModelChange ( models : ModelInfo [ ] ) : void {
257+ for ( const listener of modelChangeListeners ) {
258+ try {
259+ listener ( models ) ;
260+ } catch {
261+ // Don't let listener errors break the loop
262+ }
263+ }
264+ }
265+
266+ /**
267+ * Subscribe to model list updates (triggered when server returns new data).
268+ * Returns an unsubscribe function.
269+ */
270+ export function onModelListChange ( listener : ModelChangeListener ) : ( ) => void {
271+ modelChangeListeners . add ( listener ) ;
272+ return ( ) => modelChangeListeners . delete ( listener ) ;
273+ }
274+
165275/**
166276 * Fetch models and convert to the {name, value} format used by the model selector.
167277 */
0 commit comments