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