Skip to main content

artifactize_core/
llm.rs

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