Skip to main content

kunobi_decision/client/
provider.rs

1//! Provider-specific paths and response envelopes.
2
3use serde::Deserialize;
4use serde::de::DeserializeOwned;
5
6use super::call::DecodeFailure;
7use crate::{Error, Result, SystemOneRequest};
8
9/// Service used to access a decision model. Model IDs are selected separately.
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub enum Provider {
12    /// TypeSafe's System One API, with `TYPESAFE_*` environment defaults.
13    #[default]
14    TypeSafe,
15    /// Cloudflare Workers AI. Uses `CLOUDFLARE_API_TOKEN`.
16    Cloudflare {
17        /// Cloudflare account ID, containing only letters, digits, `_` or `-`.
18        account_id: String,
19    },
20    /// Liquid AI's decisions API. Uses `LIQUID_API_KEY`.
21    Liquid,
22    /// Vercel's TypeSafe-compatible API. Uses `AI_GATEWAY_API_KEY`.
23    Vercel,
24    /// OpenRouter's System One API. Uses `OPENROUTER_API_KEY`.
25    OpenRouter,
26    /// A custom System One API. Requires an explicit base URL.
27    /// Uses explicit credentials or `KUNOBI_DECISION_API_KEY`.
28    Compatible,
29}
30
31impl Provider {
32    /// Default model ID for this access provider.
33    pub fn default_model(&self) -> &'static str {
34        match self {
35            Self::Cloudflare { .. } => "clef",
36            Self::Liquid => "d1:free",
37            Self::Vercel => "typesafe-ai/jev",
38            Self::OpenRouter => "~typesafe/jev-latest",
39            Self::TypeSafe | Self::Compatible => "jev-latest",
40        }
41    }
42
43    /// Default API root. Compatible providers require an explicit root.
44    pub fn base_url(&self) -> &'static str {
45        match self {
46            Self::Cloudflare { .. } => "https://api.cloudflare.com",
47            Self::Liquid => "https://api.liquid.ai/decisions",
48            Self::Vercel => "https://ai-gateway.vercel.sh/typesafe",
49            Self::OpenRouter => "https://openrouter.ai/api",
50            Self::TypeSafe => "https://api.typesafe.ai",
51            Self::Compatible => "",
52        }
53    }
54
55    pub(super) fn api_key_env(&self) -> &'static str {
56        match self {
57            Self::TypeSafe => "TYPESAFE_API_KEY",
58            Self::Cloudflare { .. } => "CLOUDFLARE_API_TOKEN",
59            Self::Liquid => "LIQUID_API_KEY",
60            Self::Vercel => "AI_GATEWAY_API_KEY",
61            Self::OpenRouter => "OPENROUTER_API_KEY",
62            Self::Compatible => "KUNOBI_DECISION_API_KEY",
63        }
64    }
65
66    pub(super) fn base_url_env(&self) -> Option<&'static str> {
67        match self {
68            Self::TypeSafe => Some(super::ENV_BASE_URL),
69            Self::OpenRouter => Some("OPENROUTER_BASE_URL"),
70            _ => None,
71        }
72    }
73
74    pub(super) fn default_model_env(&self) -> Option<&'static str> {
75        match self {
76            Self::TypeSafe => Some(super::ENV_DEFAULT_MODEL),
77            Self::OpenRouter => Some("OPENROUTER_DEFAULT_MODEL"),
78            _ => None,
79        }
80    }
81
82    pub(super) fn validate(&self) -> Result<()> {
83        if let Self::Cloudflare { account_id } = self
84            && (account_id.is_empty()
85                || !account_id
86                    .bytes()
87                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')))
88        {
89            return Err(Error::Config("Invalid Cloudflare account ID".into()));
90        }
91        Ok(())
92    }
93
94    pub(super) fn prepare(
95        &self,
96        mut request: SystemOneRequest,
97        default_model: &str,
98    ) -> Result<(String, Vec<u8>)> {
99        let path = match self {
100            Self::Cloudflare { account_id } => {
101                if request.questions.len() > 64 {
102                    return Err(Error::InvalidRequest(
103                        "Clef accepts at most 64 questions per request".into(),
104                    ));
105                }
106                let model = request.model.as_deref().unwrap_or(default_model);
107                let model = model.strip_prefix("@cf/cloudflare/").unwrap_or(model);
108                if !matches!(model, "clef" | "clef-flash") {
109                    return Err(Error::InvalidRequest(
110                        "Cloudflare decision endpoints serve clef and clef-flash".into(),
111                    ));
112                }
113                let path =
114                    format!("/client/v4/accounts/{account_id}/ai/run/@cf/cloudflare/{model}");
115                request.model = Some(model.to_owned());
116                path
117            }
118            _ => "/v1/systemone".into(),
119        };
120        Ok((path, request.to_body(default_model)?))
121    }
122}
123
124pub(super) fn unwrap_cloudflare<T: DeserializeOwned>(
125    body: &[u8],
126) -> std::result::Result<T, DecodeFailure> {
127    #[derive(Deserialize)]
128    struct Envelope<T> {
129        success: bool,
130        result: Option<T>,
131    }
132    let envelope: Envelope<T> = serde_json::from_slice(body).map_err(|err| DecodeFailure {
133        message: "Unexpected Cloudflare response shape".into(),
134        source: Some(err),
135    })?;
136    if !envelope.success {
137        return Err(DecodeFailure {
138            message: "Cloudflare reported an unsuccessful request".into(),
139            source: None,
140        });
141    }
142    envelope.result.ok_or_else(|| DecodeFailure {
143        message: "Cloudflare response is missing its result".into(),
144        source: None,
145    })
146}