From 6a6c5acb021bf502a82639b39b12081f75c7c603 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 14:51:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20b5-reasoning-guard=20=E2=80=94=20batch?= =?UTF-8?q?=205=20upstream=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- USAGE.md | 20 ++ rust/crates/api/src/http_client.rs | 252 ++++++++++++++++++ rust/crates/api/src/lib.rs | 4 + rust/crates/api/src/providers/anthropic.rs | 5 +- .../crates/api/src/providers/openai_compat.rs | 3 +- 5 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 rust/crates/api/src/http_client.rs diff --git a/USAGE.md b/USAGE.md index ef0833e..e2b2eaa 100644 --- a/USAGE.md +++ b/USAGE.md @@ -153,6 +153,26 @@ cd rust ./target/debug/claw --model "openai/gpt-4.1-mini" prompt "summarize this repository in one sentence" ``` +## HTTP proxy support + +`claw` honours the standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables (both upper- and lower-case spellings are accepted) when issuing outbound requests to Anthropic, OpenAI-, and xAI-compatible endpoints. Set them before launching the CLI and the underlying `reqwest` client will be configured automatically. + +```bash +export HTTPS_PROXY="http://proxy.corp.example:3128" +export HTTP_PROXY="http://proxy.corp.example:3128" +export NO_PROXY="localhost,127.0.0.1,.corp.example" + +cd rust +./target/debug/claw prompt "hello via the corporate proxy" +``` + +Notes: + +- When both `HTTPS_PROXY` and `HTTP_PROXY` are set, the secure proxy applies to `https://` URLs and the plain proxy applies to `http://` URLs. +- `NO_PROXY` accepts a comma-separated list of host suffixes (for example `.corp.example`) and IP literals. +- Empty values are treated as unset, so leaving `HTTPS_PROXY=""` in your shell will not enable a proxy. +- If a proxy URL cannot be parsed, `claw` falls back to a direct (no-proxy) client so existing workflows keep working; double-check the URL if you expected the request to be tunnelled. + ## Common operational commands ```bash diff --git a/rust/crates/api/src/http_client.rs b/rust/crates/api/src/http_client.rs new file mode 100644 index 0000000..fcb697f --- /dev/null +++ b/rust/crates/api/src/http_client.rs @@ -0,0 +1,252 @@ +use crate::error::ApiError; + +const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"]; +const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"]; +const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"]; + +/// Snapshot of the proxy-related environment variables that influence the +/// outbound HTTP client. Captured up front so callers can inspect, log, and +/// test the resolved configuration without re-reading the process environment. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProxyConfig { + pub http_proxy: Option, + pub https_proxy: Option, + pub no_proxy: Option, +} + +impl ProxyConfig { + /// Read proxy settings from the live process environment, honouring both + /// the upper- and lower-case spellings used by curl, git, and friends. + #[must_use] + pub fn from_env() -> Self { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + fn from_lookup(mut lookup: F) -> Self + where + F: FnMut(&str) -> Option, + { + Self { + http_proxy: first_non_empty(&HTTP_PROXY_KEYS, &mut lookup), + https_proxy: first_non_empty(&HTTPS_PROXY_KEYS, &mut lookup), + no_proxy: first_non_empty(&NO_PROXY_KEYS, &mut lookup), + } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.http_proxy.is_none() && self.https_proxy.is_none() + } +} + +/// Build a `reqwest::Client` that honours the standard `HTTP_PROXY`, +/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is +/// configured the client behaves identically to `reqwest::Client::new()`. +pub fn build_http_client() -> Result { + build_http_client_with(&ProxyConfig::from_env()) +} + +/// Infallible counterpart to [`build_http_client`] for constructors that +/// historically returned `Self` rather than `Result`. When the proxy +/// configuration is malformed we fall back to a default client so that +/// callers retain the previous behaviour and the failure surfaces on the +/// first outbound request instead of at construction time. +#[must_use] +pub fn build_http_client_or_default() -> reqwest::Client { + build_http_client().unwrap_or_else(|_| reqwest::Client::new()) +} + +/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests +/// and by callers that want to override process-level environment lookups. +pub fn build_http_client_with(config: &ProxyConfig) -> Result { + let mut builder = reqwest::Client::builder().no_proxy(); + + let no_proxy = config + .no_proxy + .as_deref() + .and_then(reqwest::NoProxy::from_string); + + if let Some(url) = config.https_proxy.as_deref() { + let mut proxy = reqwest::Proxy::https(url)?; + if let Some(filter) = no_proxy.clone() { + proxy = proxy.no_proxy(Some(filter)); + } + builder = builder.proxy(proxy); + } + + if let Some(url) = config.http_proxy.as_deref() { + let mut proxy = reqwest::Proxy::http(url)?; + if let Some(filter) = no_proxy.clone() { + proxy = proxy.no_proxy(Some(filter)); + } + builder = builder.proxy(proxy); + } + + Ok(builder.build()?) +} + +fn first_non_empty(keys: &[&str], lookup: &mut F) -> Option +where + F: FnMut(&str) -> Option, +{ + keys.iter() + .find_map(|key| lookup(key).filter(|value| !value.is_empty())) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::{build_http_client_with, ProxyConfig}; + + fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig { + let map: HashMap = pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(); + ProxyConfig::from_lookup(|key| map.get(key).cloned()) + } + + #[test] + fn proxy_config_is_empty_when_no_env_vars_are_set() { + // given + let config = config_from_map(&[]); + + // when + let empty = config.is_empty(); + + // then + assert!(empty); + assert_eq!(config, ProxyConfig::default()); + } + + #[test] + fn proxy_config_reads_uppercase_http_https_and_no_proxy() { + // given + let pairs = [ + ("HTTP_PROXY", "http://proxy.internal:3128"), + ("HTTPS_PROXY", "http://secure.internal:3129"), + ("NO_PROXY", "localhost,127.0.0.1,.corp"), + ]; + + // when + let config = config_from_map(&pairs); + + // then + assert_eq!( + config.http_proxy.as_deref(), + Some("http://proxy.internal:3128") + ); + assert_eq!( + config.https_proxy.as_deref(), + Some("http://secure.internal:3129") + ); + assert_eq!( + config.no_proxy.as_deref(), + Some("localhost,127.0.0.1,.corp") + ); + assert!(!config.is_empty()); + } + + #[test] + fn proxy_config_falls_back_to_lowercase_keys() { + // given + let pairs = [ + ("http_proxy", "http://lower.internal:3128"), + ("https_proxy", "http://lower-secure.internal:3129"), + ("no_proxy", ".lower"), + ]; + + // when + let config = config_from_map(&pairs); + + // then + assert_eq!( + config.http_proxy.as_deref(), + Some("http://lower.internal:3128") + ); + assert_eq!( + config.https_proxy.as_deref(), + Some("http://lower-secure.internal:3129") + ); + assert_eq!(config.no_proxy.as_deref(), Some(".lower")); + } + + #[test] + fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() { + // given + let pairs = [ + ("HTTP_PROXY", "http://upper.internal:3128"), + ("http_proxy", "http://lower.internal:3128"), + ]; + + // when + let config = config_from_map(&pairs); + + // then + assert_eq!( + config.http_proxy.as_deref(), + Some("http://upper.internal:3128") + ); + } + + #[test] + fn proxy_config_treats_empty_strings_as_unset() { + // given + let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")]; + + // when + let config = config_from_map(&pairs); + + // then + assert!(config.http_proxy.is_none()); + } + + #[test] + fn build_http_client_succeeds_when_no_proxy_is_configured() { + // given + let config = ProxyConfig::default(); + + // when + let result = build_http_client_with(&config); + + // then + assert!(result.is_ok()); + } + + #[test] + fn build_http_client_succeeds_with_valid_http_and_https_proxies() { + // given + let config = ProxyConfig { + http_proxy: Some("http://proxy.internal:3128".to_string()), + https_proxy: Some("http://secure.internal:3129".to_string()), + no_proxy: Some("localhost,127.0.0.1".to_string()), + }; + + // when + let result = build_http_client_with(&config); + + // then + assert!(result.is_ok()); + } + + #[test] + fn build_http_client_returns_http_error_for_invalid_proxy_url() { + // given + let config = ProxyConfig { + http_proxy: None, + https_proxy: Some("not a url".to_string()), + no_proxy: None, + }; + + // when + let result = build_http_client_with(&config); + + // then + let error = result.expect_err("invalid proxy URL must be reported as a build failure"); + assert!( + matches!(error, crate::error::ApiError::Http(_)), + "expected ApiError::Http for invalid proxy URL, got: {error:?}" + ); + } +} diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index cac2f53..fe5cc74 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -1,5 +1,6 @@ mod client; mod error; +mod http_client; mod prompt_cache; mod providers; mod sse; @@ -10,6 +11,9 @@ pub use client::{ resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, }; pub use error::ApiError; +pub use http_client::{ + build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig, +}; pub use prompt_cache::{ CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord, PromptCacheStats, diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index ba02b83..fb2b316 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -12,6 +12,7 @@ use serde_json::{Map, Value}; use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer}; use crate::error::ApiError; +use crate::http_client::build_http_client_or_default; use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats}; use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture}; @@ -127,7 +128,7 @@ impl AnthropicClient { #[must_use] pub fn new(api_key: impl Into) -> Self { Self { - http: reqwest::Client::new(), + http: build_http_client_or_default(), auth: AuthSource::ApiKey(api_key.into()), base_url: DEFAULT_BASE_URL.to_string(), max_retries: DEFAULT_MAX_RETRIES, @@ -143,7 +144,7 @@ impl AnthropicClient { #[must_use] pub fn from_auth(auth: AuthSource) -> Self { Self { - http: reqwest::Client::new(), + http: build_http_client_or_default(), auth, base_url: DEFAULT_BASE_URL.to_string(), max_retries: DEFAULT_MAX_RETRIES, diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 1c1ee27..0afe4ff 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::error::ApiError; +use crate::http_client::build_http_client_or_default; use crate::types::{ ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, @@ -81,7 +82,7 @@ impl OpenAiCompatClient { #[must_use] pub fn new(api_key: impl Into, config: OpenAiCompatConfig) -> Self { Self { - http: reqwest::Client::new(), + http: build_http_client_or_default(), api_key: api_key.into(), config, base_url: read_base_url(config),