Skip to main content

harness_gateway_client/
config.rs

1//! Client configuration: the redacted bearer secret, the validated gateway
2//! endpoint, and the error a bad setup reports.
3
4use std::fmt;
5
6/// The error returned when setting up the gateway client fails because an
7/// environment variable, the bearer key, or the endpoint URL is missing or
8/// unusable.
9///
10/// This is a setup error. It happens before the client sends its first
11/// request, so it stays within client setup, separate from the Engine and
12/// its [`CompletionErrorKind`](crate::CompletionErrorKind) values. The
13/// message names the environment variable or the rule that failed, and never
14/// contains a key.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum GatewayConfigError {
18    /// A required environment variable was missing.
19    #[error("missing environment variable: {0}")]
20    MissingEnv(String),
21
22    /// An environment variable was set, but its value was invalid Unicode.
23    #[error("environment variable is set but not valid Unicode: {0}")]
24    InvalidEnv(String),
25
26    /// A configuration value failed validation.
27    #[error("{0}")]
28    InvalidConfig(String),
29
30    /// A configuration input was invalid, and the error keeps the concrete
31    /// cause as its `source`.
32    ///
33    /// The cause is, for example, a URL parse failure or an unusable secret.
34    #[error("{message}")]
35    Config {
36        /// A human-readable description of the problem. The text of the
37        /// source error appears only in `source`.
38        message: String,
39        /// The underlying error that caused this one.
40        #[source]
41        source: Box<dyn std::error::Error + Send + Sync>,
42    },
43}
44
45/// A bearer credential whose contents never appear in `Debug` or `Display`
46/// output or in logs.
47///
48/// Wrap a secret, such as the gateway bearer key, in a `SecretString` as soon
49/// as you read it, so an accidental `{:?}` or log line cannot leak it. The
50/// client reads the value only to set the `Authorization` header of its
51/// requests.
52#[derive(Clone)]
53#[non_exhaustive]
54pub struct SecretString(String);
55
56impl SecretString {
57    /// Wraps a secret so it is redacted everywhere it is formatted.
58    ///
59    /// # Errors
60    /// Returns [`SecretError::Empty`] when `secret` is empty, so a client can
61    /// never be built to authenticate with a blank bearer credential.
62    pub fn new(secret: impl Into<String>) -> std::result::Result<SecretString, SecretError> {
63        let secret = secret.into();
64        if secret.is_empty() {
65            return Err(SecretError::Empty);
66        }
67        Ok(SecretString(secret))
68    }
69
70    /// Borrows the raw secret. Crate-internal so no downstream code can read a
71    /// credential back out of the type.
72    pub(crate) fn expose(&self) -> &str {
73        &self.0
74    }
75}
76
77/// The reason constructing a [`SecretString`] failed.
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79#[non_exhaustive]
80pub enum SecretError {
81    /// The supplied credential was empty.
82    #[error("secret must not be empty")]
83    Empty,
84}
85
86impl From<SecretError> for GatewayConfigError {
87    fn from(error: SecretError) -> GatewayConfigError {
88        // An unusable credential is a client setup problem. The concrete
89        // `SecretError` is preserved as the source rather than flattened
90        // into a string (AUDIT-DISCARDED-SOURCE).
91        GatewayConfigError::Config {
92            message: "gateway bearer key is unusable".to_owned(),
93            source: Box::new(error),
94        }
95    }
96}
97
98impl fmt::Debug for SecretString {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str("SecretString(<redacted>)")
101    }
102}
103
104impl fmt::Display for SecretString {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str("<redacted>")
107    }
108}
109
110/// A validated base URL for the gateway's OpenAI-compatible API (its `/v1`
111/// root).
112///
113/// Construction requires an `http` or `https` scheme and a host, so a
114/// client always points at a usable endpoint. Any trailing slash is removed
115/// so request paths join cleanly.
116#[non_exhaustive]
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct GatewayEndpoint {
119    pub(crate) url: String,
120    /// Whether the host names the local machine: `localhost`, or an IP whose
121    /// `is_loopback()` holds. Decided at construction from the parsed host.
122    loopback: bool,
123}
124
125impl GatewayEndpoint {
126    /// Validates and normalizes a gateway base URL.
127    ///
128    /// The URL is checked with a strict URL parser.
129    ///
130    /// # Errors
131    /// Returns a [`GatewayConfigError`] when `url` fails to parse as an
132    /// absolute URL, uses a scheme other than `http` or `https`, omits the
133    /// host, embeds credentials (a `user:pass@` component), or has a query or
134    /// fragment.
135    /// A query or fragment is rejected because an API root is a bare path.
136    /// No error includes `url`, because a URL can contain a credential.
137    pub fn new(url: &str) -> std::result::Result<GatewayEndpoint, GatewayConfigError> {
138        let reject = GatewayConfigError::InvalidConfig;
139        let trimmed = url.trim();
140        // Preserve the concrete `url::ParseError` as a private source rather than
141        // flattening it into the message (AUDIT-DISCARDED-SOURCE).
142        let parsed = url::Url::parse(trimmed).map_err(|error| GatewayConfigError::Config {
143            message: "gateway URL is not a valid URL".to_owned(),
144            source: Box::new(error),
145        })?;
146        if !matches!(parsed.scheme(), "http" | "https") {
147            return Err(reject(
148                "gateway URL must use the http or https scheme".to_owned(),
149            ));
150        }
151        let loopback = match parsed.host() {
152            None | Some(url::Host::Domain("")) => {
153                return Err(reject("gateway URL names no host".to_owned()));
154            }
155            // The URL parser lowercases the host of an http(s) URL, so the
156            // literal comparison covers `LOCALHOST` too.
157            Some(url::Host::Domain(domain)) => domain == "localhost",
158            Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
159            Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
160        };
161        if !parsed.username().is_empty() || parsed.password().is_some() {
162            return Err(reject(
163                "gateway URL must not embed credentials (user:pass@)".to_owned(),
164            ));
165        }
166        if parsed.query().is_some() || parsed.fragment().is_some() {
167            return Err(reject(
168                "gateway URL must not include a query or fragment".to_owned(),
169            ));
170        }
171        Ok(GatewayEndpoint {
172            // Normalized by the URL parser; trim the trailing slash so request
173            // paths (`{base}/chat/completions`) join cleanly.
174            url: parsed.as_str().trim_end_matches('/').to_string(),
175            loopback,
176        })
177    }
178
179    /// Returns the normalized base URL.
180    #[must_use]
181    pub fn url(&self) -> &str {
182        &self.url
183    }
184
185    /// Returns whether the endpoint's host is the local machine.
186    ///
187    /// It returns `true` for `localhost`, for `127.0.0.1` and the rest of
188    /// `127.0.0.0/8`, and for `::1`. It returns `false` for every other name
189    /// or address. By default, a gateway on the local machine accepts callers
190    /// that omit the key, so
191    /// [`GatewayChat::from_env`](crate::GatewayChat::from_env) makes the
192    /// bearer key optional exactly when this returns `true`.
193    #[must_use]
194    pub fn is_loopback(&self) -> bool {
195        self.loopback
196    }
197}
198
199impl TryFrom<&str> for GatewayEndpoint {
200    type Error = GatewayConfigError;
201
202    fn try_from(url: &str) -> std::result::Result<GatewayEndpoint, GatewayConfigError> {
203        GatewayEndpoint::new(url)
204    }
205}