1use serde::{Deserialize, Serialize};
2
3pub const VERSION: &str = crate::node::V1;
4pub const NODE_TYPE: &str = "llm";
5pub const RUNTIME_TARGET_PREFIX: &str = "/llm/app/";
6pub const RUNTIME_STATUS: &str = "runtime/status";
7pub const RUNTIME_RETRY: &str = "runtime/retry";
8pub const MODEL_LIST: &str = "model/list";
9pub const MODEL_DOWNLOAD: &str = "model/download";
10pub const MODEL_DOWNLOAD_STATUS: &str = "model/download/status";
11pub const MODEL_DOWNLOAD_CANCEL: &str = "model/download/cancel";
12pub const MODEL_IMPORT_START: &str = "model/import/start";
13pub const MODEL_IMPORT_STATUS: &str = "model/import/status";
14pub const MODEL_IMPORT_FINALIZE: &str = "model/import/finalize";
15pub const MODEL_IMPORT_CANCEL: &str = "model/import/cancel";
16pub const MODEL_IMPORT_UPLOAD_PATH_PREFIX: &str = "/llm/model/import/upload";
17pub const MODEL_IMPORT_OFFSET_HEADER: &str = "x-upload-offset";
18pub const MODEL_IMPORT_CHUNK_SHA256_HEADER: &str = "x-chunk-sha256";
19pub const MODEL_DELETE: &str = "model/delete";
20pub const TTS_SYNTHESIZE_TARGET: &str = "/llm/tts";
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct EmptyRequest {}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct ModelDownloadRequest {
29 pub model_id: String,
30 pub idempotency_key: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct ModelRequest {
36 pub model_id: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct ModelImportStartRequest {
42 pub display_name: String,
43 pub file_name: String,
44 pub artifact_sha256: String,
45 pub size_bytes: u64,
46 pub idempotency_key: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51pub struct ModelImportOperationRequest {
52 pub operation_id: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "camelCase")]
57pub enum ModelImportState {
58 Uploading,
59 Importing,
60 Verifying,
61 Available,
62 Cancelling,
63 Cancelled,
64 Error,
65}
66
67impl ModelImportState {
68 pub fn is_terminal(&self) -> bool {
69 matches!(self, Self::Available | Self::Cancelled | Self::Error)
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ModelImportError {
76 pub code: String,
77 pub detail: String,
78 pub retryable: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ModelImportSnapshot {
84 pub operation_id: String,
85 pub model_id: String,
86 pub display_name: String,
87 pub artifact_sha256: String,
88 pub size_bytes: u64,
89 pub received_bytes: u64,
90 pub state: ModelImportState,
91 pub status: String,
92 pub started_at_epoch_ms: i64,
93 pub updated_at_epoch_ms: i64,
94 pub cancellable: bool,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub error: Option<ModelImportError>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct ModelImportUploadGrant {
102 pub bearer_token: String,
103 pub chunk_size_bytes: u64,
104 pub expires_at_epoch_ms: i64,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ModelImportStartResponse {
110 pub ok: bool,
111 pub started: bool,
112 pub operation: ModelImportSnapshot,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub upload: Option<ModelImportUploadGrant>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub reason: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub detail: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct ModelImportStatusResponse {
124 pub ok: bool,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub operation: Option<ModelImportSnapshot>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub reason: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub detail: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct ModelImportChunkResponse {
136 pub ok: bool,
137 pub operation_id: String,
138 pub received_bytes: u64,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub reason: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub detail: Option<String>,
143}
144
145#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "camelCase")]
153pub struct TtsSynthesizeRequest {
154 pub text: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub model: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub instructions: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub voice: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub format: Option<String>,
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "camelCase")]
172pub struct TtsSynthesizeResponse {
173 pub ok: bool,
174 pub model: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub audio_base64: Option<String>,
177 pub mime_type: String,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub voice: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub format: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub reason: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub detail: Option<String>,
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn import_start_request_is_strict_and_uses_public_names() {
194 let value = serde_json::to_value(ModelImportStartRequest {
195 display_name: "Local model".to_string(),
196 file_name: "model.gguf".to_string(),
197 artifact_sha256: "a".repeat(64),
198 size_bytes: 1024,
199 idempotency_key: "import-1".to_string(),
200 })
201 .expect("serialize import request");
202 assert_eq!(value["artifactSha256"], "a".repeat(64));
203 assert!(value.get("sourcePath").is_none());
204 assert!(value.get("scopeId").is_none());
205
206 let mut invalid = value;
207 invalid["sourcePath"] = serde_json::json!("/private/model.gguf");
208 assert!(serde_json::from_value::<ModelImportStartRequest>(invalid).is_err());
209 }
210
211 #[test]
212 fn import_terminal_states_are_explicit() {
213 assert!(ModelImportState::Available.is_terminal());
214 assert!(ModelImportState::Cancelled.is_terminal());
215 assert!(ModelImportState::Error.is_terminal());
216 assert!(!ModelImportState::Uploading.is_terminal());
217 }
218
219 #[test]
220 fn tts_contract_keeps_routing_mode_out_and_instructions_optional() {
221 let request = TtsSynthesizeRequest {
222 text: "Hello".to_string(),
223 model: Some("kokoro:v1.0-en".to_string()),
224 instructions: Some("Speak warmly".to_string()),
225 voice: None,
226 format: Some("wav".to_string()),
227 };
228 let value = serde_json::to_value(&request).expect("serialize TTS request");
229 assert_eq!(value["instructions"], "Speak warmly");
230 assert!(value.get("mode").is_none());
231
232 let minimal: TtsSynthesizeRequest =
233 serde_json::from_value(serde_json::json!({"text": "Hello"}))
234 .expect("deserialize minimal TTS request");
235 assert_eq!(minimal.text, "Hello");
236 assert!(minimal.instructions.is_none());
237 }
238}