mermaid-cli 0.28.0

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
//! Anthropic provider — wraps `models::adapters::anthropic::AnthropicAdapter`.
//!
//! Same pattern as `ollama.rs`: the adapter handles the wire format
//! (`cache_control` blocks, extended-thinking signature round-trip);
//! this wrapper plumbs `ChatRequest` / `StreamContext` into it.
//!
//! Anthropic is the one provider that emits a `provider_continuation`
//! that MUST round-trip on the next request. The adapter's
//! `ModelResponse.provider_continuation` already carries it; we forward
//! that onto the `FinalResponse` so the reducer can commit it via
//! `ChatMessage::with_provider_continuation`.

use async_trait::async_trait;

use mermaid_domain::ChatRequest;
use mermaid_model::models::adapters::anthropic::AnthropicAdapter;
use mermaid_model::models::{Model, ModelConfig, ModelError, Result};

use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
use super::{ContextSizing, ModelProvider, RejectionCache, resolve_limits_cached};
use mermaid_model::models::ModelCapabilities;

/// Anthropic's Messages-API root, and the env var its key lives in.
///
/// One definition, next to the provider, like `meta`'s. `ProviderFactory`
/// builds the endpoint from these and `providers::discovery` reports it from
/// the same constants — the literal used to be spelled out separately in both.
pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
pub const DEFAULT_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";

/// Anthropic adapter fronted by `ModelProvider`.
pub struct AnthropicProvider {
    adapter: AnthropicAdapter,
    capabilities: ModelCapabilities,
    /// Links the adapter's learned rejections to the cross-session cache.
    rejections: RejectionCache,
}

impl AnthropicProvider {
    /// Wrap a fresh [`AnthropicAdapter`] as a `ModelProvider`.
    ///
    /// # Errors
    ///
    /// Only [`AnthropicAdapter::new`]'s — the HTTP client build. The API is
    /// not contacted here, so an invalid key or unreachable `base_url` still
    /// constructs and fails on the first request.
    pub fn new(api_key: String, model_name: String, base_url: String) -> Result<Self> {
        let adapter = AnthropicAdapter::new(api_key, model_name, base_url)?;
        let capabilities = adapter.capabilities().clone().with_provider_continuation();
        Ok(Self {
            adapter,
            capabilities,
            rejections: RejectionCache::default(),
        })
    }
}

#[async_trait]
impl ModelProvider for AnthropicProvider {
    fn capabilities(&self) -> &ModelCapabilities {
        &self.capabilities
    }

    /// Live limit discovery via Anthropic's Models API (`GET /v1/models/
    /// {id}` → `max_input_tokens` window + `max_tokens` output ceiling).
    /// Cache-first via `provider_probes` (TTL-bounded), one live fetch on a
    /// miss; a fetch failure resolves all-`None` (adapter floors apply).
    /// Also whether the model takes server-side compaction, which it does
    /// unless it refused it here or in an earlier session.
    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
        let _ = request;
        let model = Model::name(&self.adapter).to_string();
        let limits =
            resolve_limits_cached("anthropic", &model, || self.adapter.fetch_model_limits()).await;
        let window = limits.as_ref().and_then(|l| l.max_context_tokens);
        self.rejections
            .seed("anthropic", &model, self.adapter.param_memory())
            .await;
        ContextSizing {
            model_max: window,
            effective: window,
            source: None,
            max_output: limits.as_ref().and_then(|l| l.max_output_tokens),
            compacts_natively: self.adapter.compacts_natively(),
        }
    }

    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
        let config = ModelConfig::from(&request);
        let chat_fut = self
            .adapter
            .chat(&request.messages, &config, Some(ctx.sink.clone()));

        // Seed what earlier sessions learned this model rejects, and persist
        // whatever this turn learns (see `learning`).
        let learned_model = Model::name(&self.adapter).to_string();
        let memory = self.adapter.param_memory();
        self.rejections
            .seed("anthropic", &learned_model, memory)
            .await;

        let response = tokio::select! {
            biased;
            _ = ctx.token.cancelled() => {
                return Err(ModelError::Cancelled);
            },
            r = chat_fut => {
                self.rejections.persist("anthropic", &learned_model, memory).await;
                r?
            },
        };

        let usage = response.usage.clone();
        let provider_continuation = response.provider_continuation.clone();
        let stop_reason = response.stop_reason.clone();
        // F3: the wrapper's `Done` is the sole terminal event, and it goes on
        // the same sink the adapter just finished writing to — so it cannot
        // overtake a `ToolCall` still in flight. Carrying
        // `provider_continuation` out of `ModelResponse` here is what lets
        // multi-turn extended thinking round-trip.
        let _ = ctx
            .sink
            .send(StreamEvent::Done {
                usage: usage.clone(),
                provider_continuation: provider_continuation.clone(),
                stop_reason: stop_reason.clone(),
            })
            .await;

        Ok(FinalResponse {
            usage,
            provider_continuation,
            tool_calls: response.tool_calls.unwrap_or_default(),
            stop_reason,
        })
    }
}