Skip to main content

artifactize_core/
llm.rs

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