Skip to main content

core_api/node/contracts/
llm.rs

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/// Provider-neutral speech synthesis request sent directly to an LLM Node.
146///
147/// `instructions`, `voice`, and `format` are optional preferences. A backend
148/// may ignore a preference it cannot implement; `text` is the only semantic
149/// input every TTS backend must support. `mode` deliberately does not cross
150/// this boundary because it is a MeowCore routing concern.
151#[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/// Speech synthesis response returned directly by an LLM Node.
166///
167/// Version 1 transports short audio inline as base64. The effective model,
168/// voice, format, and MIME type are returned so callers need not infer which
169/// optional request preferences a backend applied.
170#[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}