openkind_client/lib.rs
1//! `openkind-client`: an async Rust client for the `openkindd`
2//! daemon and the TypeSafe Jev SystemOne HTTP API, including OpenRouter's
3//! System One route. Cloudflare Workers AI uses the same question types via
4//! [`ClientBuilder::cloudflare_account`].
5//!
6//! Built directly on the `openkind-core` wire types. Successful evaluation
7//! responses are validated against their originating requests, including
8//! question types and Choice keys. Wire-compatible with
9//! `https://api.typesafe.ai` and the `typesafe_sdk` Python SDK: the same
10//! retry taxonomy, `Retry-After` handling, error envelope parsing, request
11//! ids, and env-var resolution ā with defaults mirroring the Python
12//! `RetryPolicy` (2 retries, 0.5sā5s exponential backoff, 25% jitter,
13//! 408/429/5xx retried, 30s total budget).
14//!
15//! # Quick start
16//! ```no_run
17//! use openkind_client::{question, Client};
18//!
19//! # async fn demo() -> Result<(), openkind_client::Error> {
20//! let client = Client::builder()
21//! .api_key("dev-key")
22//! .base_url("http://127.0.0.1:18080")
23//! .build()?;
24//!
25//! let response = client
26//! .system_one(
27//! "I was charged twice. Please help.",
28//! [
29//! ("billing", question::noul("Is this about billing?")),
30//! (
31//! "tone",
32//! question::choice("What is the tone?", [("calm", None), ("angry", None)]),
33//! ),
34//! ],
35//! )
36//! .await?;
37//!
38//! let billing = match &response.answers["billing"] {
39//! openkind_client::Answer::Noul(answer) => answer.noul,
40//! _ => unreachable!(),
41//! };
42//! println!("billing probability: {billing}");
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! # Rate limiting, overload, and retries
48//!
49//! 429 (`rate_limited`) and 529 (`overloaded`) responses carry
50//! `retry-after-ms` / `Retry-After` headers. The client reads them and waits
51//! exactly as long as the server asked, falling back to exponential backoff
52//! with jitter for other transient failures (408, 5xx, connection errors,
53//! timeouts). See [`RetryPolicy`] to tune or disable.
54//!
55//! # Errors
56//!
57//! All failures surface as [`Error`]; server rejections as
58//! [`Error::Api`](`Error::Api`) with the status, error `code`, `message`,
59//! `x-typesafe-request-id`, and parsed `retry_after`. See [`error`].
60//!
61//! # Environment variables
62//!
63//! | Builder option | Primary | Python-SDK fallback |
64//! |------------------|-------------------------|-----------------------|
65//! | `api_key` | `OPENKIND_API_KEY` | `TYPESAFE_API_KEY` |
66//! | `base_url` | `OPENKIND_BASE_URL` | `TYPESAFE_BASE_URL` |
67//! | `default_model` | `OPENKIND_DEFAULT_MODEL` | `TYPESAFE_DEFAULT_MODEL` |
68//!
69//! Explicit builder values win; empty/whitespace env values are ignored.
70
71#![warn(missing_docs)]
72
73/// Async HTTP client implementation, builder, and request options.
74pub mod client;
75/// Client error types, envelope extraction, and status classifications.
76pub mod error;
77/// Question construction convenience helpers.
78pub mod question;
79/// Retry policy, exponential backoff, and jitter strategies.
80pub mod retry;
81
82pub use client::{
83 Client, ClientBuilder, Health, IntoState, RequestOptions, DEFAULT_BASE_URL, DEFAULT_MODEL,
84 DEFAULT_TIMEOUT, MAX_RESPONSE_BODY_SIZE,
85};
86pub use error::{ApiError, ApiErrorKind, Error};
87pub use retry::RetryPolicy;
88
89// Wire types re-exported from `openkind-core` ā the single source of
90// truth for the Jev protocol. Client and server share these structs, which
91// is what guarantees schema conformance (`jev-v1-request.json` /
92// `jev-v1-response.json`).
93pub use openkind_core::{
94 Answer, ChoiceAnswer, ChoiceQuestion, ModelInfo, ModelsResponse, NoulAnswer, NoulCriteria,
95 NoulQuestion, Question, ScoreAnswer, ScoreQuestion, State, SystemRequest, SystemResponse,
96 Usage, API_VERSION,
97};
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use std::collections::HashMap;
103
104 /// The re-exported wire types must round-trip through the pinned Jev
105 /// request schema shape: `state`, `model`, `questions` with `type` tags.
106 #[test]
107 fn system_request_serializes_to_jev_wire_shape() {
108 let request = SystemRequest {
109 state: State::Text("hello".into()),
110 model: "jev-latest".into(),
111 questions: HashMap::from_iter([
112 ("b".to_string(), question::noul("Is this billing?")),
113 (
114 "c".to_string(),
115 question::choice("Tone?", [("calm", None), ("angry", None)]),
116 ),
117 (
118 "s".to_string(),
119 question::score("Severity?", ["low", "high"]),
120 ),
121 ]),
122 };
123 let wire = serde_json::to_value(&request).unwrap();
124 assert_eq!(wire["state"], "hello");
125 assert_eq!(wire["model"], "jev-latest");
126 assert_eq!(wire["questions"]["b"]["type"], "noul");
127 assert_eq!(wire["questions"]["c"]["type"], "choice");
128 assert_eq!(wire["questions"]["s"]["type"], "score");
129 // Response side: noul answers carry no confidence field per spec.
130 let response: SystemResponse = serde_json::from_value(serde_json::json!({
131 "model": "jev-latest",
132 "usage": {"input_tokens": 1, "output_tokens": 2},
133 "answers": {"b": {"type": "noul", "noul": 0.5}}
134 }))
135 .unwrap();
136 match &response.answers["b"] {
137 Answer::Noul(answer) => assert_eq!(answer.noul, 0.5),
138 other => panic!("expected noul answer, got {other:?}"),
139 }
140 }
141}