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:
Chummy 2026-02-18 21:09:01 +08:00
parent 13ee9e6398
commit ce104bed45
36 changed files with 2025 additions and 323 deletions

View file

@ -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()

View file

@ -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?;
}

View file

@ -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);
}

View file

@ -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());
}

View file

@ -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)

View file

@ -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() {

View file

@ -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()

View file

@ -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(