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";
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23pub struct EmptyRequest {}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct ModelDownloadRequest {
28 pub model_id: String,
29 pub idempotency_key: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct ModelRequest {
35 pub model_id: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct ModelImportStartRequest {
41 pub display_name: String,
42 pub file_name: String,
43 pub artifact_sha256: String,
44 pub size_bytes: u64,
45 pub idempotency_key: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase", deny_unknown_fields)]
50pub struct ModelImportOperationRequest {
51 pub operation_id: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "camelCase")]
56pub enum ModelImportState {
57 Uploading,
58 Importing,
59 Verifying,
60 Available,
61 Cancelling,
62 Cancelled,
63 Error,
64}
65
66impl ModelImportState {
67 pub fn is_terminal(&self) -> bool {
68 matches!(self, Self::Available | Self::Cancelled | Self::Error)
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ModelImportError {
75 pub code: String,
76 pub detail: String,
77 pub retryable: bool,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct ModelImportSnapshot {
83 pub operation_id: String,
84 pub model_id: String,
85 pub display_name: String,
86 pub artifact_sha256: String,
87 pub size_bytes: u64,
88 pub received_bytes: u64,
89 pub state: ModelImportState,
90 pub status: String,
91 pub started_at_epoch_ms: i64,
92 pub updated_at_epoch_ms: i64,
93 pub cancellable: bool,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub error: Option<ModelImportError>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct ModelImportUploadGrant {
101 pub bearer_token: String,
102 pub chunk_size_bytes: u64,
103 pub expires_at_epoch_ms: i64,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct ModelImportStartResponse {
109 pub ok: bool,
110 pub started: bool,
111 pub operation: ModelImportSnapshot,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub upload: Option<ModelImportUploadGrant>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub reason: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub detail: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct ModelImportStatusResponse {
123 pub ok: bool,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub operation: Option<ModelImportSnapshot>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub reason: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub detail: Option<String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct ModelImportChunkResponse {
135 pub ok: bool,
136 pub operation_id: String,
137 pub received_bytes: u64,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub reason: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub detail: Option<String>,
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn import_start_request_is_strict_and_uses_public_names() {
150 let value = serde_json::to_value(ModelImportStartRequest {
151 display_name: "Local model".to_string(),
152 file_name: "model.gguf".to_string(),
153 artifact_sha256: "a".repeat(64),
154 size_bytes: 1024,
155 idempotency_key: "import-1".to_string(),
156 })
157 .expect("serialize import request");
158 assert_eq!(value["artifactSha256"], "a".repeat(64));
159 assert!(value.get("sourcePath").is_none());
160 assert!(value.get("scopeId").is_none());
161
162 let mut invalid = value;
163 invalid["sourcePath"] = serde_json::json!("/private/model.gguf");
164 assert!(serde_json::from_value::<ModelImportStartRequest>(invalid).is_err());
165 }
166
167 #[test]
168 fn import_terminal_states_are_explicit() {
169 assert!(ModelImportState::Available.is_terminal());
170 assert!(ModelImportState::Cancelled.is_terminal());
171 assert!(ModelImportState::Error.is_terminal());
172 assert!(!ModelImportState::Uploading.is_terminal());
173 }
174}