#[cfg(all(test, not(feature = "bedrock")))]
use crate::classify::tiers::llm::SYSTEM_PROMPT;
use crate::classify::tiers::llm_prompt::LlmUsage;
use trusty_common::inference::{ChatMessage, ChatRequest};
pub struct BedrockClassifier {
#[allow(dead_code)] pub(crate) model: String,
#[cfg(feature = "bedrock")]
inner: trusty_common::inference::BedrockAdapter,
}
pub const BEDROCK_NOT_BUILT: &str = "bedrock feature not compiled in — this tga was built with \
--no-default-features; rebuild with the default features (or --features bedrock)";
pub const DEFAULT_BEDROCK_MODEL: &str = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
#[cfg_attr(not(feature = "bedrock"), allow(dead_code))]
pub(crate) fn converse_request(model: &str, system: &str, message: &str) -> ChatRequest {
let mut req = ChatRequest::new(
model.to_string(),
vec![
ChatMessage::system(system),
ChatMessage::user(format!("Classify this commit message:\n\n{message}")),
],
);
req.max_tokens = Some(256);
req
}
impl BedrockClassifier {
#[cfg(feature = "bedrock")]
pub async fn new(model: &str) -> Result<Self, String> {
Ok(Self {
model: model.to_string(),
inner: trusty_common::inference::BedrockAdapter::new(None),
})
}
#[cfg(feature = "bedrock")]
pub async fn with_region(model: &str, region: Option<&str>) -> Result<Self, String> {
Ok(Self {
model: model.to_string(),
inner: trusty_common::inference::BedrockAdapter::new(region),
})
}
#[cfg(not(feature = "bedrock"))]
pub async fn new(_model: &str) -> Result<Self, String> {
Err(BEDROCK_NOT_BUILT.to_string())
}
#[cfg(not(feature = "bedrock"))]
pub async fn with_region(_model: &str, _region: Option<&str>) -> Result<Self, String> {
Err(BEDROCK_NOT_BUILT.to_string())
}
#[cfg(feature = "bedrock")]
pub async fn complete(
&self,
system: &str,
message: &str,
) -> (Option<String>, Option<LlmUsage>) {
use tracing::warn;
use trusty_common::inference::InferenceAdapter;
let req = converse_request(&self.model, system, message);
match self.inner.chat(&req).await {
Ok(resp) => {
let usage = LlmUsage {
input_tokens: u64::from(resp.usage.prompt_tokens),
output_tokens: u64::from(resp.usage.completion_tokens),
};
(resp.first_text(), Some(usage))
}
Err(e) => {
warn!(error = %e, "bedrock converse call failed");
(None, None)
}
}
}
#[cfg(not(feature = "bedrock"))]
pub async fn complete(
&self,
_system: &str,
_message: &str,
) -> (Option<String>, Option<LlmUsage>) {
(None, None)
}
}
#[cfg(all(test, not(feature = "bedrock")))]
mod tests {
use super::*;
#[tokio::test]
async fn bedrock_stub_returns_error_without_feature() {
let result = BedrockClassifier::new(DEFAULT_BEDROCK_MODEL).await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("must error without feature"),
};
assert!(err.contains("bedrock feature not compiled in"));
}
#[test]
fn shared_system_prompt_contains_complexity_instruction() {
assert!(
SYSTEM_PROMPT.contains("complexity"),
"shared SYSTEM_PROMPT must instruct the model to return a complexity score"
);
}
}
#[cfg(test)]
mod request_tests {
use super::*;
const SONNET_5: &str = "us.anthropic.claude-sonnet-5";
fn request_json(model: &str) -> serde_json::Value {
let req = converse_request(model, "sys", "fix: null check");
serde_json::to_value(&req).expect("serialize ChatRequest")
}
#[test]
fn sonnet5_request_has_no_temperature() {
let json = request_json(SONNET_5);
assert!(json.get("temperature").is_none(), "{json}");
assert_eq!(json["model"], SONNET_5);
assert_eq!(json["max_tokens"], 256);
}
#[test]
fn default_model_request_has_no_temperature() {
let json = request_json(DEFAULT_BEDROCK_MODEL);
assert!(json.get("temperature").is_none(), "{json}");
assert_eq!(json["model"], DEFAULT_BEDROCK_MODEL);
}
#[test]
fn default_model_is_a_us_inference_profile() {
assert!(
DEFAULT_BEDROCK_MODEL.starts_with("us.anthropic."),
"{DEFAULT_BEDROCK_MODEL}"
);
assert!(
DEFAULT_BEDROCK_MODEL.contains("claude-haiku-4-5"),
"{DEFAULT_BEDROCK_MODEL}"
);
}
}