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, learn_output_cap, output_cap_from_error, resolve_limits_cached,
23 retry_cap,
24};
25use mermaid_model::models::ModelCapabilities;
26
27pub struct OpenAICompatProvider {
28 adapter: OpenAICompatAdapter,
29 capabilities: ModelCapabilities,
30}
31
32impl OpenAICompatProvider {
33 pub fn new(
41 profile: &'static ProviderProfile,
42 base_url: String,
43 api_key: Option<String>,
44 model_name: String,
45 extra_headers: HashMap<String, String>,
46 ) -> Result<Self> {
47 let adapter =
48 OpenAICompatAdapter::new(profile, base_url, api_key, model_name, extra_headers)?;
49 let capabilities = adapter.capabilities().clone();
50 Ok(Self {
51 adapter,
52 capabilities,
53 })
54 }
55}
56
57#[async_trait]
58impl ModelProvider for OpenAICompatProvider {
59 fn capabilities(&self) -> &ModelCapabilities {
60 &self.capabilities
61 }
62
63 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
71 let _ = request;
72 let provider = self.adapter.provider_name().to_string();
73 let model = Model::name(&self.adapter).to_string();
74 let limits = resolve_limits_cached(&provider, &model, || async {
75 let listings = self.adapter.list_models_for_limits().await?;
76 let found = listings.into_iter().find(|m| m.id == model);
77 Ok(ModelLimits {
78 max_context_tokens: found.as_ref().and_then(|m| m.max_context_tokens),
79 max_output_tokens: found.as_ref().and_then(|m| m.max_output_tokens),
80 })
81 })
82 .await;
83 let window = limits.as_ref().and_then(|l| l.max_context_tokens);
84 ContextSizing {
85 model_max: window,
86 effective: window,
87 source: None,
88 max_output: limits.as_ref().and_then(|l| l.max_output_tokens),
89 }
90 }
91
92 async fn supports_vision(&self) -> Option<bool> {
93 Some(self.capabilities.supports_vision)
97 }
98
99 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
100 let config = ModelConfig::from(&request);
101 let chat_fut = async {
102 match self
103 .adapter
104 .chat(&request.messages, &config, Some(ctx.sink.clone()))
105 .await
106 {
107 Ok(response) => Ok(response),
108 Err(err) => {
109 let Some(cap) = output_cap_from_error(&err) else {
116 return Err(err);
117 };
118 let Some(clamped) = retry_cap(config.max_tokens, cap) else {
119 return Err(err);
120 };
121 let provider = self.adapter.provider_name().to_string();
122 let model = Model::name(&self.adapter).to_string();
123 learn_output_cap(provider, model.clone(), cap).await;
124 let _ = ctx.sink.send(StreamEvent::Status(format!(
125 "{model} rejected the output budget; learned its {cap}-token cap and retrying"
126 ))).await;
127 let retry_config = ModelConfig {
128 max_tokens: clamped,
129 ..config.clone()
130 };
131 self.adapter
132 .chat(&request.messages, &retry_config, Some(ctx.sink.clone()))
133 .await
134 },
135 }
136 };
137
138 let response = tokio::select! {
139 biased;
140 _ = ctx.token.cancelled() => {
141 return Err(ModelError::Cancelled);
142 },
143 r = chat_fut => r?,
144 };
145
146 let usage = response.usage.clone();
147 let stop_reason = response.stop_reason.clone();
148 let _ = ctx
151 .sink
152 .send(StreamEvent::Done {
153 usage: usage.clone(),
154 provider_continuation: None,
155 stop_reason: stop_reason.clone(),
156 })
157 .await;
158
159 Ok(FinalResponse {
160 usage,
161 provider_continuation: None,
162 tool_calls: response.tool_calls.unwrap_or_default(),
163 stop_reason,
164 })
165 }
166}