|
| 1 | +use anyhow::Result; |
| 2 | +use std::io::Write; |
| 3 | + |
| 4 | +const DOCS_AI_URL: &str = "https://app.datadoghq.com/api/unstable/docs-ai/chat"; |
| 5 | +// Public key embedded in the Datadog docs site JS — not a user credential. |
| 6 | +const DOCS_AI_API_KEY: &str = "ddpub_docsai_nkbIDfPWw4pKuRlLef8aDs2onVqdimFI"; |
| 7 | + |
| 8 | +// Allows test overrides without changing the function signature (mirrors PUP_MOCK_SERVER). |
| 9 | +fn endpoint_url() -> String { |
| 10 | + std::env::var("PUP_DOCS_AI_URL").unwrap_or_else(|_| DOCS_AI_URL.to_string()) |
| 11 | +} |
| 12 | + |
| 13 | +/// Ask the Datadog Docs AI a question and stream the response to `out`. |
| 14 | +/// |
| 15 | +/// `out` is injected so callers can redirect output (pass `&mut std::io::stdout()` |
| 16 | +/// for normal use) and tests can capture or control write failures. |
| 17 | +#[cfg(not(target_arch = "wasm32"))] |
| 18 | +pub async fn ask(question: &str, out: &mut impl Write) -> Result<()> { |
| 19 | + use futures::StreamExt; |
| 20 | + |
| 21 | + if question.trim().is_empty() { |
| 22 | + anyhow::bail!("question cannot be empty"); |
| 23 | + } |
| 24 | + |
| 25 | + let conversation_id = format!("dd_docsai_{}", uuid::Uuid::new_v4()); |
| 26 | + |
| 27 | + let body = serde_json::json!({ |
| 28 | + "data": { |
| 29 | + "attributes": { |
| 30 | + "query": question, |
| 31 | + "conversation_id": conversation_id, |
| 32 | + "anchor_url": "https://docs.datadoghq.com/", |
| 33 | + "rewrite_query": true |
| 34 | + } |
| 35 | + } |
| 36 | + }); |
| 37 | + |
| 38 | + let client = reqwest::Client::builder() |
| 39 | + .timeout(std::time::Duration::from_secs(120)) |
| 40 | + .connect_timeout(std::time::Duration::from_secs(10)) |
| 41 | + .build() |
| 42 | + .map_err(|e| anyhow::anyhow!("Failed to build HTTP client: {e}"))?; |
| 43 | + |
| 44 | + let resp = client |
| 45 | + .post(endpoint_url()) |
| 46 | + .header("Content-Type", "application/json") |
| 47 | + .header("Accept", "text/event-stream") |
| 48 | + .header("x-docs-ai-api-key", DOCS_AI_API_KEY) |
| 49 | + .header("User-Agent", crate::useragent::get()) |
| 50 | + .json(&body) |
| 51 | + .send() |
| 52 | + .await |
| 53 | + .map_err(|e| anyhow::anyhow!("Docs AI request failed: {e}"))?; |
| 54 | + |
| 55 | + if !resp.status().is_success() { |
| 56 | + let status = resp.status(); |
| 57 | + let err_body = match resp.text().await { |
| 58 | + Ok(b) => b, |
| 59 | + Err(e) => format!("<failed to read error body: {e}>"), |
| 60 | + }; |
| 61 | + anyhow::bail!("Docs AI error (HTTP {status}): {err_body}"); |
| 62 | + } |
| 63 | + |
| 64 | + let mut buffer = String::new(); |
| 65 | + let mut bytes_stream = resp.bytes_stream(); |
| 66 | + let mut saw_done = false; |
| 67 | + |
| 68 | + // SSE framing follows the same pattern as bits.rs: accumulate chunks, |
| 69 | + // split on \n\n event boundaries, strip "data: " prefix per line. |
| 70 | + 'outer: while let Some(chunk_result) = bytes_stream.next().await { |
| 71 | + let chunk = chunk_result.map_err(|e| anyhow::anyhow!("Stream read error: {e}"))?; |
| 72 | + buffer.push_str(&String::from_utf8_lossy(&chunk)); |
| 73 | + |
| 74 | + while let Some(end) = buffer.find("\n\n") { |
| 75 | + let event_block = buffer[..end].to_string(); |
| 76 | + buffer = buffer[end + 2..].to_string(); |
| 77 | + |
| 78 | + for line in event_block.lines() { |
| 79 | + let Some(data_str) = line.strip_prefix("data: ") else { |
| 80 | + continue; |
| 81 | + }; |
| 82 | + if data_str.trim() == "[DONE]" { |
| 83 | + saw_done = true; |
| 84 | + break 'outer; |
| 85 | + } |
| 86 | + if let Some(text) = extract_sse_content(data_str) { |
| 87 | + if !emit(out, &text)? { |
| 88 | + return Ok(()); |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + // Drain any remaining buffer when the stream ends without [DONE]. |
| 96 | + for line in buffer.lines() { |
| 97 | + let Some(data_str) = line.strip_prefix("data: ") else { |
| 98 | + continue; |
| 99 | + }; |
| 100 | + if data_str.trim() == "[DONE]" { |
| 101 | + saw_done = true; |
| 102 | + } else if let Some(text) = extract_sse_content(data_str) { |
| 103 | + if !emit(out, &text)? { |
| 104 | + return Ok(()); |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + if !saw_done { |
| 110 | + eprintln!( |
| 111 | + "Warning: Docs AI stream ended without a completion signal — the response may be truncated." |
| 112 | + ); |
| 113 | + } |
| 114 | + writeln!(out).map_err(|e| anyhow::anyhow!("Failed to write Docs AI response: {e}"))?; |
| 115 | + Ok(()) |
| 116 | +} |
| 117 | + |
| 118 | +/// Write `text` to `out` and flush. Returns `Ok(false)` on `BrokenPipe` (caller |
| 119 | +/// should stop and return `Ok(())`), `Ok(true)` on success, `Err` on other I/O errors. |
| 120 | +fn emit(out: &mut impl Write, text: &str) -> Result<bool> { |
| 121 | + if let Err(e) = writeln!(out, "{text}") { |
| 122 | + if e.kind() == std::io::ErrorKind::BrokenPipe { |
| 123 | + return Ok(false); |
| 124 | + } |
| 125 | + return Err(anyhow::anyhow!("Failed to write Docs AI response: {e}")); |
| 126 | + } |
| 127 | + if let Err(e) = out.flush() { |
| 128 | + if e.kind() == std::io::ErrorKind::BrokenPipe { |
| 129 | + return Ok(false); |
| 130 | + } |
| 131 | + return Err(anyhow::anyhow!("Failed to write Docs AI response: {e}")); |
| 132 | + } |
| 133 | + Ok(true) |
| 134 | +} |
| 135 | + |
| 136 | +/// Extract text content from a single SSE data line. |
| 137 | +/// |
| 138 | +/// Tries multiple envelope shapes in priority order. Returns None for non-JSON |
| 139 | +/// frames, metadata events, or shapes that carry no printable content. |
| 140 | +pub(crate) fn extract_sse_content(data_str: &str) -> Option<String> { |
| 141 | + // Non-JSON lines are intentionally skipped — SSE streams include comment lines, |
| 142 | + // heartbeat pings, and control frames (e.g. "event: ping") that are not JSON. |
| 143 | + let val = serde_json::from_str::<serde_json::Value>(data_str).ok()?; |
| 144 | + let text = val |
| 145 | + .pointer("/data/attributes/content") |
| 146 | + .or_else(|| val.pointer("/attributes/content")) |
| 147 | + .or_else(|| val.get("content")) |
| 148 | + .or_else(|| val.get("text")) |
| 149 | + .and_then(|v| v.as_str())?; |
| 150 | + Some(text.to_string()) |
| 151 | +} |
| 152 | + |
| 153 | +// --------------------------------------------------------------------------- |
| 154 | +// WASM stub |
| 155 | +// --------------------------------------------------------------------------- |
| 156 | + |
| 157 | +#[cfg(target_arch = "wasm32")] |
| 158 | +pub async fn ask(_question: &str, _out: &mut impl Write) -> Result<()> { |
| 159 | + anyhow::bail!("docs ask is not supported in WASM builds") |
| 160 | +} |
| 161 | + |
| 162 | +#[cfg(test)] |
| 163 | +mod tests { |
| 164 | + use super::*; |
| 165 | + |
| 166 | + // --- extract_sse_content unit tests --- |
| 167 | + |
| 168 | + #[test] |
| 169 | + fn extract_nested_data_attributes_content() { |
| 170 | + let json = r#"{"data":{"attributes":{"content":"hello world"}}}"#; |
| 171 | + assert_eq!(extract_sse_content(json).as_deref(), Some("hello world")); |
| 172 | + } |
| 173 | + |
| 174 | + #[test] |
| 175 | + fn extract_attributes_content() { |
| 176 | + let json = r#"{"attributes":{"content":"from attributes"}}"#; |
| 177 | + assert_eq!( |
| 178 | + extract_sse_content(json).as_deref(), |
| 179 | + Some("from attributes") |
| 180 | + ); |
| 181 | + } |
| 182 | + |
| 183 | + #[test] |
| 184 | + fn extract_flat_content_field() { |
| 185 | + let json = r#"{"content":"flat content"}"#; |
| 186 | + assert_eq!(extract_sse_content(json).as_deref(), Some("flat content")); |
| 187 | + } |
| 188 | + |
| 189 | + #[test] |
| 190 | + fn extract_flat_text_field() { |
| 191 | + let json = r#"{"text":"flat text"}"#; |
| 192 | + assert_eq!(extract_sse_content(json).as_deref(), Some("flat text")); |
| 193 | + } |
| 194 | + |
| 195 | + #[test] |
| 196 | + fn extract_returns_none_for_non_json() { |
| 197 | + assert_eq!(extract_sse_content("not json"), None); |
| 198 | + assert_eq!(extract_sse_content(""), None); |
| 199 | + } |
| 200 | + |
| 201 | + #[test] |
| 202 | + fn extract_returns_none_when_no_content_field() { |
| 203 | + assert_eq!(extract_sse_content(r#"{"data":{"attributes":{}}}"#), None); |
| 204 | + assert_eq!(extract_sse_content(r#"{"other":"value"}"#), None); |
| 205 | + } |
| 206 | + |
| 207 | + // --- ask() integration tests via mockito --- |
| 208 | + |
| 209 | + #[cfg(not(target_arch = "wasm32"))] |
| 210 | + #[tokio::test] |
| 211 | + async fn ask_streams_sse_content_to_out() { |
| 212 | + let _guard = crate::test_utils::ENV_LOCK.lock().await; |
| 213 | + |
| 214 | + let mut server = mockito::Server::new_async().await; |
| 215 | + let sse_body = concat!( |
| 216 | + "data: {\"content\":\"Hello\"}\n\n", |
| 217 | + "data: {\"content\":\", world\"}\n\n", |
| 218 | + "data: [DONE]\n\n", |
| 219 | + ); |
| 220 | + let _mock = server |
| 221 | + .mock("POST", "/api/unstable/docs-ai/chat") |
| 222 | + .with_status(200) |
| 223 | + .with_header("content-type", "text/event-stream") |
| 224 | + .with_body(sse_body) |
| 225 | + .create_async() |
| 226 | + .await; |
| 227 | + |
| 228 | + std::env::set_var( |
| 229 | + "PUP_DOCS_AI_URL", |
| 230 | + format!("{}/api/unstable/docs-ai/chat", server.url()), |
| 231 | + ); |
| 232 | + let mut buf = Vec::new(); |
| 233 | + let result = ask("what is a monitor?", &mut buf).await; |
| 234 | + std::env::remove_var("PUP_DOCS_AI_URL"); |
| 235 | + |
| 236 | + result.expect("ask() should succeed"); |
| 237 | + assert!( |
| 238 | + buf.starts_with(b"Hello\n, world\n"), |
| 239 | + "output should contain streamed content, one event per line" |
| 240 | + ); |
| 241 | + } |
| 242 | + |
| 243 | + #[cfg(not(target_arch = "wasm32"))] |
| 244 | + #[tokio::test] |
| 245 | + async fn ask_returns_error_on_non_200() { |
| 246 | + let _guard = crate::test_utils::ENV_LOCK.lock().await; |
| 247 | + |
| 248 | + let mut server = mockito::Server::new_async().await; |
| 249 | + let _mock = server |
| 250 | + .mock("POST", "/api/unstable/docs-ai/chat") |
| 251 | + .with_status(429) |
| 252 | + .with_header("content-type", "application/json") |
| 253 | + .with_body(r#"{"error":"rate limited"}"#) |
| 254 | + .create_async() |
| 255 | + .await; |
| 256 | + |
| 257 | + std::env::set_var( |
| 258 | + "PUP_DOCS_AI_URL", |
| 259 | + format!("{}/api/unstable/docs-ai/chat", server.url()), |
| 260 | + ); |
| 261 | + let result = ask("what is a monitor?", &mut std::io::sink()).await; |
| 262 | + std::env::remove_var("PUP_DOCS_AI_URL"); |
| 263 | + |
| 264 | + let err = result.expect_err("ask() should fail on HTTP 429"); |
| 265 | + let msg = err.to_string(); |
| 266 | + assert!(msg.contains("429"), "error should mention HTTP status"); |
| 267 | + assert!( |
| 268 | + msg.contains("rate limited"), |
| 269 | + "error should include the response body" |
| 270 | + ); |
| 271 | + } |
| 272 | + |
| 273 | + #[cfg(not(target_arch = "wasm32"))] |
| 274 | + #[tokio::test] |
| 275 | + async fn ask_rejects_empty_question() { |
| 276 | + let result = ask("", &mut std::io::sink()).await; |
| 277 | + assert!(result.is_err()); |
| 278 | + assert!(result |
| 279 | + .unwrap_err() |
| 280 | + .to_string() |
| 281 | + .contains("question cannot be empty")); |
| 282 | + } |
| 283 | + |
| 284 | + #[cfg(not(target_arch = "wasm32"))] |
| 285 | + #[tokio::test] |
| 286 | + async fn ask_rejects_whitespace_only_question() { |
| 287 | + let result = ask(" ", &mut std::io::sink()).await; |
| 288 | + assert!(result.is_err()); |
| 289 | + assert!(result |
| 290 | + .unwrap_err() |
| 291 | + .to_string() |
| 292 | + .contains("question cannot be empty")); |
| 293 | + } |
| 294 | + |
| 295 | + #[cfg(not(target_arch = "wasm32"))] |
| 296 | + #[tokio::test] |
| 297 | + async fn ask_handles_broken_pipe_gracefully() { |
| 298 | + let _guard = crate::test_utils::ENV_LOCK.lock().await; |
| 299 | + |
| 300 | + let mut server = mockito::Server::new_async().await; |
| 301 | + let _mock = server |
| 302 | + .mock("POST", "/api/unstable/docs-ai/chat") |
| 303 | + .with_status(200) |
| 304 | + .with_header("content-type", "text/event-stream") |
| 305 | + .with_body("data: {\"content\":\"Hello\"}\n\ndata: [DONE]\n\n") |
| 306 | + .create_async() |
| 307 | + .await; |
| 308 | + |
| 309 | + std::env::set_var( |
| 310 | + "PUP_DOCS_AI_URL", |
| 311 | + format!("{}/api/unstable/docs-ai/chat", server.url()), |
| 312 | + ); |
| 313 | + let result = ask("what is a monitor?", &mut BrokenPipeWriter).await; |
| 314 | + std::env::remove_var("PUP_DOCS_AI_URL"); |
| 315 | + |
| 316 | + assert!(result.is_ok(), "broken pipe should be silently handled"); |
| 317 | + } |
| 318 | + |
| 319 | + /// A writer whose flush always returns BrokenPipe, simulating a closed pipe. |
| 320 | + struct BrokenPipeWriter; |
| 321 | + |
| 322 | + impl Write for BrokenPipeWriter { |
| 323 | + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { |
| 324 | + Ok(buf.len()) |
| 325 | + } |
| 326 | + fn flush(&mut self) -> std::io::Result<()> { |
| 327 | + Err(std::io::Error::new( |
| 328 | + std::io::ErrorKind::BrokenPipe, |
| 329 | + "broken pipe", |
| 330 | + )) |
| 331 | + } |
| 332 | + } |
| 333 | +} |
0 commit comments