use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct LlmConfig {
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub load_env: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredExtractionConfig {
pub schema: serde_json::Value,
#[serde(default = "default_schema_name")]
pub schema_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_description: Option<String>,
#[serde(default)]
pub strict: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
pub llm: LlmConfig,
}
fn default_schema_name() -> String {
"extraction".to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum CallMode {
#[default]
TextOnly,
VisionOnly,
TextPlusVision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum MergeMode {
#[default]
ObjectMerge,
ArrayConcat,
ObjectFirst,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_llm_config_default_trait_is_satisfied() {
let cfg = LlmConfig::default();
assert!(cfg.model.is_empty(), "default model should be empty string");
assert!(cfg.api_key.is_none());
assert!(cfg.base_url.is_none());
assert!(cfg.timeout_secs.is_none());
assert!(cfg.max_retries.is_none());
assert!(cfg.temperature.is_none());
assert!(cfg.max_tokens.is_none());
assert!(cfg.load_env.is_none());
assert!(cfg.headers.is_none());
}
#[test]
fn test_llm_config_struct_update_syntax() {
let cfg = LlmConfig {
model: "openai/gpt-4o-mini".to_string(),
..Default::default()
};
assert_eq!(cfg.model, "openai/gpt-4o-mini");
assert!(cfg.api_key.is_none());
assert!(cfg.base_url.is_none());
assert!(cfg.timeout_secs.is_none());
assert!(cfg.max_retries.is_none());
assert!(cfg.temperature.is_none());
assert!(cfg.max_tokens.is_none());
assert!(cfg.load_env.is_none());
assert!(cfg.headers.is_none());
}
#[test]
fn test_llm_config_load_env_and_headers_round_trip() {
let toml_src = r#"
model = "openai/gpt-4o"
load_env = true
[headers]
"X-Gateway-Key" = "abc123"
"X-Tenant" = "acme"
"#;
let cfg: LlmConfig = toml::from_str(toml_src).expect("deserialize LlmConfig from TOML");
assert_eq!(cfg.model, "openai/gpt-4o");
assert_eq!(cfg.load_env, Some(true));
let headers = cfg.headers.as_ref().expect("headers present");
assert_eq!(headers.get("X-Gateway-Key").map(String::as_str), Some("abc123"));
assert_eq!(headers.get("X-Tenant").map(String::as_str), Some("acme"));
let round_tripped: LlmConfig =
serde_json::from_str(&serde_json::to_string(&cfg).expect("serialize")).expect("deserialize");
assert_eq!(round_tripped, cfg);
}
#[test]
fn test_llm_config_omits_empty_passthrough_fields() {
let cfg = LlmConfig {
model: "openai/gpt-4o".to_string(),
..Default::default()
};
let json = serde_json::to_string(&cfg).expect("serialize");
assert!(
!json.contains("load_env"),
"load_env should be omitted when None: {json}"
);
assert!(!json.contains("headers"), "headers should be omitted when None: {json}");
}
#[test]
fn test_call_mode_serde_round_trip() {
for (mode, wire) in [
(CallMode::TextOnly, "\"text_only\""),
(CallMode::VisionOnly, "\"vision_only\""),
(CallMode::TextPlusVision, "\"text_plus_vision\""),
] {
let json = serde_json::to_string(&mode).expect("serialize");
assert_eq!(json, wire);
let decoded: CallMode = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded, mode);
}
assert_eq!(CallMode::default(), CallMode::TextOnly);
}
#[test]
fn test_merge_mode_serde_round_trip() {
for (mode, wire) in [
(MergeMode::ObjectMerge, "\"object_merge\""),
(MergeMode::ArrayConcat, "\"array_concat\""),
(MergeMode::ObjectFirst, "\"object_first\""),
] {
let json = serde_json::to_string(&mode).expect("serialize");
assert_eq!(json, wire);
let decoded: MergeMode = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded, mode);
}
assert_eq!(MergeMode::default(), MergeMode::ObjectMerge);
}
}