Skip to content

Commit 3f108e2

Browse files
enowdevenowdev
authored andcommitted
chore(merge): update PR #31 with latest main
# Conflicts: # src-tauri/src/services/chat_service.rs
2 parents 5c10f21 + 0c76ade commit 3f108e2

7 files changed

Lines changed: 251 additions & 57 deletions

File tree

src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ globset = "0.4"
3636
regex = "1"
3737
urlencoding = "2"
3838

39+
[target.'cfg(unix)'.dependencies]
40+
libc = "0.2"
41+
3942
[profile.release]
4043
opt-level = 3
4144
lto = true

src-tauri/src/agents/runner.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,7 @@ impl AgentRunner {
11241124
"stream": true,
11251125
});
11261126

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

1210-
let client = reqwest::Client::new();
1210+
let client = crate::services::http_client::streaming_client()?;
12111211
let mut request = client
12121212
.post(&endpoint)
12131213
.header(CONTENT_TYPE, "application/json")

src-tauri/src/services/chat_service.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::{
1717
models::Message,
1818
};
1919

20-
use super::{now_rfc3339, provider_service};
20+
use super::{http_client, now_rfc3339, provider_service};
2121

2222
/// Keywords that trigger full visual/preview system prompt injection
2323
const VISUAL_KEYWORDS: &[&str] = &[
@@ -383,7 +383,7 @@ async fn send_openai_compatible(
383383
on_token: &Channel<String>,
384384
cancel_token: &CancellationToken,
385385
) -> AppResult<(String, Option<TokenUsage>)> {
386-
let client = reqwest::Client::new();
386+
let client = http_client::streaming_client()?;
387387
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));
388388

