use std::sync::Arc;
use async_trait::async_trait;
use atomr_agents_core::Result;
use atomr_agents_observability::{ModelPinRef, RunEvent, RunEventKind, Telemetry};
use atomr_agents_tool::Provider;
use atomr_infer_core::batch::ExecuteBatch;
use thiserror::Error;
use crate::inference::{InferenceClient, TurnResult};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelPin {
pub provider: Provider,
pub model_id: String,
pub model_version: String,
pub params_hash: String,
}
impl ModelPin {
pub fn new(
provider: Provider,
model_id: impl Into<String>,
model_version: impl Into<String>,
params_hash: impl Into<String>,
) -> Self {
Self {
provider,
model_id: model_id.into(),
model_version: model_version.into(),
params_hash: params_hash.into(),
}
}
fn as_ref(&self) -> ModelPinRef {
ModelPinRef {
provider: provider_str(self.provider).into(),
model_id: self.model_id.clone(),
model_version: self.model_version.clone(),
params_hash: self.params_hash.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedModel {
pub provider: Provider,
pub model_id: String,
pub model_version: String,
pub params_hash: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DriftKind {
VersionDrift,
ParamsDrift,
}
impl DriftKind {
fn as_str(self) -> &'static str {
match self {
DriftKind::VersionDrift => "version_drift",
DriftKind::ParamsDrift => "params_drift",
}
}
}
pub trait ModelResolver: Send + Sync + 'static {
fn resolve_version(&self) -> ResolvedModel;
}
pub struct StaticResolver(pub ResolvedModel);
impl ModelResolver for StaticResolver {
fn resolve_version(&self) -> ResolvedModel {
self.0.clone()
}
}
#[derive(Debug, Error)]
pub enum ModelPinViolation {
#[error("strict_pin: a run requires a ModelPin but none was set")]
NoPinInStrictMode,
#[error("model pin mismatch ({kind:?}): expected {expected:?}, got {actual:?}")]
Mismatch {
kind: DriftKind,
expected: ModelPin,
actual: ResolvedModel,
},
}
impl From<ModelPinViolation> for atomr_agents_core::AgentError {
fn from(e: ModelPinViolation) -> Self {
atomr_agents_core::AgentError::PolicyDenied(e.to_string())
}
}
pub fn detect_drift(pin: &ModelPin, actual: &ResolvedModel) -> Option<DriftKind> {
if pin.provider != actual.provider
|| pin.model_id != actual.model_id
|| pin.model_version != actual.model_version
{
Some(DriftKind::VersionDrift)
} else if pin.params_hash != actual.params_hash {
Some(DriftKind::ParamsDrift)
} else {
None
}
}
pub struct PinnedClient {
inner: Arc<dyn InferenceClient>,
resolver: Arc<dyn ModelResolver>,
pin: Option<ModelPin>,
strict: bool,
refuse_on_drift: bool,
telemetry: Option<Telemetry>,
run_id: Option<String>,
}
impl PinnedClient {
pub fn new(inner: Arc<dyn InferenceClient>, resolver: Arc<dyn ModelResolver>) -> Self {
Self {
inner,
resolver,
pin: None,
strict: false,
refuse_on_drift: false,
telemetry: None,
run_id: None,
}
}
pub fn pin_model(mut self, pin: ModelPin) -> Self {
self.pin = Some(pin);
self
}
pub fn strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
pub fn refuse_on_drift(mut self, refuse: bool) -> Self {
self.refuse_on_drift = refuse;
self
}
pub fn with_telemetry(mut self, telemetry: Telemetry, run_id: impl Into<String>) -> Self {
self.telemetry = Some(telemetry);
self.run_id = Some(run_id.into());
self
}
pub fn pin(&self) -> Option<&ModelPin> {
self.pin.as_ref()
}
pub fn revalidate(&self) -> Option<DriftKind> {
let actual = self.resolver.resolve_version();
self.pin.as_ref().and_then(|p| detect_drift(p, &actual))
}
#[allow(clippy::result_large_err)]
fn enforce(&self) -> std::result::Result<(), ModelPinViolation> {
let Some(pin) = self.pin.as_ref() else {
if self.strict {
return Err(ModelPinViolation::NoPinInStrictMode);
}
return Ok(());
};
let actual = self.resolver.resolve_version();
if let Some(kind) = detect_drift(pin, &actual) {
if let (Some(t), Some(run)) = (&self.telemetry, &self.run_id) {
t.emit(
RunEvent::new(RunEventKind::ModelDrift {
expected: format!("{:?}", pin.as_ref()),
actual: format!(
"{:?}",
ModelPinRef {
provider: provider_str(actual.provider).into(),
model_id: actual.model_id.clone(),
model_version: actual.model_version.clone(),
params_hash: actual.params_hash.clone(),
}
),
drift: kind.as_str().into(),
})
.with_model_pin(pin.as_ref()),
);
let _ = run; }
if self.refuse_on_drift {
return Err(ModelPinViolation::Mismatch {
kind,
expected: pin.clone(),
actual,
});
}
}
Ok(())
}
}
#[async_trait]
impl InferenceClient for PinnedClient {
fn provider(&self) -> Provider {
self.inner.provider()
}
async fn run(&self, batch: ExecuteBatch) -> Result<TurnResult> {
self.enforce()?;
self.inner.run(batch).await
}
}
fn provider_str(p: Provider) -> &'static str {
match p {
Provider::OpenAi => "open_ai",
Provider::Anthropic => "anthropic",
}
}
#[cfg(test)]
mod tests {
use super::*;
use atomr_agents_observability::InMemoryTelemetrySink;
use atomr_infer_core::tokens::TokenUsage;
struct FixedClient(Provider);
#[async_trait]
impl InferenceClient for FixedClient {
fn provider(&self) -> Provider {
self.0
}
async fn run(&self, _batch: ExecuteBatch) -> Result<TurnResult> {
Ok(TurnResult {
text: "ok".into(),
usage: TokenUsage::default(),
finish_reason: None,
tool_calls: vec![],
})
}
}
fn batch() -> ExecuteBatch {
ExecuteBatch {
request_id: "r".into(),
model: "m".into(),
messages: vec![],
sampling: Default::default(),
stream: false,
estimated_tokens: 0,
}
}
fn resolved(version: &str, params: &str) -> ResolvedModel {
ResolvedModel {
provider: Provider::Anthropic,
model_id: "claude".into(),
model_version: version.into(),
params_hash: params.into(),
}
}
#[tokio::test]
async fn strict_mode_without_pin_refuses() {
let c = PinnedClient::new(
Arc::new(FixedClient(Provider::Anthropic)),
Arc::new(StaticResolver(resolved("1", "p"))),
)
.strict(true);
assert!(c.run(batch()).await.is_err());
}
#[tokio::test]
async fn matching_pin_passes_through() {
let c = PinnedClient::new(
Arc::new(FixedClient(Provider::Anthropic)),
Arc::new(StaticResolver(resolved("1", "p"))),
)
.pin_model(ModelPin::new(Provider::Anthropic, "claude", "1", "p"))
.strict(true);
let r = c.run(batch()).await.unwrap();
assert_eq!(r.text, "ok");
assert!(c.revalidate().is_none());
}
#[tokio::test]
async fn version_drift_emits_event_and_can_refuse() {
let sink = Arc::new(InMemoryTelemetrySink::new());
let tel = Telemetry::new().with_sink(sink.clone());
let c = PinnedClient::new(
Arc::new(FixedClient(Provider::Anthropic)),
Arc::new(StaticResolver(resolved("2", "p"))),
)
.pin_model(ModelPin::new(Provider::Anthropic, "claude", "1", "p"))
.with_telemetry(tel, "run-1")
.refuse_on_drift(true);
let err = c.run(batch()).await.unwrap_err();
assert!(err.to_string().contains("mismatch"));
let events = sink.events();
assert_eq!(events.len(), 1);
match &events[0].kind {
RunEventKind::ModelDrift { drift, .. } => assert_eq!(drift, "version_drift"),
other => panic!("unexpected {other:?}"),
}
assert!(events[0].model_pin.is_some());
}
#[tokio::test]
async fn params_drift_detected() {
assert_eq!(
detect_drift(
&ModelPin::new(Provider::Anthropic, "claude", "1", "p1"),
&resolved("1", "p2")
),
Some(DriftKind::ParamsDrift)
);
}
}