kunobi_decision/client/
provider.rs1use serde::Deserialize;
4use serde::de::DeserializeOwned;
5
6use super::call::DecodeFailure;
7use crate::{Error, Result, SystemOneRequest};
8
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub enum Provider {
12 #[default]
14 TypeSafe,
15 Cloudflare {
17 account_id: String,
19 },
20 Liquid,
22 Vercel,
24 OpenRouter,
26 Compatible,
29}
30
31impl Provider {
32 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 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}