389389
let messages: Vec<Value> = history
@@ -466,7 +466,7 @@ async fn send_anthropic(
466466
on_token: &Channel<String>,
467467
cancel_token: &CancellationToken,
468468
) -> AppResult<(String, Option<TokenUsage>)> {
469-
let client = reqwest::Client::new();
469+
let client = http_client::streaming_client()?;
470470

471471
let (system_msgs, chat_msgs): (Vec<_>, Vec<_>) =
472472
history.iter().partition(|m| m.role == "system");
@@ -933,7 +933,7 @@ async fn generate_title_openai(
933933
model: &str,
934934
messages: &[Value],
935935
) -> AppResult<String> {
936-
let client = reqwest::Client::new();
936+
let client = http_client::request_client()?;
937937
let endpoint = format!(
938938
"{}/chat/completions",
939939
provider.base_url.trim_end_matches('/')
@@ -1006,7 +1006,7 @@ async fn generate_title_anthropic(
10061006
format!("{}/messages", provider.base_url.trim_end_matches('/'))
10071007
};
10081008

1009-
let client = reqwest::Client::new();
1009+
let client = http_client::request_client()?;
10101010
let mut request = client
10111011
.post(&endpoint)
10121012
.header(CONTENT_TYPE, "application/json")
@@ -1119,7 +1119,7 @@ pub async fn generate_excalidraw(
11191119

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

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

11251125
let payload = serde_json::json!({
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//! Shared HTTP client builder for outbound LLM provider requests.
2+
//!
3+
//! All provider-facing HTTP calls (chat completions, model listings,
4+
//! title generation, agent tool runs) go through these constructors.
5+
//! This guarantees:
6+
//!
7+
//! - **Connect timeout** so a dead provider host fails fast (10s).
8+
//! - **Streaming-aware request timeouts** — streaming endpoints get a
9+
//! long ceiling (10 min) so SSE doesn't get cut, while non-streaming
10+
//! calls get a sane upper bound (2 min).
11+
//! - **Read timeout** to detect stalled streams between chunks (60s).
12+
//! - **Identifying User-Agent** so providers (and the user's own
13+
//! proxy/firewall) can attribute traffic to the app.
14+
//!
15+
//! Without this, `reqwest::Client::new()` produces a client with no
16+
//! timeouts at all — a network blip or a silently-rate-limited provider
17+
//! freezes the agent indefinitely.
18+
19+
use std::time::Duration;
20+
21+
use reqwest::Client;
22+
23+
use crate::error::{AppError, AppResult};
24+
25+
const USER_AGENT: &str = concat!("enowX-Coder/", env!("CARGO_PKG_VERSION"));
26+
27+
/// Connect timeout for all outbound HTTP — applies to TCP + TLS handshake.
28+
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
29+
30+
/// Per-chunk read timeout for streaming responses. If we don't see a byte
31+
/// from the upstream provider in this window, the stream is considered dead.
32+
const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(60);
33+
34+
/// Total request timeout for non-streaming calls (model listings,
35+
/// title generation, etc.). Generous, but bounded.
36+
const NON_STREAMING_TIMEOUT: Duration = Duration::from_secs(120);
37+
38+
/// Hard upper bound for streaming requests. SSE streams shouldn't outlast
39+
/// this — if they do, something is wrong upstream.
40+
const STREAMING_TIMEOUT: Duration = Duration::from_secs(600);
41+
42+
/// Build the shared `reqwest::Client` for streaming LLM responses.
43+
///
44+
/// Keeps a long total ceiling so multi-minute completions can finish,
45+
/// but still bounds connect + per-read so dead connections fail fast.
46+
pub fn streaming_client() -> AppResult<Client> {
47+
Client::builder()
48+
.user_agent(USER_AGENT)
49+
.connect_timeout(CONNECT_TIMEOUT)
50+
.read_timeout(STREAM_READ_TIMEOUT)
51+
.timeout(STREAMING_TIMEOUT)
52+
.build()
53+
.map_err(|e| AppError::Internal(format!("Failed to build streaming HTTP client: {e}")))
54+
}
55+
56+
/// Build the shared `reqwest::Client` for short, non-streaming requests
57+
/// (listing models, generating titles, single-shot completions).
58+
pub fn request_client() -> AppResult<Client> {
59+
Client::builder()
60+
.user_agent(USER_AGENT)
61+
.connect_timeout(CONNECT_TIMEOUT)
62+
.timeout(NON_STREAMING_TIMEOUT)
63+
.build()
64+
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {e}")))
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
#[test]
72+
fn streaming_client_builds() {
73+
let client = streaming_client();
74+
assert!(client.is_ok(), "streaming_client should build cleanly");
75+
}
76+
77+
#[test]
78+
fn request_client_builds() {
79+
let client = request_client();
80+
assert!(client.is_ok(), "request_client should build cleanly");
81+
}
82+
83+
#[test]
84+
fn user_agent_includes_version() {
85+
assert!(USER_AGENT.starts_with("enowX-Coder/"));
86+
assert!(USER_AGENT.len() > "enowX-Coder/".len());
87+
}
88+
}

src-tauri/src/services/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod agent_service;
22
pub mod chat_service;
33
pub mod drawing_service;
4+
pub mod http_client;
45
pub mod model_service;
56
pub mod project_service;
67
pub mod provider_model_service;

src-tauri/src/services/model_service.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
use reqwest::Client;
21
use serde::Deserialize;
32

4-
use crate::{
5-
error::{AppError, AppResult},
6-
models::Provider,
7-
};
3+
use crate::error::{AppError, AppResult};
4+
use crate::models::Provider;
5+
use crate::services::http_client;
86

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

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

6260
if let Some(key) = api_key {
@@ -98,7 +96,7 @@ async fn fetch_anthropic_models(
9896
use_x_api_key: bool,
9997
) -> AppResult<Vec<String>> {
10098
let url = format!("{}/models", base_url.trim_end_matches('/'));
101-
let client = Client::new();
99+
let client = http_client::request_client()?;
102100
let mut req = client
103101
.get(&url)
104102
.header("anthropic-version", "2023-06-01");

0 commit comments

Comments
 (0)