Skip to main content

node_app_sdk_rust/llm/
errors.rs

1//! Typed errors for the public api-store LLM endpoint (feature 472).
2//!
3//! Resolves UI finding U1: applications need a typed, pattern-matchable
4//! error for the structured 400 body produced when `route: Private` cannot
5//! be honoured on the selected executor. Mirrors the TypeScript surface in
6//! `sdk/typescript/src/llm/errors.ts`.
7
8use serde::{Deserialize, Serialize};
9
10use super::types::LlmRoute;
11
12/// Reason the peer rejected a `route: Private` request.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum PrivateUnavailableReason {
16    NoEsp32Endpoint,
17    FirmwareUpgradeRequired,
18    PeerOffline,
19    PlatformNotEnabled,
20}
21
22/// Typed error returned by the SDK when the host emits the structured 400
23/// body for `api_store.private_unavailable`.
24///
25/// Inspect `fallback_available` to decide whether retrying with
26/// [`LlmRoute::Auto`] would succeed. The L402 macaroon is NOT consumed by
27/// this failure path (security invariant S6) โ€” applications can retry with
28/// the same token without re-paying.
29#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
30#[error("private routing unavailable: {reason:?} (fallback_available={fallback_available})")]
31pub struct PrivateUnavailableError {
32    pub reason: PrivateUnavailableReason,
33    pub fallback_available: bool,
34}
35
36/// Typed error returned by the SDK when `route` + `execution_preference`
37/// form a rejected combination per the Routing Truth Table in
38/// `data-model.md ยง4`.
39#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
40#[error("invalid route_mode + execution_preference combination: route_mode={route_mode:?}, execution_preference={execution_preference:?} โ€” {hint}")]
41pub struct InvalidRouteModeCombinationError {
42    pub route_mode: LlmRoute,
43    pub execution_preference: Option<String>,
44    pub hint: String,
45}
46
47/// Wire shape of the structured 400 body for `api_store.private_unavailable`.
48#[derive(Debug, Clone, Deserialize)]
49pub struct PrivateUnavailableBody {
50    pub code: String,
51    pub requested_route: String,
52    pub reason: PrivateUnavailableReason,
53    pub fallback_available: bool,
54}
55
56impl From<PrivateUnavailableBody> for PrivateUnavailableError {
57    fn from(body: PrivateUnavailableBody) -> Self {
58        Self {
59            reason: body.reason,
60            fallback_available: body.fallback_available,
61        }
62    }
63}
64
65/// Wire shape of the structured 400 body for
66/// `api_store.invalid_route_mode_combination`.
67#[derive(Debug, Clone, Deserialize)]
68pub struct InvalidRouteModeCombinationBody {
69    pub code: String,
70    pub route_mode: LlmRoute,
71    #[serde(default)]
72    pub execution_preference: Option<String>,
73    pub hint: String,
74}
75
76impl From<InvalidRouteModeCombinationBody> for InvalidRouteModeCombinationError {
77    fn from(body: InvalidRouteModeCombinationBody) -> Self {
78        Self {
79            route_mode: body.route_mode,
80            execution_preference: body.execution_preference,
81            hint: body.hint,
82        }
83    }
84}
85
86/// Decode a 400 response body into the corresponding typed error variant,
87/// returning `None` for unknown codes so callers can fall through to a
88/// generic transport error.
89pub fn map_api_error_body(bytes: &[u8]) -> Option<Box<dyn std::error::Error + Send + Sync>> {
90    let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
91    let code = v.get("code")?.as_str()?;
92    match code {
93        "api_store.private_unavailable" => {
94            let body: PrivateUnavailableBody = serde_json::from_value(v).ok()?;
95            Some(Box::new(PrivateUnavailableError::from(body)))
96        }
97        "api_store.invalid_route_mode_combination" => {
98            let body: InvalidRouteModeCombinationBody = serde_json::from_value(v).ok()?;
99            Some(Box::new(InvalidRouteModeCombinationError::from(body)))
100        }
101        _ => None,
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn decodes_private_unavailable() {
111        let body = br#"{
112            "code": "api_store.private_unavailable",
113            "requested_route": "private",
114            "reason": "no_esp32_endpoint",
115            "fallback_available": true
116        }"#;
117        let err = map_api_error_body(body).expect("typed err");
118        let downcast = err
119            .downcast_ref::<PrivateUnavailableError>()
120            .expect("PrivateUnavailableError");
121        assert_eq!(downcast.reason, PrivateUnavailableReason::NoEsp32Endpoint);
122        assert!(downcast.fallback_available);
123    }
124
125    #[test]
126    fn unknown_code_returns_none() {
127        assert!(map_api_error_body(br#"{ "code": "api_store.other" }"#).is_none());
128    }
129
130    #[test]
131    fn invalid_route_mode_combination_decodes() {
132        let body = br#"{
133            "code": "api_store.invalid_route_mode_combination",
134            "route_mode": "private",
135            "execution_preference": "network_only",
136            "hint": "fix it"
137        }"#;
138        let err = map_api_error_body(body).expect("typed err");
139        let downcast = err
140            .downcast_ref::<InvalidRouteModeCombinationError>()
141            .expect("InvalidRouteModeCombinationError");
142        assert_eq!(downcast.route_mode, LlmRoute::Private);
143        assert_eq!(downcast.execution_preference.as_deref(), Some("network_only"));
144    }
145}