everruns_provider/driver_helpers.rs
1// Shared Chat Driver Helpers
2//
3// Common utilities extracted from individual LLM driver implementations
4// (Anthropic, Gemini, OpenAI) to eliminate duplication.
5//
6// See knowledge/foundations/llm-drivers.md for driver requirements.
7
8use crate::driver_registry::DiscoveredModel;
9use crate::error::{AgentLoopError, Result};
10use crate::url_validation::is_blocked_ip;
11use reqwest::StatusCode;
12use reqwest::dns::{Addrs, Name, Resolve, Resolving};
13use serde::de::DeserializeOwned;
14use std::sync::{Arc, OnceLock};
15use std::time::Duration;
16
17/// Placeholder text for audio content in providers that don't support audio input.
18pub const AUDIO_CONTENT_PLACEHOLDER: &str = "[Audio content not supported]";
19
20// ============================================================================
21// Shared HTTP clients (EVE-635)
22// ============================================================================
23
24/// Connect/TLS handshake timeout applied to every provider HTTP client.
25const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
26/// Per-read inactivity timeout for streaming chat clients. This bounds a
27/// silently stalled connection (no bytes for this long) without capping the
28/// total time a long, actively-streaming response may take — an overall
29/// `.timeout()` would do the latter and kill legitimate long streams. Set well
30/// above the agent loop's own stall timeout (EVE-531, ~120s) so it only acts as
31/// a transport-level backstop.
32const HTTP_STREAM_READ_TIMEOUT: Duration = Duration::from_secs(300);
33/// Overall request timeout for non-streaming reads (embeddings, vector store)
34/// so a hung response body cannot block a request indefinitely.
35const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
36/// Idle pooled-connection lifetime, so reused HTTP keep-alive/HTTP-2
37/// connections are eventually recycled.
38const HTTP_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
39/// Shorter idle lifetime for the streaming client. A provider/edge often closes
40/// idle keep-alive sockets after ~10-30s; reusing one the peer already closed is
41/// a dominant source of the mid-stream `error decoding response body` flake. The
42/// streaming reconnect layer (`stream_reconnect`) recovers from those, but
43/// recycling sooner avoids most of them outright — matching the official OpenAI
44/// SDK transport (httpx), whose default `keepalive_expiry` is 5s. Kept a little
45/// above 5s so genuinely rapid successive turns still reuse a warm connection.
46const HTTP_STREAM_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(15);
47/// DNS resolution is capped so a stuck resolver cannot stall a provider call
48/// past the outbound HTTP timeout. Mirrors `url_validation`'s lookup cap.
49const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
50
51/// SSRF-guarding DNS resolver for the shared provider HTTP clients (EVE-623).
52///
53/// Provider `base_url`s are org-configurable and validated only at create time,
54/// so a hostname that passed validation can later DNS-rebind to a private or
55/// cloud-metadata address at request time. These streaming/request drivers hold
56/// a `reqwest::Client` directly rather than routing each call through
57/// [`crate::EgressService`], so we enforce the same DNS-pinning contract
58/// (TM-API-013, TM-TOOL-018) inside the client itself: every resolved address is
59/// checked against [`is_blocked_ip`] and the connection is refused if any
60/// resolved IP is private/internal. Combined with redirects disabled, this keeps
61/// these clients from reaching `169.254.169.254`/loopback/RFC1918 regardless of
62/// what the configured URL or a 3xx `Location` later points at.
63struct SsrfGuardResolver;
64
65/// Boxed error type expected by reqwest's [`Resolving`] future. reqwest's own
66/// `BoxError` alias is crate-private, so we spell it out here.
67type DnsBoxError = Box<dyn std::error::Error + Send + Sync>;
68
69impl Resolve for SsrfGuardResolver {
70 fn resolve(&self, name: Name) -> Resolving {
71 let host = name.as_str().to_string();
72 Box::pin(async move {
73 // hyper strips the port before resolving; resolve with port 0 and let
74 // reqwest apply the URL's actual port. We only inspect the IPs here.
75 let lookup = tokio::time::timeout(
76 DNS_LOOKUP_TIMEOUT,
77 tokio::net::lookup_host(format!("{host}:0")),
78 )
79 .await
80 .map_err(|_| -> DnsBoxError {
81 Box::new(std::io::Error::new(
82 std::io::ErrorKind::TimedOut,
83 "DNS lookup timed out",
84 ))
85 })?
86 .map_err(|e| -> DnsBoxError { Box::new(e) })?;
87
88 let addrs: Vec<std::net::SocketAddr> = lookup.collect();
89 for addr in &addrs {
90 if is_blocked_ip(addr.ip()) {
91 tracing::warn!(
92 host = %host,
93 resolved_ip = %addr.ip(),
94 "Provider HTTP client blocked: hostname resolves to private/internal address"
95 );
96 return Err(Box::new(std::io::Error::other(format!(
97 "host {host} resolves to blocked address {} (private/internal)",
98 addr.ip()
99 ))) as DnsBoxError);
100 }
101 }
102 Ok(Box::new(addrs.into_iter()) as Addrs)
103 })
104 }
105}
106
107/// Apply the shared SSRF-hardening to a provider HTTP client builder (EVE-623):
108/// disable redirect following (a 3xx `Location` must never be auto-fetched, since
109/// it can point at an internal address) and install the [`SsrfGuardResolver`].
110fn harden_builder(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
111 builder
112 .redirect(reqwest::redirect::Policy::none())
113 .dns_resolver(Arc::new(SsrfGuardResolver))
114}
115
116/// Process-wide HTTP client shared by all streaming chat drivers.
117///
118/// `reqwest::Client` is internally reference-counted and built to be cloned and
119/// shared; it carries no per-request credentials (auth headers are attached per
120/// request), so one pool is safe to reuse across providers, credentials, and
121/// reasoning steps. Sharing it is what lets TCP/TLS handshakes and HTTP/2
122/// connections be reused across agent turns even though the driver structs are
123/// rebuilt on every step (EVE-635).
124pub fn shared_streaming_http_client() -> reqwest::Client {
125 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
126 CLIENT
127 .get_or_init(|| {
128 harden_builder(
129 reqwest::Client::builder()
130 .connect_timeout(HTTP_CONNECT_TIMEOUT)
131 .read_timeout(HTTP_STREAM_READ_TIMEOUT)
132 .pool_idle_timeout(HTTP_STREAM_POOL_IDLE_TIMEOUT),
133 )
134 .build()
135 // Fall back to a minimal but still SSRF-hardened client rather than
136 // a bare `Client::new()`, so a builder failure can never silently
137 // drop the redirect/DNS-pinning guard (EVE-623).
138 .unwrap_or_else(|_| {
139 harden_builder(reqwest::Client::builder())
140 .build()
141 .unwrap_or_else(|_| reqwest::Client::new())
142 })
143 })
144 .clone()
145}
146
147/// Process-wide HTTP client shared by non-streaming request/response drivers
148/// (embeddings, vector store). Uses an overall request timeout because these
149/// reads are bounded and must not hang forever.
150pub fn shared_request_http_client() -> reqwest::Client {
151 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
152 CLIENT
153 .get_or_init(|| {
154 harden_builder(
155 reqwest::Client::builder()
156 .connect_timeout(HTTP_CONNECT_TIMEOUT)
157 .timeout(HTTP_REQUEST_TIMEOUT)
158 .pool_idle_timeout(HTTP_POOL_IDLE_TIMEOUT),
159 )
160 .build()
161 // Fall back to a minimal but still SSRF-hardened client rather than
162 // a bare `Client::new()`, so a builder failure can never silently
163 // drop the redirect/DNS-pinning guard (EVE-623).
164 .unwrap_or_else(|_| {
165 harden_builder(reqwest::Client::builder())
166 .build()
167 .unwrap_or_else(|_| reqwest::Client::new())
168 })
169 })
170 .clone()
171}
172
173// ============================================================================
174// Data URL Parsing
175// ============================================================================
176
177/// Parsed data URL components (e.g., `data:image/jpeg;base64,/9j/4AAQ...`).
178#[derive(Debug, Clone)]
179pub struct ParsedDataUrl {
180 /// MIME type (e.g., "image/jpeg", "image/png")
181 pub media_type: String,
182 /// Base64-encoded data (without the `data:...;base64,` prefix)
183 pub data: String,
184}
185
186/// Parse a data URL into its media type and data components.
187///
188/// Handles formats like `data:<media_type>;base64,<data>` and `data:<media_type>,<data>`.
189/// The `;base64` suffix is stripped from the media type if present, but its presence
190/// is not enforced — callers should assume data may be base64-encoded.
191///
192/// Returns `None` if the URL doesn't start with `data:` or has no comma separator.
193/// Unlike the previous per-driver implementations, this does NOT silently
194/// fall back to `image/jpeg` on parse failure — callers handle fallback.
195pub fn parse_data_url(url: &str) -> Option<ParsedDataUrl> {
196 if !url.starts_with("data:") {
197 return None;
198 }
199
200 let parts: Vec<&str> = url.splitn(2, ',').collect();
201 if parts.len() != 2 {
202 return None;
203 }
204
205 let media_type = parts[0]
206 .trim_start_matches("data:")
207 .trim_end_matches(";base64")
208 .to_string();
209 let data = parts[1].to_string();
210
211 Some(ParsedDataUrl { media_type, data })
212}
213
214// ============================================================================
215// Error Detection Helpers
216// ============================================================================
217
218/// Check if an HTTP error indicates the request payload is too large.
219///
220/// Detects common patterns across LLM providers:
221/// - HTTP 413 Payload Too Large
222/// - HTTP 4xx with context length / token limit errors
223/// - Generic "too long" / "exceeds maximum" patterns (with token/context qualifiers)
224///
225/// Provider-specific patterns (must be lowercase) can be checked via `extra_patterns`.
226pub fn is_request_too_large(status: StatusCode, error_text: &str, extra_patterns: &[&str]) -> bool {
227 let error_lower = error_text.to_lowercase();
228
229 // HTTP 413 Payload Too Large (universal)
230 if status == StatusCode::PAYLOAD_TOO_LARGE {
231 return true;
232 }
233
234 // Only check text patterns for client errors
235 if status.is_client_error() {
236 // Generic patterns that apply across providers
237 if error_lower.contains("input is too long") || error_lower.contains("maximum context") {
238 return true;
239 }
240
241 // Require a token/context qualifier with "exceeds the maximum" to avoid false positives
242 if error_lower.contains("exceeds the maximum")
243 && (error_lower.contains("token") || error_lower.contains("context"))
244 {
245 return true;
246 }
247
248 // Provider-specific patterns (already lowercase, no allocation needed)
249 for pattern in extra_patterns {
250 if error_lower.contains(pattern) {
251 return true;
252 }
253 }
254 }
255
256 false
257}
258
259/// Anthropic-specific "request too large" error patterns (passed to `is_request_too_large`).
260pub const ANTHROPIC_TOO_LARGE_PATTERNS: &[&str] = &[
261 "prompt is too long",
262 "request size exceeded",
263 "context length",
264 "too many tokens",
265];
266
267/// Gemini-specific "request too large" error patterns (passed to `is_request_too_large`).
268pub const GEMINI_TOO_LARGE_PATTERNS: &[&str] = &[
269 "request payload size exceeds",
270 "content too large",
271 "token limit exceeded",
272];
273
274/// Check if an HTTP error indicates the model was not found.
275///
276/// Only matches on 404 status. Uses provider-specific patterns (must be lowercase)
277/// to avoid false positives on generic 404s (e.g., "Endpoint not found").
278pub fn is_model_not_found(status: StatusCode, error_text: &str, patterns: &[&str]) -> bool {
279 if status != StatusCode::NOT_FOUND {
280 return false;
281 }
282
283 let error_lower = error_text.to_lowercase();
284
285 // Provider-specific patterns (already lowercase, no allocation needed)
286 for pattern in patterns {
287 if error_lower.contains(pattern) {
288 return true;
289 }
290 }
291
292 false
293}
294
295/// Anthropic-specific model-not-found patterns.
296/// Matches `not_found_error` (Anthropic's error type) or `model` + `not found` together.
297pub const ANTHROPIC_NOT_FOUND_PATTERNS: &[&str] = &["not_found_error"];
298
299/// Gemini-specific model-not-found patterns.
300/// Gemini returns 404 with `"NOT_FOUND"` status or `"model"` in the message.
301pub const GEMINI_NOT_FOUND_PATTERNS: &[&str] = &["not_found", "model"];
302
303// ============================================================================
304// Model Discovery (/models endpoint)
305// ============================================================================
306
307/// Fetch and map a provider's `/models` catalog into [`DiscoveredModel`]s.
308///
309/// Extracts the skeleton shared by the OpenAI-compatible `/models` discovery
310/// implementations (Fireworks, OpenRouter, MAI/Foundry):
311/// 1. send the (already authenticated) request,
312/// 2. on a non-success status, drain the body to allow connection reuse and
313/// return [`models_api_status_error`] — unless the status is in
314/// `none_on_statuses`, in which case discovery is treated as unsupported and
315/// `Ok(None)` is returned,
316/// 3. deserialize the body into the provider-specific response type `T`,
317/// 4. apply the provider's `map` to produce the discovered models.
318///
319/// Error message prefixes are passed in so each provider keeps its exact
320/// user-facing wording.
321///
322/// Note: callers resolve the request through their runtime
323/// `ProviderEndpoint` first, so this stays agnostic to the auth scheme.
324pub async fn fetch_models<T, F>(
325 request: reqwest::RequestBuilder,
326 fetch_err_prefix: &str,
327 parse_err_prefix: &str,
328 none_on_statuses: &[StatusCode],
329 map: F,
330) -> Result<Option<Vec<DiscoveredModel>>>
331where
332 T: DeserializeOwned,
333 F: FnOnce(T) -> Vec<DiscoveredModel>,
334{
335 let response = request
336 .send()
337 .await
338 .map_err(|e| AgentLoopError::llm(format!("{fetch_err_prefix}: {e}")))?;
339
340 let status = response.status();
341 if !status.is_success() {
342 let _ = response.bytes().await; // drain body to allow connection reuse
343 if none_on_statuses.contains(&status) {
344 return Ok(None);
345 }
346 return Err(crate::openai_protocol::models_api_status_error(status));
347 }
348
349 let parsed: T = response
350 .json()
351 .await
352 .map_err(|e| AgentLoopError::llm(format!("{parse_err_prefix}: {e}")))?;
353
354 Ok(Some(map(parsed)))
355}
356
357// ============================================================================
358// Thinking Budget Constants
359// ============================================================================
360
361/// Thinking token budgets for Anthropic's extended thinking feature.
362/// Maps reasoning effort levels to token budgets.
363pub mod thinking_budget {
364 pub const LOW: u32 = 1024;
365 pub const MEDIUM: u32 = 4096;
366 pub const HIGH: u32 = 16384;
367 pub const XHIGH: u32 = 32768;
368
369 /// Map a reasoning effort string to a thinking budget.
370 pub fn from_effort(effort: &str) -> Option<u32> {
371 match effort.to_lowercase().as_str() {
372 "low" => Some(LOW),
373 "medium" => Some(MEDIUM),
374 "high" => Some(HIGH),
375 "xhigh" => Some(XHIGH),
376 _ => None,
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use std::str::FromStr;
385
386 #[test]
387 fn test_parse_data_url_valid() {
388 let result = parse_data_url("data:image/png;base64,iVBOR").unwrap();
389 assert_eq!(result.media_type, "image/png");
390 assert_eq!(result.data, "iVBOR");
391 }
392
393 #[test]
394 fn test_parse_data_url_jpeg() {
395 let result = parse_data_url("data:image/jpeg;base64,/9j/4AAQ").unwrap();
396 assert_eq!(result.media_type, "image/jpeg");
397 assert_eq!(result.data, "/9j/4AAQ");
398 }
399
400 #[test]
401 fn test_parse_data_url_not_data() {
402 assert!(parse_data_url("https://example.com/image.png").is_none());
403 }
404
405 #[test]
406 fn test_parse_data_url_no_comma() {
407 assert!(parse_data_url("data:image/jpeg;base64").is_none());
408 }
409
410 #[test]
411 fn test_is_request_too_large_413() {
412 assert!(is_request_too_large(StatusCode::PAYLOAD_TOO_LARGE, "", &[]));
413 }
414
415 #[test]
416 fn test_is_request_too_large_generic() {
417 assert!(is_request_too_large(
418 StatusCode::BAD_REQUEST,
419 "input is too long",
420 &[]
421 ));
422 }
423
424 #[test]
425 fn test_is_request_too_large_anthropic() {
426 assert!(is_request_too_large(
427 StatusCode::BAD_REQUEST,
428 "prompt is too long: 100000 tokens",
429 ANTHROPIC_TOO_LARGE_PATTERNS
430 ));
431 }
432
433 #[test]
434 fn test_is_request_too_large_gemini() {
435 assert!(is_request_too_large(
436 StatusCode::BAD_REQUEST,
437 "request payload size exceeds limit",
438 GEMINI_TOO_LARGE_PATTERNS
439 ));
440 }
441
442 #[test]
443 fn test_is_model_not_found_with_pattern() {
444 assert!(is_model_not_found(
445 StatusCode::NOT_FOUND,
446 r#"{"error":{"type":"not_found_error"}}"#,
447 ANTHROPIC_NOT_FOUND_PATTERNS
448 ));
449 }
450
451 #[test]
452 fn test_is_model_not_found_no_match_without_pattern() {
453 // Generic "not found" without matching patterns should NOT match
454 assert!(!is_model_not_found(
455 StatusCode::NOT_FOUND,
456 "Endpoint not found",
457 ANTHROPIC_NOT_FOUND_PATTERNS
458 ));
459 }
460
461 #[test]
462 fn test_is_model_not_found_not_404() {
463 assert!(!is_model_not_found(
464 StatusCode::BAD_REQUEST,
465 "model not found",
466 &[]
467 ));
468 }
469
470 #[test]
471 fn test_is_model_not_found_gemini() {
472 assert!(is_model_not_found(
473 StatusCode::NOT_FOUND,
474 r#"{"error":{"status":"NOT_FOUND","message":"model foo"}}"#,
475 GEMINI_NOT_FOUND_PATTERNS
476 ));
477 }
478
479 #[tokio::test]
480 async fn ssrf_resolver_blocks_loopback_literal() {
481 // A literal loopback "hostname" must be refused at resolve time so the
482 // shared provider clients can never connect to 127.0.0.1 (EVE-623).
483 let resolver = SsrfGuardResolver;
484 let name = Name::from_str("127.0.0.1").unwrap();
485 let result = resolver.resolve(name).await;
486 assert!(result.is_err(), "loopback literal should be blocked");
487 }
488
489 #[tokio::test]
490 async fn ssrf_resolver_blocks_link_local_metadata_literal() {
491 // The cloud metadata endpoint is the primary SSRF target.
492 let resolver = SsrfGuardResolver;
493 let name = Name::from_str("169.254.169.254").unwrap();
494 let result = resolver.resolve(name).await;
495 assert!(result.is_err(), "metadata IP should be blocked");
496 }
497
498 #[tokio::test]
499 async fn ssrf_resolver_blocks_private_rfc1918_literal() {
500 let resolver = SsrfGuardResolver;
501 for host in ["10.0.0.1", "192.168.1.1", "172.16.0.1"] {
502 let name = Name::from_str(host).unwrap();
503 assert!(
504 resolver.resolve(name).await.is_err(),
505 "{host} (RFC1918) should be blocked"
506 );
507 }
508 }
509
510 #[tokio::test]
511 async fn ssrf_resolver_allows_public_literal() {
512 // A public IP literal resolves to itself and must be allowed through.
513 let resolver = SsrfGuardResolver;
514 let name = Name::from_str("1.1.1.1").unwrap();
515 let result = resolver.resolve(name).await;
516 assert!(result.is_ok(), "public IP must be allowed");
517 }
518
519 #[test]
520 fn shared_clients_build_without_panicking() {
521 // Exercises the hardened builders end to end.
522 let _ = shared_streaming_http_client();
523 let _ = shared_request_http_client();
524 }
525
526 #[test]
527 fn test_thinking_budget_from_effort() {
528 assert_eq!(thinking_budget::from_effort("low"), Some(1024));
529 assert_eq!(thinking_budget::from_effort("medium"), Some(4096));
530 assert_eq!(thinking_budget::from_effort("HIGH"), Some(16384));
531 assert_eq!(thinking_budget::from_effort("xhigh"), Some(32768));
532 assert_eq!(thinking_budget::from_effort("unknown"), None);
533 }
534}