use serde::{Deserialize, Serialize};
use crate::core::errors::{Result, TgaError};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum LlmSource {
#[default]
Openrouter,
Bedrock,
#[serde(rename = "anthropic-api")]
AnthropicApi,
Jev,
}
pub const JEV_API_KEY_ENV: &str = "TYPESAFE_API_KEY";
pub const JEV_DEFAULT_BUDGET_USD: f64 = 0.25;
pub const JEV_DEFAULT_NAME_MATCHER_BYTES: usize = 64 << 20;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct JevOptions {
#[serde(default = "default_jev_budget_usd")]
pub budget_usd: f64,
#[serde(default)]
pub sensitive_terms: Vec<String>,
#[serde(default)]
pub payload_dump_dir: Option<std::path::PathBuf>,
#[serde(default)]
pub id_patterns: Vec<String>,
#[serde(default = "default_jev_name_matcher_bytes")]
pub name_matcher_bytes: usize,
#[serde(default)]
pub obfuscate: bool,
}
fn default_jev_budget_usd() -> f64 {
JEV_DEFAULT_BUDGET_USD
}
fn default_jev_name_matcher_bytes() -> usize {
JEV_DEFAULT_NAME_MATCHER_BYTES
}
impl Default for JevOptions {
fn default() -> Self {
Self {
budget_usd: JEV_DEFAULT_BUDGET_USD,
sensitive_terms: Vec::new(),
payload_dump_dir: None,
id_patterns: Vec::new(),
name_matcher_bytes: JEV_DEFAULT_NAME_MATCHER_BYTES,
obfuscate: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum LlmEffort {
Low,
Medium,
High,
Xhigh,
Max,
}
impl LlmEffort {
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LlmFallbackScope {
#[default]
LowConfidence,
Unanswered,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LlmContextItem {
Paths,
PrTitle,
IssueType,
}
pub const LLM_CONTEXT_DEFAULT_MAX_PATHS: usize = 30;
pub const LLM_CONTEXT_DEFAULT_MAX_PATH_BYTES: usize = 2048;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct LlmConfig {
#[serde(default)]
pub source: LlmSource,
#[serde(default = "default_api_key_env")]
pub api_key_env: String,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub effort: Option<LlmEffort>,
#[serde(default)]
pub jev: JevOptions,
#[serde(default)]
pub context: Vec<LlmContextItem>,
#[serde(default = "default_context_max_paths")]
pub context_max_paths: usize,
#[serde(default = "default_context_max_path_bytes")]
pub context_max_path_bytes: usize,
}
fn default_context_max_paths() -> usize {
LLM_CONTEXT_DEFAULT_MAX_PATHS
}
fn default_context_max_path_bytes() -> usize {
LLM_CONTEXT_DEFAULT_MAX_PATH_BYTES
}
fn default_api_key_env() -> String {
"OPENROUTER_API_KEY".to_string()
}
const JEV_OPTION_KEYS: [&str; 6] = [
"obfuscate",
"payload_dump_dir",
"sensitive_terms",
"id_patterns",
"name_matcher_bytes",
"budget_usd",
];
pub(super) fn reject_misplaced_top_level_keys(text: &str) -> Result<()> {
type TopLevel = std::collections::HashMap<String, serde::de::IgnoredAny>;
let Ok(map) = serde_yaml::from_str::<TopLevel>(text) else {
return Ok(());
};
for key in map.keys().map(String::as_str) {
let belongs = if key == "jev" || JEV_OPTION_KEYS.contains(&key) {
"it belongs under `llm.jev:`"
} else if key.starts_with("llm.") {
"YAML does not split a dotted key; write it nested under `llm:`"
} else {
continue;
};
return Err(TgaError::ConfigError(format!(
"top-level key `{key}` is not read; {belongs}"
)));
}
Ok(())
}
impl LlmConfig {
pub fn effective_api_key_env(&self) -> &str {
if self.source == LlmSource::Jev && self.api_key_env == default_api_key_env() {
JEV_API_KEY_ENV
} else {
&self.api_key_env
}
}
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
source: LlmSource::default(),
api_key_env: default_api_key_env(),
region: None,
model: None,
effort: None,
jev: JevOptions::default(),
context: Vec::new(),
context_max_paths: LLM_CONTEXT_DEFAULT_MAX_PATHS,
context_max_path_bytes: LLM_CONTEXT_DEFAULT_MAX_PATH_BYTES,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fallback_scope_and_effort_parse() {
let cfg: LlmConfig =
serde_yaml::from_str("source: anthropic-api\neffort: low\n").expect("parse");
assert_eq!(cfg.effort, Some(LlmEffort::Low));
assert_eq!(cfg.effort.map(LlmEffort::as_str), Some("low"));
assert!(serde_yaml::from_str::<LlmConfig>("effort: lowest\n").is_err());
let scope: LlmFallbackScope = serde_yaml::from_str("unanswered").expect("scope");
assert_eq!(scope, LlmFallbackScope::Unanswered);
assert_eq!(LlmFallbackScope::default(), LlmFallbackScope::LowConfidence);
assert!(serde_yaml::from_str::<LlmFallbackScope>("abstentions").is_err());
}
#[test]
fn jev_source_and_options_parse() {
let min: LlmConfig = serde_yaml::from_str("source: jev\n").expect("parse");
assert_eq!(min.source, LlmSource::Jev);
assert_eq!(min.effective_api_key_env(), JEV_API_KEY_ENV);
assert_eq!(min.jev, JevOptions::default());
assert_eq!(min.jev.budget_usd, JEV_DEFAULT_BUDGET_USD);
assert!(min.jev.sensitive_terms.is_empty());
assert!(min.jev.payload_dump_dir.is_none());
assert!(min.jev.id_patterns.is_empty());
assert_eq!(min.jev.name_matcher_bytes, JEV_DEFAULT_NAME_MATCHER_BYTES);
assert!(!min.jev.obfuscate);
let full: LlmConfig = serde_yaml::from_str(
"source: jev\napi_key_env: MY_JEV_KEY\njev:\n budget_usd: 0.1\n \
sensitive_terms: [ledgerd, paygate]\n payload_dump_dir: /tmp/jev\n \
obfuscate: true\n",
)
.expect("parse");
assert!(full.jev.obfuscate);
assert_eq!(full.effective_api_key_env(), "MY_JEV_KEY");
assert_eq!(full.jev.budget_usd, 0.1);
assert_eq!(full.jev.sensitive_terms, ["ledgerd", "paygate"]);
assert_eq!(
full.jev.payload_dump_dir.as_deref(),
Some(std::path::Path::new("/tmp/jev"))
);
assert!(serde_yaml::from_str::<LlmConfig>("source: jev\njev:\n budget: 1\n").is_err());
let or = LlmConfig::default();
assert_eq!(or.effective_api_key_env(), "OPENROUTER_API_KEY");
}
#[test]
fn misplaced_llm_keys_fail_the_config_load() {
for key in [
"payload_dump_dir: ./dump",
"budget_usd: 0.1",
"sensitive_terms: [ledgerd]",
"id_patterns: ['Q\\d+']",
"name_matcher_bytes: 1024",
"obfuscate: true",
"modle: jev-1.13.0",
] {
let e = serde_yaml::from_str::<LlmConfig>(&format!("source: jev\n{key}\n"))
.expect_err(key)
.to_string();
let name = key.split(':').next().expect("key");
assert!(e.contains(name), "{key}: {e}");
}
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
std::fs::write(&path, "llm:\n source: jev\n payload_dump_dir: ./dump\n").expect("write");
assert!(
crate::core::config::Config::load(&path).is_err(),
"a misplaced payload_dump_dir loaded"
);
std::fs::write(
&path,
"llm:\n source: jev\n jev:\n payload_dump_dir: ./dump\n",
)
.expect("write");
let cfg = crate::core::config::Config::load(&path).expect("documented shape");
assert!(cfg.llm.expect("llm").jev.payload_dump_dir.is_some());
}
#[test]
fn misplaced_top_level_jev_keys_fail_the_config_load() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
let load = |yaml: &str| {
std::fs::write(&path, yaml).expect("write");
crate::core::config::Config::load(&path)
};
let jev_keys = [
"obfuscate: true",
"payload_dump_dir: ./dump",
"sensitive_terms: [ledgerd]",
"id_patterns: ['Q\\d+']",
"name_matcher_bytes: 1024",
"budget_usd: 0.1",
];
let mut cases: Vec<(String, &str, &str)> = vec![
(
"llm:\n source: jev\njev:\n obfuscate: true\n".into(),
"jev",
"llm.jev",
),
(
"llm:\n source: jev\nllm.jev.obfuscate: true\n".into(),
"llm.jev.obfuscate",
"nested",
),
("llm.source: jev\n".into(), "llm.source", "nested"),
];
for key in jev_keys {
let name = key.split(':').next().expect("key");
cases.push((format!("llm:\n source: jev\n{key}\n"), name, "llm.jev"));
}
let mut loaded = Vec::new();
for (yaml, key, belongs) in &cases {
match load(yaml) {
Ok(_) => loaded.push(*key),
Err(e) => {
let e = e.to_string();
assert!(e.contains(&format!("`{key}`")), "{key}: {e}");
assert!(e.contains(belongs), "{key}: {e}");
}
}
}
assert!(loaded.is_empty(), "misplaced keys loaded: {loaded:?}");
let cfg = load(
"llm:\n source: jev\n jev:\n obfuscate: true\nvelocity: {}\nquality_report: {}\n",
)
.expect("unknown top-level key tolerated");
assert!(cfg.llm.expect("llm").jev.obfuscate);
}
#[test]
fn llm_context_items_parse_and_unknown_items_fail_the_load() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
let load = |yaml: &str| {
std::fs::write(&path, yaml).expect("write");
crate::core::config::Config::load(&path)
};
let cfg = load(
"llm:\n source: openrouter\n context: [paths, pr_title, issue_type]\n \
context_max_paths: 5\n context_max_path_bytes: 100\n",
)
.unwrap_or_else(|e| panic!("documented shape: {e}"));
let llm = serde_yaml::to_value(cfg.llm.expect("llm")).expect("serialize");
assert_eq!(
llm["context"],
serde_yaml::from_str::<serde_yaml::Value>("[paths, pr_title, issue_type]")
.expect("yaml")
);
assert_eq!(llm["context_max_paths"], serde_yaml::Value::from(5));
assert_eq!(llm["context_max_path_bytes"], serde_yaml::Value::from(100));
let default = serde_yaml::to_value(LlmConfig::default()).expect("serialize");
assert_eq!(default["context"], serde_yaml::Value::Sequence(Vec::new()));
assert_eq!(default["context_max_paths"], serde_yaml::Value::from(30));
assert_eq!(
default["context_max_path_bytes"],
serde_yaml::Value::from(2048)
);
for (yaml, named) in [
("llm:\n context: [paths, diff]\n", "diff"),
("llm:\n context: [PR_TITLE]\n", "PR_TITLE"),
("llm:\n context: paths\n", "paths"),
] {
let e = load(yaml).expect_err(yaml).to_string();
assert!(e.contains(named), "{yaml:?}: {e}");
}
}
#[test]
fn misplaced_top_level_jev_key_fails_beside_a_duplicate_key() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
let load = |yaml: &str| {
std::fs::write(&path, yaml).expect("write");
crate::core::config::Config::load(&path)
};
let misplaced = "jev:\n obfuscate: true\n";
let cases = [
("top level", "velocity: {}\nvelocity: {}\n"),
(
"developer_aliases",
"developer_aliases:\n Ann: [a@x.io]\n Ann: [b@x.io]\n",
),
("ignored section", "velocity:\n window: 1\n window: 2\n"),
];
let mut loaded = Vec::new();
for (name, dup) in cases {
match load(&format!("llm:\n source: jev\n{dup}{misplaced}")) {
Ok(_) => loaded.push(name),
Err(e) => assert!(e.to_string().contains("`jev`"), "{name}: {e}"),
}
}
assert!(
loaded.is_empty(),
"loaded despite misplaced `jev`: {loaded:?}"
);
for yaml in [
"- jev:\n obfuscate: true\n",
"jev: {obfuscate: true}\nllm: [\n",
] {
assert!(load(yaml).is_err(), "loaded: {yaml:?}");
}
}
#[test]
fn misplaced_top_level_jev_key_fails_beside_a_tagged_key() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
let load = |yaml: &str| {
std::fs::write(&path, yaml).expect("write");
crate::core::config::Config::load(&path)
};
let mut loaded = Vec::new();
for (name, extra) in [
("int tag", "!!int abc: 1\njev:\n obfuscate: true\n"),
("bool tag", "!!bool xyz: 1\njev:\n obfuscate: true\n"),
("tagged jev", "!foo jev: {obfuscate: true}\n"),
(
"scalar keys",
"1: a\n~: b\ntrue: c\njev:\n obfuscate: true\n",
),
] {
match load(&format!("llm:\n source: jev\n{extra}")) {
Ok(_) => loaded.push(name),
Err(e) => assert!(e.to_string().contains("`jev`"), "{name}: {e}"),
}
}
assert!(
loaded.is_empty(),
"loaded despite misplaced `jev`: {loaded:?}"
);
let cfg = load("llm:\n source: jev\n jev:\n obfuscate: true\n1: a\n~: b\ntrue: c\n")
.expect("non-string scalar keys are not refused");
assert!(cfg.llm.expect("llm").jev.obfuscate);
for key in ["? {a: 1}\n: x\n", "? [a]\n: x\n"] {
let yaml = format!("{key}jev: {{obfuscate: true}}\n");
assert!(load(&yaml).is_err(), "loaded: {yaml:?}");
}
}
}