Skip to main content

kora_models/
provider.rs

1//! Provider clients: OpenAI (API) and Ollama (localhost).
2//!
3//! HTTP is isolated behind [`Transport`] so request construction and response
4//! handling are testable without touching the network.
5
6use serde_json::{json, Value};
7
8use crate::base64;
9use crate::schema::{build_json_schema, system_prompt, user_prompt};
10use crate::validate::{parse_response, truncate};
11use crate::{AnalyzeRequest, FieldType, ModelConfig, ModelError, Provider, Step, ToolSpec};
12
13/// Long enough for a large local model to read an image, since a timeout
14/// that fires on ordinary work teaches people to raise it blindly. Override
15/// with `[models] timeout_secs`.
16pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
17/// Three attempts total. Enough to ride out a rate limit or a restarting
18/// server; few enough that a provider which is genuinely down is reported
19/// while somebody is still watching.
20pub const DEFAULT_MAX_RETRIES: u32 = 2;
21/// Wait before the first retry. Doubles each attempt.
22const RETRY_BASE_MS: u64 = 500;
23/// A `Retry-After` longer than this is not waited out: a provider asking for
24/// a minute is telling the program to come back later, not to block.
25const MAX_RETRY_AFTER_SECS: u64 = 20;
26const OPENAI_BASE: &str = "https://api.openai.com/v1";
27const OLLAMA_BASE: &str = "http://localhost:11434";
28
29/// One HTTP POST: (url, headers, body) -> response body text.
30pub(crate) type Transport = dyn Fn(&str, &[(&str, String)], &Value) -> Result<String, ModelError>;
31
32/// `"openai:gpt-4o"` / `"local:llama3.1:8b"` -> config.
33///
34/// Everything after the first colon is the model name, so Ollama tags
35/// (`llama3.1:8b`) survive intact.
36pub fn parse_model_spec(spec: &str) -> Result<ModelConfig, ModelError> {
37    let (scheme, model) = spec.split_once(':').ok_or_else(|| {
38        ModelError::new(format!(
39            "model spec `{spec}` needs a provider prefix, e.g. `openai:gpt-4o` or `local:llama3.1:8b`"
40        ))
41    })?;
42    if model.trim().is_empty() {
43        return Err(ModelError::new(format!(
44            "model spec `{spec}` has no model name after `{scheme}:`"
45        )));
46    }
47    let provider = match scheme {
48        "openai" => Provider::OpenAI,
49        "local" | "ollama" => Provider::Ollama,
50        other => {
51            return Err(ModelError::new(format!(
52                "unknown model provider `{other}` (expected `openai` or `local`)"
53            )))
54        }
55    };
56    Ok(ModelConfig {
57        provider,
58        model: model.to_string(),
59        endpoint: None,
60        api_key: None,
61        max_output_tokens: 4096,
62        timeout_secs: DEFAULT_TIMEOUT_SECS,
63        max_retries: DEFAULT_MAX_RETRIES,
64    })
65}
66
67pub(crate) fn step_with(
68    config: &ModelConfig,
69    req: &AnalyzeRequest,
70    transport: &Transport,
71) -> Result<Step, ModelError> {
72    match config.provider {
73        Provider::OpenAI => openai(config, req, transport),
74        Provider::Ollama => ollama(config, req, transport),
75    }
76}
77
78/// Tool declarations in the shape both providers accept.
79fn tools_json(tools: &[ToolSpec]) -> Value {
80    Value::Array(
81        tools
82            .iter()
83            .map(|tool| {
84                let mut properties = serde_json::Map::new();
85                let mut required = Vec::new();
86                for (name, ty) in &tool.params {
87                    properties.insert(name.clone(), param_schema(ty));
88                    required.push(Value::String(name.clone()));
89                }
90                json!({
91                    "type": "function",
92                    "function": {
93                        "name": tool.name,
94                        "description": tool.description,
95                        "parameters": {
96                            "type": "object",
97                            "properties": properties,
98                            "required": required,
99                        }
100                    }
101                })
102            })
103            .collect(),
104    )
105}
106
107fn param_schema(ty: &FieldType) -> Value {
108    match ty {
109        FieldType::Str => json!({"type": "string"}),
110        FieldType::Int => json!({"type": "integer"}),
111        FieldType::Float => json!({"type": "number"}),
112        FieldType::Bool => json!({"type": "boolean"}),
113        FieldType::ListOfStr => json!({"type": "array", "items": {"type": "string"}}),
114        // `field_type_of` never produces these for tool params today — the
115        // runtime gate is stricter than this enum — but the match must stay
116        // exhaustive as `FieldType` grows for `analyze()` results.
117        FieldType::Object(nested) => crate::schema::object_schema(nested),
118        FieldType::ListOfObject(nested) => {
119            json!({"type": "array", "items": crate::schema::object_schema(nested)})
120        }
121    }
122}
123
124/// Conversation messages: system, user, then any tool exchanges so far.
125///
126/// The two providers attach images differently — OpenAI splits the user
127/// message into typed content parts, Ollama keeps plain text and hangs a
128/// parallel `images` array off the message — so the provider decides the
129/// shape rather than the caller.
130fn messages(req: &AnalyzeRequest, provider: &Provider) -> Vec<Value> {
131    let text = user_prompt(&req.prompt, &req.data_json);
132    let user = if req.images.is_empty() {
133        json!({"role": "user", "content": text})
134    } else {
135        match provider {
136            Provider::OpenAI => {
137                let mut parts = vec![json!({"type": "text", "text": text})];
138                for image in &req.images {
139                    parts.push(json!({
140                        "type": "image_url",
141                        "image_url": {
142                            "url": format!(
143                                "data:{};base64,{}",
144                                image.mime,
145                                base64::encode(&image.bytes)
146                            )
147                        }
148                    }));
149                }
150                json!({"role": "user", "content": parts})
151            }
152            Provider::Ollama => {
153                let encoded: Vec<Value> = req
154                    .images
155                    .iter()
156                    .map(|i| Value::String(base64::encode(&i.bytes)))
157                    .collect();
158                json!({"role": "user", "content": text, "images": encoded})
159            }
160        }
161    };
162
163    let mut out = vec![
164        json!({"role": "system", "content": system_prompt(&req.schema)}),
165        user,
166    ];
167    for exchange in &req.tool_history {
168        out.push(json!({
169            "role": "assistant",
170            "content": format!("Calling {}({})", exchange.name, exchange.arguments_json),
171        }));
172        out.push(json!({
173            "role": "user",
174            "content": format!("Result of {}: {}", exchange.name, exchange.result_json),
175        }));
176    }
177    out
178}
179
180fn openai(
181    config: &ModelConfig,
182    req: &AnalyzeRequest,
183    transport: &Transport,
184) -> Result<Step, ModelError> {
185    let key = config
186        .api_key
187        .clone()
188        .or_else(|| std::env::var("OPENAI_API_KEY").ok())
189        .filter(|k| !k.trim().is_empty())
190        .ok_or_else(|| {
191            ModelError::new("OPENAI_API_KEY not set (export it, or set api_key in kora.toml)")
192        })?;
193
194    let mut body = json!({
195        "model": config.model,
196        "max_completion_tokens": config.max_output_tokens,
197        "messages": messages(req, &Provider::OpenAI),
198    });
199    if req.tools.is_empty() {
200        // Structured output and tool calling are mutually exclusive shapes:
201        // constrain the answer only once no tool can still be requested.
202        body["response_format"] = json!({
203            "type": "json_schema",
204            "json_schema": {
205                "name": sanitize_schema_name(&req.schema.type_name),
206                "strict": true,
207                "schema": build_json_schema(&req.schema),
208            }
209        });
210    } else {
211        body["tools"] = tools_json(&req.tools);
212    }
213
214    let headers = [
215        ("Authorization", format!("Bearer {key}")),
216        ("Content-Type", "application/json".to_string()),
217    ];
218    let url = format!("{OPENAI_BASE}/chat/completions");
219    let text = transport(&url, &headers, &body)?;
220    let response: Value = serde_json::from_str(&text).map_err(|e| {
221        ModelError::new(format!(
222            "OpenAI returned a non-JSON body ({e}): {}",
223            truncate(&text, 300)
224        ))
225    })?;
226
227    let tokens_in = response["usage"]["prompt_tokens"].as_u64().unwrap_or(0);
228    let tokens_out = response["usage"]["completion_tokens"].as_u64().unwrap_or(0);
229
230    let message = &response["choices"][0]["message"];
231    if let Some(call) = message["tool_calls"].get(0) {
232        let name = call["function"]["name"].as_str().unwrap_or_default();
233        let arguments_json = call["function"]["arguments"]
234            .as_str()
235            .unwrap_or("{}")
236            .to_string();
237        return Ok(Step::CallTool {
238            name: name.to_string(),
239            arguments_json,
240            tokens_in,
241            tokens_out,
242        });
243    }
244
245    let content = message["content"].as_str().ok_or_else(|| {
246        ModelError::new(format!(
247            "OpenAI response had no message content: {}",
248            truncate(&text, 300)
249        ))
250    })?;
251    parse_response(content, &req.schema, tokens_in, tokens_out).map(Step::Done)
252}
253
254fn ollama(
255    config: &ModelConfig,
256    req: &AnalyzeRequest,
257    transport: &Transport,
258) -> Result<Step, ModelError> {
259    let base = config.endpoint.as_deref().unwrap_or(OLLAMA_BASE);
260    let mut body = json!({
261        "model": config.model,
262        "stream": false,
263        "options": {"num_predict": config.max_output_tokens},
264        "messages": messages(req, &Provider::Ollama),
265    });
266    if req.tools.is_empty() {
267        // Ollama takes the JSON schema directly in `format`.
268        body["format"] = build_json_schema(&req.schema);
269    } else {
270        body["tools"] = tools_json(&req.tools);
271    }
272
273    let headers = [("Content-Type", "application/json".to_string())];
274    let url = format!("{}/api/chat", base.trim_end_matches('/'));
275    let text = transport(&url, &headers, &body)?;
276    let response: Value = serde_json::from_str(&text).map_err(|e| {
277        ModelError::new(format!(
278            "Ollama returned a non-JSON body ({e}): {}",
279            truncate(&text, 300)
280        ))
281    })?;
282
283    let tokens_in = response["prompt_eval_count"].as_u64().unwrap_or(0);
284    let tokens_out = response["eval_count"].as_u64().unwrap_or(0);
285
286    let message = &response["message"];
287    if let Some(call) = message["tool_calls"].get(0) {
288        let name = call["function"]["name"].as_str().unwrap_or_default();
289        // Ollama returns arguments as a JSON object, not a string.
290        let arguments_json = match &call["function"]["arguments"] {
291            Value::String(s) => s.clone(),
292            other => other.to_string(),
293        };
294        return Ok(Step::CallTool {
295            name: name.to_string(),
296            arguments_json,
297            tokens_in,
298            tokens_out,
299        });
300    }
301
302    let content = message["content"].as_str().ok_or_else(|| {
303        ModelError::new(format!(
304            "Ollama response had no message content: {}",
305            truncate(&text, 300)
306        ))
307    })?;
308    parse_response(content, &req.schema, tokens_in, tokens_out).map(Step::Done)
309}
310
311/// OpenAI requires schema names to match `^[a-zA-Z0-9_-]+$`.
312fn sanitize_schema_name(name: &str) -> String {
313    let cleaned: String = name
314        .chars()
315        .map(|c| {
316            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
317                c
318            } else {
319                '_'
320            }
321        })
322        .collect();
323    if cleaned.is_empty() {
324        "Result".to_string()
325    } else {
326        cleaned
327    }
328}
329
330/// The real network transport, carrying this config's timeout and retries.
331///
332/// Retrying here rather than around `step_with` keeps one HTTP request as the
333/// unit that is retried: a tool loop that has already run three turns does not
334/// start over because the fourth request was rate limited.
335pub(crate) fn transport_for(config: &ModelConfig) -> Box<Transport> {
336    // Zero is how "no timeout" sneaks back in, so it is clamped rather than
337    // honoured -- the same rule the `http` module applies.
338    let timeout = std::time::Duration::from_secs(config.timeout_secs.max(1));
339    let attempts = config.max_retries.saturating_add(1);
340    Box::new(move |url: &str, headers: &[(&str, String)], body: &Value| {
341        retry_loop(attempts, || send(url, headers, body, timeout))
342    })
343}
344
345/// The retry policy, with the request it retries passed in.
346///
347/// Split from the socket so the policy can be tested without one: how many
348/// attempts a 429 is worth is the part that will be argued about, and it
349/// should not need a listening port to check.
350fn retry_loop<F>(attempts: u32, mut attempt_once: F) -> Result<String, ModelError>
351where
352    F: FnMut() -> Result<String, (ModelError, Option<u64>)>,
353{
354    let mut attempt = 0;
355    loop {
356        attempt += 1;
357        let (error, retry_after) = match attempt_once() {
358            Ok(text) => return Ok(text),
359            Err(e) => e,
360        };
361        if !error.retryable || attempt >= attempts {
362            return Err(error);
363        }
364        std::thread::sleep(retry_delay(attempt, retry_after));
365    }
366}
367
368/// Exponential backoff, or what the provider asked for when it said.
369///
370/// The jitter matters more here than in a single-threaded client: a
371/// `parallel for` fans out across every core, so a shared rate limit hits
372/// every branch at once and an unjittered backoff marches them all back into
373/// the provider together.
374fn retry_delay(attempt: u32, retry_after: Option<u64>) -> std::time::Duration {
375    if let Some(secs) = retry_after {
376        return std::time::Duration::from_secs(secs.min(MAX_RETRY_AFTER_SECS));
377    }
378    let base = RETRY_BASE_MS.saturating_mul(1 << (attempt - 1).min(5));
379    std::time::Duration::from_millis(base + jitter_ms(base))
380}
381
382/// Up to a quarter of the wait, from the clock rather than a random source.
383///
384/// A real generator would be one more thing to seed, and nothing here needs
385/// to be unpredictable -- only for two threads to differ.
386fn jitter_ms(base: u64) -> u64 {
387    let nanos = std::time::SystemTime::now()
388        .duration_since(std::time::UNIX_EPOCH)
389        .map(|d| d.subsec_nanos() as u64)
390        .unwrap_or(0);
391    nanos % (base / 4).max(1)
392}
393
394/// Whether waiting could plausibly change the answer, and for how long.
395///
396/// 408, 409, 429 and every 5xx are the provider saying "not now". Everything
397/// else in the 4xx range is the request itself being wrong, and a retry only
398/// wastes the caller's time twice.
399fn retryable_status(code: u16) -> bool {
400    matches!(code, 408 | 409 | 429) || (500..600).contains(&code)
401}
402
403fn retry_after_secs(response: &ureq::Response) -> Option<u64> {
404    response.header("retry-after")?.trim().parse::<u64>().ok()
405}
406
407/// One attempt. The `Option<u64>` alongside an error is the provider's own
408/// `Retry-After`, which is worth more than any backoff guessed at locally.
409#[allow(clippy::type_complexity)]
410fn send(
411    url: &str,
412    headers: &[(&str, String)],
413    body: &Value,
414    timeout: std::time::Duration,
415) -> Result<String, (ModelError, Option<u64>)> {
416    let agent = ureq::AgentBuilder::new().timeout(timeout).build();
417    let mut request = agent.post(url);
418    for (name, value) in headers {
419        request = request.set(name, value);
420    }
421    match request.send_json(body.clone()) {
422        Ok(response) => response.into_string().map_err(|e| {
423            (
424                ModelError::retryable(format!("could not read response body from {url}: {e}")),
425                None,
426            )
427        }),
428        Err(ureq::Error::Status(code, response)) => {
429            let retry_after = retry_after_secs(&response);
430            let body = response.into_string().unwrap_or_default();
431            let message = format!("{url} returned HTTP {code}: {}", truncate(&body, 300));
432            let error = if retryable_status(code) {
433                ModelError::retryable(message)
434            } else {
435                ModelError::new(message)
436            };
437            Err((error, retry_after))
438        }
439        // Everything left is transport: a refused connection, a DNS failure,
440        // a timeout. None of those say anything about the request itself.
441        Err(e) => Err((
442            ModelError::retryable(format!("request to {url} failed: {e}")),
443            None,
444        )),
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::{AnalyzeOutcome, FieldType, Schema, SchemaField};
452    use std::cell::RefCell;
453
454    fn schema() -> Schema {
455        Schema {
456            type_name: "Insight".into(),
457            fields: vec![
458                SchemaField {
459                    name: "summary".into(),
460                    field_type: FieldType::Str,
461                    description: None,
462                    pattern: None,
463                },
464                SchemaField {
465                    name: "count".into(),
466                    field_type: FieldType::Int,
467                    description: None,
468                    pattern: None,
469                },
470            ],
471        }
472    }
473
474    fn request() -> AnalyzeRequest {
475        AnalyzeRequest {
476            prompt: "find anomalies".into(),
477            data_json: "{\"rows\":2}".into(),
478            images: Vec::new(),
479            schema: schema(),
480            tools: Vec::new(),
481            tool_history: Vec::new(),
482        }
483    }
484
485    /// Records the outgoing request and replays a canned response body.
486    /// What the recording transport captured: (url, request body).
487    type Captured = std::rc::Rc<RefCell<Option<(String, Value)>>>;
488
489    /// A boxed transport plus the handle that observes what it was sent.
490    type Recorder = (Box<Transport>, Captured);
491
492    /// Build a transport that replays `reply` and remembers the request.
493    fn recording(reply: &'static str) -> Recorder {
494        let seen: Captured = std::rc::Rc::new(RefCell::new(None));
495        let sink = seen.clone();
496        let transport = Box::new(move |url: &str, _h: &[(&str, String)], body: &Value| {
497            *sink.borrow_mut() = Some((url.to_string(), body.clone()));
498            Ok(reply.to_string())
499        });
500        (transport, seen)
501    }
502
503    #[test]
504    fn spec_openai() {
505        let c = parse_model_spec("openai:gpt-4o").unwrap();
506        assert_eq!(c.provider, Provider::OpenAI);
507        assert_eq!(c.model, "gpt-4o");
508        assert_eq!(c.max_output_tokens, 4096);
509        assert_eq!(c.timeout_secs, DEFAULT_TIMEOUT_SECS);
510    }
511
512    #[test]
513    fn spec_local_keeps_tag() {
514        let c = parse_model_spec("local:llama3.1:8b").unwrap();
515        assert_eq!(c.provider, Provider::Ollama);
516        assert_eq!(c.model, "llama3.1:8b");
517    }
518
519    #[test]
520    fn spec_errors() {
521        assert!(parse_model_spec("gpt-4o")
522            .unwrap_err()
523            .message
524            .contains("prefix"));
525        assert!(parse_model_spec("openai:")
526            .unwrap_err()
527            .message
528            .contains("no model name"));
529        assert!(parse_model_spec("groq:x")
530            .unwrap_err()
531            .message
532            .contains("unknown model provider"));
533    }
534
535    #[test]
536    fn openai_request_shape_and_parse() {
537        let reply = r#"{
538            "choices":[{"message":{"content":"{\"summary\":\"ok\",\"count\":2,\"__uncertain__\":\"\"}"}}],
539            "usage":{"prompt_tokens":11,"completion_tokens":7}
540        }"#;
541        let (transport, seen) = recording(reply);
542        let mut config = parse_model_spec("openai:gpt-4o").unwrap();
543        config.api_key = Some("test-key".into());
544
545        let outcome = step_with(&config, &request(), &*transport).unwrap();
546        match outcome {
547            Step::Done(AnalyzeOutcome::Ok {
548                fields_json,
549                tokens_in,
550                tokens_out,
551            }) => {
552                assert_eq!(fields_json["summary"], "ok");
553                assert_eq!(tokens_in, 11);
554                assert_eq!(tokens_out, 7);
555            }
556            other => panic!("expected Ok, got {other:?}"),
557        }
558
559        let (url, body) = seen.borrow().clone().unwrap();
560        assert_eq!(url, "https://api.openai.com/v1/chat/completions");
561        assert_eq!(body["response_format"]["type"], "json_schema");
562        assert_eq!(body["response_format"]["json_schema"]["strict"], true);
563        assert_eq!(body["messages"][0]["role"], "system");
564        assert!(body["messages"][1]["content"]
565            .as_str()
566            .unwrap()
567            .contains("DATA:"));
568    }
569
570    #[test]
571    fn openai_missing_key_is_clear() {
572        // Ensure the env var cannot rescue the call.
573        std::env::remove_var("OPENAI_API_KEY");
574        let (transport, _seen) = recording("{}");
575        let config = parse_model_spec("openai:gpt-4o").unwrap();
576        let err = step_with(&config, &request(), &*transport).unwrap_err();
577        assert!(
578            err.message.contains("OPENAI_API_KEY not set"),
579            "{}",
580            err.message
581        );
582    }
583
584    #[test]
585    fn ollama_request_shape_and_uncertain() {
586        let reply = r#"{
587            "message":{"content":"{\"summary\":\"\",\"count\":0,\"__uncertain__\":\"no revenue column\"}"},
588            "prompt_eval_count":30,"eval_count":9
589        }"#;
590        let (transport, seen) = recording(reply);
591        let config = parse_model_spec("local:llama3.1:8b").unwrap();
592
593        match step_with(&config, &request(), &*transport).unwrap() {
594            Step::Done(AnalyzeOutcome::Uncertain {
595                reason,
596                tokens_in,
597                tokens_out,
598            }) => {
599                assert_eq!(reason, "no revenue column");
600                assert_eq!(tokens_in, 30);
601                assert_eq!(tokens_out, 9);
602            }
603            other => panic!("expected Uncertain, got {other:?}"),
604        }
605
606        let (url, body) = seen.borrow().clone().unwrap();
607        assert_eq!(url, "http://localhost:11434/api/chat");
608        assert_eq!(body["stream"], false);
609        assert_eq!(body["format"]["type"], "object");
610    }
611
612    #[test]
613    fn ollama_endpoint_override() {
614        let reply =
615            r#"{"message":{"content":"{\"summary\":\"a\",\"count\":1,\"__uncertain__\":\"\"}"}}"#;
616        let (transport, seen) = recording(reply);
617        let mut config = parse_model_spec("local:llama3.1:8b").unwrap();
618        config.endpoint = Some("http://box:11434/".into());
619
620        step_with(&config, &request(), &*transport).unwrap();
621        assert_eq!(
622            seen.borrow().clone().unwrap().0,
623            "http://box:11434/api/chat"
624        );
625    }
626
627    /// The same image must arrive in each provider's own shape: OpenAI wants
628    /// typed content parts with a data URL, Ollama wants bare base64 in a
629    /// sibling array. Getting either wrong is a silently text-only request.
630    #[test]
631    fn openai_attaches_images_as_content_parts() {
632        let reply = r#"{
633            "choices":[{"message":{"content":"{\"summary\":\"ok\",\"count\":1,\"__uncertain__\":\"\"}"}}],
634            "usage":{"prompt_tokens":1,"completion_tokens":1}
635        }"#;
636        let (transport, seen) = recording(reply);
637        let mut config = parse_model_spec("openai:gpt-4o").unwrap();
638        config.api_key = Some("test-key".into());
639        let mut req = request();
640        req.images = vec![crate::ImagePart {
641            mime: "image/png".into(),
642            bytes: b"foobar".to_vec(),
643        }];
644
645        step_with(&config, &req, &*transport).unwrap();
646        let (_, body) = seen.borrow().clone().unwrap();
647        let parts = &body["messages"][1]["content"];
648        assert_eq!(parts[0]["type"], "text");
649        assert_eq!(parts[1]["type"], "image_url");
650        assert_eq!(
651            parts[1]["image_url"]["url"],
652            "data:image/png;base64,Zm9vYmFy"
653        );
654    }
655
656    #[test]
657    fn ollama_attaches_images_beside_the_text() {
658        let reply =
659            r#"{"message":{"content":"{\"summary\":\"a\",\"count\":1,\"__uncertain__\":\"\"}"}}"#;
660        let (transport, seen) = recording(reply);
661        let config = parse_model_spec("local:llava:7b").unwrap();
662        let mut req = request();
663        req.images = vec![crate::ImagePart {
664            mime: "image/png".into(),
665            bytes: b"foobar".to_vec(),
666        }];
667
668        step_with(&config, &req, &*transport).unwrap();
669        let (_, body) = seen.borrow().clone().unwrap();
670        let message = &body["messages"][1];
671        assert!(message["content"].as_str().unwrap().contains("DATA:"));
672        assert_eq!(message["images"][0], "Zm9vYmFy");
673    }
674
675    /// A text-only call must keep the plain-string content shape: some
676    /// providers and local models reject the content-parts form outright.
677    #[test]
678    fn no_images_keeps_plain_string_content() {
679        let reply = r#"{
680            "choices":[{"message":{"content":"{\"summary\":\"ok\",\"count\":1,\"__uncertain__\":\"\"}"}}]
681        }"#;
682        let (transport, seen) = recording(reply);
683        let mut config = parse_model_spec("openai:gpt-4o").unwrap();
684        config.api_key = Some("test-key".into());
685
686        step_with(&config, &request(), &*transport).unwrap();
687        let (_, body) = seen.borrow().clone().unwrap();
688        assert!(body["messages"][1]["content"].is_string());
689    }
690
691    #[test]
692    fn schema_name_sanitized() {
693        assert_eq!(sanitize_schema_name("Insight"), "Insight");
694        assert_eq!(sanitize_schema_name("my type!"), "my_type_");
695        assert_eq!(sanitize_schema_name(""), "Result");
696    }
697}
698
699#[cfg(test)]
700mod retry_tests {
701    use super::*;
702
703    #[test]
704    fn a_bad_request_is_never_retried() {
705        // 400 and 401 are the request being wrong. Retrying one wastes the
706        // caller's time twice and reaches the same answer.
707        for code in [400, 401, 403, 404, 422] {
708            assert!(!retryable_status(code), "{code} should not be retried");
709        }
710    }
711
712    #[test]
713    fn a_rate_limit_or_a_server_error_is_retried() {
714        for code in [408, 409, 429, 500, 502, 503, 504] {
715            assert!(retryable_status(code), "{code} should be retried");
716        }
717    }
718
719    #[test]
720    fn the_backoff_grows_and_stays_bounded() {
721        let first = retry_delay(1, None);
722        let second = retry_delay(2, None);
723        assert!(
724            first.as_millis() >= RETRY_BASE_MS as u128,
725            "the first wait should be at least the base"
726        );
727        assert!(
728            second >= first,
729            "waits should grow: {second:?} came after {first:?}"
730        );
731        // Jitter is a fraction of the wait, not a multiple of it.
732        assert!(first.as_millis() < (RETRY_BASE_MS as u128) * 2);
733    }
734
735    #[test]
736    fn the_provider_is_believed_over_the_local_backoff() {
737        assert_eq!(retry_delay(1, Some(3)).as_secs(), 3);
738    }
739
740    #[test]
741    fn an_absurd_retry_after_is_capped_rather_than_waited_out() {
742        // A provider asking for an hour is telling the program to come back
743        // later, not to hold a thread open until then.
744        assert_eq!(
745            retry_delay(1, Some(3600)).as_secs(),
746            MAX_RETRY_AFTER_SECS,
747            "a long Retry-After should be capped"
748        );
749    }
750
751    #[test]
752    fn retries_stop_at_the_configured_count() {
753        // Counts attempts rather than sleeping: the policy is what is under
754        // test, not the clock.
755        let attempts = std::cell::Cell::new(0);
756        let result = retry_loop(3, || {
757            attempts.set(attempts.get() + 1);
758            Err((ModelError::retryable("nope"), Some(0)))
759        });
760        assert!(result.is_err());
761        assert_eq!(attempts.get(), 3, "three attempts, then give up");
762    }
763
764    #[test]
765    fn an_unretryable_failure_is_reported_on_the_first_attempt() {
766        let attempts = std::cell::Cell::new(0);
767        let result = retry_loop(3, || {
768            attempts.set(attempts.get() + 1);
769            Err((ModelError::new("bad api key"), None))
770        });
771        assert!(result.is_err());
772        assert_eq!(attempts.get(), 1, "a 401 does not improve with waiting");
773    }
774
775    #[test]
776    fn a_retry_that_succeeds_returns_the_answer() {
777        let attempts = std::cell::Cell::new(0);
778        let result = retry_loop(3, || {
779            attempts.set(attempts.get() + 1);
780            if attempts.get() < 2 {
781                Err((ModelError::retryable("try again"), Some(0)))
782            } else {
783                Ok("body".to_string())
784            }
785        });
786        assert_eq!(result.unwrap(), "body");
787        assert_eq!(attempts.get(), 2);
788    }
789}