1use anyhow::{Context, Result, bail, ensure};
8use reqwest::{
9 Client, StatusCode,
10 header::{ACCEPT, IF_NONE_MATCH},
11};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::{BTreeMap, BTreeSet, HashSet};
15use std::time::Duration;
16use vtcode_commons::tool_types::CompactStr;
17use vtcode_config::TimeoutsConfig;
18
19use super::merge_gateway_contract::{
20 MergeAvailabilityStatus, MergeInputModality, MergeModelCatalogResponse, MergeModelRecord, MergeModelsListQuery,
21 MergeServiceTier, MergeVendorModelInfo,
22};
23
24const CATALOG_PAGE_LIMIT: u32 = 500;
25const MAX_CATALOG_PAGES: usize = 1_000;
26const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
27const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 120;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum MergeCatalogAvailability {
33 Available,
34 Deprecated,
35 Unknown,
36}
37
38impl From<MergeAvailabilityStatus> for MergeCatalogAvailability {
39 fn from(value: MergeAvailabilityStatus) -> Self {
40 match value {
41 MergeAvailabilityStatus::Available => Self::Available,
42 MergeAvailabilityStatus::Deprecated => Self::Deprecated,
43 MergeAvailabilityStatus::Unknown => Self::Unknown,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum MergeCatalogServiceTier {
52 Standard,
53 Flex,
54 Priority,
55 Unknown,
56}
57
58impl From<MergeServiceTier> for MergeCatalogServiceTier {
59 fn from(value: MergeServiceTier) -> Self {
60 match value {
61 MergeServiceTier::Standard => Self::Standard,
62 MergeServiceTier::Flex => Self::Flex,
63 MergeServiceTier::Priority => Self::Priority,
64 MergeServiceTier::Unknown => Self::Unknown,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct MergeCatalogFilters {
72 pub model: Option<String>,
73 pub provider: Option<String>,
74 pub vendor: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct MergeCatalogModel {
80 pub model: String,
81 pub provider: String,
82 pub display_name: Option<String>,
83 pub availability: MergeCatalogAvailability,
84 pub context_window: Option<u32>,
85 pub max_output_tokens: Option<u32>,
86 pub supports_tool_use: bool,
87 pub supports_streaming: bool,
88 pub supports_vision: bool,
89 pub supports_structured_output: bool,
90 pub service_tiers: Vec<MergeCatalogServiceTier>,
91 pub supports_reasoning: bool,
92 pub reasoning_disable_supported: bool,
93 pub reasoning_controls: Vec<String>,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct MergeCatalogSnapshot {
99 pub models: Vec<MergeCatalogModel>,
100 pub etag: Option<String>,
101}
102
103#[derive(Clone)]
105pub struct MergeGatewayCatalogClient {
106 api_key: String,
107 catalog_base_url: String,
108 http_client: Client,
109}
110
111impl MergeGatewayCatalogClient {
112 pub fn try_with_client(
114 api_key: impl Into<String>,
115 base_url: impl Into<String>,
116 http_client: Client,
117 ) -> Result<Self> {
118 Self::try_from_parts(api_key.into(), base_url.into(), http_client)
119 }
120
121 pub fn try_with_timeouts(
123 api_key: impl Into<String>,
124 base_url: impl Into<String>,
125 timeouts: Option<TimeoutsConfig>,
126 ) -> Result<Self> {
127 let http_client = build_http_client(timeouts.as_ref())?;
128 Self::try_from_parts(api_key.into(), base_url.into(), http_client)
129 }
130
131 pub async fn fetch_snapshot(
134 &self,
135 filters: &MergeCatalogFilters,
136 etag: Option<&str>,
137 ) -> Result<Option<MergeCatalogSnapshot>> {
138 let etag = normalize_optional_value(etag);
139 let mut cursor: Option<String> = None;
140 let mut seen_cursors: HashSet<String> = HashSet::new();
141 let mut models = Vec::new();
142 let mut snapshot_etag: Option<String> = None;
143 let mut page_index: usize = 1;
144
145 loop {
146 ensure!(
147 page_index <= MAX_CATALOG_PAGES,
148 "Merge Gateway catalog exceeded the maximum of {MAX_CATALOG_PAGES} pages"
149 );
150 let query = build_wire_query(filters, cursor.as_deref());
151 let mut request = self
152 .http_client
153 .get(self.models_endpoint_url())
154 .bearer_auth(&self.api_key)
155 .header(ACCEPT, "application/json")
156 .query(&query);
157
158 if page_index == 1 {
159 if let Some(etag) = etag.as_deref() {
160 request = request.header(IF_NONE_MATCH, etag);
161 }
162 }
163
164 let response = request.send().await.with_context(|| {
165 format!(
166 "Merge Gateway catalog request failed on page {page_index} ({})",
167 describe_filters(filters, cursor.as_deref())
168 )
169 })?;
170
171 if response.status() == StatusCode::NOT_MODIFIED {
172 if page_index == 1 && etag.is_some() {
173 return Ok(None);
174 }
175
176 bail!(
177 "Merge Gateway catalog returned 304 Not Modified on page {page_index} without a usable If-None-Match validator"
178 );
179 }
180
181 if !response.status().is_success() {
182 bail!(
183 "Merge Gateway catalog request failed on page {page_index} with HTTP {} ({})",
184 response.status(),
185 describe_filters(filters, cursor.as_deref())
186 );
187 }
188
189 if snapshot_etag.is_none() {
190 snapshot_etag = header_value_to_string(response.headers().get(reqwest::header::ETAG));
191 }
192
193 let raw: Value = response
194 .json()
195 .await
196 .with_context(|| format!("Merge Gateway catalog response on page {page_index} was not valid JSON"))?;
197 validate_catalog_envelope(&raw, page_index)?;
198 let page: MergeModelCatalogResponse = serde_json::from_value(raw)
199 .with_context(|| format!("Merge Gateway catalog response on page {page_index} was malformed"))?;
200
201 models.extend(page.data.into_iter().map(normalize_catalog_model));
202
203 if page.has_more {
204 let next_cursor = page
205 .next_cursor
206 .as_deref()
207 .map(str::trim)
208 .filter(|value| !value.is_empty())
209 .map(ToOwned::to_owned)
210 .ok_or_else(|| {
211 anyhow::anyhow!(
212 "Merge Gateway catalog page {page_index} set has_more=true without a non-empty next_cursor"
213 )
214 })?;
215
216 if !seen_cursors.insert(next_cursor.clone()) {
217 bail!("Merge Gateway catalog repeated pagination cursor `{next_cursor}` on page {page_index}");
218 }
219
220 cursor = Some(next_cursor);
221 page_index += 1;
222 continue;
223 }
224
225 return Ok(Some(MergeCatalogSnapshot { models, etag: snapshot_etag }));
226 }
227 }
228
229 fn try_from_parts(api_key: String, base_url: String, http_client: Client) -> Result<Self> {
230 let api_key = api_key.trim().to_owned();
231 ensure!(!api_key.is_empty(), "Merge Gateway API key cannot be empty");
232
233 let catalog_base_url = normalize_catalog_base_url(&base_url)?;
234 Ok(Self { api_key, catalog_base_url, http_client })
235 }
236
237 fn models_endpoint_url(&self) -> String {
238 format!("{}/models", self.catalog_base_url)
239 }
240}
241
242fn build_http_client(timeouts: Option<&TimeoutsConfig>) -> Result<Client> {
243 let request_timeout = timeouts
244 .and_then(|config| config.ceiling_duration(config.default_ceiling_seconds))
245 .unwrap_or_else(|| Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS));
246
247 Client::builder()
248 .timeout(request_timeout)
249 .connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
250 .build()
251 .context("failed to build Merge Gateway catalog HTTP client")
252}
253
254fn build_wire_query(filters: &MergeCatalogFilters, cursor: Option<&str>) -> MergeModelsListQuery {
255 MergeModelsListQuery {
256 model: normalize_optional_value(filters.model.as_deref()),
257 provider: normalize_optional_value(filters.provider.as_deref()),
258 vendor: normalize_optional_value(filters.vendor.as_deref()),
259 cursor: normalize_optional_value(cursor),
260 limit: Some(CATALOG_PAGE_LIMIT),
261 }
262}
263
264fn normalize_optional_value(value: Option<&str>) -> Option<CompactStr> {
265 value.map(str::trim).filter(|value| !value.is_empty()).map(CompactStr::from)
266}
267
268fn normalize_catalog_base_url(base_url: &str) -> Result<String> {
269 let mut normalized = base_url.trim().trim_end_matches('/').to_owned();
270 ensure!(!normalized.is_empty(), "Merge Gateway base URL cannot be empty");
271
272 loop {
273 let mut changed = false;
274 for suffix in ["/chat/completions", "/responses", "/openai", "/models"] {
275 if let Some(stripped) = normalized.strip_suffix(suffix) {
276 normalized = stripped.trim_end_matches('/').to_owned();
277 changed = true;
278 break;
279 }
280 }
281
282 if !changed {
283 break;
284 }
285 }
286
287 if !normalized.ends_with("/v1") && !normalized.contains("/v1/") {
288 normalized.push_str("/v1");
289 }
290
291 Ok(normalized)
292}
293
294fn validate_catalog_envelope(raw: &Value, page_index: usize) -> Result<()> {
295 let object = raw.as_object().and_then(|map| map.get("object")).and_then(Value::as_str);
296 ensure!(
297 object == Some("list"),
298 "Merge Gateway catalog envelope on page {page_index} must set object=\"list\""
299 );
300 Ok(())
301}
302
303fn header_value_to_string(value: Option<&reqwest::header::HeaderValue>) -> Option<String> {
304 value
305 .and_then(|header| header.to_str().ok())
306 .map(str::trim)
307 .filter(|value| !value.is_empty())
308 .map(ToOwned::to_owned)
309}
310
311fn describe_filters(filters: &MergeCatalogFilters, cursor: Option<&str>) -> String {
312 format!(
313 "cursor={cursor:?}, model={:?}, provider={:?}, vendor={:?}",
314 filters.model.as_deref(),
315 filters.provider.as_deref(),
316 filters.vendor.as_deref(),
317 )
318}
319
320fn normalize_catalog_model(record: MergeModelRecord) -> MergeCatalogModel {
321 let context_window = aggregate_min_context_window(&record.vendors);
322 let max_output_tokens = aggregate_min_max_output_tokens(&record.vendors);
323 let supports_tool_use = all_vendors_support(&record.vendors, |vendor| vendor.capabilities.supports_tool_calling);
324 let supports_streaming = all_vendors_support(&record.vendors, |vendor| vendor.capabilities.streaming);
325 let supports_vision = all_vendors_support(&record.vendors, |vendor| {
326 vendor
327 .capabilities
328 .input
329 .iter()
330 .any(|modality| matches!(modality, MergeInputModality::Image))
331 });
332 let supports_structured_output =
333 all_vendors_support(&record.vendors, |vendor| vendor.capabilities.supports_structured_outputs);
334 let service_tiers = aggregate_service_tiers(&record.vendors);
335 let supports_reasoning = all_vendors_support(&record.vendors, |vendor| vendor.capabilities.supports_reasoning());
336 let reasoning_disable_supported = !record.vendors.is_empty()
337 && record
338 .vendors
339 .values()
340 .all(|vendor| vendor.capabilities.reasoning_disable_supported());
341 let reasoning_controls = aggregate_reasoning_controls(&record.vendors);
342
343 MergeCatalogModel {
344 model: record.model.to_string(),
345 provider: record.provider.to_string(),
346 display_name: record.display_name.map(|value| value.to_string()),
347 availability: record.availability_status.into(),
348 context_window,
349 max_output_tokens,
350 supports_tool_use,
351 supports_streaming,
352 supports_vision,
353 supports_structured_output,
354 service_tiers,
355 supports_reasoning,
356 reasoning_disable_supported,
357 reasoning_controls,
358 }
359}
360
361fn aggregate_min_context_window(vendors: &BTreeMap<CompactStr, MergeVendorModelInfo>) -> Option<u32> {
362 vendors.values().filter_map(|vendor| vendor.context_window).min()
363}
364
365fn aggregate_min_max_output_tokens(vendors: &BTreeMap<CompactStr, MergeVendorModelInfo>) -> Option<u32> {
366 vendors.values().filter_map(|vendor| vendor.max_output_tokens).min()
367}
368
369fn all_vendors_support(
370 vendors: &BTreeMap<CompactStr, MergeVendorModelInfo>,
371 predicate: impl Fn(&MergeVendorModelInfo) -> bool,
372) -> bool {
373 !vendors.is_empty() && vendors.values().all(predicate)
374}
375
376fn aggregate_service_tiers(vendors: &BTreeMap<CompactStr, MergeVendorModelInfo>) -> Vec<MergeCatalogServiceTier> {
377 let mut intersection: Option<BTreeSet<MergeCatalogServiceTier>> = None;
378
379 for vendor in vendors.values() {
380 let tiers: BTreeSet<MergeCatalogServiceTier> = vendor
381 .service_tiers
382 .iter()
383 .copied()
384 .map(MergeCatalogServiceTier::from)
385 .collect();
386
387 intersection = Some(match intersection {
388 None => tiers,
389 Some(current) => current.intersection(&tiers).copied().collect(),
390 });
391 }
392
393 intersection.map(|tiers| tiers.into_iter().collect()).unwrap_or_default()
394}
395
396fn aggregate_reasoning_controls(vendors: &BTreeMap<CompactStr, MergeVendorModelInfo>) -> Vec<String> {
397 if !all_vendors_support(vendors, |vendor| vendor.capabilities.supports_reasoning()) {
398 return Vec::new();
399 }
400
401 let mut controls = BTreeSet::new();
402 for vendor in vendors.values() {
403 controls.extend(
404 vendor
405 .capabilities
406 .reasoning_controls()
407 .into_iter()
408 .map(|control| control.to_string()),
409 );
410 }
411 controls.into_iter().collect()
412}
413
414#[cfg(test)]
415mod tests;