Skip to main content

artifactize_core/
llm.rs

1//! Exact-model rig backends with explicit API-key or Codex credentials, and the
2//! loopback-only test endpoint override for offline fake providers.
3
4pub mod codex;
5mod delivery;
6#[cfg(test)]
7mod delivery_tests;
8pub mod models;
9#[cfg(test)]
10pub(crate) mod tests;
11mod usage;
12
13use std::{
14    net::IpAddr,
15    path::Path,
16    sync::{Arc, Mutex},
17    time::Duration,
18};
19
20use futures_util::StreamExt;
21use rig_core::{
22    Model,
23    completion::{CompletionRequest, CompletionResponse, FinishReason},
24    error::ProviderError,
25    observe::{Action, AdapterContext, AdapterEvent, Observation, Subject, Witness},
26    providers::{anthropic, openai},
27    streaming::CompletionStream,
28};
29use serde::{Deserialize, Serialize};
30use serde_json::{Value, json};
31use tokio::time::Instant;
32use tokio_util::sync::CancellationToken;
33
34use crate::{
35    agent::error::{Code, Failure},
36    config::Backend,
37};
38
39/// Attempts per turn: the first and up to two retries.
40const ATTEMPTS: usize = 3;
41/// Start transient retries at 250 ms and double per attempt when the provider
42/// sends no Retry-After; cancellation and the review deadline still bound every wait.
43const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(250);
44/// Bound sanitized provider diagnostics retained in errors and audit records.
45const MAX_DIAGNOSTIC_CHARS: usize = 4096;
46/// Messages requires a per-turn output limit. Allow room for reasoning and verdict text
47/// while bounding one response; this is independent of the review-wide maxTokens budget.
48const ANTHROPIC_OUTPUT_TOKENS: u64 = 16_384;
49
50/// A backend's client for one review. The API-key backends keep the key they send: a
51/// provider error may echo it, and every error leaves through the shared rule for server text.
52pub enum Client {
53    Openai(Box<Model<openai::responses_api::wire::Responses>>, String),
54    Anthropic(Box<Model<anthropic::wire::Messages>>, String),
55    Codex(codex::Codex),
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Attempt {
61    #[serde(default)]
62    pub turn: usize,
63    #[serde(default)]
64    pub attempt: usize,
65    #[serde(default)]
66    pub usage: usage::Usage,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub error: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub error_code: Option<Code>,
71}
72
73/// A classified provider failure, with the wait the provider asked for, if any.
74struct Classified {
75    code: Code,
76    message: String,
77    retry_after: Option<Duration>,
78}
79
80impl From<Classified> for Failure {
81    fn from(classified: Classified) -> Self {
82        Self::new(classified.code, classified.message)
83    }
84}
85
86impl From<Failure> for Classified {
87    fn from(failure: Failure) -> Self {
88        Self {
89            code: failure.code,
90            message: failure.message,
91            retry_after: None,
92        }
93    }
94}
95
96// Only counters cross this boundary; prompts, reasoning and raw responses stay transient.
97#[derive(Default)]
98struct ReportedUsage(Mutex<std::collections::BTreeMap<String, u64>>);
99
100impl Witness for ReportedUsage {
101    fn observe(&self, observation: Observation) {
102        if let Action::Adapter { observation } = observation.action
103            && let AdapterEvent::Usage { usage } = observation.event
104        {
105            let mut counters = self.0.lock().unwrap();
106            for (name, value) in [
107                ("inputTokens", usage.input_tokens),
108                ("outputTokens", usage.output_tokens),
109                ("totalTokens", usage.total_tokens),
110                ("cacheReadTokens", usage.cached_input_tokens),
111                ("reasoningTokens", usage.reasoning_tokens),
112            ] {
113                insert_counter(&mut counters, name, value);
114            }
115        }
116    }
117}
118
119fn insert_counter(
120    counters: &mut std::collections::BTreeMap<String, u64>,
121    name: &str,
122    value: Option<u64>,
123) {
124    if let Some(value) = value.filter(|value| *value <= crate::types::MAX_SAFE_JSON_INTEGER) {
125        counters.insert(name.into(), value);
126    }
127}
128
129impl Client {
130    /// `state` and `repo` locate Codex credentials; the API-key backends ignore them.
131    pub fn new(
132        backend: Backend,
133        model: &crate::config::ModelId,
134        state: &Path,
135        repo: &Path,
136    ) -> Result<Self, Failure> {
137        let base = base_url(backend)?;
138        if backend == Backend::Codex {
139            return Ok(Self::Codex(codex::Codex::new(model, &base, state, repo)?));
140        }
141        let key = api_key(backend, "for this Agent backend")
142            .map_err(|message| Failure::new(Code::Authentication, message))?;
143        let http = delivery::Tap::new(http_client()?);
144        Ok(match backend {
145            Backend::Openai => Self::Openai(
146                Box::new(
147                    openai::OpenAIConfig::new(key.clone())
148                        .with_base_url(base)
149                        .connect(http)
150                        .responses(model.as_str()),
151                ),
152                key,
153            ),
154            Backend::Anthropic => Self::Anthropic(
155                Box::new(
156                    anthropic::AnthropicConfig::new(key.clone())
157                        .with_base_url(base)
158                        .connect(http)
159                        .completion(model.as_str()),
160                ),
161                key,
162            ),
163            Backend::Codex => unreachable!("built above"),
164        })
165    }
166
167    /// The provider parameters of every request of one review. The Responses backends key
168    /// their prompt cache on the review's `session` id; Anthropic caches by prefix alone.
169    pub fn parameters(
170        backend: Backend,
171        reasoning: Option<&str>,
172        session: &crate::types::SessionId,
173    ) -> Result<Value, String> {
174        if let Some(reasoning) = reasoning {
175            backend.validate_reasoning(reasoning)?;
176        }
177        Ok(match backend {
178            Backend::Openai => {
179                let mut value = json!({
180                    "store":false,
181                    "parallel_tool_calls":false,
182                    "include":["reasoning.encrypted_content"],
183                    "prompt_cache_key":session,
184                });
185                if let Some(reasoning) = reasoning {
186                    value["reasoning"] = json!({"effort":reasoning});
187                }
188                value
189            }
190            Backend::Anthropic => reasoning.map_or(
191                json!({}),
192                |effort| json!({"thinking":{"type":"adaptive"}, "output_config":{"effort":effort}}),
193            ),
194            // rig's Codex contract states store:false and encrypted reasoning itself. The
195            // `session-id` header repeats the cache key (see `codex::envelope`).
196            Backend::Codex => {
197                let mut value = json!({"prompt_cache_key":session});
198                if let Some(effort) = reasoning {
199                    value["reasoning"] = json!({"effort":effort, "summary":"auto"});
200                }
201                value
202            }
203        })
204    }
205
206    /// Structured output constrains the final answer only; tool calls keep their contract.
207    /// Merge Anthropic's format with the reasoning effort already in output_config.
208    pub fn output_format(backend: Backend, parameters: &mut Value, schema: Value) {
209        match backend {
210            Backend::Openai | Backend::Codex => {
211                parameters["text"] = json!({"format":{
212                    "type":"json_schema", "name":"artifactize_verdict", "strict":true,
213                    "schema":schema,
214                }});
215            }
216            Backend::Anthropic => {
217                let config = parameters
218                    .as_object_mut()
219                    .expect("parameters object")
220                    .entry("output_config")
221                    .or_insert_with(|| json!({}));
222                config["format"] = json!({"type":"json_schema", "schema":schema});
223            }
224        }
225    }
226
227    async fn stream(
228        &self,
229        mut request: CompletionRequest,
230        observed: AdapterContext,
231    ) -> Result<CompletionStream, Classified> {
232        match self {
233            Self::Openai(model, _) => model.stream_observed(request, observed),
234            Self::Anthropic(model, _) => {
235                request.max_tokens = Some(ANTHROPIC_OUTPUT_TOKENS);
236                model.stream_observed(request, observed)
237            }
238            // Each turn reads the credentials afresh, refreshing them when due.
239            Self::Codex(client) => {
240                let model = client.model().await.map_err(|error| Classified {
241                    code: if error.transient {
242                        Code::Transient
243                    } else {
244                        Code::Authentication
245                    },
246                    message: error.message,
247                    retry_after: None,
248                })?;
249                model.stream_observed(request, observed)
250            }
251        }
252        .map_err(|error| self.classify(&error))
253    }
254
255    /// The credentials this client sent: its key, or the Codex access tokens of its turns.
256    fn sent(&self) -> Vec<String> {
257        match self {
258            Self::Openai(_, key) | Self::Anthropic(_, key) => vec![key.clone()],
259            Self::Codex(client) => client.sent(),
260        }
261    }
262
263    /// The error code of a provider failure, from its status, codes and message. Its message
264    /// is the shared rule's: text holding part of a credential this client sent is withheld
265    /// before it reaches output, delivery events, attempts or saved sessions.
266    fn classify(&self, error: &ProviderError) -> Classified {
267        let text = match self {
268            Self::Codex(_) => codex::diagnostic_text(error),
269            _ => diagnostic_text(error),
270        };
271        let sent = self.sent();
272        let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
273        let message = crate::secret::server_text(&text, &sent, MAX_DIAGNOSTIC_CHARS);
274        let report = error.report();
275        let status = report.http_status;
276        // Classified by the whole text (codes and words), never shown.
277        let text = format!("{} {text}", report.code.as_deref().unwrap_or_default()).to_lowercase();
278        let any = |terms: &[&str]| terms.iter().any(|term| text.contains(term));
279        let code = if matches!(self, Self::Codex(_)) && codex::usage_limited(error)
280            || any(&["quota", "billing", "credit balance", "usage limit"])
281        {
282            Code::Quota
283        } else if matches!(status, Some(401 | 403))
284            || any(&[
285                "authentication",
286                "unauthorized",
287                "permission",
288                "invalid_api_key",
289                "api key",
290            ])
291        {
292            Code::Authentication
293        } else if status == Some(429) || any(&["rate_limit", "rate limit"]) {
294            Code::RateLimit
295        } else if error.is_retryable()
296            || matches!(self, Self::Openai(..) | Self::Codex(_)) && codex::overloaded(error)
297            || status.is_some_and(|status| status >= 500)
298            || (status.is_none()
299                && matches!(
300                    report.code.as_deref(),
301                    // Codex reports overloads inside a successful HTTP stream, without
302                    // an HTTP error status; it is the same retryable provider failure.
303                    Some(
304                        "overloaded_error" | "server_is_overloaded" | "server_error" | "api_error"
305                    )
306                ))
307        {
308            Code::Transient
309        } else {
310            Code::ProviderError
311        };
312        Classified {
313            code,
314            message,
315            retry_after: retry_after(error),
316        }
317    }
318
319    /// One turn, retried while an attempt fails with a rate limit or a transient error
320    /// before producing any output or usage: at any turn, since every request replays the
321    /// whole conversation. A retry waits as long as the provider's `Retry-After` asks, or a
322    /// short backoff, within the review's deadline.
323    pub async fn turn(
324        &self,
325        request: &CompletionRequest,
326        context: Turn<'_>,
327        attempts: &mut Vec<Attempt>,
328    ) -> Result<CompletionResponse, Failure> {
329        self.turn_observed(request, context, attempts, &mut |_| {})
330            .await
331    }
332
333    /// Optional display delivery leaves the provider/retry/budget contract unchanged.
334    pub async fn turn_observed(
335        &self,
336        request: &CompletionRequest,
337        context: Turn<'_>,
338        attempts: &mut Vec<Attempt>,
339        observe: &mut impl FnMut(crate::agent::session::Delivery),
340    ) -> Result<CompletionResponse, Failure> {
341        for attempt in 1..=ATTEMPTS {
342            if context.cancellation.is_cancelled() {
343                return Err(Failure::cancelled());
344            }
345            if Instant::now() >= context.deadline {
346                return Err(Failure::timeout());
347            }
348            let usage = Arc::new(ReportedUsage::default());
349            let observed = AdapterContext::new(
350                usage.clone(),
351                Subject::default(),
352                format!("turn-{}", context.number),
353            );
354            let mut emitted = false;
355            let sink = delivery::Sink::default();
356            // Task-local registration encloses opening AND every poll, not just request creation.
357            let (result, partial) = sink
358                .scope(async {
359                    let cancelled = || Classified::from(Failure::cancelled());
360                    let timeout = || Classified::from(Failure::timeout());
361                    let deadline = context.deadline;
362                    let opened = tokio::select! {
363                        biased;
364                        _ = context.cancellation.cancelled() => return (Err(cancelled()), None),
365                        _ = tokio::time::sleep_until(deadline) => return (Err(timeout()), None),
366                        opened = self.stream(request.clone(), observed) => opened,
367                    };
368                    let mut stream = match opened {
369                        Ok(stream) => stream,
370                        Err(failure) => return (Err(failure), None),
371                    };
372                    let mut cadence = tokio::time::interval(delivery::CADENCE);
373                    cadence.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
374                    let read = loop {
375                        tokio::select! {
376                            biased;
377                            _ = context.cancellation.cancelled() => break Err(cancelled()),
378                            _ = tokio::time::sleep_until(deadline) => break Err(timeout()),
379                            _ = cadence.tick() => {
380                                for event in sink.take(context.number, attempt) {
381                                    observe(event);
382                                }
383                            }
384                            item = stream.next() => match item {
385                                Some(Ok(item)) => {
386                                    emitted = true;
387                                    sink.normalized(&item);
388                                    if sink.ready() {
389                                        for event in sink.take(context.number, attempt) {
390                                            observe(event);
391                                        }
392                                    }
393                                }
394                                Some(Err(error)) => break Err(self.classify(&error)),
395                                None => break Ok(()),
396                            }
397                        }
398                    };
399                    for event in sink.take(context.number, attempt) {
400                        observe(event);
401                    }
402                    let partial = stream.partial();
403                    let result = match read {
404                        Ok(()) => stream.finish().await.map_err(|error| self.classify(&error)),
405                        Err(error) => Err(error),
406                    };
407                    let final_response = result.as_ref().ok().unwrap_or(&partial);
408                    if result.is_ok() {
409                        sink.final_response(&final_response.choice);
410                    }
411                    for event in sink.take(context.number, attempt) {
412                        observe(event);
413                    }
414                    if let Err(error) = &result {
415                        observe(crate::agent::session::Delivery {
416                            turn: context.number,
417                            attempt,
418                            block: crate::types::DeliveryBlock::attempt(),
419                            kind: crate::agent::session::DeliveryKind::Text,
420                            text: error.message.clone(),
421                            state: crate::agent::session::DeliveryState::Interrupted,
422                        });
423                    }
424                    (result, Some(partial))
425                })
426                .await;
427            let mut counters = usage.0.lock().unwrap().clone();
428            if let Some(partial) = &partial {
429                add_cache_usage(&mut counters, partial);
430            }
431            if let Ok(response) = &result {
432                add_cache_usage(&mut counters, response);
433            }
434            let used_tokens = counters.values().any(|value| *value > 0);
435            attempts.push(Attempt {
436                turn: context.number,
437                attempt,
438                usage: counters.into(),
439                error: result.as_ref().err().map(|failure| failure.message.clone()),
440                error_code: result.as_ref().err().map(|failure| failure.code),
441            });
442            let failure = match result {
443                Ok(response) => return Ok(response),
444                Err(failure) => failure,
445            };
446            if !failure.code.retryable() || emitted || used_tokens || attempt == ATTEMPTS {
447                return Err(failure.into());
448            }
449            let wait = failure
450                .retry_after
451                .unwrap_or(RETRY_BACKOFF_BASE * (1 << (attempt - 1)));
452            // A wait too long to add to now is past any deadline.
453            if failure.retry_after.is_some()
454                && Instant::now()
455                    .checked_add(wait)
456                    .is_none_or(|end| end >= context.deadline)
457            {
458                return Err(Failure::new(
459                    failure.code,
460                    format!(
461                        "{}. The provider asked to retry after {:.1} s, past the review deadline.",
462                        failure.message.trim_end_matches('.'),
463                        wait.as_secs_f64()
464                    ),
465                ));
466            }
467            tokio::select! {
468                _ = context.cancellation.cancelled() => return Err(Failure::cancelled()),
469                _ = tokio::time::sleep_until(context.deadline) => return Err(Failure::timeout()),
470                _ = tokio::time::sleep(wait) => {},
471            }
472        }
473        unreachable!()
474    }
475}
476
477pub struct Turn<'a> {
478    pub number: usize,
479    pub deadline: Instant,
480    pub cancellation: &'a CancellationToken,
481}
482
483fn add_cache_usage(
484    counters: &mut std::collections::BTreeMap<String, u64>,
485    response: &CompletionResponse,
486) {
487    insert_counter(
488        counters,
489        "cacheWriteTokens",
490        response.usage.cache_creation_input_tokens,
491    );
492    insert_counter(
493        counters,
494        "cacheWrite1hTokens",
495        response
496            .raw
497            .pointer("/usage/cache_creation/ephemeral_1h_input_tokens")
498            .and_then(Value::as_u64),
499    );
500}
501
502pub fn validate_response(response: &CompletionResponse, model: &str) -> Result<(), String> {
503    if response
504        .model
505        .as_deref()
506        .is_some_and(|actual| actual != model)
507    {
508        return Err(format!(
509            "Provider model mismatch: requested {model:?}, received {:?}.",
510            response.model
511        ));
512    }
513    if !matches!(
514        response.finish_reason(),
515        Some(FinishReason::Stop | FinishReason::ToolCalls)
516    ) {
517        return Err(format!(
518            "Provider response was incomplete: {:?}.",
519            response.finish_reason()
520        ));
521    }
522    Ok(())
523}
524
525/// `retry-after-ms` is read as a count of seconds first, so a thousandth of it is the wait.
526const MILLIS_PER_SECOND: u32 = 1000;
527
528/// The wait a provider asked for: `retry-after-ms`, or `Retry-After` in seconds or as an
529/// HTTP date.
530fn retry_after(error: &ProviderError) -> Option<Duration> {
531    wait_header(
532        error.provider_response_headers()?,
533        time::OffsetDateTime::now_utc(),
534    )
535}
536
537fn wait_header(
538    headers: &rig_core::http_client::HeaderMap,
539    now: time::OffsetDateTime,
540) -> Option<Duration> {
541    let header = |name: &str| headers.get(name)?.to_str().ok().map(str::trim);
542    let seconds = |value: &str| {
543        value
544            .parse::<f64>()
545            .ok()
546            .filter(|value| value.is_finite() && *value >= 0.0)
547            .and_then(|value| Duration::try_from_secs_f64(value).ok())
548    };
549    if let Some(wait) = header("retry-after-ms").and_then(seconds) {
550        return Some(wait / MILLIS_PER_SECOND);
551    }
552    let value = header("retry-after")?;
553    seconds(value).or_else(|| {
554        let at = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc2822)
555            .ok()?;
556        Some(Duration::try_from(at - now).unwrap_or_default())
557    })
558}
559
560/// A provider error as text, cleaned for a terminal but not clipped: callers decide what of
561/// it may be shown ([`crate::secret::server_text`]) before cutting it.
562fn diagnostic_text(error: &ProviderError) -> String {
563    clean_text(&message(error))
564}
565
566/// A provider's error for a request that carried `secrets` (an API key): the shared rule for
567/// server text, so an echoed key is withheld rather than shown.
568fn diagnostic_hiding(error: &ProviderError, secrets: &[&str]) -> String {
569    crate::secret::server_text(&diagnostic_text(error), secrets, MAX_DIAGNOSTIC_CHARS)
570}
571
572fn message(error: &ProviderError) -> String {
573    error
574        .provider_response()
575        .and_then(|response| serde_json::from_str::<Value>(&response.body).ok())
576        .and_then(|body| {
577            body.pointer("/error/message")
578                .or_else(|| body.get("message"))
579                .and_then(Value::as_str)
580                .map(str::to_owned)
581        })
582        .unwrap_or_else(|| error.to_string())
583}
584
585/// The environment variable that holds an API-key backend's key; `None` for codex.
586pub fn key_variable(backend: Backend) -> Option<&'static str> {
587    match backend {
588        Backend::Openai => Some("OPENAI_API_KEY"),
589        Backend::Anthropic => Some("ANTHROPIC_API_KEY"),
590        Backend::Codex => None,
591    }
592}
593
594/// Whether an API-key backend's key is set in the environment (never its value); always
595/// `false` for codex, which signs in instead.
596pub fn key_present(backend: Backend) -> bool {
597    key_variable(backend)
598        .and_then(crate::platform::environment::var_text)
599        .is_some_and(|key| !key.trim().is_empty())
600}
601
602/// The environment variable that points the backend at a loopback test endpoint.
603pub fn base_url_variable(backend: Backend) -> &'static str {
604    match backend {
605        Backend::Openai => "ARTIFACTIZE_OPENAI_BASE_URL",
606        Backend::Anthropic => "ARTIFACTIZE_ANTHROPIC_BASE_URL",
607        Backend::Codex => "ARTIFACTIZE_CODEX_BASE_URL",
608    }
609}
610
611/// Every test endpoint variable: the backends' API roots and the Codex sign-in root.
612const TEST_ENDPOINTS: [&str; 4] = [
613    "ARTIFACTIZE_OPENAI_BASE_URL",
614    "ARTIFACTIZE_ANTHROPIC_BASE_URL",
615    "ARTIFACTIZE_CODEX_BASE_URL",
616    crate::auth::codex::AUTH_URL_VARIABLE,
617];
618
619/// The loopback test endpoint that replaces the backend's API root, if one is set.
620pub fn test_endpoint(backend: Backend) -> Result<Option<String>, String> {
621    variable_endpoint(base_url_variable(backend))
622}
623
624/// The loopback test endpoint in `variable`, if set. An empty value is unset; any other
625/// value must be a loopback URL.
626pub fn variable_endpoint(variable: &str) -> Result<Option<String>, String> {
627    let Some(value) = crate::platform::environment::var(variable).filter(|value| !value.is_empty())
628    else {
629        return Ok(None);
630    };
631    value
632        .to_str()
633        .ok_or_else(|| "it is not UTF-8".to_owned())
634        .and_then(loopback_url)
635        .map(Some)
636        .map_err(|error| format!("{variable}: {error}."))
637}
638
639/// The first test endpoint variable that is set, valid or not.
640pub fn active_test_endpoint() -> Option<&'static str> {
641    TEST_ENDPOINTS.into_iter().find(|variable| {
642        crate::platform::environment::var(variable).is_some_and(|value| !value.is_empty())
643    })
644}
645
646/// An http(s) URL on localhost, 127.0.0.0/8 or [::1], without credentials, a query or a
647/// fragment, and without a trailing slash. A test endpoint never leaves the machine.
648fn loopback_url(value: &str) -> Result<String, String> {
649    let url = url::Url::parse(value).map_err(|_| "it is not a URL".to_owned())?;
650    let loopback = match url.host() {
651        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
652        Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip).is_loopback(),
653        Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip).is_loopback(),
654        None => false,
655    };
656    if !matches!(url.scheme(), "http" | "https") || !loopback {
657        return Err("a test endpoint must be http(s) on localhost, 127.0.0.0/8 or [::1]".into());
658    }
659    if !url.username().is_empty()
660        || url.password().is_some()
661        || url.query().is_some()
662        || url.fragment().is_some()
663    {
664        return Err("a test endpoint has no credentials, query or fragment".into());
665    }
666    Ok(url.as_str().trim_end_matches('/').to_owned())
667}
668
669/// The backend's API root: its loopback test endpoint, else the provider's.
670fn base_url(backend: Backend) -> Result<String, String> {
671    Ok(test_endpoint(backend)?.unwrap_or_else(|| {
672        match backend {
673            Backend::Openai => "https://api.openai.com/v1",
674            Backend::Anthropic => "https://api.anthropic.com",
675            Backend::Codex => codex::BASE_URL,
676        }
677        .into()
678    }))
679}
680
681/// The backend's API key from its environment variable; never printed or logged.
682fn api_key(backend: Backend, purpose: &str) -> Result<String, String> {
683    let variable = key_variable(backend).expect("an API-key backend");
684    crate::platform::environment::var_text(variable)
685        .filter(|key| !key.trim().is_empty())
686        .ok_or_else(|| format!("{variable} is required {purpose}."))
687}
688
689fn http_client() -> Result<reqwest::Client, String> {
690    reqwest::Client::builder()
691        .retry(reqwest::retry::never())
692        .redirect(reqwest::redirect::Policy::none())
693        .build()
694        .map_err(|error| error.to_string())
695}
696
697/// `message` cleaned for a terminal (escape sequences and control characters), unclipped.
698fn clean_text(message: &str) -> String {
699    String::from_utf8(crate::runtime::clean_output(message.as_bytes()))
700        .expect("clean output is UTF-8")
701}