diff --git a/README.md b/README.md index 061039b..5a7a3a6 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,16 @@ Construct the client explicitly with `ClientConfig::openrouter("")`. OpenRouter resolves `jev-latest` to a concrete `typesafe/jev-*` model ID in its response. +Compatible routers exposed at a nonstandard path can be selected without +weakening provider-aware response validation: + +```rust +let config = tinyjevclient::ClientConfig::openrouter("") + .with_endpoint_url("https://openrouter.ai/api/alpha/decisions"); +let client = tinyjevclient::Client::new(config)?; +# Ok::<(), tinyjevclient::Error>(()) +``` + For a Tiny Humans API key, use `ClientConfig::tinyhumans_openrouter("")`, which explicitly targets `https://api.tinyhumans.ai/agent-integrations/openrouter/systemone`. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index c9a6c90..0161b82 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -109,11 +109,13 @@ impl Client { &self, request: &EvaluationRequest, ) -> std::result::Result<(EvaluationResponse, Option), Failure> { - let url = format!( - "{}/{}", - self.config.base_url.trim_end_matches('/'), - self.config.system_one_path - ); + 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) @@ -157,37 +159,9 @@ impl ClientConfig { reason: "api key must not be empty".to_owned(), }); } - let url = reqwest::Url::parse(&self.base_url).map_err(|_| Error::InvalidConfig { - reason: "base URL must be an absolute HTTP URL".to_owned(), - })?; - if !matches!(url.scheme(), "http" | "https") { - return Err(Error::InvalidConfig { - reason: "base URL must use HTTP or HTTPS".to_owned(), - }); - } - if !url.username().is_empty() || url.password().is_some() { - return Err(Error::InvalidConfig { - reason: "base URL must not contain credentials".to_owned(), - }); - } - if url.query().is_some() || url.fragment().is_some() { - return Err(Error::InvalidConfig { - reason: "base URL must not contain a query or fragment".to_owned(), - }); - } - if url.scheme() == "http" - && !url - .host_str() - .and_then(|host| { - host.trim_matches(['[', ']']) - .parse::() - .ok() - }) - .is_some_and(|address| address.is_loopback()) - { - return Err(Error::InvalidConfig { - reason: "HTTP base URLs must use a literal loopback address".to_owned(), - }); + validate_url(&self.base_url, "base URL")?; + if let Some(endpoint_url) = &self.endpoint_url { + validate_url(endpoint_url, "endpoint URL")?; } if self.timeout.is_zero() { return Err(Error::InvalidConfig { @@ -208,6 +182,42 @@ impl ClientConfig { } } +fn validate_url(value: &str, label: &str) -> Result<()> { + let url = reqwest::Url::parse(value).map_err(|_| Error::InvalidConfig { + reason: format!("{label} must be an absolute HTTP URL"), + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(Error::InvalidConfig { + reason: format!("{label} must use HTTP or HTTPS"), + }); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(Error::InvalidConfig { + reason: format!("{label} must not contain credentials"), + }); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(Error::InvalidConfig { + reason: format!("{label} must not contain a query or fragment"), + }); + } + if url.scheme() == "http" + && !url + .host_str() + .and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }) + .is_some_and(|address| address.is_loopback()) + { + return Err(Error::InvalidConfig { + reason: format!("HTTP {label} must use a literal loopback address"), + }); + } + Ok(()) +} + enum Failure { Terminal(Error), Retryable { diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 572fb1f..00fa2c5 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -129,6 +129,34 @@ async fn sends_the_documented_endpoint_and_bearer_header() { assert!(sent.contains("\"model\":\"jev-latest\"")); } +#[tokio::test] +async fn an_exact_endpoint_override_is_not_extended_with_a_provider_path() { + let (base_url, requests) = server(vec![response( + 200, + &success().replace("jev-latest", "typesafe/jev-1.13-20260917"), + "", + )]) + .await; + let endpoint = format!("{base_url}/api/alpha/decisions"); + let mut config = ClientConfig::openrouter("secret-test-key").with_endpoint_url(endpoint); + config.timeout = Duration::from_secs(1); + config.retry.max_retries = 0; + + Client::new(config) + .unwrap() + .evaluate(&request()) + .await + .unwrap(); + + assert!( + requests + .lock() + .await + .join("") + .starts_with("POST /api/alpha/decisions HTTP/1.1") + ); +} + #[tokio::test] async fn openrouter_uses_system_one_and_accepts_a_resolved_jev_model() { let (base_url, requests) = server(vec![response( @@ -249,6 +277,27 @@ fn validates_every_configuration_bound_and_redacted_key_replacement() { let replaced = ClientConfig::new("old").with_api_key("new-secret"); let rendered = format!("{replaced:?}"); assert!(!rendered.contains("new-secret")); + let endpoint = + ClientConfig::new("key").with_endpoint_url("https://example.com/custom/decisions"); + assert_eq!( + endpoint.endpoint_url(), + Some("https://example.com/custom/decisions") + ); + for endpoint_url in [ + "not a URL", + "file:///tmp/decisions", + "http://example.com/decisions", + "http://localhost:8080/decisions", + "https://user:password@example.com/decisions", + "https://example.com/decisions?tenant=x", + "https://example.com/decisions#fragment", + ] { + let config = ClientConfig::new("key").with_endpoint_url(endpoint_url); + assert!(matches!( + Client::new(config), + Err(Error::InvalidConfig { .. }) + )); + } let mut scheme = ClientConfig::new("key"); scheme.base_url = "file:///tmp/socket".into(); diff --git a/crates/tinyjevclient/src/client/types.rs b/crates/tinyjevclient/src/client/types.rs index 350001f..a7d9e15 100644 --- a/crates/tinyjevclient/src/client/types.rs +++ b/crates/tinyjevclient/src/client/types.rs @@ -40,6 +40,8 @@ pub struct ClientConfig { pub base_url: String, /// Provider-specific System One endpoint path. pub(super) system_one_path: &'static str, + /// Exact evaluation endpoint, when the provider is exposed elsewhere. + pub(super) endpoint_url: Option, /// Provider-specific response validation behavior. pub provider: Provider, /// Total timeout for one HTTP attempt. @@ -56,6 +58,7 @@ impl ClientConfig { api_key: ApiKey(api_key.into()), base_url: "https://api.typesafe.ai".to_owned(), system_one_path: DEFAULT_SYSTEM_ONE_PATH, + endpoint_url: None, provider: Provider::TypeSafe, timeout: Duration::from_secs(30), retry: RetryPolicy::default(), @@ -69,6 +72,7 @@ impl ClientConfig { api_key: ApiKey(api_key.into()), base_url: "https://openrouter.ai/api".to_owned(), system_one_path: DEFAULT_SYSTEM_ONE_PATH, + endpoint_url: None, provider: Provider::OpenRouter, timeout: Duration::from_secs(30), retry: RetryPolicy::default(), @@ -85,6 +89,7 @@ impl ClientConfig { api_key: ApiKey(api_key.into()), base_url: "https://api.tinyhumans.ai".to_owned(), system_one_path: TINYHUMANS_SYSTEM_ONE_PATH, + endpoint_url: None, provider: Provider::OpenRouter, timeout: Duration::from_secs(30), retry: RetryPolicy::default(), @@ -97,6 +102,20 @@ impl ClientConfig { self.api_key = ApiKey(api_key.into()); self } + + /// Use an exact evaluation endpoint instead of the provider's conventional + /// System One path. + #[must_use] + pub fn with_endpoint_url(mut self, endpoint_url: impl Into) -> Self { + self.endpoint_url = Some(endpoint_url.into()); + self + } + + /// Returns the exact endpoint override, when configured. + #[must_use] + pub fn endpoint_url(&self) -> Option<&str> { + self.endpoint_url.as_deref() + } } impl fmt::Debug for ClientConfig { @@ -105,6 +124,7 @@ impl fmt::Debug for ClientConfig { .field("api_key", &"[REDACTED]") .field("base_url", &self.base_url) .field("system_one_path", &self.system_one_path) + .field("endpoint_url", &self.endpoint_url) .field("provider", &self.provider) .field("timeout", &self.timeout) .field("retry", &self.retry) diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md index ce11898..5d5ada9 100644 --- a/docs/specs/system-one-client.md +++ b/docs/specs/system-one-client.md @@ -53,6 +53,9 @@ loopback IP addresses used by local test servers. `ClientConfig::openrouter` uses OpenRouter's compatible System One base URL, `https://openrouter.ai/api`. The first-party constructor and `Client::from_env` retain the `TypeSafe` endpoint and `TYPESAFE_API_KEY` behavior. +`ClientConfig::with_endpoint_url` may select an exact compatible endpoint such +as OpenRouter's Decisions route while preserving the chosen provider's response +validation and every URL hardening rule. `ClientConfig::tinyhumans_openrouter` uses the Tiny Humans OpenRouter System One endpoint, `https://api.tinyhumans.ai/agent-integrations/openrouter/systemone`,