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;