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}