KeiSeiKit-1.0/_primitives/_rust/kei-spawn/src/drive_http.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

200 lines
6.8 KiB
Rust

//! drive_http — reqwest::blocking-backed Anthropic driver.
//!
//! Gated behind the `http-driver` Cargo feature. Reads `KEI_ANTHROPIC_KEY`
//! at every `invoke` call (so key rotation takes effect without rebuilds).
//!
//! Endpoint defaults to <https://api.anthropic.com/v1/messages> and can be
//! overridden via `KEI_ANTHROPIC_ENDPOINT` (test hook for httpmock).
//!
//! Constructor Pattern: one struct + one impl + small helpers, every fn
//! ≤30 LOC, file ≤200 LOC.
#![cfg(feature = "http-driver")]
use std::io::Read as _;
use std::time::Duration;
use crate::drive::{AgentResult, AnthropicDriver, DriveError};
use crate::drive_http_parse::{
compose_user_content, excerpt, parse_response, Message, MessagesRequest, ANTHROPIC_VERSION,
DEFAULT_ENDPOINT, MAX_TOKENS, MODEL_ID,
};
const ENV_API_KEY: &str = "KEI_ANTHROPIC_KEY";
const ENV_ENDPOINT: &str = "KEI_ANTHROPIC_ENDPOINT";
const TIMEOUT_TOTAL: Duration = Duration::from_secs(300);
// reqwest 0.12 blocking ClientBuilder exposes `connect_timeout` but not
// a per-read timeout; we cap the TCP+TLS handshake at 60s (matches the
// "60s read" intent — request-body read is bounded by the 300s total).
const TIMEOUT_CONNECT: Duration = Duration::from_secs(60);
const ERR_BODY_EXCERPT: usize = 512;
/// Cap response body at 10 MiB to mitigate memory-DoS from an oversized
/// or malicious upstream (CWE-400). Anthropic `messages` responses are
/// well under 1 MiB in practice; 10 MiB leaves ample headroom.
const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
/// Real Anthropic-backed driver. Zero-state: key + endpoint read per call.
pub struct HttpDriver;
impl AnthropicDriver for HttpDriver {
fn invoke(
&self,
prompt: &str,
subagent_type: &str,
isolation: Option<&str>,
) -> Result<AgentResult, DriveError> {
let key = read_key()?;
let endpoint = read_endpoint();
let client = build_client()?;
let user_content = compose_user_content(prompt, subagent_type, isolation);
let body = build_request_body(&user_content);
send_and_parse(&client, &endpoint, &key, &body)
}
}
fn read_key() -> Result<String, DriveError> {
std::env::var(ENV_API_KEY).map_err(|_| DriveError::Transport {
message: format!("{ENV_API_KEY} is not set in the environment"),
})
}
fn read_endpoint() -> String {
std::env::var(ENV_ENDPOINT).unwrap_or_else(|_| DEFAULT_ENDPOINT.to_string())
}
fn build_client() -> Result<reqwest::blocking::Client, DriveError> {
reqwest::blocking::Client::builder()
.timeout(TIMEOUT_TOTAL)
.connect_timeout(TIMEOUT_CONNECT)
.build()
.map_err(|e| DriveError::Transport {
message: format!("build reqwest client: {e}"),
})
}
fn build_request_body(user_content: &str) -> String {
let req = MessagesRequest {
model: MODEL_ID,
max_tokens: MAX_TOKENS,
messages: vec![Message {
role: "user",
content: user_content,
}],
};
// Safe: types are `Serialize` with only `&str`/`u32`/`Vec`.
serde_json::to_string(&req).unwrap_or_else(|_| "{}".to_string())
}
fn send_and_parse(
client: &reqwest::blocking::Client,
endpoint: &str,
key: &str,
body: &str,
) -> Result<AgentResult, DriveError> {
let resp = client
.post(endpoint)
.header("x-api-key", key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json")
.body(body.to_string())
.send()
.map_err(map_network_error)?;
let status = resp.status();
let text = read_body_bounded(resp)?;
if status.is_success() {
parse_response(&text)
} else {
Err(http_error(status.as_u16(), &text))
}
}
/// Read response body with a hard cap of `MAX_BODY_BYTES`.
///
/// Two defences layered:
/// 1. `content-length` pre-check rejects declared-huge bodies without
/// allocating (saves memory on honest servers advertising the size).
/// 2. `io::Read::take(MAX + 1)` caps the actual bytes consumed from the
/// wire — covers chunked-encoding where `content_length()` is `None`.
/// If the reader yields MAX+1 bytes we reject as oversize.
fn read_body_bounded(resp: reqwest::blocking::Response) -> Result<String, DriveError> {
if let Some(len) = resp.content_length() {
if len > MAX_BODY_BYTES as u64 {
return Err(DriveError::Transport {
message: format!(
"response body {len} bytes exceeds {MAX_BODY_BYTES}-byte limit (content-length)"
),
});
}
}
let mut buf = Vec::with_capacity(8192);
let mut limited = resp.take(MAX_BODY_BYTES as u64 + 1);
limited
.read_to_end(&mut buf)
.map_err(|e| DriveError::Transport {
message: format!("read response body: {e}"),
})?;
if buf.len() > MAX_BODY_BYTES {
return Err(DriveError::Transport {
message: format!("response body exceeds {MAX_BODY_BYTES}-byte limit (streamed)"),
});
}
String::from_utf8(buf).map_err(|e| DriveError::Transport {
message: format!("response body not utf-8: {e}"),
})
}
fn map_network_error(e: reqwest::Error) -> DriveError {
DriveError::Transport {
message: format!("network error: {e}"),
}
}
fn http_error(status: u16, body: &str) -> DriveError {
DriveError::Transport {
message: format!(
"HTTP {status}: body[:{ERR_BODY_EXCERPT}]={}",
excerpt(body, ERR_BODY_EXCERPT)
),
}
}
#[cfg(test)]
mod tests {
//! Unit-level tests for helpers. End-to-end tests (with httpmock)
//! live in `tests/http_driver.rs`.
use super::*;
#[test]
fn build_request_body_contains_model_and_prompt() {
let body = build_request_body("hello");
assert!(body.contains("\"model\":\"claude-opus-4-7\""));
assert!(body.contains("\"max_tokens\":4096"));
assert!(body.contains("\"role\":\"user\""));
assert!(body.contains("\"content\":\"hello\""));
}
#[test]
fn http_error_truncates_long_body() {
let long = "x".repeat(5_000);
let err = http_error(429, &long);
match err {
DriveError::Transport { message } => {
assert!(message.contains("HTTP 429"));
assert!(message.len() < 5_000);
}
other => panic!("expected Transport, got {other}"),
}
}
#[test]
fn read_endpoint_returns_default_when_unset() {
// Save + clear so the assertion is deterministic.
let prev = std::env::var(ENV_ENDPOINT).ok();
std::env::remove_var(ENV_ENDPOINT);
let got = read_endpoint();
if let Some(p) = prev {
std::env::set_var(ENV_ENDPOINT, p);
}
assert_eq!(got, DEFAULT_ENDPOINT);
}
}