use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use crate::classify::rules::CategoryDef;
use crate::classify::tiers::bedrock::BedrockClassifier;
use crate::classify::tiers::llm_prompt::{self, LlmCall, LlmUsage};
use crate::classify::tiers::ClassificationResult;
use crate::core::config::{LlmConfig, LlmEffort, LlmSource};
use crate::core::creds::CredentialSource;
const DEFAULT_ENDPOINT: &str = "https://api.openai.com/v1/chat/completions";
const OPENROUTER_ENDPOINT: &str = "https://openrouter.ai/api/v1/chat/completions";
const OPENROUTER_REFERER: &str = "https://github.com/bobmatnyc/trusty-git-analytics";
const OPENROUTER_TITLE: &str = "trusty-git-analytics";
pub(crate) const ANTHROPIC_ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
pub(crate) const ANTHROPIC_API_VERSION: &str = "2023-06-01";
pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-haiku-4-5";
pub const SYSTEM_PROMPT: &str = "You are a git commit classifier. Respond with ONLY a JSON \
object: {\"category\": \"feature|bugfix|chore|documentation|refactor|test|ci|performance|style|build|revert|merge|breaking|uncategorized\", \
\"subcategory\": \"optional string or null\", \"confidence\": 0.0-1.0, \
\"complexity\": <integer 1-5>}. \
Complexity 1-5: \
1=trivial (config/version bump/typo), 2=simple (single-file bugfix), \
3=moderate (multi-file feature), 4=complex (cross-module/arch change), \
5=highly complex (system design/major refactor). \
No prose, no markdown. \
Example: {\"category\": \"bugfix\", \"subcategory\": \"null-check\", \
\"confidence\": 0.9, \"complexity\": 2}";
pub struct LlmClassifier {
client: Client,
pub(crate) model: String,
pub(crate) api_key: Option<String>,
pub(crate) endpoint: String,
pub(crate) extra_headers: HeaderMap,
bedrock: Option<BedrockClassifier>,
pub(crate) use_anthropic_format: bool,
allowed_categories: Option<Vec<CategoryDef>>,
system_prompt: String,
effort: Option<LlmEffort>,
}
impl LlmClassifier {
pub fn new(model: &str, api_key: Option<String>) -> Self {
Self {
client: Client::new(),
model: model.to_string(),
api_key,
endpoint: DEFAULT_ENDPOINT.to_string(),
extra_headers: HeaderMap::new(),
bedrock: None,
use_anthropic_format: false,
allowed_categories: None,
system_prompt: SYSTEM_PROMPT.to_string(),
effort: None,
}
}
pub fn build_anthropic(model: &str, api_key: Option<String>) -> Self {
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(ANTHROPIC_API_VERSION) {
headers.insert("anthropic-version", v);
}
Self {
client: Client::new(),
model: model.to_string(),
api_key,
endpoint: ANTHROPIC_ENDPOINT.to_string(),
extra_headers: headers,
bedrock: None,
use_anthropic_format: true,
allowed_categories: None,
system_prompt: SYSTEM_PROMPT.to_string(),
effort: None,
}
}
pub fn from_provider(
provider: &str,
model: &str,
openrouter_api_key: Option<String>,
) -> Result<Self, String> {
Self::from_provider_with_creds(
provider,
model,
openrouter_api_key,
&CredentialSource::from_env(),
)
}
pub(crate) fn from_provider_with_creds(
provider: &str,
model: &str,
openrouter_api_key: Option<String>,
creds: &CredentialSource,
) -> Result<Self, String> {
let normalized = provider.trim().to_ascii_lowercase();
match normalized.as_str() {
"openrouter" => Ok(Self::build_openrouter(model, openrouter_api_key, creds)),
"openai" => Ok(Self::new(model, creds.get("OPENAI_API_KEY"))),
"bedrock" => {
info!(model, "LLM provider: bedrock (requested via sync path)");
#[cfg(feature = "bedrock")]
{
Err("bedrock provider requires the async constructor; use \
LlmClassifier::from_provider_async"
.to_string())
}
#[cfg(not(feature = "bedrock"))]
{
let _ = model;
Err(
"bedrock feature not compiled in — rebuild with --features bedrock"
.to_string(),
)
}
}
"auto" | "" => {
let or_key = openrouter_api_key
.or_else(|| creds.get(trusty_common::env_vars::ENV_OPENROUTER_API_KEY));
if or_key.is_some() {
info!("LLM provider auto-selected: openrouter");
Ok(Self::build_openrouter(model, or_key, creds))
} else {
info!("LLM provider auto-selected: openai");
Ok(Self::new(model, creds.get("OPENAI_API_KEY")))
}
}
other => {
warn!(
provider = %other,
"unknown LLM provider; falling back to OpenAI endpoint"
);
Ok(Self::new(model, creds.get("OPENAI_API_KEY")))
}
}
}
pub async fn from_provider_async(
provider: &str,
model: &str,
openrouter_api_key: Option<String>,
) -> Result<Self, String> {
if provider.trim().eq_ignore_ascii_case("bedrock") {
info!(model, "LLM provider: bedrock (async init)");
let bedrock = BedrockClassifier::new(model).await?;
return Ok(Self {
client: Client::new(),
model: model.to_string(),
api_key: None,
endpoint: String::new(),
extra_headers: HeaderMap::new(),
bedrock: Some(bedrock),
use_anthropic_format: false,
allowed_categories: None,
system_prompt: SYSTEM_PROMPT.to_string(),
effort: None,
});
}
Self::from_provider(provider, model, openrouter_api_key)
}
pub async fn from_llm_config(cfg: &LlmConfig, model: &str) -> Result<Self, String> {
Self::from_llm_config_with_creds(cfg, model, &CredentialSource::from_env()).await
}
pub(crate) async fn from_llm_config_with_creds(
cfg: &LlmConfig,
model: &str,
creds: &CredentialSource,
) -> Result<Self, String> {
match &cfg.source {
LlmSource::Openrouter => {
let key = creds.get(&cfg.api_key_env);
if key.is_none() {
return Err(format!(
"LLM source 'openrouter' requires an API key but the environment \
variable '{}' (set via llm.api_key_env) is not set or empty. \
Export the variable with your OpenRouter API key before running tga.",
cfg.api_key_env
));
}
info!(
model,
api_key_env = %cfg.api_key_env,
"LLM provider: openrouter (from llm: config section)"
);
Ok(Self::build_openrouter(model, key, creds))
}
LlmSource::Bedrock => {
info!(
model,
region = ?cfg.region,
"LLM provider: bedrock (from llm: config section)"
);
let bedrock = BedrockClassifier::with_region(model, cfg.region.as_deref()).await?;
Ok(Self {
client: Client::new(),
model: model.to_string(),
api_key: None,
endpoint: String::new(),
extra_headers: HeaderMap::new(),
bedrock: Some(bedrock),
use_anthropic_format: false,
allowed_categories: None,
system_prompt: SYSTEM_PROMPT.to_string(),
effort: None,
})
}
LlmSource::AnthropicApi => {
let key = creds.get(&cfg.api_key_env);
if key.is_none() {
return Err(format!(
"LLM source 'anthropic-api' requires an API key but the environment \
variable '{}' (set via llm.api_key_env) is not set or empty. \
Export the variable with your Anthropic API key before running tga. \
Example: export {}=sk-ant-...", cfg.api_key_env, cfg.api_key_env
));
}
let effective_model = if model == "gpt-4o-mini" {
ANTHROPIC_DEFAULT_MODEL
} else {
model
};
info!(
model = effective_model,
api_key_env = %cfg.api_key_env,
"LLM provider: anthropic-api (direct Anthropic Messages API)"
);
Ok(Self::build_anthropic(effective_model, key).with_effort(cfg.effort))
}
}
}
fn build_openrouter(model: &str, api_key: Option<String>, creds: &CredentialSource) -> Self {
let key = api_key.or_else(|| creds.get(trusty_common::env_vars::ENV_OPENROUTER_API_KEY));
let mut headers = HeaderMap::new();
headers.insert("HTTP-Referer", HeaderValue::from_static(OPENROUTER_REFERER));
headers.insert("X-Title", HeaderValue::from_static(OPENROUTER_TITLE));
Self {
client: Client::new(),
model: model.to_string(),
api_key: key,
endpoint: OPENROUTER_ENDPOINT.to_string(),
extra_headers: headers,
bedrock: None,
use_anthropic_format: false,
allowed_categories: None,
system_prompt: SYSTEM_PROMPT.to_string(),
effort: None,
}
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
pub fn has_api_key(&self) -> bool {
self.bedrock.is_some() || self.api_key.is_some()
}
pub fn with_allowed_categories(mut self, categories: Vec<CategoryDef>) -> Self {
self.system_prompt = llm_prompt::restricted_system_prompt(&categories);
self.allowed_categories = Some(categories);
self
}
pub fn with_effort(mut self, effort: Option<LlmEffort>) -> Self {
self.effort = effort;
self
}
pub fn system_prompt(&self) -> &str {
&self.system_prompt
}
pub fn model(&self) -> &str {
&self.model
}
pub fn provider_label(&self) -> &'static str {
if self.bedrock.is_some() {
"bedrock"
} else if self.use_anthropic_format {
"anthropic-api"
} else if self.endpoint == OPENROUTER_ENDPOINT {
"openrouter"
} else {
"openai-compatible"
}
}
pub async fn classify(&self, message: &str) -> Option<ClassificationResult> {
self.classify_detailed(message).await.verdict
}
pub async fn classify_detailed(&self, message: &str) -> LlmCall {
let (text, usage) = if let Some(bedrock) = &self.bedrock {
bedrock.complete(&self.system_prompt, message).await
} else if self.use_anthropic_format {
self.complete_anthropic(message).await
} else {
self.complete_openai_compat(message).await
};
llm_prompt::resolve(text.as_deref(), self.allowed_categories.as_deref(), usage)
}
async fn complete_anthropic(&self, message: &str) -> (Option<String>, Option<LlmUsage>) {
let Some(api_key) = self.api_key.as_deref() else {
return (None, None);
};
let body = AnthropicRequest {
model: &self.model,
max_tokens: 2048,
system: &self.system_prompt,
messages: vec![AnthropicMessage {
role: "user",
content: format!("Classify this commit message:\n\n{message}"),
}],
output_config: self
.effort
.map(|e| AnthropicOutputConfig { effort: e.as_str() }),
};
let response = match self
.client
.post(&self.endpoint)
.header("x-api-key", api_key)
.headers(self.extra_headers.clone())
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!(error = %e, "Anthropic API request failed");
return (None, None);
}
};
if !response.status().is_success() {
let status = response.status();
let body = error_body(response).await;
warn!(%status, %body, "Anthropic API returned non-success status");
return (None, None);
}
let parsed: AnthropicResponse = match response.json().await {
Ok(j) => j,
Err(e) => {
warn!(error = %e, "Anthropic API response JSON decode failed");
return (None, None);
}
};
let usage = parsed.usage.map(|u| LlmUsage {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
});
if let Some(reason @ ("refusal" | "max_tokens")) = parsed.stop_reason.as_deref() {
warn!(
stop_reason = reason,
"Anthropic API reply has no complete verdict"
);
}
let text = parsed
.content
.into_iter()
.find(|c| c.kind == "text")
.and_then(|c| c.text);
debug!(content = ?text, "Anthropic API raw response");
(text, usage)
}
async fn complete_openai_compat(&self, message: &str) -> (Option<String>, Option<LlmUsage>) {
let Some(api_key) = self.api_key.as_deref() else {
return (None, None);
};
let body = ChatRequest {
model: &self.model,
messages: vec![
ChatMessage {
role: "system",
content: self.system_prompt.clone(),
},
ChatMessage {
role: "user",
content: format!("Classify this commit message:\n\n{message}"),
},
],
temperature: 0.0,
response_format: Some(ResponseFormat {
kind: "json_object".to_string(),
}),
};
let response = match self
.client
.post(&self.endpoint)
.bearer_auth(api_key)
.headers(self.extra_headers.clone())
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!(error = %e, "LLM request failed");
return (None, None);
}
};
if !response.status().is_success() {
let status = response.status();
let body = error_body(response).await;
warn!(%status, %body, "LLM returned non-success status");
return (None, None);
}
let parsed: ChatResponse = match response.json().await {
Ok(j) => j,
Err(e) => {
warn!(error = %e, "LLM response JSON decode failed");
return (None, None);
}
};
let usage = parsed.usage.map(|u| LlmUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
});
let text = parsed.choices.into_iter().next().map(|c| c.message.content);
debug!(content = ?text, "LLM raw response");
(text, usage)
}
}
pub(crate) async fn error_body(response: reqwest::Response) -> String {
let text = response.text().await.unwrap_or_default();
truncate_chars(&text, 500)
}
pub(crate) fn truncate_chars(text: &str, max: usize) -> String {
match text.char_indices().nth(max) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text.to_string(),
}
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<ChatMessage>,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<ResponseFormat>,
}
#[derive(Serialize)]
struct ChatMessage {
role: &'static str,
content: String,
}
#[derive(Serialize)]
struct ResponseFormat {
#[serde(rename = "type")]
kind: String,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
#[serde(default)]
usage: Option<ChatUsage>,
}
#[derive(Deserialize)]
struct ChatUsage {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
completion_tokens: u64,
}
#[derive(Deserialize)]
struct ChatChoice {
message: ChatChoiceMessage,
}
#[derive(Deserialize)]
struct ChatChoiceMessage {
content: String,
}
#[derive(Serialize)]
struct AnthropicRequest<'a> {
model: &'a str,
max_tokens: u32,
system: &'a str,
messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
output_config: Option<AnthropicOutputConfig>,
}
#[derive(Serialize)]
struct AnthropicOutputConfig {
effort: &'static str,
}
#[derive(Serialize)]
struct AnthropicMessage {
role: &'static str,
content: String,
}
#[derive(Deserialize)]
struct AnthropicResponse {
content: Vec<AnthropicContent>,
#[serde(default)]
stop_reason: Option<String>,
#[serde(default)]
usage: Option<AnthropicUsage>,
}
#[derive(Deserialize)]
struct AnthropicUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
}
#[derive(Deserialize)]
struct AnthropicContent {
#[serde(rename = "type")]
kind: String,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct LlmVerdict {
pub category: String,
#[serde(default)]
pub subcategory: Option<String>,
#[serde(default = "default_confidence")]
pub confidence: f64,
#[serde(default)]
pub complexity: Option<u8>,
}
pub fn default_confidence() -> f64 {
0.5
}