Skip to content
This repository was archived by the owner on Oct 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ Construct the client explicitly with `ClientConfig::openrouter("<key>")`.
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("<key>")
.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("<key>")`,
which explicitly targets
`https://api.tinyhumans.ai/agent-integrations/openrouter/systemone`.
Expand Down
82 changes: 46 additions & 36 deletions crates/tinyjevclient/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,13 @@ impl Client {
&self,
request: &EvaluationRequest,
) -> std::result::Result<(EvaluationResponse, Option<String>), 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)
Expand Down Expand Up @@ -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::<std::net::IpAddr>()
.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 {
Expand All @@ -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::<std::net::IpAddr>()
.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 {
Expand Down
49 changes: 49 additions & 0 deletions crates/tinyjevclient/src/client/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,34 @@ async fn sends_the_documented_endpoint_and_bearer_header() {
assert!(sent.contains("\"model\":\"jev-latest\""));
}

#[tokio::test]
Comment thread
senamakel marked this conversation as resolved.
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(
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions crates/tinyjevclient/src/client/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Provider-specific response validation behavior.
pub provider: Provider,
/// Total timeout for one HTTP attempt.
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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<String>) -> 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 {
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions docs/specs/system-one-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading