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
+}