1use serde::{Deserialize, Serialize};
4
5pub const RUNTIME_API_VERSION: u32 = 1;
7pub const MODEL_PACK_FORMAT_VERSION: u32 = 1;
9pub const RUNTIME_STATE_FORMAT_VERSION: u32 = 1;
11pub const RELEASE_INDEX_SCHEMA_VERSION: u32 = 1;
13pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
15
16pub const RUNTIME_ARTIFACT_ID: &str = "rill-runtime";
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct ModelPackManifest {
21 pub format_version: u32,
22 pub id: String,
23 pub version: String,
24 pub runtime_api_version: u32,
25 pub min_runtime_version: String,
26 pub publisher_key_id: String,
27 pub capabilities: Vec<String>,
28}
29
30impl ModelPackManifest {
31 pub fn validate_shape(&self) -> Result<(), &'static str> {
32 if self.format_version != MODEL_PACK_FORMAT_VERSION {
33 return Err("unsupported model-pack format version");
34 }
35 if self.runtime_api_version != RUNTIME_API_VERSION {
36 return Err("unsupported runtime API version");
37 }
38 if self.id.is_empty() || self.id.len() > 96 {
39 return Err("invalid model-pack id");
40 }
41 if self.version.is_empty() || self.version.len() > 48 {
42 return Err("invalid model-pack version");
43 }
44 if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
45 return Err("invalid publisher key id");
46 }
47 if self.capabilities.is_empty() || self.capabilities.len() > 32 {
48 return Err("invalid model-pack capabilities");
49 }
50 if self
51 .capabilities
52 .iter()
53 .any(|capability| capability.is_empty() || capability.len() > 96)
54 {
55 return Err("invalid model-pack capability");
56 }
57 Ok(())
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
62#[serde(rename_all = "camelCase")]
63pub enum ReleaseArtifactKind {
64 Runtime,
65 Model,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct ReleaseArtifact {
71 pub kind: ReleaseArtifactKind,
72 pub id: String,
73 pub version: String,
74 pub runtime_api_version: u32,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub target_os: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub target_arch: Option<String>,
79 pub url: String,
80 pub sha256: String,
81 pub size: u64,
82}
83
84impl ReleaseArtifact {
85 pub fn validate_shape(&self) -> Result<(), &'static str> {
86 if self.id.is_empty() || self.id.len() > 96 {
87 return Err("invalid artifact id");
88 }
89 if self.version.is_empty() || self.version.len() > 48 {
90 return Err("invalid artifact version");
91 }
92 if self.runtime_api_version != RUNTIME_API_VERSION {
93 return Err("unsupported artifact runtime API version");
94 }
95 if self.url.is_empty() || self.url.len() > 2048 {
96 return Err("invalid artifact URL");
97 }
98 if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
99 return Err("invalid artifact SHA-256");
100 }
101 if self.size == 0 || self.size > 64 * 1024 * 1024 {
102 return Err("invalid artifact size");
103 }
104 match self.kind {
105 ReleaseArtifactKind::Runtime => {
106 if self.id != RUNTIME_ARTIFACT_ID
107 || self.target_os.as_deref().is_none_or(str::is_empty)
108 || self.target_arch.as_deref().is_none_or(str::is_empty)
109 {
110 return Err("runtime artifact requires a target OS and architecture");
111 }
112 }
113 ReleaseArtifactKind::Model => {
114 if self.target_os.is_some() || self.target_arch.is_some() {
115 return Err("model artifact must be platform independent");
116 }
117 }
118 }
119 Ok(())
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub struct ReleaseIndexPayload {
126 pub schema_version: u32,
127 pub channel: String,
128 pub generated_at: String,
129 pub publisher_key_id: String,
130 pub artifacts: Vec<ReleaseArtifact>,
131}
132
133impl ReleaseIndexPayload {
134 pub fn validate_shape(&self) -> Result<(), &'static str> {
135 if self.schema_version != RELEASE_INDEX_SCHEMA_VERSION {
136 return Err("unsupported release-index schema");
137 }
138 if self.channel != "stable" {
139 return Err("unsupported release channel");
140 }
141 if self.generated_at.is_empty() || self.generated_at.len() > 64 {
142 return Err("invalid release-index timestamp");
143 }
144 if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
145 return Err("invalid release-index publisher");
146 }
147 if self.artifacts.is_empty() || self.artifacts.len() > 64 {
148 return Err("invalid release-index artifact count");
149 }
150 for artifact in &self.artifacts {
151 artifact.validate_shape()?;
152 }
153 Ok(())
154 }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "camelCase", deny_unknown_fields)]
159pub struct SignedReleaseIndex {
160 pub payload: ReleaseIndexPayload,
161 pub signature: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166#[serde(
167 tag = "method",
168 rename_all = "camelCase",
169 rename_all_fields = "camelCase",
170 deny_unknown_fields
171)]
172pub enum RuntimeRequest {
173 Handshake {
174 request_id: String,
175 api_version: u32,
176 client_name: String,
177 client_version: String,
178 },
179 Health {
180 request_id: String,
181 api_version: u32,
182 },
183 Invoke {
184 request_id: String,
185 api_version: u32,
186 capability: String,
187 input: serde_json::Value,
188 },
189}
190
191impl RuntimeRequest {
192 pub fn request_id(&self) -> &str {
193 match self {
194 Self::Handshake { request_id, .. }
195 | Self::Health { request_id, .. }
196 | Self::Invoke { request_id, .. } => request_id,
197 }
198 }
199
200 pub fn api_version(&self) -> u32 {
201 match self {
202 Self::Handshake { api_version, .. }
203 | Self::Health { api_version, .. }
204 | Self::Invoke { api_version, .. } => *api_version,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210#[serde(
211 tag = "kind",
212 rename_all = "camelCase",
213 rename_all_fields = "camelCase",
214 deny_unknown_fields
215)]
216pub enum RuntimeResponse {
217 Handshake {
218 request_id: String,
219 api_version: u32,
220 runtime_version: String,
221 model_pack_id: String,
222 model_pack_version: String,
223 capabilities: Vec<String>,
224 },
225 Health {
226 request_id: String,
227 api_version: u32,
228 healthy: bool,
229 model_pack_id: String,
230 model_pack_version: String,
231 },
232 Result {
233 request_id: String,
234 api_version: u32,
235 output: serde_json::Value,
236 },
237 Error {
238 request_id: String,
239 api_version: u32,
240 code: String,
241 message: String,
242 retryable: bool,
243 },
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn protocol_roundtrip_is_tagged_and_strict() {
252 let request = RuntimeRequest::Health {
253 request_id: "health-1".into(),
254 api_version: RUNTIME_API_VERSION,
255 };
256 let json = serde_json::to_string(&request).unwrap();
257 assert!(json.contains("\"method\":\"health\""));
258 let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
259 assert_eq!(restored, request);
260 assert!(
261 serde_json::from_str::<RuntimeRequest>(
262 r#"{"method":"health","requestId":"x","apiVersion":1,"extra":true}"#
263 )
264 .is_err()
265 );
266 }
267
268 #[test]
269 fn handshake_fixture_is_stable() {
270 let request = RuntimeRequest::Handshake {
271 request_id: "fixture".into(),
272 api_version: RUNTIME_API_VERSION,
273 client_name: "example-host".into(),
274 client_version: "0.6.10".into(),
275 };
276 assert_eq!(
277 serde_json::to_string(&request).unwrap(),
278 r#"{"method":"handshake","requestId":"fixture","apiVersion":1,"clientName":"example-host","clientVersion":"0.6.10"}"#
279 );
280 }
281
282 #[test]
283 fn invoke_roundtrip_preserves_capability_and_input() {
284 let request = RuntimeRequest::Invoke {
285 request_id: "invoke-1".into(),
286 api_version: RUNTIME_API_VERSION,
287 capability: "rillml.example".into(),
288 input: serde_json::json!({"samples": []}),
289 };
290 let json = serde_json::to_string(&request).unwrap();
291 assert!(json.contains("\"method\":\"invoke\""));
292 assert!(json.contains("\"capability\":\"rillml.example\""));
293 let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
294 assert_eq!(restored, request);
295 }
296
297 #[test]
298 fn release_artifacts_enforce_platform_boundaries() {
299 let runtime = ReleaseArtifact {
300 kind: ReleaseArtifactKind::Runtime,
301 id: RUNTIME_ARTIFACT_ID.into(),
302 version: "0.5.0".into(),
303 runtime_api_version: RUNTIME_API_VERSION,
304 target_os: Some("macos".into()),
305 target_arch: Some("aarch64".into()),
306 url: "https://example.invalid/rill-runtime".into(),
307 sha256: "ab".repeat(32),
308 size: 1024,
309 };
310 assert!(runtime.validate_shape().is_ok());
311 let mut model = runtime;
312 model.kind = ReleaseArtifactKind::Model;
313 model.id = "rillml.example.default".into();
314 assert!(model.validate_shape().is_err());
315 model.target_os = None;
316 model.target_arch = None;
317 assert!(model.validate_shape().is_ok());
318 }
319}