node-app-sdk-rust 5.25.1

Rust SDK for building Node-App native plugins (cdylib) — Lightning-powered marketplace nodes
Documentation
//! Rust consumer SDK types for the public api-store LLM endpoint
//! (feature 472).
//!
//! Mirror of the TypeScript shapes in `sdk/typescript/src/llm/types.ts` and
//! the domain types in `core/domain/src/models/api_store_llm.rs`. The SDK
//! types are independent of the domain crate so plugins outside the
//! workspace can depend on `node-app-sdk-rust` alone.

use serde::{Deserialize, Serialize};

/// Consumer-expressed routing intent.
///
/// - [`LlmRoute::Public`] — force direct provider execution; skip any
///   custodial-hardware hop even on peers that have it configured.
/// - [`LlmRoute::Private`] — force routing through the custodial-hardware
///   path. Returns [`crate::llm::PrivateUnavailableError`] when no hardware
///   path is available on the selected executor.
/// - [`LlmRoute::Auto`] — default; pick the more-isolated path when
///   available, never hard-fail because of routing.
///
/// **Honest framing**: `Private` mode isolates the upstream provider's API
/// credentials on the peer's host and routes egress through a separate
/// device; it does NOT mask prompt contents from the upstream provider or
/// anonymize the user.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmRoute {
    /// Force direct provider execution.
    Public,
    /// Force custodial-hardware routing; hard-fail when unavailable.
    Private,
    /// Pick the more-isolated path when available (default).
    #[default]
    Auto,
}

impl LlmRoute {
    pub fn as_str(&self) -> &'static str {
        match self {
            LlmRoute::Public => "public",
            LlmRoute::Private => "private",
            LlmRoute::Auto => "auto",
        }
    }
}

// ── Builder shortcuts (T1012) ────────────────────────────────────────────────
//
// Idiomatic mutator chain for the three request shapes — resolves UI finding
// U2: consumers want `request.private()` to read the way they'd say it out
// loud. Each method returns `Self` so callers can chain at the call site
// without an explicit `.build()` step.

impl ExecuteChatRequest {
    /// Force `route = Public` — direct provider execution, skip ESP32 hop.
    #[must_use]
    pub fn public(mut self) -> Self {
        self.route = Some(LlmRoute::Public);
        self
    }
    /// Force `route = Private` — custodial-hardware routing, hard-fail when
    /// unavailable. **Honest framing**: does NOT mask prompt contents from
    /// the upstream provider.
    #[must_use]
    pub fn private(mut self) -> Self {
        self.route = Some(LlmRoute::Private);
        self
    }
    /// Force `route = Auto` — pick the more-isolated path when available;
    /// never hard-fail because of routing.
    #[must_use]
    pub fn auto(mut self) -> Self {
        self.route = Some(LlmRoute::Auto);
        self
    }
}

impl ExecuteBestModelRequest {
    #[must_use]
    pub fn public(mut self) -> Self {
        self.route = Some(LlmRoute::Public);
        self
    }
    #[must_use]
    pub fn private(mut self) -> Self {
        self.route = Some(LlmRoute::Private);
        self
    }
    #[must_use]
    pub fn auto(mut self) -> Self {
        self.route = Some(LlmRoute::Auto);
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fresh_request() -> ExecuteChatRequest {
        ExecuteChatRequest {
            messages: vec![ChatMessage {
                role: "user".into(),
                content: "hi".into(),
            }],
            model: None,
            platform: None,
            route: None,
            execution_preference: None,
            max_output_tokens: None,
            correlation_id: None,
            tools: None,
            tool_choice: None,
            temperature: None,
            response_format: None,
        }
    }

    #[test]
    fn builder_shortcut_public() {
        let req = fresh_request().public();
        assert_eq!(req.route, Some(LlmRoute::Public));
    }

    #[test]
    fn builder_shortcut_private() {
        let req = fresh_request().private();
        assert_eq!(req.route, Some(LlmRoute::Private));
    }

    #[test]
    fn builder_shortcut_auto() {
        let req = fresh_request().auto();
        assert_eq!(req.route, Some(LlmRoute::Auto));
    }

    #[test]
    fn builder_shortcuts_chain_and_overwrite() {
        // Chaining should always end on the last call's value.
        let req = fresh_request().public().private();
        assert_eq!(req.route, Some(LlmRoute::Private));
    }

    #[test]
    fn route_field_serialises_as_route_mode_on_wire() {
        let req = fresh_request().private();
        let json = serde_json::to_string(&req).expect("serialize");
        assert!(
            json.contains("\"route_mode\":\"private\""),
            "wire field name must be route_mode, got: {}",
            json
        );
        assert!(
            !json.contains("\"route\""),
            "must not also emit a `route` field, got: {}",
            json
        );
    }
}

/// Truthful echo of which path actually executed the call. Never `Auto`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProxyMode {
    Public,
    Private,
}

