use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::Duration;
use misanthropic::model::{ModelInfo, Models};
use misanthropic::{batch, response};
use serde::{Deserialize, Serialize};
use super::RetryAfter;
use super::backend::Inference;
use super::inference::Quirks;
const DEFAULT_MAX_BATCH: usize = 1000;
const DEFAULT_POLL_PERIOD: Duration = Duration::from_secs(5);
const DUMMY_KEY: &str = "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EndpointVariant {
#[default]
Anthropic,
Ollama,
Blallama,
}
impl From<EndpointVariant> for Quirks {
fn from(variant: EndpointVariant) -> Self {
let mut quirks = Quirks::default();
match variant {
EndpointVariant::Anthropic => {}
EndpointVariant::Ollama => {
quirks.cache_markers_ignored = true;
quirks.tool_choice_not_respected = true;
quirks.cache_stats_unreported = true;
}
EndpointVariant::Blallama => {
quirks.breakpoint_after_assistant = true;
quirks.output_config_cache_safe = true;
}
}
quirks
}
}
#[derive(Deserialize)]
struct Tags {
#[serde(default)]
models: Vec<Tag>,
}
#[derive(Deserialize)]
struct Tag {
name: String,
#[serde(default)]
modified_at: Option<chrono::DateTime<chrono::Utc>>,
}
fn models_from_tags(body: &str) -> Result<Models, misanthropic::client::Error> {
let tags: Tags = serde_json::from_str(body)?;
Ok(tags
.models
.into_iter()
.map(|tag| ModelInfo {
id: tag.name.clone().into(),
display_name: tag.name.into(),
capabilities: Default::default(),
max_input_tokens: 0,
max_tokens: 0,
kind: Default::default(),
created_at: tag.modified_at.unwrap_or_default(),
})
.collect())
}
const COURTESY_BACKOFF: Duration = Duration::from_secs(10);
impl RetryAfter for misanthropic::client::Error {
fn retry_after(&self) -> Option<Duration> {
match self {
misanthropic::client::Error::Anthropic(e) => {
e.retry_after().or(match e {
misanthropic::client::AnthropicError::Overloaded {
..
} => Some(COURTESY_BACKOFF),
_ => None,
})
}
_ => None,
}
}
}
pub struct Client {
client: misanthropic::Client,
variant: EndpointVariant,
concurrency: NonZeroUsize,
max_batch: usize,
poll_period: Duration,
}
impl Client {
pub fn new(client: misanthropic::Client) -> Self {
Self {
client,
variant: EndpointVariant::default(),
concurrency: 1.try_into().unwrap(),
max_batch: DEFAULT_MAX_BATCH,
poll_period: DEFAULT_POLL_PERIOD,
}
}
pub fn with_variant(mut self, variant: EndpointVariant) -> Self {
self.variant = variant;
if !matches!(variant, EndpointVariant::Anthropic) {
self.client.key = Arc::new(
DUMMY_KEY
.to_string()
.try_into()
.expect("DUMMY_KEY has a valid key length"),
);
}
self
}
pub fn with_concurrency(mut self, n: NonZeroUsize) -> Self {
self.set_concurrency(n);
self
}
pub fn set_concurrency(&mut self, n: NonZeroUsize) {
self.concurrency = n;
}
pub fn with_batch(
mut self,
max_batch: usize,
poll_period: Duration,
) -> Self {
self.max_batch = max_batch;
self.poll_period = poll_period;
self
}
}
#[async_trait::async_trait]
impl Inference for Client {
type Error = misanthropic::client::Error;
async fn infer<P>(
&self,
prompt: P,
) -> Result<response::Message, Self::Error>
where
P: Serialize + Send,
{
self.client.message(prompt).await
}
async fn infer_batch<P>(
&self,
prompts: &[&P],
) -> Result<Vec<Result<response::Message, Self::Error>>, Self::Error>
where
P: Serialize + Send + Sync,
{
let chunk = self.max_batch.max(1);
let mut out: Vec<Option<Result<response::Message, Self::Error>>> =
(0..prompts.len()).map(|_| None).collect();
for start in (0..prompts.len()).step_by(chunk) {
let end = (start + chunk).min(prompts.len());
let mut id_to_idx: HashMap<batch::Id, usize> = HashMap::new();
let items: Vec<(batch::Id, &P)> = (start..end)
.map(|i| {
let id = batch::Id::default();
id_to_idx.insert(id, i);
(id, prompts[i])
})
.collect();
let mut pending = self.client.tagged_batch(items).await?;
let ready = loop {
match self.client.batch_poll(pending).await? {
batch::Batch::Ready(ready) => break ready,
batch::Batch::Pending(p) => {
pending = p;
tokio::time::sleep(self.poll_period).await;
}
}
};
let (_, results) = ready.decompose();
for (id, result) in results {
if let Some(&idx) = id_to_idx.get(&id) {
let r: Result<
response::Message,
misanthropic::client::AnthropicError,
> = result.into();
out[idx] = Some(r.map_err(Into::into));
}
}
}
Ok(out
.into_iter()
.map(|slot| {
slot.unwrap_or(Err(
misanthropic::client::Error::UnexpectedResponse {
message: "batch returned no result for prompt",
},
))
})
.collect())
}
async fn models(&self) -> Result<misanthropic::model::Models, Self::Error> {
match self.variant {
EndpointVariant::Anthropic => self.client.models().await,
EndpointVariant::Ollama | EndpointVariant::Blallama => {
let url = self.client.messages_url.join("/api/tags").map_err(
|_| misanthropic::client::Error::UnexpectedResponse {
message: "cannot derive /api/tags from messages_url",
},
)?;
let body = self
.client
.inner
.get(url)
.send()
.await?
.error_for_status()?
.text()
.await?;
models_from_tags(&body)
}
}
}
fn quirks(&self) -> Quirks {
self.variant.into()
}
fn max_concurrency(&self) -> NonZeroUsize {
self.concurrency
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variant_quirks_mapping() {
assert_eq!(Quirks::from(EndpointVariant::Anthropic), Quirks::default());
let ollama = Quirks::from(EndpointVariant::Ollama);
assert!(ollama.cache_markers_ignored);
assert!(ollama.tool_choice_not_respected);
assert!(ollama.cache_stats_unreported);
assert!(!ollama.breakpoint_after_assistant);
assert!(!ollama.output_config_cache_safe);
let blallama = Quirks::from(EndpointVariant::Blallama);
assert!(blallama.breakpoint_after_assistant);
assert!(blallama.output_config_cache_safe);
assert!(!blallama.cache_markers_ignored);
assert!(!blallama.tool_choice_not_respected);
assert!(!blallama.cache_stats_unreported);
}
#[test]
fn overloaded_without_header_gets_courtesy_backoff() {
use misanthropic::client::{AnthropicError, Error};
let e = Error::Anthropic(AnthropicError::Overloaded {
message: "Session is busy.".into(),
retry_after: None,
});
assert_eq!(e.retry_after(), Some(COURTESY_BACKOFF));
let e = Error::Anthropic(AnthropicError::Overloaded {
message: "overloaded".into(),
retry_after: Some(3),
});
assert_eq!(e.retry_after(), Some(Duration::from_secs(3)));
let e = Error::Anthropic(AnthropicError::RateLimit {
message: "slow down".into(),
retry_after: None,
});
assert_eq!(e.retry_after(), None, "header-less 429 stays fatal");
let e = Error::Anthropic(AnthropicError::API {
message: "boom".into(),
});
assert_eq!(e.retry_after(), None);
}
#[test]
fn models_from_tags_synthesizes() {
let body = r#"{
"models": [
{"name": "llama3.3:70b", "modified_at": "2026-01-01T00:00:00Z"},
{"name": "qwen3:32b"}
]
}"#;
let models = models_from_tags(body).unwrap();
let infos: Vec<&ModelInfo> = models.iter().collect();
assert_eq!(infos.len(), 2);
assert_eq!(infos[0].id.name(), "llama3.3:70b");
assert!(!infos[0].capabilities.batch.supported, "batch never");
assert_eq!(infos[0].max_tokens, 0, "ceiling unreported");
assert_eq!(infos[1].id.name(), "qwen3:32b");
}
}