Skip to main content

provider_core/
catalog_discovery.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Bounded, read-only provider catalog discovery.
4
5use 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;
29/// Absolute wall-clock deadline for one complete catalog HTTP request used by
30/// discovery or qualification. Executor connect/read timeouts remain
31/// lower-level early-failure bounds and cannot extend this whole-request limit.
32pub 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
129/// A catalog-only request. Debug output never exposes the request body or credential.
130pub struct CatalogHttpRequest {
131    endpoint: CatalogEndpoint,
132    body: Vec<u8>,
133    authorization: Option<ResolvedSecret>,
134    /// When set, the executor writes the resolved secret in this header name
135    /// instead of the `Authorization: Bearer` header. The Anthropic
136    /// (`x-api-key`) and Gemini (`x-goog-api-key`) catalog protocols require a
137    /// custom API-key header.
138    pub auth_header_name: Option<&'static str>,
139    /// A fixed protocol header the executor writes alongside the credential
140    /// header. The Anthropic model-list protocol requires the pinned
141    /// `anthropic-version: 2023-06-01` header on every request.
142    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            // The Anthropic and Gemini catalog protocols carry the key in a
275            // custom header (`x-api-key`, `x-goog-api-key`); every other
276            // catalog endpoint uses the `Authorization: Bearer` default.
277            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/// Content-free predicate identifying where strict OpenRouter catalog
350/// validation failed. It never contains provider response data.
351#[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/// The native model-list response shared by the OpenAI-compatible direct APIs
530/// (DeepSeek `/models`, Alibaba DashScope `/compatible-mode/v1/models`). Both
531/// advertise only a stable `id`; context, capability, and pricing evidence are
532/// not part of the list response, so normalization stays conservative.
533#[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                // The list response carries no context length, so the
614                // conservative unenriched value applies until qualification
615                // binds fuller evidence (same policy as unenriched Ollama).
616                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
630/// Decodes the shared OpenAI-compatible model-list envelope
631/// (`{"data": [{"id": "..."}]}`). The response is strict: every listed model
632/// must carry a valid, unique id or the whole discovery fails closed.
633fn 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/// Normalizes the Gemini model-list response. Gemini's catalog is not
671/// OpenAI-compatible: the envelope is `{"models": [{"name": "models/..."}]}`
672/// and the id appears in the request path, not the request body.
673#[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                // The list response carries no context length, so the
694                // conservative unenriched value applies until qualification
695                // binds fuller evidence (same policy as unenriched Ollama).
696                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
710/// Decodes the Gemini model-list envelope (`{"models": [{"name": "models/..."}]}`).
711/// The Gemini `name` field carries a `models/` prefix that is stripped to yield
712/// the bare model id, the form used in the generateContent path template. The
713/// response is strict: every listed model must carry a valid, unique id after
714/// the prefix strip or the whole discovery fails closed.
715fn 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
744/// Performs authenticated DeepSeek direct-API model-list discovery
745/// (`GET https://api.deepseek.com/models`). Any authentication or endpoint
746/// failure is returned fail closed.
747pub 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
776/// Performs authenticated Qwen / Alibaba DashScope token-plan model-list
777/// discovery (`GET .../compatible-mode/v1/models`). Any authentication or
778/// endpoint failure is returned fail closed.
779pub 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
808/// Performs authenticated xAI Grok direct-API model-list discovery
809/// (`GET https://api.x.ai/v1/models`). Any authentication or endpoint failure
810/// is returned fail closed.
811pub 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
840/// Performs authenticated Kimi / Moonshot direct-API model-list discovery
841/// (`GET https://api.moonshot.ai/v1/models`). Any authentication or endpoint
842/// failure is returned fail closed.
843pub 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
872/// Performs authenticated Z.ai GLM API model-list discovery
873/// (`GET https://api.z.ai/api/paas/v4/models`). Any authentication or endpoint
874/// failure is returned fail closed.
875pub 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
904/// Performs authenticated Anthropic native model-list discovery
905/// (`GET https://api.anthropic.com/v1/models`). The request carries the
906/// `x-api-key` header and the pinned `anthropic-version` header; any
907/// authentication or endpoint failure is returned fail closed.
908pub 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
937/// Performs authenticated Gemini model-list discovery
938/// (`GET https://generativelanguage.googleapis.com/v1beta/models`). The
939/// request carries the `x-goog-api-key` header and the response names are
940/// stripped of their `models/` prefix; any authentication or failure is
941/// returned fail closed.
942pub 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
996/// Discovers Ollama Cloud through the direct API and probes only explicitly
997/// configured, provider-neutral models that were absent from the catalog.
998pub 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
1024/// Discovers direct-API Ollama Cloud models using a credential resolved by
1025/// the caller under its operation-level deadline. One zeroizing raw secret is
1026/// retained only for this bounded discovery operation and is copied into a
1027/// fresh sensitive bearer header for each exact tags/show request.
1028pub 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
1050/// Discovers signed-in Ollama Cloud aliases through the local daemon relay.
1051/// The local endpoint receives no API key; only strictly verified remote-cloud
1052/// entries are retained in the resulting cloud catalog snapshot.
1053pub 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
1073/// Discovers signed-in relay aliases and probes only explicitly configured
1074/// `:cloud` models that were absent from local tags. The probe remains on the
1075/// relay and never introduces a bearer credential or transport fallback.
1076pub 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
1260/// Performs authenticated OpenRouter discovery without public-catalog
1261/// fallback. Any authentication or endpoint failure is returned fail closed.
1262pub 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
1293/// Performs authenticated OpenRouter discovery for the exact configured model
1294/// set. Provider rows outside that already-authorized set cannot become routes
1295/// and cannot poison discovery merely because their unrelated metadata is
1296/// malformed. Every configured row remains strictly validated and must appear
1297/// exactly once.
1298pub 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
1317/// Performs configured-model OpenRouter discovery using a credential that was
1318/// resolved by the caller under its own operation-level deadline.
1319///
1320/// This split keeps the synchronous platform credential API outside the async
1321/// HTTP deadline without exposing credential bytes. The resolved value is
1322/// consumed exactly once and remains zeroizing.
1323pub 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    // The Anthropic and Gemini model-list protocols carry the raw key in a
1431    // custom header (`x-api-key` + pinned `anthropic-version`,
1432    // `x-goog-api-key`); every other catalog endpoint uses the
1433    // `Authorization: Bearer` default.
1434    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    // Bound the complete executor future, independent of any per-blocking-read
1480    // socket timeout configured by the concrete executor.
1481    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            // A listed but unenriched model remains conservatively unusable for
1596            // automatic routing until qualification binds fuller evidence.
1597            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}