Skip to main content

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}