feat(proxy): add scoped proxy configuration and docs runbooks
- add scope-aware proxy schema and runtime wiring for providers/channels/tools - add agent callable proxy_config tool for fast proxy setup - standardize docs system with index, template, and playbooks
This commit is contained in:
parent
13ee9e6398
commit
ce104bed45
36 changed files with 2025 additions and 323 deletions
|
|
@ -15,7 +15,6 @@ pub struct DingTalkChannel {
|
|||
client_id: String,
|
||||
client_secret: String,
|
||||
allowed_users: Vec<String>,
|
||||
client: reqwest::Client,
|
||||
/// Per-chat session webhooks for sending replies (chatID -> webhook URL).
|
||||
/// DingTalk provides a unique webhook URL with each incoming message.
|
||||
session_webhooks: Arc<RwLock<HashMap<String, String>>>,
|
||||
|
|
@ -34,11 +33,14 @@ impl DingTalkChannel {
|
|||
client_id,
|
||||
client_secret,
|
||||
allowed_users,
|
||||
client: reqwest::Client::new(),
|
||||
session_webhooks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.dingtalk")
|
||||
}
|
||||
|
||||
fn is_user_allowed(&self, user_id: &str) -> bool {
|
||||
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||
}
|
||||
|
|
@ -86,7 +88,7 @@ impl DingTalkChannel {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post("https://api.dingtalk.com/v1.0/gateway/connections/open")
|
||||
.json(&body)
|
||||
.send()
|
||||
|
|
@ -128,7 +130,12 @@ impl Channel for DingTalkChannel {
|
|||
}
|
||||
});
|
||||
|
||||
let resp = self.client.post(webhook_url).json(&body).send().await?;
|
||||
let resp = self
|
||||
.http_client()
|
||||
.post(webhook_url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ pub struct DiscordChannel {
|
|||
allowed_users: Vec<String>,
|
||||
listen_to_bots: bool,
|
||||
mention_only: bool,
|
||||
client: reqwest::Client,
|
||||
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
|
|
@ -31,11 +30,14 @@ impl DiscordChannel {
|
|||
allowed_users,
|
||||
listen_to_bots,
|
||||
mention_only,
|
||||
client: reqwest::Client::new(),
|
||||
typing_handle: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.discord")
|
||||
}
|
||||
|
||||
/// Check if a Discord user ID is in the allowlist.
|
||||
/// Empty list means deny everyone until explicitly configured.
|
||||
/// `"*"` means allow everyone.
|
||||
|
|
@ -198,7 +200,7 @@ impl Channel for DiscordChannel {
|
|||
let body = json!({ "content": chunk });
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bot {}", self.bot_token))
|
||||
.json(&body)
|
||||
|
|
@ -229,7 +231,7 @@ impl Channel for DiscordChannel {
|
|||
|
||||
// Get Gateway URL
|
||||
let gw_resp: serde_json::Value = self
|
||||
.client
|
||||
.http_client()
|
||||
.get("https://discord.com/api/v10/gateway/bot")
|
||||
.header("Authorization", format!("Bot {}", self.bot_token))
|
||||
.send()
|
||||
|
|
@ -424,7 +426,7 @@ impl Channel for DiscordChannel {
|
|||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.client
|
||||
self.http_client()
|
||||
.get("https://discord.com/api/v10/users/@me")
|
||||
.header("Authorization", format!("Bot {}", self.bot_token))
|
||||
.send()
|
||||
|
|
@ -436,7 +438,7 @@ impl Channel for DiscordChannel {
|
|||
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
|
||||
self.stop_typing(recipient).await?;
|
||||
|
||||
let client = self.client.clone();
|
||||
let client = self.http_client();
|
||||
let token = self.bot_token.clone();
|
||||
let channel_id = recipient.to_string();
|
||||
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@ pub struct LarkChannel {
|
|||
use_feishu: bool,
|
||||
/// How to receive events: WebSocket long-connection or HTTP webhook.
|
||||
receive_mode: crate::config::schema::LarkReceiveMode,
|
||||
client: reqwest::Client,
|
||||
/// Cached tenant access token
|
||||
tenant_token: Arc<RwLock<Option<String>>>,
|
||||
/// Dedup set: WS message_ids seen in last ~30 min to prevent double-dispatch
|
||||
|
|
@ -165,7 +164,6 @@ impl LarkChannel {
|
|||
allowed_users,
|
||||
use_feishu: true,
|
||||
receive_mode: crate::config::schema::LarkReceiveMode::default(),
|
||||
client: reqwest::Client::new(),
|
||||
tenant_token: Arc::new(RwLock::new(None)),
|
||||
ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
|
|
@ -185,6 +183,10 @@ impl LarkChannel {
|
|||
ch
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.lark")
|
||||
}
|
||||
|
||||
fn api_base(&self) -> &'static str {
|
||||
if self.use_feishu {
|
||||
FEISHU_BASE_URL
|
||||
|
|
@ -212,7 +214,7 @@ impl LarkChannel {
|
|||
/// POST /callback/ws/endpoint → (wss_url, client_config)
|
||||
async fn get_ws_endpoint(&self) -> anyhow::Result<(String, WsClientConfig)> {
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(format!("{}/callback/ws/endpoint", self.ws_base()))
|
||||
.header("locale", if self.use_feishu { "zh" } else { "en" })
|
||||
.json(&serde_json::json!({
|
||||
|
|
@ -488,7 +490,7 @@ impl LarkChannel {
|
|||
"app_secret": self.app_secret,
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
let resp = self.http_client().post(&url).json(&body).send().await?;
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
|
||||
let code = data.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
|
||||
|
|
@ -642,7 +644,7 @@ impl Channel for LarkChannel {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
|
|
@ -655,7 +657,7 @@ impl Channel for LarkChannel {
|
|||
self.invalidate_token().await;
|
||||
let new_token = self.get_tenant_access_token().await?;
|
||||
let retry_resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {new_token}"))
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ pub struct MatrixChannel {
|
|||
access_token: String,
|
||||
room_id: String,
|
||||
allowed_users: Vec<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -79,10 +78,13 @@ impl MatrixChannel {
|
|||
access_token,
|
||||
room_id,
|
||||
allowed_users,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client("channel.matrix")
|
||||
}
|
||||
|
||||
fn is_user_allowed(&self, sender: &str) -> bool {
|
||||
if self.allowed_users.iter().any(|u| u == "*") {
|
||||
return true;
|
||||
|
|
@ -95,7 +97,7 @@ impl MatrixChannel {
|
|||
async fn get_my_user_id(&self) -> anyhow::Result<String> {
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
||||
.send()
|
||||
|
|
@ -130,7 +132,7 @@ impl Channel for MatrixChannel {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.put(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
||||
.json(&body)
|
||||
|
|
@ -157,7 +159,7 @@ impl Channel for MatrixChannel {
|
|||
);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
||||
.send()
|
||||
|
|
@ -179,7 +181,7 @@ impl Channel for MatrixChannel {
|
|||
);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
||||
.send()
|
||||
|
|
@ -250,7 +252,7 @@ impl Channel for MatrixChannel {
|
|||
async fn health_check(&self) -> bool {
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
|
||||
let Ok(resp) = self
|
||||
.client
|
||||
.http_client()
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
||||
.send()
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ pub struct MattermostChannel {
|
|||
thread_replies: bool,
|
||||
/// When true, only respond to messages that @-mention the bot.
|
||||
mention_only: bool,
|
||||
client: reqwest::Client,
|
||||
/// Handle for the background typing-indicator loop (aborted on stop_typing).
|
||||
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
|
@ -38,11 +37,14 @@ impl MattermostChannel {
|
|||
allowed_users,
|
||||
thread_replies,
|
||||
mention_only,
|
||||
client: reqwest::Client::new(),
|
||||
typing_handle: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.mattermost")
|
||||
}
|
||||
|
||||
/// Check if a user ID is in the allowlist.
|
||||
/// Empty list means deny everyone. "*" means allow everyone.
|
||||
fn is_user_allowed(&self, user_id: &str) -> bool {
|
||||
|
|
@ -53,7 +55,7 @@ impl MattermostChannel {
|
|||
/// and detect @-mentions by username.
|
||||
async fn get_bot_identity(&self) -> (String, String) {
|
||||
let resp: Option<serde_json::Value> = async {
|
||||
self.client
|
||||
self.http_client()
|
||||
.get(format!("{}/api/v4/users/me", self.base_url))
|
||||
.bearer_auth(&self.bot_token)
|
||||
.send()
|
||||
|
|
@ -109,7 +111,7 @@ impl Channel for MattermostChannel {
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(format!("{}/api/v4/posts", self.base_url))
|
||||
.bearer_auth(&self.bot_token)
|
||||
.json(&body_map)
|
||||
|
|
@ -147,7 +149,7 @@ impl Channel for MattermostChannel {
|
|||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let resp = match self
|
||||
.client
|
||||
.http_client()
|
||||
.get(format!(
|
||||
"{}/api/v4/channels/{}/posts",
|
||||
self.base_url, channel_id
|
||||
|
|
@ -202,7 +204,7 @@ impl Channel for MattermostChannel {
|
|||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.client
|
||||
self.http_client()
|
||||
.get(format!("{}/api/v4/users/me", self.base_url))
|
||||
.bearer_auth(&self.bot_token)
|
||||
.send()
|
||||
|
|
@ -215,7 +217,7 @@ impl Channel for MattermostChannel {
|
|||
// Cancel any existing typing loop before starting a new one.
|
||||
self.stop_typing(recipient).await?;
|
||||
|
||||
let client = self.client.clone();
|
||||
let client = self.http_client();
|
||||
let token = self.bot_token.clone();
|
||||
let base_url = self.base_url.clone();
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ pub struct QQChannel {
|
|||
app_id: String,
|
||||
app_secret: String,
|
||||
allowed_users: Vec<String>,
|
||||
client: reqwest::Client,
|
||||
/// Cached access token + expiry timestamp.
|
||||
token_cache: Arc<RwLock<Option<(String, u64)>>>,
|
||||
/// Message deduplication set.
|
||||
|
|
@ -33,12 +32,15 @@ impl QQChannel {
|
|||
app_id,
|
||||
app_secret,
|
||||
allowed_users,
|
||||
client: reqwest::Client::new(),
|
||||
token_cache: Arc::new(RwLock::new(None)),
|
||||
dedup: Arc::new(RwLock::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.qq")
|
||||
}
|
||||
|
||||
fn is_user_allowed(&self, user_id: &str) -> bool {
|
||||
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||
}
|
||||
|
|
@ -50,7 +52,12 @@ impl QQChannel {
|
|||
"clientSecret": self.app_secret,
|
||||
});
|
||||
|
||||
let resp = self.client.post(QQ_AUTH_URL).json(&body).send().await?;
|
||||
let resp = self
|
||||
.http_client()
|
||||
.post(QQ_AUTH_URL)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
|
|
@ -109,7 +116,7 @@ impl QQChannel {
|
|||
/// Get the WebSocket gateway URL.
|
||||
async fn get_gateway_url(&self, token: &str) -> anyhow::Result<String> {
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.get(format!("{QQ_API_BASE}/gateway"))
|
||||
.header("Authorization", format!("QQBot {token}"))
|
||||
.send()
|
||||
|
|
@ -190,7 +197,7 @@ impl Channel for QQChannel {
|
|||
};
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("QQBot {token}"))
|
||||
.json(&body)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ pub struct SignalChannel {
|
|||
allowed_from: Vec<String>,
|
||||
ignore_attachments: bool,
|
||||
ignore_stories: bool,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
// ── signal-cli SSE event JSON shapes ────────────────────────────
|
||||
|
|
@ -81,10 +80,6 @@ impl SignalChannel {
|
|||
ignore_stories: bool,
|
||||
) -> Self {
|
||||
let http_url = http_url.trim_end_matches('/').to_string();
|
||||
let client = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Signal HTTP client should build");
|
||||
Self {
|
||||
http_url,
|
||||
account,
|
||||
|
|
@ -92,10 +87,15 @@ impl SignalChannel {
|
|||
allowed_from,
|
||||
ignore_attachments,
|
||||
ignore_stories,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
let builder = Client::builder().connect_timeout(Duration::from_secs(10));
|
||||
let builder = crate::config::apply_runtime_proxy_to_builder(builder, "channel.signal");
|
||||
builder.build().expect("Signal HTTP client should build")
|
||||
}
|
||||
|
||||
/// Effective sender: prefer `sourceNumber` (E.164), fall back to `source`.
|
||||
fn sender(envelope: &Envelope) -> Option<String> {
|
||||
envelope
|
||||
|
|
@ -178,7 +178,7 @@ impl SignalChannel {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.header("Content-Type", "application/json")
|
||||
|
|
@ -298,7 +298,7 @@ impl Channel for SignalChannel {
|
|||
|
||||
loop {
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.get(url.clone())
|
||||
.header("Accept", "text/event-stream")
|
||||
.send()
|
||||
|
|
@ -408,7 +408,7 @@ impl Channel for SignalChannel {
|
|||
async fn health_check(&self) -> bool {
|
||||
let url = format!("{}/api/v1/check", self.http_url);
|
||||
let Ok(resp) = self
|
||||
.client
|
||||
.http_client()
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ pub struct SlackChannel {
|
|||
bot_token: String,
|
||||
channel_id: Option<String>,
|
||||
allowed_users: Vec<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SlackChannel {
|
||||
|
|
@ -15,10 +14,13 @@ impl SlackChannel {
|
|||
bot_token,
|
||||
channel_id,
|
||||
allowed_users,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.slack")
|
||||
}
|
||||
|
||||
/// Check if a Slack user ID is in the allowlist.
|
||||
/// Empty list means deny everyone until explicitly configured.
|
||||
/// `"*"` means allow everyone.
|
||||
|
|
@ -29,7 +31,7 @@ impl SlackChannel {
|
|||
/// Get the bot's own user ID so we can ignore our own messages
|
||||
async fn get_bot_user_id(&self) -> Option<String> {
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
.http_client()
|
||||
.get("https://slack.com/api/auth.test")
|
||||
.bearer_auth(&self.bot_token)
|
||||
.send()
|
||||
|
|
@ -58,7 +60,7 @@ impl Channel for SlackChannel {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post("https://slack.com/api/chat.postMessage")
|
||||
.bearer_auth(&self.bot_token)
|
||||
.json(&body)
|
||||
|
|
@ -108,7 +110,7 @@ impl Channel for SlackChannel {
|
|||
}
|
||||
|
||||
let resp = match self
|
||||
.client
|
||||
.http_client()
|
||||
.get("https://slack.com/api/conversations.history")
|
||||
.bearer_auth(&self.bot_token)
|
||||
.query(¶ms)
|
||||
|
|
@ -179,7 +181,7 @@ impl Channel for SlackChannel {
|
|||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.client
|
||||
self.http_client()
|
||||
.get("https://slack.com/api/auth.test")
|
||||
.bearer_auth(&self.bot_token)
|
||||
.send()
|
||||
|
|
|
|||
|
|
@ -357,6 +357,10 @@ impl TelegramChannel {
|
|||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.telegram")
|
||||
}
|
||||
|
||||
fn normalize_identity(value: &str) -> String {
|
||||
value.trim().trim_start_matches('@').to_string()
|
||||
}
|
||||
|
|
@ -448,7 +452,7 @@ impl TelegramChannel {
|
|||
}
|
||||
|
||||
async fn fetch_bot_username(&self) -> anyhow::Result<String> {
|
||||
let resp = self.client.get(self.api_url("getMe")).send().await?;
|
||||
let resp = self.http_client().get(self.api_url("getMe")).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Failed to fetch bot info: {}", resp.status());
|
||||
|
|
@ -857,7 +861,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let markdown_resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendMessage"))
|
||||
.json(&markdown_body)
|
||||
.send()
|
||||
|
|
@ -887,7 +891,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
|
||||
}
|
||||
let plain_resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendMessage"))
|
||||
.json(&plain_body)
|
||||
.send()
|
||||
|
|
@ -936,7 +940,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url(method))
|
||||
.json(&body)
|
||||
.send()
|
||||
|
|
@ -1029,7 +1033,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendDocument"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
|
|
@ -1068,7 +1072,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendDocument"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
|
|
@ -1112,7 +1116,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendPhoto"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
|
|
@ -1151,7 +1155,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendPhoto"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
|
|
@ -1195,7 +1199,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendVideo"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
|
|
@ -1239,7 +1243,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendAudio"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
|
|
@ -1283,7 +1287,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendVoice"))
|
||||
.multipart(form)
|
||||
.send()
|
||||
|
|
@ -1320,7 +1324,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendDocument"))
|
||||
.json(&body)
|
||||
.send()
|
||||
|
|
@ -1357,7 +1361,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendPhoto"))
|
||||
.json(&body)
|
||||
.send()
|
||||
|
|
@ -1685,7 +1689,7 @@ impl Channel for TelegramChannel {
|
|||
"allowed_updates": ["message"]
|
||||
});
|
||||
|
||||
let resp = match self.client.post(&url).json(&body).send().await {
|
||||
let resp = match self.http_client().post(&url).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Telegram poll error: {e}");
|
||||
|
|
@ -1750,7 +1754,7 @@ Ensure only one `zeroclaw` process is using this bot token."
|
|||
"action": "typing"
|
||||
});
|
||||
let _ = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.api_url("sendChatAction"))
|
||||
.json(&typing_body)
|
||||
.send()
|
||||
|
|
@ -1769,7 +1773,7 @@ Ensure only one `zeroclaw` process is using this bot token."
|
|||
|
||||
match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
self.client.get(self.api_url("getMe")).send(),
|
||||
self.http_client().get(self.api_url("getMe")).send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -1788,7 +1792,7 @@ Ensure only one `zeroclaw` process is using this bot token."
|
|||
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
|
||||
self.stop_typing(recipient).await?;
|
||||
|
||||
let client = self.client.clone();
|
||||
let client = self.http_client();
|
||||
let url = self.api_url("sendChatAction");
|
||||
let chat_id = recipient.to_string();
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ pub struct WhatsAppChannel {
|
|||
endpoint_id: String,
|
||||
verify_token: String,
|
||||
allowed_numbers: Vec<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl WhatsAppChannel {
|
||||
|
|
@ -28,10 +27,13 @@ impl WhatsAppChannel {
|
|||
endpoint_id,
|
||||
verify_token,
|
||||
allowed_numbers,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.whatsapp")
|
||||
}
|
||||
|
||||
/// Check if a phone number is allowed (E.164 format: +1234567890)
|
||||
fn is_number_allowed(&self, phone: &str) -> bool {
|
||||
self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
|
||||
|
|
@ -164,7 +166,7 @@ impl Channel for WhatsAppChannel {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
|
|
@ -201,7 +203,7 @@ impl Channel for WhatsAppChannel {
|
|||
// Check if we can reach the WhatsApp API
|
||||
let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id);
|
||||
|
||||
self.client
|
||||
self.http_client()
|
||||
.get(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.send()
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@ pub mod schema;
|
|||
|
||||
#[allow(unused_imports)]
|
||||
pub use schema::{
|
||||
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
|
||||
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
|
||||
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
|
||||
ChannelsConfig, ClassificationRule, ComposioConfig, Config, CostConfig, CronConfig,
|
||||
DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HardwareConfig,
|
||||
HardwareTransport, HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig,
|
||||
LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, ObservabilityConfig,
|
||||
PeripheralBoardConfig, PeripheralsConfig, QueryClassificationConfig, ReliabilityConfig,
|
||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||
SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, StreamMode, TelegramConfig, TunnelConfig, WebSearchConfig,
|
||||
WebhookConfig,
|
||||
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig,
|
||||
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
|
||||
SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
|
||||
StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, TunnelConfig,
|
||||
WebSearchConfig, WebhookConfig,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,41 @@ use std::collections::HashMap;
|
|||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
|
||||
"provider.anthropic",
|
||||
"provider.compatible",
|
||||
"provider.copilot",
|
||||
"provider.gemini",
|
||||
"provider.glm",
|
||||
"provider.ollama",
|
||||
"provider.openai",
|
||||
"provider.openrouter",
|
||||
"channel.dingtalk",
|
||||
"channel.discord",
|
||||
"channel.lark",
|
||||
"channel.matrix",
|
||||
"channel.mattermost",
|
||||
"channel.qq",
|
||||
"channel.signal",
|
||||
"channel.slack",
|
||||
"channel.telegram",
|
||||
"channel.whatsapp",
|
||||
"tool.browser",
|
||||
"tool.composio",
|
||||
"tool.http_request",
|
||||
"tool.pushover",
|
||||
"memory.embeddings",
|
||||
"tunnel.custom",
|
||||
];
|
||||
|
||||
const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] =
|
||||
&["provider.*", "channel.*", "tool.*", "memory.*", "tunnel.*"];
|
||||
|
||||
static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
|
||||
static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
|
||||
OnceLock::new();
|
||||
|
||||
// ── Top-level config ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -87,6 +122,9 @@ pub struct Config {
|
|||
#[serde(default)]
|
||||
pub web_search: WebSearchConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub proxy: ProxyConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub identity: IdentityConfig,
|
||||
|
||||
|
|
@ -772,6 +810,465 @@ impl Default for WebSearchConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Proxy ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProxyScope {
|
||||
Environment,
|
||||
#[default]
|
||||
Zeroclaw,
|
||||
Services,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyConfig {
|
||||
/// Enable proxy support for selected scope.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Proxy URL for HTTP requests (supports http, https, socks5, socks5h).
|
||||
#[serde(default)]
|
||||
pub http_proxy: Option<String>,
|
||||
/// Proxy URL for HTTPS requests (supports http, https, socks5, socks5h).
|
||||
#[serde(default)]
|
||||
pub https_proxy: Option<String>,
|
||||
/// Fallback proxy URL for all schemes.
|
||||
#[serde(default)]
|
||||
pub all_proxy: Option<String>,
|
||||
/// No-proxy bypass list. Same format as NO_PROXY.
|
||||
#[serde(default)]
|
||||
pub no_proxy: Vec<String>,
|
||||
/// Proxy application scope.
|
||||
#[serde(default)]
|
||||
pub scope: ProxyScope,
|
||||
/// Service selectors used when scope = "services".
|
||||
#[serde(default)]
|
||||
pub services: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ProxyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
http_proxy: None,
|
||||
https_proxy: None,
|
||||
all_proxy: None,
|
||||
no_proxy: Vec::new(),
|
||||
scope: ProxyScope::Zeroclaw,
|
||||
services: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
pub fn supported_service_keys() -> &'static [&'static str] {
|
||||
SUPPORTED_PROXY_SERVICE_KEYS
|
||||
}
|
||||
|
||||
pub fn supported_service_selectors() -> &'static [&'static str] {
|
||||
SUPPORTED_PROXY_SERVICE_SELECTORS
|
||||
}
|
||||
|
||||
pub fn has_any_proxy_url(&self) -> bool {
|
||||
normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
|
||||
|| normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
|
||||
|| normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
|
||||
}
|
||||
|
||||
pub fn normalized_services(&self) -> Vec<String> {
|
||||
normalize_service_list(self.services.clone())
|
||||
}
|
||||
|
||||
pub fn normalized_no_proxy(&self) -> Vec<String> {
|
||||
normalize_no_proxy_list(self.no_proxy.clone())
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (field, value) in [
|
||||
("http_proxy", self.http_proxy.as_deref()),
|
||||
("https_proxy", self.https_proxy.as_deref()),
|
||||
("all_proxy", self.all_proxy.as_deref()),
|
||||
] {
|
||||
if let Some(url) = normalize_proxy_url_option(value) {
|
||||
validate_proxy_url(field, &url)?;
|
||||
}
|
||||
}
|
||||
|
||||
for selector in self.normalized_services() {
|
||||
if !is_supported_proxy_service_selector(&selector) {
|
||||
anyhow::bail!(
|
||||
"Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.enabled && !self.has_any_proxy_url() {
|
||||
anyhow::bail!(
|
||||
"Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
|
||||
);
|
||||
}
|
||||
|
||||
if self.enabled
|
||||
&& self.scope == ProxyScope::Services
|
||||
&& self.normalized_services().is_empty()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_apply_to_service(&self, service_key: &str) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self.scope {
|
||||
ProxyScope::Environment => false,
|
||||
ProxyScope::Zeroclaw => true,
|
||||
ProxyScope::Services => {
|
||||
let service_key = service_key.trim().to_ascii_lowercase();
|
||||
if service_key.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.normalized_services()
|
||||
.iter()
|
||||
.any(|selector| service_selector_matches(selector, &service_key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_to_reqwest_builder(
|
||||
&self,
|
||||
mut builder: reqwest::ClientBuilder,
|
||||
service_key: &str,
|
||||
) -> reqwest::ClientBuilder {
|
||||
if !self.should_apply_to_service(service_key) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
let no_proxy = self.no_proxy_value();
|
||||
|
||||
if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
|
||||
match reqwest::Proxy::all(&url) {
|
||||
Ok(proxy) => {
|
||||
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
proxy_url = %url,
|
||||
service_key,
|
||||
"Ignoring invalid all_proxy URL: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
|
||||
match reqwest::Proxy::http(&url) {
|
||||
Ok(proxy) => {
|
||||
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
proxy_url = %url,
|
||||
service_key,
|
||||
"Ignoring invalid http_proxy URL: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
|
||||
match reqwest::Proxy::https(&url) {
|
||||
Ok(proxy) => {
|
||||
builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
proxy_url = %url,
|
||||
service_key,
|
||||
"Ignoring invalid https_proxy URL: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
pub fn apply_to_process_env(&self) {
|
||||
set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
|
||||
set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
|
||||
set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
|
||||
|
||||
let no_proxy_joined = {
|
||||
let list = self.normalized_no_proxy();
|
||||
(!list.is_empty()).then(|| list.join(","))
|
||||
};
|
||||
set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
|
||||
}
|
||||
|
||||
pub fn clear_process_env() {
|
||||
clear_proxy_env_pair("HTTP_PROXY");
|
||||
clear_proxy_env_pair("HTTPS_PROXY");
|
||||
clear_proxy_env_pair("ALL_PROXY");
|
||||
clear_proxy_env_pair("NO_PROXY");
|
||||
}
|
||||
|
||||
fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
|
||||
let joined = {
|
||||
let list = self.normalized_no_proxy();
|
||||
(!list.is_empty()).then(|| list.join(","))
|
||||
};
|
||||
joined.as_deref().and_then(reqwest::NoProxy::from_string)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
|
||||
proxy.no_proxy(no_proxy)
|
||||
}
|
||||
|
||||
fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
|
||||
let value = raw?.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
|
||||
normalize_comma_values(values)
|
||||
}
|
||||
|
||||
fn normalize_service_list(values: Vec<String>) -> Vec<String> {
|
||||
let mut normalized = normalize_comma_values(values)
|
||||
.into_iter()
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort_unstable();
|
||||
normalized.dedup();
|
||||
normalized
|
||||
}
|
||||
|
||||
fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
|
||||
let mut output = Vec::new();
|
||||
for value in values {
|
||||
for part in value.split(',') {
|
||||
let normalized = part.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
output.push(normalized.to_string());
|
||||
}
|
||||
}
|
||||
output.sort_unstable();
|
||||
output.dedup();
|
||||
output
|
||||
}
|
||||
|
||||
fn is_supported_proxy_service_selector(selector: &str) -> bool {
|
||||
if SUPPORTED_PROXY_SERVICE_KEYS
|
||||
.iter()
|
||||
.any(|known| known.eq_ignore_ascii_case(selector))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
SUPPORTED_PROXY_SERVICE_SELECTORS
|
||||
.iter()
|
||||
.any(|known| known.eq_ignore_ascii_case(selector))
|
||||
}
|
||||
|
||||
fn service_selector_matches(selector: &str, service_key: &str) -> bool {
|
||||
if selector == service_key {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(prefix) = selector.strip_suffix(".*") {
|
||||
return service_key.starts_with(prefix)
|
||||
&& service_key
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|suffix| suffix.starts_with('.'));
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
|
||||
|
||||
match parsed.scheme() {
|
||||
"http" | "https" | "socks5" | "socks5h" => {}
|
||||
scheme => {
|
||||
anyhow::bail!(
|
||||
"Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if parsed.host_str().is_none() {
|
||||
anyhow::bail!("Invalid {field} URL: host is required");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_proxy_env_pair(key: &str, value: Option<&str>) {
|
||||
let lowercase_key = key.to_ascii_lowercase();
|
||||
if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
|
||||
std::env::set_var(key, &value);
|
||||
std::env::set_var(lowercase_key, value);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
std::env::remove_var(lowercase_key);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_proxy_env_pair(key: &str) {
|
||||
std::env::remove_var(key);
|
||||
std::env::remove_var(key.to_ascii_lowercase());
|
||||
}
|
||||
|
||||
fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
|
||||
RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
|
||||
}
|
||||
|
||||
fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
|
||||
RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn clear_runtime_proxy_client_cache() {
|
||||
match runtime_proxy_client_cache().write() {
|
||||
Ok(mut guard) => {
|
||||
guard.clear();
|
||||
}
|
||||
Err(poisoned) => {
|
||||
poisoned.into_inner().clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_proxy_cache_key(
|
||||
service_key: &str,
|
||||
timeout_secs: Option<u64>,
|
||||
connect_timeout_secs: Option<u64>,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}|timeout={}|connect_timeout={}",
|
||||
service_key.trim().to_ascii_lowercase(),
|
||||
timeout_secs
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
connect_timeout_secs
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
|
||||
match runtime_proxy_client_cache().read() {
|
||||
Ok(guard) => guard.get(cache_key).cloned(),
|
||||
Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
|
||||
match runtime_proxy_client_cache().write() {
|
||||
Ok(mut guard) => {
|
||||
guard.insert(cache_key, client);
|
||||
}
|
||||
Err(poisoned) => {
|
||||
poisoned.into_inner().insert(cache_key, client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_runtime_proxy_config(config: ProxyConfig) {
|
||||
match runtime_proxy_state().write() {
|
||||
Ok(mut guard) => {
|
||||
*guard = config;
|
||||
}
|
||||
Err(poisoned) => {
|
||||
*poisoned.into_inner() = config;
|
||||
}
|
||||
}
|
||||
|
||||
clear_runtime_proxy_client_cache();
|
||||
}
|
||||
|
||||
pub fn runtime_proxy_config() -> ProxyConfig {
|
||||
match runtime_proxy_state().read() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_runtime_proxy_to_builder(
|
||||
builder: reqwest::ClientBuilder,
|
||||
service_key: &str,
|
||||
) -> reqwest::ClientBuilder {
|
||||
runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
|
||||
}
|
||||
|
||||
pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
|
||||
let cache_key = runtime_proxy_cache_key(service_key, None, None);
|
||||
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
|
||||
return client;
|
||||
}
|
||||
|
||||
let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
|
||||
let client = builder.build().unwrap_or_else(|error| {
|
||||
tracing::warn!(service_key, "Failed to build proxied client: {error}");
|
||||
reqwest::Client::new()
|
||||
});
|
||||
set_runtime_proxy_cached_client(cache_key, client.clone());
|
||||
client
|
||||
}
|
||||
|
||||
pub fn build_runtime_proxy_client_with_timeouts(
|
||||
service_key: &str,
|
||||
timeout_secs: u64,
|
||||
connect_timeout_secs: u64,
|
||||
) -> reqwest::Client {
|
||||
let cache_key =
|
||||
runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
|
||||
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
|
||||
return client;
|
||||
}
|
||||
|
||||
let builder = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
|
||||
let builder = apply_runtime_proxy_to_builder(builder, service_key);
|
||||
let client = builder.build().unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
service_key,
|
||||
"Failed to build proxied timeout client: {error}"
|
||||
);
|
||||
reqwest::Client::new()
|
||||
});
|
||||
set_runtime_proxy_cached_client(cache_key, client.clone());
|
||||
client
|
||||
}
|
||||
|
||||
fn parse_proxy_scope(raw: &str) -> Option<ProxyScope> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"environment" | "env" => Some(ProxyScope::Environment),
|
||||
"zeroclaw" | "internal" | "core" => Some(ProxyScope::Zeroclaw),
|
||||
"services" | "service" => Some(ProxyScope::Services),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_proxy_enabled(raw: &str) -> Option<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// ── Memory ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
|
|
@ -1922,6 +2419,7 @@ impl Default for Config {
|
|||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
web_search: WebSearchConfig::default(),
|
||||
proxy: ProxyConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
|
|
@ -2368,6 +2866,74 @@ impl Config {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Proxy enabled flag: ZEROCLAW_PROXY_ENABLED
|
||||
let explicit_proxy_enabled = std::env::var("ZEROCLAW_PROXY_ENABLED")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(parse_proxy_enabled);
|
||||
if let Some(enabled) = explicit_proxy_enabled {
|
||||
self.proxy.enabled = enabled;
|
||||
}
|
||||
|
||||
// Proxy URLs: ZEROCLAW_* wins, then generic *PROXY vars.
|
||||
let mut proxy_url_overridden = false;
|
||||
if let Ok(proxy_url) =
|
||||
std::env::var("ZEROCLAW_HTTP_PROXY").or_else(|_| std::env::var("HTTP_PROXY"))
|
||||
{
|
||||
self.proxy.http_proxy = normalize_proxy_url_option(Some(&proxy_url));
|
||||
proxy_url_overridden = true;
|
||||
}
|
||||
if let Ok(proxy_url) =
|
||||
std::env::var("ZEROCLAW_HTTPS_PROXY").or_else(|_| std::env::var("HTTPS_PROXY"))
|
||||
{
|
||||
self.proxy.https_proxy = normalize_proxy_url_option(Some(&proxy_url));
|
||||
proxy_url_overridden = true;
|
||||
}
|
||||
if let Ok(proxy_url) =
|
||||
std::env::var("ZEROCLAW_ALL_PROXY").or_else(|_| std::env::var("ALL_PROXY"))
|
||||
{
|
||||
self.proxy.all_proxy = normalize_proxy_url_option(Some(&proxy_url));
|
||||
proxy_url_overridden = true;
|
||||
}
|
||||
if let Ok(no_proxy) =
|
||||
std::env::var("ZEROCLAW_NO_PROXY").or_else(|_| std::env::var("NO_PROXY"))
|
||||
{
|
||||
self.proxy.no_proxy = normalize_no_proxy_list(vec![no_proxy]);
|
||||
}
|
||||
|
||||
if explicit_proxy_enabled.is_none()
|
||||
&& proxy_url_overridden
|
||||
&& self.proxy.has_any_proxy_url()
|
||||
{
|
||||
self.proxy.enabled = true;
|
||||
}
|
||||
|
||||
// Proxy scope and service selectors.
|
||||
if let Ok(scope_raw) = std::env::var("ZEROCLAW_PROXY_SCOPE") {
|
||||
if let Some(scope) = parse_proxy_scope(&scope_raw) {
|
||||
self.proxy.scope = scope;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
scope = %scope_raw,
|
||||
"Ignoring invalid ZEROCLAW_PROXY_SCOPE (valid: environment|zeroclaw|services)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(services_raw) = std::env::var("ZEROCLAW_PROXY_SERVICES") {
|
||||
self.proxy.services = normalize_service_list(vec![services_raw]);
|
||||
}
|
||||
|
||||
if let Err(error) = self.proxy.validate() {
|
||||
tracing::warn!("Invalid proxy configuration ignored: {error}");
|
||||
self.proxy.enabled = false;
|
||||
}
|
||||
|
||||
if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
|
||||
self.proxy.apply_to_process_env();
|
||||
}
|
||||
|
||||
set_runtime_proxy_config(self.proxy.clone());
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
|
|
@ -2682,6 +3248,7 @@ default_temperature = 0.7
|
|||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
web_search: WebSearchConfig::default(),
|
||||
proxy: ProxyConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
|
|
@ -2821,6 +3388,7 @@ tool_dispatcher = "xml"
|
|||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
web_search: WebSearchConfig::default(),
|
||||
proxy: ProxyConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
|
|
@ -3619,6 +4187,28 @@ default_temperature = 0.7
|
|||
.expect("env override test lock poisoned")
|
||||
}
|
||||
|
||||
fn clear_proxy_env_test_vars() {
|
||||
for key in [
|
||||
"ZEROCLAW_PROXY_ENABLED",
|
||||
"ZEROCLAW_HTTP_PROXY",
|
||||
"ZEROCLAW_HTTPS_PROXY",
|
||||
"ZEROCLAW_ALL_PROXY",
|
||||
"ZEROCLAW_NO_PROXY",
|
||||
"ZEROCLAW_PROXY_SCOPE",
|
||||
"ZEROCLAW_PROXY_SERVICES",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
] {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_api_key() {
|
||||
let _env_guard = env_override_test_guard();
|
||||
|
|
@ -4108,6 +4698,128 @@ default_model = "legacy-model"
|
|||
std::env::remove_var("ZEROCLAW_STORAGE_CONNECT_TIMEOUT_SECS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_config_scope_services_requires_entries_when_enabled() {
|
||||
let proxy = ProxyConfig {
|
||||
enabled: true,
|
||||
http_proxy: Some("http://127.0.0.1:7890".into()),
|
||||
https_proxy: None,
|
||||
all_proxy: None,
|
||||
no_proxy: Vec::new(),
|
||||
scope: ProxyScope::Services,
|
||||
services: Vec::new(),
|
||||
};
|
||||
|
||||
let error = proxy.validate().unwrap_err().to_string();
|
||||
assert!(error.contains("proxy.scope='services'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_proxy_scope_services() {
|
||||
let _env_guard = env_override_test_guard();
|
||||
clear_proxy_env_test_vars();
|
||||
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("ZEROCLAW_PROXY_ENABLED", "true");
|
||||
std::env::set_var("ZEROCLAW_HTTP_PROXY", "http://127.0.0.1:7890");
|
||||
std::env::set_var(
|
||||
"ZEROCLAW_PROXY_SERVICES",
|
||||
"provider.openai, tool.http_request",
|
||||
);
|
||||
std::env::set_var("ZEROCLAW_PROXY_SCOPE", "services");
|
||||
|
||||
config.apply_env_overrides();
|
||||
|
||||
assert!(config.proxy.enabled);
|
||||
assert_eq!(config.proxy.scope, ProxyScope::Services);
|
||||
assert_eq!(
|
||||
config.proxy.http_proxy.as_deref(),
|
||||
Some("http://127.0.0.1:7890")
|
||||
);
|
||||
assert!(config.proxy.should_apply_to_service("provider.openai"));
|
||||
assert!(config.proxy.should_apply_to_service("tool.http_request"));
|
||||
assert!(!config.proxy.should_apply_to_service("provider.anthropic"));
|
||||
|
||||
clear_proxy_env_test_vars();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_proxy_scope_environment_applies_process_env() {
|
||||
let _env_guard = env_override_test_guard();
|
||||
clear_proxy_env_test_vars();
|
||||
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("ZEROCLAW_PROXY_ENABLED", "true");
|
||||
std::env::set_var("ZEROCLAW_PROXY_SCOPE", "environment");
|
||||
std::env::set_var("ZEROCLAW_HTTP_PROXY", "http://127.0.0.1:7890");
|
||||
std::env::set_var("ZEROCLAW_HTTPS_PROXY", "http://127.0.0.1:7891");
|
||||
std::env::set_var("ZEROCLAW_NO_PROXY", "localhost,127.0.0.1");
|
||||
|
||||
config.apply_env_overrides();
|
||||
|
||||
assert_eq!(config.proxy.scope, ProxyScope::Environment);
|
||||
assert_eq!(
|
||||
std::env::var("HTTP_PROXY").ok().as_deref(),
|
||||
Some("http://127.0.0.1:7890")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("HTTPS_PROXY").ok().as_deref(),
|
||||
Some("http://127.0.0.1:7891")
|
||||
);
|
||||
assert!(std::env::var("NO_PROXY")
|
||||
.ok()
|
||||
.is_some_and(|value| value.contains("localhost")));
|
||||
|
||||
clear_proxy_env_test_vars();
|
||||
}
|
||||
|
||||
fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
|
||||
match runtime_proxy_client_cache().read() {
|
||||
Ok(guard) => guard.contains_key(cache_key),
|
||||
Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_proxy_client_cache_reuses_default_profile_key() {
|
||||
let service_key = format!(
|
||||
"provider.cache_test.{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch")
|
||||
.as_nanos()
|
||||
);
|
||||
let cache_key = runtime_proxy_cache_key(&service_key, None, None);
|
||||
|
||||
clear_runtime_proxy_client_cache();
|
||||
assert!(!runtime_proxy_cache_contains(&cache_key));
|
||||
|
||||
let _ = build_runtime_proxy_client(&service_key);
|
||||
assert!(runtime_proxy_cache_contains(&cache_key));
|
||||
|
||||
let _ = build_runtime_proxy_client(&service_key);
|
||||
assert!(runtime_proxy_cache_contains(&cache_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
|
||||
let service_key = format!(
|
||||
"provider.cache_timeout_test.{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch")
|
||||
.as_nanos()
|
||||
);
|
||||
let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
|
||||
|
||||
clear_runtime_proxy_client_cache();
|
||||
let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
|
||||
assert!(runtime_proxy_cache_contains(&cache_key));
|
||||
|
||||
set_runtime_proxy_config(ProxyConfig::default());
|
||||
assert!(!runtime_proxy_cache_contains(&cache_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_default_values() {
|
||||
let g = GatewayConfig::default();
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ impl EmbeddingProvider for NoopEmbedding {
|
|||
// ── OpenAI-compatible embedding provider ─────────────────────
|
||||
|
||||
pub struct OpenAiEmbedding {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
|
|
@ -53,7 +52,6 @@ pub struct OpenAiEmbedding {
|
|||
impl OpenAiEmbedding {
|
||||
pub fn new(base_url: &str, api_key: &str, model: &str, dims: usize) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
|
|
@ -61,6 +59,10 @@ impl OpenAiEmbedding {
|
|||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("memory.embeddings")
|
||||
}
|
||||
|
||||
fn has_explicit_api_path(&self) -> bool {
|
||||
let Ok(url) = reqwest::Url::parse(&self.base_url) else {
|
||||
return false;
|
||||
|
|
@ -112,7 +114,7 @@ impl EmbeddingProvider for OpenAiEmbedding {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(self.embeddings_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ pub fn run_wizard() -> Result<Config> {
|
|||
browser: BrowserConfig::default(),
|
||||
http_request: crate::config::HttpRequestConfig::default(),
|
||||
web_search: crate::config::WebSearchConfig::default(),
|
||||
proxy: crate::config::ProxyConfig::default(),
|
||||
identity: crate::config::IdentityConfig::default(),
|
||||
cost: crate::config::CostConfig::default(),
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
|
|
@ -356,6 +357,7 @@ pub fn run_quick_setup(
|
|||
browser: BrowserConfig::default(),
|
||||
http_request: crate::config::HttpRequestConfig::default(),
|
||||
web_search: crate::config::WebSearchConfig::default(),
|
||||
proxy: crate::config::ProxyConfig::default(),
|
||||
identity: crate::config::IdentityConfig::default(),
|
||||
cost: crate::config::CostConfig::default(),
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize};
|
|||
pub struct AnthropicProvider {
|
||||
credential: Option<String>,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -161,11 +160,6 @@ impl AnthropicProvider {
|
|||
.filter(|k| !k.is_empty())
|
||||
.map(ToString::to_string),
|
||||
base_url,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -404,6 +398,10 @@ impl AnthropicProvider {
|
|||
tool_calls,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.anthropic", 120, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -433,7 +431,7 @@ impl Provider for AnthropicProvider {
|
|||
};
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
|
|
@ -480,7 +478,7 @@ impl Provider for AnthropicProvider {
|
|||
};
|
||||
|
||||
let req = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
|
|
@ -502,7 +500,7 @@ impl Provider for AnthropicProvider {
|
|||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
if let Some(credential) = self.credential.as_ref() {
|
||||
let mut request = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
request = self.apply_auth(request, credential);
|
||||
|
|
@ -594,7 +592,9 @@ mod tests {
|
|||
let provider = AnthropicProvider::new(None);
|
||||
let request = provider
|
||||
.apply_auth(
|
||||
provider.client.get("https://api.anthropic.com/v1/models"),
|
||||
provider
|
||||
.http_client()
|
||||
.get("https://api.anthropic.com/v1/models"),
|
||||
"sk-ant-oat01-test-token",
|
||||
)
|
||||
.build()
|
||||
|
|
@ -622,7 +622,9 @@ mod tests {
|
|||
let provider = AnthropicProvider::new(None);
|
||||
let request = provider
|
||||
.apply_auth(
|
||||
provider.client.get("https://api.anthropic.com/v1/models"),
|
||||
provider
|
||||
.http_client()
|
||||
.get("https://api.anthropic.com/v1/models"),
|
||||
"sk-ant-api-key",
|
||||
)
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ pub struct OpenAiCompatibleProvider {
|
|||
/// When false, do not fall back to /v1/responses on chat completions 404.
|
||||
/// GLM/Zhipu does not support the responses API.
|
||||
supports_responses_fallback: bool,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
/// How the provider expects the API key to be sent.
|
||||
|
|
@ -49,11 +48,6 @@ impl OpenAiCompatibleProvider {
|
|||
credential: credential.map(ToString::to_string),
|
||||
auth_header: auth_style,
|
||||
supports_responses_fallback: true,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,14 +65,13 @@ impl OpenAiCompatibleProvider {
|
|||
credential: credential.map(ToString::to_string),
|
||||
auth_header: auth_style,
|
||||
supports_responses_fallback: false,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.compatible", 120, 10)
|
||||
}
|
||||
|
||||
/// Build the full URL for chat completions, detecting if base_url already includes the path.
|
||||
/// This allows custom providers with non-standard endpoints (e.g., VolcEngine ARK uses
|
||||
/// `/api/coding/v3/chat/completions` instead of `/v1/chat/completions`).
|
||||
|
|
@ -513,7 +506,7 @@ impl OpenAiCompatibleProvider {
|
|||
let url = self.responses_url();
|
||||
|
||||
let response = self
|
||||
.apply_auth_header(self.client.post(&url).json(&request), credential)
|
||||
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -578,7 +571,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
let url = self.chat_completions_url();
|
||||
|
||||
let response = self
|
||||
.apply_auth_header(self.client.post(&url).json(&request), credential)
|
||||
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -660,7 +653,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
let url = self.chat_completions_url();
|
||||
let response = self
|
||||
.apply_auth_header(self.client.post(&url).json(&request), credential)
|
||||
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -760,7 +753,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
let url = self.chat_completions_url();
|
||||
let response = self
|
||||
.apply_auth_header(self.client.post(&url).json(&request), credential)
|
||||
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
|
|
@ -900,7 +893,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
};
|
||||
|
||||
let url = self.chat_completions_url();
|
||||
let client = self.client.clone();
|
||||
let client = self.http_client();
|
||||
let auth_header = self.auth_header.clone();
|
||||
|
||||
// Use a channel to bridge the async HTTP response to the stream
|
||||
|
|
@ -967,7 +960,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
// the goal is TLS handshake and HTTP/2 negotiation.
|
||||
let url = self.chat_completions_url();
|
||||
let _ = self
|
||||
.apply_auth_header(self.client.get(&url), credential)
|
||||
.apply_auth_header(self.http_client().get(&url), credential)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@ pub struct CopilotProvider {
|
|||
/// Mutex ensures only one caller refreshes tokens at a time,
|
||||
/// preventing duplicate device flow prompts or redundant API calls.
|
||||
refresh_lock: Arc<Mutex<Option<CachedApiKey>>>,
|
||||
http: Client,
|
||||
token_dir: PathBuf,
|
||||
}
|
||||
|
||||
|
|
@ -204,15 +203,14 @@ impl CopilotProvider {
|
|||
.filter(|token| !token.is_empty())
|
||||
.map(String::from),
|
||||
refresh_lock: Arc::new(Mutex::new(None)),
|
||||
http: Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
token_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.copilot", 120, 10)
|
||||
}
|
||||
|
||||
/// Required headers for Copilot API requests (editor identification).
|
||||
const COPILOT_HEADERS: [(&str, &str); 4] = [
|
||||
("Editor-Version", "vscode/1.85.1"),
|
||||
|
|
@ -326,7 +324,7 @@ impl CopilotProvider {
|
|||
};
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&request);
|
||||
|
|
@ -438,7 +436,7 @@ impl CopilotProvider {
|
|||
/// Run GitHub OAuth device code flow.
|
||||
async fn device_code_login(&self) -> anyhow::Result<String> {
|
||||
let response: DeviceCodeResponse = self
|
||||
.http
|
||||
.http_client()
|
||||
.post(GITHUB_DEVICE_CODE_URL)
|
||||
.header("Accept", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
|
|
@ -467,7 +465,7 @@ impl CopilotProvider {
|
|||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
let token_response: AccessTokenResponse = self
|
||||
.http
|
||||
.http_client()
|
||||
.post(GITHUB_ACCESS_TOKEN_URL)
|
||||
.header("Accept", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
|
|
@ -502,7 +500,7 @@ impl CopilotProvider {
|
|||
|
||||
/// Exchange a GitHub access token for a Copilot API key.
|
||||
async fn exchange_for_api_key(&self, access_token: &str) -> anyhow::Result<ApiKeyInfo> {
|
||||
let mut request = self.http.get(GITHUB_API_KEY_URL);
|
||||
let mut request = self.http_client().get(GITHUB_API_KEY_URL);
|
||||
for (header, value) in &Self::COPILOT_HEADERS {
|
||||
request = request.header(*header, *value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ use std::path::PathBuf;
|
|||
/// Gemini provider supporting multiple authentication methods.
|
||||
pub struct GeminiProvider {
|
||||
auth: Option<GeminiAuth>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
/// Resolved credential — the variant determines both the HTTP auth method
|
||||
|
|
@ -161,11 +160,6 @@ impl GeminiProvider {
|
|||
|
||||
Self {
|
||||
auth: resolved_auth,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +273,10 @@ impl GeminiProvider {
|
|||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.gemini", 120, 10)
|
||||
}
|
||||
|
||||
fn build_generate_content_request(
|
||||
&self,
|
||||
auth: &GeminiAuth,
|
||||
|
|
@ -286,6 +284,7 @@ impl GeminiProvider {
|
|||
request: &GenerateContentRequest,
|
||||
model: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let req = self.http_client().post(url).json(request);
|
||||
match auth {
|
||||
GeminiAuth::OAuthToken(token) => {
|
||||
// Internal API expects the model in the request body envelope
|
||||
|
|
@ -317,12 +316,12 @@ impl GeminiProvider {
|
|||
.collect(),
|
||||
}),
|
||||
};
|
||||
self.client
|
||||
self.http_client()
|
||||
.post(url)
|
||||
.json(&internal_request)
|
||||
.bearer_auth(token)
|
||||
}
|
||||
_ => self.client.post(url).json(request),
|
||||
_ => req,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -408,7 +407,7 @@ impl Provider for GeminiProvider {
|
|||
"https://generativelanguage.googleapis.com/v1beta/models".to_string()
|
||||
};
|
||||
|
||||
let mut request = self.client.get(&url);
|
||||
let mut request = self.http_client().get(&url);
|
||||
if let GeminiAuth::OAuthToken(token) = auth {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
|
|
@ -470,17 +469,13 @@ mod tests {
|
|||
fn auth_source_explicit_key() {
|
||||
let provider = GeminiProvider {
|
||||
auth: Some(GeminiAuth::ExplicitKey("key".into())),
|
||||
client: Client::new(),
|
||||
};
|
||||
assert_eq!(provider.auth_source(), "config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_source_none_without_credentials() {
|
||||
let provider = GeminiProvider {
|
||||
auth: None,
|
||||
client: Client::new(),
|
||||
};
|
||||
let provider = GeminiProvider { auth: None };
|
||||
assert_eq!(provider.auth_source(), "none");
|
||||
}
|
||||
|
||||
|
|
@ -488,7 +483,6 @@ mod tests {
|
|||
fn auth_source_oauth() {
|
||||
let provider = GeminiProvider {
|
||||
auth: Some(GeminiAuth::OAuthToken("ya29.mock".into())),
|
||||
client: Client::new(),
|
||||
};
|
||||
assert_eq!(provider.auth_source(), "Gemini CLI OAuth");
|
||||
}
|
||||
|
|
@ -534,7 +528,6 @@ mod tests {
|
|||
fn oauth_request_uses_bearer_auth_header() {
|
||||
let provider = GeminiProvider {
|
||||
auth: Some(GeminiAuth::OAuthToken("ya29.mock-token".into())),
|
||||
client: Client::new(),
|
||||
};
|
||||
let auth = GeminiAuth::OAuthToken("ya29.mock-token".into());
|
||||
let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth);
|
||||
|
|
@ -570,7 +563,6 @@ mod tests {
|
|||
fn api_key_request_does_not_set_bearer_header() {
|
||||
let provider = GeminiProvider {
|
||||
auth: Some(GeminiAuth::ExplicitKey("api-key-123".into())),
|
||||
client: Client::new(),
|
||||
};
|
||||
let auth = GeminiAuth::ExplicitKey("api-key-123".into());
|
||||
let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth);
|
||||
|
|
@ -689,10 +681,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn warmup_without_key_is_noop() {
|
||||
let provider = GeminiProvider {
|
||||
auth: None,
|
||||
client: Client::new(),
|
||||
};
|
||||
let provider = GeminiProvider { auth: None };
|
||||
let result = provider.warmup().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ pub struct GlmProvider {
|
|||
api_key_id: String,
|
||||
api_key_secret: String,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
/// Cached JWT token + expiry timestamp (ms)
|
||||
token_cache: Mutex<Option<(String, u64)>>,
|
||||
}
|
||||
|
|
@ -90,11 +89,6 @@ impl GlmProvider {
|
|||
api_key_id: id,
|
||||
api_key_secret: secret,
|
||||
base_url: "https://api.z.ai/api/paas/v4".to_string(),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
token_cache: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
|
@ -149,6 +143,10 @@ impl GlmProvider {
|
|||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.glm", 120, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -185,7 +183,7 @@ impl Provider for GlmProvider {
|
|||
let url = format!("{}/chat/completions", self.base_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&request)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize};
|
|||
pub struct OllamaProvider {
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
// ─── Request Structures ───────────────────────────────────────────────────────
|
||||
|
|
@ -76,11 +75,6 @@ impl OllamaProvider {
|
|||
.trim_end_matches('/')
|
||||
.to_string(),
|
||||
api_key,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +85,10 @@ impl OllamaProvider {
|
|||
.is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"))
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.ollama", 300, 10)
|
||||
}
|
||||
|
||||
fn resolve_request_details(&self, model: &str) -> anyhow::Result<(String, bool)> {
|
||||
let requests_cloud = model.ends_with(":cloud");
|
||||
let normalized_model = model.strip_suffix(":cloud").unwrap_or(model).to_string();
|
||||
|
|
@ -139,7 +137,7 @@ impl OllamaProvider {
|
|||
temperature
|
||||
);
|
||||
|
||||
let mut request_builder = self.client.post(&url).json(&request);
|
||||
let mut request_builder = self.http_client().post(&url).json(&request);
|
||||
|
||||
if should_auth {
|
||||
if let Some(key) = self.api_key.as_ref() {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize};
|
|||
pub struct OpenAiProvider {
|
||||
base_url: String,
|
||||
credential: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -148,11 +147,6 @@ impl OpenAiProvider {
|
|||
.map(|u| u.trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| "https://api.openai.com/v1".to_string()),
|
||||
credential: credential.map(ToString::to_string),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +248,10 @@ impl OpenAiProvider {
|
|||
|
||||
ProviderChatResponse { text, tool_calls }
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.openai", 120, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -290,7 +288,7 @@ impl Provider for OpenAiProvider {
|
|||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(format!("{}/chat/completions", self.base_url))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.json(&request)
|
||||
|
|
@ -331,7 +329,7 @@ impl Provider for OpenAiProvider {
|
|||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client()
|
||||
.post(format!("{}/chat/completions", self.base_url))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.json(&native_request)
|
||||
|
|
@ -358,7 +356,7 @@ impl Provider for OpenAiProvider {
|
|||
|
||||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
if let Some(credential) = self.credential.as_ref() {
|
||||
self.client
|
||||
self.http_client()
|
||||
.get(format!("{}/models", self.base_url))
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.send()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
pub struct OpenRouterProvider {
|
||||
credential: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -113,11 +112,6 @@ impl OpenRouterProvider {
|
|||
pub fn new(credential: Option<&str>) -> Self {
|
||||
Self {
|
||||
credential: credential.map(ToString::to_string),
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +219,10 @@ impl OpenRouterProvider {
|
|||
tool_calls,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -233,7 +231,7 @@ impl Provider for OpenRouterProvider {
|
|||
// Hit a lightweight endpoint to establish TLS + HTTP/2 connection pool.
|
||||
// This prevents the first real chat request from timing out on cold start.
|
||||
if let Some(credential) = self.credential.as_ref() {
|
||||
self.client
|
||||
self.http_client()
|
||||
.get("https://openrouter.ai/api/v1/auth/key")
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.send()
|
||||
|
|
@ -274,7 +272,7 @@ impl Provider for OpenRouterProvider {
|
|||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client()
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.header(
|
||||
|
|
@ -324,7 +322,7 @@ impl Provider for OpenRouterProvider {
|
|||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client()
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.header(
|
||||
|
|
@ -372,7 +370,7 @@ impl Provider for OpenRouterProvider {
|
|||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client()
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.header(
|
||||
|
|
@ -460,7 +458,7 @@ impl Provider for OpenRouterProvider {
|
|||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.http_client()
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {credential}"))
|
||||
.header(
|
||||
|
|
|
|||
|
|
@ -736,7 +736,7 @@ impl BrowserTool {
|
|||
}
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let client = crate::config::build_runtime_proxy_client("tool.browser");
|
||||
let mut request = client
|
||||
.post(endpoint)
|
||||
.timeout(Duration::from_millis(self.computer_use.timeout_ms))
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ pub struct ComposioTool {
|
|||
api_key: String,
|
||||
default_entity_id: String,
|
||||
security: Arc<SecurityPolicy>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ComposioTool {
|
||||
|
|
@ -37,14 +36,13 @@ impl ComposioTool {
|
|||
api_key: api_key.to_string(),
|
||||
default_entity_id: normalize_entity_id(default_entity_id.unwrap_or("default")),
|
||||
security,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("tool.composio", 60, 10)
|
||||
}
|
||||
|
||||
/// List available Composio apps/actions for the authenticated user.
|
||||
///
|
||||
/// Uses v3 endpoint first and falls back to v2 for compatibility.
|
||||
|
|
@ -68,7 +66,7 @@ impl ComposioTool {
|
|||
|
||||
async fn list_actions_v3(&self, app_name: Option<&str>) -> anyhow::Result<Vec<ComposioAction>> {
|
||||
let url = format!("{COMPOSIO_API_BASE_V3}/tools");
|
||||
let mut req = self.client.get(&url).header("x-api-key", &self.api_key);
|
||||
let mut req = self.client().get(&url).header("x-api-key", &self.api_key);
|
||||
|
||||
req = req.query(&[("limit", "200")]);
|
||||
if let Some(app) = app_name.map(str::trim).filter(|app| !app.is_empty()) {
|
||||
|
|
@ -95,7 +93,7 @@ impl ComposioTool {
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.client()
|
||||
.get(&url)
|
||||
.header("x-api-key", &self.api_key)
|
||||
.send()
|
||||
|
|
@ -180,7 +178,7 @@ impl ComposioTool {
|
|||
);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.client()
|
||||
.post(&url)
|
||||
.header("x-api-key", &self.api_key)
|
||||
.json(&body)
|
||||
|
|
@ -216,7 +214,7 @@ impl ComposioTool {
|
|||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.client()
|
||||
.post(&url)
|
||||
.header("x-api-key", &self.api_key)
|
||||
.json(&body)
|
||||
|
|
@ -288,7 +286,7 @@ impl ComposioTool {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.client()
|
||||
.post(&url)
|
||||
.header("x-api-key", &self.api_key)
|
||||
.json(&body)
|
||||
|
|
@ -321,7 +319,7 @@ impl ComposioTool {
|
|||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.client()
|
||||
.post(&url)
|
||||
.header("x-api-key", &self.api_key)
|
||||
.json(&body)
|
||||
|
|
@ -345,7 +343,7 @@ impl ComposioTool {
|
|||
let url = format!("{COMPOSIO_API_BASE_V3}/auth_configs");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.client()
|
||||
.get(&url)
|
||||
.header("x-api-key", &self.api_key)
|
||||
.query(&[
|
||||
|
|
|
|||
|
|
@ -114,10 +114,12 @@ impl HttpRequestTool {
|
|||
headers: Vec<(String, String)>,
|
||||
body: Option<&str>,
|
||||
) -> anyhow::Result<reqwest::Response> {
|
||||
let client = reqwest::Client::builder()
|
||||
let builder = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(self.timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
let builder = crate::config::apply_runtime_proxy_to_builder(builder, "tool.http_request");
|
||||
let client = builder.build()?;
|
||||
|
||||
let mut request = client.request(method, url);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub mod image_info;
|
|||
pub mod memory_forget;
|
||||
pub mod memory_recall;
|
||||
pub mod memory_store;
|
||||
pub mod proxy_config;
|
||||
pub mod pushover;
|
||||
pub mod schedule;
|
||||
pub mod schema;
|
||||
|
|
@ -48,6 +49,7 @@ pub use image_info::ImageInfoTool;
|
|||
pub use memory_forget::MemoryForgetTool;
|
||||
pub use memory_recall::MemoryRecallTool;
|
||||
pub use memory_store::MemoryStoreTool;
|
||||
pub use proxy_config::ProxyConfigTool;
|
||||
pub use pushover::PushoverTool;
|
||||
pub use schedule::ScheduleTool;
|
||||
#[allow(unused_imports)]
|
||||
|
|
@ -144,6 +146,7 @@ pub fn all_tools_with_runtime(
|
|||
Box::new(MemoryRecallTool::new(memory.clone())),
|
||||
Box::new(MemoryForgetTool::new(memory, security.clone())),
|
||||
Box::new(ScheduleTool::new(security.clone(), root_config.clone())),
|
||||
Box::new(ProxyConfigTool::new(config.clone(), security.clone())),
|
||||
Box::new(GitOperationsTool::new(
|
||||
security.clone(),
|
||||
workspace_dir.to_path_buf(),
|
||||
|
|
@ -292,6 +295,7 @@ mod tests {
|
|||
assert!(!names.contains(&"browser_open"));
|
||||
assert!(names.contains(&"schedule"));
|
||||
assert!(names.contains(&"pushover"));
|
||||
assert!(names.contains(&"proxy_config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -330,6 +334,7 @@ mod tests {
|
|||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(names.contains(&"browser_open"));
|
||||
assert!(names.contains(&"pushover"));
|
||||
assert!(names.contains(&"proxy_config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
492
src/tools/proxy_config.rs
Normal file
492
src/tools/proxy_config.rs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::config::{
|
||||
runtime_proxy_config, set_runtime_proxy_config, Config, ProxyConfig, ProxyScope,
|
||||
};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ProxyConfigTool {
|
||||
config: Arc<Config>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl ProxyConfigTool {
|
||||
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { config, security }
|
||||
}
|
||||
|
||||
fn load_config_without_env(&self) -> anyhow::Result<Config> {
|
||||
let contents = fs::read_to_string(&self.config.config_path).map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to read config file {}: {error}",
|
||||
self.config.config_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut parsed: Config = toml::from_str(&contents).map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to parse config file {}: {error}",
|
||||
self.config.config_path.display()
|
||||
)
|
||||
})?;
|
||||
parsed.config_path = self.config.config_path.clone();
|
||||
parsed.workspace_dir = self.config.workspace_dir.clone();
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn require_write_access(&self) -> Option<ToolResult> {
|
||||
if !self.security.can_act() {
|
||||
return Some(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Action blocked: autonomy is read-only".into()),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.security.record_action() {
|
||||
return Some(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Action blocked: rate limit exceeded".into()),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_scope(raw: &str) -> Option<ProxyScope> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"environment" | "env" => Some(ProxyScope::Environment),
|
||||
"zeroclaw" | "internal" | "core" => Some(ProxyScope::Zeroclaw),
|
||||
"services" | "service" => Some(ProxyScope::Services),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_string_list(raw: &Value, field: &str) -> anyhow::Result<Vec<String>> {
|
||||
if let Some(raw_string) = raw.as_str() {
|
||||
return Ok(raw_string
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect());
|
||||
}
|
||||
|
||||
if let Some(array) = raw.as_array() {
|
||||
let mut out = Vec::new();
|
||||
for item in array {
|
||||
let value = item
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("'{field}' array must only contain strings"))?;
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
anyhow::bail!("'{field}' must be a string or string[]")
|
||||
}
|
||||
|
||||
fn parse_optional_string_update(
|
||||
args: &Value,
|
||||
field: &str,
|
||||
) -> anyhow::Result<Option<Option<String>>> {
|
||||
let Some(raw) = args.get(field) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if raw.is_null() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
|
||||
let value = raw
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("'{field}' must be a string or null"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(Some((!value.is_empty()).then_some(value)))
|
||||
}
|
||||
|
||||
fn env_snapshot() -> Value {
|
||||
json!({
|
||||
"HTTP_PROXY": std::env::var("HTTP_PROXY").ok(),
|
||||
"HTTPS_PROXY": std::env::var("HTTPS_PROXY").ok(),
|
||||
"ALL_PROXY": std::env::var("ALL_PROXY").ok(),
|
||||
"NO_PROXY": std::env::var("NO_PROXY").ok(),
|
||||
})
|
||||
}
|
||||
|
||||
fn proxy_json(proxy: &ProxyConfig) -> Value {
|
||||
json!({
|
||||
"enabled": proxy.enabled,
|
||||
"scope": proxy.scope,
|
||||
"http_proxy": proxy.http_proxy,
|
||||
"https_proxy": proxy.https_proxy,
|
||||
"all_proxy": proxy.all_proxy,
|
||||
"no_proxy": proxy.normalized_no_proxy(),
|
||||
"services": proxy.normalized_services(),
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get(&self) -> anyhow::Result<ToolResult> {
|
||||
let file_proxy = self.load_config_without_env()?.proxy;
|
||||
let runtime_proxy = runtime_proxy_config();
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"proxy": Self::proxy_json(&file_proxy),
|
||||
"runtime_proxy": Self::proxy_json(&runtime_proxy),
|
||||
"environment": Self::env_snapshot(),
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_services(&self) -> anyhow::Result<ToolResult> {
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"supported_service_keys": ProxyConfig::supported_service_keys(),
|
||||
"supported_selectors": ProxyConfig::supported_service_selectors(),
|
||||
"usage_example": {
|
||||
"action": "set",
|
||||
"scope": "services",
|
||||
"services": ["provider.openai", "tool.http_request", "channel.telegram"]
|
||||
}
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_set(&self, args: &Value) -> anyhow::Result<ToolResult> {
|
||||
let mut cfg = self.load_config_without_env()?;
|
||||
let previous_scope = cfg.proxy.scope;
|
||||
let mut proxy = cfg.proxy.clone();
|
||||
let mut touched_proxy_url = false;
|
||||
|
||||
if let Some(enabled) = args.get("enabled") {
|
||||
proxy.enabled = enabled
|
||||
.as_bool()
|
||||
.ok_or_else(|| anyhow::anyhow!("'enabled' must be a boolean"))?;
|
||||
}
|
||||
|
||||
if let Some(scope_raw) = args.get("scope") {
|
||||
let scope = scope_raw
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("'scope' must be a string"))?;
|
||||
proxy.scope = Self::parse_scope(scope).ok_or_else(|| {
|
||||
anyhow::anyhow!("Invalid scope '{scope}'. Use environment|zeroclaw|services")
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(update) = Self::parse_optional_string_update(args, "http_proxy")? {
|
||||
proxy.http_proxy = update;
|
||||
touched_proxy_url = true;
|
||||
}
|
||||
|
||||
if let Some(update) = Self::parse_optional_string_update(args, "https_proxy")? {
|
||||
proxy.https_proxy = update;
|
||||
touched_proxy_url = true;
|
||||
}
|
||||
|
||||
if let Some(update) = Self::parse_optional_string_update(args, "all_proxy")? {
|
||||
proxy.all_proxy = update;
|
||||
touched_proxy_url = true;
|
||||
}
|
||||
|
||||
if let Some(no_proxy_raw) = args.get("no_proxy") {
|
||||
proxy.no_proxy = Self::parse_string_list(no_proxy_raw, "no_proxy")?;
|
||||
}
|
||||
|
||||
if let Some(services_raw) = args.get("services") {
|
||||
proxy.services = Self::parse_string_list(services_raw, "services")?;
|
||||
}
|
||||
|
||||
if args.get("enabled").is_none() && touched_proxy_url {
|
||||
proxy.enabled = true;
|
||||
}
|
||||
|
||||
proxy.no_proxy = proxy.normalized_no_proxy();
|
||||
proxy.services = proxy.normalized_services();
|
||||
proxy.validate()?;
|
||||
|
||||
cfg.proxy = proxy.clone();
|
||||
cfg.save()?;
|
||||
set_runtime_proxy_config(proxy.clone());
|
||||
|
||||
if proxy.enabled && proxy.scope == ProxyScope::Environment {
|
||||
proxy.apply_to_process_env();
|
||||
} else if previous_scope == ProxyScope::Environment {
|
||||
ProxyConfig::clear_process_env();
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"message": "Proxy configuration updated",
|
||||
"proxy": Self::proxy_json(&proxy),
|
||||
"environment": Self::env_snapshot(),
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_disable(&self, args: &Value) -> anyhow::Result<ToolResult> {
|
||||
let mut cfg = self.load_config_without_env()?;
|
||||
let clear_env_default = cfg.proxy.scope == ProxyScope::Environment;
|
||||
cfg.proxy.enabled = false;
|
||||
cfg.save()?;
|
||||
|
||||
set_runtime_proxy_config(cfg.proxy.clone());
|
||||
|
||||
let clear_env = args
|
||||
.get("clear_env")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(clear_env_default);
|
||||
if clear_env {
|
||||
ProxyConfig::clear_process_env();
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"message": "Proxy disabled",
|
||||
"proxy": Self::proxy_json(&cfg.proxy),
|
||||
"environment": Self::env_snapshot(),
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_apply_env(&self) -> anyhow::Result<ToolResult> {
|
||||
let cfg = self.load_config_without_env()?;
|
||||
let proxy = cfg.proxy;
|
||||
proxy.validate()?;
|
||||
|
||||
if !proxy.enabled {
|
||||
anyhow::bail!("Proxy is disabled. Use action 'set' with enabled=true first");
|
||||
}
|
||||
|
||||
if proxy.scope != ProxyScope::Environment {
|
||||
anyhow::bail!(
|
||||
"apply_env only works when proxy.scope is 'environment' (current: {:?})",
|
||||
proxy.scope
|
||||
);
|
||||
}
|
||||
|
||||
proxy.apply_to_process_env();
|
||||
set_runtime_proxy_config(proxy.clone());
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"message": "Proxy environment variables applied",
|
||||
"proxy": Self::proxy_json(&proxy),
|
||||
"environment": Self::env_snapshot(),
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_clear_env(&self) -> anyhow::Result<ToolResult> {
|
||||
ProxyConfig::clear_process_env();
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"message": "Proxy environment variables cleared",
|
||||
"environment": Self::env_snapshot(),
|
||||
}))?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ProxyConfigTool {
|
||||
fn name(&self) -> &str {
|
||||
"proxy_config"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Manage ZeroClaw proxy settings (scope: environment | zeroclaw | services), including runtime and process env application"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "set", "disable", "list_services", "apply_env", "clear_env"],
|
||||
"default": "get"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable proxy"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "Proxy scope: environment | zeroclaw | services"
|
||||
},
|
||||
"http_proxy": {
|
||||
"type": ["string", "null"],
|
||||
"description": "HTTP proxy URL"
|
||||
},
|
||||
"https_proxy": {
|
||||
"type": ["string", "null"],
|
||||
"description": "HTTPS proxy URL"
|
||||
},
|
||||
"all_proxy": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Fallback proxy URL for all protocols"
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "Comma-separated string or array of NO_PROXY entries",
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}}
|
||||
]
|
||||
},
|
||||
"services": {
|
||||
"description": "Comma-separated string or array of service selectors used when scope=services",
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}}
|
||||
]
|
||||
},
|
||||
"clear_env": {
|
||||
"type": "boolean",
|
||||
"description": "When action=disable, clear process proxy environment variables"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let action = args
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("get")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
let result = match action.as_str() {
|
||||
"get" => self.handle_get(),
|
||||
"list_services" => self.handle_list_services(),
|
||||
"set" | "disable" | "apply_env" | "clear_env" => {
|
||||
if let Some(blocked) = self.require_write_access() {
|
||||
return Ok(blocked);
|
||||
}
|
||||
|
||||
match action.as_str() {
|
||||
"set" => self.handle_set(&args),
|
||||
"disable" => self.handle_disable(&args),
|
||||
"apply_env" => self.handle_apply_env(),
|
||||
"clear_env" => self.handle_clear_env(),
|
||||
_ => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"Unknown action '{action}'. Valid: get, set, disable, list_services, apply_env, clear_env"
|
||||
),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(error) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_security() -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(tmp: &TempDir) -> Arc<Config> {
|
||||
let config = Config {
|
||||
workspace_dir: tmp.path().join("workspace"),
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.save().unwrap();
|
||||
Arc::new(config)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_services_action_returns_known_keys() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = ProxyConfigTool::new(test_config(&tmp), test_security());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({"action": "list_services"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("provider.openai"));
|
||||
assert!(result.output.contains("tool.http_request"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_scope_services_requires_services_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = ProxyConfigTool::new(test_config(&tmp), test_security());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"action": "set",
|
||||
"enabled": true,
|
||||
"scope": "services",
|
||||
"http_proxy": "http://127.0.0.1:7890",
|
||||
"services": []
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result
|
||||
.error
|
||||
.unwrap_or_default()
|
||||
.contains("proxy.scope='services'"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_and_get_round_trip_proxy_scope() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = ProxyConfigTool::new(test_config(&tmp), test_security());
|
||||
|
||||
let set_result = tool
|
||||
.execute(json!({
|
||||
"action": "set",
|
||||
"scope": "services",
|
||||
"http_proxy": "http://127.0.0.1:7890",
|
||||
"services": ["provider.openai", "tool.http_request"]
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(set_result.success, "{:?}", set_result.error);
|
||||
|
||||
let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
|
||||
assert!(get_result.success);
|
||||
assert!(get_result.output.contains("provider.openai"));
|
||||
assert!(get_result.output.contains("services"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +1,21 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json";
|
||||
const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
pub struct PushoverTool {
|
||||
client: Client,
|
||||
security: Arc<SecurityPolicy>,
|
||||
workspace_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PushoverTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>, workspace_dir: PathBuf) -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(PUSHOVER_REQUEST_TIMEOUT_SECS))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
client,
|
||||
security,
|
||||
workspace_dir,
|
||||
}
|
||||
|
|
@ -182,12 +173,12 @@ impl Tool for PushoverTool {
|
|||
form = form.text("sound", sound);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(PUSHOVER_API_URL)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
let client = crate::config::build_runtime_proxy_client_with_timeouts(
|
||||
"tool.pushover",
|
||||
PUSHOVER_REQUEST_TIMEOUT_SECS,
|
||||
10,
|
||||
);
|
||||
let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ impl Tunnel for CustomTunnel {
|
|||
async fn health_check(&self) -> bool {
|
||||
// If a health URL is configured, try to reach it
|
||||
if let Some(ref url) = self.health_url {
|
||||
return reqwest::Client::new()
|
||||
return crate::config::build_runtime_proxy_client("tunnel.custom")
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue