Skip to main content

harness_gateway_client/
transport.rs

1//! The HTTP transport: the gateway client, bounded SSE response reading,
2//! and environment loading.
3//!
4//! The request body, the stream reassembly, and the read loop that applies
5//! the byte cap and measures the timing are the shared wire code in
6//! `wire`; this file owns only what touches the wire: sending, the
7//! per-receive timeout, the response as a chunk source, and the clock the
8//! read loop is handed.
9
10use std::fmt;
11use std::num::NonZeroU64;
12use std::time::{Duration, Instant};
13
14use promptforge::model::{Completion, CompletionError, CompletionOptions, Message, ToolSchema};
15
16use crate::config::{GatewayConfigError, GatewayEndpoint, SecretString};
17use crate::failure::{elapsed, transport_failure, unavailable};
18use crate::wire::classify::classify_http_failure;
19use crate::wire::delta::StreamDelta;
20use crate::wire::read::{ChunkSource, read_body_capped, read_completion_stream};
21use crate::wire::request::build_request_body;
22use crate::wire::stream::escape_controls;
23
24/// A client that sends chat completion requests to one gateway URL.
25///
26/// The client usually presents the gateway's shared bearer key on every
27/// request. The key is optional: by default, a gateway on the same machine
28/// admits keyless loopback callers. A keyless client
29/// ([`GatewayChat::keyless`]) omits the `Authorization` header entirely.
30#[derive(Clone)]
31#[non_exhaustive]
32pub struct GatewayChat {
33    transport: GatewayTransport,
34    base_url: String,
35    /// The bearer presented on every request, or `None` to present nothing.
36    key: Option<SecretString>,
37    /// Longest wait for the response headers, and then for each next body
38    /// chunk; a stream that keeps arriving is never cut off.
39    request_timeout: Duration,
40    /// Byte ceiling enforced on a response body before it is decoded.
41    max_response_bytes: u64,
42}
43
44/// Default longest wait for the next receive, matching the executor's run
45/// limits.
46const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
47/// Default response-body ceiling, matching the executor's run limits.
48const DEFAULT_MAX_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
49
50#[derive(Clone)]
51enum GatewayTransport {
52    Http(reqwest::Client),
53    Disabled,
54}
55
56/// A [`reqwest::Response`] body as the reassembly's chunk source, with each
57/// receive bounded by the client's timeout.
58struct ResponseChunks {
59    response: reqwest::Response,
60    timeout: Duration,
61}
62
63impl ChunkSource for ResponseChunks {
64    type Chunk = bytes::Bytes;
65
66    async fn next_chunk(&mut self) -> Result<Option<Self::Chunk>, CompletionError> {
67        tokio::time::timeout(self.timeout, self.response.chunk())
68            .await
69            .map_err(elapsed)?
70            .map_err(transport_failure)
71    }
72}
73
74impl fmt::Debug for GatewayChat {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        // The bearer key is a credential and must never appear in Debug output,
77        // logs, or panic messages. It is redacted to a fixed marker regardless of
78        // whether one is set, so no length or presence signal leaks either.
79        f.debug_struct("GatewayChat")
80            .field("base_url", &self.base_url)
81            .field("key", &"<redacted>")
82            .finish_non_exhaustive()
83    }
84}
85
86impl GatewayChat {
87    /// Builds a client that sends requests to `endpoint` with `key` as the
88    /// bearer key.
89    ///
90    /// The endpoint is a validated [`GatewayEndpoint`], and the key is a
91    /// [`SecretString`], which keeps it redacted. [`GatewayChat::from_env`]
92    /// builds its client this way when a key is set.
93    #[must_use]
94    pub fn new(endpoint: GatewayEndpoint, key: SecretString) -> GatewayChat {
95        GatewayChat {
96            transport: GatewayTransport::Http(reqwest::Client::new()),
97            base_url: endpoint.url,
98            key: Some(key),
99            request_timeout: DEFAULT_REQUEST_TIMEOUT,
100            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
101        }
102    }
103
104    /// Builds a client whose requests omit the `Authorization` header that
105    /// carries the bearer key.
106    ///
107    /// This client suits a gateway on the same machine, because such a
108    /// gateway trusts keyless loopback callers, unless its operator set
109    /// `trust_loopback = false`. On a shared machine, that trust also covers
110    /// every other OS account there. Against any other gateway, the requests
111    /// fail with an `Unavailable`-kind error when the gateway answers 401.
112    ///
113    /// This constructor accepts an endpoint with any host, so the caller
114    /// decides when a keyless client is appropriate.
115    /// [`GatewayChat::from_env`] decides by [`GatewayEndpoint::is_loopback`].
116    #[must_use]
117    pub fn keyless(endpoint: GatewayEndpoint) -> GatewayChat {
118        GatewayChat {
119            transport: GatewayTransport::Http(reqwest::Client::new()),
120            base_url: endpoint.url,
121            key: None,
122            request_timeout: DEFAULT_REQUEST_TIMEOUT,
123            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
124        }
125    }
126
127    /// Builds an offline client, for execution paths that must stay
128    /// hermetic.
129    ///
130    /// Any attempted model call fails with an `Unavailable`-kind
131    /// [`CompletionError`]. The client reads no gateway configuration and
132    /// sends no HTTP request.
133    #[must_use]
134    pub fn disabled() -> GatewayChat {
135        GatewayChat {
136            transport: GatewayTransport::Disabled,
137            base_url: String::new(),
138            key: None,
139            request_timeout: DEFAULT_REQUEST_TIMEOUT,
140            max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
141        }
142    }
143
144    /// Whether this client presents a bearer key; a test seam for the
145    /// environment constructor, which never exposes the key itself.
146    #[cfg(test)]
147    fn has_key(&self) -> bool {
148        self.key.is_some()
149    }
150
151    /// Applies the run's HTTP limits to this client: a timeout for each
152    /// receive and a cap on the response size.
153    ///
154    /// `request_timeout` is the longest a completion request waits for its
155    /// response headers, and then for each next body chunk. Every chunk that
156    /// arrives restarts the wait. A long stream that keeps arriving
157    /// completes, and a stream that stalls fails as a timeout.
158    ///
159    /// The client refuses the response body as soon as it would exceed
160    /// `max_response_bytes`, before any UTF-8 or JSON decoding runs.
161    #[must_use]
162    pub fn with_request_limits(
163        mut self,
164        request_timeout: Duration,
165        max_response_bytes: NonZeroU64,
166    ) -> GatewayChat {
167        self.request_timeout = request_timeout;
168        self.max_response_bytes = max_response_bytes.get();
169        self
170    }
171
172    /// Builds a client from environment variables.
173    ///
174    /// - `PROMPTFORGE_GATEWAY_URL` holds the gateway URL. It is required.
175    /// - `PROMPTFORGE_GATEWAY_API_KEY` holds the gateway's shared bearer key.
176    ///   An empty value counts as missing. The key is optional when the
177    ///   URL's host is loopback (`127.0.0.1`, `::1`, `localhost`) and
178    ///   required for every other host. By default, a loopback gateway
179    ///   trusts keyless callers on the same machine, so for a loopback URL a
180    ///   missing key yields a keyless client.
181    ///
182    /// That trust also admits every other OS account on a shared machine, so
183    /// the gateway's operator there sets `trust_loopback = false`. Then set
184    /// the key, or a keyless client's requests fail with an
185    /// `Unavailable`-kind error when the gateway answers 401.
186    ///
187    /// # Errors
188    /// Returns a [`GatewayConfigError`] when `PROMPTFORGE_GATEWAY_URL` is
189    /// missing or invalid, when either variable is set to a value that is not
190    /// valid Unicode, or when the URL's host is not loopback (a LAN or
191    /// remote gateway) and `PROMPTFORGE_GATEWAY_API_KEY` is missing or empty.
192    pub fn from_env() -> Result<GatewayChat, GatewayConfigError> {
193        from_env_with(|name| match std::env::var(name) {
194            Ok(value) => Ok(Some(value)),
195            Err(std::env::VarError::NotPresent) => Ok(None),
196            // A set-but-non-Unicode value is a real misconfiguration, surfaced
197            // explicitly instead of being silently treated as "not set".
198            Err(std::env::VarError::NotUnicode(_)) => {
199                Err(GatewayConfigError::InvalidEnv(name.to_owned()))
200            }
201        })
202    }
203
204    /// Sends a list of messages to the gateway and returns the model's reply.
205    ///
206    /// The request always streams. It asks for server-sent events (SSE) and
207    /// sets `stream_options.include_usage`, so the stream ends with a
208    /// summary chunk that reports token usage. The client reassembles the
209    /// streamed fragments into the body a buffered chat completion response
210    /// would carry. It calls `on_delta` with each text or reasoning
211    /// fragment, as a [`StreamDelta`], as soon as the fragment arrives. A
212    /// caller that ignores the fragments passes an empty closure.
213    ///
214    /// The returned [`Completion`] holds the reassembled turn, the metadata
215    /// parsed from the stream's summary chunk, and a
216    /// [`ClientTiming`](promptforge::metrics::ClientTiming) measured on this
217    /// client's own clock: time to first token, mean inter-token latency,
218    /// and end-to-end time.
219    ///
220    /// When `tools` is `Some` and holds at least one schema, the request
221    /// carries a `tools` array with one `OpenAI` function tool per schema:
222    /// an object whose `type` is `function` and whose `function` holds the
223    /// schema's name, description, and parameters. The request also sets
224    /// `tool_choice` to `auto`. Passing `None` or an empty slice omits the
225    /// `tools` field, so the request is a plain chat completion.
226    ///
227    /// The request names the model that `options` names, and the returned
228    /// completion is labeled with that name, whatever name the response
229    /// gave. The optional `temperature`, `max_tokens`, and `thinking`
230    /// settings in `options` extend the request when present.
231    ///
232    /// # Errors
233    /// Returns a [`CompletionError`] whose [`kind`](CompletionError::kind) is
234    /// always one of the following:
235    /// - `Unavailable` when this client was built with [`GatewayChat::disabled`],
236    ///   or when the gateway answers 401 or 403.
237    /// - `Timeout` when the request timeout elapses before the response
238    ///   headers or the next body chunk arrive.
239    /// - `Transport` on any other transport failure, such as a failed
240    ///   connection, or when the stream carries a mid-stream error envelope
241    ///   that names no known cause.
242    /// - `ContextOverflow`, `RateLimited`, `QuotaExhausted`, `Overloaded`,
243    ///   `Refused`, `ServerError`, or `Rejected` when the gateway answers
244    ///   with a status outside the 2xx range, as [`classify_http_failure`]
245    ///   reads it.
246    /// - `MalformedResponse` when the stream exceeds the response size cap,
247    ///   a chunk fails to parse as usable JSON (a JSON decode error is kept
248    ///   as the error's source), or the stream ends before the `[DONE]`
249    ///   sentinel. It is also the kind when a `length` or `content_filter`
250    ///   finish reason cuts off a batch of tool calls, because partial
251    ///   arguments must not run.
252    /// - `EmptyReply` when the turn has zero tool calls and blank text.
253    pub async fn complete(
254        &self,
255        messages: &[Message],
256        tools: Option<&[ToolSchema]>,
257        options: &CompletionOptions,
258        on_delta: impl Fn(StreamDelta),
259    ) -> Result<Completion, CompletionError> {
260        let GatewayTransport::Http(http) = &self.transport else {
261            return Err(unavailable());
262        };
263        let request_body = build_request_body(messages, tools, options);
264
265        let started = Instant::now();
266        // No reqwest `.timeout`: it caps the whole request including the
267        // body, which would cut off a long stream that is still arriving.
268        // The timeout bounds the headers here and each receive in
269        // `ResponseChunks`.
270        let mut request = http
271            .post(format!("{}/chat/completions", self.base_url))
272            .json(&request_body);
273        if let Some(key) = &self.key {
274            request = request.bearer_auth(key.expose());
275        }
276        let response = tokio::time::timeout(self.request_timeout, request.send())
277            .await
278            .map_err(elapsed)?
279            .map_err(transport_failure)?;
280
281        let status = response.status();
282        let content_length = response.content_length();
283        let mut chunks = ResponseChunks {
284            response,
285            timeout: self.request_timeout,
286        };
287        if !status.is_success() {
288            let raw_body =
289                read_body_capped(&mut chunks, content_length, self.max_response_bytes).await?;
290            // F5: bound the body, then escape control characters so a hostile
291            // payload cannot forge log lines. The classifier keeps the escaped
292            // body only as the opt-in `CompletionError::detail`, never in the
293            // public `Display`.
294            let body = String::from_utf8_lossy(&raw_body);
295            let body = escape_controls(&body, 2000);
296            return Err(classify_http_failure(status.as_u16(), &body));
297        }
298
299        // The byte cap, the `[DONE]` rule, the truncation rule, the strict
300        // turn normalizer, and the timing arithmetic all run inside the
301        // shared read loop: one rule set for every transport. This client
302        // contributes the chunks and the clock.
303        read_completion_stream(
304            &mut chunks,
305            request_body,
306            self.max_response_bytes,
307            on_delta,
308            started,
309            Instant::now,
310        )
311        .await
312    }
313}
314
315/// The environment-driven constructor behind [`GatewayChat::from_env`],
316/// with the variable lookup injected so tests need not touch the process
317/// environment.
318///
319/// The key is optional exactly when the URL's host is loopback; an empty key
320/// counts as unset ([`SecretString::new`] refuses only an empty secret, and
321/// `Result::ok` folds that refusal into `None`).
322fn from_env_with(
323    lookup: impl Fn(&str) -> Result<Option<String>, GatewayConfigError>,
324) -> Result<GatewayChat, GatewayConfigError> {
325    let base_url = lookup("PROMPTFORGE_GATEWAY_URL")?
326        .ok_or_else(|| GatewayConfigError::MissingEnv("PROMPTFORGE_GATEWAY_URL".into()))?;
327    let endpoint = GatewayEndpoint::new(&base_url)?;
328    let key = lookup("PROMPTFORGE_GATEWAY_API_KEY")?
329        .map(SecretString::new)
330        .and_then(Result::ok);
331    match key {
332        Some(key) => Ok(GatewayChat::new(endpoint, key)),
333        None if endpoint.is_loopback() => Ok(GatewayChat::keyless(endpoint)),
334        None => Err(GatewayConfigError::MissingEnv(
335            "PROMPTFORGE_GATEWAY_API_KEY".into(),
336        )),
337    }
338}
339
340#[cfg(test)]
341pub(crate) mod tests;