Skip to main content

mermaid_cli/providers/model/
openai_compat.rs

1//! OpenAI-compatible provider — wraps
2//! `models::adapters::openai_compat::OpenAICompatAdapter`.
3//!
4//! This provider covers the OpenAI long-tail: OpenRouter, Groq,
5//! Fireworks, Together, custom vLLM endpoints, plus the user-defined
6//! entries in `[providers.*]`. The adapter looks up a
7//! `ProviderProfile` (registry entry) and applies per-provider
8//! reasoning shapes (flat `reasoning_effort` vs nested `reasoning:
9//! {effort}`). This wrapper just forwards.
10
11use std::collections::HashMap;
12
13use async_trait::async_trait;
14
15use mermaid_domain::ChatRequest;
16use mermaid_model::models::adapters::ModelLimits;
17use mermaid_model::models::adapters::openai_compat::OpenAICompatAdapter;
18use mermaid_model::models::{Model, ModelConfig, ModelError, ProviderProfile, Result};
19
20use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
21use super::{
22    ContextSizing, ModelProvider, RejectionCache, learn_output_cap, output_cap_from_error,
23    resolve_limits_cached, retry_cap,
24};
25use mermaid_model::models::ModelCapabilities;
26
27pub struct OpenAICompatProvider {
28    adapter: OpenAICompatAdapter,
29    capabilities: ModelCapabilities,
30    /// Links the adapter's learned rejections to the cross-session cache.
31    rejections: RejectionCache,
32}
33
34impl OpenAICompatProvider {
35    /// Wrap a fresh [`OpenAICompatAdapter`] as a `ModelProvider`.
36    ///
37    /// # Errors
38    ///
39    /// Only [`OpenAICompatAdapter::new`]'s — the HTTP client build. The
40    /// endpoint is not contacted here, so a wrong `base_url` or missing key
41    /// still constructs and fails on the first request.
42    pub fn new(
43        profile: &'static ProviderProfile,
44        base_url: String,
45        api_key: Option<String>,
46        model_name: String,
47        extra_headers: HashMap<String, String>,
48    ) -> Result<Self> {
49        let adapter =
50            OpenAICompatAdapter::new(profile, base_url, api_key, model_name, extra_headers)?;
51        let capabilities = adapter.capabilities().clone();
52        Ok(Self {
53            adapter,
54            capabilities,
55            rejections: RejectionCache::default(),
56        })
57    }
58}
59
60#[async_trait]
61impl ModelProvider for OpenAICompatProvider {
62    fn capabilities(&self) -> &ModelCapabilities {
63        &self.capabilities
64    }
65
66    /// Live limit discovery: most OpenAI-compatible providers attach the
67    /// model's context window / output ceiling to their `/models` metadata
68    /// (OpenRouter et al); Cloudflare exposes it on its account-level
69    /// `models/search` endpoint instead (`list_models_for_limits` routes
70    /// there). Cache-first via `provider_probes` (TTL-bounded), one live
71    /// fetch on a miss, static fallback (all `None`) when the provider
72    /// exposes nothing or the fetch fails.
73    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
74        let _ = request;
75        let provider = self.adapter.provider_name().to_string();
76        let model = Model::name(&self.adapter).to_string();
77        let limits = resolve_limits_cached(&provider, &model, || async {
78            let listings = self.adapter.list_models_for_limits().await?;
79            let found = listings.into_iter().find(|m| m.id == model);
80            Ok(ModelLimits {
81                max_context_tokens: found.as_ref().and_then(|m| m.max_context_tokens),
82                max_output_tokens: found.as_ref().and_then(|m| m.max_output_tokens),
83            })
84        })
85        .await;
86        let window = limits.as_ref().and_then(|l| l.max_context_tokens);
87        ContextSizing {
88            model_max: window,
89            effective: window,
90            source: None,
91            max_output: limits.as_ref().and_then(|l| l.max_output_tokens),
92            compacts_natively: false,
93        }
94    }
95
96    async fn supports_vision(&self) -> Option<bool> {
97        // Report the model-driven capability (derived from the model id) so the
98        // no-vision-model warning fires for text-only models and stays quiet for
99        // genuine vision models — instead of the default `None` (never warns).
100        Some(self.capabilities.supports_vision)
101    }
102
103    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
104        let config = ModelConfig::from(&request);
105        let chat_fut = async {
106            match self
107                .adapter
108                .chat(&request.messages, &config, Some(ctx.sink.clone()))
109                .await
110            {
111                Ok(response) => Ok(response),
112                Err(err) => {
113                    // Learn-from-400 parity with the Ollama wrapper. AUTO
114                    // omits max_tokens here, so this fires mainly for
115                    // explicit user caps above the model's real ceiling:
116                    // learn the cap (persisted for later sizing), clamp,
117                    // retry ONCE. A 400 streamed no events, so the sink is
118                    // untouched and the retry starts from a clean stream.
119                    let Some(cap) = output_cap_from_error(&err) else {
120                        return Err(err);
121                    };
122                    let Some(clamped) = retry_cap(config.max_tokens, cap) else {
123                        return Err(err);
124                    };
125                    let provider = self.adapter.provider_name().to_string();
126                    let model = Model::name(&self.adapter).to_string();
127                    learn_output_cap(provider, model.clone(), cap).await;
128                    let _ = ctx.sink.send(StreamEvent::Status(format!(
129                        "{model} rejected the output budget; learned its {cap}-token cap and retrying"
130                    ))).await;
131                    let retry_config = ModelConfig {
132                        max_tokens: clamped,
133                        ..config.clone()
134                    };
135                    self.adapter
136                        .chat(&request.messages, &retry_config, Some(ctx.sink.clone()))
137                        .await
138                },
139            }
140        };
141
142        // Seed what earlier sessions learned this model rejects, and persist
143        // whatever this turn learns (see `learning`).
144        let learned_model = Model::name(&self.adapter).to_string();
145        let memory = self.adapter.param_memory();
146        self.rejections
147            .seed(self.adapter.provider_name(), &learned_model, memory)
148            .await;
149
150        let response = tokio::select! {
151            biased;
152            _ = ctx.token.cancelled() => {
153                return Err(ModelError::Cancelled);
154            },
155            r = chat_fut => {
156                self.rejections.persist(self.adapter.provider_name(), &learned_model, memory).await;
157                r?
158            },
159        };
160
161        let usage = response.usage.clone();
162        let stop_reason = response.stop_reason.clone();
163        // The terminal Done goes on the same sink the adapter just finished
164        // writing to, so it cannot overtake a still-queued ToolCall.
165        let _ = ctx
166            .sink
167            .send(StreamEvent::Done {
168                usage: usage.clone(),
169                provider_continuation: None,
170                stop_reason: stop_reason.clone(),
171            })
172            .await;
173
174        Ok(FinalResponse {
175            usage,
176            provider_continuation: None,
177            tool_calls: response.tool_calls.unwrap_or_default(),
178            stop_reason,
179        })
180    }
181}