@ondeinference / onde / commits / 76022a8

Pulse v1

keypair34 committed Apr 17, 2026 at 19:41 UTC 76022a83a276f2b6e92437375c0854d85f1d22b5
7 files changed +187 -4
Cargo.lock
+13
@@ -3628,6 +3628,7 @@ dependencies = [
3628 "mistralrs",
3629 "mistralrs-core",
3630 "serde",
3631 + "smbcloud-gresiq-sdk",
3632 "thiserror 2.0.18",
3633 "tokio",
3634 "tsync",
@@ -5121,6 +5122,18 @@ version = "0.3.2"
5122 source = "registry+https://github.com/rust-lang/crates.io-index"
5123 checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
5124
5125 +[[package]]
5126 +name = "smbcloud-gresiq-sdk"
5127 +version = "0.1.0"
5128 +dependencies = [
5129 + "log",
5130 + "reqwest 0.12.28",
5131 + "serde",
5132 + "serde_json",
5133 + "thiserror 2.0.18",
5134 + "tokio",
5135 +]
5136 +
5137 [[package]]
5138 name = "socket2"
5139 version = "0.6.3"
Cargo.toml
+7 -2
@@ -105,7 +105,12 @@ mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/a
105 mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/all-platform-fixes" }
106
107 # Patch sysctl with watchOS (target_os = "watchos") support until upstream merges it.
108 -# Upstream PR: https://github.com/johalun/sysctl-rs/pull/74
109 -# Fork branch: https://github.com/setoelkahfi/sysctl-rs/tree/feature/watchos
108 +
109 +[dependencies.smbcloud-gresiq-sdk]
110 +path = "../smbcloud-cli/crates/smbcloud-gresiq-sdk"
111 +# TODO: version = "0.1" after publishing to crates.io
112 +
113 +# Patch sysctl with watchOS support until upstream merges it.
114 +# PR: https://github.com/johalun/sysctl-rs/pull/74
115 [patch.crates-io]
116 sysctl = { git = "https://github.com/setoelkahfi/sysctl-rs.git", branch = "feature/watchos" }
src/inference/engine.rs
+26 -2
@@ -198,6 +198,7 @@ struct LoadedModel {
198 ))]
199 pub struct ChatEngine {
200 inner: Mutex<Option<LoadedModel>>,
201 + pulse: Option<crate::pulse::PulseClient>,
202 }
203
204 #[cfg(any(
@@ -217,6 +218,7 @@ impl ChatEngine {
218 pub fn new() -> Self {
219 Self {
220 inner: Mutex::new(None),
221 + pulse: crate::pulse::PulseClient::from_env(),
222 }
223 }
224
@@ -346,6 +348,10 @@ impl ChatEngine {
348 sampling.max_tokens,
349 );
350
351 + // Capture before config is moved into LoadedModel.
352 + let pulse_model_id = config.model_id.clone();
353 + let pulse_model_name = config.display_name.clone();
354 +
355 let mut guard = self.inner.lock().await;
356 *guard = Some(LoadedModel {
357 model: Arc::new(model),
@@ -355,6 +361,10 @@ impl ChatEngine {
361 sampling,
362 });
363
364 + if let Some(ref pulse) = self.pulse {
365 + pulse.record_model_loaded(pulse_model_id, pulse_model_name, elapsed.as_millis() as u64);
366 + }
367 +
368 Ok(elapsed)
369 }
370
@@ -570,11 +580,16 @@ impl ChatEngine {
580 let user_message = user_message.into();
581
582 // ── 1. Snapshot model handle + build request, then release lock ──
573 - let (model, request) = {
583 + let (model, request, pulse_model_id) = {
584 let guard = self.inner.lock().await;
585 let loaded = guard.as_ref().ok_or(InferenceError::NoModelLoaded)?;
586 let request = self::build_request(loaded, &user_message);
577 - (loaded.model.clone(), request)
587 + let pulse_model_id = match &loaded.config {
588 + LoadedModelConfig::Gguf(c) => c.model_id.clone(),
589 + #[cfg(target_os = "macos")]
590 + LoadedModelConfig::Isq(c) => c.model_id.clone(),
591 + };
592 + (loaded.model.clone(), request, pulse_model_id)
593 }; // ← mutex released before inference
594
595 log::info!(
@@ -617,6 +632,15 @@ impl ChatEngine {
632 }
633 }
634
635 + if let Some(ref pulse) = self.pulse {
636 + pulse.record_inference(
637 + pulse_model_id,
638 + crate::pulse::next_request_id(),
639 + elapsed.as_millis() as u64,
640 + "success".to_string(),
641 + );
642 + }
643 +
644 Ok(InferenceResult {
645 text: reply,
646 duration_secs: elapsed.as_secs_f64(),
src/lib.rs
+2
@@ -43,6 +43,8 @@
43 //! ```
44
45 pub mod hf_cache;
46 +
47 +pub mod pulse;
48 pub mod inference;
49
50 uniffi::setup_scaffolding!();
src/pulse/client.rs new
+69
@@ -0,0 +1,69 @@
1 +use smbcloud_gresiq_sdk::GresiqClient;
2 +
3 +use super::events::{InferenceEvent, ModelLoadedEvent};
4 +
5 +/// onde's thin wrapper around `GresiqClient`.
6 +///
7 +/// `GresiqClient` handles the HTTP transport and auth headers.
8 +/// This layer adds the `edge_id` (which machine we're running on)
9 +/// and the fire-and-forget spawning so telemetry never blocks inference.
10 +#[derive(Debug, Clone)]
11 +pub struct PulseClient {
12 + inner: GresiqClient,
13 + edge_id: String,
14 +}
15 +
16 +impl PulseClient {
17 + /// Reads `GRESIQ_BASE_URL`, `GRESIQ_API_KEY`, and `GRESIQ_API_SECRET`
18 + /// from the environment (delegated to `GresiqClient::from_env`), plus
19 + /// `ONDE_EDGE_ID` for the node name. Returns `None` if any required
20 + /// GresIQ var is missing — telemetry stays off, nothing breaks.
21 + pub fn from_env() -> Option<Self> {
22 + let inner = GresiqClient::from_env()?;
23 + let edge_id = std::env::var("ONDE_EDGE_ID")
24 + .unwrap_or_else(|_| "onde-unknown".to_string());
25 + Some(PulseClient { inner, edge_id })
26 + }
27 +
28 + /// Spawns a background task to POST the model-load event and returns
29 + /// immediately. Won't slow down the first message. If the request fails,
30 + /// you get a `warn!` log entry — that's the whole error budget.
31 + pub fn record_model_loaded(&self, model_id: String, model_name: String, load_duration_ms: u64) {
32 + let client = self.clone();
33 + tokio::spawn(async move {
34 + let event = ModelLoadedEvent {
35 + edge_id: client.edge_id.clone(),
36 + model_id,
37 + model_name,
38 + load_duration_ms,
39 + };
40 + if let Err(error) = client.inner.insert("pulse/model_loaded", &event).await {
41 + log::warn!("pulse: model_loaded failed: {}", error);
42 + }
43 + });
44 + }
45 +
46 + /// Same deal as `record_model_loaded` but for inference. Spawns, returns,
47 + /// logs on failure.
48 + pub fn record_inference(
49 + &self,
50 + model_id: String,
51 + request_id: String,
52 + duration_ms: u64,
53 + status: String,
54 + ) {
55 + let client = self.clone();
56 + tokio::spawn(async move {
57 + let event = InferenceEvent {
58 + edge_id: client.edge_id.clone(),
59 + model_id,
60 + request_id,
61 + duration_ms,
62 + status,
63 + };
64 + if let Err(error) = client.inner.insert("pulse/inference_event", &event).await {
65 + log::warn!("pulse: inference_event failed: {}", error);
66 + }
67 + });
68 + }
69 +}
src/pulse/events.rs new
+32
@@ -0,0 +1,32 @@
1 +use serde::Serialize;
2 +
3 +/// What gets sent to GresIQ when a model finishes loading.
4 +#[derive(Debug, Clone, Serialize)]
5 +pub struct ModelLoadedEvent {
6 + /// Which machine this is. Keep it stable across restarts — change it
7 + /// and you'll get a duplicate edge row in the dashboard.
8 + pub edge_id: String,
9 + /// The HuggingFace repo ID, e.g. `bartowski/Qwen2.5-3B-Instruct-GGUF`.
10 + pub model_id: String,
11 + /// What shows up in the UI, e.g. `Qwen 2.5 3B`.
12 + pub model_name: String,
13 + /// How long the load took. This is `elapsed.as_millis() as u64`,
14 + /// where `elapsed` is what `load_gguf_model` returns.
15 + pub load_duration_ms: u64,
16 +}
17 +
18 +/// What gets sent to GresIQ after each `send_message` or `generate`.
19 +#[derive(Debug, Clone, Serialize)]
20 +pub struct InferenceEvent {
21 + /// Same edge as in `ModelLoadedEvent`.
22 + pub edge_id: String,
23 + /// Same model as in `ModelLoadedEvent`.
24 + pub model_id: String,
25 + /// Auto-generated per request. Looks like `onde-1720000000000-42`.
26 + pub request_id: String,
27 + /// Wall-clock time for the whole response, in milliseconds.
28 + pub duration_ms: u64,
29 + /// `"success"`, `"cancelled"`, or `"error"`. Currently always
30 + /// `"success"` here — inference errors throw before we reach this point.
31 + pub status: String,
32 +}
src/pulse/mod.rs new
+38
@@ -0,0 +1,38 @@
1 +//! Sends timing data to the Onde Inference dashboard via smbCloud GresIQ.
2 +//!
3 +//! Opt-in at the infra level, not the code level. Set three env vars and
4 +//! telemetry starts flowing. Set none and nothing happens — no warnings,
5 +//! no stubs, the branches just never run.
6 +//!
7 +//! The engine reads the vars once in `ChatEngine::new()`. You can't flip
8 +//! telemetry on mid-run without restarting.
9 +//!
10 +//! | Variable | Example |
11 +//! |---------------------|--------------------------------|
12 +//! | `GRESIQ_BASE_URL` | `https://api.smbcloud.xyz` |
13 +//! | `GRESIQ_API_KEY` | `gresiq_abc123...` |
14 +//! | `GRESIQ_API_SECRET` | `<secret from GresIQ console>` |
15 +//!
16 +//! `ONDE_EDGE_ID` is what this machine gets called in the dashboard.
17 +//! Anything works — just keep it stable across restarts or you'll
18 +//! end up with duplicate edges. Defaults to `"onde-unknown"`.
19 +
20 +mod client;
21 +mod events;
22 +
23 +pub use client::PulseClient;
24 +pub use events::{InferenceEvent, ModelLoadedEvent};
25 +
26 +use std::sync::atomic::{AtomicU64, Ordering};
27 +
28 +/// Cheap unique ID per inference request. No uuid crate needed.
29 +/// Looks like `onde-1720000000000-42`.
30 +pub(crate) fn next_request_id() -> String {
31 + static COUNTER: AtomicU64 = AtomicU64::new(0);
32 + let ms = std::time::SystemTime::now()
33 + .duration_since(std::time::UNIX_EPOCH)
34 + .map(|d| d.as_millis() as u64)
35 + .unwrap_or(0);
36 + let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
37 + format!("onde-{}-{}", ms, seq)
38 +}