/// Local-vs-network preference. Composes with `route` per the Routing
/// Truth Table in `data-model.md §4`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionPreference {
    PreferLocal,
    LocalOnly,
    NetworkOnly,
}

/// Single chat message — opaque to the SDK.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
    pub role: String,
    pub content: String,
}

/// Unary chat request.
///
/// `route` (which is mapped to the wire field `route_mode` at serialisation
/// time): set to [`LlmRoute::Private`] to force the custodial-hardware path.
/// Catch [`crate::llm::PrivateUnavailableError`] to detect and recover from
/// a peer that cannot honour the request.
///
/// **Honest framing**: Private mode does NOT mask prompt contents from the
/// upstream provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteChatRequest {
    pub messages: Vec<ChatMessage>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,

    /// Platform (e.g. "OpenAI", "Anthropic").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub platform: Option<String>,

    /// Consumer-expressed routing intent. See [`LlmRoute`] for the full
    /// honest-framing contract — in short: `Private` isolates the upstream
    /// provider's credentials on the peer's host and routes egress through
    /// a separate device, but does NOT mask prompt contents from the
    /// upstream provider or anonymise the user. Wire field is `route_mode`.
    ///
    /// `None` (the default) means the server picks per its Routing Truth
    /// Table; the actual route used will be reflected on the response's
    /// [`ExecuteChatResponse::execution_route`] field.
    #[serde(rename = "route_mode", default, skip_serializing_if = "Option::is_none")]
    pub route: Option<LlmRoute>,

    /// Local-vs-network preference.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub execution_preference: Option<ExecutionPreference>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_output_tokens: Option<u32>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tools: Option<serde_json::Value>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_choice: Option<serde_json::Value>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub temperature: Option<f32>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub response_format: Option<serde_json::Value>,
}

/// Streaming variant of [`ExecuteChatRequest`]. Structurally identical.
pub type StreamChatRequest = ExecuteChatRequest;

/// "best-model" variant — model is resolved server-side.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteBestModelRequest {
    pub messages: Vec<ChatMessage>,

    #[serde(rename = "route_mode", default, skip_serializing_if = "Option::is_none")]
    pub route: Option<LlmRoute>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub execution_preference: Option<ExecutionPreference>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
}

/// Single-message execute variant.
pub type ExecuteRequest = ExecuteChatRequest;

/// Unary call response.
///
/// `execution_route` is the truthful echo of what actually ran — always
/// [`ProxyMode::Public`] or [`ProxyMode::Private`], never `Auto`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteChatResponse {
    pub response_text: String,
    pub platform: String,
    pub model: String,

    /// Truthful echo of which path actually executed the call —
    /// [`ProxyMode::Public`] or [`ProxyMode::Private`], never `Auto`.
    /// Consumers SHOULD log this so they can correlate cost / latency
    /// with the route their request actually took, especially when
    /// [`ExecuteChatRequest::route`] was [`LlmRoute::Auto`].
    ///
    /// **Honest framing** (per security finding S5/U4): observing
    /// [`ProxyMode::Private`] here means the upstream provider's API
    /// credentials stayed on the peer's host and egress went through a
    /// separate device. It does NOT mean the upstream provider was
    /// blind to the prompt contents, nor that the user was anonymised
    /// to the upstream provider.
    pub execution_route: ProxyMode,

    /// Identifier of the node that executed the call (self or peer).
    pub via_node_id: String,

    pub prompt_tokens: u32,
    pub completion_tokens: u32,
    pub cost_sats: u64,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub correlation_id: Option<String>,
}

/// Streaming chunk. The final chunk MUST be either `End` or `Error`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StreamChatChunk {
    Delta {
        text: String,
    },
    End {
        execution_route: ProxyMode,
        via_node_id: String,
        prompt_tokens: u32,
        completion_tokens: u32,
        cost_sats: u64,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        correlation_id: Option<String>,
    },
    Error {
        code: String,
        message: String,
    },
}