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