1use 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
13pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
17pub const DEFAULT_MAX_RETRIES: u32 = 2;
21const RETRY_BASE_MS: u64 = 500;
23const MAX_RETRY_AFTER_SECS: u64 = 20;
26const OPENAI_BASE: &str = "https://api.openai.com/v1";
27const OLLAMA_BASE: &str = "http://localhost:11434";
28
29pub(crate) type Transport = dyn Fn(&str, &[(&str, String)], &Value) -> Result<String, ModelError>;
31
32pub 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
78fn 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 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
124fn 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 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 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 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
311fn 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
330pub(crate) fn transport_for(config: &ModelConfig) -> Box<Transport> {
336 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
345fn 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
368fn 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
382fn 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
394fn 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#[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 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 type Captured = std::rc::Rc<RefCell<Option<(String, Value)>>>;
488
489 type Recorder = (Box<Transport>, Captured);
491
492 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 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 #[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 #[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 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 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 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 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}