From 417356c685e69ecc35d3d3f4fd37deaf24347164 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 20:00:37 +0300 Subject: [PATCH 1/3] feat: support OpenRouter System One inference Co-authored-by: Medulla --- README.md | 7 ++++ crates/tinyjevclient/src/client/README.md | 4 +++ crates/tinyjevclient/src/client/mod.rs | 27 ++++++++++++-- crates/tinyjevclient/src/client/test.rs | 25 +++++++++++++ crates/tinyjevclient/src/client/types.rs | 26 ++++++++++++++ crates/tinyjevclient/src/lib.rs | 4 ++- crates/tinyjevclient/src/response/mod.rs | 36 ++++++++++++++++++- crates/tinyjevclient/src/response/test.rs | 11 ++++++ crates/tinyjevclient/tests/openrouter_live.rs | 30 ++++++++++++++++ docs/specs/system-one-client.md | 10 +++++- 10 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 crates/tinyjevclient/tests/openrouter_live.rs diff --git a/README.md b/README.md index 60f757b..6baae05 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ real API call: TYPESAFE_API_KEY='' cargo run -p tinyjevclient --example basic ``` +## OpenRouter + +OpenRouter supports the same System One request and response format for Jev. +Use `Client::from_openrouter_env()` with `OPENROUTER_API_KEY`, or construct the +client explicitly with `ClientConfig::openrouter("")`. OpenRouter resolves +`jev-latest` to a concrete `typesafe/jev-*` model ID in its response. + Remote API roots must use HTTPS; HTTP is reserved for literal loopback IPs. Failed evaluations retain their classified error, attempt count, and elapsed time so reliability measurements do not lose unsuccessful work. diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md index 49d5a7b..2375987 100644 --- a/crates/tinyjevclient/src/client/README.md +++ b/crates/tinyjevclient/src/client/README.md @@ -12,3 +12,7 @@ transport failures use the same explicit bounded retry policy because the transport error taxonomy cannot reliably distinguish transient DNS, TLS, and connectivity failures from permanent ones. Other request/body/redirect errors are terminal, and automatic redirects are disabled. + +`ClientConfig::openrouter` targets OpenRouter's compatible System One API at +`https://openrouter.ai/api/v1/systemone`; `Client::from_openrouter_env` reads +`OPENROUTER_API_KEY` for that configuration. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index ab5b303..c2d1f6f 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -5,7 +5,7 @@ mod test; mod types; -pub use types::{Client, ClientConfig, EvaluationFailure, EvaluationResult, RetryPolicy}; +pub use types::{Client, ClientConfig, EvaluationFailure, EvaluationResult, Provider, RetryPolicy}; use std::time::{Duration, Instant}; @@ -44,6 +44,17 @@ impl Client { Self::new(ClientConfig::new(api_key)) } + /// Construct an `OpenRouter` client using `OPENROUTER_API_KEY`. + /// + /// # Errors + /// + /// Returns [`Error::MissingApiKey`] when the variable is absent, or the + /// same configuration errors as [`Self::new`]. + pub fn from_openrouter_env() -> Result { + let api_key = std::env::var("OPENROUTER_API_KEY").map_err(|_| Error::MissingApiKey)?; + Self::new(ClientConfig::openrouter(api_key)) + } + /// Evaluate typed questions against shared state. /// /// The returned latency includes retry delays and all attempts. Request and @@ -71,8 +82,7 @@ impl Client { attempts = attempts.saturating_add(1); match self.send_once(request).await { Ok((response, request_id)) => { - response - .validate_for(request) + self.validate_response(&response, request) .map_err(|error| EvaluationFailure { error, attempts, @@ -139,6 +149,17 @@ impl Client { .map_err(|source| Failure::Terminal(Error::Decode { source }))?; Ok((decoded, request_id)) } + + fn validate_response( + &self, + response: &EvaluationResponse, + request: &EvaluationRequest, + ) -> Result<()> { + match self.config.provider { + Provider::TypeSafe => response.validate_for(request), + Provider::OpenRouter => response.validate_for_openrouter(request), + } + } } impl ClientConfig { diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 3e67df9..26f4f9e 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -129,6 +129,28 @@ async fn sends_the_documented_endpoint_and_bearer_header() { assert!(sent.contains("\"model\":\"jev-latest\"")); } +#[tokio::test] +async fn openrouter_uses_system_one_and_accepts_a_resolved_jev_model() { + let (base_url, requests) = server(vec![response( + 200, + &success().replace("jev-latest", "typesafe/jev-1.13-20260917"), + "", + )]) + .await; + let mut config = ClientConfig::openrouter("secret-test-key"); + config.base_url = base_url; + config.timeout = Duration::from_secs(1); + config.retry.max_retries = 0; + let result = Client::new(config) + .unwrap() + .evaluate(&request()) + .await + .unwrap(); + assert_eq!(result.response.model, "typesafe/jev-1.13-20260917"); + let sent = requests.lock().await.join(""); + assert!(sent.starts_with("POST /v1/systemone HTTP/1.1")); +} + #[tokio::test] async fn retries_rate_limits_and_reports_attempts() { let (base_url, requests) = @@ -220,6 +242,9 @@ fn validates_every_configuration_bound_and_redacted_key_replacement() { let mut secure = ClientConfig::new("key"); secure.base_url = "https://example.com".into(); assert!(Client::new(secure).is_ok()); + let openrouter = ClientConfig::openrouter("key"); + assert_eq!(openrouter.base_url, "https://openrouter.ai/api"); + assert_eq!(openrouter.provider, Provider::OpenRouter); let mut ipv6_loopback = ClientConfig::new("key"); ipv6_loopback.base_url = "http://[::1]:8080".into(); assert!(Client::new(ipv6_loopback).is_ok()); diff --git a/crates/tinyjevclient/src/client/types.rs b/crates/tinyjevclient/src/client/types.rs index 0339068..238c6c8 100644 --- a/crates/tinyjevclient/src/client/types.rs +++ b/crates/tinyjevclient/src/client/types.rs @@ -4,6 +4,16 @@ use std::{fmt, time::Duration}; use crate::{Error, EvaluationResponse}; +/// System One API provider. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Provider { + /// `TypeSafe`'s first-party System One API. + #[default] + TypeSafe, + /// `OpenRouter`'s compatible System One API. + OpenRouter, +} + /// Async `TypeSafe` System One client. #[derive(Clone)] pub struct Client { @@ -25,6 +35,8 @@ pub struct ClientConfig { pub(super) api_key: ApiKey, /// API root without the versioned endpoint path. pub base_url: String, + /// Provider-specific response validation behavior. + pub provider: Provider, /// Total timeout for one HTTP attempt. pub timeout: Duration, /// Transient failure retry policy. @@ -38,6 +50,19 @@ impl ClientConfig { Self { api_key: ApiKey(api_key.into()), base_url: "https://api.typesafe.ai".to_owned(), + provider: Provider::TypeSafe, + timeout: Duration::from_secs(30), + retry: RetryPolicy::default(), + } + } + + /// Create configuration for `OpenRouter`'s System One API. + #[must_use] + pub fn openrouter(api_key: impl Into) -> Self { + Self { + api_key: ApiKey(api_key.into()), + base_url: "https://openrouter.ai/api".to_owned(), + provider: Provider::OpenRouter, timeout: Duration::from_secs(30), retry: RetryPolicy::default(), } @@ -56,6 +81,7 @@ impl fmt::Debug for ClientConfig { f.debug_struct("ClientConfig") .field("api_key", &"[REDACTED]") .field("base_url", &self.base_url) + .field("provider", &self.provider) .field("timeout", &self.timeout) .field("retry", &self.retry) .finish() diff --git a/crates/tinyjevclient/src/lib.rs b/crates/tinyjevclient/src/lib.rs index f1adb0f..1c94e35 100644 --- a/crates/tinyjevclient/src/lib.rs +++ b/crates/tinyjevclient/src/lib.rs @@ -41,7 +41,9 @@ mod error; mod request; mod response; -pub use client::{Client, ClientConfig, EvaluationFailure, EvaluationResult, RetryPolicy}; +pub use client::{ + Client, ClientConfig, EvaluationFailure, EvaluationResult, Provider, RetryPolicy, +}; pub use error::{Error, Result}; pub use request::{Choice, EvaluationRequest, Noul, NoulCriteria, Question, Score}; pub use response::{Answer, ChoiceAnswer, EvaluationResponse, NoulAnswer, ScoreAnswer, Usage}; diff --git a/crates/tinyjevclient/src/response/mod.rs b/crates/tinyjevclient/src/response/mod.rs index c08b417..caa0ec9 100644 --- a/crates/tinyjevclient/src/response/mod.rs +++ b/crates/tinyjevclient/src/response/mod.rs @@ -22,10 +22,44 @@ impl EvaluationResponse { /// Returns [`Error::InvalidResponse`] when answer ids or primitive types do /// not match the request, or when a probability payload is inconsistent. pub fn validate_for(&self, request: &EvaluationRequest) -> Result<()> { + self.validate_for_model(request, |response_model| response_model == request.model) + } + + /// Check an `OpenRouter` System One response against its request. + /// + /// `OpenRouter` resolves bare Jev model IDs into the `typesafe/` namespace, + /// so a response can name a concrete release when the request used an + /// alias such as `jev-latest`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidResponse`] when answer ids or primitive types do + /// not match the request, or when a probability payload is inconsistent. + pub fn validate_for_openrouter(&self, request: &EvaluationRequest) -> Result<()> { + let requested = request.model.trim_start_matches('~'); + let expected = if requested.contains('/') { + requested.to_owned() + } else { + format!("typesafe/{requested}") + }; + self.validate_for_model(request, |response_model| { + if requested == "jev-latest" { + response_model.starts_with("typesafe/jev-") + } else { + response_model == expected || response_model.starts_with(&format!("{expected}-")) + } + }) + } + + fn validate_for_model( + &self, + request: &EvaluationRequest, + model_matches: impl FnOnce(&str) -> bool, + ) -> Result<()> { if self.model.trim().is_empty() { return Err(Error::invalid_response("response model must not be empty")); } - if self.model != request.model { + if !model_matches(&self.model) { return Err(Error::invalid_response( "response model must match the requested model", )); diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs index efb7d43..6a36c6d 100644 --- a/crates/tinyjevclient/src/response/test.rs +++ b/crates/tinyjevclient/src/response/test.rs @@ -138,6 +138,17 @@ fn rejects_empty_model_extra_ids_and_nonmaximal_choice() { assert!(nonmaximal.validate_for(&request()).is_err()); } +#[test] +fn openrouter_accepts_resolved_jev_models_only() { + let mut resolved = response(); + resolved.model = "typesafe/jev-1.13-20260917".into(); + resolved.validate_for_openrouter(&request()).unwrap(); + + let mut unrelated = resolved; + unrelated.model = "typesafe/other-1".into(); + assert!(unrelated.validate_for_openrouter(&request()).is_err()); +} + #[test] fn rejects_out_of_range_empty_and_mismatched_probability_payloads() { let mut confidence = response(); diff --git a/crates/tinyjevclient/tests/openrouter_live.rs b/crates/tinyjevclient/tests/openrouter_live.rs new file mode 100644 index 0000000..294bfb8 --- /dev/null +++ b/crates/tinyjevclient/tests/openrouter_live.rs @@ -0,0 +1,30 @@ +//! Explicit paid integration coverage for `OpenRouter`'s System One endpoint. + +use std::collections::BTreeMap; + +use serde_json::json; +use tinyjevclient::{Client, EvaluationRequest, Noul, Question}; + +/// Verifies that JEV evaluates a typed question through `OpenRouter`. +/// +/// This test is ignored because it spends a paid API call and requires +/// `OPENROUTER_API_KEY`. +#[tokio::test] +#[ignore = "requires OPENROUTER_API_KEY and makes a paid OpenRouter request"] +async fn jev_evaluates_through_openrouter() -> Result<(), Box> { + let request = EvaluationRequest::jev( + json!({"ticket": "I was charged twice and want a refund."}), + BTreeMap::from([( + "refund".to_owned(), + Question::Noul(Noul { + instructions: json!("Is the customer asking for money back?"), + criteria: None, + }), + )]), + ); + + let result = Client::from_openrouter_env()?.evaluate(&request).await?; + assert!(result.response.model.starts_with("typesafe/jev-")); + assert!(result.response.answers.contains_key("refund")); + Ok(()) +} diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md index 0e66f1b..eb96a96 100644 --- a/docs/specs/system-one-client.md +++ b/docs/specs/system-one-client.md @@ -35,7 +35,8 @@ classified `Error`, attempt count, and elapsed time. Response validation requires: - exact question ids and primitive types; -- the exact requested model id; +- the exact requested model id for `TypeSafe`, or OpenRouter's resolved + `typesafe/` Jev release matching the requested Jev alias; - finite probabilities in `[0, 1]`, with each distribution sum differing from `1.0` by at most `0.000001`; - Choice labels exactly matching criteria and the chosen label tying for the @@ -49,6 +50,11 @@ Remote base URLs require HTTPS, contain no credentials, query, or fragment, and automatic redirects are disabled. Plain HTTP is accepted only for literal loopback IP addresses used by local test servers. +`ClientConfig::openrouter` uses OpenRouter's compatible System One base URL, +`https://openrouter.ai/api`, and `Client::from_openrouter_env` reads +`OPENROUTER_API_KEY`. The first-party constructor and `Client::from_env` retain +the `TypeSafe` endpoint and `TYPESAFE_API_KEY` behavior. + Authentication, request validation, response decoding, and non-connect transport failures are terminal. Timeouts, connection-establishment failures, 408, 429, 529, and server errors use the explicit retry policy. `max_retries` @@ -85,6 +91,8 @@ println!("{:?}", result.response.answers["violation"]); - Mock HTTP tests cover authentication, 408/429/529/5xx classification, timeout, connection failure, redirects, decoding, retry exhaustion, Retry-After forms, secret redaction, and failure metadata. +- OpenRouter configuration and resolved-Jev response validation have mock tests; + its paid live integration test is explicitly ignored by default. - Every production source file has at least 90% line coverage. - Format, clippy, build, tests, rustdoc, MSRV, cargo-deny, and coverage are green. From 4a45ba75aa36f10d6ef1b34a1d106792ba4dc2be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 20:53:00 +0300 Subject: [PATCH 2/3] feat: support Tiny Humans OpenRouter proxy Co-authored-by: Medulla --- README.md | 5 +++++ crates/tinyjevclient/src/client/README.md | 4 ++++ crates/tinyjevclient/src/client/mod.rs | 11 +++++++++++ crates/tinyjevclient/src/client/test.rs | 6 ++++++ crates/tinyjevclient/src/client/types.rs | 15 +++++++++++++++ docs/specs/system-one-client.md | 4 ++++ 6 files changed, 45 insertions(+) diff --git a/README.md b/README.md index 6baae05..b391ee5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ Use `Client::from_openrouter_env()` with `OPENROUTER_API_KEY`, or construct the client explicitly with `ClientConfig::openrouter("")`. OpenRouter resolves `jev-latest` to a concrete `typesafe/jev-*` model ID in its response. +For a Tiny Humans API key, use the hosted OpenRouter proxy instead: +`Client::from_tinyhumans_openrouter_env()` reads `TINYHUMANS_API_KEY`, and +`ClientConfig::tinyhumans_openrouter("")` explicitly targets +`https://api.tinyhumans.ai/agent-integrations/openrouter/v1/systemone`. + Remote API roots must use HTTPS; HTTP is reserved for literal loopback IPs. Failed evaluations retain their classified error, attempt count, and elapsed time so reliability measurements do not lose unsuccessful work. diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md index 2375987..44c4fac 100644 --- a/crates/tinyjevclient/src/client/README.md +++ b/crates/tinyjevclient/src/client/README.md @@ -16,3 +16,7 @@ are terminal, and automatic redirects are disabled. `ClientConfig::openrouter` targets OpenRouter's compatible System One API at `https://openrouter.ai/api/v1/systemone`; `Client::from_openrouter_env` reads `OPENROUTER_API_KEY` for that configuration. + +`ClientConfig::tinyhumans_openrouter` targets the Tiny Humans OpenRouter proxy +at `https://api.tinyhumans.ai/agent-integrations/openrouter/v1/systemone`; its +environment constructor reads `TINYHUMANS_API_KEY`. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index c2d1f6f..9ccf2f2 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -55,6 +55,17 @@ impl Client { Self::new(ClientConfig::openrouter(api_key)) } + /// Construct a `TinyHumans` `OpenRouter` proxy client using `TINYHUMANS_API_KEY`. + /// + /// # Errors + /// + /// Returns [`Error::MissingApiKey`] when the variable is absent, or the + /// same configuration errors as [`Self::new`]. + pub fn from_tinyhumans_openrouter_env() -> Result { + let api_key = std::env::var("TINYHUMANS_API_KEY").map_err(|_| Error::MissingApiKey)?; + Self::new(ClientConfig::tinyhumans_openrouter(api_key)) + } + /// Evaluate typed questions against shared state. /// /// The returned latency includes retry delays and all attempts. Request and diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 26f4f9e..1bbf588 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -245,6 +245,12 @@ fn validates_every_configuration_bound_and_redacted_key_replacement() { let openrouter = ClientConfig::openrouter("key"); assert_eq!(openrouter.base_url, "https://openrouter.ai/api"); assert_eq!(openrouter.provider, Provider::OpenRouter); + let tinyhumans = ClientConfig::tinyhumans_openrouter("key"); + assert_eq!( + tinyhumans.base_url, + "https://api.tinyhumans.ai/agent-integrations/openrouter" + ); + assert_eq!(tinyhumans.provider, Provider::OpenRouter); let mut ipv6_loopback = ClientConfig::new("key"); ipv6_loopback.base_url = "http://[::1]:8080".into(); assert!(Client::new(ipv6_loopback).is_ok()); diff --git a/crates/tinyjevclient/src/client/types.rs b/crates/tinyjevclient/src/client/types.rs index 238c6c8..4d5ad53 100644 --- a/crates/tinyjevclient/src/client/types.rs +++ b/crates/tinyjevclient/src/client/types.rs @@ -68,6 +68,21 @@ impl ClientConfig { } } + /// Create configuration for the `TinyHumans` `OpenRouter` System One proxy. + /// + /// The proxy accepts a `TinyHumans` API key and forwards typed Jev requests + /// to `OpenRouter` while applying the caller's `TinyHumans` account limits. + #[must_use] + pub fn tinyhumans_openrouter(api_key: impl Into) -> Self { + Self { + api_key: ApiKey(api_key.into()), + base_url: "https://api.tinyhumans.ai/agent-integrations/openrouter".to_owned(), + provider: Provider::OpenRouter, + timeout: Duration::from_secs(30), + retry: RetryPolicy::default(), + } + } + /// Replace the API key without exposing it through a public field. #[must_use] pub fn with_api_key(mut self, api_key: impl Into) -> Self { diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md index eb96a96..f2efa06 100644 --- a/docs/specs/system-one-client.md +++ b/docs/specs/system-one-client.md @@ -55,6 +55,10 @@ loopback IP addresses used by local test servers. `OPENROUTER_API_KEY`. The first-party constructor and `Client::from_env` retain the `TypeSafe` endpoint and `TYPESAFE_API_KEY` behavior. +`ClientConfig::tinyhumans_openrouter` uses Tiny Humans' OpenRouter proxy base +URL, `https://api.tinyhumans.ai/agent-integrations/openrouter`, and +`Client::from_tinyhumans_openrouter_env` reads `TINYHUMANS_API_KEY`. + Authentication, request validation, response decoding, and non-connect transport failures are terminal. Timeouts, connection-establishment failures, 408, 429, 529, and server errors use the explicit retry policy. `max_retries` From 24973699bd7db5ad08f62ecfd65b41a20c2c043a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 20 Sep 2026 21:06:10 +0300 Subject: [PATCH 3/3] fix: address OpenRouter review feedback Co-authored-by: Medulla --- README.md | 11 ++++--- crates/tinyjevclient/src/client/README.md | 6 ++-- crates/tinyjevclient/src/client/mod.rs | 22 -------------- crates/tinyjevclient/src/client/test.rs | 26 ++++++++++++++++ crates/tinyjevclient/src/error/mod.rs | 18 +++++------ crates/tinyjevclient/src/response/mod.rs | 2 +- crates/tinyjevclient/src/response/test.rs | 6 ++++ crates/tinyjevclient/tests/openrouter_live.rs | 30 ------------------- docs/specs/system-one-client.md | 7 ++--- 9 files changed, 52 insertions(+), 76 deletions(-) delete mode 100644 crates/tinyjevclient/tests/openrouter_live.rs diff --git a/README.md b/README.md index b391ee5..9b02314 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,12 @@ TYPESAFE_API_KEY='' cargo run -p tinyjevclient --example basic ## OpenRouter OpenRouter supports the same System One request and response format for Jev. -Use `Client::from_openrouter_env()` with `OPENROUTER_API_KEY`, or construct the -client explicitly with `ClientConfig::openrouter("")`. OpenRouter resolves -`jev-latest` to a concrete `typesafe/jev-*` model ID in its response. +Construct the client explicitly with `ClientConfig::openrouter("")`. +OpenRouter resolves `jev-latest` to a concrete `typesafe/jev-*` model ID in its +response. -For a Tiny Humans API key, use the hosted OpenRouter proxy instead: -`Client::from_tinyhumans_openrouter_env()` reads `TINYHUMANS_API_KEY`, and -`ClientConfig::tinyhumans_openrouter("")` explicitly targets +For a Tiny Humans API key, use `ClientConfig::tinyhumans_openrouter("")`, +which explicitly targets `https://api.tinyhumans.ai/agent-integrations/openrouter/v1/systemone`. Remote API roots must use HTTPS; HTTP is reserved for literal loopback IPs. diff --git a/crates/tinyjevclient/src/client/README.md b/crates/tinyjevclient/src/client/README.md index 44c4fac..dacd9b3 100644 --- a/crates/tinyjevclient/src/client/README.md +++ b/crates/tinyjevclient/src/client/README.md @@ -14,9 +14,7 @@ connectivity failures from permanent ones. Other request/body/redirect errors are terminal, and automatic redirects are disabled. `ClientConfig::openrouter` targets OpenRouter's compatible System One API at -`https://openrouter.ai/api/v1/systemone`; `Client::from_openrouter_env` reads -`OPENROUTER_API_KEY` for that configuration. +`https://openrouter.ai/api/v1/systemone`. `ClientConfig::tinyhumans_openrouter` targets the Tiny Humans OpenRouter proxy -at `https://api.tinyhumans.ai/agent-integrations/openrouter/v1/systemone`; its -environment constructor reads `TINYHUMANS_API_KEY`. +at `https://api.tinyhumans.ai/agent-integrations/openrouter/v1/systemone`. diff --git a/crates/tinyjevclient/src/client/mod.rs b/crates/tinyjevclient/src/client/mod.rs index 9ccf2f2..20d6976 100644 --- a/crates/tinyjevclient/src/client/mod.rs +++ b/crates/tinyjevclient/src/client/mod.rs @@ -44,28 +44,6 @@ impl Client { Self::new(ClientConfig::new(api_key)) } - /// Construct an `OpenRouter` client using `OPENROUTER_API_KEY`. - /// - /// # Errors - /// - /// Returns [`Error::MissingApiKey`] when the variable is absent, or the - /// same configuration errors as [`Self::new`]. - pub fn from_openrouter_env() -> Result { - let api_key = std::env::var("OPENROUTER_API_KEY").map_err(|_| Error::MissingApiKey)?; - Self::new(ClientConfig::openrouter(api_key)) - } - - /// Construct a `TinyHumans` `OpenRouter` proxy client using `TINYHUMANS_API_KEY`. - /// - /// # Errors - /// - /// Returns [`Error::MissingApiKey`] when the variable is absent, or the - /// same configuration errors as [`Self::new`]. - pub fn from_tinyhumans_openrouter_env() -> Result { - let api_key = std::env::var("TINYHUMANS_API_KEY").map_err(|_| Error::MissingApiKey)?; - Self::new(ClientConfig::tinyhumans_openrouter(api_key)) - } - /// Evaluate typed questions against shared state. /// /// The returned latency includes retry delays and all attempts. Request and diff --git a/crates/tinyjevclient/src/client/test.rs b/crates/tinyjevclient/src/client/test.rs index 1bbf588..4c15bc0 100644 --- a/crates/tinyjevclient/src/client/test.rs +++ b/crates/tinyjevclient/src/client/test.rs @@ -151,6 +151,32 @@ async fn openrouter_uses_system_one_and_accepts_a_resolved_jev_model() { assert!(sent.starts_with("POST /v1/systemone HTTP/1.1")); } +#[tokio::test] +async fn tinyhumans_proxy_uses_the_compatibility_system_one_path() { + let (base_url, requests) = server(vec![response( + 200, + &success().replace("jev-latest", "typesafe/jev-1.13-20260917"), + "", + )]) + .await; + let mut config = ClientConfig::tinyhumans_openrouter("secret-test-key"); + config.base_url = base_url; + 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 /v1/systemone HTTP/1.1") + ); +} + #[tokio::test] async fn retries_rate_limits_and_reports_attempts() { let (base_url, requests) = diff --git a/crates/tinyjevclient/src/error/mod.rs b/crates/tinyjevclient/src/error/mod.rs index edbcc77..51cca39 100644 --- a/crates/tinyjevclient/src/error/mod.rs +++ b/crates/tinyjevclient/src/error/mod.rs @@ -20,35 +20,35 @@ pub enum Error { reason: String, }, /// Authentication was rejected. - #[error("TypeSafe authentication failed")] + #[error("provider authentication failed")] Authentication, /// The provider rejected the request shape. - #[error("TypeSafe rejected the request")] + #[error("provider rejected the request")] Unprocessable, /// The account or endpoint rate limit was reached. - #[error("TypeSafe rate limit exceeded")] + #[error("provider rate limit exceeded")] RateLimited, - /// The `TypeSafe` service reported temporary overload. - #[error("TypeSafe service overloaded")] + /// The provider reported temporary overload. + #[error("provider service overloaded")] Overloaded, /// The endpoint returned another unsuccessful status. - #[error("TypeSafe request failed with status {status}")] + #[error("provider request failed with status {status}")] HttpStatus { /// Returned HTTP status code. status: u16, }, /// The request timed out. - #[error("TypeSafe request timed out")] + #[error("provider request timed out")] Timeout, /// The HTTP transport failed before a response was available. - #[error("TypeSafe transport failed")] + #[error("provider transport failed")] Transport { /// Underlying transport failure. #[source] source: reqwest::Error, }, /// The response body was not valid JSON for the declared wire shape. - #[error("TypeSafe response could not be decoded")] + #[error("provider response could not be decoded")] Decode { /// Underlying JSON decoding failure. #[source] diff --git a/crates/tinyjevclient/src/response/mod.rs b/crates/tinyjevclient/src/response/mod.rs index caa0ec9..2c1d3cf 100644 --- a/crates/tinyjevclient/src/response/mod.rs +++ b/crates/tinyjevclient/src/response/mod.rs @@ -43,7 +43,7 @@ impl EvaluationResponse { format!("typesafe/{requested}") }; self.validate_for_model(request, |response_model| { - if requested == "jev-latest" { + if matches!(requested, "jev-latest" | "typesafe/jev-latest") { response_model.starts_with("typesafe/jev-") } else { response_model == expected || response_model.starts_with(&format!("{expected}-")) diff --git a/crates/tinyjevclient/src/response/test.rs b/crates/tinyjevclient/src/response/test.rs index 6a36c6d..f69ce67 100644 --- a/crates/tinyjevclient/src/response/test.rs +++ b/crates/tinyjevclient/src/response/test.rs @@ -144,6 +144,12 @@ fn openrouter_accepts_resolved_jev_models_only() { resolved.model = "typesafe/jev-1.13-20260917".into(); resolved.validate_for_openrouter(&request()).unwrap(); + let mut namespaced_latest = request(); + namespaced_latest.model = "~typesafe/jev-latest".into(); + resolved + .validate_for_openrouter(&namespaced_latest) + .unwrap(); + let mut unrelated = resolved; unrelated.model = "typesafe/other-1".into(); assert!(unrelated.validate_for_openrouter(&request()).is_err()); diff --git a/crates/tinyjevclient/tests/openrouter_live.rs b/crates/tinyjevclient/tests/openrouter_live.rs deleted file mode 100644 index 294bfb8..0000000 --- a/crates/tinyjevclient/tests/openrouter_live.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Explicit paid integration coverage for `OpenRouter`'s System One endpoint. - -use std::collections::BTreeMap; - -use serde_json::json; -use tinyjevclient::{Client, EvaluationRequest, Noul, Question}; - -/// Verifies that JEV evaluates a typed question through `OpenRouter`. -/// -/// This test is ignored because it spends a paid API call and requires -/// `OPENROUTER_API_KEY`. -#[tokio::test] -#[ignore = "requires OPENROUTER_API_KEY and makes a paid OpenRouter request"] -async fn jev_evaluates_through_openrouter() -> Result<(), Box> { - let request = EvaluationRequest::jev( - json!({"ticket": "I was charged twice and want a refund."}), - BTreeMap::from([( - "refund".to_owned(), - Question::Noul(Noul { - instructions: json!("Is the customer asking for money back?"), - criteria: None, - }), - )]), - ); - - let result = Client::from_openrouter_env()?.evaluate(&request).await?; - assert!(result.response.model.starts_with("typesafe/jev-")); - assert!(result.response.answers.contains_key("refund")); - Ok(()) -} diff --git a/docs/specs/system-one-client.md b/docs/specs/system-one-client.md index f2efa06..7c26449 100644 --- a/docs/specs/system-one-client.md +++ b/docs/specs/system-one-client.md @@ -51,13 +51,12 @@ automatic redirects are disabled. Plain HTTP is accepted only for literal loopback IP addresses used by local test servers. `ClientConfig::openrouter` uses OpenRouter's compatible System One base URL, -`https://openrouter.ai/api`, and `Client::from_openrouter_env` reads -`OPENROUTER_API_KEY`. The first-party constructor and `Client::from_env` retain -the `TypeSafe` endpoint and `TYPESAFE_API_KEY` behavior. +`https://openrouter.ai/api`. The first-party constructor and `Client::from_env` +retain the `TypeSafe` endpoint and `TYPESAFE_API_KEY` behavior. `ClientConfig::tinyhumans_openrouter` uses Tiny Humans' OpenRouter proxy base URL, `https://api.tinyhumans.ai/agent-integrations/openrouter`, and -`Client::from_tinyhumans_openrouter_env` reads `TINYHUMANS_API_KEY`. +accepts the key supplied explicitly to its constructor. Authentication, request validation, response decoding, and non-connect transport failures are terminal. Timeouts, connection-establishment failures,