use serde::Deserialize;
use serde_json::{Value, json};
use super::reasoning::{ReasoningChunk, ReasoningLevel};
#[derive(Debug, Clone)]
pub struct ProviderProfile {
pub name: &'static str,
pub base_url: &'static str,
pub api_key_env: &'static str,
pub extra_headers: &'static [(&'static str, &'static str)],
pub reasoning_strategy: ReasoningStrategy,
pub reasoning_extraction: ReasoningExtraction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningStrategy {
None,
Effort,
OpenRouterShape,
}
impl ReasoningStrategy {
pub fn render(&self, level: ReasoningLevel) -> Option<Value> {
match self {
ReasoningStrategy::None => None,
ReasoningStrategy::Effort => match level {
ReasoningLevel::None => Some(json!({"reasoning_effort": "none"})),
ReasoningLevel::Minimal => Some(json!({"reasoning_effort": "minimal"})),
ReasoningLevel::Low => Some(json!({"reasoning_effort": "low"})),
ReasoningLevel::Medium => Some(json!({"reasoning_effort": "medium"})),
ReasoningLevel::High => Some(json!({"reasoning_effort": "high"})),
ReasoningLevel::XHigh => Some(json!({"reasoning_effort": "xhigh"})),
ReasoningLevel::Max => Some(json!({"reasoning_effort": "high"})),
},
ReasoningStrategy::OpenRouterShape => match level {
ReasoningLevel::None => Some(json!({"reasoning": {"exclude": true}})),
ReasoningLevel::Minimal => Some(json!({"reasoning": {"effort": "low"}})),
ReasoningLevel::Low => Some(json!({"reasoning": {"effort": "low"}})),
ReasoningLevel::Medium => Some(json!({"reasoning": {"effort": "medium"}})),
ReasoningLevel::High => Some(json!({"reasoning": {"effort": "high"}})),
ReasoningLevel::XHigh => Some(json!({"reasoning": {"effort": "high"}})),
ReasoningLevel::Max => Some(json!({"reasoning": {"effort": "max"}})),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReasoningExtraction {
None,
DeltaContentField(&'static str),
InlineThinkTags,
}
impl ReasoningExtraction {
pub fn parse_delta(&self, delta: &Value) -> Option<ReasoningChunk> {
match self {
ReasoningExtraction::None | ReasoningExtraction::InlineThinkTags => None,
ReasoningExtraction::DeltaContentField(field) => {
let text = delta.get(field).and_then(|v| v.as_str())?;
if text.is_empty() {
None
} else {
Some(ReasoningChunk {
text: text.to_string(),
signature: None,
})
}
},
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CompatStyle {
Openai,
OpenaiEffort,
Openrouter,
}
impl CompatStyle {
pub fn reasoning_strategy(self) -> ReasoningStrategy {
match self {
CompatStyle::Openai => ReasoningStrategy::None,
CompatStyle::OpenaiEffort => ReasoningStrategy::Effort,
CompatStyle::Openrouter => ReasoningStrategy::OpenRouterShape,
}
}
}
pub const REGISTRY: &[ProviderProfile] = &[
ProviderProfile {
name: "openai",
base_url: "https://api.openai.com/v1",
api_key_env: "OPENAI_API_KEY",
extra_headers: &[],
reasoning_strategy: ReasoningStrategy::Effort,
reasoning_extraction: ReasoningExtraction::None,
},
ProviderProfile {
name: "groq",
base_url: "https://api.groq.com/openai/v1",
api_key_env: "GROQ_API_KEY",
extra_headers: &[],
reasoning_strategy: ReasoningStrategy::Effort,
reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning"),
},
ProviderProfile {
name: "openrouter",
base_url: "https://openrouter.ai/api/v1",
api_key_env: "OPENROUTER_API_KEY",
extra_headers: &[
("HTTP-Referer", "https://github.com/noahsabaj/mermaid-cli"),
("X-OpenRouter-Title", "Mermaid"),
],
reasoning_strategy: ReasoningStrategy::OpenRouterShape,
reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning"),
},
ProviderProfile {
name: "cerebras",
base_url: "https://api.cerebras.ai/v1",
api_key_env: "CEREBRAS_API_KEY",
extra_headers: &[],
reasoning_strategy: ReasoningStrategy::Effort,
reasoning_extraction: ReasoningExtraction::None,
},
ProviderProfile {
name: "deepinfra",
base_url: "https://api.deepinfra.com/v1/openai",
api_key_env: "DEEPINFRA_API_KEY",
extra_headers: &[],
reasoning_strategy: ReasoningStrategy::None,
reasoning_extraction: ReasoningExtraction::DeltaContentField("reasoning_content"),
},
ProviderProfile {
name: "together",
base_url: "https://api.together.xyz/v1",
api_key_env: "TOGETHER_API_KEY",
extra_headers: &[],
reasoning_strategy: ReasoningStrategy::None,
reasoning_extraction: ReasoningExtraction::InlineThinkTags,
},
];
pub fn lookup_provider(name: &str) -> Option<&'static ProviderProfile> {
let lower = name.to_lowercase();
REGISTRY.iter().find(|p| p.name == lower)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookup_known_provider() {
let p = lookup_provider("groq").expect("groq is in the registry");
assert_eq!(p.name, "groq");
assert!(p.base_url.starts_with("https://api.groq.com"));
assert_eq!(p.api_key_env, "GROQ_API_KEY");
}
#[test]
fn lookup_is_case_insensitive() {
assert!(lookup_provider("OpenAI").is_some());
assert!(lookup_provider("OPENROUTER").is_some());
}
#[test]
fn lookup_unknown_provider() {
assert!(lookup_provider("does-not-exist").is_none());
}
#[test]
fn registry_has_six_providers() {
assert_eq!(REGISTRY.len(), 6);
}
#[test]
fn openrouter_has_analytics_headers() {
let p = lookup_provider("openrouter").unwrap();
let names: Vec<&str> = p.extra_headers.iter().map(|(k, _)| *k).collect();
assert!(names.contains(&"HTTP-Referer"));
assert!(names.contains(&"X-OpenRouter-Title"));
}
#[test]
fn effort_renders_string_per_level() {
let s = ReasoningStrategy::Effort;
assert_eq!(
s.render(ReasoningLevel::None),
Some(json!({"reasoning_effort": "none"})),
);
assert_eq!(
s.render(ReasoningLevel::Low),
Some(json!({"reasoning_effort": "low"})),
);
assert_eq!(
s.render(ReasoningLevel::Medium),
Some(json!({"reasoning_effort": "medium"})),
);
assert_eq!(
s.render(ReasoningLevel::High),
Some(json!({"reasoning_effort": "high"})),
);
assert_eq!(
s.render(ReasoningLevel::XHigh),
Some(json!({"reasoning_effort": "xhigh"})),
);
assert_eq!(
s.render(ReasoningLevel::Max),
Some(json!({"reasoning_effort": "high"})),
);
}
#[test]
fn openrouter_shape_renders_nested_object() {
let s = ReasoningStrategy::OpenRouterShape;
assert_eq!(
s.render(ReasoningLevel::None),
Some(json!({"reasoning": {"exclude": true}})),
);
assert_eq!(
s.render(ReasoningLevel::Medium),
Some(json!({"reasoning": {"effort": "medium"}})),
);
assert_eq!(
s.render(ReasoningLevel::Max),
Some(json!({"reasoning": {"effort": "max"}})),
);
assert_eq!(
s.render(ReasoningLevel::XHigh),
Some(json!({"reasoning": {"effort": "high"}})),
);
}
#[test]
fn none_strategy_renders_nothing() {
let s = ReasoningStrategy::None;
for level in [
ReasoningLevel::None,
ReasoningLevel::Low,
ReasoningLevel::Medium,
ReasoningLevel::High,
ReasoningLevel::Max,
] {
assert_eq!(s.render(level), None);
}
}
#[test]
fn delta_field_extraction_finds_named_field() {
let e = ReasoningExtraction::DeltaContentField("reasoning_content");
let delta = json!({"reasoning_content": "weighing options", "content": ""});
let chunk = e.parse_delta(&delta).expect("should extract");
assert_eq!(chunk.text, "weighing options");
assert!(chunk.signature.is_none());
}
#[test]
fn delta_field_extraction_returns_none_when_absent() {
let e = ReasoningExtraction::DeltaContentField("reasoning_content");
let delta = json!({"content": "regular text"});
assert!(e.parse_delta(&delta).is_none());
}
#[test]
fn delta_field_extraction_returns_none_for_empty_string() {
let e = ReasoningExtraction::DeltaContentField("reasoning");
let delta = json!({"reasoning": ""});
assert!(e.parse_delta(&delta).is_none());
}
#[test]
fn none_extraction_always_returns_none() {
let e = ReasoningExtraction::None;
assert!(e.parse_delta(&json!({"reasoning_content": "x"})).is_none());
}
#[test]
fn inline_think_tags_does_not_parse_via_json() {
let e = ReasoningExtraction::InlineThinkTags;
assert!(
e.parse_delta(&json!({"content": "<think>x</think>"}))
.is_none()
);
}
#[test]
fn compat_style_maps_to_strategy() {
assert_eq!(
CompatStyle::Openai.reasoning_strategy(),
ReasoningStrategy::None
);
assert_eq!(
CompatStyle::OpenaiEffort.reasoning_strategy(),
ReasoningStrategy::Effort
);
assert_eq!(
CompatStyle::Openrouter.reasoning_strategy(),
ReasoningStrategy::OpenRouterShape
);
}
}