1use std::time::{Duration, Instant};
4
5use async_trait::async_trait;
6use serde_json::{Map, Value, json};
7
8use super::{
9 Answer, DecisionModel, DecisionRequest, DecisionResponse, DecisionUsage, FallbackWhen,
10};
11use crate::llm::LlmError;
12
13#[derive(Clone)]
34pub struct GatewayDecisionModel {
35 client: reqwest::Client,
36 base_url: String,
37 model: String,
38 token: String,
39 timeout: Duration,
40 options: Value,
41}
42
43impl std::fmt::Debug for GatewayDecisionModel {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("GatewayDecisionModel")
47 .field("base_url", &self.base_url)
48 .field("model", &self.model)
49 .field("timeout", &self.timeout)
50 .field("options", &self.options)
51 .finish_non_exhaustive()
52 }
53}
54
55impl GatewayDecisionModel {
56 pub const DEFAULT_BASE_URL: &'static str = "https://ai-gateway.vercel.sh";
58 pub const JEV: &'static str = "typesafe-ai/jev";
60 pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
62
63 pub fn new(model: impl Into<String>, token: impl Into<String>) -> Self {
65 Self {
66 client: reqwest::Client::new(),
67 base_url: Self::DEFAULT_BASE_URL.to_string(),
68 model: model.into(),
69 token: token.into(),
70 timeout: Self::DEFAULT_TIMEOUT,
71 options: Value::Null,
72 }
73 }
74
75 pub fn from_env() -> Result<Self, LlmError> {
83 let var = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
84 let token = var("AI_GATEWAY_API_KEY")
85 .or_else(|| var("VERCEL_OIDC_TOKEN"))
86 .ok_or_else(|| {
87 LlmError::Auth(
88 "no AI Gateway credential: set AI_GATEWAY_API_KEY (for example in \
89 .env.local), or run `vercel env pull` for a VERCEL_OIDC_TOKEN"
90 .into(),
91 )
92 })?;
93 let model = var("AI_GATEWAY_DECISION_MODEL").unwrap_or_else(|| Self::JEV.to_string());
94 let mut this = Self::new(model, token);
95 if let Some(base) = var("AI_GATEWAY_BASE_URL") {
96 this.base_url = base.trim_end_matches('/').to_string();
97 }
98 Ok(this)
99 }
100
101 #[must_use]
103 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
104 self.base_url = base_url.into().trim_end_matches('/').to_string();
105 self
106 }
107
108 #[must_use]
110 pub fn with_timeout(mut self, timeout: Duration) -> Self {
111 self.timeout = timeout;
112 self
113 }
114
115 #[must_use]
117 pub fn zero_data_retention(mut self, on: bool) -> Self {
118 merge_json(
119 &mut self.options,
120 &object("gateway", json!({ "zeroDataRetention": on })),
121 );
122 self
123 }
124
125 #[must_use]
127 pub fn only_providers<S: Into<String>>(
128 mut self,
129 providers: impl IntoIterator<Item = S>,
130 ) -> Self {
131 let only: Vec<String> = providers.into_iter().map(Into::into).collect();
132 merge_json(
133 &mut self.options,
134 &object("gateway", json!({ "only": only })),
135 );
136 self
137 }
138
139 #[must_use]
144 pub fn fallback(mut self, model: impl Into<String>, when: FallbackWhen) -> Self {
145 let entry = json!({ "model": model.into(), "when": when.to_value() });
146 merge_json(
147 &mut self.options,
148 &object("gateway", json!({ "models": [entry] })),
149 );
150 self
151 }
152
153 pub fn body(&self, request: &DecisionRequest) -> Value {
155 let mut body = json!({
156 "model": self.model,
157 "state": request.state,
158 "questions": request.questions,
159 });
160 let mut options = self.options.clone();
161 if let Some(extra) = &request.provider_options {
162 merge_json(&mut options, extra);
163 }
164 if !options.is_null() {
165 body["providerOptions"] = options;
166 }
167 body
168 }
169
170 pub fn parse(status: u16, body: &str, latency: Duration) -> Result<DecisionResponse, LlmError> {
172 let value: Value = serde_json::from_str(body).map_err(|e| LlmError::Api {
173 status,
174 message: format!("unreadable response ({e}): {}", truncate(body, 300)),
175 })?;
176 if !(200..300).contains(&status) {
177 let message = value["error"]["message"]
178 .as_str()
179 .map_or_else(|| truncate(body, 300), str::to_string);
180 return Err(match status {
181 401 => LlmError::Auth(format!(
182 "AI Gateway rejected the credential: {message}. Check AI_GATEWAY_API_KEY"
183 )),
184 _ => LlmError::Api { status, message },
185 });
186 }
187 let answers = value["answers"]
188 .as_object()
189 .ok_or_else(|| LlmError::Api {
190 status,
191 message: format!("response has no answers: {}", truncate(body, 300)),
192 })?
193 .iter()
194 .map(|(k, v)| (k.clone(), Answer::from_value(v.clone())))
195 .collect();
196 let usage = value.get("usage").map(|u| DecisionUsage {
197 input_tokens: u["inputTokens"].as_u64().unwrap_or(0),
198 output_tokens: u["outputTokens"].as_u64().unwrap_or(0),
199 });
200 let gateway = &value["providerMetadata"]["gateway"];
201 let fallback_triggered_by = gateway["routing"]["modelAttempts"]
202 .as_array()
203 .into_iter()
204 .flatten()
205 .filter_map(|attempt| attempt["triggeredBy"].as_array())
206 .flatten()
207 .map(|t| {
208 (
209 t["question"].as_str().unwrap_or_default().to_string(),
210 t["reason"].as_str().unwrap_or_default().to_string(),
211 )
212 })
213 .collect();
214 Ok(DecisionResponse {
215 model: value["model"].as_str().unwrap_or_default().to_string(),
216 answers,
217 usage,
218 cost: gateway["cost"].as_str().map(str::to_string),
219 fallback_triggered_by,
220 latency,
221 provider_metadata: value["providerMetadata"].clone(),
222 })
223 }
224}
225
226fn merge_json(base: &mut Value, overlay: &Value) {
228 match (base, overlay) {
229 (Value::Object(b), Value::Object(o)) => {
230 for (k, v) in o {
231 merge_json(b.entry(k.clone()).or_insert(Value::Null), v);
232 }
233 }
234 (b, o) => *b = o.clone(),
235 }
236}
237
238fn object(key: &str, value: Value) -> Value {
240 let mut m = Map::new();
241 m.insert(key.to_string(), value);
242 Value::Object(m)
243}
244
245fn truncate(s: &str, max: usize) -> String {
246 match s.char_indices().nth(max) {
247 Some((i, _)) => format!("{}…", &s[..i]),
248 None => s.to_string(),
249 }
250}
251
252#[async_trait]
253impl DecisionModel for GatewayDecisionModel {
254 fn model_id(&self) -> &str {
255 &self.model
256 }
257
258 async fn decide(&self, request: DecisionRequest) -> Result<DecisionResponse, LlmError> {
259 for (id, question) in &request.questions {
260 question
261 .validate()
262 .map_err(|e| LlmError::Config(format!("question '{id}': {e}")))?;
263 }
264 let started = Instant::now();
265 let response = self
266 .client
267 .post(format!("{}/v1/evaluate", self.base_url))
268 .bearer_auth(&self.token)
269 .timeout(self.timeout)
270 .json(&self.body(&request))
271 .send()
272 .await
273 .map_err(|e| LlmError::Transport(e.to_string()))?;
274 let status = response.status().as_u16();
275 let text = response
276 .text()
277 .await
278 .map_err(|e| LlmError::Transport(e.to_string()))?;
279 let latency = started.elapsed();
280 let parsed = Self::parse(status, &text, latency);
281 match &parsed {
282 Ok(r) => tracing::info!(
283 model = %r.model,
284 ms = latency.as_millis() as u64,
285 questions = r.answers.len(),
286 fallback = !r.fallback_triggered_by.is_empty(),
287 "decision"
288 ),
289 Err(e) => {
290 tracing::warn!(model = %self.model, ms = latency.as_millis() as u64, "decision failed: {e}");
291 }
292 }
293 parsed
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::decision::Question;
301
302 fn jev() -> GatewayDecisionModel {
303 GatewayDecisionModel::new(GatewayDecisionModel::JEV, "test-token")
304 }
305
306 #[test]
307 fn the_body_carries_state_questions_and_options() {
308 let model = jev().zero_data_retention(true).fallback(
309 "google/gemini-3.8-flash",
310 FallbackWhen::ProbabilityBetween {
311 question: Some("confirmed".into()),
312 low: 0.3,
313 high: 0.8,
314 },
315 );
316 let mut request = DecisionRequest::new(
317 json!({ "caller": "yes" }),
318 [("confirmed", Question::boolean("Agreed?"))],
319 );
320 request.provider_options = Some(json!({ "gateway": { "only": ["typesafe-ai"] } }));
321 assert_eq!(
322 model.body(&request),
323 json!({
324 "model": "typesafe-ai/jev",
325 "state": { "caller": "yes" },
326 "questions": { "confirmed": { "type": "boolean", "instructions": "Agreed?" } },
327 "providerOptions": { "gateway": {
328 "zeroDataRetention": true,
329 "only": ["typesafe-ai"],
330 "models": [ { "model": "google/gemini-3.8-flash",
331 "when": { "question": "confirmed", "probabilityBetween": [0.3, 0.8] } } ]
332 } }
333 })
334 );
335 assert!(
336 jev().body(&request).get("providerOptions").is_some(),
337 "per-request options alone"
338 );
339 let plain = DecisionRequest::new(json!("x"), [("a", Question::boolean("A?"))]);
340 assert!(jev().body(&plain).get("providerOptions").is_none());
341 }
342
343 #[test]
344 fn a_documented_response_is_read() {
345 let body = r#"{
346 "model": "typesafe-ai/jev",
347 "answers": { "refund": { "type": "boolean", "probability": 0.98 } },
348 "usage": { "inputTokens": 275, "outputTokens": 20 },
349 "providerMetadata": { "gateway": {
350 "routing": { "originalModelId": "typesafe-ai/jev", "finalProvider": "typesafe-ai" },
351 "cost": "0.00001155", "generationId": "gen_1" } }
352 }"#;
353 let r = GatewayDecisionModel::parse(200, body, Duration::from_millis(120)).unwrap();
354 assert_eq!(r.model, "typesafe-ai/jev");
355 assert_eq!(r.answers["refund"].probability(), Some(0.98));
356 assert_eq!(
357 r.usage,
358 Some(DecisionUsage {
359 input_tokens: 275,
360 output_tokens: 20
361 })
362 );
363 assert_eq!(r.cost.as_deref(), Some("0.00001155"));
364 assert!(r.fallback_triggered_by.is_empty());
365 }
366
367 #[test]
368 fn a_triggered_fallback_is_reported() {
369 let body = r#"{
370 "model": "openai/gpt-6-astra",
371 "answers": { "intent": { "type": "choice", "choice": "billing" } },
372 "providerMetadata": { "gateway": { "routing": { "modelAttempts": [
373 { "canonicalSlug": "typesafe-ai/jev", "success": true },
374 { "canonicalSlug": "openai/gpt-6-astra", "success": true,
375 "triggeredBy": [ { "question": "intent", "reason": "confidence_below" } ] }
376 ] } } }
377 }"#;
378 let r = GatewayDecisionModel::parse(200, body, Duration::ZERO).unwrap();
379 assert_eq!(r.model, "openai/gpt-6-astra");
380 assert_eq!(
381 r.fallback_triggered_by,
382 vec![("intent".to_string(), "confidence_below".to_string())]
383 );
384 assert_eq!(
385 r.answers["intent"].certainty(),
386 None,
387 "a language model reports none"
388 );
389 }
390
391 #[test]
392 fn errors_keep_the_gateway_message() {
393 let restricted = r#"{"error":{"message":"Free tier users do not have access to this model.","type":"no_providers_available"}}"#;
394 match GatewayDecisionModel::parse(403, restricted, Duration::ZERO) {
395 Err(LlmError::Api {
396 status: 403,
397 message,
398 }) => {
399 assert!(message.contains("Free tier"), "{message}");
400 }
401 other => panic!("{other:?}"),
402 }
403 let auth = r#"{"error":{"message":"Authentication failed","type":"authentication_error"}}"#;
404 assert!(matches!(
405 GatewayDecisionModel::parse(401, auth, Duration::ZERO),
406 Err(LlmError::Auth(_))
407 ));
408 let busy = GatewayDecisionModel::parse(503, "upstream busy", Duration::ZERO).unwrap_err();
409 assert!(busy.is_retryable(), "{busy:?}");
410 }
411
412 #[test]
413 fn provider_options_merge_recursively() {
414 let mut base = json!({ "gateway": { "zeroDataRetention": true, "only": ["typesafe-ai"] } });
415 merge_json(
416 &mut base,
417 &json!({ "gateway": { "only": ["digitalocean"] }, "openai": { "x": 1 } }),
418 );
419 assert_eq!(
420 base,
421 json!({ "gateway": { "zeroDataRetention": true, "only": ["digitalocean"] }, "openai": { "x": 1 } })
422 );
423 }
424
425 #[test]
426 fn the_token_is_never_printed() {
427 let printed = format!("{:?}", jev());
428 assert!(!printed.contains("test-token"), "{printed}");
429 }
430
431 #[tokio::test]
432 async fn an_invalid_question_fails_before_the_call() {
433 let none: [(&str, &str); 0] = [];
434 let err = jev()
435 .with_base_url("http://127.0.0.1:9")
436 .decide(DecisionRequest::new(
437 json!("x"),
438 [("pick", Question::choice("Pick.", none))],
439 ))
440 .await
441 .unwrap_err();
442 assert!(matches!(err, LlmError::Config(_)), "{err:?}");
443 }
444
445 #[tokio::test]
446 async fn a_call_reaches_the_gateway_and_reads_the_answers() {
447 use tokio::io::{AsyncReadExt, AsyncWriteExt};
448 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
449 let addr = listener.local_addr().unwrap();
450 let server = tokio::spawn(async move {
451 let (mut socket, _) = listener.accept().await.unwrap();
452 let mut seen = Vec::new();
453 let mut buf = [0u8; 4096];
454 while !String::from_utf8_lossy(&seen).contains("\"questions\"") {
456 let n = socket.read(&mut buf).await.unwrap();
457 if n == 0 {
458 break;
459 }
460 seen.extend_from_slice(&buf[..n]);
461 }
462 let body = r#"{"model":"typesafe-ai/jev","answers":{"confirmed":{"type":"boolean","probability":0.93}}}"#;
463 let reply = format!(
464 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
465 body.len()
466 );
467 socket.write_all(reply.as_bytes()).await.unwrap();
468 String::from_utf8_lossy(&seen).to_string()
469 });
470 let model = jev().with_base_url(format!("http://{addr}"));
471 let response = model
472 .decide(DecisionRequest::new(
473 json!("Caller: yes"),
474 [("confirmed", Question::boolean("Agreed?"))],
475 ))
476 .await
477 .unwrap();
478 assert_eq!(response.answers["confirmed"].probability(), Some(0.93));
479 let request = server.await.unwrap();
480 assert!(request.starts_with("POST /v1/evaluate"), "{request}");
481 assert!(
482 request
483 .to_lowercase()
484 .contains("authorization: bearer test-token"),
485 "{request}"
486 );
487 }
488}