Skip to main content

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}