Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src-tauri/src/agents/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,7 @@ impl AgentRunner {
"stream": true,
});

let client = reqwest::Client::new();
let client = crate::services::http_client::streaming_client()?;
let mut request = client
.post(endpoint)
.header(CONTENT_TYPE, "application/json")
Expand Down Expand Up @@ -1207,7 +1207,7 @@ impl AgentRunner {
format!("{}/messages", base_url.trim_end_matches('/'))
};

let client = reqwest::Client::new();
let client = crate::services::http_client::streaming_client()?;
let mut request = client
.post(&endpoint)
.header(CONTENT_TYPE, "application/json")
Expand Down
12 changes: 6 additions & 6 deletions src-tauri/src/services/chat_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::{
models::Message,
};

use super::{now_rfc3339, provider_service};
use super::{http_client, now_rfc3339, provider_service};

/// Keywords that trigger full visual/preview system prompt injection
const VISUAL_KEYWORDS: &[&str] = &[
Expand Down Expand Up @@ -334,7 +334,7 @@ async fn send_openai_compatible(
on_token: &Channel<String>,
cancel_token: &CancellationToken,
) -> AppResult<String> {
let client = reqwest::Client::new();
let client = http_client::streaming_client()?;
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));

let messages: Vec<Value> = history
Expand Down Expand Up @@ -413,7 +413,7 @@ async fn send_anthropic(
on_token: &Channel<String>,
cancel_token: &CancellationToken,
) -> AppResult<String> {
let client = reqwest::Client::new();
let client = http_client::streaming_client()?;

let (system_msgs, chat_msgs): (Vec<_>, Vec<_>) =
history.iter().partition(|m| m.role == "system");
Expand Down Expand Up @@ -836,7 +836,7 @@ async fn generate_title_openai(
model: &str,
messages: &[Value],
) -> AppResult<String> {
let client = reqwest::Client::new();
let client = http_client::request_client()?;
let endpoint = format!(
"{}/chat/completions",
provider.base_url.trim_end_matches('/')
Expand Down Expand Up @@ -909,7 +909,7 @@ async fn generate_title_anthropic(
format!("{}/messages", provider.base_url.trim_end_matches('/'))
};

let client = reqwest::Client::new();
let client = http_client::request_client()?;
let mut request = client
.post(&endpoint)
.header(CONTENT_TYPE, "application/json")
Expand Down Expand Up @@ -1022,7 +1022,7 @@ pub async fn generate_excalidraw(

messages.push(serde_json::json!({"role": "user", "content": prompt}));

let client = reqwest::Client::new();
let client = http_client::request_client()?;
let endpoint = format!("{}/chat/completions", provider.base_url.trim_end_matches('/'));

let payload = serde_json::json!({
Expand Down
88 changes: 88 additions & 0 deletions src-tauri/src/services/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Shared HTTP client builder for outbound LLM provider requests.
//!
//! All provider-facing HTTP calls (chat completions, model listings,
//! title generation, agent tool runs) go through these constructors.
//! This guarantees:
//!
//! - **Connect timeout** so a dead provider host fails fast (10s).
//! - **Streaming-aware request timeouts** — streaming endpoints get a
//! long ceiling (10 min) so SSE doesn't get cut, while non-streaming
//! calls get a sane upper bound (2 min).
//! - **Read timeout** to detect stalled streams between chunks (60s).
//! - **Identifying User-Agent** so providers (and the user's own
//! proxy/firewall) can attribute traffic to the app.
//!
//! Without this, `reqwest::Client::new()` produces a client with no
//! timeouts at all — a network blip or a silently-rate-limited provider
//! freezes the agent indefinitely.

use std::time::Duration;

use reqwest::Client;

use crate::error::{AppError, AppResult};

const USER_AGENT: &str = concat!("enowX-Coder/", env!("CARGO_PKG_VERSION"));

/// Connect timeout for all outbound HTTP — applies to TCP + TLS handshake.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);

/// Per-chunk read timeout for streaming responses. If we don't see a byte
/// from the upstream provider in this window, the stream is considered dead.
const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(60);

/// Total request timeout for non-streaming calls (model listings,
/// title generation, etc.). Generous, but bounded.
const NON_STREAMING_TIMEOUT: Duration = Duration::from_secs(120);

/// Hard upper bound for streaming requests. SSE streams shouldn't outlast
/// this — if they do, something is wrong upstream.
const STREAMING_TIMEOUT: Duration = Duration::from_secs(600);

/// Build the shared `reqwest::Client` for streaming LLM responses.
///
/// Keeps a long total ceiling so multi-minute completions can finish,
/// but still bounds connect + per-read so dead connections fail fast.
pub fn streaming_client() -> AppResult<Client> {
Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(CONNECT_TIMEOUT)
.read_timeout(STREAM_READ_TIMEOUT)
.timeout(STREAMING_TIMEOUT)
.build()
.map_err(|e| AppError::Internal(format!("Failed to build streaming HTTP client: {e}")))
}

/// Build the shared `reqwest::Client` for short, non-streaming requests
/// (listing models, generating titles, single-shot completions).
pub fn request_client() -> AppResult<Client> {
Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(CONNECT_TIMEOUT)
.timeout(NON_STREAMING_TIMEOUT)
.build()
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {e}")))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn streaming_client_builds() {
let client = streaming_client();
assert!(client.is_ok(), "streaming_client should build cleanly");
}

#[test]
fn request_client_builds() {
let client = request_client();
assert!(client.is_ok(), "request_client should build cleanly");
}

#[test]
fn user_agent_includes_version() {
assert!(USER_AGENT.starts_with("enowX-Coder/"));
assert!(USER_AGENT.len() > "enowX-Coder/".len());
}
}
1 change: 1 addition & 0 deletions src-tauri/src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod agent_service;
pub mod chat_service;
pub mod drawing_service;
pub mod http_client;
pub mod model_service;
pub mod project_service;
pub mod provider_model_service;
Expand Down
12 changes: 5 additions & 7 deletions src-tauri/src/services/model_service.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
use reqwest::Client;
use serde::Deserialize;

use crate::{
error::{AppError, AppResult},
models::Provider,
};
use crate::error::{AppError, AppResult};
use crate::models::Provider;
use crate::services::http_client;

#[derive(Debug, Deserialize)]
struct ModelList {
Expand Down Expand Up @@ -56,7 +54,7 @@ pub async fn list_models(provider: &Provider) -> AppResult<Vec<String>> {

async fn fetch_openai_models(base_url: &str, api_key: Option<&str>) -> AppResult<Vec<String>> {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = Client::new();
let client = http_client::request_client()?;
let mut req = client.get(&url);

if let Some(key) = api_key {
Expand Down Expand Up @@ -98,7 +96,7 @@ async fn fetch_anthropic_models(
use_x_api_key: bool,
) -> AppResult<Vec<String>> {
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = Client::new();
let client = http_client::request_client()?;
let mut req = client
.get(&url)
.header("anthropic-version", "2023-06-01");
Expand Down
8 changes: 6 additions & 2 deletions src-tauri/src/tools/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::time::Duration;
use globset::GlobSet;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::process::Command;
use walkdir::WalkDir;

Expand Down Expand Up @@ -34,7 +35,10 @@ fn sensitive_globset() -> &'static GlobSet {
}
builder.build().unwrap_or_else(|error| {
log::error!("sensitive_globset build failed: {error} — all file access will require permission");
GlobSetBuilder::new().build().expect("empty GlobSet always builds")
match GlobSetBuilder::new().build() {
Ok(globset) => globset,
Err(inner_error) => panic!("empty GlobSet build failed: {inner_error}"),
}
})
})
}
Expand Down Expand Up @@ -477,7 +481,7 @@ impl ToolExecutor {
let query = input["query"]
.as_str()
.ok_or_else(|| AppError::Validation("Missing 'query' field".to_string()))?;
let client = reqwest::Client::new();
let client = crate::services::http_client::request_client()?;
let url = format!(
"https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
urlencoding::encode(query)
Expand Down
Loading