1use anyhow::{Context, Result};
18use jsonschema::validator_for;
19use sha2::{Digest, Sha256};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use crate::config::types::{ProviderConfig, RegistryConfig};
23
24use super::embedded::{
25 embedded_manifest, embedded_provider_schema, embedded_providers,
26 embedded_transcription_providers,
27};
28use super::store::RegistryStore;
29use super::types::{RegistryManifest, RegistryProvider, RegistryTranscriptionProvider};
30
31#[allow(async_fn_in_trait)]
33pub trait RegistryFetcherTrait {
34 async fn fetch_manifest(&self) -> Result<RegistryManifest>;
35 async fn fetch_provider(
36 &self,
37 provider_id: &str,
38 manifest: &RegistryManifest,
39 ) -> Result<RegistryProvider>;
40 async fn fetch_transcription_provider(
41 &self,
42 provider_id: &str,
43 manifest: &RegistryManifest,
44 ) -> Result<RegistryTranscriptionProvider>;
45}
46
47impl RegistryFetcherTrait for super::RegistryFetcher {
48 async fn fetch_manifest(&self) -> Result<RegistryManifest> {
49 self.fetch_manifest().await
50 }
51
52 async fn fetch_provider(
53 &self,
54 provider_id: &str,
55 manifest: &RegistryManifest,
56 ) -> Result<RegistryProvider> {
57 self.fetch_provider(provider_id, manifest).await
58 }
59
60 async fn fetch_transcription_provider(
61 &self,
62 provider_id: &str,
63 manifest: &RegistryManifest,
64 ) -> Result<RegistryTranscriptionProvider> {
65 self.fetch_transcription_provider(provider_id, manifest)
66 .await
67 }
68}
69
70const META_LAST_CHECK: &str = "last_registry_check";
72const META_LAST_SUCCESS: &str = "last_registry_success";
73const META_MANIFEST_JSON: &str = "registry_manifest_json";
74const META_REHYDRATE_FP: &str = "canonical_models_rehydrate_fp";
77
78#[derive(Debug, Clone)]
80pub struct LoadedRegistry {
81 pub manifest: RegistryManifest,
82 pub providers: Vec<ProviderConfig>,
83 pub source: RegistrySource,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RegistrySource {
89 Cache,
91 Embedded,
93 MinimalFallback,
95}
96
97pub fn load_registry(store: &RegistryStore) -> LoadedRegistry {
105 if let Some(loaded) = load_cached_registry(store) {
107 let merged = merge_embedded_provider_updates(store);
110
111 maybe_rehydrate_from_catalog(store, merged);
115
116 if merged {
118 if let Some(reloaded) = load_cached_registry(store) {
119 tracing::info!(
120 version = reloaded.manifest.version,
121 providers = reloaded.providers.len(),
122 "loaded registry from local cache (after embedded merge)"
123 );
124 return reloaded;
125 }
126 }
127 tracing::debug!(
128 version = loaded.manifest.version,
129 providers = loaded.providers.len(),
130 "loaded registry from local cache"
131 );
132 return loaded;
133 }
134
135 if let Some(loaded) = load_embedded_registry() {
136 tracing::info!(
137 version = loaded.manifest.version,
138 providers = loaded.providers.len(),
139 "loaded registry from embedded snapshot"
140 );
141 if let Err(err) = seed_cache_from_embedded(store) {
143 tracing::warn!(error = %err, "failed to seed cache from embedded snapshot");
144 }
145 return loaded;
146 }
147
148 tracing::error!("failed to load embedded registry snapshot, using minimal fallback");
149 LoadedRegistry {
150 manifest: RegistryManifest {
151 version: 0,
152 updated_at: "1970-01-01T00:00:00Z".to_string(),
153 providers: std::collections::HashMap::new(),
154 transcription_providers: Default::default(),
155 coverage: Default::default(),
156 models: Default::default(),
157 },
158 providers: minimal_fallback_providers(),
159 source: RegistrySource::MinimalFallback,
160 }
161}
162
163fn merge_embedded_provider_updates(store: &RegistryStore) -> bool {
177 let embedded_manifest = match embedded_manifest() {
178 Ok(m) => m,
179 Err(_) => return false,
180 };
181 let embedded_providers = match embedded_providers() {
182 Ok(p) => p,
183 Err(_) => return false,
184 };
185
186 let mut updated = 0;
187 for ep in &embedded_providers {
188 let id = &ep.id;
189 let embedded_sha = embedded_manifest
190 .providers
191 .get(id)
192 .map(|e| e.sha256.as_str());
193 let cached_sha = store.provider_sha256(id).ok().flatten();
194
195 let needs_update = match (&cached_sha, embedded_sha) {
196 (Some(cached), Some(embedded)) if cached == crate::registry::LOCAL_API_SYNC_SHA => {
200 let key = format!("api_sync_catalog_sha:{id}");
201 store.meta_get(&key).ok().flatten().as_deref() != Some(embedded)
202 }
203 (Some(cached), Some(embedded)) => cached != embedded,
204 (None, Some(_)) => store
207 .load_provider_models(id)
208 .map(|m| m.is_empty())
209 .unwrap_or(true),
210 (None, None) => false,
211 (Some(_), None) => false,
212 };
213
214 if !needs_update {
215 continue;
216 }
217
218 let preserve_api_sync = cached_sha.as_deref() == Some(crate::registry::LOCAL_API_SYNC_SHA);
219 let sha_to_write = if preserve_api_sync {
220 Some(crate::registry::LOCAL_API_SYNC_SHA)
221 } else {
222 embedded_sha
223 };
224
225 if let Err(err) = store.upsert_provider_union_models(ep, sha_to_write) {
228 tracing::warn!(
229 provider = id,
230 error = %err,
231 "failed to upsert embedded provider update"
232 );
233 } else {
234 if preserve_api_sync && let Some(embedded) = embedded_sha {
235 let key = format!("api_sync_catalog_sha:{id}");
236 let _ = store.meta_set(&key, embedded);
237 }
238 updated += 1;
239 }
240 }
241
242 let keep: std::collections::HashSet<&str> = embedded_manifest
245 .providers
246 .keys()
247 .map(|s| s.as_str())
248 .collect();
249 if let Err(err) = store.delete_providers_not_in(&keep) {
250 tracing::warn!(error = %err, "failed to prune stale providers against embedded catalog");
251 }
252
253 let mut tx_updated = 0;
255 if let Ok(tx_providers) = embedded_transcription_providers() {
256 for ep in &tx_providers {
257 let embedded_sha = embedded_manifest
258 .transcription_providers
259 .get(&ep.id)
260 .map(|e| e.sha256.as_str());
261 let cached_sha = store.transcription_provider_sha256(&ep.id).ok().flatten();
262 let needs_update = match (&cached_sha, embedded_sha) {
263 (Some(cached), Some(embedded)) => cached != embedded,
264 (None, Some(_)) => true,
265 _ => false,
266 };
267 if needs_update {
268 if let Err(err) = store.upsert_transcription_provider(ep, embedded_sha) {
269 tracing::warn!(
270 provider = %ep.id,
271 error = %err,
272 "failed to upsert embedded transcription provider"
273 );
274 } else {
275 tx_updated += 1;
276 }
277 }
278 }
279 }
280
281 let tx_keep: std::collections::HashSet<&str> = embedded_manifest
282 .transcription_providers
283 .keys()
284 .map(|s| s.as_str())
285 .collect();
286 if let Err(err) = store.delete_transcription_providers_not_in(&tx_keep) {
287 tracing::warn!(
288 error = %err,
289 "failed to prune stale transcription providers against embedded catalog"
290 );
291 }
292
293 if updated > 0 || tx_updated > 0 {
294 tracing::info!(
295 updated_providers = updated,
296 updated_transcription = tx_updated,
297 "merged embedded provider updates into local cache"
298 );
299 let _ = store.save_manifest_meta(&embedded_manifest);
301 let _ = save_registry_metadata(store, &embedded_manifest, None, None);
302 return true;
303 }
304 false
305}
306
307fn maybe_rehydrate_from_catalog(store: &RegistryStore, force: bool) {
310 let fingerprint = catalog_rehydrate_fingerprint(store);
311 if !force {
312 if let Ok(Some(prev)) = store.meta_get(META_REHYDRATE_FP) {
313 if prev == fingerprint {
314 return;
315 }
316 }
317 }
318 match store.rehydrate_provider_models_from_catalog() {
319 Ok(_) => {
320 let _ = store.meta_set(META_REHYDRATE_FP, &fingerprint);
321 }
322 Err(err) => {
323 tracing::warn!(error = %err, "failed to rehydrate models from canonical catalog");
324 }
325 }
326}
327
328fn catalog_rehydrate_fingerprint(store: &RegistryStore) -> String {
329 let version = store
331 .manifest_version()
332 .ok()
333 .flatten()
334 .or_else(|| embedded_manifest().ok().map(|m| m.version))
335 .unwrap_or(0);
336 let models = store
337 .load_canonical_model_catalog()
338 .map(|c| c.len())
339 .unwrap_or(0);
340 format!("v{version}:m{models}")
341}
342
343pub fn load_cached_registry(store: &RegistryStore) -> Option<LoadedRegistry> {
345 let manifest = store_stored_manifest(store).ok().flatten()?;
346 let providers = store.load_all_providers().ok()?;
347 if providers.is_empty() {
348 return None;
349 }
350 Some(LoadedRegistry {
351 manifest,
352 providers,
353 source: RegistrySource::Cache,
354 })
355}
356
357pub fn load_embedded_registry() -> Option<LoadedRegistry> {
359 let manifest = embedded_manifest().ok()?;
360 let providers = embedded_providers().ok()?;
361 Some(LoadedRegistry {
362 manifest,
363 providers: providers
364 .into_iter()
365 .map(super::store::registry_provider_to_config)
366 .collect(),
367 source: RegistrySource::Embedded,
368 })
369}
370
371fn seed_cache_from_embedded(store: &RegistryStore) -> Result<()> {
373 let manifest = embedded_manifest().context("failed to parse embedded manifest")?;
374 let providers = embedded_providers().context("failed to parse embedded providers")?;
375 store.replace_all(&providers)?;
376 if let Ok(tx_providers) = embedded_transcription_providers() {
377 for p in &tx_providers {
378 let sha = manifest
379 .transcription_providers
380 .get(&p.id)
381 .map(|e| e.sha256.as_str());
382 store.upsert_transcription_provider(p, sha)?;
383 }
384 }
385 store.save_manifest_meta(&manifest)?;
386 save_registry_metadata(
387 store,
388 &manifest,
389 Some(current_timestamp_secs()),
390 Some(current_timestamp_secs()),
391 )?;
392 Ok(())
393}
394
395fn minimal_fallback_providers() -> Vec<ProviderConfig> {
397 use crate::config::types::{ModelTaskSize, ProviderModelConfig};
398
399 vec![ProviderConfig {
400 id: "openai".to_string(),
401 label: "OpenAI".to_string(),
402 description: "OpenAI API key required".to_string(),
403 kind: crate::config::types::ProviderKind::OpenAiResponses,
404 api_key_env: "OPENAI_API_KEY".to_string(),
405 base_url: Some("https://api.openai.com/v1".to_string()),
406 models: vec![ProviderModelConfig {
407 name: "gpt-5.1".to_string(),
408 task_size: Some(ModelTaskSize::Large),
409 context_window_tokens: Some(1_000_000),
410 max_output_tokens: None,
411 recommended_temperature: None,
412 supports_thinking: None,
413 supports_images: None,
414 supports_audio: None,
415 supports_video: None,
416 supports_documents: None,
417 tool_prompt_manifest: None,
418 pricing_input_per_1m: None,
419 pricing_output_per_1m: None,
420 reasoning_levels: Vec::new(),
421 default_reasoning_effort: None,
422 }],
423 request_options: crate::config::providers::default_request_options_for("openai"),
424 ..Default::default()
425 }]
426}
427
428pub fn should_check_registry_update(store: &RegistryStore, config: &RegistryConfig) -> bool {
430 if !config.update_enabled {
431 return false;
432 }
433
434 let last_check = store
435 .meta_get(META_LAST_CHECK)
436 .ok()
437 .flatten()
438 .and_then(|s| s.parse::<u64>().ok())
439 .unwrap_or(0);
440
441 let now = current_timestamp_secs();
442 let elapsed = now.saturating_sub(last_check);
443 let interval = config.check_interval_hours.saturating_mul(3600);
444
445 elapsed >= interval
446}
447
448pub fn registry_check_interval_with_jitter(config: &RegistryConfig) -> u64 {
450 let base = config.check_interval_hours.saturating_mul(3600);
451 let jitter = if config.check_jitter_hours > 0 {
452 fastrand::u64(0..=config.check_jitter_hours.saturating_mul(3600))
453 } else {
454 0
455 };
456 base.saturating_add(jitter)
457}
458
459pub async fn check_registry_manifest(
464 store: &RegistryStore,
465 fetcher: &impl RegistryFetcherTrait,
466 config: &RegistryConfig,
467) -> Result<Option<RegistryManifest>> {
468 let local = store_stored_manifest(store)
469 .ok()
470 .flatten()
471 .unwrap_or_else(|| {
472 embedded_manifest().unwrap_or_else(|_| RegistryManifest {
473 version: 0,
474 updated_at: "1970-01-01T00:00:00Z".to_string(),
475 providers: std::collections::HashMap::new(),
476 transcription_providers: Default::default(),
477 coverage: Default::default(),
478 models: Default::default(),
479 })
480 });
481
482 let manifest = fetch_manifest_with_retry(fetcher, config).await?;
483
484 if manifest.version == local.version && provider_hashes_equal(&manifest, &local) {
486 save_registry_metadata(store, &manifest, Some(current_timestamp_secs()), None)?;
487 tracing::debug!(
488 version = manifest.version,
489 "remote manifest unchanged, no update needed"
490 );
491 return Ok(None);
492 }
493
494 Ok(Some(manifest))
495}
496
497pub async fn download_registry_updates(
501 store: &RegistryStore,
502 fetcher: &impl RegistryFetcherTrait,
503 manifest: &RegistryManifest,
504 config: &RegistryConfig,
505) -> Result<Vec<RegistryProvider>> {
506 let mut to_fetch = Vec::new();
507
508 for (provider_id, entry) in &manifest.providers {
509 let cached_sha = store.provider_sha256(provider_id)?;
510 if cached_sha.as_deref() != Some(&entry.sha256) {
511 to_fetch.push(provider_id.as_str());
512 }
513 }
514
515 if to_fetch.is_empty() {
516 tracing::debug!("all provider hashes match, nothing to download");
517 return Ok(Vec::new());
518 }
519
520 tracing::info!(
521 changed = to_fetch.len(),
522 total = manifest.providers.len(),
523 "downloading changed registry providers"
524 );
525
526 let schema = embedded_provider_schema();
527 let mut providers = Vec::with_capacity(to_fetch.len());
528
529 const MAX_PARALLEL: usize = 8;
532 for chunk in to_fetch.chunks(MAX_PARALLEL) {
533 let fetch_futs: Vec<_> = chunk
534 .iter()
535 .map(|provider_id| {
536 let pid = (*provider_id).to_string();
537 async move {
538 let provider =
539 fetch_provider_with_retry(fetcher, pid.as_str(), manifest, config).await?;
540 Ok::<_, anyhow::Error>(provider)
541 }
542 })
543 .collect();
544 let fetched = futures_util::future::try_join_all(fetch_futs).await?;
545 for provider in fetched {
546 validate_registry_schema(&provider, schema)?;
547 providers.push(provider);
548 }
549 }
550
551 validate_registry_hashes(&providers, manifest)?;
552
553 Ok(providers)
554}
555
556pub fn validate_registry_schema(
558 provider: &RegistryProvider,
559 schema_json: Option<&str>,
560) -> Result<()> {
561 if let Some(schema_json) = schema_json {
562 let schema_value: serde_json::Value = serde_json::from_str(schema_json)
563 .context("failed to parse embedded provider schema")?;
564 let validator = validator_for(&schema_value)
565 .map_err(|e| anyhow::anyhow!("failed to build schema validator: {e}"))?;
566 let provider_value = serde_json::to_value(provider)
567 .context("failed to serialize provider for validation")?;
568 if let Err(error) = validator.validate(&provider_value) {
569 anyhow::bail!("provider schema validation failed: {}", error);
570 }
571 }
572 Ok(())
573}
574
575pub fn validate_registry_hashes(
577 providers: &[RegistryProvider],
578 manifest: &RegistryManifest,
579) -> Result<()> {
580 for provider in providers {
581 let entry = manifest
582 .providers
583 .get(&provider.id)
584 .with_context(|| format!("provider '{}' not in manifest", provider.id))?;
585 let provider_json = serde_json::to_string(provider).with_context(|| {
586 format!(
587 "failed to serialize provider '{}' for hash check",
588 provider.id
589 )
590 })?;
591 let hash = hex::encode(Sha256::digest(provider_json.as_bytes()));
592 if hash != entry.sha256 {
593 anyhow::bail!(
594 "provider '{}' hash mismatch: expected {}, got {}",
595 provider.id,
596 entry.sha256,
597 hash
598 );
599 }
600 }
601 Ok(())
602}
603
604pub fn apply_registry_update_atomically(
611 store: &RegistryStore,
612 manifest: &RegistryManifest,
613 providers: &[RegistryProvider],
614) -> Result<()> {
615 apply_registry_update_atomically_with_transcription(store, manifest, providers, &[])
616}
617
618pub fn apply_registry_update_atomically_with_transcription(
620 store: &RegistryStore,
621 manifest: &RegistryManifest,
622 providers: &[RegistryProvider],
623 transcription: &[RegistryTranscriptionProvider],
624) -> Result<()> {
625 let keep: std::collections::HashSet<&str> =
626 manifest.providers.keys().map(|s| s.as_str()).collect();
627
628 for provider in providers {
629 let sha = manifest
630 .providers
631 .get(&provider.id)
632 .map(|e| e.sha256.as_str());
633 store.upsert_provider_union_models(provider, sha)?;
634 }
635
636 store.delete_providers_not_in(&keep)?;
637
638 let tx_keep: std::collections::HashSet<&str> = manifest
639 .transcription_providers
640 .keys()
641 .map(|s| s.as_str())
642 .collect();
643 for provider in transcription {
644 let sha = manifest
645 .transcription_providers
646 .get(&provider.id)
647 .map(|e| e.sha256.as_str());
648 store.upsert_transcription_provider(provider, sha)?;
649 }
650 store.delete_transcription_providers_not_in(&tx_keep)?;
651
652 store.save_manifest_meta(manifest)?;
653 let _ = store.meta_set(META_REHYDRATE_FP, "");
655 crate::config::providers::invalidate_registry_catalog_cache();
656
657 Ok(())
658}
659
660pub fn save_registry_metadata(
662 store: &RegistryStore,
663 manifest: &RegistryManifest,
664 last_check: Option<u64>,
665 last_success: Option<u64>,
666) -> Result<()> {
667 if let Some(ts) = last_check {
668 store.meta_set(META_LAST_CHECK, &ts.to_string())?;
669 }
670 if let Some(ts) = last_success {
671 store.meta_set(META_LAST_SUCCESS, &ts.to_string())?;
672 }
673 let manifest_json =
674 serde_json::to_string(manifest).context("failed to serialize manifest metadata")?;
675 store.meta_set(META_MANIFEST_JSON, &manifest_json)?;
676 Ok(())
677}
678
679pub async fn run_registry_update_check(
683 store: &RegistryStore,
684 fetcher: &impl RegistryFetcherTrait,
685 config: &RegistryConfig,
686) -> bool {
687 if !config.update_enabled {
688 return false;
689 }
690
691 let now = current_timestamp_secs();
693 if let Err(err) = save_registry_metadata(
694 store,
695 ¤t_stored_manifest_or_embedded(store),
696 Some(now),
697 None,
698 ) {
699 tracing::warn!(error = %err, "failed to save registry check timestamp");
700 }
701
702 let manifest = match check_registry_manifest(store, fetcher, config).await {
703 Ok(Some(m)) => m,
704 Ok(None) => {
705 tracing::debug!("registry update check: no changes");
706 return false;
707 }
708 Err(err) => {
709 tracing::warn!(error = %err, "registry manifest check failed, keeping existing registry");
710 return false;
711 }
712 };
713
714 let providers = match download_registry_updates(store, fetcher, &manifest, config).await {
715 Ok(p) => p,
716 Err(err) => {
717 tracing::warn!(error = %err, "registry update download failed, keeping existing registry");
718 return false;
719 }
720 };
721
722 let transcription =
723 match download_transcription_updates(store, fetcher, &manifest, config).await {
724 Ok(p) => p,
725 Err(err) => {
726 tracing::warn!(
727 error = %err,
728 "transcription registry update download failed, keeping existing registry"
729 );
730 return false;
731 }
732 };
733
734 if let Err(err) = apply_registry_update_atomically_with_transcription(
735 store,
736 &manifest,
737 &providers,
738 &transcription,
739 ) {
740 tracing::warn!(error = %err, "failed to apply registry update, keeping previous registry");
741 return false;
742 }
743
744 if let Err(err) = save_registry_metadata(store, &manifest, Some(now), Some(now)) {
745 tracing::warn!(error = %err, "failed to save registry success timestamp");
746 }
747
748 tracing::info!(
749 version = manifest.version,
750 providers = manifest.providers.len(),
751 transcription_providers = manifest.transcription_providers.len(),
752 "registry cache updated from remote"
753 );
754
755 true
756}
757
758fn store_stored_manifest(store: &RegistryStore) -> Result<Option<RegistryManifest>> {
761 match store.meta_get(META_MANIFEST_JSON)? {
762 Some(json) => Ok(Some(
763 serde_json::from_str(&json).context("failed to parse stored manifest metadata")?,
764 )),
765 None => Ok(None),
766 }
767}
768
769fn current_stored_manifest_or_embedded(store: &RegistryStore) -> RegistryManifest {
770 store_stored_manifest(store)
771 .ok()
772 .flatten()
773 .unwrap_or_else(|| {
774 embedded_manifest().unwrap_or_else(|_| RegistryManifest {
775 version: 0,
776 updated_at: "1970-01-01T00:00:00Z".to_string(),
777 providers: std::collections::HashMap::new(),
778 transcription_providers: Default::default(),
779 coverage: Default::default(),
780 models: Default::default(),
781 })
782 })
783}
784
785fn provider_hashes_equal(a: &RegistryManifest, b: &RegistryManifest) -> bool {
786 if a.providers.len() != b.providers.len() {
787 return false;
788 }
789 if a.transcription_providers.len() != b.transcription_providers.len() {
790 return false;
791 }
792 let llm_ok = a.providers.iter().all(|(id, entry)| {
793 b.providers
794 .get(id)
795 .map(|other| entry.sha256 == other.sha256)
796 .unwrap_or(false)
797 });
798 if !llm_ok {
799 return false;
800 }
801 a.transcription_providers.iter().all(|(id, entry)| {
802 b.transcription_providers
803 .get(id)
804 .map(|other| entry.sha256 == other.sha256)
805 .unwrap_or(false)
806 })
807}
808
809pub async fn download_transcription_updates(
811 store: &RegistryStore,
812 fetcher: &impl RegistryFetcherTrait,
813 manifest: &RegistryManifest,
814 config: &RegistryConfig,
815) -> Result<Vec<RegistryTranscriptionProvider>> {
816 let mut to_fetch = Vec::new();
817 for (provider_id, entry) in &manifest.transcription_providers {
818 let cached_sha = store.transcription_provider_sha256(provider_id)?;
819 if cached_sha.as_deref() != Some(&entry.sha256) {
820 to_fetch.push(provider_id.as_str());
821 }
822 }
823
824 if to_fetch.is_empty() {
825 return Ok(Vec::new());
826 }
827
828 tracing::info!(
829 changed = to_fetch.len(),
830 total = manifest.transcription_providers.len(),
831 "downloading changed transcription providers"
832 );
833
834 let mut providers = Vec::with_capacity(to_fetch.len());
835 for provider_id in &to_fetch {
836 let provider =
837 fetch_transcription_with_retry(fetcher, provider_id, manifest, config).await?;
838 providers.push(provider);
839 }
840 Ok(providers)
841}
842
843async fn fetch_transcription_with_retry(
844 fetcher: &impl RegistryFetcherTrait,
845 provider_id: &str,
846 manifest: &RegistryManifest,
847 config: &RegistryConfig,
848) -> Result<RegistryTranscriptionProvider> {
849 let mut last_err = None;
850 let attempts = config.max_retries.saturating_add(1).max(1);
851 for attempt in 1..=attempts {
852 match fetcher
853 .fetch_transcription_provider(provider_id, manifest)
854 .await
855 {
856 Ok(p) => return Ok(p),
857 Err(err) => {
858 tracing::debug!(
859 attempt,
860 provider = provider_id,
861 error = %err,
862 "transcription provider fetch failed"
863 );
864 last_err = Some(err);
865 if attempt < attempts {
866 tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64))
867 .await;
868 }
869 }
870 }
871 }
872 let err = last_err.ok_or_else(|| {
873 anyhow::anyhow!(
874 "failed to fetch transcription provider '{provider_id}' after retries (no error recorded)"
875 )
876 })?;
877 Err(err).context(format!(
878 "failed to fetch transcription provider '{provider_id}' after retries"
879 ))
880}
881
882async fn fetch_manifest_with_retry(
883 fetcher: &impl RegistryFetcherTrait,
884 config: &RegistryConfig,
885) -> Result<RegistryManifest> {
886 let mut last_err = None;
887 let attempts = config.max_retries.saturating_add(1).max(1);
888 for attempt in 1..=attempts {
889 match fetcher.fetch_manifest().await {
890 Ok(m) => return Ok(m),
891 Err(err) => {
892 tracing::debug!(attempt, error = %err, "manifest fetch failed");
893 last_err = Some(err);
894 if attempt < attempts {
895 tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64))
896 .await;
897 }
898 }
899 }
900 }
901 let err = last_err.ok_or_else(|| {
902 anyhow::anyhow!("failed to fetch registry manifest after retries (no error recorded)")
903 })?;
904 Err(err).context("failed to fetch registry manifest after retries")
905}
906
907async fn fetch_provider_with_retry(
908 fetcher: &impl RegistryFetcherTrait,
909 provider_id: &str,
910 manifest: &RegistryManifest,
911 config: &RegistryConfig,
912) -> Result<RegistryProvider> {
913 let mut last_err = None;
914 let attempts = config.max_retries.saturating_add(1).max(1);
915 for attempt in 1..=attempts {
916 match fetcher.fetch_provider(provider_id, manifest).await {
917 Ok(p) => return Ok(p),
918 Err(err) => {
919 tracing::debug!(attempt, provider = provider_id, error = %err, "provider fetch failed");
920 last_err = Some(err);
921 if attempt < attempts {
922 tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64))
923 .await;
924 }
925 }
926 }
927 }
928 let err = last_err.ok_or_else(|| {
929 anyhow::anyhow!(
930 "failed to fetch provider '{provider_id}' after retries (no error recorded)"
931 )
932 })?;
933 Err(err).context(format!(
934 "failed to fetch provider '{provider_id}' after retries"
935 ))
936}
937
938pub fn current_timestamp_secs() -> u64 {
939 SystemTime::now()
940 .duration_since(UNIX_EPOCH)
941 .unwrap_or_default()
942 .as_secs()
943}
944
945#[cfg(test)]
946mod tests {
947 use super::super::types::{ManifestProviderEntry, RegistryModel};
948 use super::*;
949
950 fn test_provider(id: &str, name: &str) -> RegistryProvider {
951 RegistryProvider {
952 id: id.to_string(),
953 label: id.to_string(),
954 description: "test".to_string(),
955 kind: "openai-chat-completions".to_string(),
956 api_key_env: "TEST_API_KEY".to_string(),
957 base_url: None,
958 extends: None,
959 tool_calling_mode: None,
960 aggregator: false,
961 defaults: Default::default(),
962 request_options: Default::default(),
963 models: vec![RegistryModel {
964 model_ref: None,
965 api_name: None,
966 name: name.to_string(),
967 task_size: Some("large".to_string()),
968 context_window_tokens: Some(128_000),
969 max_output_tokens: None,
970 recommended_temperature: None,
971 supports_thinking: None,
972 reasoning_levels: Vec::new(),
973 default_reasoning_effort: None,
974 supports_attachments: None,
975 supports_images: None,
976 supports_audio: None,
977 supports_video: None,
978 supports_documents: None,
979 attachments: Default::default(),
980 capabilities: Vec::new(),
981 pricing: None,
982 }],
983 }
984 }
985
986 fn provider_hash(provider: &RegistryProvider) -> String {
987 let json = serde_json::to_string(provider).expect("serialize");
988 hex::encode(Sha256::digest(json.as_bytes()))
989 }
990
991 fn manifest_entry(provider: &RegistryProvider) -> ManifestProviderEntry {
992 ManifestProviderEntry {
993 file: format!("providers/{}.json", provider.id),
994 sha256: provider_hash(provider),
995 model_count: provider.models.len(),
996 }
997 }
998
999 #[test]
1000 fn should_check_after_default_interval() {
1001 let store = RegistryStore::open_memory().expect("open");
1002 let config = RegistryConfig::default();
1003
1004 assert!(should_check_registry_update(&store, &config));
1006
1007 let now = current_timestamp_secs();
1009 save_registry_metadata(
1010 &store,
1011 &RegistryManifest {
1012 version: 1,
1013 updated_at: "2026-01-01T00:00:00Z".to_string(),
1014 providers: std::collections::HashMap::new(),
1015 transcription_providers: Default::default(),
1016 coverage: Default::default(),
1017 models: Default::default(),
1018 },
1019 Some(now),
1020 None,
1021 )
1022 .expect("save");
1023 assert!(!should_check_registry_update(&store, &config));
1024
1025 let past = now.saturating_sub(25 * 3600);
1027 save_registry_metadata(
1028 &store,
1029 &RegistryManifest {
1030 version: 1,
1031 updated_at: "2026-01-01T00:00:00Z".to_string(),
1032 providers: std::collections::HashMap::new(),
1033 transcription_providers: Default::default(),
1034 coverage: Default::default(),
1035 models: Default::default(),
1036 },
1037 Some(past),
1038 None,
1039 )
1040 .expect("save");
1041 assert!(should_check_registry_update(&store, &config));
1042 }
1043
1044 #[test]
1045 fn should_not_check_when_disabled() {
1046 let store = RegistryStore::open_memory().expect("open");
1047 let mut config = RegistryConfig::default();
1048 config.update_enabled = false;
1049 assert!(!should_check_registry_update(&store, &config));
1050 }
1051
1052 #[test]
1053 fn load_embedded_registry_returns_providers() {
1054 let loaded = load_embedded_registry().expect("embedded registry should load");
1055 assert!(!loaded.providers.is_empty());
1056 assert_eq!(loaded.source, RegistrySource::Embedded);
1057 }
1058
1059 #[test]
1060 fn load_registry_seeds_empty_cache_from_embedded() {
1061 let store = RegistryStore::open_memory().expect("open");
1062 assert!(store.is_empty().unwrap());
1063
1064 let loaded = load_registry(&store);
1065 assert_eq!(loaded.source, RegistrySource::Embedded);
1066 assert!(!loaded.providers.is_empty());
1067
1068 assert!(!store.is_empty().unwrap());
1070 assert!(load_cached_registry(&store).is_some());
1071 }
1072
1073 #[test]
1074 fn load_registry_prefers_cache_when_populated() {
1075 let store = RegistryStore::open_memory().expect("open");
1076 let embedded_manifest = super::embedded_manifest().expect("manifest");
1077 let embedded_providers = super::embedded_providers().expect("providers");
1078 store.replace_all(&embedded_providers).expect("seed");
1079 store
1080 .save_manifest_meta(&embedded_manifest)
1081 .expect("save meta");
1082 save_registry_metadata(&store, &embedded_manifest, None, None).expect("save manifest json");
1083
1084 let loaded = load_registry(&store);
1085 assert_eq!(loaded.source, RegistrySource::Cache);
1086 }
1087
1088 #[test]
1089 fn load_registry_prunes_providers_removed_from_embedded_catalog() {
1090 let store = RegistryStore::open_memory().expect("open");
1094 let embedded_manifest = super::embedded_manifest().expect("manifest");
1095 let embedded_providers = super::embedded_providers().expect("providers");
1096 store.replace_all(&embedded_providers).expect("seed");
1097 store
1098 .save_manifest_meta(&embedded_manifest)
1099 .expect("save meta");
1100 save_registry_metadata(&store, &embedded_manifest, None, None).expect("save manifest json");
1101
1102 let stale = test_provider("stale-removed-provider", "ghost-model");
1103 store
1104 .upsert_provider_with_sha256(&stale, Some(crate::registry::LOCAL_API_SYNC_SHA))
1105 .expect("inject stale provider");
1106 assert!(
1107 store
1108 .load_all_providers()
1109 .expect("load")
1110 .iter()
1111 .any(|p| p.id == "stale-removed-provider"),
1112 "precondition: stale provider must be present before load"
1113 );
1114
1115 let loaded = load_registry(&store);
1116 assert_eq!(loaded.source, RegistrySource::Cache);
1117 assert!(
1118 loaded
1119 .providers
1120 .iter()
1121 .all(|p| p.id != "stale-removed-provider"),
1122 "load_registry must drop providers absent from the embedded catalog"
1123 );
1124 assert!(
1125 store
1126 .load_all_providers()
1127 .expect("reload")
1128 .iter()
1129 .all(|p| p.id != "stale-removed-provider"),
1130 "stale provider must be deleted from the cache"
1131 );
1132 }
1133
1134 #[test]
1135 fn apply_registry_update_atomically_replaces_providers() {
1136 let store = RegistryStore::open_memory().expect("open");
1137 let provider = test_provider("test", "test-model");
1138 let mut manifest = RegistryManifest {
1139 version: 1,
1140 updated_at: "2026-01-01T00:00:00Z".to_string(),
1141 providers: std::collections::HashMap::new(),
1142 transcription_providers: Default::default(),
1143 coverage: Default::default(),
1144 models: Default::default(),
1145 };
1146 manifest
1147 .providers
1148 .insert(provider.id.clone(), manifest_entry(&provider));
1149
1150 apply_registry_update_atomically(&store, &manifest, &[provider]).expect("apply");
1151
1152 let loaded = store.load_all_providers().expect("load");
1153 assert_eq!(loaded.len(), 1);
1154 assert_eq!(loaded[0].id, "test");
1155 }
1156
1157 #[test]
1158 fn validate_registry_hashes_detects_mismatch() {
1159 let provider = test_provider("test", "test-model");
1160 let mut manifest = RegistryManifest {
1161 version: 1,
1162 updated_at: "2026-01-01T00:00:00Z".to_string(),
1163 providers: std::collections::HashMap::new(),
1164 transcription_providers: Default::default(),
1165 coverage: Default::default(),
1166 models: Default::default(),
1167 };
1168 manifest.providers.insert(
1169 provider.id.clone(),
1170 ManifestProviderEntry {
1171 file: "providers/test.json".to_string(),
1172 sha256: "bad-hash".to_string(),
1173 model_count: 1,
1174 },
1175 );
1176
1177 assert!(validate_registry_hashes(&[provider], &manifest).is_err());
1178 }
1179
1180 #[test]
1181 fn validate_registry_hashes_accepts_correct_hash() {
1182 let provider = test_provider("test", "test-model");
1183 let mut manifest = RegistryManifest {
1184 version: 1,
1185 updated_at: "2026-01-01T00:00:00Z".to_string(),
1186 providers: std::collections::HashMap::new(),
1187 transcription_providers: Default::default(),
1188 coverage: Default::default(),
1189 models: Default::default(),
1190 };
1191 manifest
1192 .providers
1193 .insert(provider.id.clone(), manifest_entry(&provider));
1194
1195 assert!(validate_registry_hashes(&[provider], &manifest).is_ok());
1196 }
1197
1198 struct MockFetcher {
1202 manifest: Option<RegistryManifest>,
1203 manifest_err: Option<String>,
1204 providers: std::collections::HashMap<String, Result<RegistryProvider, String>>,
1205 }
1206
1207 impl MockFetcher {
1208 fn new() -> Self {
1209 Self {
1210 manifest: None,
1211 manifest_err: Some("not configured".to_string()),
1212 providers: std::collections::HashMap::new(),
1213 }
1214 }
1215
1216 fn with_manifest(mut self, manifest: RegistryManifest) -> Self {
1217 self.manifest = Some(manifest);
1218 self.manifest_err = None;
1219 self
1220 }
1221
1222 fn with_manifest_error(mut self, err: &str) -> Self {
1223 self.manifest = None;
1224 self.manifest_err = Some(err.to_string());
1225 self
1226 }
1227
1228 fn with_provider(mut self, provider: RegistryProvider) -> Self {
1229 self.providers.insert(provider.id.clone(), Ok(provider));
1230 self
1231 }
1232
1233 fn with_provider_error(mut self, id: &str, err: &str) -> Self {
1234 self.providers.insert(id.to_string(), Err(err.to_string()));
1235 self
1236 }
1237 }
1238
1239 impl RegistryFetcherTrait for MockFetcher {
1240 async fn fetch_manifest(&self) -> Result<RegistryManifest> {
1241 self.manifest
1242 .clone()
1243 .ok_or_else(|| anyhow::anyhow!(self.manifest_err.clone().unwrap_or_default()))
1244 }
1245
1246 async fn fetch_provider(
1247 &self,
1248 provider_id: &str,
1249 _manifest: &RegistryManifest,
1250 ) -> Result<RegistryProvider> {
1251 self.providers
1252 .get(provider_id)
1253 .cloned()
1254 .map(|r| r.map_err(|e| anyhow::anyhow!(e)))
1255 .unwrap_or_else(|| {
1256 Err(anyhow::anyhow!(
1257 "provider {provider_id} not configured in mock"
1258 ))
1259 })
1260 }
1261
1262 async fn fetch_transcription_provider(
1263 &self,
1264 provider_id: &str,
1265 _manifest: &RegistryManifest,
1266 ) -> Result<RegistryTranscriptionProvider> {
1267 Err(anyhow::anyhow!(
1268 "transcription provider {provider_id} not configured in mock"
1269 ))
1270 }
1271 }
1272
1273 #[tokio::test]
1274 async fn first_startup_without_cache_seeds_from_embedded() {
1275 let store = RegistryStore::open_memory().expect("open");
1276 assert!(store.is_empty().unwrap());
1277
1278 let loaded = load_registry(&store);
1279 assert!(!loaded.providers.is_empty());
1280 assert!(!store.is_empty().unwrap());
1282 }
1283
1284 #[tokio::test]
1285 async fn startup_with_valid_cache_uses_cache() {
1286 let store = RegistryStore::open_memory().expect("open");
1287 let provider = test_provider("cached", "cached-model");
1288 let mut manifest = RegistryManifest {
1289 version: 1,
1290 updated_at: "2026-01-01T00:00:00Z".to_string(),
1291 providers: std::collections::HashMap::new(),
1292 transcription_providers: Default::default(),
1293 coverage: Default::default(),
1294 models: Default::default(),
1295 };
1296 manifest
1297 .providers
1298 .insert(provider.id.clone(), manifest_entry(&provider));
1299 store.replace_all(&[provider]).expect("seed");
1300 store.save_manifest_meta(&manifest).expect("save meta");
1301 save_registry_metadata(&store, &manifest, Some(current_timestamp_secs()), None)
1302 .expect("save meta");
1303
1304 let loaded = load_registry(&store);
1305 assert_eq!(loaded.source, RegistrySource::Cache);
1306 assert!(!loaded.providers.is_empty());
1307 }
1308
1309 #[tokio::test]
1310 async fn remote_manifest_equal_to_local_no_update() {
1311 let store = RegistryStore::open_memory().expect("open");
1312 let provider = test_provider("test", "model-1");
1313 let mut manifest = RegistryManifest {
1314 version: 1,
1315 updated_at: "2026-01-01T00:00:00Z".to_string(),
1316 providers: std::collections::HashMap::new(),
1317 transcription_providers: Default::default(),
1318 coverage: Default::default(),
1319 models: Default::default(),
1320 };
1321 manifest
1322 .providers
1323 .insert(provider.id.clone(), manifest_entry(&provider));
1324 store.replace_all(&[provider]).expect("seed");
1325 store.save_manifest_meta(&manifest).expect("save meta");
1326 save_registry_metadata(&store, &manifest, Some(current_timestamp_secs()), None)
1327 .expect("save meta");
1328
1329 let fetcher = MockFetcher::new().with_manifest(manifest);
1330 let config = RegistryConfig::default();
1331 let result = check_registry_manifest(&store, &fetcher, &config).await;
1332 assert!(result.is_ok());
1333 assert!(result.unwrap().is_none());
1334 }
1335
1336 #[tokio::test]
1337 async fn newer_remote_manifest_triggers_update() {
1338 let store = RegistryStore::open_memory().expect("open");
1339 let old_provider = test_provider("old", "old-model");
1341 let mut old_manifest = RegistryManifest {
1342 version: 1,
1343 updated_at: "2026-01-01T00:00:00Z".to_string(),
1344 providers: std::collections::HashMap::new(),
1345 transcription_providers: Default::default(),
1346 coverage: Default::default(),
1347 models: Default::default(),
1348 };
1349 old_manifest
1350 .providers
1351 .insert(old_provider.id.clone(), manifest_entry(&old_provider));
1352 store.replace_all(&[old_provider]).expect("seed");
1353 store.save_manifest_meta(&old_manifest).expect("save meta");
1354 save_registry_metadata(&store, &old_manifest, Some(current_timestamp_secs()), None)
1355 .expect("save meta");
1356
1357 let new_provider = test_provider("new", "new-model");
1359 let mut new_manifest = RegistryManifest {
1360 version: 2,
1361 updated_at: "2026-07-03T00:00:00Z".to_string(),
1362 providers: std::collections::HashMap::new(),
1363 transcription_providers: Default::default(),
1364 coverage: Default::default(),
1365 models: Default::default(),
1366 };
1367 new_manifest
1368 .providers
1369 .insert(new_provider.id.clone(), manifest_entry(&new_provider));
1370
1371 let fetcher = MockFetcher::new()
1372 .with_manifest(new_manifest.clone())
1373 .with_provider(new_provider);
1374
1375 let config = RegistryConfig::default();
1376 let result = check_registry_manifest(&store, &fetcher, &config).await;
1377 assert!(result.is_ok());
1378 let remote = result.unwrap().unwrap();
1379 assert_eq!(remote.version, 2);
1380 }
1381
1382 #[tokio::test]
1383 async fn network_failure_keeps_existing_registry() {
1384 let store = RegistryStore::open_memory().expect("open");
1385 let provider = test_provider("existing", "model-1");
1386 let mut manifest = RegistryManifest {
1387 version: 1,
1388 updated_at: "2026-01-01T00:00:00Z".to_string(),
1389 providers: std::collections::HashMap::new(),
1390 transcription_providers: Default::default(),
1391 coverage: Default::default(),
1392 models: Default::default(),
1393 };
1394 manifest
1395 .providers
1396 .insert(provider.id.clone(), manifest_entry(&provider));
1397 store.replace_all(&[provider]).expect("seed");
1398 store.save_manifest_meta(&manifest).expect("save meta");
1399 save_registry_metadata(&store, &manifest, Some(current_timestamp_secs()), None)
1400 .expect("save meta");
1401
1402 let fetcher = MockFetcher::new().with_manifest_error("network error");
1403 let config = RegistryConfig::default();
1404 let result = check_registry_manifest(&store, &fetcher, &config).await;
1405 assert!(result.is_err());
1406
1407 let loaded = load_registry(&store);
1409 assert_eq!(loaded.source, RegistrySource::Cache);
1410 assert!(!loaded.providers.is_empty());
1411 }
1412
1413 #[tokio::test]
1414 async fn invalid_remote_json_returns_error() {
1415 let store = RegistryStore::open_memory().expect("open");
1416
1417 let fetcher = MockFetcher::new().with_manifest_error("invalid JSON");
1418 let config = RegistryConfig::default();
1419 let result = check_registry_manifest(&store, &fetcher, &config).await;
1420 assert!(result.is_err());
1421 }
1422
1423 #[tokio::test]
1424 async fn invalid_hash_rejected() {
1425 let provider = test_provider("bad-hash", "model-1");
1426 let mut manifest = RegistryManifest {
1427 version: 1,
1428 updated_at: "2026-01-01T00:00:00Z".to_string(),
1429 providers: std::collections::HashMap::new(),
1430 transcription_providers: Default::default(),
1431 coverage: Default::default(),
1432 models: Default::default(),
1433 };
1434 manifest.providers.insert(
1435 provider.id.clone(),
1436 ManifestProviderEntry {
1437 file: "providers/bad-hash.json".to_string(),
1438 sha256: "0000000000000000000000000000000000000000000000000000000000000000"
1439 .to_string(),
1440 model_count: 1,
1441 },
1442 );
1443
1444 assert!(validate_registry_hashes(&[provider], &manifest).is_err());
1445 }
1446
1447 #[tokio::test]
1448 async fn partial_update_failure_keeps_previous() {
1449 let store = RegistryStore::open_memory().expect("open");
1450 let old_provider = test_provider("old", "old-model");
1451 let mut old_manifest = RegistryManifest {
1452 version: 1,
1453 updated_at: "2026-01-01T00:00:00Z".to_string(),
1454 providers: std::collections::HashMap::new(),
1455 transcription_providers: Default::default(),
1456 coverage: Default::default(),
1457 models: Default::default(),
1458 };
1459 old_manifest
1460 .providers
1461 .insert(old_provider.id.clone(), manifest_entry(&old_provider));
1462 store.replace_all(&[old_provider]).expect("seed");
1463 store.save_manifest_meta(&old_manifest).expect("save meta");
1464 save_registry_metadata(&store, &old_manifest, Some(current_timestamp_secs()), None)
1465 .expect("save meta");
1466
1467 let good_provider = test_provider("good", "good-model");
1469 let bad_provider = test_provider("bad", "bad-model");
1470 let mut new_manifest = RegistryManifest {
1471 version: 2,
1472 updated_at: "2026-07-03T00:00:00Z".to_string(),
1473 providers: std::collections::HashMap::new(),
1474 transcription_providers: Default::default(),
1475 coverage: Default::default(),
1476 models: Default::default(),
1477 };
1478 new_manifest
1479 .providers
1480 .insert(good_provider.id.clone(), manifest_entry(&good_provider));
1481 new_manifest
1482 .providers
1483 .insert(bad_provider.id.clone(), manifest_entry(&bad_provider));
1484
1485 let fetcher = MockFetcher::new()
1486 .with_manifest(new_manifest)
1487 .with_provider(good_provider)
1488 .with_provider_error("bad", "network error for bad provider");
1489
1490 let config = RegistryConfig::default();
1491 let updated = run_registry_update_check(&store, &fetcher, &config).await;
1492 assert!(!updated, "update should have failed");
1493
1494 let loaded = store.load_all_providers().expect("load");
1496 assert_eq!(loaded.len(), 1);
1497 assert_eq!(loaded[0].id, "old");
1498 }
1499
1500 #[tokio::test]
1501 async fn rollback_to_previous_registry_on_apply_failure() {
1502 let store = RegistryStore::open_memory().expect("open");
1503 let old_provider = test_provider("old", "old-model");
1504 let mut old_manifest = RegistryManifest {
1505 version: 1,
1506 updated_at: "2026-01-01T00:00:00Z".to_string(),
1507 providers: std::collections::HashMap::new(),
1508 transcription_providers: Default::default(),
1509 coverage: Default::default(),
1510 models: Default::default(),
1511 };
1512 old_manifest
1513 .providers
1514 .insert(old_provider.id.clone(), manifest_entry(&old_provider));
1515 store.replace_all(&[old_provider]).expect("seed");
1516 store.save_manifest_meta(&old_manifest).expect("save meta");
1517
1518 let new_provider = test_provider("new", "new-model");
1520 let mut new_manifest = RegistryManifest {
1521 version: 2,
1522 updated_at: "2026-07-03T00:00:00Z".to_string(),
1523 providers: std::collections::HashMap::new(),
1524 transcription_providers: Default::default(),
1525 coverage: Default::default(),
1526 models: Default::default(),
1527 };
1528 new_manifest.providers.insert(
1529 new_provider.id.clone(),
1530 ManifestProviderEntry {
1531 file: "providers/new.json".to_string(),
1532 sha256: "wrong-hash".to_string(),
1533 model_count: 1,
1534 },
1535 );
1536
1537 assert!(validate_registry_hashes(&[new_provider], &new_manifest).is_err());
1539
1540 let loaded = store.load_all_providers().expect("load");
1542 assert_eq!(loaded.len(), 1);
1543 assert_eq!(loaded[0].id, "old");
1544 }
1545
1546 #[test]
1547 fn respecting_interval_with_jitter() {
1548 let store = RegistryStore::open_memory().expect("open");
1549 let config = RegistryConfig::default();
1550 let now = current_timestamp_secs();
1551
1552 save_registry_metadata(
1554 &store,
1555 &RegistryManifest {
1556 version: 1,
1557 updated_at: "2026-01-01T00:00:00Z".to_string(),
1558 providers: std::collections::HashMap::new(),
1559 transcription_providers: Default::default(),
1560 coverage: Default::default(),
1561 models: Default::default(),
1562 },
1563 Some(now),
1564 None,
1565 )
1566 .expect("save");
1567 assert!(!should_check_registry_update(&store, &config));
1568
1569 let past = now.saturating_sub(31 * 3600);
1571 save_registry_metadata(
1572 &store,
1573 &RegistryManifest {
1574 version: 1,
1575 updated_at: "2026-01-01T00:00:00Z".to_string(),
1576 providers: std::collections::HashMap::new(),
1577 transcription_providers: Default::default(),
1578 coverage: Default::default(),
1579 models: Default::default(),
1580 },
1581 Some(past),
1582 None,
1583 )
1584 .expect("save");
1585 assert!(should_check_registry_update(&store, &config));
1586
1587 let interval = registry_check_interval_with_jitter(&config);
1589 let base = config.check_interval_hours * 3600;
1590 let max = base + config.check_jitter_hours * 3600;
1591 assert!(interval >= base && interval <= max);
1592 }
1593}