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 specs/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 attach auth on `request` themselves (via
323/// [`crate::openai_protocol::apply_models_api_auth`] or an awaited
324/// `AuthHeaderProvider`), so this stays agnostic to the auth scheme.
325pub async fn fetch_models<T, F>(
326 request: reqwest::RequestBuilder,
327 fetch_err_prefix: &str,
328 parse_err_prefix: &str,
329 none_on_statuses: &[StatusCode],
330 map: F,
331) -> Result<Option<Vec<DiscoveredModel>>>
332where
333 T: DeserializeOwned,
334 F: FnOnce(T) -> Vec<DiscoveredModel>,
335{
336 let response = request
337 .send()
338 .await
339 .map_err(|e| AgentLoopError::llm(format!("{fetch_err_prefix}: {e}")))?;
340
341 let status = response.status();
342 if !status.is_success() {
343 let _ = response.bytes().await; // drain body to allow connection reuse
344 if none_on_statuses.contains(&status) {
345 return Ok(None);
346 }
347 return Err(crate::openai_protocol::models_api_status_error(status));
348 }
349
350 let parsed: T = response
351 .json()
352 .await
353 .map_err(|e| AgentLoopError::llm(format!("{parse_err_prefix}: {e}")))?;
354
355 Ok(Some(map(parsed)))
356}
357
358// ============================================================================
359// Thinking Budget Constants
360// ============================================================================
361
362/// Thinking token budgets for Anthropic's extended thinking feature.
363/// Maps reasoning effort levels to token budgets.
364pub mod thinking_budget {
365 pub const LOW: u32 = 1024;
366 pub const MEDIUM: u32 = 4096;
367 pub const HIGH: u32 = 16384;
368 pub const XHIGH: u32 = 32768;
369
370 /// Map a reasoning effort string to a thinking budget.
371 pub fn from_effort(effort: &str) -> Option<u32> {
372 match effort.to_lowercase().as_str() {
373 "low" => Some(LOW),
374 "medium" => Some(MEDIUM),
375 "high" => Some(HIGH),
376 "xhigh" => Some(XHIGH),
377 _ => None,
378 }
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::str::FromStr;
386
387 #[test]
388 fn test_parse_data_url_valid() {
389 let result = parse_data_url("data:image/png;base64,iVBOR").unwrap();
390 assert_eq!(result.media_type, "image/png");
391 assert_eq!(result.data, "iVBOR");
392 }
393
394 #[test]
395 fn test_parse_data_url_jpeg() {
396 let result = parse_data_url("data:image/jpeg;base64,/9j/4AAQ").unwrap();
397 assert_eq!(result.media_type, "image/jpeg");
398 assert_eq!(result.data, "/9j/4AAQ");
399 }
400
401 #[test]
402 fn test_parse_data_url_not_data() {
403 assert!(parse_data_url("https://example.com/image.png").is_none());
404 }
405
406 #[test]
407 fn test_parse_data_url_no_comma() {
408 assert!(parse_data_url("data:image/jpeg;base64").is_none());
409 }
410
411 #[test]
412 fn test_is_request_too_large_413() {
413 assert!(is_request_too_large(StatusCode::PAYLOAD_TOO_LARGE, "", &[]));
414 }
415
416 #[test]
417 fn test_is_request_too_large_generic() {
418 assert!(is_request_too_large(
419 StatusCode::BAD_REQUEST,
420 "input is too long",
421 &[]
422 ));
423 }
424
425 #[test]
426 fn test_is_request_too_large_anthropic() {
427 assert!(is_request_too_large(
428 StatusCode::BAD_REQUEST,
429 "prompt is too long: 100000 tokens",
430 ANTHROPIC_TOO_LARGE_PATTERNS
431 ));
432 }
433
434 #[test]
435 fn test_is_request_too_large_gemini() {
436 assert!(is_request_too_large(
437 StatusCode::BAD_REQUEST,
438 "request payload size exceeds limit",
439 GEMINI_TOO_LARGE_PATTERNS
440 ));
441 }
442
443 #[test]
444 fn test_is_model_not_found_with_pattern() {
445 assert!(is_model_not_found(
446 StatusCode::NOT_FOUND,
447 r#"{"error":{"type":"not_found_error"}}"#,
448 ANTHROPIC_NOT_FOUND_PATTERNS
449 ));
450 }
451
452 #[test]
453 fn test_is_model_not_found_no_match_without_pattern() {
454 // Generic "not found" without matching patterns should NOT match
455 assert!(!is_model_not_found(
456 StatusCode::NOT_FOUND,
457 "Endpoint not found",
458 ANTHROPIC_NOT_FOUND_PATTERNS
459 ));
460 }
461
462 #[test]
463 fn test_is_model_not_found_not_404() {
464 assert!(!is_model_not_found(
465 StatusCode::BAD_REQUEST,
466 "model not found",
467 &[]
468 ));
469 }
470
471 #[test]
472 fn test_is_model_not_found_gemini() {
473 assert!(is_model_not_found(
474 StatusCode::NOT_FOUND,
475 r#"{"error":{"status":"NOT_FOUND","message":"model foo"}}"#,
476 GEMINI_NOT_FOUND_PATTERNS
477 ));
478 }
479
480 #[tokio::test]
481 async fn ssrf_resolver_blocks_loopback_literal() {
482 // A literal loopback "hostname" must be refused at resolve time so the
483 // shared provider clients can never connect to 127.0.0.1 (EVE-623).
484 let resolver = SsrfGuardResolver;
485 let name = Name::from_str("127.0.0.1").unwrap();
486 let result = resolver.resolve(name).await;
487 assert!(result.is_err(), "loopback literal should be blocked");
488 }
489
490 #[tokio::test]
491 async fn ssrf_resolver_blocks_link_local_metadata_literal() {
492 // The cloud metadata endpoint is the primary SSRF target.
493 let resolver = SsrfGuardResolver;
494 let name = Name::from_str("169.254.169.254").unwrap();
495 let result = resolver.resolve(name).await;
496 assert!(result.is_err(), "metadata IP should be blocked");
497 }
498
499 #[tokio::test]
500 async fn ssrf_resolver_blocks_private_rfc1918_literal() {
501 let resolver = SsrfGuardResolver;
502 for host in ["10.0.0.1", "192.168.1.1", "172.16.0.1"] {
503 let name = Name::from_str(host).unwrap();
504 assert!(
505 resolver.resolve(name).await.is_err(),
506 "{host} (RFC1918) should be blocked"
507 );
508 }
509 }
510
511 #[tokio::test]
512 async fn ssrf_resolver_allows_public_literal() {
513 // A public IP literal resolves to itself and must be allowed through.
514 let resolver = SsrfGuardResolver;
515 let name = Name::from_str("1.1.1.1").unwrap();
516 let result = resolver.resolve(name).await;
517 assert!(result.is_ok(), "public IP must be allowed");
518 }
519
520 #[test]
521 fn shared_clients_build_without_panicking() {
522 // Exercises the hardened builders end to end.
523 let _ = shared_streaming_http_client();
524 let _ = shared_request_http_client();
525 }
526
527 #[test]
528 fn test_thinking_budget_from_effort() {
529 assert_eq!(thinking_budget::from_effort("low"), Some(1024));
530 assert_eq!(thinking_budget::from_effort("medium"), Some(4096));
531 assert_eq!(thinking_budget::from_effort("HIGH"), Some(16384));
532 assert_eq!(thinking_budget::from_effort("xhigh"), Some(32768));
533 assert_eq!(thinking_budget::from_effort("unknown"), None);
534 }
535}