mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
feat: b5-reasoning-guard — batch 5 upstream parity
This commit is contained in:
20
USAGE.md
20
USAGE.md
@@ -153,6 +153,26 @@ cd rust
|
|||||||
./target/debug/claw --model "openai/gpt-4.1-mini" prompt "summarize this repository in one sentence"
|
./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
|
## Common operational commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
252
rust/crates/api/src/http_client.rs
Normal file
252
rust/crates/api/src/http_client.rs
Normal file
@@ -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<String>,
|
||||||
|
pub https_proxy: Option<String>,
|
||||||
|
pub no_proxy: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<F>(mut lookup: F) -> Self
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
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<reqwest::Client, ApiError> {
|
||||||
|
build_http_client_with(&ProxyConfig::from_env())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infallible counterpart to [`build_http_client`] for constructors that
|
||||||
|
/// historically returned `Self` rather than `Result<Self, _>`. 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<reqwest::Client, ApiError> {
|
||||||
|
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<F>(keys: &[&str], lookup: &mut F) -> Option<String>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
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<String, String> = 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod client;
|
mod client;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod http_client;
|
||||||
mod prompt_cache;
|
mod prompt_cache;
|
||||||
mod providers;
|
mod providers;
|
||||||
mod sse;
|
mod sse;
|
||||||
@@ -10,6 +11,9 @@ pub use client::{
|
|||||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||||
};
|
};
|
||||||
pub use error::ApiError;
|
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::{
|
pub use prompt_cache::{
|
||||||
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
|
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
|
||||||
PromptCacheStats,
|
PromptCacheStats,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use serde_json::{Map, Value};
|
|||||||
use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer};
|
use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer};
|
||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
|
use crate::http_client::build_http_client_or_default;
|
||||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||||
|
|
||||||
use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture};
|
use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture};
|
||||||
@@ -127,7 +128,7 @@ impl AnthropicClient {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(api_key: impl Into<String>) -> Self {
|
pub fn new(api_key: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
http: reqwest::Client::new(),
|
http: build_http_client_or_default(),
|
||||||
auth: AuthSource::ApiKey(api_key.into()),
|
auth: AuthSource::ApiKey(api_key.into()),
|
||||||
base_url: DEFAULT_BASE_URL.to_string(),
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
max_retries: DEFAULT_MAX_RETRIES,
|
max_retries: DEFAULT_MAX_RETRIES,
|
||||||
@@ -143,7 +144,7 @@ impl AnthropicClient {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_auth(auth: AuthSource) -> Self {
|
pub fn from_auth(auth: AuthSource) -> Self {
|
||||||
Self {
|
Self {
|
||||||
http: reqwest::Client::new(),
|
http: build_http_client_or_default(),
|
||||||
auth,
|
auth,
|
||||||
base_url: DEFAULT_BASE_URL.to_string(),
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
max_retries: DEFAULT_MAX_RETRIES,
|
max_retries: DEFAULT_MAX_RETRIES,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use serde::Deserialize;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
|
use crate::http_client::build_http_client_or_default;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||||
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
|
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
|
||||||
@@ -81,7 +82,7 @@ impl OpenAiCompatClient {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(api_key: impl Into<String>, config: OpenAiCompatConfig) -> Self {
|
pub fn new(api_key: impl Into<String>, config: OpenAiCompatConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
http: reqwest::Client::new(),
|
http: build_http_client_or_default(),
|
||||||
api_key: api_key.into(),
|
api_key: api_key.into(),
|
||||||
config,
|
config,
|
||||||
base_url: read_base_url(config),
|
base_url: read_base_url(config),
|
||||||
|
|||||||
Reference in New Issue
Block a user