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;