mermaid_cli/providers/model/
openai_compat.rs1use 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 rejections: RejectionCache,
32}
33
34impl OpenAICompatProvider {
35 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 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 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 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 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 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}