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