| 1 | //! One immutable authority for a compiled provider catalog and its route resolver. |
| 2 | //! |
| 3 | //! Before this seam, callers could independently project a |
| 4 | //! [`crate::catalog::CatalogSnapshot`] through |
| 5 | //! [`crate::catalog::CatalogSnapshot::to_offerings`] and create a |
| 6 | //! [`super::RouteResolver`]. That made it possible for a picker, readiness |
| 7 | //! view, and execution path to use different catalog snapshots without any |
| 8 | //! type-level signal. `RouteAuthoritySnapshot` replaces that ad-hoc pairing for |
| 9 | //! new consumers: it owns the exact compiled catalog and the resolver projected |
| 10 | //! from that catalog together. |
| 11 | //! |
| 12 | //! It deliberately does not claim that a catalog row is runnable. Calling |
| 13 | //! [`RouteAuthoritySnapshot::resolve`] still mints a |
| 14 | //! [`super::ReadyRouteCandidate`] through the sole resolver. The returned |
| 15 | //! receipt distinguishes an exact catalog row, a custom-endpoint route whose |
| 16 | //! provider facts are intentionally not reused, and an allowed pass-through |
| 17 | //! route with no catalog row. All state is secret-free. |
| 18 | |
| 19 | use std::collections::BTreeMap; |
| 20 | |
| 21 | use serde::{Deserialize, Serialize}; |
| 22 | |
| 23 | use crate::catalog::{CatalogOffering, CatalogSnapshot, CatalogStatus}; |
| 24 | |
| 25 | use super::{ |
| 26 | ProviderId, ReadyRouteCandidate, RouteError, RouteRequest, RouteResolver, WireModelId, |
| 27 | }; |
| 28 | |
| 29 | /// A secret-free provider catalog cache scope. |
| 30 | /// |
| 31 | /// The base URL is represented only by its already-redacted fingerprint. The |
| 32 | /// provider remains an open catalog string because a catalog can include a |
| 33 | /// discoverable provider that is not yet a built-in [`crate::ProviderKind`]. |
| 34 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] |
| 35 | pub struct RouteCatalogScope { |
| 36 | provider: String, |
| 37 | base_url_fingerprint: String, |
| 38 | } |
| 39 | |
| 40 | impl RouteCatalogScope { |
| 41 | /// Construct a normalized, secret-free cache scope. |
| 42 | #[must_use] |
| 43 | pub fn new(provider: impl Into<String>, base_url_fingerprint: impl Into<String>) -> Self { |
| 44 | Self { |
| 45 | provider: provider.into().trim().to_string(), |
| 46 | base_url_fingerprint: base_url_fingerprint.into().trim().to_string(), |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /// Provider id associated with this cache scope. |
| 51 | #[must_use] |
| 52 | pub fn provider(&self) -> &str { |
| 53 | &self.provider |
| 54 | } |
| 55 | |
| 56 | /// Secret-free base URL fingerprint associated with this cache scope. |
| 57 | #[must_use] |
| 58 | pub fn base_url_fingerprint(&self) -> &str { |
| 59 | &self.base_url_fingerprint |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /// Provenance receipt for a route resolved from a [`RouteAuthoritySnapshot`]. |
| 64 | /// |
| 65 | /// A catalog source describes metadata provenance, not account authorization or |
| 66 | /// endpoint health. Consumers must keep readiness/auth checks separate. |
| 67 | #[derive(Debug, Clone, PartialEq, Serialize)] |
| 68 | #[serde(tag = "kind", rename_all = "snake_case")] |
| 69 | pub enum CatalogOfferingReceipt { |
| 70 | /// The resolved provider/wire pair exactly matches an offering in the |
| 71 | /// compiled catalog owned by this authority snapshot. |
| 72 | Catalog { offering: Box<CatalogOffering> }, |
| 73 | /// The caller selected an explicit base URL. The resolver deliberately |
| 74 | /// clears catalog-owned capabilities and pricing for that custom endpoint, |
| 75 | /// so it must not be presented as an exact catalog offering. |
| 76 | CustomEndpoint { |
| 77 | provider: ProviderId, |
| 78 | wire_model_id: WireModelId, |
| 79 | }, |
| 80 | /// The resolver permitted an unknown/pass-through wire model, but the |
| 81 | /// compiled catalog did not assert facts for that provider/wire pair. |
| 82 | NotCataloged { |
| 83 | provider: ProviderId, |
| 84 | wire_model_id: WireModelId, |
| 85 | }, |
| 86 | } |
| 87 | |
| 88 | impl CatalogOfferingReceipt { |
| 89 | /// Return the exact catalog offering, if this route was catalog-backed. |
| 90 | #[must_use] |
| 91 | pub fn offering(&self) -> Option<&CatalogOffering> { |
| 92 | match self { |
| 93 | Self::Catalog { offering } => Some(offering), |
| 94 | Self::CustomEndpoint { .. } | Self::NotCataloged { .. } => None, |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// One route-resolution result plus honest catalog provenance. |
| 100 | #[derive(Debug, Clone)] |
| 101 | pub struct AuthorityResolution { |
| 102 | candidate: ReadyRouteCandidate, |
| 103 | receipt: CatalogOfferingReceipt, |
| 104 | } |
| 105 | |
| 106 | impl AuthorityResolution { |
| 107 | /// The executable candidate minted by the snapshot's resolver. |
| 108 | #[must_use] |
| 109 | pub fn candidate(&self) -> &ReadyRouteCandidate { |
| 110 | &self.candidate |
| 111 | } |
| 112 | |
| 113 | /// Honest catalog provenance for the candidate's provider/wire pair. |
| 114 | #[must_use] |
| 115 | pub fn receipt(&self) -> &CatalogOfferingReceipt { |
| 116 | &self.receipt |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /// A compiled catalog and the resolver derived from that exact catalog. |
| 121 | /// |
| 122 | /// The private fields prevent a consumer from retaining the snapshot while |
| 123 | /// silently replacing its resolver. Refreshers build a new immutable snapshot |
| 124 | /// when their catalog changes; existing consumers keep their coherent view. |
| 125 | #[derive(Debug, Clone)] |
| 126 | pub struct RouteAuthoritySnapshot { |
| 127 | catalog: CatalogSnapshot, |
| 128 | resolver: RouteResolver, |
| 129 | scope_statuses: BTreeMap<RouteCatalogScope, CatalogStatus>, |
| 130 | } |
| 131 | |
| 132 | impl RouteAuthoritySnapshot { |
| 133 | /// Bind a compiled catalog to the resolver that consumes its offerings. |
| 134 | #[must_use] |
| 135 | pub fn new(catalog: CatalogSnapshot) -> Self { |
| 136 | let resolver = RouteResolver::from_offerings(catalog.to_offerings()); |
| 137 | Self { |
| 138 | catalog, |
| 139 | resolver, |
| 140 | scope_statuses: BTreeMap::new(), |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// Record the cache health known for one provider/base-URL scope. |
| 145 | /// |
| 146 | /// An omitted status stays [`CatalogStatus::Unknown`]; this API never |
| 147 | /// derives freshness from a model row or invents a successful refresh. |
| 148 | #[must_use] |
| 149 | pub fn with_scope_status(mut self, scope: RouteCatalogScope, status: CatalogStatus) -> Self { |
| 150 | self.scope_statuses.insert(scope, status); |
| 151 | self |
| 152 | } |
| 153 | |
| 154 | /// The exact compiled catalog bound to this resolver. |
| 155 | #[must_use] |
| 156 | pub fn catalog(&self) -> &CatalogSnapshot { |
| 157 | &self.catalog |
| 158 | } |
| 159 | |
| 160 | /// Cache health for one provider/base-URL scope, or honestly unknown. |
| 161 | #[must_use] |
| 162 | pub fn scope_status(&self, scope: &RouteCatalogScope) -> CatalogStatus { |
| 163 | self.scope_statuses |
| 164 | .get(scope) |
| 165 | .cloned() |
| 166 | .unwrap_or(CatalogStatus::Unknown) |
| 167 | } |
| 168 | |
| 169 | /// Resolve through the authority-bound resolver and retain catalog receipt. |
| 170 | /// |
| 171 | /// # Errors |
| 172 | /// Returns the route resolver's validation error when a request cannot |
| 173 | /// produce an executable candidate. |
| 174 | pub fn resolve(&self, request: &RouteRequest) -> Result<AuthorityResolution, RouteError> { |
| 175 | let candidate = self.resolver.resolve(request)?; |
| 176 | let receipt = if request.base_url_override.is_some() { |
| 177 | CatalogOfferingReceipt::CustomEndpoint { |
| 178 | provider: candidate.provider_id().clone(), |
| 179 | wire_model_id: candidate.wire_model_id().clone(), |
| 180 | } |
| 181 | } else if let Some(offering) = self.catalog.offerings.iter().find(|offering| { |
| 182 | offering.provider == candidate.provider_id().as_str() |
| 183 | && offering.wire_model_id == candidate.wire_model_id().as_str() |
| 184 | }) { |
| 185 | CatalogOfferingReceipt::Catalog { |
| 186 | offering: Box::new(offering.clone()), |
| 187 | } |
| 188 | } else { |
| 189 | CatalogOfferingReceipt::NotCataloged { |
| 190 | provider: candidate.provider_id().clone(), |
| 191 | wire_model_id: candidate.wire_model_id().clone(), |
| 192 | } |
| 193 | }; |
| 194 | |
| 195 | Ok(AuthorityResolution { candidate, receipt }) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | #[cfg(test)] |
| 200 | mod tests { |
| 201 | use crate::ProviderKind; |
| 202 | use crate::catalog::{CatalogCompiler, CatalogSource}; |
| 203 | use crate::models_dev::ModelsDevLimit; |
| 204 | use crate::route::{LogicalModelRef, RouteRequest}; |
| 205 | |
| 206 | use super::{CatalogOfferingReceipt, RouteAuthoritySnapshot, RouteCatalogScope}; |
| 207 | |
| 208 | fn request(model: &str) -> RouteRequest { |
| 209 | RouteRequest { |
| 210 | explicit_provider: Some(ProviderKind::Deepseek), |
| 211 | model_selector: Some(LogicalModelRef::from(model)), |
| 212 | saved_provider_model: None, |
| 213 | base_url_override: None, |
| 214 | limit_overrides: Vec::new(), |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | fn offering(source: CatalogSource, context: u64) -> crate::catalog::CatalogOffering { |
| 219 | crate::catalog::CatalogOffering { |
| 220 | provider: "deepseek".to_string(), |
| 221 | wire_model_id: "deepseek-v4-flash-vision-exp".to_string(), |
| 222 | endpoint_key: "responses".to_string(), |
| 223 | default_for_provider: true, |
| 224 | limit: Some(ModelsDevLimit { |
| 225 | context: Some(context), |
| 226 | ..Default::default() |
| 227 | }), |
| 228 | source, |
| 229 | ..Default::default() |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | #[test] |
| 234 | fn one_compiled_snapshot_drives_candidate_and_catalog_receipt() { |
| 235 | let snapshot = CatalogCompiler::new() |
| 236 | .with_bundled(vec![offering(CatalogSource::Bundled, 32_000)]) |
| 237 | .with_config(vec![offering(CatalogSource::ConfigOverride, 64_000)]) |
| 238 | .compile(); |
| 239 | let authority = RouteAuthoritySnapshot::new(snapshot); |
| 240 | |
| 241 | let resolved = authority |
| 242 | .resolve(&request("deepseek-v4-flash-vision-exp")) |
| 243 | .expect("catalog-backed direct route resolves"); |
| 244 | |
| 245 | assert_eq!(resolved.candidate().limits().context_tokens, Some(64_000)); |
| 246 | let CatalogOfferingReceipt::Catalog { offering } = resolved.receipt() else { |
| 247 | panic!("compiled catalog row must be retained as the receipt"); |
| 248 | }; |
| 249 | assert_eq!(offering.source, CatalogSource::ConfigOverride); |
| 250 | assert_eq!( |
| 251 | offering.limit.as_ref().and_then(|limit| limit.context), |
| 252 | Some(64_000) |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | #[test] |
| 257 | fn custom_endpoint_never_claims_catalog_offering_facts() { |
| 258 | let snapshot = CatalogCompiler::new() |
| 259 | .with_bundled(vec![offering(CatalogSource::Bundled, 32_000)]) |
| 260 | .compile(); |
| 261 | let authority = RouteAuthoritySnapshot::new(snapshot); |
| 262 | let mut request = request("deepseek-v4-flash-vision-exp"); |
| 263 | request.base_url_override = Some("https://compatible.example/v1".to_string()); |
| 264 | |
| 265 | let resolved = authority |
| 266 | .resolve(&request) |
| 267 | .expect("custom compatible endpoint still resolves"); |
| 268 | |
| 269 | assert!(matches!( |
| 270 | resolved.receipt(), |
| 271 | CatalogOfferingReceipt::CustomEndpoint { .. } |
| 272 | )); |
| 273 | assert!(resolved.receipt().offering().is_none()); |
| 274 | } |
| 275 | |
| 276 | #[test] |
| 277 | fn direct_provider_pass_through_is_explicitly_not_cataloged() { |
| 278 | let authority = RouteAuthoritySnapshot::new(CatalogCompiler::new().compile()); |
| 279 | |
| 280 | let resolved = authority |
| 281 | .resolve(&request("future-deepseek-model")) |
| 282 | .expect("direct-provider pass-through remains executable"); |
| 283 | |
| 284 | assert!(matches!( |
| 285 | resolved.receipt(), |
| 286 | CatalogOfferingReceipt::NotCataloged { .. } |
| 287 | )); |
| 288 | assert!(resolved.receipt().offering().is_none()); |
| 289 | } |
| 290 | |
| 291 | #[test] |
| 292 | fn scope_status_is_explicit_and_defaults_to_unknown() { |
| 293 | let snapshot = CatalogCompiler::new().compile(); |
| 294 | let fresh_scope = RouteCatalogScope::new("deepseek", "fingerprint-a"); |
| 295 | let absent_scope = RouteCatalogScope::new("deepseek", "fingerprint-b"); |
| 296 | let authority = RouteAuthoritySnapshot::new(snapshot) |
| 297 | .with_scope_status(fresh_scope.clone(), crate::catalog::CatalogStatus::Fresh); |
| 298 | |
| 299 | assert_eq!( |
| 300 | authority.scope_status(&fresh_scope), |
| 301 | crate::catalog::CatalogStatus::Fresh |
| 302 | ); |
| 303 | assert_eq!( |
| 304 | authority.scope_status(&absent_scope), |
| 305 | crate::catalog::CatalogStatus::Unknown |
| 306 | ); |
| 307 | } |
| 308 | } |
| 309 |