Kill reqwest — minimal HTTP client on raw hyper + tokio-rustls

New src/agent/api/http.rs: ~240 lines, supports GET/POST, JSON/form
bodies, SSE streaming via chunk(), TLS via rustls. No tracing dep.

Removes reqwest from the main crate and telegram channel crate.
Cargo.lock drops ~900 lines of transitive dependencies.

tracing now only pulled in by tui-markdown.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 12:50:40 -04:00
parent a421c3c9f3
commit 1cf4f504c0
9 changed files with 360 additions and 915 deletions

View file

@ -6,6 +6,7 @@
// Diagnostics: anomalies always logged to debug panel.
// Set POC_DEBUG=1 for verbose per-turn logging.
pub mod http;
pub mod parsing;
pub mod types;
mod openai;
@ -13,9 +14,10 @@ mod openai;
pub use types::*;
use anyhow::Result;
use reqwest::Client;
use std::time::{Duration, Instant};
use self::http::{HttpClient, HttpResponse};
use tokio::sync::mpsc;
use crate::agent::tools::{self as agent_tools, summarize_args, ActiveToolCall};
@ -77,7 +79,7 @@ pub enum StreamEvent {
#[derive(Clone)]
pub struct ApiClient {
client: Client,
client: HttpClient,
api_key: String,
pub model: String,
base_url: String,
@ -85,11 +87,10 @@ pub struct ApiClient {
impl ApiClient {
pub fn new(base_url: &str, api_key: &str, model: &str) -> Self {
let client = Client::builder()
let client = HttpClient::builder()
.connect_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(600))
.build()
.expect("failed to build HTTP client");
.build();
Self {
client,
@ -198,14 +199,14 @@ impl ApiClient {
/// Send an HTTP request and check for errors. Shared by both backends.
pub(crate) async fn send_and_check(
client: &Client,
client: &HttpClient,
url: &str,
body: &impl serde::Serialize,
auth_header: (&str, &str),
extra_headers: &[(&str, &str)],
debug_label: &str,
request_json: Option<&str>,
) -> Result<reqwest::Response> {
) -> Result<HttpResponse> {
let debug = std::env::var("POC_DEBUG").is_ok();
let start = Instant::now();
@ -219,49 +220,36 @@ pub(crate) async fn send_and_check(
);
}
let mut req = client
.post(url)
.header(auth_header.0, auth_header.1)
.header("Content-Type", "application/json");
let mut headers: Vec<(&str, &str)> = Vec::with_capacity(extra_headers.len() + 1);
headers.push(auth_header);
headers.extend_from_slice(extra_headers);
for (name, value) in extra_headers {
req = req.header(*name, *value);
}
let response = req
.json(body)
.send()
let response = client
.send_json("POST", url, &headers, body)
.await
.map_err(|e| {
let cause = if e.is_connect() {
let msg = e.to_string();
let cause = if msg.contains("connect timeout") || msg.contains("TCP connect") {
"connection refused"
} else if e.is_timeout() {
} else if msg.contains("request timeout") {
"request timed out"
} else if e.is_request() {
"request error"
} else {
"unknown"
"request error"
};
anyhow::anyhow!("{} ({}): {:?}", cause, url, e.without_url())
anyhow::anyhow!("{} ({}): {}", cause, url, msg)
})?;
let status = response.status();
let elapsed = start.elapsed();
if debug {
// Log interesting response headers
let headers = response.headers();
for name in [
"x-ratelimit-remaining",
"x-ratelimit-limit",
"x-request-id",
] {
if let Some(val) = headers.get(name) {
dbglog!(
"header {}: {}",
name,
val.to_str().unwrap_or("?")
);
if let Some(val) = response.header(name) {
dbglog!("header {}: {}", name, val);
}
}
}
@ -357,7 +345,7 @@ impl SseReader {
/// Ok(None) when the stream ends or [DONE] is received.
pub(crate) async fn next_event(
&mut self,
response: &mut reqwest::Response,
response: &mut HttpResponse,
) -> Result<Option<serde_json::Value>> {
loop {
// Drain complete lines from the buffer before reading more chunks