1use 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
17pub 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
106pub 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}