Skip to main content

harness_gateway_client/
catalog.rs

1//! Catalog transport: fetching and decoding gateway `GET /v1/models`.
2
3use std::num::NonZeroU32;
4
5use runvil::model::{CompletionError, ModelCatalog, ModelDescriptor, ModelId, ThinkingMode};
6use serde::Deserialize;
7
8use crate::failure::{malformed, transport_failure};
9use crate::wire::classify::classify_http_failure;
10use crate::wire::stream::escape_controls;
11
12/// Wire shape of one entry from gateway `GET /v1/models`.
13///
14/// The list mixes inference models with the gateway's speech-to-text models,
15/// which have only `id`, `object`, and `kind` because they answer no
16/// completion request. The inference fields are therefore optional at the
17/// wire, and an entry without a context window is skipped rather than
18/// failing the whole catalog.
19#[derive(Debug, Deserialize)]
20struct ModelsListEntry {
21    id: String,
22    #[serde(default)]
23    description: String,
24    #[serde(default)]
25    context: Option<u32>,
26    #[serde(default)]
27    thinking: Option<ThinkingMode>,
28    /// The model's provider id, when the gateway names one.
29    #[serde(default)]
30    provider: Option<String>,
31}
32
33/// Wire shape of gateway `GET /v1/models`.
34#[derive(Debug, Deserialize)]
35struct ModelsListResponse {
36    data: Vec<ModelsListEntry>,
37}
38
39/// The largest gateway error body kept for a catalog-fetch diagnostic, in bytes.
40const MAX_CATALOG_ERROR_BODY: usize = 2000;
41
42/// The largest success-path model-catalog body accepted before decoding, in
43/// bytes. A gateway that returns more than this is refused rather than buffered
44/// unbounded, mirroring the bound the error path already applies. Sized well
45/// above any realistic model list (16 MiB) so legitimate catalogs are unaffected.
46const MAX_CATALOG_BODY: u64 = 16 * 1024 * 1024;
47
48/// Reads a success-path response body, refusing it once it would exceed `cap`
49/// bytes so a decode cannot buffer an unbounded body first.
50///
51/// The advertised `Content-Length` short-circuits an oversize body, and the
52/// streamed chunks are bounded so a gateway that omits or lies about the length
53/// still cannot force an unbounded allocation.
54async fn read_catalog_body_capped(
55    mut response: reqwest::Response,
56    cap: u64,
57) -> std::result::Result<Vec<u8>, CompletionError> {
58    if let Some(len) = response.content_length()
59        && len > cap
60    {
61        return Err(malformed(format!(
62            "model list body of {len} bytes exceeds the {cap}-byte limit"
63        )));
64    }
65    let mut body: Vec<u8> = Vec::new();
66    while let Some(chunk) = response.chunk().await.map_err(transport_failure)? {
67        if body.len() as u64 + chunk.len() as u64 > cap {
68            return Err(malformed(format!(
69                "model list body exceeds the {cap}-byte limit"
70            )));
71        }
72        body.extend_from_slice(&chunk);
73    }
74    Ok(body)
75}
76
77/// Reads at most `limit` bytes of a non-success response body, stopping early so
78/// an oversized error body cannot exhaust memory.
79///
80/// A read failure is returned as the concrete [`reqwest::Error`] (MODEL-010) so
81/// the caller can retain it as an error-chain `#[source]`, rather than being
82/// flattened into display text that severs the cause.
83async fn read_error_body_bounded(
84    mut response: reqwest::Response,
85    limit: usize,
86) -> std::result::Result<String, reqwest::Error> {
87    let mut buffer: Vec<u8> = Vec::new();
88    while buffer.len() < limit {
89        match response.chunk().await? {
90            Some(chunk) => {
91                let take = (limit - buffer.len()).min(chunk.len());
92                buffer.extend_from_slice(&chunk[..take]);
93                if take < chunk.len() {
94                    break;
95                }
96            }
97            None => break,
98        }
99    }
100    if buffer.is_empty() {
101        return Ok("(empty body)".to_owned());
102    }
103    // F5: escape control characters so a hostile catalog error body cannot forge
104    // log lines or smuggle terminal control sequences into a diagnostic.
105    let lossy = String::from_utf8_lossy(&buffer);
106    let mut escaped = String::with_capacity(lossy.len());
107    for ch in lossy.chars() {
108        if ch.is_control() {
109            escaped.extend(ch.escape_default());
110        } else {
111            escaped.push(ch);
112        }
113    }
114    Ok(escaped)
115}
116
117/// Returns the process-wide catalog HTTP client, building it once on first use.
118///
119/// A single reusable client (MODEL-018) lets catalog fetches share one
120/// connection pool and transport configuration rather than each constructing a
121/// throwaway client with its own pool. The returned handle is a cheap clone of
122/// the shared client (its state is reference-counted internally).
123fn catalog_client() -> reqwest::Client {
124    static CATALOG_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
125    CATALOG_CLIENT.get_or_init(reqwest::Client::new).clone()
126}
127
128/// Sends a bearer-authed GET through the shared client (MODEL-018) and
129/// returns the success response, classifying every failure the same way for
130/// each gateway endpoint: `Transport` (or `Timeout`) when the send fails, the
131/// classified kind with a bounded, control-escaped body as its detail on a
132/// non-success status (MODEL-010: no unbounded buffering), and `Transport`
133/// (or `Timeout`) when that error body cannot be read, keeping the
134/// [`reqwest::Error`] as a typed source under the same timeout marking as a
135/// send failure.
136async fn get_authed(
137    url: String,
138    token: &str,
139) -> std::result::Result<reqwest::Response, CompletionError> {
140    let response = catalog_client()
141        .get(url)
142        .bearer_auth(token)
143        .send()
144        .await
145        .map_err(transport_failure)?;
146    let status = response.status();
147    if status.is_success() {
148        return Ok(response);
149    }
150    let body = match read_error_body_bounded(response, MAX_CATALOG_ERROR_BODY).await {
151        Ok(body) => body,
152        Err(source) => return Err(transport_failure(source)),
153    };
154    Err(classify_http_failure(status.as_u16(), &body))
155}
156
157/// Fetches a [`ModelCatalog`] from the Gateway's `/models` endpoint.
158///
159/// `base_url` is the root of the Gateway's OpenAI-compatible API, for
160/// example `http://127.0.0.1:8081/v1`. The request sends `token` as a
161/// bearer token.
162///
163/// # Errors
164/// Returns a [`CompletionError`]. Its [`kind`](CompletionError::kind) is:
165///
166/// - `Transport` or `Timeout` when the HTTP request or a response read fails;
167/// - the kind [`classify_http_failure`] picks for the response when the
168///   Gateway returns a status outside the 2xx range;
169/// - `MalformedResponse` when the body is not a valid model list.
170pub async fn fetch_model_catalog(
171    base_url: &str,
172    token: &str,
173) -> std::result::Result<ModelCatalog, CompletionError> {
174    let base = base_url.trim_end_matches('/');
175    let response = get_authed(format!("{base}/models"), token).await?;
176    // Bound the success body BEFORE decoding so an oversized (or unbounded)
177    // model list cannot exhaust memory, matching the bound the error path applies.
178    let body = read_catalog_body_capped(response, MAX_CATALOG_BODY).await?;
179    // A body that does not decode as a model list is a malformed response, not a
180    // transport failure - matching this function's documented error contract.
181    let list: ModelsListResponse = serde_json::from_slice(&body).map_err(|error| {
182        // MODEL-009: keep the decode error as a private `#[source]` cause instead
183        // of flattening it into the message, while the classification stays
184        // `MalformedResponse`.
185        malformed("model list response was not valid JSON").with_source(error)
186    })?;
187    let mut descriptors = Vec::with_capacity(list.data.len());
188    for entry in list.data {
189        // An entry with no context window is not an inference model (the
190        // gateway lists its transcription models here too); it is not a
191        // descriptor and must not fail the catalog.
192        let Some(context) = entry.context else {
193            continue;
194        };
195        let id = ModelId::gateway(entry.id).map_err(|error| {
196            malformed(format!("model catalog entry has an invalid id: {error}"))
197        })?;
198        let context = NonZeroU32::new(context).ok_or_else(|| {
199            malformed("a model declares a zero-token context window")
200                .with_detail(escape_controls(id.name(), MAX_CATALOG_ERROR_BODY))
201        })?;
202        let thinking = entry.thinking.ok_or_else(|| {
203            malformed("a model declares a context window but no thinking mode")
204                .with_detail(escape_controls(id.name(), MAX_CATALOG_ERROR_BODY))
205        })?;
206        let descriptor = ModelDescriptor::new(id, entry.description, context, thinking);
207        descriptors.push(match entry.provider {
208            Some(provider) => descriptor.with_provider(provider),
209            None => descriptor,
210        });
211    }
212    ModelCatalog::new(descriptors).map_err(|error| {
213        malformed("gateway returned an inconsistent model catalog")
214            .with_detail(escape_controls(&error.to_string(), MAX_CATALOG_ERROR_BODY))
215    })
216}
217
218#[cfg(test)]
219#[path = "catalog-tests.rs"]
220mod tests;