Skip to content

Commit 01a2247

Browse files
committed
feat(serving): wire fake ONNX session adapter
Connect ONNX serve/unserve dispatch to the Rust ONNX session manager without adding real ONNX Runtime dependencies. RPC AppState now owns a bounded fake ONNX session manager, and the new serving_onnx adapter resolves validated .onnx primary artifacts under the model library root before load. Serving ONNX records backend-owned served status after fake session load, and unserve removes the fake session plus served status through the ONNX provider identity. OnnxModelId now accepts slash-delimited Pumas library model ids while rejecting absolute, empty, or traversal-style segments. Real ort integration, tokenizer/postprocess execution, duplicate-load idempotency, status reconciliation before record, and gateway embedding dispatch remain open and documented in the plan. Verification: - cargo fmt --manifest-path rust/Cargo.toml --all -- --check - cargo test --manifest-path rust/crates/pumas-core/Cargo.toml onnx - cargo test --manifest-path rust/crates/pumas-rpc/Cargo.toml serving - git diff --check Agent: codex
1 parent ea37257 commit 01a2247

8 files changed

Lines changed: 243 additions & 26 deletions

File tree

docs/plans/onnx-runtime-embedding-serving/execution-and-coordination.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,18 @@ Update during implementation:
402402
passed: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`,
403403
`cargo test --manifest-path rust/crates/pumas-core/Cargo.toml serving`, and
404404
`cargo test --manifest-path rust/crates/pumas-rpc/Cargo.toml serving`.
405+
- 2026-05-11: Wired the fake ONNX serving adapter through RPC `serve_model` and
406+
`unserve_model`. `AppState` now owns a bounded Rust ONNX session manager,
407+
`serving_onnx.rs` resolves validated `.onnx` primary artifacts under the
408+
model library root, loads/unloads through the fake session manager, and
409+
records/removes backend served status for `onnx_runtime`. `OnnxModelId` now
410+
accepts slash-delimited Pumas library model ids while rejecting empty,
411+
absolute, and traversal-style segments. Real ONNX Runtime execution,
412+
duplicate load idempotency, status reconciliation before record, and gateway
413+
embedding dispatch remain open. Verification passed:
414+
`cargo fmt --manifest-path rust/Cargo.toml --all -- --check`,
415+
`cargo test --manifest-path rust/crates/pumas-core/Cargo.toml onnx`, and
416+
`cargo test --manifest-path rust/crates/pumas-rpc/Cargo.toml serving`.
405417

406418
## Commit Cadence Notes
407419

@@ -625,6 +637,9 @@ changes remain.
625637
- Serving validation is in progress for ONNX: `.onnx` artifacts and running
626638
ONNX profiles validate through provider behavior, while unsupported artifacts
627639
and per-load placement overrides fail before provider execution.
640+
- Fake ONNX serving adapter is in progress: RPC serving can load/unload ONNX
641+
served status through the Rust fake session manager, with real ONNX Runtime
642+
inference and gateway embedding dispatch still pending.
628643

629644
### Deviations
630645

docs/plans/onnx-runtime-embedding-serving/milestones.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ Runtime execution.
366366
**Tasks:**
367367
- [x] Create a focused Rust ONNX provider/session module or crate with README
368368
contract sections before expanding internals.
369-
- [ ] Keep ONNX session manager construction at the Rust composition root.
369+
- [x] Keep ONNX session manager construction at the Rust composition root.
370370
Serving/gateway handlers may receive traits/handles, but must not create
371371
ONNX sessions, tokenizer state, or global managers ad hoc.
372372
- [x] Define validated Rust request/session types for model path, model id,
@@ -396,8 +396,9 @@ Runtime execution.
396396
**Status:** In progress. The first Rust ONNX skeleton slice added
397397
`rust/crates/pumas-core/src/onnx_runtime/` with README coverage, validated
398398
contract types, a fake backend, a bounded `OnnxSessionManager`, and focused
399-
unit tests. Composition-root wiring, gateway OpenAI-compatible errors, and
400-
full shutdown/cancellation ordering remain open for later M1/M3/M5 slices.
399+
unit tests. RPC `AppState` now owns the bounded ONNX session manager for fake
400+
serving. Gateway OpenAI-compatible errors and full shutdown/cancellation
401+
ordering remain open for later M1/M3/M5 slices.
401402

402403
### Milestone 2: ONNX Embedding Execution
403404

@@ -571,14 +572,14 @@ state.
571572
the wrong provider when no ONNX route exists.
572573
- [ ] Return a clear validation error when an ONNX model has no saved route and
573574
no explicit ONNX profile selection.
574-
- [ ] Add ONNX provider adapter calls from `serve_model` to the Rust ONNX
575+
- [x] Add ONNX provider adapter calls from `serve_model` to the Rust ONNX
575576
session manager.
576577
- [ ] Move existing Ollama and llama.cpp serving paths behind provider serving
577578
adapters before adding ONNX load/unload so the RPC handler only performs
578579
boundary parsing, validation orchestration, and response shaping.
579580
- [ ] Confirm the Rust ONNX provider status/list includes the model before
580581
recording loaded status.
581-
- [ ] Add unload support through the Rust ONNX session manager and served
582+
- [x] Add unload support through the Rust ONNX session manager and served
582583
status removal.
583584
- [ ] Make load and unload idempotent where possible: duplicate load returns
584585
the existing loaded state, duplicate unload returns an unchanged snapshot,
@@ -604,9 +605,11 @@ state.
604605
**Status:** In progress. Serving validation accepts ONNX requests only when the
605606
selected ONNX profile is running and the primary executable artifact is `.onnx`.
606607
Provider behavior drives ONNX artifact compatibility, and ONNX rejects
607-
llama.cpp-specific placement overrides with non-critical domain errors. Actual
608-
load/unload calls still return explicit not-yet-wired responses until the
609-
session-manager adapter slice lands.
608+
llama.cpp-specific placement overrides with non-critical domain errors. The RPC
609+
serving boundary now loads/unloads ONNX through the Rust fake session manager
610+
and records/removes backend served status. Real ONNX Runtime execution,
611+
duplicate load/unload idempotency, session status reconciliation before record,
612+
and gateway embedding routing remain open.
610613

611614
### Milestone 5: Pumas Gateway Routing
612615

rust/crates/pumas-core/src/onnx_runtime/mod.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,21 @@ impl OnnxModelId {
4242
}
4343
if !trimmed
4444
.chars()
45-
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
45+
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/'))
4646
{
4747
return Err(OnnxRuntimeError::validation(
4848
"model_id",
49-
"model id may only contain ASCII letters, numbers, '.', '-', or '_'",
49+
"model id may only contain ASCII letters, numbers, '.', '-', '_', or '/'",
50+
));
51+
}
52+
if trimmed.starts_with('/')
53+
|| trimmed
54+
.split('/')
55+
.any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
56+
{
57+
return Err(OnnxRuntimeError::validation(
58+
"model_id",
59+
"model id path segments must be non-empty and may not be '.' or '..'",
5060
));
5161
}
5262
Ok(Self(trimmed.to_string()))
@@ -566,7 +576,7 @@ mod tests {
566576
#[test]
567577
fn embedding_request_validates_model_id_and_shape() {
568578
let err =
569-
OnnxEmbeddingRequest::parse("bad/id", vec!["hello".to_string()], None).unwrap_err();
579+
OnnxEmbeddingRequest::parse("../bad", vec!["hello".to_string()], None).unwrap_err();
570580
assert_eq!(err.field.as_deref(), Some("model_id"));
571581

572582
let err = OnnxEmbeddingRequest::parse("model", Vec::new(), None).unwrap_err();
@@ -577,6 +587,15 @@ mod tests {
577587
assert_eq!(err.field.as_deref(), Some("dimensions"));
578588
}
579589

590+
#[test]
591+
fn model_id_accepts_library_style_segments_without_path_traversal() {
592+
let model_id = OnnxModelId::parse("embedding/nomic/model-v1.5").unwrap();
593+
assert_eq!(model_id.as_str(), "embedding/nomic/model-v1.5");
594+
595+
let err = OnnxModelId::parse("embedding//model").unwrap_err();
596+
assert_eq!(err.field.as_deref(), Some("model_id"));
597+
}
598+
580599
#[test]
581600
fn embedding_request_rejects_oversized_payloads() {
582601
let too_many_inputs = vec!["hello".to_string(); MAX_EMBEDDING_INPUTS + 1];

rust/crates/pumas-rpc/src/handlers/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Domain-specific JSON-RPC handlers that parse request params, call API services,
1919
| `serving_llama_cpp.rs` | llama.cpp serving adapter entry points for dedicated and router profiles. |
2020
| `serving_llama_cpp_router.rs` | llama.cpp router serving lifecycle and router HTTP helpers. |
2121
| `serving_llama_cpp_shared.rs` | Shared llama.cpp serving compatibility and runtime-version helpers. |
22+
| `serving_onnx.rs` | ONNX Runtime serving adapter entry points backed by the Rust ONNX session manager. |
2223
| `openai_gateway.rs` | OpenAI-compatible gateway model listing, routing, proxy, and response helpers. |
2324
| `process.rs` | Legacy singleton process launch/stop and filesystem/window process handlers. |
2425
| `torch.rs` | Torch server status, slot, and configuration handlers. |

rust/crates/pumas-rpc/src/handlers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod serving_llama_cpp;
1414
mod serving_llama_cpp_router;
1515
mod serving_llama_cpp_shared;
1616
mod serving_ollama;
17+
mod serving_onnx;
1718
mod shared;
1819
mod shortcuts;
1920
mod status;

rust/crates/pumas-rpc/src/handlers/serving.rs

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use super::parse_params;
44
use super::serving_llama_cpp::{serve_llama_cpp_model, unserve_llama_cpp_model};
55
use super::serving_ollama::{serve_ollama_model, unserve_ollama_model};
6+
use super::serving_onnx::{serve_onnx_model, unserve_onnx_model};
67
use crate::server::AppState;
78
use pumas_library::models::{
89
ModelServeError, ModelServeErrorCode, ServeModelRequest, ServeModelResponse,
@@ -93,14 +94,7 @@ pub async fn serve_model(state: &AppState, params: &Value) -> pumas_library::Res
9394
Some(ProviderServingAdapterKind::LlamaCppRuntime) => {
9495
serve_llama_cpp_model(state, request).await
9596
}
96-
Some(ProviderServingAdapterKind::OnnxRuntime) => {
97-
let error = serving_error(
98-
ModelServeErrorCode::UnsupportedProvider,
99-
"ONNX Runtime serving is not wired in this slice",
100-
&request,
101-
);
102-
non_critical_failure_response(state, error).await
103-
}
97+
Some(ProviderServingAdapterKind::OnnxRuntime) => serve_onnx_model(state, request).await,
10498
None => {
10599
let error = serving_error(
106100
ModelServeErrorCode::UnsupportedProvider,
@@ -154,12 +148,7 @@ pub async fn unserve_model(state: &AppState, params: &Value) -> pumas_library::R
154148
unserve_llama_cpp_model(state, command.request, profile_id, model_alias).await
155149
}
156150
Some(ProviderUnloadBehavior::SessionManager) => {
157-
Ok(serde_json::to_value(UnserveModelResponse {
158-
success: true,
159-
error: Some("ONNX Runtime unload is not wired in this slice".to_string()),
160-
unloaded: false,
161-
snapshot: Some(state.api.get_serving_status().await?.snapshot),
162-
})?)
151+
unserve_onnx_model(state, command.request, profile_id, model_alias).await
163152
}
164153
None => Ok(serde_json::to_value(UnserveModelResponse {
165154
success: true,
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! ONNX Runtime serving adapter used by the serving RPC boundary.
2+
3+
use super::serving::{
4+
effective_gateway_alias_from_config, non_critical_failure_response, serving_error,
5+
};
6+
use crate::server::AppState;
7+
use pumas_library::models::{
8+
ModelServeErrorCode, RuntimeProfileId, RuntimeProviderId, ServeModelRequest,
9+
ServeModelResponse, ServedModelLoadState, ServedModelStatus, UnserveModelRequest,
10+
UnserveModelResponse,
11+
};
12+
use pumas_library::{ExecutableArtifactFormat, OnnxLoadOptions, OnnxLoadRequest, OnnxModelId};
13+
use serde_json::Value;
14+
use tracing::warn;
15+
16+
pub(super) async fn serve_onnx_model(
17+
state: &AppState,
18+
request: ServeModelRequest,
19+
) -> pumas_library::Result<Value> {
20+
let Some(onnx_path) = resolve_onnx_model_path(state, &request).await? else {
21+
return non_critical_failure_response(
22+
state,
23+
serving_error(
24+
ModelServeErrorCode::ModelNotExecutable,
25+
"model has no executable ONNX artifact",
26+
&request,
27+
),
28+
)
29+
.await;
30+
};
31+
let library_root = state.api.model_library().library_root().to_path_buf();
32+
let load_request = match OnnxLoadRequest::parse(
33+
library_root,
34+
&onnx_path,
35+
&request.model_id,
36+
OnnxLoadOptions::default(),
37+
) {
38+
Ok(load_request) => load_request,
39+
Err(error) => {
40+
warn!("ONNX serving load request validation failed: {}", error);
41+
return non_critical_failure_response(
42+
state,
43+
serving_error(
44+
ModelServeErrorCode::InvalidRequest,
45+
"ONNX Runtime rejected the selected model load request",
46+
&request,
47+
),
48+
)
49+
.await;
50+
}
51+
};
52+
53+
let session = match state.onnx_session_manager.load(load_request).await {
54+
Ok(session) => session,
55+
Err(error) => {
56+
warn!("ONNX fake session load failed: {}", error);
57+
return non_critical_failure_response(
58+
state,
59+
serving_error(
60+
ModelServeErrorCode::ProviderLoadFailed,
61+
"ONNX Runtime could not load the selected model",
62+
&request,
63+
),
64+
)
65+
.await;
66+
}
67+
};
68+
69+
let status = ServedModelStatus {
70+
model_id: request.model_id.clone(),
71+
model_alias: Some(effective_gateway_alias_from_config(&request)),
72+
provider: RuntimeProviderId::OnnxRuntime,
73+
profile_id: request.config.profile_id.clone(),
74+
load_state: ServedModelLoadState::Loaded,
75+
device_mode: request.config.device_mode,
76+
device_id: request.config.device_id.clone(),
77+
gpu_layers: request.config.gpu_layers,
78+
tensor_split: request.config.tensor_split.clone(),
79+
context_size: Some(session.embedding_dimensions as u32),
80+
keep_loaded: request.config.keep_loaded,
81+
endpoint_url: None,
82+
memory_bytes: None,
83+
loaded_at: None,
84+
last_error: None,
85+
};
86+
let snapshot = state.api.record_served_model(status.clone()).await?;
87+
88+
Ok(serde_json::to_value(ServeModelResponse {
89+
success: true,
90+
error: None,
91+
loaded: true,
92+
loaded_models_unchanged: false,
93+
status: Some(status),
94+
load_error: None,
95+
snapshot: Some(snapshot),
96+
})?)
97+
}
98+
99+
pub(super) async fn unserve_onnx_model(
100+
state: &AppState,
101+
request: UnserveModelRequest,
102+
profile_id: RuntimeProfileId,
103+
model_alias: String,
104+
) -> pumas_library::Result<Value> {
105+
let model_id = match OnnxModelId::parse(&request.model_id) {
106+
Ok(model_id) => model_id,
107+
Err(error) => {
108+
warn!("ONNX serving unload request validation failed: {}", error);
109+
return Ok(serde_json::to_value(UnserveModelResponse {
110+
success: true,
111+
error: Some("ONNX Runtime rejected the selected model unload request".to_string()),
112+
unloaded: false,
113+
snapshot: Some(state.api.get_serving_status().await?.snapshot),
114+
})?);
115+
}
116+
};
117+
118+
match state.onnx_session_manager.unload(&model_id).await {
119+
Ok(Some(_)) => {}
120+
Ok(None) => {
121+
return Ok(serde_json::to_value(UnserveModelResponse {
122+
success: true,
123+
error: Some("ONNX Runtime model was not loaded".to_string()),
124+
unloaded: false,
125+
snapshot: Some(state.api.get_serving_status().await?.snapshot),
126+
})?);
127+
}
128+
Err(error) => {
129+
warn!("ONNX fake session unload failed: {}", error);
130+
return Ok(serde_json::to_value(UnserveModelResponse {
131+
success: true,
132+
error: Some("ONNX Runtime could not unload the selected model".to_string()),
133+
unloaded: false,
134+
snapshot: Some(state.api.get_serving_status().await?.snapshot),
135+
})?);
136+
}
137+
}
138+
139+
let snapshot = state
140+
.api
141+
.record_unserved_model(
142+
&request.model_id,
143+
Some(RuntimeProviderId::OnnxRuntime),
144+
Some(&profile_id),
145+
Some(model_alias.as_str()),
146+
)
147+
.await?;
148+
Ok(serde_json::to_value(UnserveModelResponse {
149+
success: true,
150+
error: None,
151+
unloaded: true,
152+
snapshot: Some(snapshot),
153+
})?)
154+
}
155+
156+
async fn resolve_onnx_model_path(
157+
state: &AppState,
158+
request: &ServeModelRequest,
159+
) -> pumas_library::Result<Option<std::path::PathBuf>> {
160+
let library = state.api.model_library().clone();
161+
let model_id = request.model_id.clone();
162+
let primary_file =
163+
tokio::task::spawn_blocking(move || library.get_primary_model_file(&model_id))
164+
.await
165+
.map_err(|err| {
166+
pumas_library::PumasError::Other(format!(
167+
"Failed to join primary ONNX model lookup task: {}",
168+
err
169+
))
170+
})?;
171+
let Some(onnx_path) = primary_file else {
172+
return Ok(None);
173+
};
174+
if ExecutableArtifactFormat::from_path(&onnx_path) != Some(ExecutableArtifactFormat::Onnx) {
175+
return Ok(None);
176+
}
177+
Ok(Some(onnx_path))
178+
}

0 commit comments

Comments
 (0)