返回 CodeWhale
provider_catalog_live.rs
根目录 / crates / tui / src / provider_catalog_live.rs
1 //! Durable, secret-free per-provider `/models` catalog cache.
2 //!
3 //! This is the persistence owner for [`codewhale_config::catalog::ProviderCatalogCache`].
4 //! It replaces the process-only client refresh and provider_lake CLI cache writers: successful
5 //! refreshes replace one exact `(provider kind, identity, base URL fingerprint)`
6 //! partition, failures retain that partition's prior rows, and startup loads
7 //! only the active route's exact partition. Credentials authorize the fetch in
8 //! `client`; they never enter this module or its disk envelope. Baseten and
9 //! Codewhale account rosters are memory-only and cleared before each refresh;
10 //! a named custom route at either official endpoint follows the same rule.
11 //!
12 //! This deliberately does not import the legacy `model_catalog` cache at
13 //! `catalog/openrouter.json`: that file has no provider/base-URL scope, so
14 //! treating it as a provider-owned roster could leak stale facts across custom
15 //! endpoints. `model_catalog` remains a read-only compatibility fallback for
16 //! older model-metadata consumers while provider-lake/runtime consumers migrate;
17 //! `catalog/provider-catalogs.json` is the sole writer-owned live roster store.
18 //! Older per-endpoint `provider-*.json` files also lack the built-in/custom
19 //! kind boundary and account-roster exclusion, so they are left untouched and
20 //! replaced only by a newly authenticated refresh into this store.
21
22 use std::collections::BTreeMap;
23 use std::fs::{self, OpenOptions};
24 use std::io::Read as _;
25 use std::path::{Path, PathBuf};
26 use std::sync::atomic::{AtomicBool, Ordering};
27 use std::sync::{LazyLock, RwLock};
28
29 use anyhow::{Context, Result};
30 use codewhale_config::catalog::now_unix;
31 use codewhale_config::catalog::{
32 CatalogRefreshError, CatalogSnapshot, CatalogStatus, ProviderCatalogCache,
33 ProviderCatalogDelta, base_url_fingerprint,
34 };
35 use codewhale_config::persistence::atomic_write_json;
36 use codewhale_config::pricing::{Currency, OfferingPricing, PricingProvenance};
37 use serde::{Deserialize, Serialize};
38
39 use crate::config::{ApiProvider, Config};
40
41 const CACHE_SCHEMA_VERSION: u32 = 2;
42 const CACHE_FILE: &str = "provider-catalogs.json";
43 const MAX_CACHE_BYTES: u64 = 32 * 1024 * 1024;
44 const MAX_CACHE_SCOPES: usize = 64;
45 const MAX_CACHE_ROWS: usize = 50_000;
46
47 #[derive(Debug, Clone, Copy)]
48 struct CachePersistenceLimits {
49 max_bytes: u64,
50 max_scopes: usize,
51 max_rows: usize,
52 }
53
54 const CACHE_PERSISTENCE_LIMITS: CachePersistenceLimits = CachePersistenceLimits {
55 max_bytes: MAX_CACHE_BYTES,
56 max_scopes: MAX_CACHE_SCOPES,
57 max_rows: MAX_CACHE_ROWS,
58 };
59
60 /// Provider-owned catalogs are refreshed daily. Past-TTL rows remain visible
61 /// with an explicit stale receipt until a successful replacement arrives.
62 pub const DEFAULT_PROVIDER_CATALOG_TTL_SECS: u64 = 24 * 60 * 60;
63
64 static DISK_LOADED: AtomicBool = AtomicBool::new(false);
65
66 static CACHE: LazyLock<RwLock<ProviderCatalogCache>> =
67 LazyLock::new(|| RwLock::new(ProviderCatalogCache::new()));
68 static REFRESH_GENERATIONS: LazyLock<RwLock<BTreeMap<String, u64>>> =
69 LazyLock::new(|| RwLock::new(BTreeMap::new()));
70
71 #[derive(Debug, Clone)]
72 pub struct ProviderCatalogRefreshTicket {
73 provider: String,
74 provider_kind: ApiProvider,
75 fingerprint: Option<String>,
76 generation: u64,
77 }
78
79 /// Immutable, secret-free catalog rate evidence captured at dispatch.
80 ///
81 /// Rates are stored as canonical decimal strings rather than `f64` so route
82 /// receipts retain exact equality and stable JSON. `catalog_revision` binds
83 /// every identity, scope, timestamp, currency, provenance, and rate field; it
84 /// therefore changes even when two refreshes land in the same Unix second.
85 #[derive(Debug, Clone, PartialEq, Eq)]
86 pub struct ProviderLivePricingQuote {
87 pub(crate) provider: ApiProvider,
88 pub(crate) provider_identity: String,
89 pub(crate) wire_model: String,
90 pub(crate) endpoint_fingerprint: String,
91 pub(crate) catalog_fetched_at: u64,
92 pub(crate) catalog_revision: String,
93 pub(crate) currency: Currency,
94 pub(crate) provenance: PricingProvenance,
95 pub(crate) cloud_facts: Option<CloudFactsPricingSource>,
96 pub(crate) input_per_million: Option<String>,
97 pub(crate) output_per_million: Option<String>,
98 pub(crate) cache_read_per_million: Option<String>,
99 pub(crate) cache_write_per_million: Option<String>,
100 }
101
102 /// Signed source identity and validity, bound into a frozen rate receipt.
103 /// The base URL is admitted only through the canonical official-endpoint
104 /// contract; custom URLs or credential-bearing URLs cannot enter this field.
105 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106 pub(crate) struct CloudFactsPricingSource {
107 pub(crate) facts_version: u64,
108 pub(crate) key_id: String,
109 pub(crate) valid_until: Option<u64>,
110 pub(crate) base_url: String,
111 }
112
113 #[derive(Serialize, Deserialize)]
114 struct ProviderLivePricingQuoteWire {
115 provider: ApiProvider,
116 provider_identity: String,
117 wire_model: String,
118 endpoint_fingerprint: String,
119 catalog_fetched_at: u64,
120 catalog_revision: String,
121 currency: Currency,
122 provenance: PricingProvenance,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 cloud_facts: Option<CloudFactsPricingSource>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 input_per_million: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 output_per_million: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 cache_read_per_million: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 cache_write_per_million: Option<String>,
133 }
134
135 impl From<&ProviderLivePricingQuote> for ProviderLivePricingQuoteWire {
136 fn from(quote: &ProviderLivePricingQuote) -> Self {
137 Self {
138 provider: quote.provider,
139 provider_identity: quote.provider_identity.clone(),
140 wire_model: quote.wire_model.clone(),
141 endpoint_fingerprint: quote.endpoint_fingerprint.clone(),
142 catalog_fetched_at: quote.catalog_fetched_at,
143 catalog_revision: quote.catalog_revision.clone(),
144 currency: quote.currency.clone(),
145 provenance: quote.provenance.clone(),
146 cloud_facts: quote.cloud_facts.clone(),
147 input_per_million: quote.input_per_million.clone(),
148 output_per_million: quote.output_per_million.clone(),
149 cache_read_per_million: quote.cache_read_per_million.clone(),
150 cache_write_per_million: quote.cache_write_per_million.clone(),
151 }
152 }
153 }
154
155 impl Serialize for ProviderLivePricingQuote {
156 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
157 where
158 S: serde::Serializer,
159 {
160 if !self.is_structurally_valid() {
161 return serializer.serialize_none();
162 }
163 ProviderLivePricingQuoteWire::from(self).serialize(serializer)
164 }
165 }
166
167 impl<'de> Deserialize<'de> for ProviderLivePricingQuote {
168 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
169 where
170 D: serde::Deserializer<'de>,
171 {
172 let wire = ProviderLivePricingQuoteWire::deserialize(deserializer)?;
173 let quote = Self {
174 provider: wire.provider,
175 provider_identity: wire.provider_identity,
176 wire_model: wire.wire_model,
177 endpoint_fingerprint: wire.endpoint_fingerprint,
178 catalog_fetched_at: wire.catalog_fetched_at,
179 catalog_revision: wire.catalog_revision,
180 currency: wire.currency,
181 provenance: wire.provenance,
182 cloud_facts: wire.cloud_facts,
183 input_per_million: wire.input_per_million,
184 output_per_million: wire.output_per_million,
185 cache_read_per_million: wire.cache_read_per_million,
186 cache_write_per_million: wire.cache_write_per_million,
187 };
188 quote
189 .is_structurally_valid()
190 .then_some(quote)
191 .ok_or_else(|| serde::de::Error::custom("invalid provider-live pricing quote"))
192 }
193 }
194
195 pub(crate) fn deserialize_optional_provider_live_pricing<'de, D>(
196 deserializer: D,
197 ) -> std::result::Result<Option<ProviderLivePricingQuote>, D::Error>
198 where
199 D: serde::Deserializer<'de>,
200 {
201 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
202 Ok(value.and_then(|value| serde_json::from_value(value).ok()))
203 }
204
205 impl ProviderLivePricingQuote {
206 fn is_structurally_valid(&self) -> bool {
207 self.pricing_for_route(
208 self.provider,
209 &self.provider_identity,
210 &self.wire_model,
211 &self.endpoint_fingerprint,
212 self.catalog_fetched_at,
213 )
214 .is_some()
215 }
216 fn canonical_rate(rate: Option<f64>) -> Option<String> {
217 rate.map(|rate| rate.to_string())
218 }
219
220 fn revision_for(
221 provider: ApiProvider,
222 provider_identity: &str,
223 wire_model: &str,
224 endpoint_fingerprint: &str,
225 catalog_fetched_at: u64,
226 currency: &Currency,
227 provenance: &PricingProvenance,
228 input_per_million: &Option<String>,
229 output_per_million: &Option<String>,
230 cache_read_per_million: &Option<String>,
231 cache_write_per_million: &Option<String>,
232 cloud_facts: Option<&CloudFactsPricingSource>,
233 ) -> Option<String> {
234 let payload = serde_json::to_vec(&(
235 "codewhale-provider-live-pricing-quote-v1",
236 provider,
237 provider_identity,
238 wire_model,
239 endpoint_fingerprint,
240 catalog_fetched_at,
241 currency,
242 provenance,
243 input_per_million,
244 output_per_million,
245 cache_read_per_million,
246 cache_write_per_million,
247 ))
248 .ok()?;
249 // Preserve the existing provider-live wire revision. The additional
250 // cloud source is independently domain-separated and hashes the whole
251 // original binding as well as the signed version/key/expiry.
252 let payload = match cloud_facts {
253 Some(source) => {
254 serde_json::to_vec(&("codewhale-cloud-facts-pricing-quote-v1", payload, source))
255 .ok()?
256 }
257 None => payload,
258 };
259 Some(format!("sha256:{}", crate::hashing::sha256_hex(payload)))
260 }
261
262 fn from_pricing(
263 provider: ApiProvider,
264 provider_identity: &str,
265 wire_model: &str,
266 endpoint_fingerprint: &str,
267 catalog_fetched_at: u64,
268 pricing: &OfferingPricing,
269 ) -> Option<Self> {
270 let provider_identity = provider_identity.trim();
271 let wire_model = wire_model.trim();
272 if crate::cost_status::sanitize_persisted_route_label(provider_identity)
273 != provider_identity
274 || crate::cost_status::sanitize_persisted_route_label(wire_model) != wire_model
275 {
276 return None;
277 }
278 let input_per_million = Self::canonical_rate(pricing.input_per_million);
279 let output_per_million = Self::canonical_rate(pricing.output_per_million);
280 let cache_read_per_million = Self::canonical_rate(pricing.cache_read_per_million);
281 let cache_write_per_million = Self::canonical_rate(pricing.cache_write_per_million);
282 let catalog_revision = Self::revision_for(
283 provider,
284 provider_identity,
285 wire_model,
286 endpoint_fingerprint,
287 catalog_fetched_at,
288 &pricing.currency,
289 &pricing.provenance,
290 &input_per_million,
291 &output_per_million,
292 &cache_read_per_million,
293 &cache_write_per_million,
294 None,
295 )?;
296 Some(Self {
297 provider,
298 provider_identity: provider_identity.to_string(),
299 wire_model: wire_model.to_string(),
300 endpoint_fingerprint: endpoint_fingerprint.to_string(),
301 catalog_fetched_at,
302 catalog_revision,
303 currency: pricing.currency.clone(),
304 provenance: pricing.provenance.clone(),
305 cloud_facts: None,
306 input_per_million,
307 output_per_million,
308 cache_read_per_million,
309 cache_write_per_million,
310 })
311 }
312
313 fn parse_rate(rate: &Option<String>) -> Option<Option<f64>> {
314 let Some(rate) = rate else {
315 return Some(None);
316 };
317 let parsed = rate.parse::<f64>().ok()?;
318 (parsed.is_finite() && parsed >= 0.0 && parsed.to_string() == *rate).then_some(Some(parsed))
319 }
320
321 /// Rehydrate the frozen row only when every receipt binding is intact.
322 /// This is deliberately cache-free: a refresh after dispatch cannot alter
323 /// an earlier turn, while malformed or legacy receipts fail closed.
324 pub(crate) fn pricing_for_route(
325 &self,
326 provider: ApiProvider,
327 provider_identity: &str,
328 wire_model: &str,
329 endpoint_fingerprint: &str,
330 dispatched_at_unix: u64,
331 ) -> Option<OfferingPricing> {
332 let provider_identity = provider_identity.trim();
333 let wire_model = wire_model.trim();
334 if self.provider != provider
335 || crate::cost_status::sanitize_persisted_route_label(&self.provider_identity)
336 != self.provider_identity
337 || crate::cost_status::sanitize_persisted_route_label(&self.wire_model)
338 != self.wire_model
339 || self.endpoint_fingerprint.len() != 64
340 || !self
341 .endpoint_fingerprint
342 .bytes()
343 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
344 || self.provider_identity != provider_identity
345 || self.wire_model != wire_model
346 || self.endpoint_fingerprint != endpoint_fingerprint
347 || self.catalog_fetched_at > dispatched_at_unix
348 || self.currency != Currency::Usd
349 {
350 return None;
351 }
352 match (&self.provenance, &self.cloud_facts) {
353 (PricingProvenance::UserOverride, None) => {}
354 (PricingProvenance::ProviderLive, None)
355 if dispatched_at_unix.saturating_sub(self.catalog_fetched_at)
356 < DEFAULT_PROVIDER_CATALOG_TTL_SECS
357 && reviewed_provider_live_scope(
358 provider,
359 provider_identity,
360 endpoint_fingerprint,
361 ) => {}
362 (PricingProvenance::CloudFacts, Some(source))
363 if source.facts_version > 0
364 && !source.key_id.is_empty()
365 && source.key_id.len() <= 128
366 && source
367 .key_id
368 .bytes()
369 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
370 && source
371 .valid_until
372 .is_none_or(|expires| dispatched_at_unix <= expires)
373 && cloud_pricing_scope(provider, provider_identity, &source.base_url)
374 && base_url_fingerprint(&source.base_url) == endpoint_fingerprint => {}
375 _ => return None,
376 }
377 let input_per_million = Self::parse_rate(&self.input_per_million)?;
378 let output_per_million = Self::parse_rate(&self.output_per_million)?;
379 let cache_read_per_million = Self::parse_rate(&self.cache_read_per_million)?;
380 let cache_write_per_million = Self::parse_rate(&self.cache_write_per_million)?;
381 let cost = codewhale_config::models_dev::ModelsDevCost {
382 input: input_per_million,
383 output: output_per_million,
384 cache_read: cache_read_per_million,
385 cache_write: cache_write_per_million,
386 };
387 if !codewhale_config::pricing::catalog_cost_is_valid(&cost) {
388 return None;
389 }
390 // A reviewed per-token route needs both ordinary request classes. Cache
391 // classes remain optional and fail closed later if a turn used them.
392 if self.provenance == PricingProvenance::ProviderLive
393 && (cost.input.is_none() || cost.output.is_none())
394 {
395 return None;
396 }
397 if cost.input.is_none()
398 && cost.output.is_none()
399 && cost.cache_read.is_none()
400 && cost.cache_write.is_none()
401 && self.provenance != PricingProvenance::UserOverride
402 {
403 return None;
404 }
405 let expected_revision = Self::revision_for(
406 self.provider,
407 &self.provider_identity,
408 &self.wire_model,
409 &self.endpoint_fingerprint,
410 self.catalog_fetched_at,
411 &self.currency,
412 &self.provenance,
413 &self.input_per_million,
414 &self.output_per_million,
415 &self.cache_read_per_million,
416 &self.cache_write_per_million,
417 self.cloud_facts.as_ref(),
418 )?;
419 if self.catalog_revision != expected_revision {
420 return None;
421 }
422 Some(OfferingPricing {
423 provider: self.provider_identity.clone(),
424 wire_model_id: self.wire_model.clone(),
425 canonical_model: None,
426 currency: self.currency.clone(),
427 input_per_million: cost.input,
428 output_per_million: cost.output,
429 cache_read_per_million: cost.cache_read,
430 cache_write_per_million: cost.cache_write,
431 provenance: self.provenance.clone(),
432 effective_at: Some(self.catalog_fetched_at),
433 endpoint_fingerprint: Some(self.endpoint_fingerprint.clone()),
434 })
435 }
436 }
437
438 #[derive(Debug, Clone, Serialize, Deserialize)]
439 struct PersistedProviderCatalogs {
440 schema_version: u32,
441 cache: ProviderCatalogCache,
442 }
443
444 #[derive(Serialize)]
445 struct PersistedProviderCatalogsRef<'a> {
446 schema_version: u32,
447 cache: &'a ProviderCatalogCache,
448 }
449
450 /// Resolve the cache under Codewhale's catalog state directory.
451 ///
452 /// Unguarded tests are confined to the TUI test root, matching the Models.dev
453 /// cache contract, so they never inspect a developer's real provider catalog.
454 #[must_use]
455 pub fn cache_path() -> Option<PathBuf> {
456 #[cfg(test)]
457 {
458 if !crate::test_support::guarded_environment_provides_state_paths() {
459 return Some(
460 crate::test_support::unsealed_test_state_root()
461 .join("catalog")
462 .join(CACHE_FILE),
463 );
464 }
465 }
466 codewhale_config::resolve_state_dir("catalog")
467 .ok()
468 .map(|dir| dir.join(CACHE_FILE))
469 }
470
471 fn canonical_provider_scope(provider: &str) -> String {
472 // Despite the historical name, this is the exact configured ownership
473 // scope. Never collapse a custom table that happens to resemble a built-in
474 // or setup-template alias.
475 crate::provider_lake::catalog_partition_key(provider)
476 }
477
478 #[cfg(test)]
479 fn inferred_provider_kind(identity: &str) -> ApiProvider {
480 // No recognized built-in spelling resolves to a compatible-template id,
481 // so the parse fallback below already answers Custom for every named
482 // custom table (#6289).
483 ApiProvider::parse(identity).unwrap_or(ApiProvider::Custom)
484 }
485
486 fn storage_provider(kind: ApiProvider, identity: &str) -> String {
487 format!("{}:{}", kind.as_str(), identity.trim())
488 }
489
490 /// Whether a catalog scope holds an account-scoped roster that must never be
491 /// shared across credentials (#6289).
492 ///
493 /// Baseten's `/models` answers per workspace, so its rows are fenced by
494 /// endpoint fingerprint — never by table name. The Codewhale API's own rows
495 /// are fenced the same way.
496 fn is_account_scoped_scope(provider: &str, fingerprint: &str) -> bool {
497 provider.starts_with("codewhale:")
498 || fingerprint == base_url_fingerprint(codewhale_config::catalog::BASETEN_BASE_URL)
499 || fingerprint == base_url_fingerprint(ApiProvider::Codewhale.default_base_url())
500 }
501
502 fn cache_lock_path(path: &Path) -> PathBuf {
503 let mut name = path
504 .file_name()
505 .map(|name| name.to_os_string())
506 .unwrap_or_else(|| CACHE_FILE.into());
507 name.push(".lock");
508 path.with_file_name(name)
509 }
510
511 fn open_cache_lock(path: &Path) -> Result<fs::File> {
512 let parent = path
513 .parent()
514 .context("provider catalog lock path has no parent")?;
515 fs::create_dir_all(parent)
516 .with_context(|| format!("create provider catalog directory {}", parent.display()))?;
517 let mut options = OpenOptions::new();
518 options.read(true).write(true).create(true).truncate(false);
519 #[cfg(unix)]
520 {
521 use std::os::unix::fs::OpenOptionsExt as _;
522 options
523 .mode(0o600)
524 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
525 }
526 #[cfg(windows)]
527 {
528 use std::os::windows::fs::OpenOptionsExt as _;
529 options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
530 }
531 let file = options
532 .open(path)
533 .with_context(|| format!("open provider catalog lock {}", path.display()))?;
534 let metadata = file
535 .metadata()
536 .with_context(|| format!("inspect provider catalog lock {}", path.display()))?;
537 anyhow::ensure!(
538 metadata.is_file(),
539 "provider catalog lock {} must be a regular file",
540 path.display()
541 );
542 #[cfg(unix)]
543 {
544 use std::os::unix::fs::MetadataExt as _;
545 anyhow::ensure!(
546 metadata.nlink() == 1,
547 "provider catalog lock {} must not be hard linked",
548 path.display()
549 );
550 }
551 #[cfg(windows)]
552 {
553 use std::os::windows::fs::MetadataExt as _;
554 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
555 anyhow::ensure!(
556 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
557 "provider catalog lock {} must not be a reparse point",
558 path.display()
559 );
560 }
561 Ok(file)
562 }
563
564 fn load_from_disk_unlocked_with_limit(path: &Path, max_bytes: u64) -> Option<ProviderCatalogCache> {
565 let mut options = OpenOptions::new();
566 options.read(true);
567 #[cfg(unix)]
568 {
569 use std::os::unix::fs::OpenOptionsExt as _;
570 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
571 }
572 #[cfg(windows)]
573 {
574 use std::os::windows::fs::OpenOptionsExt as _;
575 options.custom_flags(0x0020_0000);
576 }
577 let file = options.open(path).ok()?;
578 let metadata = file.metadata().ok()?;
579 if !metadata.is_file() {
580 return None;
581 }
582 #[cfg(unix)]
583 {
584 use std::os::unix::fs::MetadataExt as _;
585 if metadata.nlink() != 1 {
586 return None;
587 }
588 }
589 #[cfg(windows)]
590 {
591 use std::os::windows::fs::MetadataExt as _;
592 if metadata.file_attributes() & 0x0000_0400 != 0 {
593 return None;
594 }
595 }
596 if metadata.len() > max_bytes {
597 tracing::debug!(
598 target: "provider_catalog",
599 path = %path.display(),
600 max_bytes,
601 "provider catalog cache exceeds read limit"
602 );
603 return None;
604 }
605 // Re-check through `take`: the file can grow after metadata is sampled.
606 let mut body = Vec::new();
607 file.take(max_bytes.saturating_add(1))
608 .read_to_end(&mut body)
609 .ok()?;
610 if body.len() as u64 > max_bytes {
611 return None;
612 }
613 let persisted: PersistedProviderCatalogs = serde_json::from_slice(&body).ok()?;
614 if persisted.schema_version != CACHE_SCHEMA_VERSION {
615 return None;
616 }
617 let mut cache = persisted.cache;
618 if cache.entries.len() > MAX_CACHE_SCOPES || cached_row_count(&cache) > MAX_CACHE_ROWS {
619 return None;
620 }
621 if !cache.entries.iter().all(|(key, entry)| {
622 let Some((kind, identity)) = entry.provider.split_once(':') else { return false; };
623 ApiProvider::parse(kind).is_some_and(|parsed| parsed.as_str() == kind)
624 && !identity.is_empty()
625 && key == &ProviderCatalogCache::cache_key(&entry.provider, &entry.base_url_fingerprint)
626 && entry.offerings.iter().all(|row| {
627 row.provider == identity
628 && crate::provider_lake::valid_catalog_model_id(&row.wire_model_id)
629 && provider_cost_source_allowed(row)
630 && matches!(&row.source, codewhale_config::catalog::CatalogSource::Live {
631 base_url_fingerprint, fetched_at
632 } if base_url_fingerprint == &entry.base_url_fingerprint && *fetched_at == entry.fetched_at)
633 })
634 }) { return None; }
635 // Older builds could durably cache account-scoped rosters. Scrub
636 // those entries on every load so upgrading cannot attach one workspace's
637 // catalog to a different credential.
638 cache
639 .entries
640 .retain(|_, entry| !is_account_scoped_scope(&entry.provider, &entry.base_url_fingerprint));
641 Some(cache)
642 }
643
644 fn provider_cost_source_allowed(row: &codewhale_config::catalog::CatalogOffering) -> bool {
645 use codewhale_config::catalog::CatalogSource;
646 matches!(
647 row.cost_source,
648 None | Some(
649 CatalogSource::Bundled
650 | CatalogSource::CodewhaleBundled { .. }
651 | CatalogSource::ModelsDevLive { .. }
652 )
653 )
654 }
655
656 fn load_from_disk_unlocked(path: &Path) -> Option<ProviderCatalogCache> {
657 load_from_disk_unlocked_with_limit(path, MAX_CACHE_BYTES)
658 }
659
660 fn load_from_disk() -> Option<ProviderCatalogCache> {
661 let path = cache_path()?;
662 if !path.is_file() {
663 return None;
664 }
665 let lock_file = open_cache_lock(&cache_lock_path(&path)).ok()?;
666 let lock = fd_lock::RwLock::new(lock_file);
667 let _guard = lock.read().ok()?;
668 load_from_disk_unlocked(&path)
669 }
670
671 fn ensure_cache_loaded() -> Result<()> {
672 if DISK_LOADED.load(Ordering::Acquire) {
673 return Ok(());
674 }
675 let mut cache = CACHE
676 .write()
677 .map_err(|_| anyhow::anyhow!("catalog cache unavailable"))?;
678 if DISK_LOADED.load(Ordering::Acquire) {
679 return Ok(());
680 }
681 if let Some(path) = cache_path() {
682 match fs::symlink_metadata(&path) {
683 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
684 Err(err) => return Err(err.into()),
685 Ok(_) => {
686 let loaded = load_from_disk().context("invalid provider catalog cache")?;
687 for (key, entry) in loaded.entries {
688 if cache
689 .entries
690 .get(&key)
691 .is_none_or(|local| entry.fetched_at >= local.fetched_at)
692 {
693 cache.entries.insert(key, entry);
694 }
695 }
696 }
697 }
698 }
699 DISK_LOADED.store(true, Ordering::Release);
700 Ok(())
701 }
702
703 /// Read one exact cached route without creating a client or fetching credentials.
704 pub(crate) fn cached_entry_for_route(
705 kind: ApiProvider,
706 identity: &str,
707 base_url: &str,
708 ) -> Result<Option<codewhale_config::catalog::CachedProviderCatalog>> {
709 ensure_cache_loaded()?;
710 let cache = CACHE
711 .read()
712 .map_err(|_| anyhow::anyhow!("catalog cache unavailable"))?;
713 Ok(cache
714 .get(
715 &storage_provider(kind, identity),
716 &base_url_fingerprint(base_url),
717 )
718 .cloned())
719 }
720
721 fn merge_durable_scope(
722 mut durable_cache: ProviderCatalogCache,
723 process_cache: &ProviderCatalogCache,
724 provider: &str,
725 fingerprint: &str,
726 ) -> ProviderCatalogCache {
727 durable_cache
728 .entries
729 .retain(|_, entry| !is_account_scoped_scope(&entry.provider, &entry.base_url_fingerprint));
730 if !is_account_scoped_scope(provider, fingerprint)
731 && let Some(entry) = process_cache.get(provider, fingerprint).cloned()
732 {
733 durable_cache.entries.insert(
734 ProviderCatalogCache::cache_key(provider, fingerprint),
735 entry,
736 );
737 }
738 durable_cache
739 }
740
741 fn persisted_envelope_len(cache: &ProviderCatalogCache) -> Result<u64> {
742 struct Counter(u64);
743 impl std::io::Write for Counter {
744 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
745 self.0 = self.0.saturating_add(bytes.len() as u64);
746 Ok(bytes.len())
747 }
748 fn flush(&mut self) -> std::io::Result<()> {
749 Ok(())
750 }
751 }
752 let envelope = PersistedProviderCatalogsRef {
753 schema_version: CACHE_SCHEMA_VERSION,
754 cache,
755 };
756 let mut counter = Counter(0);
757 serde_json::to_writer_pretty(&mut counter, &envelope)
758 .context("measure provider catalog cache for bounded persistence")?;
759 Ok(counter.0.saturating_add(1))
760 }
761
762 fn cached_row_count(cache: &ProviderCatalogCache) -> usize {
763 cache.entries.values().fold(0usize, |total, entry| {
764 total.saturating_add(entry.offerings.len())
765 })
766 }
767
768 /// Compact a durable cache without ever truncating one provider roster.
769 ///
770 /// The exact scope being written is protected: if that scope alone fits, older
771 /// failed/stale scopes are evicted whole until the envelope is bounded. If the
772 /// protected scope alone does not fit, persistence is refused and the prior
773 /// atomic file remains intact. This avoids both self-bricking the 32 MiB read
774 /// limit and turning a partial provider roster into false authoritative truth.
775 fn bounded_cache_for_persistence(
776 mut cache: ProviderCatalogCache,
777 protected_scope: Option<(&str, &str)>,
778 now: u64,
779 limits: CachePersistenceLimits,
780 ) -> Result<ProviderCatalogCache> {
781 cache
782 .entries
783 .retain(|_, entry| !is_account_scoped_scope(&entry.provider, &entry.base_url_fingerprint));
784
785 let protected_key = protected_scope
786 .filter(|(provider, fingerprint)| !is_account_scoped_scope(provider, fingerprint))
787 .map(|(provider, fingerprint)| ProviderCatalogCache::cache_key(provider, fingerprint));
788
789 if let Some(key) = protected_key.as_deref()
790 && let Some(entry) = cache.entries.get(key).cloned()
791 {
792 let mut protected_only = ProviderCatalogCache::new();
793 protected_only.entries.insert(key.to_string(), entry);
794 anyhow::ensure!(
795 protected_only.entries.len() <= limits.max_scopes.min(MAX_CACHE_SCOPES)
796 && cached_row_count(&protected_only) <= limits.max_rows
797 && persisted_envelope_len(&protected_only)? <= limits.max_bytes,
798 "provider catalog scope {key:?} exceeds bounded persistence limits"
799 );
800 }
801
802 // Rank once while the cache/file locks are held. An older implementation
803 // reserialized and rescanned the entire envelope for every eviction, which
804 // made a valid sub-32-MiB file with many tiny scopes quadratic to compact.
805 let mut eviction_keys = cache
806 .entries
807 .iter()
808 .filter(|(key, _)| protected_key.as_deref() != Some(key.as_str()))
809 .map(|(key, entry)| {
810 let health_rank = if matches!(entry.status, CatalogStatus::Failed { .. }) {
811 0u8
812 } else if entry.is_stale(now) || matches!(entry.status, CatalogStatus::Stale { .. }) {
813 1u8
814 } else {
815 2u8
816 };
817 (health_rank, entry.fetched_at, key.clone())
818 })
819 .collect::<Vec<_>>();
820 eviction_keys.sort();
821 let eviction_keys = eviction_keys
822 .into_iter()
823 .map(|(_, _, key)| key)
824 .collect::<Vec<_>>();
825 let mut eviction_index = 0usize;
826 let mut rows = cached_row_count(&cache);
827 let max_scopes = limits.max_scopes.min(MAX_CACHE_SCOPES);
828
829 let mut evict_next = |cache: &mut ProviderCatalogCache| -> Result<usize> {
830 let key = eviction_keys
831 .get(eviction_index)
832 .context("provider catalog envelope cannot fit even after whole-scope compaction")?;
833 eviction_index = eviction_index.saturating_add(1);
834 let entry = cache
835 .entries
836 .remove(key)
837 .context("provider catalog eviction candidate disappeared")?;
838 Ok(entry.offerings.len())
839 };
840
841 // First enforce the cheap cardinality limits in bulk. Only after at most 64
842 // scopes remain do we serialize to enforce the exact on-disk byte limit.
843 while cache.entries.len() > max_scopes || rows > limits.max_rows {
844 rows = rows.saturating_sub(evict_next(&mut cache)?);
845 }
846 while persisted_envelope_len(&cache)? > limits.max_bytes {
847 let _removed_rows = evict_next(&mut cache)?;
848 }
849
850 Ok(cache)
851 }
852
853 fn write_bounded_cache(
854 path: &Path,
855 cache: ProviderCatalogCache,
856 protected_scope: Option<(&str, &str)>,
857 limits: CachePersistenceLimits,
858 ) -> Result<()> {
859 let cache = bounded_cache_for_persistence(cache, protected_scope, now_unix(), limits)?;
860 let envelope = PersistedProviderCatalogs {
861 schema_version: CACHE_SCHEMA_VERSION,
862 cache,
863 };
864 anyhow::ensure!(
865 persisted_envelope_len(&envelope.cache)? <= limits.max_bytes,
866 "bounded provider catalog cache exceeds its write limit"
867 );
868 atomic_write_json(path, &envelope)
869 }
870
871 fn persist_scope(cache: &ProviderCatalogCache, provider: &str, fingerprint: &str) -> bool {
872 let Some(path) = cache_path() else {
873 return false;
874 };
875 let provider = canonical_provider_scope(provider);
876 let result = (|| -> Result<()> {
877 let lock_file = open_cache_lock(&cache_lock_path(&path))?;
878 let mut lock = fd_lock::RwLock::new(lock_file);
879 let _guard = lock
880 .write()
881 .with_context(|| format!("write-lock provider catalog cache {}", path.display()))?;
882 // Merge only the exact scope this process just changed into the latest
883 // disk snapshot. A stale long-running TUI therefore cannot erase a
884 // different scope written by the Runtime API (or vice versa).
885 let durable_cache = merge_durable_scope(
886 if path.exists() {
887 load_from_disk_unlocked(&path).context("invalid prior catalog cache")?
888 } else {
889 ProviderCatalogCache::new()
890 },
891 cache,
892 &provider,
893 fingerprint,
894 );
895 write_bounded_cache(
896 &path,
897 durable_cache,
898 Some((&provider, fingerprint)),
899 CACHE_PERSISTENCE_LIMITS,
900 )
901 .with_context(|| format!("atomically write provider catalog {}", path.display()))
902 })();
903 if let Err(error) = result {
904 tracing::debug!(
905 target: "provider_catalog",
906 error = %error,
907 "provider catalog cache write failed"
908 );
909 return false;
910 }
911 true
912 }
913
914 /// Persist a failure without letting a stale process replace newer rows from
915 /// another Codewhale process for the same exact scope.
916 ///
917 /// The ordinary scoped merge is sufficient for successes because the response
918 /// being committed is the new roster. A failure is different: its process may
919 /// have started with an older last-known-good entry. Re-read the durable exact
920 /// scope while holding the cross-process write lock, prefer it when it is at
921 /// least as recent, then change only the status before writing. Thus a failed
922 /// refresh can preserve the newest roster without resurrecting its own stale
923 /// snapshot over another process's success.
924 fn persist_failure_scope(
925 cache: &mut ProviderCatalogCache,
926 provider: &str,
927 fingerprint: &str,
928 reason: CatalogRefreshError,
929 ) {
930 let Some(path) = cache_path() else {
931 return;
932 };
933 let provider = canonical_provider_scope(provider);
934 let result = (|| -> Result<()> {
935 let lock_file = open_cache_lock(&cache_lock_path(&path))?;
936 let mut lock = fd_lock::RwLock::new(lock_file);
937 let _guard = lock
938 .write()
939 .with_context(|| format!("write-lock provider catalog cache {}", path.display()))?;
940 let durable_cache = if path.exists() {
941 load_from_disk_unlocked(&path).context("invalid prior catalog cache")?
942 } else {
943 ProviderCatalogCache::new()
944 };
945
946 if !is_account_scoped_scope(&provider, fingerprint)
947 && let Some(durable_entry) = durable_cache.get(&provider, fingerprint).cloned()
948 {
949 let durable_is_newer = cache
950 .get(&provider, fingerprint)
951 .is_none_or(|local| durable_entry.fetched_at >= local.fetched_at);
952 if durable_is_newer {
953 cache.entries.insert(
954 ProviderCatalogCache::cache_key(&provider, fingerprint),
955 durable_entry,
956 );
957 cache.record_failure(&provider, fingerprint, reason);
958 }
959 }
960
961 let durable_cache = merge_durable_scope(durable_cache, cache, &provider, fingerprint);
962 write_bounded_cache(
963 &path,
964 durable_cache,
965 Some((&provider, fingerprint)),
966 CACHE_PERSISTENCE_LIMITS,
967 )
968 .with_context(|| format!("atomically write provider catalog {}", path.display()))
969 })();
970 if let Err(error) = result {
971 tracing::debug!(
972 target: "provider_catalog",
973 error = %error,
974 "provider catalog failure receipt write failed"
975 );
976 }
977 }
978
979 fn publish_exact_scope_for_identity(
980 cache: &ProviderCatalogCache,
981 provider_kind: ApiProvider,
982 provider_identity: &str,
983 fingerprint: &str,
984 ) -> usize {
985 let provider = canonical_provider_scope(provider_identity);
986 let offerings = cache
987 .get(&storage_provider(provider_kind, &provider), fingerprint)
988 .map(|entry| entry.offerings.clone())
989 .unwrap_or_default();
990 let count = offerings.len();
991 crate::provider_lake::replace_provider_live_snapshot_for_identity(
992 provider_kind,
993 &provider,
994 CatalogSnapshot { offerings },
995 );
996 count
997 }
998
999 /// Load and publish only the active route's exact provider/base-URL scope.
1000 ///
1001 /// A cache created for another custom endpoint or for an old endpoint override
1002 /// is retained on disk but cannot leak into the active picker.
1003 pub fn maybe_load_persisted_cache_for_config(config: &Config) -> usize {
1004 let provider = config.api_provider();
1005 let provider_identity = canonical_provider_scope(&config.provider_identity_for(provider));
1006 let fingerprint = base_url_fingerprint(&config.active_route_base_url());
1007 if is_account_scoped_scope(
1008 &storage_provider(provider, &provider_identity),
1009 &fingerprint,
1010 ) {
1011 forget_account_scoped_provider(provider, &provider_identity);
1012 return 0;
1013 }
1014 if let Ok(mut guard) = CACHE.write()
1015 && let Some(loaded) = load_from_disk()
1016 {
1017 // Keep session-only scopes that cannot exist on disk, while allowing a
1018 // newer durable scope from another Codewhale process to refresh this
1019 // process. Every in-process writer takes CACHE before the file lock, so
1020 // this read/merge cannot overwrite a concurrent local refresh.
1021 for (key, entry) in loaded.entries {
1022 let should_replace = guard
1023 .entries
1024 .get(&key)
1025 .is_none_or(|current| entry.fetched_at >= current.fetched_at);
1026 if should_replace {
1027 guard.entries.insert(key, entry);
1028 }
1029 }
1030 }
1031 CACHE
1032 .read()
1033 .map(|guard| {
1034 publish_exact_scope_for_identity(&guard, provider, &provider_identity, &fingerprint)
1035 })
1036 .unwrap_or(0)
1037 }
1038
1039 fn forget_account_scoped_provider(provider_kind: ApiProvider, provider: &str) {
1040 let provider = canonical_provider_scope(provider);
1041 if let Ok(mut cache) = CACHE.write() {
1042 cache
1043 .entries
1044 .retain(|_, entry| entry.provider != storage_provider(provider_kind, &provider));
1045 }
1046 crate::provider_lake::replace_provider_live_snapshot_for_identity(
1047 provider_kind,
1048 &provider,
1049 CatalogSnapshot::default(),
1050 );
1051 }
1052
1053 /// Begin a provider refresh and invalidate older in-flight results.
1054 ///
1055 /// Account-scoped Baseten and Codewhale routes additionally drop their prior
1056 /// in-memory rosters: the same URL can expose different models after a credential
1057 /// change, and no safe account identifier is available for cache reuse.
1058 #[cfg(test)]
1059 pub fn begin_refresh(provider: &str) -> ProviderCatalogRefreshTicket {
1060 begin_refresh_inner(inferred_provider_kind(provider), provider, None)
1061 }
1062
1063 pub fn begin_refresh_for_identity(
1064 provider_kind: ApiProvider,
1065 provider: &str,
1066 base_url: &str,
1067 ) -> ProviderCatalogRefreshTicket {
1068 begin_refresh_inner(
1069 provider_kind,
1070 provider,
1071 Some(base_url_fingerprint(base_url)),
1072 )
1073 }
1074
1075 fn begin_refresh_inner(
1076 provider_kind: ApiProvider,
1077 provider: &str,
1078 fingerprint: Option<String>,
1079 ) -> ProviderCatalogRefreshTicket {
1080 let provider = canonical_provider_scope(provider);
1081 let scope = storage_provider(provider_kind, &provider);
1082 // Hold the generation gate through account-roster invalidation, so an older
1083 // refresh can never publish between the new ticket and the clear.
1084 let generation = if let Ok(mut generations) = REFRESH_GENERATIONS.write() {
1085 let generation = generations.entry(scope.clone()).or_default();
1086 *generation = generation.saturating_add(1);
1087 if fingerprint
1088 .as_deref()
1089 .is_some_and(|fp| is_account_scoped_scope(&scope, fp))
1090 {
1091 forget_account_scoped_provider(provider_kind, &provider);
1092 }
1093 *generation
1094 } else {
1095 0
1096 };
1097 ProviderCatalogRefreshTicket {
1098 provider,
1099 provider_kind,
1100 fingerprint,
1101 generation,
1102 }
1103 }
1104
1105 fn with_current_ticket<T>(
1106 ticket: &ProviderCatalogRefreshTicket,
1107 provider: &str,
1108 operation: impl FnOnce() -> T,
1109 ) -> Option<T> {
1110 let provider = canonical_provider_scope(provider);
1111 if ticket.provider != provider {
1112 return None;
1113 }
1114 let generations = REFRESH_GENERATIONS.read().ok()?;
1115 if generations
1116 .get(&storage_provider(ticket.provider_kind, &ticket.provider))
1117 .copied()
1118 != Some(ticket.generation)
1119 {
1120 return None;
1121 }
1122 // Keep the generation read guard alive through publication. A newer
1123 // `begin_refresh` needs the write lock, so it cannot slip between the
1124 // current-ticket check and this operation's cache/lake update.
1125 let result = operation();
1126 drop(generations);
1127 Some(result)
1128 }
1129
1130 /// Record a successful refresh only if no newer refresh superseded it.
1131 pub fn record_success_if_current(
1132 ticket: &ProviderCatalogRefreshTicket,
1133 delta: ProviderCatalogDelta,
1134 ) -> Option<CatalogStatus> {
1135 let provider = canonical_provider_scope(&delta.provider);
1136 if ticket
1137 .fingerprint
1138 .as_ref()
1139 .is_some_and(|fp| fp != &delta.base_url_fingerprint)
1140 {
1141 return None;
1142 }
1143 with_current_ticket(ticket, &provider, || {
1144 record_success_for_identity(ticket.provider_kind, delta)
1145 })
1146 }
1147
1148 /// Record a failed refresh only if no newer refresh superseded it.
1149 pub fn record_failure_if_current(
1150 ticket: &ProviderCatalogRefreshTicket,
1151 provider: &str,
1152 fingerprint: &str,
1153 reason: CatalogRefreshError,
1154 ) -> Option<CatalogStatus> {
1155 let provider = canonical_provider_scope(provider);
1156 if ticket
1157 .fingerprint
1158 .as_deref()
1159 .is_some_and(|fp| fp != fingerprint)
1160 {
1161 return None;
1162 }
1163 with_current_ticket(ticket, &provider, || {
1164 record_failure_for_identity(ticket.provider_kind, &provider, fingerprint, reason)
1165 })
1166 }
1167
1168 /// Current freshness receipt for one exact provider/base-URL scope.
1169 ///
1170 /// Runtime route resolution uses this independently from picker visibility:
1171 /// stale or failed rows may remain selectable as an explicit fallback, but
1172 /// their limits, capabilities, and prices are not treated as current endpoint
1173 /// facts during execution.
1174 #[cfg(test)]
1175 pub fn status_for_scope(provider: &str, base_url: &str) -> CatalogStatus {
1176 let fingerprint = base_url_fingerprint(base_url);
1177 status_for_fingerprint(provider, &fingerprint)
1178 }
1179
1180 /// Current freshness receipt when the caller already owns the endpoint
1181 /// fingerprint (for example, an immutable usage-pricing receipt).
1182 #[cfg(test)]
1183 pub(crate) fn status_for_fingerprint(provider: &str, fingerprint: &str) -> CatalogStatus {
1184 status_for_route_fingerprint(inferred_provider_kind(provider), provider, fingerprint)
1185 }
1186
1187 pub(crate) fn status_for_route(
1188 provider: ApiProvider,
1189 identity: &str,
1190 base_url: &str,
1191 ) -> CatalogStatus {
1192 status_for_route_fingerprint(provider, identity, &base_url_fingerprint(base_url))
1193 }
1194
1195 fn status_for_route_fingerprint(
1196 kind: ApiProvider,
1197 provider: &str,
1198 fingerprint: &str,
1199 ) -> CatalogStatus {
1200 let provider = storage_provider(kind, provider);
1201 CACHE
1202 .read()
1203 .map(|cache| cache.status(&provider, fingerprint, now_unix()))
1204 .unwrap_or(CatalogStatus::Unknown)
1205 }
1206
1207 /// Freeze the exact reviewed provider-live rate row fresh at CodeWhale's
1208 /// pre-permit application-dispatch boundary.
1209 ///
1210 /// Status, scope, model, source, and rates are all read beneath one `CACHE`
1211 /// read guard. The returned value owns every fact needed by later auditing, so
1212 /// completion-time code never re-opens mutable catalog or provider-lake state.
1213 fn reviewed_provider_live_scope(
1214 provider: ApiProvider,
1215 provider_identity: &str,
1216 endpoint_fingerprint: &str,
1217 ) -> bool {
1218 match provider {
1219 ApiProvider::Openrouter => {
1220 provider_identity == ApiProvider::Openrouter.as_str()
1221 && endpoint_fingerprint
1222 == base_url_fingerprint(crate::config::DEFAULT_OPENROUTER_BASE_URL)
1223 }
1224 ApiProvider::Custom => {
1225 endpoint_fingerprint
1226 == base_url_fingerprint(codewhale_config::catalog::BASETEN_BASE_URL)
1227 }
1228 _ => false,
1229 }
1230 }
1231
1232 #[must_use]
1233 pub(crate) fn fresh_provider_live_pricing_quote_at(
1234 provider: ApiProvider,
1235 provider_identity: &str,
1236 wire_model: &str,
1237 endpoint_fingerprint: &str,
1238 dispatched_at_unix: u64,
1239 ) -> Option<ProviderLivePricingQuote> {
1240 let provider_identity = canonical_provider_scope(provider_identity);
1241 let wire_model = wire_model.trim();
1242 let endpoint_fingerprint = endpoint_fingerprint.trim();
1243 if provider_identity.is_empty()
1244 || wire_model.is_empty()
1245 || !reviewed_provider_live_scope(provider, &provider_identity, endpoint_fingerprint)
1246 {
1247 return None;
1248 }
1249
1250 let cache = CACHE.read().ok()?;
1251 let storage_scope = storage_provider(provider, &provider_identity);
1252 if cache.status(&storage_scope, endpoint_fingerprint, dispatched_at_unix)
1253 != CatalogStatus::Fresh
1254 {
1255 return None;
1256 }
1257 let entry = cache.get(&storage_scope, endpoint_fingerprint)?;
1258 if entry.provider != storage_scope
1259 || entry.base_url_fingerprint.trim() != endpoint_fingerprint
1260 || entry.fetched_at > dispatched_at_unix
1261 {
1262 return None;
1263 }
1264 let offering = entry.offerings.iter().find(|offering| {
1265 offering.provider.trim() == provider_identity && offering.wire_model_id.trim() == wire_model
1266 })?;
1267 let pricing = OfferingPricing::from_catalog_offering(offering)?;
1268 if pricing.provider.trim() != provider_identity
1269 || pricing.wire_model_id.trim() != wire_model
1270 || pricing.currency != Currency::Usd
1271 || pricing.provenance != PricingProvenance::ProviderLive
1272 || pricing.effective_at != Some(entry.fetched_at)
1273 || pricing.endpoint_fingerprint.as_deref() != Some(endpoint_fingerprint)
1274 || pricing.input_per_million.is_none()
1275 || pricing.output_per_million.is_none()
1276 {
1277 return None;
1278 }
1279 ProviderLivePricingQuote::from_pricing(
1280 provider,
1281 &provider_identity,
1282 wire_model,
1283 endpoint_fingerprint,
1284 entry.fetched_at,
1285 &pricing,
1286 )
1287 }
1288
1289 /// A price is a fact, so it is in scope exactly where a fact is.
1290 ///
1291 /// This defers to [`crate::provider_lake::cloud_facts_apply_to_route`] rather
1292 /// than repeating the scope table. The copy it replaces admitted the dual-wire
1293 /// and regional routes (`deepseek-anthropic`, `siliconflow-CN`) that the
1294 /// catalog gate refuses; no price actually escaped through it, because the
1295 /// offering lookup below independently returns a non-`CloudFacts` row on those
1296 /// routes and the quote then fails — but that is one authority masking another,
1297 /// not agreement, and it would become a real leak the moment either moved. The
1298 /// copy also carried its own `!= OpenaiCodex` test, which `cloud_facts::scope`
1299 /// has always enforced for every consumer.
1300 ///
1301 /// The one condition that is this file's own: the *configured* identity must be
1302 /// the canonical provider. A differently-named provider table pointing at the
1303 /// official host is a separate credential and billing relationship.
1304 fn cloud_pricing_scope(provider: ApiProvider, identity: &str, base_url: &str) -> bool {
1305 identity == provider.as_str()
1306 && crate::provider_lake::cloud_facts_apply_to_route(provider, base_url)
1307 }
1308
1309 /// Capture the effective mutable price authority once. Provider-owned live
1310 /// prices retain priority; signed cloud prices are admitted only on the exact
1311 /// canonical official route. The historical wire field name remains stable.
1312 pub(crate) fn configured_dispatch_pricing_quote_at(
1313 models: &[codewhale_config::catalog::configured::ConfiguredModel],
1314 provider: ApiProvider,
1315 identity: &str,
1316 model: &str,
1317 base_url: &str,
1318 dispatched_at: u64,
1319 ) -> Option<ProviderLivePricingQuote> {
1320 if provider == ApiProvider::OpenaiCodex {
1321 return None;
1322 }
1323 codewhale_config::catalog::configured::validate_configured_models(models).ok()?;
1324 let declared = models
1325 .iter()
1326 .find(|row| row.id == model && row.matches_route(identity, base_url))?;
1327 let cost = declared.cost.clone().unwrap_or_default();
1328 let pricing = OfferingPricing {
1329 provider: identity.to_string(),
1330 wire_model_id: model.to_string(),
1331 canonical_model: None,
1332 currency: Currency::Usd,
1333 input_per_million: cost.input,
1334 output_per_million: cost.output,
1335 cache_read_per_million: cost.cache_read,
1336 cache_write_per_million: cost.cache_write,
1337 provenance: PricingProvenance::UserOverride,
1338 effective_at: None,
1339 endpoint_fingerprint: Some(base_url_fingerprint(base_url)),
1340 };
1341 // Freeze even an unpriced declaration: missing rates must not fall through
1342 // to a same-named bundled or subsequently refreshed price.
1343 ProviderLivePricingQuote::from_pricing(
1344 provider,
1345 identity,
1346 model,
1347 &base_url_fingerprint(base_url),
1348 dispatched_at,
1349 &pricing,
1350 )
1351 }
1352
1353 pub(crate) fn fresh_dispatch_pricing_quote_at(
1354 provider: ApiProvider,
1355 provider_identity: &str,
1356 wire_model: &str,
1357 base_url: &str,
1358 dispatched_at_unix: u64,
1359 ) -> Option<ProviderLivePricingQuote> {
1360 let endpoint_fingerprint = base_url_fingerprint(base_url);
1361 if let Some(quote) = fresh_provider_live_pricing_quote_at(
1362 provider,
1363 provider_identity,
1364 wire_model,
1365 &endpoint_fingerprint,
1366 dispatched_at_unix,
1367 ) {
1368 return Some(quote);
1369 }
1370 if !cloud_pricing_scope(provider, provider_identity, base_url) {
1371 return None;
1372 }
1373 let snapshot = codewhale_config::cloud_facts::overlay::snapshot();
1374 let facts = snapshot.facts.as_ref()?;
1375 let offering = crate::provider_lake::catalog_offering_for_route(
1376 provider,
1377 provider_identity,
1378 base_url,
1379 wire_model,
1380 )?;
1381 let codewhale_config::catalog::CatalogSource::CloudFacts {
1382 facts_version,
1383 key_id,
1384 fetched_at,
1385 valid_until,
1386 } = offering.pricing_source()
1387 else {
1388 return None;
1389 };
1390 // Provider cache files cannot authenticate a cloud price by copying a
1391 // source stamp. This row must be a projection of the current verified
1392 // overlay, with matching independent price authority and exact wire ID.
1393 if offering.wire_model_id != wire_model
1394 || !matches!(
1395 offering.source,
1396 codewhale_config::catalog::CatalogSource::CloudFacts { .. }
1397 )
1398 || *facts_version != facts.facts_version
1399 || key_id != &facts.key_id
1400 || *valid_until != facts.valid_until
1401 {
1402 return None;
1403 }
1404 let pricing = OfferingPricing::from_catalog_offering_at(&offering, dispatched_at_unix)?;
1405 let mut quote = ProviderLivePricingQuote::from_pricing(
1406 provider,
1407 provider_identity,
1408 wire_model,
1409 &endpoint_fingerprint,
1410 *fetched_at,
1411 &pricing,
1412 )?;
1413 quote.cloud_facts = Some(CloudFactsPricingSource {
1414 facts_version: *facts_version,
1415 key_id: key_id.clone(),
1416 valid_until: *valid_until,
1417 base_url: base_url.to_string(),
1418 });
1419 quote.catalog_revision = ProviderLivePricingQuote::revision_for(
1420 quote.provider,
1421 &quote.provider_identity,
1422 &quote.wire_model,
1423 &quote.endpoint_fingerprint,
1424 quote.catalog_fetched_at,
1425 &quote.currency,
1426 &quote.provenance,
1427 &quote.input_per_million,
1428 &quote.output_per_million,
1429 &quote.cache_read_per_million,
1430 &quote.cache_write_per_million,
1431 quote.cloud_facts.as_ref(),
1432 )?;
1433 quote.pricing_for_route(
1434 provider,
1435 provider_identity,
1436 wire_model,
1437 &endpoint_fingerprint,
1438 dispatched_at_unix,
1439 )?;
1440 (snapshot.generation == codewhale_config::cloud_facts::overlay::snapshot().generation)
1441 .then_some(quote)
1442 }
1443
1444 /// Record and atomically persist a successful provider refresh.
1445 ///
1446 /// `ProviderCatalogCache::record_success` replaces the exact scope, so models
1447 /// removed upstream disappear instead of accumulating forever.
1448 #[cfg(test)]
1449 pub fn record_success(delta: ProviderCatalogDelta) -> CatalogStatus {
1450 record_success_for_identity(inferred_provider_kind(&delta.provider), delta)
1451 }
1452
1453 fn record_success_for_identity(
1454 kind: ApiProvider,
1455 mut delta: ProviderCatalogDelta,
1456 ) -> CatalogStatus {
1457 let provider = canonical_provider_scope(&delta.provider);
1458 delta.provider = storage_provider(kind, &provider);
1459 if delta.offerings.iter().any(|row| {
1460 row.provider != provider
1461 || !crate::provider_lake::valid_catalog_model_id(&row.wire_model_id)
1462 || !provider_cost_source_allowed(row)
1463 }) {
1464 return record_failure_for_identity(
1465 kind,
1466 &provider,
1467 &delta.base_url_fingerprint,
1468 CatalogRefreshError::InvalidResponse,
1469 );
1470 }
1471 let fingerprint = delta.base_url_fingerprint.clone();
1472 let Ok(mut guard) = CACHE.write() else {
1473 return CatalogStatus::Unknown;
1474 };
1475 guard.record_success(delta, DEFAULT_PROVIDER_CATALOG_TTL_SECS);
1476 let persisted = persist_scope(&guard, &storage_provider(kind, &provider), &fingerprint);
1477 publish_exact_scope_for_identity(&guard, kind, &provider, &fingerprint);
1478 if persisted {
1479 CatalogStatus::Fresh
1480 } else {
1481 CatalogStatus::Unknown
1482 }
1483 }
1484
1485 /// Record a typed failure while preserving and republishing prior rows for the
1486 /// exact route scope.
1487 #[cfg(test)]
1488 pub fn record_failure(
1489 provider: &str,
1490 fingerprint: &str,
1491 reason: CatalogRefreshError,
1492 ) -> CatalogStatus {
1493 record_failure_for_identity(
1494 inferred_provider_kind(provider),
1495 provider,
1496 fingerprint,
1497 reason,
1498 )
1499 }
1500
1501 fn record_failure_for_identity(
1502 kind: ApiProvider,
1503 provider: &str,
1504 fingerprint: &str,
1505 reason: CatalogRefreshError,
1506 ) -> CatalogStatus {
1507 let provider = canonical_provider_scope(provider);
1508 let scope = storage_provider(kind, &provider);
1509 let Ok(mut guard) = CACHE.write() else {
1510 return CatalogStatus::Failed { reason };
1511 };
1512 guard.record_failure(&scope, fingerprint, reason);
1513 persist_failure_scope(&mut guard, &scope, fingerprint, reason);
1514 publish_exact_scope_for_identity(&guard, kind, &provider, fingerprint);
1515 CatalogStatus::Failed { reason }
1516 }
1517
1518 #[cfg(test)]
1519 pub(crate) fn reset_cache_for_test() {
1520 DISK_LOADED.store(false, Ordering::Release);
1521 if let Ok(mut cache) = CACHE.write() {
1522 *cache = ProviderCatalogCache::new();
1523 }
1524 }
1525
1526 #[cfg(test)]
1527 mod tests {
1528 use super::*;
1529 use crate::config::{ApiProvider, ProviderConfig, ProvidersConfig};
1530 use crate::test_support::{EnvVarGuard, lock_test_env};
1531 use codewhale_config::catalog::{CatalogOffering, CatalogSource};
1532
1533 fn delta(provider: &str, fingerprint: &str, ids: &[&str]) -> ProviderCatalogDelta {
1534 delta_at(provider, fingerprint, ids, now_unix())
1535 }
1536
1537 fn delta_at(
1538 provider: &str,
1539 fingerprint: &str,
1540 ids: &[&str],
1541 fetched_at: u64,
1542 ) -> ProviderCatalogDelta {
1543 ProviderCatalogDelta {
1544 provider: provider.to_string(),
1545 base_url_fingerprint: fingerprint.to_string(),
1546 fetched_at,
1547 offerings: ids
1548 .iter()
1549 .map(|id| CatalogOffering {
1550 provider: provider.to_string(),
1551 wire_model_id: (*id).to_string(),
1552 endpoint_key: "chat".to_string(),
1553 source: CatalogSource::Live {
1554 base_url_fingerprint: fingerprint.to_string(),
1555 fetched_at,
1556 },
1557 ..CatalogOffering::default()
1558 })
1559 .collect(),
1560 }
1561 }
1562
1563 fn scope(identity: &str) -> String {
1564 storage_provider(inferred_provider_kind(identity), identity)
1565 }
1566
1567 fn stored_delta(provider: &str, fingerprint: &str, ids: &[&str]) -> ProviderCatalogDelta {
1568 stored_delta_at(provider, fingerprint, ids, now_unix())
1569 }
1570
1571 fn stored_delta_at(
1572 provider: &str,
1573 fingerprint: &str,
1574 ids: &[&str],
1575 fetched_at: u64,
1576 ) -> ProviderCatalogDelta {
1577 let mut delta = delta_at(provider, fingerprint, ids, fetched_at);
1578 delta.provider = scope(provider);
1579 delta
1580 }
1581
1582 #[test]
1583 fn success_replaces_scope_and_failure_preserves_last_rows_on_disk() {
1584 let _env = lock_test_env();
1585 let _live = crate::provider_lake::lock_live_snapshot();
1586 let home = tempfile::tempdir().expect("home");
1587 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1588 if let Ok(mut cache) = CACHE.write() {
1589 *cache = ProviderCatalogCache::new();
1590 }
1591
1592 assert_eq!(
1593 record_success(delta("openrouter", "fp", &["old"])),
1594 CatalogStatus::Fresh
1595 );
1596 assert_eq!(
1597 record_success(delta("openrouter", "fp", &["new"])),
1598 CatalogStatus::Fresh
1599 );
1600 assert!(matches!(
1601 record_failure("openrouter", "fp", CatalogRefreshError::RateLimited),
1602 CatalogStatus::Failed {
1603 reason: CatalogRefreshError::RateLimited
1604 }
1605 ));
1606
1607 let loaded = load_from_disk().expect("persisted cache");
1608 let entry = loaded
1609 .get(&scope("openrouter"), "fp")
1610 .expect("OpenRouter scope");
1611 assert_eq!(entry.offerings.len(), 1);
1612 assert_eq!(entry.offerings[0].wire_model_id, "new");
1613 assert!(matches!(entry.status, CatalogStatus::Failed { .. }));
1614 }
1615
1616 #[test]
1617 fn baseten_workspace_roster_is_session_only_and_clears_before_reauthentication() {
1618 let _env = lock_test_env();
1619 let _live = crate::provider_lake::lock_live_snapshot();
1620 let home = tempfile::tempdir().expect("home");
1621 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1622 reset_cache_for_test();
1623 crate::provider_lake::clear_live_snapshot();
1624
1625 let base_url = codewhale_config::catalog::BASETEN_BASE_URL;
1626 let fingerprint = base_url_fingerprint(base_url);
1627 record_success(delta(
1628 codewhale_config::catalog::BASETEN_PROVIDER_ID,
1629 &fingerprint,
1630 &["workspace-a-only-model"],
1631 ));
1632 assert!(
1633 crate::provider_lake::all_catalog_models_for_provider_identity(
1634 ApiProvider::Custom,
1635 Some(codewhale_config::catalog::BASETEN_PROVIDER_ID),
1636 )
1637 .contains(&"workspace-a-only-model".to_string())
1638 );
1639 assert!(
1640 load_from_disk().is_none_or(|cache| cache
1641 .get(
1642 &scope(codewhale_config::catalog::BASETEN_PROVIDER_ID),
1643 &fingerprint
1644 )
1645 .is_none()),
1646 "an account-scoped Baseten roster must never be durable without a safe account id"
1647 );
1648
1649 let mut custom = std::collections::HashMap::new();
1650 custom.insert(
1651 codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string(),
1652 ProviderConfig {
1653 kind: Some("openai-compatible".to_string()),
1654 base_url: Some(base_url.to_string()),
1655 model: Some(codewhale_config::catalog::BASETEN_DEFAULT_MODEL.to_string()),
1656 ..ProviderConfig::default()
1657 },
1658 );
1659 let config = Config {
1660 provider: Some(codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string()),
1661 providers: Some(ProvidersConfig {
1662 custom,
1663 ..ProvidersConfig::default()
1664 }),
1665 ..Config::default()
1666 };
1667 assert_eq!(maybe_load_persisted_cache_for_config(&config), 0);
1668 assert!(matches!(
1669 status_for_scope(codewhale_config::catalog::BASETEN_PROVIDER_ID, base_url),
1670 CatalogStatus::Unknown
1671 ));
1672 assert!(
1673 !crate::provider_lake::all_catalog_models_for_provider_identity(
1674 ApiProvider::Custom,
1675 Some(codewhale_config::catalog::BASETEN_PROVIDER_ID),
1676 )
1677 .contains(&"workspace-a-only-model".to_string()),
1678 "a new credential attempt must not see the previous workspace roster"
1679 );
1680
1681 reset_cache_for_test();
1682 crate::provider_lake::clear_live_snapshot();
1683 }
1684
1685 #[test]
1686 fn superseded_refresh_ticket_cannot_publish_a_late_response() {
1687 let _env = lock_test_env();
1688 let _live = crate::provider_lake::lock_live_snapshot();
1689 let home = tempfile::tempdir().expect("home");
1690 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1691 reset_cache_for_test();
1692 crate::provider_lake::clear_live_snapshot();
1693
1694 let old = begin_refresh("openrouter");
1695 let current = begin_refresh("openrouter");
1696 assert!(
1697 record_success_if_current(&old, delta("openrouter", "fp", &["late-old-model"]))
1698 .is_none()
1699 );
1700 assert!(
1701 record_success_if_current(&current, delta("openrouter", "fp", &["current-model"]),)
1702 .is_some()
1703 );
1704 assert_eq!(
1705 CACHE
1706 .read()
1707 .expect("cache")
1708 .get(&scope("openrouter"), "fp")
1709 .expect("current scope")
1710 .offerings[0]
1711 .wire_model_id,
1712 "current-model"
1713 );
1714
1715 reset_cache_for_test();
1716 crate::provider_lake::clear_live_snapshot();
1717 }
1718
1719 #[test]
1720 fn current_ticket_holds_generation_gate_through_publication() {
1721 let ticket = begin_refresh("generation-barrier-provider");
1722 let entered = std::sync::Arc::new(std::sync::Barrier::new(2));
1723 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
1724 let publish_entered = std::sync::Arc::clone(&entered);
1725 let publish_release = std::sync::Arc::clone(&release);
1726 let publisher = std::thread::spawn(move || {
1727 with_current_ticket(&ticket, "generation-barrier-provider", || {
1728 publish_entered.wait();
1729 publish_release.wait();
1730 })
1731 });
1732 entered.wait();
1733
1734 let (started_tx, started_rx) = std::sync::mpsc::channel();
1735 let (finished_tx, finished_rx) = std::sync::mpsc::channel();
1736 let newer = std::thread::spawn(move || {
1737 started_tx.send(()).expect("signal refresh start");
1738 let next = begin_refresh("generation-barrier-provider");
1739 finished_tx.send(next).expect("signal refresh finish");
1740 });
1741 started_rx.recv().expect("new refresh thread started");
1742 assert!(
1743 finished_rx
1744 .recv_timeout(std::time::Duration::from_millis(50))
1745 .is_err(),
1746 "a newer generation must wait until the accepted result finishes publication"
1747 );
1748
1749 release.wait();
1750 assert!(publisher.join().expect("publisher thread").is_some());
1751 assert!(
1752 finished_rx
1753 .recv_timeout(std::time::Duration::from_secs(1))
1754 .is_ok()
1755 );
1756 newer.join().expect("newer refresh thread");
1757 }
1758
1759 #[test]
1760 fn stale_process_snapshots_merge_exact_scopes_under_file_lock() {
1761 let _env = lock_test_env();
1762 let home = tempfile::tempdir().expect("home");
1763 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1764
1765 let mut process_a = ProviderCatalogCache::new();
1766 process_a.record_success(stored_delta("CustomA", "fp-a", &["upper-model"]), 60);
1767 persist_scope(&process_a, &scope("CustomA"), "fp-a");
1768
1769 // Simulate another process that started before A wrote and therefore
1770 // has an empty/stale in-memory snapshot. Its scoped write must merge A
1771 // from disk rather than replacing the whole envelope.
1772 let mut process_b = ProviderCatalogCache::new();
1773 process_b.record_success(stored_delta("customa", "fp-b", &["lower-model"]), 60);
1774 persist_scope(&process_b, &scope("customa"), "fp-b");
1775
1776 let loaded = load_from_disk().expect("merged durable cache");
1777 assert_eq!(
1778 loaded
1779 .get(&scope("CustomA"), "fp-a")
1780 .expect("case-sensitive upper scope")
1781 .offerings[0]
1782 .wire_model_id,
1783 "upper-model"
1784 );
1785 assert_eq!(
1786 loaded
1787 .get(&scope("customa"), "fp-b")
1788 .expect("case-sensitive lower scope")
1789 .offerings[0]
1790 .wire_model_id,
1791 "lower-model"
1792 );
1793 }
1794
1795 #[test]
1796 fn stale_process_failure_preserves_newer_durable_rows_for_the_same_scope() {
1797 let _env = lock_test_env();
1798 let _live = crate::provider_lake::lock_live_snapshot();
1799 let home = tempfile::tempdir().expect("home");
1800 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1801 reset_cache_for_test();
1802 crate::provider_lake::clear_live_snapshot();
1803
1804 // Process B began with this old roster and still holds it in memory.
1805 let mut stale_process = ProviderCatalogCache::new();
1806 stale_process.record_success(stored_delta_at("openrouter", "fp", &["old-model"], 1), 60);
1807 persist_scope(&stale_process, &scope("openrouter"), "fp");
1808 *CACHE.write().expect("cache") = stale_process;
1809
1810 // Process A completes a newer successful refresh for the same scope.
1811 let mut newer_process = ProviderCatalogCache::new();
1812 newer_process.record_success(stored_delta_at("openrouter", "fp", &["new-model"], 2), 60);
1813 persist_scope(&newer_process, &scope("openrouter"), "fp");
1814
1815 // B then fails. Its failure status is current, but its old rows are
1816 // not: the transaction must retain A's newer durable roster.
1817 assert!(matches!(
1818 record_failure("openrouter", "fp", CatalogRefreshError::Network),
1819 CatalogStatus::Failed {
1820 reason: CatalogRefreshError::Network
1821 }
1822 ));
1823 let in_memory = CACHE.read().expect("cache");
1824 let entry = in_memory
1825 .get(&scope("openrouter"), "fp")
1826 .expect("failed scope");
1827 assert_eq!(entry.offerings[0].wire_model_id, "new-model");
1828 assert!(matches!(entry.status, CatalogStatus::Failed { .. }));
1829 drop(in_memory);
1830
1831 let durable = load_from_disk().expect("durable cache");
1832 let entry = durable
1833 .get(&scope("openrouter"), "fp")
1834 .expect("durable failed scope");
1835 assert_eq!(entry.offerings[0].wire_model_id, "new-model");
1836 assert!(matches!(entry.status, CatalogStatus::Failed { .. }));
1837
1838 reset_cache_for_test();
1839 crate::provider_lake::clear_live_snapshot();
1840 }
1841
1842 #[test]
1843 fn oversized_cache_file_is_rejected_before_allocation() {
1844 let _env = lock_test_env();
1845 let home = tempfile::tempdir().expect("home");
1846 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1847 let path = cache_path().expect("cache path");
1848 fs::create_dir_all(path.parent().expect("catalog directory")).expect("catalog directory");
1849 fs::File::create(&path)
1850 .and_then(|file| file.set_len(MAX_CACHE_BYTES + 1))
1851 .expect("sparse oversized cache");
1852 assert!(load_from_disk().is_none());
1853 }
1854
1855 #[test]
1856 fn bounded_persistence_evicts_failed_then_stale_scopes_and_keeps_exact_owner() {
1857 let mut cache = ProviderCatalogCache::new();
1858 cache.record_success(
1859 stored_delta_at("failed", "fp", &["failed-model"], 10),
1860 1_000,
1861 );
1862 cache.record_failure(&scope("failed"), "fp", CatalogRefreshError::Network);
1863 cache.record_success(stored_delta_at("stale", "fp", &["stale-model"], 20), 1);
1864 cache.record_success(stored_delta_at("fresh", "fp", &["fresh-model"], 30), 1_000);
1865 cache.record_success(
1866 stored_delta_at("protected", "fp", &["protected-model"], 40),
1867 1_000,
1868 );
1869
1870 let compacted = bounded_cache_for_persistence(
1871 cache,
1872 Some((&scope("protected"), "fp")),
1873 100,
1874 CachePersistenceLimits {
1875 max_bytes: u64::MAX,
1876 max_scopes: 2,
1877 max_rows: 100,
1878 },
1879 )
1880 .expect("bounded cache");
1881
1882 assert!(compacted.get(&scope("protected"), "fp").is_some());
1883 assert!(compacted.get(&scope("fresh"), "fp").is_some());
1884 assert!(compacted.get(&scope("failed"), "fp").is_none());
1885 assert!(compacted.get(&scope("stale"), "fp").is_none());
1886 }
1887
1888 #[test]
1889 fn bounded_persistence_evicts_whole_scopes_and_refuses_an_oversized_owner() {
1890 let mut cache = ProviderCatalogCache::new();
1891 cache.record_success(
1892 stored_delta_at("protected", "fp", &["one", "two"], 40),
1893 1_000,
1894 );
1895 cache.record_success(stored_delta_at("other", "fp", &["other"], 30), 1_000);
1896 let limits = CachePersistenceLimits {
1897 max_bytes: u64::MAX,
1898 max_scopes: 10,
1899 max_rows: 2,
1900 };
1901
1902 let compacted = bounded_cache_for_persistence(
1903 cache.clone(),
1904 Some((&scope("protected"), "fp")),
1905 50,
1906 limits,
1907 )
1908 .expect("other scope can be evicted whole");
1909 assert_eq!(
1910 compacted
1911 .get(&scope("protected"), "fp")
1912 .expect("protected roster")
1913 .offerings
1914 .len(),
1915 2
1916 );
1917 assert!(compacted.get(&scope("other"), "fp").is_none());
1918
1919 let mut oversized = cache;
1920 oversized.record_success(
1921 stored_delta_at("protected", "fp", &["one", "two", "three"], 50),
1922 1_000,
1923 );
1924 assert!(
1925 bounded_cache_for_persistence(oversized, Some((&scope("protected"), "fp")), 50, limits,)
1926 .is_err(),
1927 "a provider roster must be refused, never partially persisted"
1928 );
1929 }
1930
1931 #[test]
1932 fn bounded_cache_write_matches_read_limit_and_round_trips_after_compaction() {
1933 let directory = tempfile::tempdir().expect("cache directory");
1934 let path = directory.path().join(CACHE_FILE);
1935 let mut protected_only = ProviderCatalogCache::new();
1936 protected_only.record_success(
1937 stored_delta_at("protected", "fp", &["protected-model"], 40),
1938 1_000,
1939 );
1940 let exact_bytes = persisted_envelope_len(&protected_only).expect("encoded length");
1941 let limits = CachePersistenceLimits {
1942 max_bytes: exact_bytes,
1943 max_scopes: 10,
1944 max_rows: 10,
1945 };
1946
1947 let mut combined = protected_only;
1948 combined.record_success(
1949 stored_delta_at(
1950 "evicted",
1951 "fp",
1952 &["this-entire-scope-does-not-fit-the-byte-bound"],
1953 30,
1954 ),
1955 1_000,
1956 );
1957 write_bounded_cache(&path, combined, Some((&scope("protected"), "fp")), limits)
1958 .expect("bounded disk write");
1959
1960 assert!(fs::metadata(&path).expect("cache metadata").len() <= exact_bytes);
1961 let loaded = load_from_disk_unlocked_with_limit(&path, exact_bytes)
1962 .expect("bounded cache must remain readable under the same cap");
1963 assert!(loaded.get(&scope("protected"), "fp").is_some());
1964 assert!(loaded.get(&scope("evicted"), "fp").is_none());
1965 }
1966
1967 #[test]
1968 fn baseten_alias_roster_is_session_only_and_keeps_exact_ownership() {
1969 let _env = lock_test_env();
1970 let _live = crate::provider_lake::lock_live_snapshot();
1971 let home = tempfile::tempdir().expect("home");
1972 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
1973 reset_cache_for_test();
1974 crate::provider_lake::clear_live_snapshot();
1975
1976 let alias = "base-ten";
1977 let fingerprint = base_url_fingerprint(codewhale_config::catalog::BASETEN_BASE_URL);
1978 record_success(delta(alias, &fingerprint, &["alias-workspace-model"]));
1979
1980 assert!(
1981 crate::provider_lake::all_catalog_models_for_provider_identity(
1982 ApiProvider::Custom,
1983 Some(alias),
1984 )
1985 .contains(&"alias-workspace-model".to_string())
1986 );
1987 assert!(
1988 !crate::provider_lake::all_catalog_models_for_provider_identity(
1989 ApiProvider::Custom,
1990 Some(codewhale_config::catalog::BASETEN_PROVIDER_ID),
1991 )
1992 .contains(&"alias-workspace-model".to_string()),
1993 "a reviewed schema alias must not collapse distinct exact table ownership"
1994 );
1995 assert!(
1996 load_from_disk().is_none_or(|cache| cache.get(&scope(alias), &fingerprint).is_none()),
1997 "every Baseten schema alias must remain session-only"
1998 );
1999
2000 reset_cache_for_test();
2001 crate::provider_lake::clear_live_snapshot();
2002 }
2003
2004 #[test]
2005 fn different_base_url_fingerprints_do_not_share_rows() {
2006 let mut cache = ProviderCatalogCache::new();
2007 cache.record_success(stored_delta("baseten", "one", &["model-one"]), 60);
2008 cache.record_success(stored_delta("baseten", "two", &["model-two"]), 60);
2009 assert_eq!(
2010 cache.get(&scope("baseten"), "one").unwrap().offerings[0].wire_model_id,
2011 "model-one"
2012 );
2013 assert_eq!(
2014 cache.get(&scope("baseten"), "two").unwrap().offerings[0].wire_model_id,
2015 "model-two"
2016 );
2017 }
2018
2019 #[test]
2020 fn missing_cache_for_changed_base_url_clears_the_previous_provider_partition() {
2021 let _live = crate::provider_lake::lock_live_snapshot();
2022 crate::provider_lake::clear_live_snapshot();
2023 let mut cache = ProviderCatalogCache::new();
2024 cache.record_success(
2025 stored_delta("baseten", "old-fp", &["old-endpoint-model"]),
2026 60,
2027 );
2028
2029 assert_eq!(
2030 publish_exact_scope_for_identity(&cache, ApiProvider::Custom, "baseten", "old-fp"),
2031 1
2032 );
2033 assert_eq!(
2034 crate::provider_lake::all_catalog_models_for_provider_identity(
2035 crate::config::ApiProvider::Custom,
2036 Some("baseten"),
2037 ),
2038 vec!["old-endpoint-model".to_string()]
2039 );
2040
2041 assert_eq!(
2042 publish_exact_scope_for_identity(&cache, ApiProvider::Custom, "baseten", "new-fp"),
2043 0
2044 );
2045 let after_switch = crate::provider_lake::all_catalog_models_for_provider_identity(
2046 crate::config::ApiProvider::Custom,
2047 Some("baseten"),
2048 );
2049 assert!(
2050 after_switch.is_empty(),
2051 "rows from the old Baseten endpoint must not survive a fingerprint change, and no compiled seed replaces them (#6289)"
2052 );
2053 crate::provider_lake::clear_live_snapshot();
2054 }
2055
2056 #[test]
2057 fn disk_reload_rehydrates_and_exposes_six_hundred_openrouter_models() {
2058 let _env = lock_test_env();
2059 let _live = crate::provider_lake::lock_live_snapshot();
2060 let home = tempfile::tempdir().expect("home");
2061 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2062 crate::provider_lake::clear_live_snapshot();
2063 if let Ok(mut cache) = CACHE.write() {
2064 *cache = ProviderCatalogCache::new();
2065 }
2066
2067 let config = Config {
2068 provider: Some("openrouter".to_string()),
2069 providers: Some(ProvidersConfig {
2070 openrouter: ProviderConfig {
2071 base_url: Some("https://synthetic.openrouter.invalid/api/v1".to_string()),
2072 ..ProviderConfig::default()
2073 },
2074 ..ProvidersConfig::default()
2075 }),
2076 ..Config::default()
2077 };
2078 let provider = config.provider_identity_for(config.api_provider());
2079 let fingerprint = base_url_fingerprint(&config.active_route_base_url());
2080 let fetched_at = now_unix();
2081 let ids: Vec<String> = (0..600)
2082 .map(|index| format!("synthetic/openrouter-model-{index:03}"))
2083 .collect();
2084 let status = record_success(ProviderCatalogDelta {
2085 provider: provider.clone(),
2086 base_url_fingerprint: fingerprint,
2087 fetched_at,
2088 offerings: ids
2089 .iter()
2090 .map(|id| CatalogOffering {
2091 provider: provider.clone(),
2092 wire_model_id: id.clone(),
2093 endpoint_key: "chat".to_string(),
2094 source: CatalogSource::Live {
2095 base_url_fingerprint: base_url_fingerprint(&config.active_route_base_url()),
2096 fetched_at,
2097 },
2098 ..CatalogOffering::default()
2099 })
2100 .collect(),
2101 });
2102 assert_eq!(status, CatalogStatus::Fresh);
2103 assert!(cache_path().is_some_and(|path| path.is_file()));
2104 assert_eq!(
2105 crate::provider_lake::all_catalog_models_for_provider(ApiProvider::Openrouter),
2106 ids,
2107 "the string compatibility publisher must retain built-in OpenRouter ownership"
2108 );
2109 assert!(
2110 crate::provider_lake::all_catalog_models_for_provider_identity(
2111 ApiProvider::Custom,
2112 Some("openrouter"),
2113 )
2114 .is_empty(),
2115 "built-in OpenRouter rows must not enter the custom namespace"
2116 );
2117
2118 // Simulate a new process: remove both in-memory owners, then republish
2119 // only through the durable startup load path.
2120 if let Ok(mut cache) = CACHE.write() {
2121 *cache = ProviderCatalogCache::new();
2122 }
2123 crate::provider_lake::clear_live_snapshot();
2124
2125 assert_eq!(maybe_load_persisted_cache_for_config(&config), 600);
2126 let visible =
2127 crate::provider_lake::all_catalog_models_for_provider(ApiProvider::Openrouter);
2128 assert_eq!(visible.len(), 600);
2129 assert_eq!(visible.first(), ids.first());
2130 assert_eq!(visible.last(), ids.last());
2131
2132 if let Ok(mut cache) = CACHE.write() {
2133 *cache = ProviderCatalogCache::new();
2134 }
2135 crate::provider_lake::clear_live_snapshot();
2136 }
2137 #[test]
2138 fn typed_refresh_keeps_builtin_and_same_named_custom_scopes_separate_on_disk() {
2139 let _env = lock_test_env();
2140 let _live = crate::provider_lake::lock_live_snapshot();
2141 let home = tempfile::tempdir().unwrap();
2142 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2143 reset_cache_for_test();
2144 crate::provider_lake::clear_live_snapshot();
2145 let endpoint = "https://api.openai.com/v1";
2146 let fingerprint = base_url_fingerprint(endpoint);
2147 let built_in = begin_refresh_for_identity(ApiProvider::Openai, "openai", endpoint);
2148 let custom = begin_refresh_for_identity(ApiProvider::Custom, "openai", endpoint);
2149 assert_eq!(
2150 record_success_if_current(
2151 &built_in,
2152 delta("openai", &fingerprint, &["built-in-model"])
2153 ),
2154 Some(CatalogStatus::Fresh)
2155 );
2156 assert_eq!(
2157 record_success_if_current(&custom, delta("openai", &fingerprint, &["custom-model"])),
2158 Some(CatalogStatus::Fresh)
2159 );
2160 reset_cache_for_test();
2161 crate::provider_lake::clear_live_snapshot();
2162 for (kind, expected) in [
2163 (ApiProvider::Openai, "built-in-model"),
2164 (ApiProvider::Custom, "custom-model"),
2165 ] {
2166 let entry = cached_entry_for_route(kind, "openai", endpoint)
2167 .unwrap()
2168 .unwrap();
2169 assert_eq!(entry.offerings[0].wire_model_id, expected);
2170 assert_eq!(entry.offerings[0].provider, "openai");
2171 }
2172 assert!(
2173 cached_entry_for_route(ApiProvider::Custom, "OpenAI", endpoint)
2174 .unwrap()
2175 .is_none()
2176 );
2177 reset_cache_for_test();
2178 }
2179
2180 #[test]
2181 fn typed_refresh_rejects_wrong_endpoint_and_superseded_endpoint_response() {
2182 let _env = lock_test_env();
2183 let _live = crate::provider_lake::lock_live_snapshot();
2184 let home = tempfile::tempdir().unwrap();
2185 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2186 reset_cache_for_test();
2187 crate::provider_lake::clear_live_snapshot();
2188 let first_url = "https://first.invalid/v1";
2189 let second_url = "https://second.invalid/v1";
2190 let first = begin_refresh_for_identity(ApiProvider::Custom, "ExactRoute", first_url);
2191 assert!(
2192 record_success_if_current(
2193 &first,
2194 delta(
2195 "ExactRoute",
2196 &base_url_fingerprint(second_url),
2197 &["wrong-endpoint"]
2198 )
2199 )
2200 .is_none()
2201 );
2202 let second = begin_refresh_for_identity(ApiProvider::Custom, "ExactRoute", second_url);
2203 assert!(
2204 record_failure_if_current(
2205 &second,
2206 "ExactRoute",
2207 &base_url_fingerprint(first_url),
2208 CatalogRefreshError::Network
2209 )
2210 .is_none()
2211 );
2212 assert_eq!(
2213 record_success_if_current(
2214 &second,
2215 delta(
2216 "ExactRoute",
2217 &base_url_fingerprint(second_url),
2218 &["current-model"]
2219 )
2220 ),
2221 Some(CatalogStatus::Fresh)
2222 );
2223 assert!(
2224 record_success_if_current(
2225 &first,
2226 delta(
2227 "ExactRoute",
2228 &base_url_fingerprint(first_url),
2229 &["late-model"]
2230 )
2231 )
2232 .is_none()
2233 );
2234 assert!(
2235 cached_entry_for_route(ApiProvider::Custom, "ExactRoute", first_url)
2236 .unwrap()
2237 .is_none()
2238 );
2239 assert_eq!(
2240 crate::provider_lake::catalog_models_for_route(
2241 ApiProvider::Custom,
2242 "ExactRoute",
2243 second_url
2244 ),
2245 vec!["current-model"]
2246 );
2247 reset_cache_for_test();
2248 crate::provider_lake::clear_live_snapshot();
2249 }
2250
2251 #[test]
2252 fn differently_named_baseten_endpoint_never_persists_account_roster() {
2253 let _env = lock_test_env();
2254 let _live = crate::provider_lake::lock_live_snapshot();
2255 let home = tempfile::tempdir().unwrap();
2256 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2257 reset_cache_for_test();
2258 crate::provider_lake::clear_live_snapshot();
2259 let endpoint = codewhale_config::catalog::BASETEN_BASE_URL;
2260 let ticket = begin_refresh_for_identity(ApiProvider::Custom, "TeamServing", endpoint);
2261 assert_eq!(
2262 record_success_if_current(
2263 &ticket,
2264 delta(
2265 "TeamServing",
2266 &base_url_fingerprint(endpoint),
2267 &["private-workspace-model"]
2268 )
2269 ),
2270 Some(CatalogStatus::Fresh)
2271 );
2272 assert_eq!(
2273 crate::provider_lake::catalog_models_for_route(
2274 ApiProvider::Custom,
2275 "TeamServing",
2276 endpoint
2277 ),
2278 vec!["private-workspace-model"]
2279 );
2280 assert!(
2281 !fs::read_to_string(cache_path().unwrap())
2282 .unwrap()
2283 .contains("private-workspace-model")
2284 );
2285 let _new_credentials =
2286 begin_refresh_for_identity(ApiProvider::Custom, "TeamServing", endpoint);
2287 assert!(
2288 crate::provider_lake::catalog_models_for_route(
2289 ApiProvider::Custom,
2290 "TeamServing",
2291 endpoint
2292 )
2293 .is_empty()
2294 );
2295 reset_cache_for_test();
2296 assert!(
2297 cached_entry_for_route(ApiProvider::Custom, "TeamServing", endpoint)
2298 .unwrap()
2299 .is_none()
2300 );
2301 crate::provider_lake::clear_live_snapshot();
2302 }
2303
2304 #[test]
2305 fn codewhale_account_rosters_are_memory_only_and_replaced_after_credential_refresh() {
2306 let _env = lock_test_env();
2307 let _live = crate::provider_lake::lock_live_snapshot();
2308 let home = tempfile::tempdir().unwrap();
2309 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2310 reset_cache_for_test();
2311 crate::provider_lake::clear_live_snapshot();
2312 for (kind, identity, endpoint) in [
2313 (
2314 ApiProvider::Codewhale,
2315 "codewhale",
2316 ApiProvider::Codewhale.default_base_url(),
2317 ),
2318 (
2319 ApiProvider::Codewhale,
2320 "codewhale",
2321 "https://codewhale.account.invalid/v1",
2322 ),
2323 (
2324 ApiProvider::Custom,
2325 "PrivateAccount",
2326 ApiProvider::Codewhale.default_base_url(),
2327 ),
2328 ] {
2329 let fingerprint = base_url_fingerprint(endpoint);
2330 let old = begin_refresh_for_identity(kind, identity, endpoint);
2331 assert_eq!(
2332 record_success_if_current(
2333 &old,
2334 delta(identity, &fingerprint, &["private-old-account-model"])
2335 ),
2336 Some(CatalogStatus::Fresh)
2337 );
2338 assert!(
2339 cached_entry_for_route(kind, identity, endpoint)
2340 .unwrap()
2341 .is_some()
2342 );
2343 assert!(
2344 !fs::read_to_string(cache_path().unwrap())
2345 .unwrap()
2346 .contains("private-old-account-model")
2347 );
2348 let current = begin_refresh_for_identity(kind, identity, endpoint);
2349 assert!(
2350 cached_entry_for_route(kind, identity, endpoint)
2351 .unwrap()
2352 .is_none()
2353 );
2354 assert!(
2355 !crate::provider_lake::catalog_models_for_route(kind, identity, endpoint)
2356 .contains(&"private-old-account-model".to_string())
2357 );
2358 assert!(
2359 record_success_if_current(
2360 &old,
2361 delta(identity, &fingerprint, &["private-old-account-model"])
2362 )
2363 .is_none()
2364 );
2365 assert_eq!(
2366 record_success_if_current(
2367 &current,
2368 delta(identity, &fingerprint, &["private-new-account-model"])
2369 ),
2370 Some(CatalogStatus::Fresh)
2371 );
2372 assert_eq!(
2373 crate::provider_lake::catalog_models_for_route(kind, identity, endpoint),
2374 vec!["private-new-account-model"]
2375 );
2376 assert!(
2377 !fs::read_to_string(cache_path().unwrap())
2378 .unwrap()
2379 .contains("private-new-account-model")
2380 );
2381 reset_cache_for_test();
2382 crate::provider_lake::clear_live_snapshot();
2383 assert!(
2384 cached_entry_for_route(kind, identity, endpoint)
2385 .unwrap()
2386 .is_none()
2387 );
2388 }
2389 reset_cache_for_test();
2390 crate::provider_lake::clear_live_snapshot();
2391 }
2392
2393 #[cfg(unix)]
2394 #[test]
2395 fn cache_reader_rejects_links_and_special_files_without_blocking() {
2396 use std::os::unix::ffi::OsStrExt as _;
2397 let dir = tempfile::tempdir().unwrap();
2398 let target = dir.path().join("target.json");
2399 let envelope = PersistedProviderCatalogs {
2400 schema_version: CACHE_SCHEMA_VERSION,
2401 cache: ProviderCatalogCache::new(),
2402 };
2403 fs::write(&target, serde_json::to_vec(&envelope).unwrap()).unwrap();
2404 let symlink = dir.path().join("symlink.json");
2405 std::os::unix::fs::symlink(&target, &symlink).unwrap();
2406 assert!(load_from_disk_unlocked_with_limit(&symlink, MAX_CACHE_BYTES).is_none());
2407 let hardlink = dir.path().join("hardlink.json");
2408 fs::hard_link(&target, &hardlink).unwrap();
2409 assert!(load_from_disk_unlocked_with_limit(&hardlink, MAX_CACHE_BYTES).is_none());
2410 let fifo = dir.path().join("fifo.json");
2411 let fifo_c = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
2412 // SAFETY: fifo_c owns a NUL-terminated pathname for the call.
2413 assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0);
2414 assert!(load_from_disk_unlocked_with_limit(&fifo, MAX_CACHE_BYTES).is_none());
2415 assert!(open_cache_lock(&fifo).is_err());
2416 assert!(load_from_disk_unlocked_with_limit(dir.path(), MAX_CACHE_BYTES).is_none());
2417 }
2418
2419 struct CloudQuoteReset;
2420 impl Drop for CloudQuoteReset {
2421 fn drop(&mut self) {
2422 codewhale_config::cloud_facts::overlay::clear();
2423 crate::provider_lake::clear_live_snapshot();
2424 reset_cache_for_test();
2425 }
2426 }
2427
2428 fn install_cloud_quote_fixture(
2429 channel: &str,
2430 provider: ApiProvider,
2431 version: u64,
2432 input: f64,
2433 now: u64,
2434 ) {
2435 use codewhale_config::cloud_facts::{
2436 CloudFactsState, CloudFactsStatus, FactsOrigin, ModelFact, PricingFact, ScopedFacts,
2437 overlay,
2438 };
2439 let ticket = overlay::configure(true, "cloud-quote-fixture").unwrap();
2440 assert!(overlay::publish(
2441 &ticket,
2442 Some(ScopedFacts {
2443 channel: channel.into(),
2444 facts_version: version,
2445 key_id: "cwf-test-only".into(),
2446 valid_until: Some(now + 60),
2447 models: vec![ModelFact {
2448 provider: provider.as_str().into(),
2449 id: "cloud-quote-fixture".into(),
2450 context_window: Some(16_384),
2451 pricing: Some(PricingFact {
2452 input_per_m: Some(input),
2453 output_per_m: Some(2.0),
2454 cache_read_per_m: None,
2455 }),
2456 ..Default::default()
2457 }],
2458 ..Default::default()
2459 }),
2460 CloudFactsStatus {
2461 state: CloudFactsState::Verified {
2462 channel: channel.into(),
2463 facts_version: version,
2464 key_id: "cwf-test-only".into(),
2465 fetched_at: now,
2466 origin: FactsOrigin::LocalFile,
2467 stale: false,
2468 patches: 1,
2469 defaults: 0,
2470 announcements: 0,
2471 },
2472 ..Default::default()
2473 },
2474 ));
2475 }
2476
2477 #[test]
2478 fn cloud_quote_freezes_actual_dispatch_and_survives_refresh_disable_and_reload() {
2479 let _env = lock_test_env();
2480 let _live = crate::provider_lake::lock_live_snapshot();
2481 let home = tempfile::tempdir().unwrap();
2482 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2483 let _enabled = EnvVarGuard::remove("CODEWHALE_DISABLE_CLOUD_FACTS");
2484 let _reset = CloudQuoteReset;
2485 reset_cache_for_test();
2486 crate::provider_lake::clear_live_snapshot();
2487 let now = chrono::Utc::now();
2488 let at = now.timestamp() as u64;
2489 let base = crate::config::DEFAULT_OPENAI_BASE_URL;
2490 install_cloud_quote_fixture("quote-frozen", ApiProvider::Openai, 91, 1.0, at);
2491 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
2492 None,
2493 ApiProvider::Openai,
2494 "openai",
2495 "cloud-quote-fixture",
2496 Some(base),
2497 now,
2498 );
2499 let quote = route
2500 .provider_live_pricing
2501 .as_ref()
2502 .expect("frozen cloud price");
2503 assert_eq!(quote.provenance, PricingProvenance::CloudFacts);
2504 assert_eq!(quote.cloud_facts.as_ref().unwrap().facts_version, 91);
2505 assert_eq!(quote.input_per_million.as_deref(), Some("1"));
2506 assert!(quote.cache_read_per_million.is_none());
2507 let encoded = serde_json::to_string(&route).unwrap();
2508 install_cloud_quote_fixture("quote-frozen", ApiProvider::Openai, 92, 9.0, at);
2509 let newer = fresh_dispatch_pricing_quote_at(
2510 ApiProvider::Openai,
2511 "openai",
2512 "cloud-quote-fixture",
2513 base,
2514 at,
2515 )
2516 .unwrap();
2517 assert_eq!(newer.input_per_million.as_deref(), Some("9"));
2518 assert_ne!(newer.catalog_revision, quote.catalog_revision);
2519 codewhale_config::cloud_facts::overlay::clear();
2520 assert!(
2521 fresh_dispatch_pricing_quote_at(
2522 ApiProvider::Openai,
2523 "openai",
2524 "cloud-quote-fixture",
2525 base,
2526 at,
2527 )
2528 .is_none()
2529 );
2530 let restored: crate::cost_status::EffectiveRouteEnvelope =
2531 serde_json::from_str(&encoded).unwrap();
2532 assert_eq!(restored, route);
2533 let pricing = restored
2534 .provider_live_pricing
2535 .as_ref()
2536 .unwrap()
2537 .pricing_for_route(
2538 ApiProvider::Openai,
2539 "openai",
2540 "cloud-quote-fixture",
2541 &base_url_fingerprint(base),
2542 at,
2543 )
2544 .unwrap();
2545 assert_eq!(pricing.input_per_million, Some(1.0));
2546 // Expiry is checked at dispatch; loading later does not reprice history.
2547 assert!(
2548 quote
2549 .pricing_for_route(
2550 ApiProvider::Openai,
2551 "openai",
2552 "cloud-quote-fixture",
2553 &base_url_fingerprint(base),
2554 at + 61,
2555 )
2556 .is_none()
2557 );
2558 }
2559
2560 #[test]
2561 fn cloud_quote_rejects_cross_route_and_modified_source_receipts() {
2562 let _env = lock_test_env();
2563 let _live = crate::provider_lake::lock_live_snapshot();
2564 let home = tempfile::tempdir().unwrap();
2565 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2566 let _enabled = EnvVarGuard::remove("CODEWHALE_DISABLE_CLOUD_FACTS");
2567 let _reset = CloudQuoteReset;
2568 reset_cache_for_test();
2569 crate::provider_lake::clear_live_snapshot();
2570 let at = now_unix();
2571 let base = crate::config::DEFAULT_OPENAI_BASE_URL;
2572 install_cloud_quote_fixture("quote-binding", ApiProvider::Openai, 93, 1.0, at);
2573 let quote = fresh_dispatch_pricing_quote_at(
2574 ApiProvider::Openai,
2575 "openai",
2576 "cloud-quote-fixture",
2577 base,
2578 at,
2579 )
2580 .unwrap();
2581 for (provider, identity, endpoint) in [
2582 (ApiProvider::Openai, "openai", "https://proxy.example/v1"),
2583 (ApiProvider::Custom, "openai", base),
2584 (ApiProvider::Openai, "named-openai", base),
2585 (
2586 ApiProvider::Openai,
2587 "openai",
2588 "https://secret@api.openai.com/v1",
2589 ),
2590 ] {
2591 assert!(
2592 fresh_dispatch_pricing_quote_at(
2593 provider,
2594 identity,
2595 "cloud-quote-fixture",
2596 endpoint,
2597 at
2598 )
2599 .is_none()
2600 );
2601 }
2602 let value = serde_json::to_value(&quote).unwrap();
2603 for (field, replacement) in [
2604 ("facts_version", serde_json::json!(94)),
2605 ("key_id", serde_json::json!("cwf-relabelled")),
2606 ("valid_until", serde_json::json!(at + 600)),
2607 ("base_url", serde_json::json!("https://proxy.example/v1")),
2608 ] {
2609 let mut modified = value.clone();
2610 modified["cloud_facts"][field] = replacement;
2611 assert!(
2612 serde_json::from_value::<ProviderLivePricingQuote>(modified).is_err(),
2613 "changed {field} accepted"
2614 );
2615 }
2616 let mut invalid = quote.clone();
2617 invalid.cloud_facts.as_mut().unwrap().base_url = "https://secret@api.openai.com/v1".into();
2618 assert!(serde_json::to_value(invalid).unwrap().is_null());
2619 assert!(
2620 quote
2621 .pricing_for_route(
2622 ApiProvider::Openai,
2623 "openai",
2624 "different-model",
2625 &base_url_fingerprint(base),
2626 at,
2627 )
2628 .is_none()
2629 );
2630 }
2631
2632 #[test]
2633 fn cloud_quote_static_endpoint_survives_operator_endpoint_changes() {
2634 let _env = lock_test_env();
2635 let _live = crate::provider_lake::lock_live_snapshot();
2636 let home = tempfile::tempdir().unwrap();
2637 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
2638 let _enabled = EnvVarGuard::remove("CODEWHALE_DISABLE_CLOUD_FACTS");
2639 let _reset = CloudQuoteReset;
2640 reset_cache_for_test();
2641 crate::provider_lake::clear_live_snapshot();
2642 let at = now_unix();
2643 let base = ApiProvider::Codewhale.default_base_url();
2644 let private = "https://private.example/v1?api_key=public-test-marker";
2645 let declared = EnvVarGuard::set("CODEWHALE_API_BASE", private);
2646 // The ordinary runtime credential contract intentionally accepts this
2647 // explicit operator route. It is not a public signed-facts authority.
2648 assert!(codewhale_config::provider_base_url_is_official(
2649 codewhale_config::ProviderKind::Codewhale,
2650 private,
2651 ));
2652 install_cloud_quote_fixture("quote-static", ApiProvider::Codewhale, 94, 1.0, at);
2653 assert!(
2654 fresh_dispatch_pricing_quote_at(
2655 ApiProvider::Codewhale,
2656 "codewhale",
2657 "cloud-quote-fixture",
2658 private,
2659 at,
2660 )
2661 .is_none()
2662 );
2663 let quote = fresh_dispatch_pricing_quote_at(
2664 ApiProvider::Codewhale,
2665 "codewhale",
2666 "cloud-quote-fixture",
2667 base,
2668 at,
2669 )
2670 .unwrap();
2671 let encoded = serde_json::to_string(&quote).unwrap();
2672 assert!(!encoded.contains("private.example"));
2673 assert!(!encoded.contains("public-test-marker"));
2674 drop(declared);
2675 let removed = EnvVarGuard::remove("CODEWHALE_API_BASE");
2676 let reloaded: ProviderLivePricingQuote = serde_json::from_str(&encoded).unwrap();
2677 assert_eq!(reloaded, quote);
2678 drop(removed);
2679 let _changed = EnvVarGuard::set("CODEWHALE_API_BASE", "https://another-private.example/v1");
2680 assert_eq!(
2681 serde_json::from_str::<ProviderLivePricingQuote>(&encoded).unwrap(),
2682 quote
2683 );
2684 assert!(
2685 quote
2686 .pricing_for_route(
2687 ApiProvider::Codewhale,
2688 "codewhale",
2689 "cloud-quote-fixture",
2690 &base_url_fingerprint(base),
2691 at,
2692 )
2693 .is_some()
2694 );
2695 }
2696
2697 #[test]
2698 fn durable_provider_catalog_cannot_claim_cloud_or_override_price_authority() {
2699 let dir = tempfile::tempdir().unwrap();
2700 let path = dir.path().join("catalog.json");
2701 let mut cache = ProviderCatalogCache::new();
2702 cache.record_success(
2703 stored_delta("openai", "fp", &["fixture"]),
2704 DEFAULT_PROVIDER_CATALOG_TTL_SECS,
2705 );
2706 let baseline = PersistedProviderCatalogs {
2707 schema_version: CACHE_SCHEMA_VERSION,
2708 cache,
2709 };
2710 fs::write(&path, serde_json::to_vec(&baseline).unwrap()).unwrap();
2711 assert!(load_from_disk_unlocked(&path).is_some());
2712 for source in [
2713 CatalogSource::CloudFacts {
2714 facts_version: 7,
2715 key_id: "cwf-test-only".into(),
2716 fetched_at: now_unix(),
2717 valid_until: None,
2718 },
2719 CatalogSource::ConfigOverride,
2720 CatalogSource::UserOverride,
2721 CatalogSource::Live {
2722 base_url_fingerprint: "other".into(),
2723 fetched_at: now_unix(),
2724 },
2725 ] {
2726 let mut forged = baseline.clone();
2727 forged.cache.entries.values_mut().next().unwrap().offerings[0].cost_source =
2728 Some(source);
2729 fs::write(&path, serde_json::to_vec(&forged).unwrap()).unwrap();
2730 assert!(load_from_disk_unlocked(&path).is_none());
2731 }
2732 }
2733 }
2734
2734 lines RUST