1use crate::catalog::{
6 CatalogError, CatalogSnapshot, NormalizedModelSpec, ProviderCatalogDiscovery,
7};
8use crate::http_transport::{ResolvedSecret, SecretResolutionError, SecretResolver};
9use futures_util::StreamExt;
10use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
11use router_protocol::{
12 ANTHROPIC_ORIGIN, CatalogSourceKind, DASHSCOPE_TOKEN_PLAN_ORIGIN, DEEPSEEK_ORIGIN,
13 GEMINI_ORIGIN, MOONSHOT_ORIGIN, XAI_ORIGIN, ZAI_ORIGIN,
14};
15use serde::Deserialize;
16use serde_json::{Map, Value, json};
17use sha2::{Digest, Sha256};
18use std::collections::{BTreeSet, HashSet};
19use std::fmt;
20use std::future::Future;
21use std::pin::Pin;
22use std::time::Duration;
23use thiserror::Error;
24
25pub const MAX_CATALOG_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
26pub const MAX_CATALOG_MODELS: usize = 512;
27pub const MAX_SHOW_ENRICHMENTS: usize = 16;
28pub const MAX_SHOW_RESPONSE_BYTES: usize = 256 * 1024;
29pub const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
33const MAX_ERROR_RESPONSE_BYTES: usize = 32 * 1024;
34const MAX_REQUEST_BODY_BYTES: usize = 512;
35const CATALOG_USER_AGENT: &str = "steadymux-catalog/0.1";
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum CatalogEndpoint {
39 OllamaCloudTags,
40 OllamaCloudShow,
41 OpenRouterUserModels,
42 OpenRouterPublicModels,
43 LocalOllamaTags,
44 LocalOllamaShow,
45 DeepseekModels,
46 QwenModels,
47 XaiModels,
48 MoonshotModels,
49 ZaiModels,
50 AnthropicModels,
51 GeminiModels,
52}
53
54impl CatalogEndpoint {
55 const fn method(self) -> &'static str {
56 match self {
57 Self::OllamaCloudShow | Self::LocalOllamaShow => "POST",
58 Self::OllamaCloudTags
59 | Self::OpenRouterUserModels
60 | Self::OpenRouterPublicModels
61 | Self::LocalOllamaTags
62 | Self::DeepseekModels
63 | Self::QwenModels
64 | Self::XaiModels
65 | Self::MoonshotModels
66 | Self::ZaiModels
67 | Self::AnthropicModels
68 | Self::GeminiModels => "GET",
69 }
70 }
71
72 const fn origin(self) -> &'static str {
73 match self {
74 Self::OllamaCloudTags | Self::OllamaCloudShow => "https://ollama.com",
75 Self::OpenRouterUserModels | Self::OpenRouterPublicModels => "https://openrouter.ai",
76 Self::LocalOllamaTags | Self::LocalOllamaShow => "http://127.0.0.1:11434",
77 Self::DeepseekModels => DEEPSEEK_ORIGIN,
78 Self::QwenModels => DASHSCOPE_TOKEN_PLAN_ORIGIN,
79 Self::XaiModels => XAI_ORIGIN,
80 Self::MoonshotModels => MOONSHOT_ORIGIN,
81 Self::ZaiModels => ZAI_ORIGIN,
82 Self::AnthropicModels => ANTHROPIC_ORIGIN,
83 Self::GeminiModels => GEMINI_ORIGIN,
84 }
85 }
86
87 const fn path(self) -> &'static str {
88 match self {
89 Self::OllamaCloudTags | Self::LocalOllamaTags => "/api/tags",
90 Self::OllamaCloudShow | Self::LocalOllamaShow => "/api/show",
91 Self::OpenRouterUserModels => "/api/v1/models/user",
92 Self::OpenRouterPublicModels => "/api/v1/models",
93 Self::DeepseekModels => "/models",
94 Self::QwenModels => "/compatible-mode/v1/models",
95 Self::XaiModels | Self::MoonshotModels => "/v1/models",
96 Self::ZaiModels => "/api/paas/v4/models",
97 Self::AnthropicModels | Self::GeminiModels => "/v1/models",
98 }
99 }
100
101 const fn requires_authorization(self) -> bool {
102 matches!(
103 self,
104 Self::OllamaCloudTags
105 | Self::OllamaCloudShow
106 | Self::OpenRouterUserModels
107 | Self::DeepseekModels
108 | Self::QwenModels
109 | Self::XaiModels
110 | Self::MoonshotModels
111 | Self::ZaiModels
112 | Self::AnthropicModels
113 | Self::GeminiModels
114 )
115 }
116
117 const fn allows_authorization(self) -> bool {
118 self.requires_authorization()
119 }
120
121 const fn response_limit(self) -> usize {
122 match self {
123 Self::OllamaCloudShow | Self::LocalOllamaShow => MAX_SHOW_RESPONSE_BYTES,
124 _ => MAX_CATALOG_RESPONSE_BYTES,
125 }
126 }
127}
128
129pub struct CatalogHttpRequest {
131 endpoint: CatalogEndpoint,
132 body: Vec<u8>,
133 authorization: Option<ResolvedSecret>,
134 pub auth_header_name: Option<&'static str>,
139 pub version_header: Option<(&'static str, &'static str)>,
143}
144
145impl CatalogHttpRequest {
146 pub const fn endpoint(&self) -> CatalogEndpoint {
147 self.endpoint
148 }
149
150 pub const fn method(&self) -> &'static str {
151 self.endpoint.method()
152 }
153
154 pub const fn origin(&self) -> &'static str {
155 self.endpoint.origin()
156 }
157
158 pub const fn path(&self) -> &'static str {
159 self.endpoint.path()
160 }
161
162 pub fn body_for_dispatch(&self) -> &[u8] {
163 &self.body
164 }
165
166 pub fn authorization_for_dispatch(&self) -> Option<&[u8]> {
167 self.authorization
168 .as_ref()
169 .map(ResolvedSecret::expose_for_dispatch)
170 }
171}
172
173impl fmt::Debug for CatalogHttpRequest {
174 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
175 formatter
176 .debug_struct("CatalogHttpRequest")
177 .field("endpoint", &self.endpoint)
178 .field("body", &"[REDACTED]")
179 .field(
180 "authorization",
181 &self.authorization.as_ref().map(|_| "[REDACTED]"),
182 )
183 .field("auth_header_name", &self.auth_header_name)
184 .field("version_header", &self.version_header)
185 .finish()
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub struct CatalogHttpResponse {
191 pub status: u16,
192 pub content_type: Option<String>,
193 pub redirected: bool,
194 pub body: Vec<u8>,
195}
196
197#[derive(Clone, Copy, Debug, PartialEq, Eq)]
198pub enum CatalogHttpErrorKind {
199 Cancelled,
200 Timeout,
201 Transport,
202 InvalidRequest,
203 ResponseTooLarge,
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
207#[error("catalog HTTP execution failed")]
208pub struct CatalogHttpError {
209 pub kind: CatalogHttpErrorKind,
210}
211
212pub type CatalogHttpFuture<'a> =
213 Pin<Box<dyn Future<Output = Result<CatalogHttpResponse, CatalogHttpError>> + Send + 'a>>;
214
215pub trait CatalogHttpExecutor: Send + Sync {
216 fn execute<'a>(&'a self, request: CatalogHttpRequest) -> CatalogHttpFuture<'a>;
217}
218
219#[derive(Clone)]
220pub struct ReqwestCatalogExecutor {
221 client: reqwest::Client,
222}
223
224impl fmt::Debug for ReqwestCatalogExecutor {
225 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
226 formatter
227 .debug_struct("ReqwestCatalogExecutor")
228 .field("redirects", &"disabled")
229 .field("proxy", &"disabled")
230 .finish()
231 }
232}
233
234impl ReqwestCatalogExecutor {
235 pub fn new(
236 connect_timeout: Duration,
237 read_timeout: Duration,
238 ) -> Result<Self, CatalogHttpError> {
239 if connect_timeout.is_zero() || read_timeout.is_zero() {
240 return Err(catalog_http_error(CatalogHttpErrorKind::InvalidRequest));
241 }
242 let client = reqwest::Client::builder()
243 .use_rustls_tls()
244 .redirect(reqwest::redirect::Policy::none())
245 .no_proxy()
246 .user_agent(CATALOG_USER_AGENT)
247 .connect_timeout(connect_timeout)
248 .read_timeout(read_timeout)
249 .build()
250 .map_err(|_| catalog_http_error(CatalogHttpErrorKind::InvalidRequest))?;
251 Ok(Self { client })
252 }
253
254 async fn execute_bounded(
255 &self,
256 request: CatalogHttpRequest,
257 ) -> Result<CatalogHttpResponse, CatalogHttpError> {
258 validate_catalog_request(&request)?;
259 let exact_url = format!("{}{}", request.origin(), request.path());
260 let mut builder = if request.method() == "GET" {
261 self.client.get(&exact_url)
262 } else {
263 self.client
264 .post(&exact_url)
265 .header(CONTENT_TYPE, "application/json")
266 .body(request.body)
267 }
268 .header(ACCEPT, "application/json");
269 if let Some(authorization) = request.authorization {
270 let mut header =
271 reqwest::header::HeaderValue::from_bytes(authorization.expose_for_dispatch())
272 .map_err(|_| catalog_http_error(CatalogHttpErrorKind::InvalidRequest))?;
273 header.set_sensitive(true);
274 builder = match request.auth_header_name {
278 Some(name) => builder.header(name, header),
279 None => builder.header(AUTHORIZATION, header),
280 };
281 }
282 if let Some((name, value)) = request.version_header {
283 let header = reqwest::header::HeaderValue::from_str(value)
284 .map_err(|_| catalog_http_error(CatalogHttpErrorKind::InvalidRequest))?;
285 builder = builder.header(name, header);
286 }
287 let response = builder.send().await.map_err(|error| {
288 catalog_http_error(if error.is_timeout() {
289 CatalogHttpErrorKind::Timeout
290 } else {
291 CatalogHttpErrorKind::Transport
292 })
293 })?;
294 let status = response.status().as_u16();
295 let content_type = response
296 .headers()
297 .get(CONTENT_TYPE)
298 .and_then(|value| value.to_str().ok())
299 .filter(|value| value.len() <= 128)
300 .map(str::to_owned);
301 let redirected = response.url().as_str() != exact_url;
302 let limit = if status == 200 {
303 request.endpoint.response_limit()
304 } else {
305 MAX_ERROR_RESPONSE_BYTES
306 };
307 let mut stream = response.bytes_stream();
308 let mut body = Vec::new();
309 while let Some(chunk) = stream.next().await {
310 let chunk = chunk.map_err(|error| {
311 catalog_http_error(if error.is_timeout() {
312 CatalogHttpErrorKind::Timeout
313 } else {
314 CatalogHttpErrorKind::Transport
315 })
316 })?;
317 if body.len().saturating_add(chunk.len()) > limit {
318 return Err(catalog_http_error(CatalogHttpErrorKind::ResponseTooLarge));
319 }
320 body.extend_from_slice(&chunk);
321 }
322 Ok(CatalogHttpResponse {
323 status,
324 content_type,
325 redirected,
326 body,
327 })
328 }
329}
330
331impl CatalogHttpExecutor for ReqwestCatalogExecutor {
332 fn execute<'a>(&'a self, request: CatalogHttpRequest) -> CatalogHttpFuture<'a> {
333 Box::pin(async move { self.execute_bounded(request).await })
334 }
335}
336
337#[derive(Clone, Debug, PartialEq, Eq)]
338pub struct CatalogSnapshotRequest {
339 pub snapshot_id: String,
340 pub catalog_version: String,
341 pub observed_at_epoch_seconds: u64,
342}
343
344#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
345pub struct OllamaDiscoveryOptions {
346 pub max_show_enrichments: usize,
347}
348
349#[derive(Clone, Copy, Debug, PartialEq, Eq)]
352pub enum OpenRouterMalformedStage {
353 Json,
354 DataEnvelope,
355 ModelObject,
356 ModelId,
357 DuplicateModelId,
358 ContextLength,
359 Modalities,
360 MissingConfiguredModel,
361 Normalization,
362}
363
364#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
365pub enum CatalogDiscoveryError {
366 #[error("catalog discovery request is invalid")]
367 InvalidRequest,
368 #[error("catalog credential resolution failed")]
369 SecretResolution,
370 #[error("catalog discovery timed out")]
371 Timeout,
372 #[error("catalog discovery transport failed")]
373 Transport,
374 #[error("catalog discovery response exceeded its bound")]
375 ResponseBounds,
376 #[error("catalog discovery returned a disallowed HTTP response")]
377 HttpResponse,
378 #[error("catalog discovery response was malformed")]
379 Malformed,
380 #[error("OpenRouter catalog discovery was malformed")]
381 OpenRouterMalformed(OpenRouterMalformedStage),
382 #[error("catalog snapshot was rejected")]
383 SnapshotRejected,
384}
385
386#[derive(Clone, Debug, PartialEq, Eq)]
387pub struct OllamaNativeCatalog {
388 models: Vec<OllamaNativeModel>,
389}
390
391#[derive(Clone, Debug, PartialEq, Eq)]
392struct OllamaNativeModel {
393 id: String,
394 location: OllamaModelLocation,
395 context_tokens: u64,
396 modalities: Vec<String>,
397 tool_support: bool,
398 reasoning_modes: Vec<String>,
399}
400
401#[derive(Clone, Copy, Debug, PartialEq, Eq)]
402enum OllamaModelLocation {
403 Cloud,
404 Local,
405}
406
407#[derive(Clone, Copy, Debug, PartialEq, Eq)]
408pub struct OllamaCatalogCodec {
409 source: CatalogSourceKind,
410}
411
412impl OllamaCatalogCodec {
413 pub const fn cloud() -> Self {
414 Self {
415 source: CatalogSourceKind::OllamaCloudCatalog,
416 }
417 }
418
419 pub const fn local() -> Self {
420 Self {
421 source: CatalogSourceKind::OllamaLocalInventory,
422 }
423 }
424
425 fn includes(self, model: &OllamaNativeModel) -> bool {
426 matches!(
427 (self.source, model.location),
428 (
429 CatalogSourceKind::OllamaCloudCatalog,
430 OllamaModelLocation::Cloud
431 ) | (
432 CatalogSourceKind::OllamaLocalInventory,
433 OllamaModelLocation::Local
434 )
435 )
436 }
437}
438
439impl ProviderCatalogDiscovery for OllamaCatalogCodec {
440 type NativeResponse = OllamaNativeCatalog;
441
442 fn source(&self) -> CatalogSourceKind {
443 self.source
444 }
445
446 fn normalize(
447 &self,
448 response: &Self::NativeResponse,
449 observed_at_epoch_seconds: u64,
450 ) -> Result<Vec<NormalizedModelSpec>, CatalogError> {
451 let pricing_provenance = match self.source {
452 CatalogSourceKind::OllamaCloudCatalog => "ollama_cloud_unpriced",
453 CatalogSourceKind::OllamaLocalInventory => "local_compute",
454 _ => return Err(CatalogError::Malformed),
455 };
456 let mut models = response
457 .models
458 .iter()
459 .map(|model| NormalizedModelSpec {
460 provider_model_id: model.id.clone(),
461 context_tokens: model.context_tokens,
462 modalities: model.modalities.clone(),
463 tool_support: model.tool_support,
464 reasoning_modes: model.reasoning_modes.clone(),
465 pricing_provenance: pricing_provenance.to_owned(),
466 availability: "available".to_owned(),
467 observed_at_epoch_seconds,
468 })
469 .collect::<Vec<_>>();
470 models.sort_by(|left, right| left.provider_model_id.cmp(&right.provider_model_id));
471 Ok(models)
472 }
473}
474
475#[derive(Clone, Debug, PartialEq, Eq)]
476pub struct OpenRouterNativeCatalog {
477 models: Vec<OpenRouterNativeModel>,
478 authenticated: bool,
479}
480
481#[derive(Clone, Debug, PartialEq, Eq)]
482struct OpenRouterNativeModel {
483 id: String,
484 context_tokens: u64,
485 modalities: Vec<String>,
486 tool_support: bool,
487 reasoning_modes: Vec<String>,
488}
489
490#[derive(Clone, Copy, Debug, PartialEq, Eq)]
491pub struct OpenRouterCatalogCodec;
492
493impl ProviderCatalogDiscovery for OpenRouterCatalogCodec {
494 type NativeResponse = OpenRouterNativeCatalog;
495
496 fn source(&self) -> CatalogSourceKind {
497 CatalogSourceKind::OpenrouterCatalog
498 }
499
500 fn normalize(
501 &self,
502 response: &Self::NativeResponse,
503 observed_at_epoch_seconds: u64,
504 ) -> Result<Vec<NormalizedModelSpec>, CatalogError> {
505 let provenance = if response.authenticated {
506 "openrouter_user_catalog"
507 } else {
508 "openrouter_public_catalog"
509 };
510 let mut models = response
511 .models
512 .iter()
513 .map(|model| NormalizedModelSpec {
514 provider_model_id: model.id.clone(),
515 context_tokens: model.context_tokens,
516 modalities: model.modalities.clone(),
517 tool_support: model.tool_support,
518 reasoning_modes: model.reasoning_modes.clone(),
519 pricing_provenance: provenance.to_owned(),
520 availability: "available".to_owned(),
521 observed_at_epoch_seconds,
522 })
523 .collect::<Vec<_>>();
524 models.sort_by(|left, right| left.provider_model_id.cmp(&right.provider_model_id));
525 Ok(models)
526 }
527}
528
529#[derive(Clone, Debug, PartialEq, Eq)]
534pub struct OpenAiCompatNativeCatalog {
535 models: Vec<OpenAiCompatNativeModel>,
536}
537
538#[derive(Clone, Debug, PartialEq, Eq)]
539struct OpenAiCompatNativeModel {
540 id: String,
541}
542
543#[derive(Clone, Copy, Debug, PartialEq, Eq)]
544pub struct OpenAiCompatCatalogCodec {
545 source: CatalogSourceKind,
546}
547
548impl OpenAiCompatCatalogCodec {
549 pub const fn deepseek() -> Self {
550 Self {
551 source: CatalogSourceKind::DeepseekCatalog,
552 }
553 }
554
555 pub const fn qwen() -> Self {
556 Self {
557 source: CatalogSourceKind::QwenCatalog,
558 }
559 }
560
561 pub const fn xai() -> Self {
562 Self {
563 source: CatalogSourceKind::GrokCatalog,
564 }
565 }
566
567 pub const fn moonshot() -> Self {
568 Self {
569 source: CatalogSourceKind::KimiCatalog,
570 }
571 }
572
573 pub const fn zai() -> Self {
574 Self {
575 source: CatalogSourceKind::ZaiCatalog,
576 }
577 }
578
579 pub const fn anthropic() -> Self {
580 Self {
581 source: CatalogSourceKind::ClaudeCatalog,
582 }
583 }
584}
585
586impl ProviderCatalogDiscovery for OpenAiCompatCatalogCodec {
587 type NativeResponse = OpenAiCompatNativeCatalog;
588
589 fn source(&self) -> CatalogSourceKind {
590 self.source
591 }
592
593 fn normalize(
594 &self,
595 response: &Self::NativeResponse,
596 observed_at_epoch_seconds: u64,
597 ) -> Result<Vec<NormalizedModelSpec>, CatalogError> {
598 let pricing_provenance = match self.source {
599 CatalogSourceKind::DeepseekCatalog => "deepseek_api_catalog",
600 CatalogSourceKind::QwenCatalog => "qwen_token_plan_catalog",
601 CatalogSourceKind::GrokCatalog => "grok_api_catalog",
602 CatalogSourceKind::KimiCatalog => "kimi_api_catalog",
603 CatalogSourceKind::ZaiCatalog => "zai_api_catalog",
604 CatalogSourceKind::ClaudeCatalog => "anthropic_api_catalog",
605 CatalogSourceKind::GeminiCatalog => "gemini_api_catalog",
606 _ => return Err(CatalogError::Malformed),
607 };
608 let mut models = response
609 .models
610 .iter()
611 .map(|model| NormalizedModelSpec {
612 provider_model_id: model.id.clone(),
613 context_tokens: 1,
617 modalities: vec!["text".to_owned()],
618 tool_support: false,
619 reasoning_modes: Vec::new(),
620 pricing_provenance: pricing_provenance.to_owned(),
621 availability: "available".to_owned(),
622 observed_at_epoch_seconds,
623 })
624 .collect::<Vec<_>>();
625 models.sort_by(|left, right| left.provider_model_id.cmp(&right.provider_model_id));
626 Ok(models)
627 }
628}
629
630fn decode_openai_compat_models(
634 bytes: &[u8],
635) -> Result<OpenAiCompatNativeCatalog, CatalogDiscoveryError> {
636 let root: Value =
637 serde_json::from_slice(bytes).map_err(|_| CatalogDiscoveryError::Malformed)?;
638 let values = root
639 .get("data")
640 .and_then(Value::as_array)
641 .ok_or(CatalogDiscoveryError::Malformed)?;
642 if values.is_empty() || values.len() > MAX_CATALOG_MODELS {
643 return Err(CatalogDiscoveryError::ResponseBounds);
644 }
645 let mut seen = HashSet::with_capacity(values.len());
646 let mut models = Vec::with_capacity(values.len());
647 for value in values {
648 let Some(id) = value.get("id").and_then(Value::as_str) else {
649 return Err(CatalogDiscoveryError::Malformed);
650 };
651 if !valid_model_id(id) || !seen.insert(id.to_owned()) {
652 return Err(CatalogDiscoveryError::Malformed);
653 }
654 models.push(OpenAiCompatNativeModel { id: id.to_owned() });
655 }
656 models.sort_by(|left, right| left.id.cmp(&right.id));
657 Ok(OpenAiCompatNativeCatalog { models })
658}
659
660#[derive(Clone, Debug, PartialEq, Eq)]
661pub struct GeminiNativeModel {
662 id: String,
663}
664
665#[derive(Clone, Debug, PartialEq, Eq)]
666pub struct GeminiNativeCatalog {
667 pub models: Vec<GeminiNativeModel>,
668}
669
670#[derive(Clone, Copy, Debug, PartialEq, Eq)]
674pub struct GeminiCatalogCodec;
675
676impl ProviderCatalogDiscovery for GeminiCatalogCodec {
677 type NativeResponse = GeminiNativeCatalog;
678
679 fn source(&self) -> CatalogSourceKind {
680 CatalogSourceKind::GeminiCatalog
681 }
682
683 fn normalize(
684 &self,
685 response: &Self::NativeResponse,
686 observed_at_epoch_seconds: u64,
687 ) -> Result<Vec<NormalizedModelSpec>, CatalogError> {
688 let mut models = response
689 .models
690 .iter()
691 .map(|model| NormalizedModelSpec {
692 provider_model_id: model.id.clone(),
693 context_tokens: 1,
697 modalities: vec!["text".to_owned()],
698 tool_support: false,
699 reasoning_modes: Vec::new(),
700 pricing_provenance: "gemini_api_catalog".to_owned(),
701 availability: "available".to_owned(),
702 observed_at_epoch_seconds,
703 })
704 .collect::<Vec<_>>();
705 models.sort_by(|left, right| left.provider_model_id.cmp(&right.provider_model_id));
706 Ok(models)
707 }
708}
709
710fn decode_gemini_models(bytes: &[u8]) -> Result<GeminiNativeCatalog, CatalogDiscoveryError> {
716 let root: Value =
717 serde_json::from_slice(bytes).map_err(|_| CatalogDiscoveryError::Malformed)?;
718 let values = root
719 .get("models")
720 .and_then(Value::as_array)
721 .ok_or(CatalogDiscoveryError::Malformed)?;
722 if values.is_empty() || values.len() > MAX_CATALOG_MODELS {
723 return Err(CatalogDiscoveryError::ResponseBounds);
724 }
725 let mut seen = HashSet::with_capacity(values.len());
726 let mut models = Vec::with_capacity(values.len());
727 for value in values {
728 let name = value
729 .get("name")
730 .and_then(Value::as_str)
731 .ok_or(CatalogDiscoveryError::Malformed)?;
732 let id = name
733 .strip_prefix("models/")
734 .ok_or(CatalogDiscoveryError::Malformed)?;
735 if !valid_model_id(id) || !seen.insert(id.to_owned()) {
736 return Err(CatalogDiscoveryError::Malformed);
737 }
738 models.push(GeminiNativeModel { id: id.to_owned() });
739 }
740 models.sort_by(|left, right| left.id.cmp(&right.id));
741 Ok(GeminiNativeCatalog { models })
742}
743
744pub async fn discover_deepseek(
748 executor: &dyn CatalogHttpExecutor,
749 resolver: &dyn SecretResolver,
750 secret_ref: &str,
751 snapshot: CatalogSnapshotRequest,
752) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
753 let request = catalog_request(
754 CatalogEndpoint::DeepseekModels,
755 None,
756 Some(CatalogCredential::Resolver {
757 resolver,
758 secret_ref,
759 }),
760 )?;
761 let response = execute_catalog(executor, request).await?;
762 validate_success_response(CatalogEndpoint::DeepseekModels, &response)?;
763 let native = decode_openai_compat_models(&response.body)?;
764 let models = OpenAiCompatCatalogCodec::deepseek()
765 .normalize(&native, snapshot.observed_at_epoch_seconds)
766 .map_err(|_| CatalogDiscoveryError::Malformed)?;
767 accept_snapshot(
768 snapshot,
769 "deepseek",
770 CatalogSourceKind::DeepseekCatalog,
771 receipt_for(&[response.body]),
772 models,
773 )
774}
775
776pub async fn discover_qwen(
780 executor: &dyn CatalogHttpExecutor,
781 resolver: &dyn SecretResolver,
782 secret_ref: &str,
783 snapshot: CatalogSnapshotRequest,
784) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
785 let request = catalog_request(
786 CatalogEndpoint::QwenModels,
787 None,
788 Some(CatalogCredential::Resolver {
789 resolver,
790 secret_ref,
791 }),
792 )?;
793 let response = execute_catalog(executor, request).await?;
794 validate_success_response(CatalogEndpoint::QwenModels, &response)?;
795 let native = decode_openai_compat_models(&response.body)?;
796 let models = OpenAiCompatCatalogCodec::qwen()
797 .normalize(&native, snapshot.observed_at_epoch_seconds)
798 .map_err(|_| CatalogDiscoveryError::Malformed)?;
799 accept_snapshot(
800 snapshot,
801 "qwen",
802 CatalogSourceKind::QwenCatalog,
803 receipt_for(&[response.body]),
804 models,
805 )
806}
807
808pub async fn discover_xai(
812 executor: &dyn CatalogHttpExecutor,
813 resolver: &dyn SecretResolver,
814 secret_ref: &str,
815 snapshot: CatalogSnapshotRequest,
816) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
817 let request = catalog_request(
818 CatalogEndpoint::XaiModels,
819 None,
820 Some(CatalogCredential::Resolver {
821 resolver,
822 secret_ref,
823 }),
824 )?;
825 let response = execute_catalog(executor, request).await?;
826 validate_success_response(CatalogEndpoint::XaiModels, &response)?;
827 let native = decode_openai_compat_models(&response.body)?;
828 let models = OpenAiCompatCatalogCodec::xai()
829 .normalize(&native, snapshot.observed_at_epoch_seconds)
830 .map_err(|_| CatalogDiscoveryError::Malformed)?;
831 accept_snapshot(
832 snapshot,
833 "grok",
834 CatalogSourceKind::GrokCatalog,
835 receipt_for(&[response.body]),
836 models,
837 )
838}
839
840pub async fn discover_moonshot(
844 executor: &dyn CatalogHttpExecutor,
845 resolver: &dyn SecretResolver,
846 secret_ref: &str,
847 snapshot: CatalogSnapshotRequest,
848) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
849 let request = catalog_request(
850 CatalogEndpoint::MoonshotModels,
851 None,
852 Some(CatalogCredential::Resolver {
853 resolver,
854 secret_ref,
855 }),
856 )?;
857 let response = execute_catalog(executor, request).await?;
858 validate_success_response(CatalogEndpoint::MoonshotModels, &response)?;
859 let native = decode_openai_compat_models(&response.body)?;
860 let models = OpenAiCompatCatalogCodec::moonshot()
861 .normalize(&native, snapshot.observed_at_epoch_seconds)
862 .map_err(|_| CatalogDiscoveryError::Malformed)?;
863 accept_snapshot(
864 snapshot,
865 "kimi",
866 CatalogSourceKind::KimiCatalog,
867 receipt_for(&[response.body]),
868 models,
869 )
870}
871
872pub async fn discover_zai(
876 executor: &dyn CatalogHttpExecutor,
877 resolver: &dyn SecretResolver,
878 secret_ref: &str,
879 snapshot: CatalogSnapshotRequest,
880) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
881 let request = catalog_request(
882 CatalogEndpoint::ZaiModels,
883 None,
884 Some(CatalogCredential::Resolver {
885 resolver,
886 secret_ref,
887 }),
888 )?;
889 let response = execute_catalog(executor, request).await?;
890 validate_success_response(CatalogEndpoint::ZaiModels, &response)?;
891 let native = decode_openai_compat_models(&response.body)?;
892 let models = OpenAiCompatCatalogCodec::zai()
893 .normalize(&native, snapshot.observed_at_epoch_seconds)
894 .map_err(|_| CatalogDiscoveryError::Malformed)?;
895 accept_snapshot(
896 snapshot,
897 "zai",
898 CatalogSourceKind::ZaiCatalog,
899 receipt_for(&[response.body]),
900 models,
901 )
902}
903
904pub async fn discover_anthropic(
909 executor: &dyn CatalogHttpExecutor,
910 resolver: &dyn SecretResolver,
911 secret_ref: &str,
912 snapshot: CatalogSnapshotRequest,
913) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
914 let request = catalog_request(
915 CatalogEndpoint::AnthropicModels,
916 None,
917 Some(CatalogCredential::Resolver {
918 resolver,
919 secret_ref,
920 }),
921 )?;
922 let response = execute_catalog(executor, request).await?;
923 validate_success_response(CatalogEndpoint::AnthropicModels, &response)?;
924 let native = decode_openai_compat_models(&response.body)?;
925 let models = OpenAiCompatCatalogCodec::anthropic()
926 .normalize(&native, snapshot.observed_at_epoch_seconds)
927 .map_err(|_| CatalogDiscoveryError::Malformed)?;
928 accept_snapshot(
929 snapshot,
930 "claude",
931 CatalogSourceKind::ClaudeCatalog,
932 receipt_for(&[response.body]),
933 models,
934 )
935}
936
937pub async fn discover_gemini(
943 executor: &dyn CatalogHttpExecutor,
944 resolver: &dyn SecretResolver,
945 secret_ref: &str,
946 snapshot: CatalogSnapshotRequest,
947) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
948 let request = catalog_request(
949 CatalogEndpoint::GeminiModels,
950 None,
951 Some(CatalogCredential::Resolver {
952 resolver,
953 secret_ref,
954 }),
955 )?;
956 let response = execute_catalog(executor, request).await?;
957 validate_success_response(CatalogEndpoint::GeminiModels, &response)?;
958 let native = decode_gemini_models(&response.body)?;
959 let models = GeminiCatalogCodec
960 .normalize(&native, snapshot.observed_at_epoch_seconds)
961 .map_err(|_| CatalogDiscoveryError::Malformed)?;
962 accept_snapshot(
963 snapshot,
964 "gemini",
965 CatalogSourceKind::GeminiCatalog,
966 receipt_for(&[response.body]),
967 models,
968 )
969}
970
971pub async fn discover_ollama_cloud(
972 executor: &dyn CatalogHttpExecutor,
973 resolver: &dyn SecretResolver,
974 secret_ref: &str,
975 snapshot: CatalogSnapshotRequest,
976 options: OllamaDiscoveryOptions,
977) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
978 discover_ollama(
979 executor,
980 Some(CatalogCredential::Resolver {
981 resolver,
982 secret_ref,
983 }),
984 CatalogEndpoint::OllamaCloudTags,
985 CatalogEndpoint::OllamaCloudShow,
986 snapshot,
987 options,
988 OllamaCatalogCodec::cloud(),
989 "ollama_cloud",
990 &[],
991 false,
992 )
993 .await
994}
995
996pub async fn discover_ollama_cloud_with_configured_models(
999 executor: &dyn CatalogHttpExecutor,
1000 resolver: &dyn SecretResolver,
1001 secret_ref: &str,
1002 snapshot: CatalogSnapshotRequest,
1003 options: OllamaDiscoveryOptions,
1004 configured_model_ids: &[String],
1005) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1006 discover_ollama(
1007 executor,
1008 Some(CatalogCredential::Resolver {
1009 resolver,
1010 secret_ref,
1011 }),
1012 CatalogEndpoint::OllamaCloudTags,
1013 CatalogEndpoint::OllamaCloudShow,
1014 snapshot,
1015 options,
1016 OllamaCatalogCodec::cloud(),
1017 "ollama_cloud",
1018 configured_model_ids,
1019 false,
1020 )
1021 .await
1022}
1023
1024pub async fn discover_ollama_cloud_with_resolved_secret_and_configured_models(
1029 executor: &dyn CatalogHttpExecutor,
1030 credential: ResolvedSecret,
1031 snapshot: CatalogSnapshotRequest,
1032 options: OllamaDiscoveryOptions,
1033 configured_model_ids: &[String],
1034) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1035 discover_ollama(
1036 executor,
1037 Some(CatalogCredential::Resolved(&credential)),
1038 CatalogEndpoint::OllamaCloudTags,
1039 CatalogEndpoint::OllamaCloudShow,
1040 snapshot,
1041 options,
1042 OllamaCatalogCodec::cloud(),
1043 "ollama_cloud",
1044 configured_model_ids,
1045 false,
1046 )
1047 .await
1048}
1049
1050pub async fn discover_ollama_cloud_relay(
1054 executor: &dyn CatalogHttpExecutor,
1055 snapshot: CatalogSnapshotRequest,
1056 options: OllamaDiscoveryOptions,
1057) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1058 discover_ollama(
1059 executor,
1060 None,
1061 CatalogEndpoint::LocalOllamaTags,
1062 CatalogEndpoint::LocalOllamaShow,
1063 snapshot,
1064 options,
1065 OllamaCatalogCodec::cloud(),
1066 "ollama_cloud",
1067 &[],
1068 true,
1069 )
1070 .await
1071}
1072
1073pub async fn discover_ollama_cloud_relay_with_configured_models(
1077 executor: &dyn CatalogHttpExecutor,
1078 snapshot: CatalogSnapshotRequest,
1079 options: OllamaDiscoveryOptions,
1080 configured_model_ids: &[String],
1081) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1082 discover_ollama(
1083 executor,
1084 None,
1085 CatalogEndpoint::LocalOllamaTags,
1086 CatalogEndpoint::LocalOllamaShow,
1087 snapshot,
1088 options,
1089 OllamaCatalogCodec::cloud(),
1090 "ollama_cloud",
1091 configured_model_ids,
1092 true,
1093 )
1094 .await
1095}
1096
1097pub async fn discover_local_ollama(
1098 executor: &dyn CatalogHttpExecutor,
1099 snapshot: CatalogSnapshotRequest,
1100 options: OllamaDiscoveryOptions,
1101) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1102 discover_ollama(
1103 executor,
1104 None,
1105 CatalogEndpoint::LocalOllamaTags,
1106 CatalogEndpoint::LocalOllamaShow,
1107 snapshot,
1108 options,
1109 OllamaCatalogCodec::local(),
1110 "ollama_local",
1111 &[],
1112 false,
1113 )
1114 .await
1115}
1116
1117#[allow(clippy::too_many_arguments)]
1118async fn discover_ollama(
1119 executor: &dyn CatalogHttpExecutor,
1120 credential: Option<CatalogCredential<'_>>,
1121 tags_endpoint: CatalogEndpoint,
1122 show_endpoint: CatalogEndpoint,
1123 snapshot: CatalogSnapshotRequest,
1124 options: OllamaDiscoveryOptions,
1125 codec: OllamaCatalogCodec,
1126 provider_id: &str,
1127 configured_model_ids: &[String],
1128 configured_ids_require_cloud_suffix: bool,
1129) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1130 if options.max_show_enrichments > MAX_SHOW_ENRICHMENTS
1131 || configured_model_ids.len() > MAX_SHOW_ENRICHMENTS
1132 || configured_model_ids
1133 .len()
1134 .saturating_add(options.max_show_enrichments)
1135 > MAX_SHOW_ENRICHMENTS
1136 {
1137 return Err(CatalogDiscoveryError::InvalidRequest);
1138 }
1139 let mut configured = HashSet::with_capacity(configured_model_ids.len());
1140 if configured_model_ids.iter().any(|model_id| {
1141 !valid_model_id(model_id)
1142 || !configured.insert(model_id.as_str())
1143 || (cloud_base_model_id(model_id).is_some() != configured_ids_require_cloud_suffix)
1144 }) {
1145 return Err(CatalogDiscoveryError::InvalidRequest);
1146 }
1147 let tags_request = catalog_request(tags_endpoint, None, credential)?;
1148 let tags_response = execute_catalog(executor, tags_request).await?;
1149 validate_success_response(tags_endpoint, &tags_response)?;
1150 let mut receipt_bodies = vec![tags_response.body.clone()];
1151 let mut native = decode_ollama_tags(&tags_response.body)?;
1152 native.models.retain(|model| codec.includes(model));
1153 let mut probed = HashSet::new();
1154 for model_id in configured_model_ids {
1155 if native.models.iter().any(|model| model.id == *model_id) {
1156 continue;
1157 }
1158 let show_request = catalog_request(show_endpoint, Some(model_id), credential)?;
1159 let show_response = execute_catalog(executor, show_request).await?;
1160 validate_success_response(show_endpoint, &show_response)?;
1161 let mut model = OllamaNativeModel {
1162 id: model_id.clone(),
1163 location: OllamaModelLocation::Cloud,
1164 context_tokens: 1,
1165 modalities: vec!["text".to_owned()],
1166 tool_support: false,
1167 reasoning_modes: Vec::new(),
1168 };
1169 apply_ollama_show(&mut model, &show_response.body)?;
1170 native.models.push(model);
1171 probed.insert(model_id.as_str());
1172 receipt_bodies.push(show_response.body);
1173 }
1174 native.models.sort_by(|left, right| left.id.cmp(&right.id));
1175 if native.models.is_empty() {
1176 return Err(CatalogDiscoveryError::SnapshotRejected);
1177 }
1178 let mut remaining_enrichments = options.max_show_enrichments;
1179 for model in &mut native.models {
1180 if remaining_enrichments == 0 {
1181 break;
1182 }
1183 if probed.contains(model.id.as_str()) {
1184 continue;
1185 }
1186 let show_request = catalog_request(show_endpoint, Some(&model.id), credential)?;
1187 let show_response = execute_catalog(executor, show_request).await?;
1188 validate_success_response(show_endpoint, &show_response)?;
1189 apply_ollama_show(model, &show_response.body)?;
1190 receipt_bodies.push(show_response.body);
1191 remaining_enrichments -= 1;
1192 }
1193 let models = codec
1194 .normalize(&native, snapshot.observed_at_epoch_seconds)
1195 .map_err(|_| CatalogDiscoveryError::Malformed)?;
1196 accept_snapshot(
1197 snapshot,
1198 provider_id,
1199 codec.source(),
1200 receipt_for(&receipt_bodies),
1201 models,
1202 )
1203}
1204
1205#[derive(Clone, Copy)]
1206enum CatalogCredential<'a> {
1207 Resolver {
1208 resolver: &'a dyn SecretResolver,
1209 secret_ref: &'a str,
1210 },
1211 Resolved(&'a ResolvedSecret),
1212}
1213
1214pub async fn discover_openrouter(
1215 executor: &dyn CatalogHttpExecutor,
1216 credential: Option<(&dyn SecretResolver, &str)>,
1217 snapshot: CatalogSnapshotRequest,
1218) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1219 let (response, authenticated) = if let Some(credential) = credential {
1220 let request = catalog_request(
1221 CatalogEndpoint::OpenRouterUserModels,
1222 None,
1223 Some(CatalogCredential::Resolver {
1224 resolver: credential.0,
1225 secret_ref: credential.1,
1226 }),
1227 )?;
1228 let response = execute_catalog(executor, request).await?;
1229 if matches!(response.status, 401 | 403 | 404) {
1230 let public = catalog_request(CatalogEndpoint::OpenRouterPublicModels, None, None)?;
1231 (execute_catalog(executor, public).await?, false)
1232 } else {
1233 (response, true)
1234 }
1235 } else {
1236 let public = catalog_request(CatalogEndpoint::OpenRouterPublicModels, None, None)?;
1237 (execute_catalog(executor, public).await?, false)
1238 };
1239 let endpoint = if authenticated {
1240 CatalogEndpoint::OpenRouterUserModels
1241 } else {
1242 CatalogEndpoint::OpenRouterPublicModels
1243 };
1244 validate_success_response(endpoint, &response)?;
1245 let native = decode_openrouter(&response.body, authenticated)?;
1246 let models = OpenRouterCatalogCodec
1247 .normalize(&native, snapshot.observed_at_epoch_seconds)
1248 .map_err(|_| {
1249 CatalogDiscoveryError::OpenRouterMalformed(OpenRouterMalformedStage::Normalization)
1250 })?;
1251 accept_snapshot(
1252 snapshot,
1253 "openrouter",
1254 CatalogSourceKind::OpenrouterCatalog,
1255 receipt_for(&[response.body]),
1256 models,
1257 )
1258}
1259
1260pub async fn discover_openrouter_authenticated(
1263 executor: &dyn CatalogHttpExecutor,
1264 resolver: &dyn SecretResolver,
1265 secret_ref: &str,
1266 snapshot: CatalogSnapshotRequest,
1267) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1268 let request = catalog_request(
1269 CatalogEndpoint::OpenRouterUserModels,
1270 None,
1271 Some(CatalogCredential::Resolver {
1272 resolver,
1273 secret_ref,
1274 }),
1275 )?;
1276 let response = execute_catalog(executor, request).await?;
1277 validate_success_response(CatalogEndpoint::OpenRouterUserModels, &response)?;
1278 let native = decode_openrouter(&response.body, true)?;
1279 let models = OpenRouterCatalogCodec
1280 .normalize(&native, snapshot.observed_at_epoch_seconds)
1281 .map_err(|_| {
1282 CatalogDiscoveryError::OpenRouterMalformed(OpenRouterMalformedStage::Normalization)
1283 })?;
1284 accept_snapshot(
1285 snapshot,
1286 "openrouter",
1287 CatalogSourceKind::OpenrouterCatalog,
1288 receipt_for(&[response.body]),
1289 models,
1290 )
1291}
1292
1293pub async fn discover_openrouter_authenticated_with_configured_models(
1299 executor: &dyn CatalogHttpExecutor,
1300 resolver: &dyn SecretResolver,
1301 secret_ref: &str,
1302 snapshot: CatalogSnapshotRequest,
1303 configured_model_ids: &[String],
1304) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1305 let credential = resolver
1306 .resolve(secret_ref)
1307 .map_err(|_: SecretResolutionError| CatalogDiscoveryError::SecretResolution)?;
1308 discover_openrouter_authenticated_with_resolved_secret_and_configured_models(
1309 executor,
1310 credential,
1311 snapshot,
1312 configured_model_ids,
1313 )
1314 .await
1315}
1316
1317pub async fn discover_openrouter_authenticated_with_resolved_secret_and_configured_models(
1324 executor: &dyn CatalogHttpExecutor,
1325 credential: ResolvedSecret,
1326 snapshot: CatalogSnapshotRequest,
1327 configured_model_ids: &[String],
1328) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1329 if configured_model_ids.is_empty()
1330 || configured_model_ids.len() > MAX_CATALOG_MODELS
1331 || configured_model_ids.iter().any(|id| !valid_model_id(id))
1332 {
1333 return Err(CatalogDiscoveryError::InvalidRequest);
1334 }
1335 let configured = configured_model_ids
1336 .iter()
1337 .map(String::as_str)
1338 .collect::<HashSet<_>>();
1339 if configured.len() != configured_model_ids.len() {
1340 return Err(CatalogDiscoveryError::InvalidRequest);
1341 }
1342 let request = catalog_request_with_resolved_secret(
1343 CatalogEndpoint::OpenRouterUserModels,
1344 None,
1345 Some(credential),
1346 )?;
1347 let response = execute_catalog(executor, request).await?;
1348 validate_success_response(CatalogEndpoint::OpenRouterUserModels, &response)?;
1349 let native = decode_openrouter_configured(&response.body, true, &configured)?;
1350 let models = OpenRouterCatalogCodec
1351 .normalize(&native, snapshot.observed_at_epoch_seconds)
1352 .map_err(|_| {
1353 CatalogDiscoveryError::OpenRouterMalformed(OpenRouterMalformedStage::Normalization)
1354 })?;
1355 accept_snapshot(
1356 snapshot,
1357 "openrouter",
1358 CatalogSourceKind::OpenrouterCatalog,
1359 receipt_for(&[response.body]),
1360 models,
1361 )
1362}
1363
1364fn accept_snapshot(
1365 snapshot: CatalogSnapshotRequest,
1366 provider_id: &str,
1367 source: CatalogSourceKind,
1368 receipt: String,
1369 models: Vec<NormalizedModelSpec>,
1370) -> Result<CatalogSnapshot, CatalogDiscoveryError> {
1371 CatalogSnapshot::accept(
1372 snapshot.snapshot_id,
1373 provider_id,
1374 snapshot.catalog_version,
1375 source,
1376 receipt,
1377 snapshot.observed_at_epoch_seconds,
1378 models,
1379 )
1380 .map_err(|_| CatalogDiscoveryError::SnapshotRejected)
1381}
1382
1383fn catalog_request(
1384 endpoint: CatalogEndpoint,
1385 model: Option<&str>,
1386 credential: Option<CatalogCredential<'_>>,
1387) -> Result<CatalogHttpRequest, CatalogDiscoveryError> {
1388 let credential = match (endpoint.requires_authorization(), credential) {
1389 (
1390 true,
1391 Some(CatalogCredential::Resolver {
1392 resolver,
1393 secret_ref,
1394 }),
1395 ) => Some(
1396 resolver
1397 .resolve(secret_ref)
1398 .map_err(|_: SecretResolutionError| CatalogDiscoveryError::SecretResolution)?,
1399 ),
1400 (true, Some(CatalogCredential::Resolved(secret))) => Some(
1401 ResolvedSecret::new(secret.expose_for_dispatch().to_vec())
1402 .map_err(|_| CatalogDiscoveryError::SecretResolution)?,
1403 ),
1404 (false, None) => None,
1405 _ => return Err(CatalogDiscoveryError::InvalidRequest),
1406 };
1407 catalog_request_with_resolved_secret(endpoint, model, credential)
1408}
1409
1410fn catalog_request_with_resolved_secret(
1411 endpoint: CatalogEndpoint,
1412 model: Option<&str>,
1413 credential: Option<ResolvedSecret>,
1414) -> Result<CatalogHttpRequest, CatalogDiscoveryError> {
1415 let body = if matches!(
1416 endpoint,
1417 CatalogEndpoint::OllamaCloudShow | CatalogEndpoint::LocalOllamaShow
1418 ) {
1419 let model = model
1420 .filter(|value| valid_model_id(value))
1421 .ok_or(CatalogDiscoveryError::InvalidRequest)?;
1422 serde_json::to_vec(&json!({"model": model}))
1423 .map_err(|_| CatalogDiscoveryError::InvalidRequest)?
1424 } else {
1425 if model.is_some() {
1426 return Err(CatalogDiscoveryError::InvalidRequest);
1427 }
1428 Vec::new()
1429 };
1430 let custom = matches!(
1435 endpoint,
1436 CatalogEndpoint::AnthropicModels | CatalogEndpoint::GeminiModels
1437 );
1438 let authorization = match (endpoint.requires_authorization(), credential) {
1439 (true, Some(secret)) => Some(if custom {
1440 secret
1441 } else {
1442 bearer_secret(secret)?
1443 }),
1444 (false, None) => None,
1445 _ => return Err(CatalogDiscoveryError::InvalidRequest),
1446 };
1447 let auth_header_name = match endpoint {
1448 CatalogEndpoint::AnthropicModels => Some(router_protocol::ANTHROPIC_API_KEY_HEADER),
1449 CatalogEndpoint::GeminiModels => Some(router_protocol::GEMINI_API_KEY_HEADER),
1450 _ => None,
1451 };
1452 let version_header = match endpoint {
1453 CatalogEndpoint::AnthropicModels => Some((
1454 router_protocol::ANTHROPIC_VERSION_HEADER,
1455 router_protocol::ANTHROPIC_VERSION,
1456 )),
1457 _ => None,
1458 };
1459 Ok(CatalogHttpRequest {
1460 endpoint,
1461 body,
1462 authorization,
1463 auth_header_name,
1464 version_header,
1465 })
1466}
1467
1468fn bearer_secret(secret: ResolvedSecret) -> Result<ResolvedSecret, CatalogDiscoveryError> {
1469 let mut value = Vec::with_capacity(7usize.saturating_add(secret.expose_for_dispatch().len()));
1470 value.extend_from_slice(b"Bearer ");
1471 value.extend_from_slice(secret.expose_for_dispatch());
1472 ResolvedSecret::new(value).map_err(|_| CatalogDiscoveryError::SecretResolution)
1473}
1474
1475async fn execute_catalog(
1476 executor: &dyn CatalogHttpExecutor,
1477 request: CatalogHttpRequest,
1478) -> Result<CatalogHttpResponse, CatalogDiscoveryError> {
1479 execute_with_absolute_deadline(executor, request, CATALOG_REQUEST_TIMEOUT).await
1482}
1483
1484async fn execute_with_absolute_deadline(
1485 executor: &dyn CatalogHttpExecutor,
1486 request: CatalogHttpRequest,
1487 deadline: Duration,
1488) -> Result<CatalogHttpResponse, CatalogDiscoveryError> {
1489 let endpoint = request.endpoint;
1490 let response = tokio::time::timeout(deadline, executor.execute(request))
1491 .await
1492 .map_err(|_| CatalogDiscoveryError::Timeout)?
1493 .map_err(map_http_error)?;
1494 if response.body.len() > endpoint.response_limit() {
1495 return Err(CatalogDiscoveryError::ResponseBounds);
1496 }
1497 Ok(response)
1498}
1499
1500fn map_http_error(error: CatalogHttpError) -> CatalogDiscoveryError {
1501 match error.kind {
1502 CatalogHttpErrorKind::Timeout => CatalogDiscoveryError::Timeout,
1503 CatalogHttpErrorKind::ResponseTooLarge => CatalogDiscoveryError::ResponseBounds,
1504 CatalogHttpErrorKind::Cancelled
1505 | CatalogHttpErrorKind::Transport
1506 | CatalogHttpErrorKind::InvalidRequest => CatalogDiscoveryError::Transport,
1507 }
1508}
1509
1510fn validate_catalog_request(request: &CatalogHttpRequest) -> Result<(), CatalogHttpError> {
1511 let auth_present = request.authorization.is_some();
1512 let body_valid = if request.method() == "POST" {
1513 !request.body.is_empty() && request.body.len() <= MAX_REQUEST_BODY_BYTES
1514 } else {
1515 request.body.is_empty()
1516 };
1517 if !body_valid
1518 || request.endpoint.requires_authorization() != auth_present
1519 || (!request.endpoint.allows_authorization() && auth_present)
1520 {
1521 return Err(catalog_http_error(CatalogHttpErrorKind::InvalidRequest));
1522 }
1523 Ok(())
1524}
1525
1526fn validate_success_response(
1527 endpoint: CatalogEndpoint,
1528 response: &CatalogHttpResponse,
1529) -> Result<(), CatalogDiscoveryError> {
1530 if response.body.len() > endpoint.response_limit() {
1531 return Err(CatalogDiscoveryError::ResponseBounds);
1532 }
1533 if response.redirected || response.status != 200 {
1534 return Err(CatalogDiscoveryError::HttpResponse);
1535 }
1536 if !response.content_type.as_deref().is_some_and(|value| {
1537 value
1538 .split(';')
1539 .next()
1540 .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"))
1541 }) {
1542 return Err(CatalogDiscoveryError::HttpResponse);
1543 }
1544 Ok(())
1545}
1546
1547#[derive(Deserialize)]
1548struct OllamaTagModel {
1549 #[serde(default)]
1550 name: String,
1551 #[serde(default)]
1552 model: String,
1553 #[serde(default)]
1554 remote_model: Option<String>,
1555 #[serde(default)]
1556 remote_host: Option<String>,
1557}
1558
1559fn decode_ollama_tags(bytes: &[u8]) -> Result<OllamaNativeCatalog, CatalogDiscoveryError> {
1560 let root: Value =
1561 serde_json::from_slice(bytes).map_err(|_| CatalogDiscoveryError::Malformed)?;
1562 let values = root
1563 .get("models")
1564 .and_then(Value::as_array)
1565 .ok_or(CatalogDiscoveryError::Malformed)?;
1566 if values.is_empty() || values.len() > MAX_CATALOG_MODELS {
1567 return Err(CatalogDiscoveryError::ResponseBounds);
1568 }
1569 let mut seen = HashSet::with_capacity(values.len());
1570 let mut models = Vec::with_capacity(values.len());
1571 for value in values {
1572 let item: OllamaTagModel =
1573 serde_json::from_value(value.clone()).map_err(|_| CatalogDiscoveryError::Malformed)?;
1574 let id = if item.model.is_empty() {
1575 item.name
1576 } else {
1577 item.model
1578 };
1579 if !valid_model_id(&id) || !seen.insert(id.clone()) {
1580 return Err(CatalogDiscoveryError::Malformed);
1581 }
1582 let location = match (item.remote_model.as_deref(), item.remote_host.as_deref()) {
1583 (Some(remote_model), Some(remote_host))
1584 if cloud_base_model_id(&id) == Some(remote_model)
1585 && valid_ollama_remote_host(remote_host) =>
1586 {
1587 OllamaModelLocation::Cloud
1588 }
1589 (None, None) if cloud_base_model_id(&id).is_none() => OllamaModelLocation::Local,
1590 _ => return Err(CatalogDiscoveryError::Malformed),
1591 };
1592 models.push(OllamaNativeModel {
1593 id,
1594 location,
1595 context_tokens: 1,
1598 modalities: vec!["text".to_owned()],
1599 tool_support: false,
1600 reasoning_modes: Vec::new(),
1601 });
1602 }
1603 Ok(OllamaNativeCatalog { models })
1604}
1605
1606fn apply_ollama_show(
1607 model: &mut OllamaNativeModel,
1608 bytes: &[u8],
1609) -> Result<(), CatalogDiscoveryError> {
1610 let root: Value =
1611 serde_json::from_slice(bytes).map_err(|_| CatalogDiscoveryError::Malformed)?;
1612 let capabilities = root
1613 .get("capabilities")
1614 .and_then(Value::as_array)
1615 .map(|values| {
1616 values
1617 .iter()
1618 .filter_map(Value::as_str)
1619 .collect::<BTreeSet<_>>()
1620 })
1621 .unwrap_or_default();
1622 let model_info = root
1623 .get("model_info")
1624 .and_then(Value::as_object)
1625 .ok_or(CatalogDiscoveryError::Malformed)?;
1626 let context = context_length(model_info).ok_or(CatalogDiscoveryError::Malformed)?;
1627 model.context_tokens = context;
1628 model.tool_support = capabilities.contains("tools");
1629 if capabilities.contains("vision") {
1630 model.modalities.push("image".to_owned());
1631 }
1632 if capabilities.contains("thinking") {
1633 model.reasoning_modes.push("reasoning".to_owned());
1634 }
1635 model.modalities.sort();
1636 model.modalities.dedup();
1637 model.reasoning_modes.sort();
1638 model.reasoning_modes.dedup();
1639 Ok(())
1640}
1641
1642fn context_length(model_info: &Map<String, Value>) -> Option<u64> {
1643 model_info
1644 .iter()
1645 .filter(|(key, _)| key.ends_with(".context_length"))
1646 .filter_map(|(_, value)| value.as_u64())
1647 .filter(|value| *value > 0)
1648 .max()
1649}
1650
1651fn decode_openrouter(
1652 bytes: &[u8],
1653 authenticated: bool,
1654) -> Result<OpenRouterNativeCatalog, CatalogDiscoveryError> {
1655 decode_openrouter_selected(bytes, authenticated, None)
1656}
1657
1658fn decode_openrouter_configured(
1659 bytes: &[u8],
1660 authenticated: bool,
1661 configured: &HashSet<&str>,
1662) -> Result<OpenRouterNativeCatalog, CatalogDiscoveryError> {
1663 decode_openrouter_selected(bytes, authenticated, Some(configured))
1664}
1665
1666fn decode_openrouter_selected(
1667 bytes: &[u8],
1668 authenticated: bool,
1669 configured: Option<&HashSet<&str>>,
1670) -> Result<OpenRouterNativeCatalog, CatalogDiscoveryError> {
1671 let root: Value = serde_json::from_slice(bytes)
1672 .map_err(|_| CatalogDiscoveryError::OpenRouterMalformed(OpenRouterMalformedStage::Json))?;
1673 let values = root.get("data").and_then(Value::as_array).ok_or(
1674 CatalogDiscoveryError::OpenRouterMalformed(OpenRouterMalformedStage::DataEnvelope),
1675 )?;
1676 if values.is_empty() || values.len() > MAX_CATALOG_MODELS {
1677 return Err(CatalogDiscoveryError::ResponseBounds);
1678 }
1679 let mut models = Vec::with_capacity(values.len());
1680 let mut seen = HashSet::with_capacity(values.len());
1681 for value in values {
1682 let Some(object) = value.as_object() else {
1683 if configured.is_some() {
1684 continue;
1685 }
1686 return Err(CatalogDiscoveryError::OpenRouterMalformed(
1687 OpenRouterMalformedStage::ModelObject,
1688 ));
1689 };
1690 let Some(id) = object.get("id").and_then(Value::as_str) else {
1691 if configured.is_some() {
1692 continue;
1693 }
1694 return Err(CatalogDiscoveryError::OpenRouterMalformed(
1695 OpenRouterMalformedStage::ModelId,
1696 ));
1697 };
1698 if configured.is_some_and(|wanted| !wanted.contains(id)) {
1699 continue;
1700 }
1701 if !valid_model_id(id) {
1702 return Err(CatalogDiscoveryError::OpenRouterMalformed(
1703 OpenRouterMalformedStage::ModelId,
1704 ));
1705 }
1706 let context_tokens = object
1707 .get("context_length")
1708 .and_then(Value::as_u64)
1709 .filter(|value| *value > 0)
1710 .ok_or(CatalogDiscoveryError::OpenRouterMalformed(
1711 OpenRouterMalformedStage::ContextLength,
1712 ))?;
1713 if !seen.insert(id.to_owned()) {
1714 return Err(CatalogDiscoveryError::OpenRouterMalformed(
1715 OpenRouterMalformedStage::DuplicateModelId,
1716 ));
1717 }
1718 let supported = object
1719 .get("supported_parameters")
1720 .and_then(Value::as_array)
1721 .map(|values| {
1722 values
1723 .iter()
1724 .filter_map(Value::as_str)
1725 .collect::<BTreeSet<_>>()
1726 })
1727 .unwrap_or_default();
1728 let mut modalities = object
1729 .get("architecture")
1730 .and_then(Value::as_object)
1731 .and_then(|architecture| architecture.get("input_modalities"))
1732 .and_then(Value::as_array)
1733 .map(|values| {
1734 values
1735 .iter()
1736 .filter_map(Value::as_str)
1737 .filter(|value| valid_token(value))
1738 .map(str::to_owned)
1739 .collect::<Vec<_>>()
1740 })
1741 .unwrap_or_else(|| vec!["text".to_owned()]);
1742 modalities.sort();
1743 modalities.dedup();
1744 if modalities.is_empty() {
1745 return Err(CatalogDiscoveryError::OpenRouterMalformed(
1746 OpenRouterMalformedStage::Modalities,
1747 ));
1748 }
1749 let mut reasoning_modes = Vec::new();
1750 if supported.contains("reasoning") || supported.contains("include_reasoning") {
1751 reasoning_modes.push("reasoning".to_owned());
1752 }
1753 models.push(OpenRouterNativeModel {
1754 id: id.to_owned(),
1755 context_tokens,
1756 modalities,
1757 tool_support: supported.contains("tools"),
1758 reasoning_modes,
1759 });
1760 }
1761 if configured.is_some_and(|wanted| seen.len() != wanted.len()) {
1762 return Err(CatalogDiscoveryError::OpenRouterMalformed(
1763 OpenRouterMalformedStage::MissingConfiguredModel,
1764 ));
1765 }
1766 models.sort_by(|left, right| left.id.cmp(&right.id));
1767 Ok(OpenRouterNativeCatalog {
1768 models,
1769 authenticated,
1770 })
1771}
1772
1773fn receipt_for(bodies: &[Vec<u8>]) -> String {
1774 let mut digest = Sha256::new();
1775 for body in bodies {
1776 digest.update((body.len() as u64).to_be_bytes());
1777 digest.update(body);
1778 }
1779 let digest = digest.finalize();
1780 let encoded = digest
1781 .iter()
1782 .map(|byte| format!("{byte:02x}"))
1783 .collect::<String>();
1784 format!("receipt:sha256:{encoded}")
1785}
1786
1787fn valid_model_id(value: &str) -> bool {
1788 !value.is_empty()
1789 && value.len() <= 256
1790 && !value.starts_with('/')
1791 && !value.ends_with('/')
1792 && !value.contains("..")
1793 && !value.contains("//")
1794 && value.bytes().all(|byte| {
1795 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
1796 })
1797}
1798
1799fn cloud_base_model_id(value: &str) -> Option<&str> {
1800 let base = value
1801 .strip_suffix(":cloud")
1802 .or_else(|| value.strip_suffix("-cloud"))?;
1803 (!base.is_empty()
1804 && valid_model_id(base)
1805 && !base.ends_with(":cloud")
1806 && !base.ends_with("-cloud"))
1807 .then_some(base)
1808}
1809
1810fn valid_ollama_remote_host(value: &str) -> bool {
1811 matches!(value, "https://ollama.com" | "https://ollama.com:443")
1812}
1813
1814fn valid_token(value: &str) -> bool {
1815 !value.is_empty()
1816 && value.len() <= 64
1817 && value
1818 .bytes()
1819 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1820}
1821
1822const fn catalog_http_error(kind: CatalogHttpErrorKind) -> CatalogHttpError {
1823 CatalogHttpError { kind }
1824}
1825
1826#[cfg(test)]
1827mod tests {
1828 use super::*;
1829 use std::collections::VecDeque;
1830 use std::sync::{Arc, Mutex};
1831
1832 #[derive(Clone, Debug, PartialEq, Eq)]
1833 struct Observation {
1834 endpoint: CatalogEndpoint,
1835 authorized: bool,
1836 body: Vec<u8>,
1837 auth_header_name: Option<&'static str>,
1838 version_header: Option<(&'static str, &'static str)>,
1839 debug: String,
1840 }
1841
1842 struct FakeExecutor {
1843 responses: Mutex<VecDeque<Result<CatalogHttpResponse, CatalogHttpError>>>,
1844 observations: Arc<Mutex<Vec<Observation>>>,
1845 }
1846
1847 impl CatalogHttpExecutor for FakeExecutor {
1848 fn execute<'a>(&'a self, request: CatalogHttpRequest) -> CatalogHttpFuture<'a> {
1849 let response = self
1850 .responses
1851 .lock()
1852 .expect("response queue")
1853 .pop_front()
1854 .expect("one response per request");
1855 self.observations
1856 .lock()
1857 .expect("observations")
1858 .push(Observation {
1859 endpoint: request.endpoint(),
1860 authorized: request.authorization_for_dispatch().is_some(),
1861 body: request.body_for_dispatch().to_vec(),
1862 auth_header_name: request.auth_header_name,
1863 version_header: request.version_header,
1864 debug: format!("{request:?}"),
1865 });
1866 Box::pin(async move { response })
1867 }
1868 }
1869
1870 struct NeverCompletesExecutor;
1871
1872 impl CatalogHttpExecutor for NeverCompletesExecutor {
1873 fn execute<'a>(&'a self, _request: CatalogHttpRequest) -> CatalogHttpFuture<'a> {
1874 Box::pin(async move {
1875 tokio::time::sleep(CATALOG_REQUEST_TIMEOUT.saturating_mul(100)).await;
1876 unreachable!("the absolute catalog deadline must cancel this executor")
1877 })
1878 }
1879 }
1880
1881 #[derive(Default)]
1882 struct FixtureResolver;
1883
1884 impl SecretResolver for FixtureResolver {
1885 fn resolve(&self, _secret_ref: &str) -> Result<ResolvedSecret, SecretResolutionError> {
1886 ResolvedSecret::new(b"catalog-secret".to_vec())
1887 }
1888 }
1889
1890 fn response(body: Value) -> Result<CatalogHttpResponse, CatalogHttpError> {
1891 Ok(CatalogHttpResponse {
1892 status: 200,
1893 content_type: Some("application/json; charset=utf-8".to_owned()),
1894 redirected: false,
1895 body: serde_json::to_vec(&body).expect("fixture JSON"),
1896 })
1897 }
1898
1899 fn snapshot(id: &str) -> CatalogSnapshotRequest {
1900 CatalogSnapshotRequest {
1901 snapshot_id: id.to_owned(),
1902 catalog_version: "catalog.1".to_owned(),
1903 observed_at_epoch_seconds: 100,
1904 }
1905 }
1906
1907 #[tokio::test]
1908 async fn cloud_tags_and_bounded_show_normalize_deterministically_without_secret_output() {
1909 let observations = Arc::new(Mutex::new(Vec::new()));
1910 let executor = FakeExecutor {
1911 responses: Mutex::new(VecDeque::from([
1912 response(json!({"models": [
1913 {"model": "zeta:cloud", "remote_model": "zeta", "remote_host": "https://ollama.com"},
1914 {"model": "alpha:cloud", "remote_model": "alpha", "remote_host": "https://ollama.com:443"}
1915 ]})),
1916 response(
1917 json!({"capabilities": ["tools", "thinking", "vision"], "model_info": {"fixture.context_length": 65536}}),
1918 ),
1919 ])),
1920 observations: Arc::clone(&observations),
1921 };
1922 let first = discover_ollama_cloud(
1923 &executor,
1924 &FixtureResolver,
1925 "secret:fixture",
1926 snapshot("snapshot.cloud.1"),
1927 OllamaDiscoveryOptions {
1928 max_show_enrichments: 1,
1929 },
1930 )
1931 .await
1932 .expect("cloud catalog");
1933 assert_eq!(
1934 first.model("alpha:cloud").expect("alpha").context_tokens,
1935 65_536
1936 );
1937 assert_eq!(first.model("zeta:cloud").expect("zeta").context_tokens, 1);
1938 assert!(first.model("alpha:cloud").expect("alpha").tool_support);
1939 let observed = observations.lock().expect("observations");
1940 assert_eq!(observed.len(), 2);
1941 assert!(observed.iter().all(|item| item.authorized));
1942 assert_eq!(observed[0].endpoint, CatalogEndpoint::OllamaCloudTags);
1943 assert_eq!(observed[1].endpoint, CatalogEndpoint::OllamaCloudShow);
1944 assert_eq!(observed[1].body, br#"{"model":"alpha:cloud"}"#);
1945 assert!(
1946 observed
1947 .iter()
1948 .all(|item| !item.debug.contains("catalog-secret"))
1949 );
1950 }
1951
1952 #[tokio::test]
1953 async fn local_catalog_never_authenticates_and_never_requests_more_than_show_bound() {
1954 let observations = Arc::new(Mutex::new(Vec::new()));
1955 let executor = FakeExecutor {
1956 responses: Mutex::new(VecDeque::from([response(json!({
1957 "models": [
1958 {"name": "qwen3-coder:480b-cloud", "remote_model": "qwen3-coder:480b", "remote_host": "https://ollama.com:443"},
1959 {"name": "mistral:latest"}
1960 ]
1961 }))])),
1962 observations: Arc::clone(&observations),
1963 };
1964 let catalog = discover_local_ollama(
1965 &executor,
1966 snapshot("snapshot.local.1"),
1967 OllamaDiscoveryOptions::default(),
1968 )
1969 .await
1970 .expect("local catalog");
1971 assert!(catalog.model("mistral:latest").is_some());
1972 assert!(catalog.model("qwen3-coder:480b-cloud").is_none());
1973 assert!(
1974 observations
1975 .lock()
1976 .expect("observations")
1977 .iter()
1978 .all(|item| !item.authorized)
1979 );
1980 assert_eq!(
1981 discover_local_ollama(
1982 &executor,
1983 snapshot("snapshot.local.2"),
1984 OllamaDiscoveryOptions {
1985 max_show_enrichments: MAX_SHOW_ENRICHMENTS + 1,
1986 },
1987 )
1988 .await,
1989 Err(CatalogDiscoveryError::InvalidRequest)
1990 );
1991 }
1992
1993 #[tokio::test]
1994 async fn deepseek_and_qwen_model_lists_authenticate_and_normalize_conservatively() {
1995 let observations = Arc::new(Mutex::new(Vec::new()));
1996 let executor = FakeExecutor {
1997 responses: Mutex::new(VecDeque::from([
1998 response(json!({"data": [
1999 {"id": "deepseek-v4-flash", "object": "model"},
2000 {"id": "deepseek-v4-pro", "object": "model"}
2001 ]})),
2002 response(json!({"data": [
2003 {"id": "qwen3.6-flash", "object": "model"},
2004 {"id": "qwen3.7-plus", "object": "model"},
2005 {"id": "qwen3.8-max", "object": "model"}
2006 ]})),
2007 ])),
2008 observations: Arc::clone(&observations),
2009 };
2010 let deepseek = discover_deepseek(
2011 &executor,
2012 &FixtureResolver,
2013 "secret:fixture",
2014 snapshot("snapshot.deepseek.1"),
2015 )
2016 .await
2017 .expect("deepseek catalog");
2018 assert_eq!(deepseek.models().len(), 2);
2019 let flash = deepseek.model("deepseek-v4-flash").expect("flash");
2020 assert_eq!(flash.context_tokens, 1);
2021 assert!(!flash.tool_support);
2022 assert_eq!(
2023 serde_json::to_value(&deepseek)
2024 .expect("snapshot JSON")
2025 .get("source")
2026 .and_then(Value::as_str),
2027 Some("deepseek_catalog")
2028 );
2029 let qwen = discover_qwen(
2030 &executor,
2031 &FixtureResolver,
2032 "secret:fixture",
2033 snapshot("snapshot.qwen.1"),
2034 )
2035 .await
2036 .expect("qwen catalog");
2037 assert_eq!(qwen.models().len(), 3);
2038 assert_eq!(
2039 serde_json::to_value(&qwen)
2040 .expect("snapshot JSON")
2041 .get("source")
2042 .and_then(Value::as_str),
2043 Some("qwen_catalog")
2044 );
2045 let observed = observations.lock().expect("observations");
2046 assert_eq!(observed.len(), 2);
2047 assert!(observed.iter().all(|item| item.authorized));
2048 assert_eq!(observed[0].endpoint, CatalogEndpoint::DeepseekModels);
2049 assert_eq!(observed[1].endpoint, CatalogEndpoint::QwenModels);
2050 assert!(
2051 observed
2052 .iter()
2053 .all(|item| !item.debug.contains("catalog-secret"))
2054 );
2055 }
2056
2057 #[tokio::test]
2058 async fn openai_compat_model_lists_fail_closed_on_missing_data_and_duplicates() {
2059 let executor = FakeExecutor {
2060 responses: Mutex::new(VecDeque::from([response(json!({"models": []}))])),
2061 observations: Arc::new(Mutex::new(Vec::new())),
2062 };
2063 assert!(matches!(
2064 discover_deepseek(
2065 &executor,
2066 &FixtureResolver,
2067 "secret:fixture",
2068 snapshot("snapshot.deepseek.2"),
2069 )
2070 .await,
2071 Err(CatalogDiscoveryError::Malformed)
2072 ));
2073 let executor = FakeExecutor {
2074 responses: Mutex::new(VecDeque::from([response(json!({"data": [
2075 {"id": "qwen3.6-flash", "object": "model"},
2076 {"id": "qwen3.6-flash", "object": "model"}
2077 ]}))])),
2078 observations: Arc::new(Mutex::new(Vec::new())),
2079 };
2080 assert!(matches!(
2081 discover_qwen(
2082 &executor,
2083 &FixtureResolver,
2084 "secret:fixture",
2085 snapshot("snapshot.qwen.2"),
2086 )
2087 .await,
2088 Err(CatalogDiscoveryError::Malformed)
2089 ));
2090 }
2091
2092 #[tokio::test]
2093 async fn grok_kimi_and_zai_model_lists_authenticate_and_normalize_conservatively() {
2094 let observations = Arc::new(Mutex::new(Vec::new()));
2095 let executor = FakeExecutor {
2096 responses: Mutex::new(VecDeque::from([
2097 response(json!({"data": [
2098 {"id": "grok-4.5", "object": "model"},
2099 {"id": "grok-4.6", "object": "model"}
2100 ]})),
2101 response(json!({"data": [
2102 {"id": "kimi-k2.7-code", "object": "model"},
2103 {"id": "kimi-k3", "object": "model"}
2104 ]})),
2105 response(json!({"data": [
2106 {"id": "glm-4.7-flash", "object": "model"},
2107 {"id": "glm-5.3", "object": "model"}
2108 ]})),
2109 ])),
2110 observations: Arc::clone(&observations),
2111 };
2112 let grok = discover_xai(
2113 &executor,
2114 &FixtureResolver,
2115 "secret:fixture",
2116 snapshot("snapshot.grok.1"),
2117 )
2118 .await
2119 .expect("grok catalog");
2120 assert_eq!(grok.models().len(), 2);
2121 assert_eq!(grok.provider_id(), "grok");
2122 let flash = grok.model("grok-4.5").expect("grok-4.5");
2123 assert_eq!(flash.context_tokens, 1);
2124 assert!(!flash.tool_support);
2125 assert_eq!(
2126 serde_json::to_value(&grok)
2127 .expect("snapshot JSON")
2128 .get("source")
2129 .and_then(Value::as_str),
2130 Some("grok_catalog")
2131 );
2132 let kimi = discover_moonshot(
2133 &executor,
2134 &FixtureResolver,
2135 "secret:fixture",
2136 snapshot("snapshot.kimi.1"),
2137 )
2138 .await
2139 .expect("kimi catalog");
2140 assert_eq!(kimi.models().len(), 2);
2141 assert_eq!(kimi.provider_id(), "kimi");
2142 assert_eq!(
2143 serde_json::to_value(&kimi)
2144 .expect("snapshot JSON")
2145 .get("source")
2146 .and_then(Value::as_str),
2147 Some("kimi_catalog")
2148 );
2149 let zai = discover_zai(
2150 &executor,
2151 &FixtureResolver,
2152 "secret:fixture",
2153 snapshot("snapshot.zai.1"),
2154 )
2155 .await
2156 .expect("zai catalog");
2157 assert_eq!(zai.models().len(), 2);
2158 assert_eq!(zai.provider_id(), "zai");
2159 assert_eq!(
2160 serde_json::to_value(&zai)
2161 .expect("snapshot JSON")
2162 .get("source")
2163 .and_then(Value::as_str),
2164 Some("zai_catalog")
2165 );
2166 let observed = observations.lock().expect("observations");
2167 assert_eq!(observed.len(), 3);
2168 assert!(observed.iter().all(|item| item.authorized));
2169 assert_eq!(observed[0].endpoint, CatalogEndpoint::XaiModels);
2170 assert_eq!(observed[1].endpoint, CatalogEndpoint::MoonshotModels);
2171 assert_eq!(observed[2].endpoint, CatalogEndpoint::ZaiModels);
2172 assert!(
2173 observed
2174 .iter()
2175 .all(|item| !item.debug.contains("catalog-secret"))
2176 );
2177 }
2178
2179 #[tokio::test]
2180 async fn grok_kimi_and_zai_model_lists_fail_closed_on_missing_data_and_duplicates() {
2181 let executor = FakeExecutor {
2182 responses: Mutex::new(VecDeque::from([response(json!({"models": []}))])),
2183 observations: Arc::new(Mutex::new(Vec::new())),
2184 };
2185 assert!(matches!(
2186 discover_xai(
2187 &executor,
2188 &FixtureResolver,
2189 "secret:fixture",
2190 snapshot("snapshot.grok.2"),
2191 )
2192 .await,
2193 Err(CatalogDiscoveryError::Malformed)
2194 ));
2195 let executor = FakeExecutor {
2196 responses: Mutex::new(VecDeque::from([response(json!({"data": [
2197 {"id": "kimi-k3", "object": "model"},
2198 {"id": "kimi-k3", "object": "model"}
2199 ]}))])),
2200 observations: Arc::new(Mutex::new(Vec::new())),
2201 };
2202 assert!(matches!(
2203 discover_moonshot(
2204 &executor,
2205 &FixtureResolver,
2206 "secret:fixture",
2207 snapshot("snapshot.kimi.2"),
2208 )
2209 .await,
2210 Err(CatalogDiscoveryError::Malformed)
2211 ));
2212 let executor = FakeExecutor {
2213 responses: Mutex::new(VecDeque::from([response(json!({"data": [
2214 {"id": "glm-5.3", "object": "model"},
2215 {"id": "glm-5.3", "object": "model"}
2216 ]}))])),
2217 observations: Arc::new(Mutex::new(Vec::new())),
2218 };
2219 assert!(matches!(
2220 discover_zai(
2221 &executor,
2222 &FixtureResolver,
2223 "secret:fixture",
2224 snapshot("snapshot.zai.2"),
2225 )
2226 .await,
2227 Err(CatalogDiscoveryError::Malformed)
2228 ));
2229 }
2230
2231 #[tokio::test]
2232 async fn claude_and_gemini_model_lists_use_native_api_key_headers_and_strip_gemini_prefix() {
2233 let observations = Arc::new(Mutex::new(Vec::new()));
2234 let executor = FakeExecutor {
2235 responses: Mutex::new(VecDeque::from([
2236 response(json!({"data": [
2237 {"id": "claude-opus-5", "type": "model"},
2238 {"id": "claude-sonnet-5", "type": "model"}
2239 ]})),
2240 response(json!({"models": [
2241 {"name": "models/gemini-3.6-flash", "display_name": "Gemini 3.6 Flash"},
2242 {"name": "models/gemini-3.5-flash-lite", "display_name": "Gemini 3.5 Flash Lite"}
2243 ]})),
2244 ])),
2245 observations: Arc::clone(&observations),
2246 };
2247 let claude = discover_anthropic(
2248 &executor,
2249 &FixtureResolver,
2250 "secret:fixture",
2251 snapshot("snapshot.claude.1"),
2252 )
2253 .await
2254 .expect("claude catalog");
2255 assert_eq!(claude.models().len(), 2);
2256 assert_eq!(claude.provider_id(), "claude");
2257 assert_eq!(
2258 claude
2259 .model("claude-sonnet-5")
2260 .expect("claude-sonnet-5")
2261 .context_tokens,
2262 1
2263 );
2264 assert_eq!(
2265 serde_json::to_value(&claude)
2266 .expect("snapshot JSON")
2267 .get("source")
2268 .and_then(Value::as_str),
2269 Some("claude_catalog")
2270 );
2271 let gemini = discover_gemini(
2272 &executor,
2273 &FixtureResolver,
2274 "secret:fixture",
2275 snapshot("snapshot.gemini.1"),
2276 )
2277 .await
2278 .expect("gemini catalog");
2279 assert_eq!(gemini.models().len(), 2);
2280 assert_eq!(gemini.provider_id(), "gemini");
2281 assert_eq!(
2282 gemini
2283 .model("gemini-3.6-flash")
2284 .expect("gemini-3.6-flash")
2285 .context_tokens,
2286 1
2287 );
2288 assert!(
2289 gemini.model("models/gemini-3.6-flash").is_none(),
2290 "the models/ prefix must be stripped from the catalog id"
2291 );
2292 assert_eq!(
2293 serde_json::to_value(&gemini)
2294 .expect("snapshot JSON")
2295 .get("source")
2296 .and_then(Value::as_str),
2297 Some("gemini_catalog")
2298 );
2299 let observed = observations.lock().expect("observations");
2300 assert_eq!(observed.len(), 2);
2301 assert!(observed.iter().all(|item| item.authorized));
2302 assert_eq!(observed[0].endpoint, CatalogEndpoint::AnthropicModels);
2303 assert_eq!(
2304 observed[0].auth_header_name,
2305 Some(router_protocol::ANTHROPIC_API_KEY_HEADER)
2306 );
2307 assert_eq!(
2308 observed[0].version_header,
2309 Some((
2310 router_protocol::ANTHROPIC_VERSION_HEADER,
2311 router_protocol::ANTHROPIC_VERSION
2312 ))
2313 );
2314 assert_eq!(observed[1].endpoint, CatalogEndpoint::GeminiModels);
2315 assert_eq!(
2316 observed[1].auth_header_name,
2317 Some(router_protocol::GEMINI_API_KEY_HEADER)
2318 );
2319 assert_eq!(observed[1].version_header, None);
2320 assert!(
2321 observed
2322 .iter()
2323 .all(|item| !item.debug.contains("catalog-secret"))
2324 );
2325 }
2326
2327 #[tokio::test]
2328 async fn claude_and_gemini_model_lists_fail_closed_on_duplicates_and_bad_prefix() {
2329 let executor = FakeExecutor {
2330 responses: Mutex::new(VecDeque::from([response(json!({"data": [
2331 {"id": "claude-opus-5", "type": "model"},
2332 {"id": "claude-opus-5", "type": "model"}
2333 ]}))])),
2334 observations: Arc::new(Mutex::new(Vec::new())),
2335 };
2336 assert!(matches!(
2337 discover_anthropic(
2338 &executor,
2339 &FixtureResolver,
2340 "secret:fixture",
2341 snapshot("snapshot.claude.2"),
2342 )
2343 .await,
2344 Err(CatalogDiscoveryError::Malformed)
2345 ));
2346 let executor = FakeExecutor {
2347 responses: Mutex::new(VecDeque::from([response(json!({"models": [
2348 {"name": "gemini-3.6-flash", "display_name": "Gemini"}
2349 ]}))])),
2350 observations: Arc::new(Mutex::new(Vec::new())),
2351 };
2352 assert!(matches!(
2353 discover_gemini(
2354 &executor,
2355 &FixtureResolver,
2356 "secret:fixture",
2357 snapshot("snapshot.gemini.2"),
2358 )
2359 .await,
2360 Err(CatalogDiscoveryError::Malformed)
2361 ));
2362 }
2363
2364 #[tokio::test]
2365 async fn signed_in_local_relay_partitions_actual_cloud_alias_shape_without_api_key() {
2366 let observations = Arc::new(Mutex::new(Vec::new()));
2367 let executor = FakeExecutor {
2368 responses: Mutex::new(VecDeque::from([response(json!({
2369 "models": [
2370 {"name": "qwen3-coder:480b-cloud", "remote_model": "qwen3-coder:480b", "remote_host": "https://ollama.com:443", "size": 382},
2371 {"name": "deepseek-v4-flash:cloud", "remote_model": "deepseek-v4-flash", "remote_host": "https://ollama.com:443", "size": 382},
2372 {"name": "deepseek-v4-pro:cloud", "remote_model": "deepseek-v4-pro", "remote_host": "https://ollama.com", "size": 382},
2373 {"name": "qwen3-coder-next:cloud", "remote_model": "qwen3-coder-next", "remote_host": "https://ollama.com:443", "size": 382},
2374 {"name": "mistral:latest", "size": 4_372_824_384u64}
2375 ]
2376 }))])),
2377 observations: Arc::clone(&observations),
2378 };
2379 let catalog = discover_ollama_cloud_relay(
2380 &executor,
2381 snapshot("snapshot.cloud.relay.1"),
2382 OllamaDiscoveryOptions::default(),
2383 )
2384 .await
2385 .expect("signed-in relay cloud catalog");
2386
2387 assert!(catalog.model("qwen3-coder:480b-cloud").is_some());
2388 assert!(catalog.model("deepseek-v4-flash:cloud").is_some());
2389 assert!(catalog.model("mistral:latest").is_none());
2390 assert_eq!(
2391 serde_json::to_value(&catalog)
2392 .expect("snapshot JSON")
2393 .get("source")
2394 .and_then(Value::as_str),
2395 Some("ollama_cloud_catalog")
2396 );
2397 let observed = observations.lock().expect("observations");
2398 assert_eq!(observed.len(), 1);
2399 assert_eq!(observed[0].endpoint, CatalogEndpoint::LocalOllamaTags);
2400 assert!(!observed[0].authorized);
2401 }
2402
2403 #[tokio::test]
2404 async fn configured_unlisted_cloud_model_is_bounded_and_probed_only_through_relay() {
2405 let observations = Arc::new(Mutex::new(Vec::new()));
2406 let executor = FakeExecutor {
2407 responses: Mutex::new(VecDeque::from([
2408 response(json!({"models": [{"name": "mistral:latest"}]})),
2409 response(json!({
2410 "capabilities": ["thinking"],
2411 "model_info": {"fixture.context_length": 32768}
2412 })),
2413 ])),
2414 observations: Arc::clone(&observations),
2415 };
2416 let catalog = discover_ollama_cloud_relay_with_configured_models(
2417 &executor,
2418 snapshot("snapshot.cloud.relay.configured.1"),
2419 OllamaDiscoveryOptions::default(),
2420 &["glm-5.2:cloud".to_owned()],
2421 )
2422 .await
2423 .expect("configured cloud probe");
2424 let model = catalog.model("glm-5.2:cloud").expect("probed model");
2425 assert_eq!(model.context_tokens, 32_768);
2426 assert_eq!(model.reasoning_modes, ["reasoning"]);
2427 let observed = observations.lock().expect("observations");
2428 assert_eq!(observed.len(), 2);
2429 assert_eq!(observed[0].endpoint, CatalogEndpoint::LocalOllamaTags);
2430 assert_eq!(observed[1].endpoint, CatalogEndpoint::LocalOllamaShow);
2431 assert_eq!(observed[1].body, br#"{"model":"glm-5.2:cloud"}"#);
2432 assert!(observed.iter().all(|item| !item.authorized));
2433 }
2434
2435 #[tokio::test]
2436 async fn configured_cloud_probe_rejects_transport_identity_and_request_count_confusion() {
2437 let executor = FakeExecutor {
2438 responses: Mutex::new(VecDeque::new()),
2439 observations: Arc::new(Mutex::new(Vec::new())),
2440 };
2441 assert_eq!(
2442 discover_ollama_cloud_relay_with_configured_models(
2443 &executor,
2444 snapshot("snapshot.cloud.relay.invalid.1"),
2445 OllamaDiscoveryOptions::default(),
2446 &["glm-5.2".to_owned()],
2447 )
2448 .await,
2449 Err(CatalogDiscoveryError::InvalidRequest)
2450 );
2451 assert_eq!(
2452 discover_ollama_cloud_relay_with_configured_models(
2453 &executor,
2454 snapshot("snapshot.cloud.relay.invalid.2"),
2455 OllamaDiscoveryOptions {
2456 max_show_enrichments: MAX_SHOW_ENRICHMENTS,
2457 },
2458 &["glm-5.2:cloud".to_owned()],
2459 )
2460 .await,
2461 Err(CatalogDiscoveryError::InvalidRequest)
2462 );
2463 }
2464
2465 #[test]
2466 fn cloud_markers_reject_host_and_suffix_confusion() {
2467 let hostile = [
2468 ("model:cloud", "model", "http://ollama.com"),
2469 ("model:cloud", "model", "https://ollama.com:444"),
2470 ("model:cloud", "model", "https://ollama.com/path"),
2471 ("model:cloud", "model", "https://ollama.com?query=1"),
2472 ("model:cloud", "model", "https://ollama.com#fragment"),
2473 ("model:cloud", "model", "https://user@ollama.com"),
2474 ("model:cloud", "model", "https://ollama.com.evil"),
2475 ("model:cloud", "other", "https://ollama.com"),
2476 ("model-cloud", "model:cloud", "https://ollama.com"),
2477 ("model", "model", "https://ollama.com"),
2478 ("model:cloud:cloud", "model:cloud", "https://ollama.com"),
2479 ];
2480 for (id, remote_model, remote_host) in hostile {
2481 let bytes = serde_json::to_vec(&json!({
2482 "models": [{
2483 "name": id,
2484 "remote_model": remote_model,
2485 "remote_host": remote_host
2486 }]
2487 }))
2488 .expect("hostile fixture JSON");
2489 assert_eq!(
2490 decode_ollama_tags(&bytes),
2491 Err(CatalogDiscoveryError::Malformed),
2492 "accepted confused cloud identity for {id} via {remote_host}"
2493 );
2494 }
2495 }
2496
2497 #[tokio::test]
2498 async fn openrouter_user_unauthorized_falls_back_without_forwarding_auth() {
2499 let observations = Arc::new(Mutex::new(Vec::new()));
2500 let executor = FakeExecutor {
2501 responses: Mutex::new(VecDeque::from([
2502 Ok(CatalogHttpResponse {
2503 status: 401,
2504 content_type: Some("application/json".to_owned()),
2505 redirected: false,
2506 body: b"{}".to_vec(),
2507 }),
2508 response(json!({"data": [
2509 {"id": "vendor/z-model", "context_length": 8192, "supported_parameters": []},
2510 {"id": "vendor/a-model", "context_length": 32768, "supported_parameters": ["tools", "reasoning"], "architecture": {"input_modalities": ["text", "image"]}}
2511 ]})),
2512 ])),
2513 observations: Arc::clone(&observations),
2514 };
2515 let catalog = discover_openrouter(
2516 &executor,
2517 Some((&FixtureResolver, "secret:fixture")),
2518 snapshot("snapshot.openrouter.1"),
2519 )
2520 .await
2521 .expect("public fallback catalog");
2522 let selected = catalog.model("vendor/a-model").expect("slash model ID");
2523 assert!(selected.tool_support);
2524 assert_eq!(selected.reasoning_modes, ["reasoning"]);
2525 let observed = observations.lock().expect("observations");
2526 assert_eq!(observed[0].endpoint, CatalogEndpoint::OpenRouterUserModels);
2527 assert!(observed[0].authorized);
2528 assert_eq!(
2529 observed[1].endpoint,
2530 CatalogEndpoint::OpenRouterPublicModels
2531 );
2532 assert!(!observed[1].authorized);
2533 }
2534
2535 #[tokio::test]
2536 async fn strict_authenticated_openrouter_discovery_never_falls_back_public() {
2537 let observations = Arc::new(Mutex::new(Vec::new()));
2538 let executor = FakeExecutor {
2539 responses: Mutex::new(VecDeque::from([Ok(CatalogHttpResponse {
2540 status: 401,
2541 content_type: Some("application/json".to_owned()),
2542 redirected: false,
2543 body: b"{}".to_vec(),
2544 })])),
2545 observations: Arc::clone(&observations),
2546 };
2547 assert_eq!(
2548 discover_openrouter_authenticated(
2549 &executor,
2550 &FixtureResolver,
2551 "secret:fixture",
2552 snapshot("snapshot.openrouter.strict.1"),
2553 )
2554 .await,
2555 Err(CatalogDiscoveryError::HttpResponse)
2556 );
2557 let observed = observations.lock().expect("observations");
2558 assert_eq!(observed.len(), 1);
2559 assert_eq!(observed[0].endpoint, CatalogEndpoint::OpenRouterUserModels);
2560 assert!(observed[0].authorized);
2561 }
2562
2563 #[tokio::test]
2564 async fn openrouter_public_catalog_accepts_observed_367_model_inventory() {
2565 let models = (0..367)
2566 .map(|index| {
2567 json!({
2568 "id": format!("vendor/model-{index}"),
2569 "context_length": 8_192,
2570 "supported_parameters": []
2571 })
2572 })
2573 .collect::<Vec<_>>();
2574 let executor = FakeExecutor {
2575 responses: Mutex::new(VecDeque::from([response(json!({"data": models}))])),
2576 observations: Arc::new(Mutex::new(Vec::new())),
2577 };
2578 let catalog =
2579 discover_openrouter(&executor, None, snapshot("snapshot.openrouter.public.367"))
2580 .await
2581 .expect("observed public inventory fits the bounded snapshot");
2582 assert!(catalog.model("vendor/model-0").is_some());
2583 assert!(catalog.model("vendor/model-366").is_some());
2584 }
2585
2586 #[test]
2587 fn openrouter_malformed_predicates_are_content_free_and_stable() {
2588 let cases = [
2589 (
2590 b"not-json".as_slice(),
2591 OpenRouterMalformedStage::Json,
2592 ),
2593 (br#"{}"#, OpenRouterMalformedStage::DataEnvelope),
2594 (
2595 br#"{"data":[null]}"#,
2596 OpenRouterMalformedStage::ModelObject,
2597 ),
2598 (
2599 br#"{"data":[{"id":"model-id-canary?","context_length":8192}]}"#,
2600 OpenRouterMalformedStage::ModelId,
2601 ),
2602 (
2603 br#"{"data":[{"id":"vendor/model","context_length":0}]}"#,
2604 OpenRouterMalformedStage::ContextLength,
2605 ),
2606 (
2607 br#"{"data":[{"id":"vendor/model","context_length":8192},{"id":"vendor/model","context_length":8192}]}"#,
2608 OpenRouterMalformedStage::DuplicateModelId,
2609 ),
2610 (
2611 br#"{"data":[{"id":"vendor/model","context_length":8192,"architecture":{"input_modalities":["modalities-canary!"]}}]}"#,
2612 OpenRouterMalformedStage::Modalities,
2613 ),
2614 ];
2615
2616 for (body, expected_stage) in cases {
2617 let error = decode_openrouter(body, true).expect_err("malformed fixture");
2618 assert_eq!(
2619 error,
2620 CatalogDiscoveryError::OpenRouterMalformed(expected_stage)
2621 );
2622 let diagnostic = format!("{error:?} {error}");
2623 assert!(!diagnostic.contains("model-id-canary"));
2624 assert!(!diagnostic.contains("modalities-canary"));
2625 assert!(!diagnostic.contains("not-json"));
2626 }
2627 }
2628
2629 #[test]
2630 fn openrouter_normalization_diagnostic_is_content_free() {
2631 let error =
2632 CatalogDiscoveryError::OpenRouterMalformed(OpenRouterMalformedStage::Normalization);
2633 assert_eq!(format!("{error:?}"), "OpenRouterMalformed(Normalization)");
2634 assert_eq!(
2635 error.to_string(),
2636 "OpenRouter catalog discovery was malformed"
2637 );
2638 }
2639
2640 #[tokio::test]
2641 async fn openrouter_authenticated_catalog_accepts_observed_340_model_inventory() {
2642 let models = (0..340)
2643 .map(|index| {
2644 json!({
2645 "id": format!("vendor/model-{index}"),
2646 "context_length": 8_192,
2647 "supported_parameters": []
2648 })
2649 })
2650 .collect::<Vec<_>>();
2651 let executor = FakeExecutor {
2652 responses: Mutex::new(VecDeque::from([response(json!({"data": models}))])),
2653 observations: Arc::new(Mutex::new(Vec::new())),
2654 };
2655 let catalog = discover_openrouter_authenticated(
2656 &executor,
2657 &FixtureResolver,
2658 "secret:fixture",
2659 snapshot("snapshot.openrouter.authenticated.340"),
2660 )
2661 .await
2662 .expect("observed authenticated inventory fits the bounded snapshot");
2663 assert_eq!(catalog.models().len(), 340);
2664 }
2665
2666 #[tokio::test]
2667 async fn configured_openrouter_ignores_malformed_unconfigured_rows_only() {
2668 let executor = FakeExecutor {
2669 responses: Mutex::new(VecDeque::from([response(json!({"data": [
2670 null,
2671 {"context_length": 8192},
2672 {"id": "unconfigured?invalid", "context_length": 8192},
2673 {"id": "vendor/unconfigured-missing-context"},
2674 {"id": "vendor/unconfigured-modalities", "context_length": 8192, "architecture": {"input_modalities": ["invalid!"]}},
2675 {"id": "approved/standard", "context_length": 32768, "supported_parameters": ["tools"]},
2676 {"id": "approved/complex", "context_length": 65536, "supported_parameters": ["reasoning"]}
2677 ]}))])),
2678 observations: Arc::new(Mutex::new(Vec::new())),
2679 };
2680 let configured = vec![
2681 "approved/standard".to_owned(),
2682 "approved/complex".to_owned(),
2683 ];
2684 let catalog = discover_openrouter_authenticated_with_configured_models(
2685 &executor,
2686 &FixtureResolver,
2687 "secret:fixture",
2688 snapshot("snapshot.openrouter.configured.1"),
2689 &configured,
2690 )
2691 .await
2692 .expect("unconfigured provider rows cannot poison configured discovery");
2693 assert_eq!(catalog.models().len(), 2);
2694 assert!(catalog.model("approved/standard").is_some());
2695 assert!(catalog.model("approved/complex").is_some());
2696 assert!(
2697 catalog
2698 .model("vendor/unconfigured-missing-context")
2699 .is_none()
2700 );
2701 }
2702
2703 #[test]
2704 fn configured_openrouter_requires_every_configured_row_once_and_strictly_valid() {
2705 let configured = HashSet::from(["approved/standard", "approved/complex"]);
2706 let cases = [
2707 (
2708 json!({"data": [
2709 {"id": "approved/standard", "context_length": 8192}
2710 ]}),
2711 OpenRouterMalformedStage::MissingConfiguredModel,
2712 ),
2713 (
2714 json!({"data": [
2715 {"id": "approved/standard", "context_length": 8192},
2716 {"id": "approved/standard", "context_length": 8192},
2717 {"id": "approved/complex", "context_length": 8192}
2718 ]}),
2719 OpenRouterMalformedStage::DuplicateModelId,
2720 ),
2721 (
2722 json!({"data": [
2723 {"id": "approved/standard", "context_length": 0},
2724 {"id": "approved/complex", "context_length": 8192}
2725 ]}),
2726 OpenRouterMalformedStage::ContextLength,
2727 ),
2728 (
2729 json!({"data": [
2730 {"id": "approved/standard", "context_length": 8192, "architecture": {"input_modalities": ["invalid!"]}},
2731 {"id": "approved/complex", "context_length": 8192}
2732 ]}),
2733 OpenRouterMalformedStage::Modalities,
2734 ),
2735 ];
2736 for (body, expected) in cases {
2737 let bytes = serde_json::to_vec(&body).expect("fixture JSON");
2738 assert_eq!(
2739 decode_openrouter_configured(&bytes, true, &configured),
2740 Err(CatalogDiscoveryError::OpenRouterMalformed(expected))
2741 );
2742 }
2743 }
2744
2745 #[tokio::test]
2746 async fn configured_openrouter_rejects_invalid_requested_model_set_before_http() {
2747 let observations = Arc::new(Mutex::new(Vec::new()));
2748 let executor = FakeExecutor {
2749 responses: Mutex::new(VecDeque::new()),
2750 observations: Arc::clone(&observations),
2751 };
2752 for configured in [
2753 Vec::<String>::new(),
2754 vec!["invalid?model".to_owned()],
2755 vec!["approved/model".to_owned(), "approved/model".to_owned()],
2756 ] {
2757 assert_eq!(
2758 discover_openrouter_authenticated_with_configured_models(
2759 &executor,
2760 &FixtureResolver,
2761 "secret:fixture",
2762 snapshot("snapshot.openrouter.configured.invalid"),
2763 &configured,
2764 )
2765 .await,
2766 Err(CatalogDiscoveryError::InvalidRequest)
2767 );
2768 }
2769 assert!(observations.lock().expect("observations").is_empty());
2770 }
2771
2772 #[tokio::test]
2773 async fn configured_openrouter_keeps_full_response_model_bound() {
2774 let models = (0..=MAX_CATALOG_MODELS)
2775 .map(|index| {
2776 json!({
2777 "id": format!("unconfigured/model-{index}"),
2778 "context_length": 8192
2779 })
2780 })
2781 .collect::<Vec<_>>();
2782 let executor = FakeExecutor {
2783 responses: Mutex::new(VecDeque::from([response(json!({"data": models}))])),
2784 observations: Arc::new(Mutex::new(Vec::new())),
2785 };
2786 assert_eq!(
2787 discover_openrouter_authenticated_with_configured_models(
2788 &executor,
2789 &FixtureResolver,
2790 "secret:fixture",
2791 snapshot("snapshot.openrouter.configured.bounds"),
2792 &["approved/model".to_owned()],
2793 )
2794 .await,
2795 Err(CatalogDiscoveryError::ResponseBounds)
2796 );
2797 }
2798
2799 #[tokio::test]
2800 async fn response_and_model_count_bounds_fail_before_typed_normalization() {
2801 let oversized = FakeExecutor {
2802 responses: Mutex::new(VecDeque::from([Ok(CatalogHttpResponse {
2803 status: 200,
2804 content_type: Some("application/json".to_owned()),
2805 redirected: false,
2806 body: vec![b' '; MAX_CATALOG_RESPONSE_BYTES + 1],
2807 })])),
2808 observations: Arc::new(Mutex::new(Vec::new())),
2809 };
2810 assert_eq!(
2811 discover_local_ollama(
2812 &oversized,
2813 snapshot("snapshot.bounds.1"),
2814 OllamaDiscoveryOptions::default(),
2815 )
2816 .await,
2817 Err(CatalogDiscoveryError::ResponseBounds)
2818 );
2819
2820 let many = (0..=MAX_CATALOG_MODELS)
2821 .map(|index| json!({"model": format!("model-{index}")}))
2822 .collect::<Vec<_>>();
2823 let executor = FakeExecutor {
2824 responses: Mutex::new(VecDeque::from([response(json!({"models": many}))])),
2825 observations: Arc::new(Mutex::new(Vec::new())),
2826 };
2827 assert_eq!(
2828 discover_local_ollama(
2829 &executor,
2830 snapshot("snapshot.bounds.2"),
2831 OllamaDiscoveryOptions::default(),
2832 )
2833 .await,
2834 Err(CatalogDiscoveryError::ResponseBounds)
2835 );
2836
2837 let oversized_openrouter = FakeExecutor {
2838 responses: Mutex::new(VecDeque::from([Ok(CatalogHttpResponse {
2839 status: 200,
2840 content_type: Some("application/json".to_owned()),
2841 redirected: false,
2842 body: vec![b' '; MAX_CATALOG_RESPONSE_BYTES + 1],
2843 })])),
2844 observations: Arc::new(Mutex::new(Vec::new())),
2845 };
2846 assert_eq!(
2847 discover_openrouter_authenticated(
2848 &oversized_openrouter,
2849 &FixtureResolver,
2850 "secret:fixture",
2851 snapshot("snapshot.openrouter.bounds.bytes"),
2852 )
2853 .await,
2854 Err(CatalogDiscoveryError::ResponseBounds)
2855 );
2856
2857 let many_openrouter = (0..=MAX_CATALOG_MODELS)
2858 .map(|index| {
2859 json!({
2860 "id": format!("vendor/model-{index}"),
2861 "context_length": 8_192
2862 })
2863 })
2864 .collect::<Vec<_>>();
2865 let openrouter_executor = FakeExecutor {
2866 responses: Mutex::new(VecDeque::from([response(json!({
2867 "data": many_openrouter
2868 }))])),
2869 observations: Arc::new(Mutex::new(Vec::new())),
2870 };
2871 assert_eq!(
2872 discover_openrouter_authenticated(
2873 &openrouter_executor,
2874 &FixtureResolver,
2875 "secret:fixture",
2876 snapshot("snapshot.openrouter.bounds.models"),
2877 )
2878 .await,
2879 Err(CatalogDiscoveryError::ResponseBounds)
2880 );
2881 }
2882
2883 #[tokio::test]
2884 async fn catalog_request_absolute_deadline_times_out_non_completing_executor() {
2885 let request =
2886 catalog_request_with_resolved_secret(CatalogEndpoint::LocalOllamaTags, None, None)
2887 .expect("valid local catalog request");
2888 let started = std::time::Instant::now();
2889
2890 let error = execute_with_absolute_deadline(
2891 &NeverCompletesExecutor,
2892 request,
2893 CATALOG_REQUEST_TIMEOUT,
2894 )
2895 .await
2896 .expect_err("absolute catalog request deadline");
2897 let elapsed = started.elapsed();
2898
2899 assert_eq!(error, CatalogDiscoveryError::Timeout);
2900 assert_eq!(error.to_string(), "catalog discovery timed out");
2901 assert!(elapsed >= CATALOG_REQUEST_TIMEOUT, "elapsed: {elapsed:?}");
2902 assert!(
2903 elapsed <= CATALOG_REQUEST_TIMEOUT + Duration::from_secs(1),
2904 "elapsed: {elapsed:?}"
2905 );
2906 }
2907
2908 #[tokio::test]
2909 async fn normalized_snapshot_hash_is_independent_of_provider_list_order() {
2910 async fn discover(models: Value, snapshot_id: &str) -> CatalogSnapshot {
2911 let executor = FakeExecutor {
2912 responses: Mutex::new(VecDeque::from([response(json!({"models": models}))])),
2913 observations: Arc::new(Mutex::new(Vec::new())),
2914 };
2915 discover_local_ollama(
2916 &executor,
2917 snapshot(snapshot_id),
2918 OllamaDiscoveryOptions::default(),
2919 )
2920 .await
2921 .expect("deterministic local snapshot")
2922 }
2923
2924 let forward = discover(
2925 json!([{"name": "alpha:latest"}, {"name": "zeta:latest"}]),
2926 "snapshot.order.1",
2927 )
2928 .await;
2929 let reverse = discover(
2930 json!([{"name": "zeta:latest"}, {"name": "alpha:latest"}]),
2931 "snapshot.order.2",
2932 )
2933 .await;
2934 assert_eq!(forward.content_sha256(), reverse.content_sha256());
2935 }
2936}