Skip to main content

lc_callbacks/
langsmith_client.rs

1// lc-callbacks/src/langsmith_client.rs
2//! LangSmith API client
3
4use reqwest::Client;
5use std::env;
6use std::time::Duration;
7
8/// M-18: a trace POST/PATCH must never hang the agent forever. Observability
9/// is a side channel — a slow or dead endpoint degrades to a logged error
10/// instead of blocking the run indefinitely. The caller still sees the error
11/// and, if the handler runs in the sync (default) mode, is only delayed by
12/// this bound rather than stalled.
13const LANGSMITH_TIMEOUT: Duration = Duration::from_secs(15);
14
15use super::run_tree::{RunCreate, RunTree, RunUpdate};
16
17/// LangSmith configuration
18#[derive(Debug, Clone)]
19pub struct LangSmithConfig {
20    /// API key (starts with "ls_")
21    pub api_key: String,
22
23    /// API endpoint URL
24    pub api_url: String,
25
26    /// Workspace ID (required for org accounts)
27    pub workspace_id: Option<String>,
28
29    /// Project name
30    pub project_name: String,
31
32    /// Whether tracing is enabled
33    pub tracing_enabled: bool,
34}
35
36impl LangSmithConfig {
37    /// Create config from environment variables
38    pub fn from_env() -> Result<Self, LangSmithError> {
39        let api_key = env::var("LANGSMITH_API_KEY").map_err(|_| {
40            LangSmithError::Config("LANGSMITH_API_KEY environment variable not set".to_string())
41        })?;
42
43        let tracing_enabled = env::var("LANGSMITH_TRACING")
44            .map(|v| v == "true")
45            .unwrap_or(true);
46
47        let project_name = env::var("LANGSMITH_PROJECT").unwrap_or_else(|_| "default".to_string());
48
49        let api_url = env::var("LANGSMITH_ENDPOINT")
50            .unwrap_or_else(|_| "https://api.smith.langchain.com".to_string());
51
52        let workspace_id = env::var("LANGSMITH_WORKSPACE_ID").ok();
53
54        Ok(Self {
55            api_key,
56            api_url,
57            workspace_id,
58            project_name,
59            tracing_enabled,
60        })
61    }
62
63    /// Create config with API key
64    pub fn new(api_key: impl Into<String>) -> Self {
65        Self {
66            api_key: api_key.into(),
67            api_url: "https://api.smith.langchain.com".to_string(),
68            workspace_id: None,
69            project_name: "default".to_string(),
70            tracing_enabled: true,
71        }
72    }
73
74    /// Set project name
75    pub fn with_project(mut self, project: impl Into<String>) -> Self {
76        self.project_name = project.into();
77        self
78    }
79
80    /// Set workspace ID
81    pub fn with_workspace(mut self, workspace_id: impl Into<String>) -> Self {
82        self.workspace_id = Some(workspace_id.into());
83        self
84    }
85
86    /// Set API endpoint
87    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
88        self.api_url = endpoint.into();
89        self
90    }
91
92    /// Enable or disable tracing
93    pub fn with_tracing(mut self, enabled: bool) -> Self {
94        self.tracing_enabled = enabled;
95        self
96    }
97}
98
99/// LangSmith API client
100pub struct LangSmithClient {
101    /// Configuration
102    pub config: LangSmithConfig,
103    http_client: Client,
104}
105
106impl LangSmithClient {
107    /// Create a new client
108    pub fn new(config: LangSmithConfig) -> Self {
109        // M-18: `Client::new()` sets no request timeout, so a hung endpoint
110        // blocks the `.send().await` forever. Bound the whole request cycle
111        // (connect + send + response body) so the agent degrades to an error.
112        let http_client = Client::builder()
113            .connect_timeout(Duration::from_secs(5))
114            .timeout(LANGSMITH_TIMEOUT)
115            .build()
116            // A builder failure here is a programming error (invalid TLS / too
117            // many redirects config), not a runtime condition — a plain reqwest
118            // client from valid static settings never fails to build. Falling
119            // back keeps `new()` infallible.
120            .unwrap_or_else(|_| Client::new());
121        Self {
122            config,
123            http_client,
124        }
125    }
126
127    /// Create client from environment variables
128    pub fn from_env() -> Result<Self, LangSmithError> {
129        let config = LangSmithConfig::from_env()?;
130        Ok(Self::new(config))
131    }
132
133    /// Check if tracing is enabled
134    pub fn is_tracing_enabled(&self) -> bool {
135        self.config.tracing_enabled
136    }
137
138    /// Get the project name
139    pub fn project_name(&self) -> &str {
140        &self.config.project_name
141    }
142
143    /// Create a run (POST /runs)
144    pub async fn create_run(&self, run: &RunTree) -> Result<(), LangSmithError> {
145        if !self.config.tracing_enabled {
146            return Ok(());
147        }
148
149        let url = format!("{}/runs", self.config.api_url);
150        let mut run_create = RunCreate::from(run);
151        if run_create.session_name.is_none() {
152            run_create.session_name = Some(self.config.project_name.clone());
153        }
154
155        let mut request = self
156            .http_client
157            .post(&url)
158            .header("x-api-key", &self.config.api_key)
159            .json(&run_create);
160
161        if let Some(workspace_id) = &self.config.workspace_id {
162            request = request.header("x-tenant-id", workspace_id);
163        }
164
165        let response = request
166            .send()
167            .await
168            .map_err(|e| LangSmithError::Http(e.to_string()))?;
169
170        if !response.status().is_success() {
171            let status = response.status();
172            let body = response.text().await.unwrap_or_default();
173            return Err(LangSmithError::Api(format!("HTTP {}: {}", status, body)));
174        }
175
176        Ok(())
177    }
178
179    /// Update a run (PATCH /runs/{run_id})
180    pub async fn update_run(&self, run: &RunTree) -> Result<(), LangSmithError> {
181        if !self.config.tracing_enabled {
182            return Ok(());
183        }
184
185        let url = format!("{}/runs/{}", self.config.api_url, run.id);
186        let body = RunUpdate::from(run);
187
188        let mut request = self
189            .http_client
190            .patch(&url)
191            .header("x-api-key", &self.config.api_key)
192            .json(&body);
193
194        if let Some(workspace_id) = &self.config.workspace_id {
195            request = request.header("x-tenant-id", workspace_id);
196        }
197
198        let response = request
199            .send()
200            .await
201            .map_err(|e| LangSmithError::Http(e.to_string()))?;
202
203        if !response.status().is_success() {
204            let status = response.status();
205            let body = response.text().await.unwrap_or_default();
206            return Err(LangSmithError::Api(format!("HTTP {}: {}", status, body)));
207        }
208
209        Ok(())
210    }
211}
212
213/// LangSmith error type
214#[derive(Debug)]
215#[non_exhaustive]
216pub enum LangSmithError {
217    /// HTTP request error.
218    Http(String),
219    /// API returned an error.
220    Api(String),
221    /// Configuration error.
222    Config(String),
223}
224
225impl std::fmt::Display for LangSmithError {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        match self {
228            Self::Http(msg) => write!(f, "HTTP error: {}", msg),
229            Self::Api(msg) => write!(f, "API error: {}", msg),
230            Self::Config(msg) => write!(f, "Config error: {}", msg),
231        }
232    }
233}
234
235impl std::error::Error for LangSmithError {}