super-stt-shared/src/registry/mod.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Wire types for `/registry/backend/list` and friends. All fields `snake_case`. |
3 | | |
4 | | use serde::{Deserialize, Serialize}; |
5 | | |
6 | | pub mod events; |
7 | | |
8 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
9 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
10 | | pub struct RegistryListResponse { |
11 | | pub schema_version: u32, |
12 | | pub generated_at: String, |
13 | | pub backends: Vec<RegistryBackend>, |
14 | | } |
15 | | |
16 | | // A flat mirror of the `/registry/backend/list` JSON. The lint wants related flags |
17 | | // grouped into a sub-struct, which here would reshape the wire payload to suit |
18 | | // an internal API guideline. |
19 | | #[allow(clippy::struct_excessive_bools)] |
20 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
21 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
22 | | pub struct RegistryBackend { |
23 | | pub id: String, |
24 | | /// The backend's reverse-DNS identifier, or `None` when the registry entry |
25 | | /// predates it. Names the install directory. |
26 | | #[serde(default)] |
27 | | pub backend_id: Option<String>, |
28 | | pub source: String, |
29 | | pub version: String, |
30 | | pub name: String, |
31 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
32 | | pub description: Option<String>, |
33 | | pub license: String, |
34 | | pub kind: String, |
35 | | /// The contract generation the backend declares, as published. Carried |
36 | | /// as a string so a client lists an entry whose generation it does not |
37 | | /// know; `compatibility` says whether this daemon can drive it. |
38 | | pub contract: String, |
39 | | /// The Super STT release that first understood `contract`, as stamped by |
40 | | /// the indexer. `None` for an index that predates the stamp. |
41 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
42 | | pub min_client: Option<String>, |
43 | | #[serde(default)] |
44 | | pub allowed_hosts: Vec<String>, |
45 | | pub online: bool, |
46 | | pub supports_gpu: bool, |
47 | | pub supports_cpu: bool, |
48 | | pub models: Vec<RegistryModel>, |
49 | | pub secrets: Vec<RegistrySecret>, |
50 | | pub options: Vec<RegistryOption>, |
51 | | pub compatibility: Compatibility, |
52 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
53 | | pub installed_version: Option<String>, |
54 | | /// Whether `version` is newer than `installed_version`, decided by the |
55 | | /// daemon. |
56 | | /// |
57 | | /// The comparison is semver, and it belongs here rather than in each client |
58 | | /// for the same reason `installed_version` does: the daemon is what reads |
59 | | /// the installed manifest and owns the index, so it is the one place that |
60 | | /// can answer without a client re-deriving it. A client that wants to |
61 | | /// present the versions still has both. |
62 | | /// |
63 | | /// `false` when nothing is installed, when the installed version is at or |
64 | | /// ahead of the index's, or when either version does not parse. |
65 | | #[serde(default)] |
66 | | pub update_available: bool, |
67 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
68 | | pub index_stale: Option<IndexStale>, |
69 | | } |
70 | | |
71 | | // The `/registry/backend/list` model/secret/option leaves are field-identical to |
72 | | // the `index.json` leaves, so they share one canonical definition rather than |
73 | | // drifting. Re-exported under the historical `Registry*` names. |
74 | | pub use super_stt_registry_types::index::{ |
75 | | IndexModel as RegistryModel, IndexOption as RegistryOption, IndexSecret as RegistrySecret, |
76 | | }; |
77 | | |
78 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
79 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
80 | | pub struct Compatibility { |
81 | | pub compatible: bool, |
82 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
83 | | pub selected_asset: Option<SelectedAsset>, |
84 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
85 | | pub reason: Option<String>, |
86 | | /// Whether the block is "this Super STT is too old" rather than "this |
87 | | /// machine cannot run it". |
88 | | /// |
89 | | /// The two are hidden differently. A host that lacks the right GPU will |
90 | | /// never run the asset, so Browse tucks it behind "Show incompatible"; a |
91 | | /// Super STT one version behind is a thing the user can fix in a minute, |
92 | | /// and hiding it hides the only notice they would get. `false` on an older |
93 | | /// daemon that does not send the field, which lists as it always did. |
94 | | #[serde(default)] |
95 | | pub needs_client_update: bool, |
96 | | } |
97 | | |
98 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
99 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
100 | | pub struct SelectedAsset { |
101 | | pub target: String, |
102 | | /// Acceleration backends the selected build carries. A single entry is |
103 | | /// both read and written as a bare string, a list of two or more as an |
104 | | /// array — a client that declares this field as a plain `String` still |
105 | | /// parses the catalog for every asset that carries one runtime. |
106 | | #[serde( |
107 | | deserialize_with = "super_stt_registry_types::index::one_or_many_string", |
108 | | serialize_with = "super_stt_registry_types::index::one_or_many_string_ser" |
109 | | )] |
110 | | pub accel: Vec<String>, |
111 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
112 | | pub cuda_major: Option<u32>, |
113 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
114 | | pub cuda_sm: Option<u32>, |
115 | | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
116 | | pub cudnn: bool, |
117 | | } |
118 | | |
119 | | pub use super_stt_registry_types::index::IndexStale; |
120 | | |
121 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
122 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
123 | | #[serde(untagged)] |
124 | | pub enum InstallRequest { |
125 | | BySource { source: String }, |
126 | | ByRepoUrl { repo_url: String }, |
127 | | ByLocalPath { local_path: String }, |
128 | | } |
129 | | |
130 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
131 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
132 | | pub struct InstallAccepted { |
133 | | pub install_id: String, |
134 | | pub source: String, |
135 | | pub version: String, |
136 | | pub selected_asset: SelectedAsset, |
137 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
138 | | pub warning: Option<String>, |
139 | | } |
140 | | |
141 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
142 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
143 | | pub struct UpdateRequest { |
144 | | pub source: String, |
145 | | } |
146 | | |
147 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
148 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
149 | | pub struct UpdateResponse { |
150 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
151 | | pub install_id: Option<String>, |
152 | | pub from_version: String, |
153 | | pub to_version: String, |
154 | | pub noop: bool, |
155 | | } |
156 | | |
157 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
158 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
159 | | pub struct RefreshResponse { |
160 | | pub schema_version: u32, |
161 | | pub generated_at: String, |
162 | | pub backend_count: usize, |
163 | | } |
164 | | |
165 | | #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] |
166 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
167 | | pub struct UninstallResponse { |
168 | | pub uninstalled: bool, |
169 | | /// The backend was filling stage 1, which was emptied before the files |
170 | | /// went. |
171 | | pub was_active: bool, |
172 | | /// The backend was filling stage 2 — selected as the post-processor |
173 | | /// backend, loaded or not — which was emptied before the files went. |
174 | | /// Absent from an older daemon's answer, which reads as `false`. |
175 | | #[serde(default)] |
176 | | pub was_post_processor: bool, |
177 | | } |
178 | | |
179 | | pub use super_stt_registry_types::{is_safe_component, is_safe_relative_path}; |
180 | | |
181 | | #[cfg(test)] |
182 | | mod tests { |
183 | | use super::RegistryBackend; |
184 | | |
185 | | /// The minimal `/registry/backend/list` entry every test below starts from, |
186 | | /// extending it with `serde_json::Value` indexing for the field each test |
187 | | /// cares about. One fixture rather than three keeps them from drifting |
188 | | /// apart on the fields that are merely required to parse at all. |
189 | 8 | fn minimal_backend_json() -> serde_json::Value { |
190 | 8 | serde_json::json!({ |
191 | 8 | "id": "openai", |
192 | 8 | "source": "github.com/super-stt/openai", |
193 | 8 | "version": "0.1.1", |
194 | 8 | "name": "OpenAI", |
195 | 8 | "license": "Apache-2.0", |
196 | 8 | "kind": "wasm", |
197 | 8 | "contract": "v1", |
198 | 8 | "online": true, |
199 | 8 | "supports_gpu": false, |
200 | 8 | "supports_cpu": false, |
201 | 8 | "models": [], |
202 | 8 | "secrets": [], |
203 | 8 | "options": [], |
204 | 8 | "compatibility": { "compatible": true }, |
205 | | }) |
206 | 8 | } |
207 | | |
208 | | /// A daemon that predates `update_available` still deserializes here. The |
209 | | /// field moved the update decision from the client to the daemon; a client |
210 | | /// that hard-required it would fail to list anything at all against a |
211 | | /// daemon that has not rolled over, which is worse than not knowing about |
212 | | /// an update. |
213 | | #[test] |
214 | 2 | fn a_registry_entry_without_update_available_still_parses() { |
215 | 2 | let mut v = minimal_backend_json(); |
216 | 2 | v["installed_version"] = serde_json::json!("0.1.0"); |
217 | 2 | let b: RegistryBackend = serde_json::from_value(v).expect("older payload must parse"); |
218 | 2 | assert!( |
219 | 2 | !b.update_available, |
220 | | "an absent flag reads as no update, never as one" |
221 | | ); |
222 | 2 | } |
223 | | |
224 | | /// Unknown keys are ignored, so a newer daemon adding a field does not |
225 | | /// break a client built against this shape. The compatibility runs both |
226 | | /// ways or it is not compatibility. |
227 | | #[test] |
228 | 2 | fn a_registry_entry_with_an_unknown_field_still_parses() { |
229 | 2 | let mut v = minimal_backend_json(); |
230 | 2 | v["update_available"] = serde_json::json!(true); |
231 | 2 | v["a_field_from_a_later_daemon"] = serde_json::json!(42); |
232 | 2 | let b: RegistryBackend = serde_json::from_value(v).expect("newer payload must parse"); |
233 | 2 | assert!(b.update_available); |
234 | 2 | } |
235 | | |
236 | | /// A daemon that predates `backend_id` still deserializes here, and a |
237 | | /// response carrying one round-trips. |
238 | | #[test] |
239 | 2 | fn backend_id_is_optional_on_the_wire() { |
240 | 2 | let without: RegistryBackend = |
241 | 2 | serde_json::from_value(minimal_backend_json()).expect("parses without backend_id"); |
242 | 2 | assert!(without.backend_id.is_none()); |
243 | | |
244 | 2 | let mut v = minimal_backend_json(); |
245 | 2 | v["backend_id"] = serde_json::json!("app.super-stt.voxtral"); |
246 | 2 | let with: RegistryBackend = serde_json::from_value(v).expect("parses with backend_id"); |
247 | 2 | assert_eq!(with.backend_id.as_deref(), Some("app.super-stt.voxtral")); |
248 | 2 | } |
249 | | |
250 | 4 | fn selected(accel: &[&str]) -> super::SelectedAsset { |
251 | | super::SelectedAsset { |
252 | 4 | target: "x86_64-unknown-linux-gnu".into(), |
253 | 6 | accel: accel4 .iter4 ().map4 (|a| (*a).to_string()).collect4 (), |
254 | 4 | cuda_major: Some(12), |
255 | 4 | cuda_sm: Some(86), |
256 | | cudnn: false, |
257 | | } |
258 | 4 | } |
259 | | |
260 | | /// An app built before the list form declares `accel` as a required |
261 | | /// `String` and fails to parse the *whole* `/registry/backend/list` response |
262 | | /// when it turns into an array. Every asset carrying one runtime — which |
263 | | /// is every asset a backend can publish — therefore keeps the bare-string |
264 | | /// shape on the wire. |
265 | | #[test] |
266 | 2 | fn a_single_accel_selection_still_serializes_as_a_bare_string() { |
267 | 2 | let json = serde_json::to_string(&selected(&["cuda"])).expect("serializes"); |
268 | 2 | assert!( |
269 | 2 | json.contains(r#""accel":"cuda""#), |
270 | | "accel is no longer a bare string; an older app cannot parse the catalog: {json}" |
271 | | ); |
272 | | |
273 | | #[derive(serde::Deserialize)] |
274 | | struct DeployedSelectedAsset { |
275 | | accel: String, |
276 | | } |
277 | 2 | let deployed: DeployedSelectedAsset = |
278 | 2 | serde_json::from_str(&json).expect("an older app must still parse this"); |
279 | 2 | assert_eq!(deployed.accel, "cuda"); |
280 | 2 | } |
281 | | |
282 | | /// A build carrying two runtimes has no bare-string spelling, so it is |
283 | | /// written as the array it is, and read back unchanged. |
284 | | #[test] |
285 | 2 | fn a_multi_accel_selection_serializes_as_an_array_and_round_trips() { |
286 | 2 | let json = serde_json::to_string(&selected(&["cuda", "rocm"])).expect("serializes"); |
287 | 2 | assert!( |
288 | 2 | json.contains(r#""accel":["cuda","rocm"]"#), |
289 | | "a multi-runtime build must keep its list: {json}" |
290 | | ); |
291 | 2 | let back: super::SelectedAsset = serde_json::from_str(&json).expect("round-trips"); |
292 | 2 | assert_eq!(back.accel, vec!["cuda".to_string(), "rocm".to_string()]); |
293 | 2 | } |
294 | | |
295 | | /// Read leniency is unchanged: a payload written either way parses. |
296 | | #[test] |
297 | 2 | fn a_selection_parses_from_either_shape() { |
298 | 2 | let scalar: super::SelectedAsset = |
299 | 2 | serde_json::from_str(r#"{"target":"t","accel":"cuda"}"#).expect("scalar parses"); |
300 | 2 | assert_eq!(scalar.accel, vec!["cuda".to_string()]); |
301 | 2 | let list: super::SelectedAsset = |
302 | 2 | serde_json::from_str(r#"{"target":"t","accel":["cuda"]}"#).expect("list parses"); |
303 | 2 | assert_eq!(list.accel, vec!["cuda".to_string()]); |
304 | 2 | } |
305 | | } |