diff --git a/README.md b/README.md index 5a7a3a6..b1aa0f5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ let client = tinyjevclient::Client::new(config)?; For a Tiny Humans API key, use `ClientConfig::tinyhumans_openrouter("")`, which explicitly targets `https://api.tinyhumans.ai/agent-integrations/openrouter/systemone`. +Hosts can call `.with_sdk_name("openhuman")` to attach sanitized `x-sdk-name` +product attribution. The header is sent only to that exact Tiny Humans HTTPS +endpoint, even when an endpoint override is configured. Remote API roots must use HTTPS; HTTP is reserved for literal loopback IPs. Failed evaluations retain their classified error, attempt count, and elapsed diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md index c05dfd6..613ffd6 100644 --- a/crates/tinyjevclient/src/client/README.md +++ b/crates/tinyjevclient/src/client/README.md @@ -18,3 +18,6 @@ are terminal, and automatic redirects are disabled. `ClientConfig::tinyhumans_openrouter` targets the Tiny Humans OpenRouter proxy at `https://api.tinyhumans.ai/agent-integrations/openrouter/systemone`. +`ClientConfig::with_sdk_name` sanitizes product attribution and sends +`x-sdk-name` only to this exact HTTPS endpoint. OpenRouter, TypeSafe, and +other endpoint overrides do not receive it. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index 0161b82..eff53d1 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -109,18 +109,8 @@ impl Client { &self, request: &EvaluationRequest, ) -> std::result::Result<(EvaluationResponse, Option), Failure> { - let url = self.config.endpoint_url.clone().unwrap_or_else(|| { - format!( - "{}/{}", - self.config.base_url.trim_end_matches('/'), - self.config.system_one_path - ) - }); let response = self - .http - .post(url) - .bearer_auth(self.config.api_key.expose()) - .json(request) + .evaluation_request(request) .send() .await .map_err(classify_transport)?; @@ -140,6 +130,27 @@ impl Client { Ok((decoded, request_id)) } + fn evaluation_request(&self, request: &EvaluationRequest) -> reqwest::RequestBuilder { + let url = self.config.endpoint_url.clone().unwrap_or_else(|| { + format!( + "{}/{}", + self.config.base_url.trim_end_matches('/'), + self.config.system_one_path + ) + }); + let mut builder = self + .http + .post(&url) + .bearer_auth(self.config.api_key.expose()) + .json(request); + if let Some(name) = self.config.sdk_name.as_deref() + && is_tinyhumans_proxy_endpoint(&url) + { + builder = builder.header("x-sdk-name", name); + } + builder + } + fn validate_response( &self, response: &EvaluationResponse, @@ -152,6 +163,20 @@ impl Client { } } +fn is_tinyhumans_proxy_endpoint(raw: &str) -> bool { + let Ok(url) = reqwest::Url::parse(raw) else { + return false; + }; + url.scheme() == "https" + && url.host_str() == Some("api.tinyhumans.ai") + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/agent-integrations/openrouter/systemone" + && url.query().is_none() + && url.fragment().is_none() +} + impl ClientConfig { fn validate(&self) -> Result<()> { if self.api_key.expose().trim().is_empty() { diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 00fa2c5..feb49f7 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -205,6 +205,53 @@ async fn tinyhumans_proxy_uses_the_direct_system_one_path() { ); } +#[test] +fn sdk_name_is_sanitized_and_sent_only_to_the_exact_tinyhumans_proxy() { + let config = ClientConfig::tinyhumans_openrouter("key") + .with_sdk_name(" OpenCompany\r\nInjected: yes / test "); + let client = Client::new(config.clone()).unwrap(); + let outgoing = client.evaluation_request(&request()).build().unwrap(); + assert_eq!( + outgoing.headers().get("x-sdk-name").unwrap(), + "opencompanyinjectedyestest" + ); + + let direct = Client::new(ClientConfig::openrouter("key").with_sdk_name("openhuman")).unwrap(); + assert!( + direct + .evaluation_request(&request()) + .build() + .unwrap() + .headers() + .get("x-sdk-name") + .is_none() + ); + + for endpoint in [ + "https://api.tinyhumans.ai.evil.example/agent-integrations/openrouter/systemone", + "https://api.tinyhumans.ai:444/agent-integrations/openrouter/systemone", + "https://example.com/agent-integrations/openrouter/systemone", + ] { + let other = Client::new(config.clone().with_endpoint_url(endpoint)).unwrap(); + assert!( + other + .evaluation_request(&request()) + .build() + .unwrap() + .headers() + .get("x-sdk-name") + .is_none(), + "must not attribute {endpoint}" + ); + } + assert!( + Client::new(config.with_endpoint_url( + "https://api.tinyhumans.ai/agent-integrations/openrouter/systemone?redirect=1" + )) + .is_err() + ); +} + #[tokio::test] async fn retries_rate_limits_and_reports_attempts() { let (base_url, requests) = diff --git a/crates/tinyjevclient/src/client/types.rs b/crates/tinyjevclient/src/client/types.rs index a7d9e15..90634a5 100644 --- a/crates/tinyjevclient/src/client/types.rs +++ b/crates/tinyjevclient/src/client/types.rs @@ -48,6 +48,8 @@ pub struct ClientConfig { pub timeout: Duration, /// Transient failure retry policy. pub retry: RetryPolicy, + /// Sanitized product attribution for the exact `TinyHumans` proxy endpoint. + pub(super) sdk_name: Option, } impl ClientConfig { @@ -62,6 +64,7 @@ impl ClientConfig { provider: Provider::TypeSafe, timeout: Duration::from_secs(30), retry: RetryPolicy::default(), + sdk_name: None, } } @@ -76,6 +79,7 @@ impl ClientConfig { provider: Provider::OpenRouter, timeout: Duration::from_secs(30), retry: RetryPolicy::default(), + sdk_name: None, } } @@ -93,6 +97,7 @@ impl ClientConfig { provider: Provider::OpenRouter, timeout: Duration::from_secs(30), retry: RetryPolicy::default(), + sdk_name: None, } } @@ -111,6 +116,24 @@ impl ClientConfig { self } + /// Attribute `TinyHumans` proxy requests to a product. Invalid characters + /// are dropped, the value is lowercased and capped at 64 ASCII bytes. + /// Other endpoints never receive this header. + #[must_use] + pub fn with_sdk_name(mut self, raw: &str) -> Self { + let name = raw + .trim() + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + .take(64) + .map(|character| character.to_ascii_lowercase()) + .collect::(); + self.sdk_name = (!name.is_empty()).then_some(name); + self + } + /// Returns the exact endpoint override, when configured. #[must_use] pub fn endpoint_url(&self) -> Option<&str> { @@ -128,6 +151,7 @@ impl fmt::Debug for ClientConfig { .field("provider", &self.provider) .field("timeout", &self.timeout) .field("retry", &self.retry) + .field("sdk_name", &self.sdk_name) .finish() } }