返回 CodeWhale
provider_setup.rs
根目录 / crates / tui / src / tui / ui / provider_setup.rs
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
149 lines RUST