pub mod bedrock;
pub mod error;
pub mod models;
pub mod openrouter;
pub mod schema;
pub use bedrock::BedrockProvider;
pub use error::LlmError;
pub use openrouter::OpenRouterProvider;
pub use schema::enforce_strict_mode;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::config::Provider;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseSchema {
pub name: String,
pub schema: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmRequest {
pub model: String,
pub system: String,
pub messages: Vec<ChatMessage>,
pub temperature: f32,
pub max_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_schema: Option<ResponseSchema>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmResponse {
pub text: String,
pub model: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub latency_ms: u64,
pub cost_usd: f64,
#[serde(default)]
pub finish_reason: Option<String>,
}
#[async_trait]
pub trait LlmProvider: Send + Sync {
fn name(&self) -> &str;
async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError>;
}
pub const BEDROCK_MODEL_PREFIX: &str = "bedrock/";
pub const OPENROUTER_MODEL_PREFIX: &str = "openrouter/";
pub fn strip_provider_prefix(model: &str) -> &str {
if let Some(bare) = model.strip_prefix(BEDROCK_MODEL_PREFIX) {
return bare;
}
if let Some(bare) = model.strip_prefix(OPENROUTER_MODEL_PREFIX) {
return bare;
}
model
}
pub fn resolve_provider_and_model(model: &str, default_provider: &Provider) -> (Provider, String) {
if let Some(bare) = model.strip_prefix(BEDROCK_MODEL_PREFIX) {
return (Provider::Bedrock, bare.to_string());
}
if let Some(bare) = model.strip_prefix(OPENROUTER_MODEL_PREFIX) {
return (Provider::OpenRouter, bare.to_string());
}
(default_provider.clone(), model.to_string())
}
pub async fn build_provider(
model: &str,
default_provider: &Provider,
openrouter_api_key: &str,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
let (provider, bare_model) = resolve_provider_and_model(model, default_provider);
match provider {
Provider::Bedrock => {
let p = BedrockProvider::new(bare_model, None).await?;
Ok(Arc::new(p))
}
Provider::OpenRouter => {
let p = OpenRouterProvider::new(openrouter_api_key, bare_model)?;
Ok(Arc::new(p))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn llm_response_serde_roundtrip() {
let resp = LlmResponse {
text: "LGTM".to_string(),
model: "openai/gpt-5.4-mini-20260317".to_string(),
input_tokens: 512,
output_tokens: 64,
latency_ms: 1234,
cost_usd: 0.000123,
finish_reason: None,
};
let json = serde_json::to_string(&resp).expect("serialise");
let back: LlmResponse = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.text, "LGTM");
assert_eq!(back.model, "openai/gpt-5.4-mini-20260317");
assert_eq!(back.input_tokens, 512);
assert_eq!(back.output_tokens, 64);
assert_eq!(back.latency_ms, 1234);
assert!((back.cost_usd - 0.000123_f64).abs() < 1e-15);
}
#[test]
fn llm_request_serde_roundtrip() {
let req = LlmRequest {
model: "openai/gpt-5.4-20260305".to_string(),
system: "You are a reviewer.".to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: "Review this.".to_string(),
}],
temperature: 0.3,
max_tokens: 2048,
response_schema: None,
};
let json = serde_json::to_string(&req).expect("serialise");
let back: LlmRequest = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.model, "openai/gpt-5.4-20260305");
assert_eq!(back.messages.len(), 1);
assert!((back.temperature - 0.3_f32).abs() < f32::EPSILON);
assert!(back.response_schema.is_none(), "no schema in roundtrip");
}
#[test]
fn structured_output_spec_roundtrip() {
let schema = ResponseSchema {
name: "review_output".to_string(),
schema: serde_json::json!({
"type": "object",
"properties": {
"verdict": { "type": "string" }
},
"required": ["verdict"]
}),
};
let json = serde_json::to_string(&schema).expect("serialise");
let back: ResponseSchema = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.name, "review_output");
assert_eq!(back.schema["properties"]["verdict"]["type"], "string");
}
#[test]
fn llm_request_with_schema_roundtrip() {
let req = LlmRequest {
model: "us.anthropic.claude-sonnet-4-6".to_string(),
system: "reviewer".to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: "review".to_string(),
}],
temperature: 0.3,
max_tokens: 1024,
response_schema: Some(ResponseSchema {
name: "review_output".to_string(),
schema: serde_json::json!({"type": "object"}),
}),
};
let json = serde_json::to_string(&req).expect("serialise");
let back: LlmRequest = serde_json::from_str(&json).expect("deserialise");
let schema = back.response_schema.expect("schema must survive roundtrip");
assert_eq!(schema.name, "review_output");
}
#[test]
fn provider_trait_object_compiles() {
fn _accepts_dyn(_p: &dyn LlmProvider) {}
}
#[test]
fn provider_factory_prefix_routing() {
let (prov, model) = resolve_provider_and_model(
"bedrock/us.anthropic.claude-sonnet-4-6",
&Provider::OpenRouter,
);
assert_eq!(prov, Provider::Bedrock);
assert_eq!(model, "us.anthropic.claude-sonnet-4-6");
let (prov, model) = resolve_provider_and_model(
"openrouter/openai/gpt-5.4-mini-20260317",
&Provider::Bedrock,
);
assert_eq!(prov, Provider::OpenRouter);
assert_eq!(model, "openai/gpt-5.4-mini-20260317");
let (prov, model) =
resolve_provider_and_model("us.anthropic.claude-sonnet-4-6", &Provider::Bedrock);
assert_eq!(prov, Provider::Bedrock);
assert_eq!(model, "us.anthropic.claude-sonnet-4-6");
let (prov, model) =
resolve_provider_and_model("openai/gpt-5.4-mini-20260317", &Provider::Bedrock);
assert_eq!(prov, Provider::Bedrock);
assert_eq!(model, "openai/gpt-5.4-mini-20260317");
}
#[test]
fn provider_factory_empty_bedrock_prefix_uses_bare_empty_string() {
let (prov, model) = resolve_provider_and_model("bedrock/", &Provider::OpenRouter);
assert_eq!(prov, Provider::Bedrock);
assert_eq!(model, "");
}
#[test]
fn provider_factory_mixed_providers_in_compare_set() {
let candidates = [
"bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
"bedrock/us.anthropic.claude-sonnet-4-6",
"openrouter/openai/gpt-5.4-mini-20260317",
];
let expected = [
(
Provider::Bedrock,
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
),
(Provider::Bedrock, "us.anthropic.claude-sonnet-4-6"),
(Provider::OpenRouter, "openai/gpt-5.4-mini-20260317"),
];
for (candidate, (exp_prov, exp_model)) in candidates.iter().zip(expected.iter()) {
let (prov, model) = resolve_provider_and_model(candidate, &Provider::Bedrock);
assert_eq!(prov, *exp_prov, "provider mismatch for {candidate}");
assert_eq!(model, *exp_model, "model mismatch for {candidate}");
}
}
#[test]
fn prefix_stripped_model_id_bedrock() {
assert_eq!(
strip_provider_prefix("bedrock/us.anthropic.claude-sonnet-4-6"),
"us.anthropic.claude-sonnet-4-6",
"bedrock/ prefix must be stripped"
);
assert_eq!(
strip_provider_prefix("bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"),
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"bedrock/ prefix must be stripped from date-versioned haiku id"
);
assert_eq!(
strip_provider_prefix("bedrock/us.anthropic.claude-opus-4-8"),
"us.anthropic.claude-opus-4-8",
"bedrock/ prefix must be stripped from opus id"
);
}
#[test]
fn prefix_stripped_model_id_openrouter() {
assert_eq!(
strip_provider_prefix("openrouter/openai/gpt-5.4-mini-20260317"),
"openai/gpt-5.4-mini-20260317",
"openrouter/ prefix must be stripped"
);
}
#[test]
fn prefix_stripped_model_id_bare() {
assert_eq!(
strip_provider_prefix("us.anthropic.claude-sonnet-4-6"),
"us.anthropic.claude-sonnet-4-6",
"bare id must be returned unchanged"
);
assert_eq!(
strip_provider_prefix("openai/gpt-5.4-mini-20260317"),
"openai/gpt-5.4-mini-20260317",
"bare OpenRouter id must not be stripped"
);
}
}