Skip to main content

model_runtime/
surface.rs

1//! Library-owned runtime surface for `model-runtime`.
2
3use runtime_core::{
4    describe_surface_response, parse_surface_input, set_surface_operation_curation,
5    structured_operation_response, surface_operation, surface_operation_with_execution_plan,
6    OperationId, PackageSurface, RuntimeCapabilities, SurfaceError, SurfaceExecutionMode,
7    SurfaceExecutionPlan, SurfaceOperation, SurfaceOperationCuration, SurfaceRequest,
8    SurfaceResponse, SurfaceSideEffect,
9};
10use serde::Deserialize;
11
12use crate::{
13    plan_model_access, plan_model_bundle, ModelAccessJobRequest, ModelBundlePlan, ModelFileRequest,
14    ModelPreset, ModelSource, ModelSpec,
15};
16
17/// Returns the package surface exposed by every transport wrapper.
18pub fn package_surface() -> PackageSurface {
19    PackageSurface {
20        library: env!("CARGO_PKG_NAME").to_string(),
21        version: env!("CARGO_PKG_VERSION").to_string(),
22        capabilities: RuntimeCapabilities::pure_rust(),
23        operations: vec![
24            curated(
25                surface_operation(
26                "describe",
27                "Describe package",
28                "Generic model specs, bundle plans, presets, and job helpers for multimodal runtimes.",
29                serde_json::json!({"includeOperations": true}),
30            ),
31                SurfaceOperationCuration::debug(900),
32            ),
33            curated(
34                surface_operation_with_execution_plan(
35                "model.executionPlan",
36                "Model execution plan",
37                "Validates a model access job request and returns a pure execution plan without spawning jobs or running inference.",
38                serde_json::json!({"id": "model-job-1", "kind": "Inference", "spec": {"name": "demo-model", "task": "text_embedding", "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"}, "files": [{"required": "config.json"}]}, "backend": "heuristic", "inputs": [{"kind": "json", "value": {"text": "hello"}}], "outputArtifactPrefix": "prediction"}),
39                surface_plan("model.executionPlan"),
40            ),
41                SurfaceOperationCuration::workflow(10).primary(),
42            ),
43            curated(
44                surface_operation_with_execution_plan(
45                "model.bundlePlan",
46                "Model bundle plan",
47                "Plans manifest layout and artifact references for a model spec and optional local file list without network or materialization.",
48                serde_json::json!({"spec": {"name": "demo-model", "task": "text_embedding", "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"}, "files": [{"required": "config.json"}]}, "localFiles": ["config.json"]}),
49                surface_plan("model.bundlePlan"),
50            ),
51                SurfaceOperationCuration::workflow(20),
52            ),
53            curated(
54                surface_operation_with_execution_plan(
55                "model.jobManifest",
56                "Model job manifest",
57                "Projects a planned model access job into a deterministic JobManifest without starting a job.",
58                serde_json::json!({"id": "model-job-1", "kind": "Inference", "spec": {"name": "demo-model", "task": "text_embedding", "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"}, "files": [{"required": "config.json"}]}, "backend": "heuristic", "outputArtifactPrefix": "prediction"}),
59                surface_plan("model.jobManifest"),
60            ),
61                SurfaceOperationCuration::workflow(30),
62            ),
63            curated(
64                surface_operation(
65                "model.presets",
66                "Model presets",
67                "Lists model preset ids and derived model spec summaries without downloading models.",
68                serde_json::json!({}),
69            ),
70                SurfaceOperationCuration::debug(910),
71            ),
72            curated(
73                surface_operation(
74                "model.spec",
75                "Model spec",
76                "Validates a ModelSpec-shaped input and returns safe name, task, source, files, and revision.",
77                serde_json::json!({"name": "demo-model", "task": "text_embedding", "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"}, "files": [{"required": "config.json"}]}),
78            ),
79                SurfaceOperationCuration::debug(920),
80            ),
81        ],
82    }
83}
84
85fn curated(
86    mut operation: SurfaceOperation,
87    curation: SurfaceOperationCuration,
88) -> SurfaceOperation {
89    set_surface_operation_curation(&mut operation, curation);
90    operation
91}
92
93fn surface_plan(operation: &str) -> SurfaceExecutionPlan {
94    SurfaceExecutionPlan {
95        operation: OperationId::new(operation),
96        mode: SurfaceExecutionMode::PlannedJob,
97        side_effects: vec![SurfaceSideEffect::None],
98        cancellable: false,
99        progress_unit: Some("steps".to_string()),
100        expected_artifacts: Vec::new(),
101        requirements: Vec::new(),
102        max_recommended_input_bytes: Some(1_048_576),
103    }
104}
105
106/// Runs one library-owned operation.
107pub fn run_surface_operation(request: SurfaceRequest) -> Result<SurfaceResponse, String> {
108    let surface = package_surface();
109    let operation = request.operation.clone();
110    let value = match request.operation.as_str() {
111        "describe" => return Ok(describe_surface_response(&surface, request)),
112        "model.executionPlan" => execution_plan_value(
113            operation.as_str(),
114            parse_surface_input(Some(operation.as_str()), request.input)?,
115        )?,
116        "model.bundlePlan" => bundle_plan_value(
117            operation.as_str(),
118            parse_surface_input(Some(operation.as_str()), request.input)?,
119        )?,
120        "model.jobManifest" => job_manifest_value(
121            operation.as_str(),
122            parse_surface_input(Some(operation.as_str()), request.input)?,
123        )?,
124        "model.presets" => presets_value(),
125        "model.spec" => spec_value(
126            operation.as_str(),
127            parse_surface_input(Some(operation.as_str()), request.input)?,
128        )?,
129        operation => {
130            return Err(
131                SurfaceError::unsupported_operation(operation, env!("CARGO_PKG_NAME"))
132                    .to_error_string(),
133            );
134        }
135    };
136    Ok(structured_operation_response(&surface, operation, value))
137}
138
139#[derive(Debug, Deserialize)]
140#[serde(rename_all = "camelCase")]
141struct BundlePlanRequest {
142    spec: ModelSpec,
143    #[serde(default)]
144    local_files: Vec<String>,
145}
146
147fn execution_plan_value(
148    operation: &str,
149    request: ModelAccessJobRequest,
150) -> Result<serde_json::Value, String> {
151    let plan = plan_model_access(&request)
152        .map_err(|error| invalid_request(operation, error.to_string()))?;
153    Ok(serde_json::json!({
154        "plan": plan,
155        "jobSpec": plan.job_spec,
156        "executionPlan": plan.execution_plan,
157        "expectedArtifacts": plan.expected_artifacts,
158        "kind": plan.kind.as_str(),
159        "backend": plan.backend.as_str(),
160        "sideEffects": plan.execution_plan.side_effects
161    }))
162}
163
164fn bundle_plan_value(
165    operation: &str,
166    request: BundlePlanRequest,
167) -> Result<serde_json::Value, String> {
168    let plan = plan_model_bundle(&request.spec, &request.local_files)
169        .map_err(|error| invalid_request(operation, error.to_string()))?;
170    Ok(bundle_plan_json(plan))
171}
172
173fn job_manifest_value(
174    operation: &str,
175    request: ModelAccessJobRequest,
176) -> Result<serde_json::Value, String> {
177    let plan = plan_model_access(&request)
178        .map_err(|error| invalid_request(operation, error.to_string()))?;
179    let timestamp = chrono_like_epoch();
180    let snapshot = jobs_core::JobSnapshot {
181        metadata: plan.job_spec.metadata.clone(),
182        spec: plan.job_spec.clone(),
183        status: jobs_core::JobStatus::Queued,
184        progress: None,
185        logs: Vec::new(),
186        artifacts: plan
187            .expected_artifacts
188            .iter()
189            .map(jobs_core::JobArtifact::from_artifact_ref)
190            .collect(),
191        created_at: timestamp,
192        started_at: None,
193        finished_at: None,
194        failure: None,
195    };
196    let manifest = jobs_core::JobManifest::from_snapshot(
197        OperationId::new("model.executionPlan"),
198        snapshot,
199        plan.expected_artifacts.clone(),
200        serde_json::json!({
201            "kind": plan.kind.as_str(),
202            "backend": plan.backend.as_str(),
203            "model": plan.job_spec.metadata.get("model.name")
204        }),
205    );
206    Ok(serde_json::json!({
207        "manifest": manifest,
208        "artifactCount": manifest.artifacts.len(),
209        "jobKind": plan.kind.as_str(),
210        "status": "queued"
211    }))
212}
213
214fn presets_value() -> serde_json::Value {
215    serde_json::json!({
216        "presets": ModelPreset::ALL.iter().map(|preset| {
217            let spec = preset.spec();
218            serde_json::json!({
219                "id": preset.as_str(),
220                "name": spec.name,
221                "safeName": spec.safe_name(),
222                "task": spec.task.as_protocol_str(),
223                "repoId": spec.repo_id_value(),
224                "source": source_json(&spec.source),
225                "revision": spec.revision_value(),
226                "requestedFiles": file_requests_json(&spec.files),
227                "metadata": spec.metadata
228            })
229        }).collect::<Vec<_>>()
230    })
231}
232
233fn spec_value(operation: &str, spec: ModelSpec) -> Result<serde_json::Value, String> {
234    validate_spec(operation, &spec)?;
235    Ok(spec_summary_json(&spec))
236}
237
238fn bundle_plan_json(plan: ModelBundlePlan) -> serde_json::Value {
239    let files = plan.files.clone();
240    serde_json::json!({
241        "spec": spec_summary_json(&plan.spec),
242        "manifestPath": plan.manifest_path,
243        "filesDirectory": plan.files_directory,
244        "files": files,
245        "manifest": {
246            "schemaVersion": 1,
247            "name": plan.spec.name,
248            "repoId": plan.spec.repo_id_value(),
249            "revision": plan.spec.revision_value(),
250            "task": plan.spec.task.as_protocol_str(),
251            "files": plan.files
252        },
253        "artifactRefs": plan.artifact_refs,
254        "downloadsRequired": plan.downloads_required
255    })
256}
257
258fn spec_summary_json(spec: &ModelSpec) -> serde_json::Value {
259    serde_json::json!({
260        "name": spec.name,
261        "safeName": spec.safe_name(),
262        "task": spec.task.as_protocol_str(),
263        "source": source_json(&spec.source),
264        "requestedFiles": file_requests_json(&spec.files),
265        "revision": spec.revision_value(),
266        "repoId": spec.repo_id_value(),
267        "metadata": spec.metadata
268    })
269}
270
271fn validate_spec(operation: &str, spec: &ModelSpec) -> Result<(), String> {
272    if spec.name.trim().is_empty() {
273        return Err(invalid_request(operation, "model name must not be empty"));
274    }
275    Ok(())
276}
277
278fn source_json(source: &ModelSource) -> serde_json::Value {
279    match source {
280        ModelSource::HuggingFace { repo_id, revision } => serde_json::json!({
281            "kind": source.kind(),
282            "repoId": repo_id,
283            "revision": revision
284        }),
285        ModelSource::LocalPath { path } => serde_json::json!({
286            "kind": source.kind(),
287            "path": path
288        }),
289        ModelSource::ExternalCommand { command } => serde_json::json!({
290            "kind": source.kind(),
291            "command": command
292        }),
293        ModelSource::ComfyUiInventory { root } => serde_json::json!({
294            "kind": source.kind(),
295            "root": root
296        }),
297        ModelSource::Custom(kind) => serde_json::json!({
298            "kind": kind
299        }),
300    }
301}
302
303fn file_requests_json(files: &[ModelFileRequest]) -> Vec<serde_json::Value> {
304    files
305        .iter()
306        .map(|request| match request {
307            ModelFileRequest::Required(path) => {
308                serde_json::json!({"kind": "required", "path": path})
309            }
310            ModelFileRequest::Optional(path) => {
311                serde_json::json!({"kind": "optional", "path": path})
312            }
313            ModelFileRequest::FirstAvailable(paths) => {
314                serde_json::json!({"kind": "firstAvailable", "paths": paths})
315            }
316        })
317        .collect()
318}
319
320fn invalid_request(operation: &str, message: impl Into<String>) -> String {
321    SurfaceError::invalid_request(Some(OperationId::new(operation)), message).to_error_string()
322}
323
324fn chrono_like_epoch() -> chrono::DateTime<chrono::Utc> {
325    chrono::DateTime::parse_from_rfc3339("1970-01-01T00:00:00Z")
326        .expect("valid timestamp")
327        .with_timezone(&chrono::Utc)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn package_surface_lists_model_operations() {
336        let ids = package_surface()
337            .operations
338            .into_iter()
339            .map(|operation| operation.id.0)
340            .collect::<Vec<_>>();
341        assert!(ids.contains(&"model.executionPlan".to_string()));
342        assert!(ids.contains(&"model.bundlePlan".to_string()));
343        assert!(ids.contains(&"model.jobManifest".to_string()));
344        assert!(ids.contains(&"model.presets".to_string()));
345        assert!(ids.contains(&"model.spec".to_string()));
346    }
347
348    #[test]
349    fn execution_plan_operation_returns_structured_plan() {
350        let response = run_surface_operation(SurfaceRequest {
351            operation: OperationId::new("model.executionPlan"),
352            input: serde_json::json!({
353                "id": "model-job-1",
354                "kind": "Inference",
355                "spec": {
356                    "name": "demo/model",
357                    "task": "text_embedding",
358                    "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"},
359                    "files": [{"required": "config.json"}]
360                },
361                "backend": "heuristic",
362                "inputs": [{"kind": "json", "value": {"text": "hello"}}],
363                "outputArtifactPrefix": "prediction"
364            }),
365        })
366        .expect("execution plan");
367
368        assert_eq!(response.value["operation"], "model.executionPlan");
369        assert_eq!(response.value["jobSpec"]["id"], "model-job-1");
370        assert_eq!(response.value["executionPlan"]["mode"], "plannedJob");
371        assert_eq!(response.value["kind"], "model-inference");
372    }
373
374    #[test]
375    fn bundle_plan_selects_local_first_available_file() {
376        let response = run_surface_operation(SurfaceRequest {
377            operation: OperationId::new("model.bundlePlan"),
378            input: serde_json::json!({
379                "spec": {
380                    "name": "demo",
381                    "task": "text_embedding",
382                    "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"},
383                    "files": [{"first_available": ["model.safetensors", "pytorch_model.bin"]}]
384                },
385                "localFiles": ["pytorch_model.bin"]
386            }),
387        })
388        .expect("bundle plan");
389        assert_eq!(
390            response.value["manifest"]["files"][0]["remotePath"],
391            "pytorch_model.bin"
392        );
393        assert_eq!(response.value["downloadsRequired"], false);
394    }
395
396    #[test]
397    fn job_manifest_operation_returns_manifest_projection() {
398        let response = run_surface_operation(SurfaceRequest {
399            operation: OperationId::new("model.jobManifest"),
400            input: serde_json::json!({
401                "id": "model-job-1",
402                "kind": "Inference",
403                "spec": {
404                    "name": "demo/model",
405                    "task": "text_embedding",
406                    "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"},
407                    "files": [{"required": "config.json"}]
408                },
409                "backend": "heuristic",
410                "outputArtifactPrefix": "prediction"
411            }),
412        })
413        .expect("job manifest");
414        assert_eq!(response.value["status"], "queued");
415        assert_eq!(response.value["manifest"]["jobId"], "model-job-1");
416    }
417
418    #[test]
419    fn spec_operation_rejects_empty_name_with_typed_error() {
420        let error = run_surface_operation(SurfaceRequest {
421            operation: OperationId::new("model.spec"),
422            input: serde_json::json!({
423                "name": "",
424                "task": "text_embedding",
425                "source": {"kind": "hugging_face", "repo_id": "demo/model", "revision": "main"},
426                "files": []
427            }),
428        })
429        .expect_err("empty name");
430        let parsed = runtime_core::parse_surface_error(&error).expect("typed error");
431        assert_eq!(parsed.code, "invalid_request");
432    }
433}