| 1 | //! Provider-configuration support: runtime-preset file snapshots with |
| 2 | //! rollback, and the provider key verification seam |
| 3 | //! (TUI_MODULARIZATION.md slice 8). |
| 4 | |
| 5 | use super::*; |
| 6 | |
| 7 | pub(crate) trait ProviderKeyVerifier { |
| 8 | fn verify<'a>( |
| 9 | &'a self, |
| 10 | provider: ProviderKind, |
| 11 | api_key: &'a str, |
| 12 | base_url: &'a str, |
| 13 | ) -> ProviderKeyVerification<'a>; |
| 14 | } |
| 15 | |
| 16 | pub(crate) struct LiveProviderKeyVerifier; |
| 17 | |
| 18 | impl ProviderKeyVerifier for LiveProviderKeyVerifier { |
| 19 | fn verify<'a>( |
| 20 | &'a self, |
| 21 | provider: ProviderKind, |
| 22 | api_key: &'a str, |
| 23 | base_url: &'a str, |
| 24 | ) -> ProviderKeyVerification<'a> { |
| 25 | Box::pin(crate::client::verify_provider_api_key( |
| 26 | provider, api_key, base_url, |
| 27 | )) |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | /// Publish the `/models` roster a successful key probe already downloaded as |
| 32 | /// this exact route's live catalog (the #3385 cache and its provider-lake |
| 33 | /// partition). A route roster is authoritative for the ids it lists and for |
| 34 | /// its omissions, so the model pick that follows — and `/provider` and |
| 35 | /// `/model` — offer what this key can call today instead of bundled or |
| 36 | /// Models.dev rows the provider has retired. The probe scopes rows to the |
| 37 | /// provider kind; ownership here is the exact route identity, so a named or |
| 38 | /// regional table keeps its own partition. |
| 39 | /// |
| 40 | /// The endpoint decides availability; the catalog still decides which listed |
| 41 | /// ids are chat models. A first roster for a route keeps only the ids the |
| 42 | /// catalog already offers there (case-insensitively, in the provider's own |
| 43 | /// spelling), so an OpenAI-style `/models` that also lists embedding, speech |
| 44 | /// and image models does not flood setup. When the catalog knows none of the |
| 45 | /// ids, or the route already has a roster, the probe's roster is kept whole. |
| 46 | /// |
| 47 | /// Does not: list a served chat model the catalog does not know yet (until |
| 48 | /// the catalog or `codewhale models --update` lists it — the same as before |
| 49 | /// this probe was read), refresh on its own schedule, or remove the roster if |
| 50 | /// the user abandons setup. The rows are secret-free facts about this |
| 51 | /// endpoint; account-scoped endpoints are re-fenced by |
| 52 | /// `begin_refresh_for_identity`. |
| 53 | pub(crate) fn publish_verified_roster( |
| 54 | identity: &crate::config::ProviderIdentity, |
| 55 | base_url: &str, |
| 56 | roster: Option<codewhale_config::catalog::ProviderCatalogDelta>, |
| 57 | ) { |
| 58 | let Some(mut roster) = roster else { |
| 59 | return; |
| 60 | }; |
| 61 | let key = identity.key.as_str(); |
| 62 | let has_route_roster = |
| 63 | crate::provider_catalog_live::cached_entry_for_route(identity.provider, key, base_url) |
| 64 | .ok() |
| 65 | .flatten() |
| 66 | .is_some_and(|entry| entry.fetched_at > 0); |
| 67 | if !has_route_roster { |
| 68 | // Without a route roster this is the catalog view: bundled, Models.dev |
| 69 | // and signed rows for this provider. |
| 70 | let offered = |
| 71 | crate::provider_lake::catalog_models_for_route(identity.provider, key, base_url); |
| 72 | let chat: Vec<_> = roster |
| 73 | .offerings |
| 74 | .iter() |
| 75 | .filter(|row| { |
| 76 | offered |
| 77 | .iter() |
| 78 | .any(|id| id.eq_ignore_ascii_case(&row.wire_model_id)) |
| 79 | }) |
| 80 | .cloned() |
| 81 | .collect(); |
| 82 | if !chat.is_empty() { |
| 83 | roster.offerings = chat; |
| 84 | } |
| 85 | } |
| 86 | let owner = identity.key.to_string(); |
| 87 | for row in &mut roster.offerings { |
| 88 | row.provider.clone_from(&owner); |
| 89 | } |
| 90 | roster.provider = owner; |
| 91 | let ticket = |
| 92 | crate::provider_catalog_live::begin_refresh_for_identity(identity.provider, key, base_url); |
| 93 | // `None` means a newer refresh for this route superseded the probe; its |
| 94 | // rows win, which is the failure-preserving outcome we want. |
| 95 | let _ = crate::provider_catalog_live::record_success_if_current(&ticket, roster); |
| 96 | } |
| 97 | |
| 98 | pub(crate) struct RuntimePresetFileSnapshot { |
| 99 | pub(crate) path: PathBuf, |
| 100 | pub(crate) contents: Option<Vec<u8>>, |
| 101 | } |
| 102 | |
| 103 | impl RuntimePresetFileSnapshot { |
| 104 | pub(crate) fn capture(path: PathBuf) -> Result<Self> { |
| 105 | let contents = match std::fs::read(&path) { |
| 106 | Ok(contents) => Some(contents), |
| 107 | Err(error) if error.kind() == io::ErrorKind::NotFound => None, |
| 108 | Err(error) => { |
| 109 | return Err(error) |
| 110 | .with_context(|| format!("failed to snapshot {}", path.display())); |
| 111 | } |
| 112 | }; |
| 113 | Ok(Self { path, contents }) |
| 114 | } |
| 115 | |
| 116 | fn restore(&self) -> Result<()> { |
| 117 | match &self.contents { |
| 118 | Some(contents) => crate::utils::write_atomic(&self.path, contents) |
| 119 | .with_context(|| format!("failed to restore {}", self.path.display())), |
| 120 | None => match std::fs::remove_file(&self.path) { |
| 121 | Ok(()) => Ok(()), |
| 122 | Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), |
| 123 | Err(error) => { |
| 124 | Err(error).with_context(|| format!("failed to remove {}", self.path.display())) |
| 125 | } |
| 126 | }, |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | pub(crate) fn runtime_preset_error_with_rollback( |
| 132 | error: anyhow::Error, |
| 133 | snapshots: &[&RuntimePresetFileSnapshot], |
| 134 | ) -> anyhow::Error { |
| 135 | let rollback_errors = snapshots |
| 136 | .iter() |
| 137 | .filter_map(|snapshot| snapshot.restore().err()) |
| 138 | .map(|error| format!("{error:#}")) |
| 139 | .collect::<Vec<_>>(); |
| 140 | if rollback_errors.is_empty() { |
| 141 | error |
| 142 | } else { |
| 143 | anyhow::anyhow!( |
| 144 | "{error:#}; runtime preset rollback also failed: {}", |
| 145 | rollback_errors.join("; ") |
| 146 | ) |
| 147 | } |
| 148 | } |
| 149 |