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            || matches!(self, Self::Openai(..) | Self::Codex(_)) && codex::overloaded(error)
276            || status.is_some_and(|status| status >= 500)
277            || (status.is_none()
278                && matches!(
279                    report.code.as_deref(),
280                    // Codex reports overloads inside a successful HTTP stream, without
281                    // an HTTP error status; it is the same retryable provider failure.
282                    Some(
283                        "overloaded_error" | "server_is_overloaded" | "server_error" | "api_error"
284                    )
285                ))
286        {
287            Code::Transient
288        } else {
289            Code::ProviderError
290        };
291        Classified {
292            code,
293            message,
294            retry_after: retry_after(error),
295        }
296    }
297
298    /// One turn, retried while an attempt fails with a rate limit or a transient error
299    /// before producing any output or usage: at any turn, since every request replays the
300    /// whole conversation. A retry waits as long as the provider's `Retry-After` asks, or a
301    /// short backoff, within the review's deadline.
302    pub async fn turn(
303        &self,
304        request: &CompletionRequest,
305        context: Turn<'_>,
306        attempts: &mut Vec<Attempt>,
307    ) -> Result<CompletionResponse, Failure> {
308        self.turn_observed(request, context, attempts, &mut |_| {})
309            .await
310    }
311
312    /// Optional display delivery leaves the provider/retry/budget contract unchanged.
313    pub async fn turn_observed(
314        &self,
315        request: &CompletionRequest,
316        context: Turn<'_>,
317        attempts: &mut Vec<Attempt>,
318        observe: &mut impl FnMut(crate::agent::session::Delivery),
319    ) -> Result<CompletionResponse, Failure> {
320        for attempt in 1..=ATTEMPTS {
321            if context.cancellation.is_cancelled() {
322                return Err(Failure::cancelled());
323            }
324            if Instant::now() >= context.deadline {
325                return Err(Failure::timeout());
326            }
327            let usage = Arc::new(ReportedUsage::default());
328            let observed = AdapterContext::new(
329                usage.clone(),
330                Subject::default(),
331                format!("turn-{}", context.number),
332            );
333            let mut emitted = false;
334            let sink = delivery::Sink::default();
335            // Task-local registration encloses opening AND every poll, not just request creation.
336            let (result, partial) = sink
337                .scope(async {
338                    let cancelled = || Classified::from(Failure::cancelled());
339                    let timeout = || Classified::from(Failure::timeout());
340                    let deadline = context.deadline;
341                    let opened = tokio::select! {
342                        biased;
343                        _ = context.cancellation.cancelled() => return (Err(cancelled()), None),
344                        _ = tokio::time::sleep_until(deadline) => return (Err(timeout()), None),
345                        opened = self.stream(request.clone(), observed) => opened,
346                    };
347                    let mut stream = match opened {
348                        Ok(stream) => stream,
349                        Err(failure) => return (Err(failure), None),
350                    };
351                    let mut cadence = tokio::time::interval(delivery::CADENCE);
352                    cadence.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
353                    let read = loop {
354                        tokio::select! {
355                            biased;
356                            _ = context.cancellation.cancelled() => break Err(cancelled()),
357                            _ = tokio::time::sleep_until(deadline) => break Err(timeout()),
358                            _ = cadence.tick() => {
359                                for event in sink.take(context.number, attempt) {
360                                    observe(event);
361                                }
362                            }
363                            item = stream.next() => match item {
364                                Some(Ok(item)) => {
365                                    emitted = true;
366                                    sink.normalized(&item);
367                                    if sink.ready() {
368                                        for event in sink.take(context.number, attempt) {
369                                            observe(event);
370                                        }
371                                    }
372                                }
373                                Some(Err(error)) => break Err(self.classify(&error)),
374                                None => break Ok(()),
375                            }
376                        }
377                    };
378                    for event in sink.take(context.number, attempt) {
379                        observe(event);
380                    }
381                    let partial = stream.partial();
382                    let result = match read {
383                        Ok(()) => stream.finish().await.map_err(|error| self.classify(&error)),
384                        Err(error) => Err(error),
385                    };
386                    let final_response = result.as_ref().ok().unwrap_or(&partial);
387                    if result.is_ok() {
388                        sink.final_response(&final_response.choice);
389                    }
390                    for event in sink.take(context.number, attempt) {
391                        observe(event);
392                    }
393                    if let Err(error) = &result {
394                        observe(crate::agent::session::Delivery {
395                            turn: context.number,
396                            attempt,
397                            block: crate::types::DeliveryBlock::attempt(),
398                            kind: crate::agent::session::DeliveryKind::Text,
399                            text: error.message.clone(),
400                            state: crate::agent::session::DeliveryState::Interrupted,
401                        });
402                    }
403                    (result, Some(partial))
404                })
405                .await;
406            let mut counters = usage.0.lock().unwrap().clone();
407            if let Some(partial) = &partial {
408                add_cache_usage(&mut counters, partial);
409            }
410            if let Ok(response) = &result {
411                add_cache_usage(&mut counters, response);
412            }
413            let used_tokens = counters.values().any(|value| *value > 0);
414            attempts.push(Attempt {
415                turn: context.number,
416                attempt,
417                usage: counters.into(),
418                error: result.as_ref().err().map(|failure| failure.message.clone()),
419                error_code: result.as_ref().err().map(|failure| failure.code),
420            });
421            let failure = match result {
422                Ok(response) => return Ok(response),
423                Err(failure) => failure,
424            };
425            if !failure.code.retryable() || emitted || used_tokens || attempt == ATTEMPTS {
426                return Err(failure.into());
427            }
428            let wait = failure
429                .retry_after
430                .unwrap_or(RETRY_BACKOFF_BASE * (1 << (attempt - 1)));
431            // A wait too long to add to now is past any deadline.
432            if failure.retry_after.is_some()
433                && Instant::now()
434                    .checked_add(wait)
435                    .is_none_or(|end| end >= context.deadline)
436            {
437                return Err(Failure::new(
438                    failure.code,
439                    format!(
440                        "{}. The provider asked to retry after {:.1} s, past the review deadline.",
441                        failure.message.trim_end_matches('.'),
442                        wait.as_secs_f64()
443                    ),
444                ));
445            }
446            tokio::select! {
447                _ = context.cancellation.cancelled() => return Err(Failure::cancelled()),
448                _ = tokio::time::sleep_until(context.deadline) => return Err(Failure::timeout()),
449                _ = tokio::time::sleep(wait) => {},
450            }
451        }
452        unreachable!()
453    }
454}
455
456pub struct Turn<'a> {
457    pub number: usize,
458    pub deadline: Instant,
459    pub cancellation: &'a CancellationToken,
460}
461
462fn add_cache_usage(
463    counters: &mut std::collections::BTreeMap<String, u64>,
464    response: &CompletionResponse,
465) {
466    insert_counter(
467        counters,
468        "cacheWriteTokens",
469        response.usage.cache_creation_input_tokens,
470    );
471    insert_counter(
472        counters,
473        "cacheWrite1hTokens",
474        response
475            .raw
476            .pointer("/usage/cache_creation/ephemeral_1h_input_tokens")
477            .and_then(Value::as_u64),
478    );
479}
480
481pub fn validate_response(response: &CompletionResponse, model: &str) -> Result<(), String> {
482    if response
483        .model
484        .as_deref()
485        .is_some_and(|actual| actual != model)
486    {
487        return Err(format!(
488            "Provider model mismatch: requested {model:?}, received {:?}.",
489            response.model
490        ));
491    }
492    if !matches!(
493        response.finish_reason(),
494        Some(FinishReason::Stop | FinishReason::ToolCalls)
495    ) {
496        return Err(format!(
497            "Provider response was incomplete: {:?}.",
498            response.finish_reason()
499        ));
500    }
501    Ok(())
502}
503
504/// `retry-after-ms` is read as a count of seconds first, so a thousandth of it is the wait.
505const MILLIS_PER_SECOND: u32 = 1000;
506
507/// The wait a provider asked for: `retry-after-ms`, or `Retry-After` in seconds or as an
508/// HTTP date.
509fn retry_after(error: &ProviderError) -> Option<Duration> {
510    wait_header(
511        error.provider_response_headers()?,
512        time::OffsetDateTime::now_utc(),
513    )
514}
515
516fn wait_header(
517    headers: &rig_core::http_client::HeaderMap,
518    now: time::OffsetDateTime,
519) -> Option<Duration> {
520    let header = |name: &str| headers.get(name)?.to_str().ok().map(str::trim);
521    let seconds = |value: &str| {
522        value
523            .parse::<f64>()
524            .ok()
525            .filter(|value| value.is_finite() && *value >= 0.0)
526            .and_then(|value| Duration::try_from_secs_f64(value).ok())
527    };
528    if let Some(wait) = header("retry-after-ms").and_then(seconds) {
529        return Some(wait / MILLIS_PER_SECOND);
530    }
531    let value = header("retry-after")?;
532    seconds(value).or_else(|| {
533        let at = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc2822)
534            .ok()?;
535        Some(Duration::try_from(at - now).unwrap_or_default())
536    })
537}
538
539/// A provider error as text, cleaned for a terminal but not clipped: callers decide what of
540/// it may be shown ([`crate::secret::server_text`]) before cutting it.
541fn diagnostic_text(error: &ProviderError) -> String {
542    clean_text(&message(error))
543}
544
545/// A provider's error for a request that carried `secrets` (an API key): the shared rule for
546/// server text, so an echoed key is withheld rather than shown.
547fn diagnostic_hiding(error: &ProviderError, secrets: &[&str]) -> String {
548    crate::secret::server_text(&diagnostic_text(error), secrets, MAX_DIAGNOSTIC_CHARS)
549}
550
551fn message(error: &ProviderError) -> String {
552    error
553        .provider_response()
554        .and_then(|response| serde_json::from_str::<Value>(&response.body).ok())
555        .and_then(|body| {
556            body.pointer("/error/message")
557                .or_else(|| body.get("message"))
558                .and_then(Value::as_str)
559                .map(str::to_owned)
560        })
561        .unwrap_or_else(|| error.to_string())
562}
563
564/// The environment variable that holds an API-key backend's key; `None` for codex.
565pub fn key_variable(backend: Backend) -> Option<&'static str> {
566    match backend {
567        Backend::Openai => Some("OPENAI_API_KEY"),
568        Backend::Anthropic => Some("ANTHROPIC_API_KEY"),
569        Backend::Codex => None,
570    }
571}
572
573/// Whether an API-key backend's key is set in the environment (never its value); always
574/// `false` for codex, which signs in instead.
575pub fn key_present(backend: Backend) -> bool {
576    key_variable(backend)
577        .and_then(crate::platform::environment::var_text)
578        .is_some_and(|key| !key.trim().is_empty())
579}
580
581/// The environment variable that points the backend at a loopback test endpoint.
582pub fn base_url_variable(backend: Backend) -> &'static str {
583    match backend {
584        Backend::Openai => "ARTIFACTIZE_OPENAI_BASE_URL",
585        Backend::Anthropic => "ARTIFACTIZE_ANTHROPIC_BASE_URL",
586        Backend::Codex => "ARTIFACTIZE_CODEX_BASE_URL",
587    }
588}
589
590/// Every test endpoint variable: the backends' API roots and the Codex sign-in root.
591const TEST_ENDPOINTS: [&str; 4] = [
592    "ARTIFACTIZE_OPENAI_BASE_URL",
593    "ARTIFACTIZE_ANTHROPIC_BASE_URL",
594    "ARTIFACTIZE_CODEX_BASE_URL",
595    crate::auth::codex::AUTH_URL_VARIABLE,
596];
597
598/// The loopback test endpoint that replaces the backend's API root, if one is set.
599pub fn test_endpoint(backend: Backend) -> Result<Option<String>, String> {
600    variable_endpoint(base_url_variable(backend))
601}
602
603/// The loopback test endpoint in `variable`, if set. An empty value is unset; any other
604/// value must be a loopback URL.
605pub fn variable_endpoint(variable: &str) -> Result<Option<String>, String> {
606    let Some(value) = crate::platform::environment::var(variable).filter(|value| !value.is_empty())
607    else {
608        return Ok(None);
609    };
610    value
611        .to_str()
612        .ok_or_else(|| "it is not UTF-8".to_owned())
613        .and_then(loopback_url)
614        .map(Some)
615        .map_err(|error| format!("{variable}: {error}."))
616}
617
618/// The first test endpoint variable that is set, valid or not.
619pub fn active_test_endpoint() -> Option<&'static str> {
620    TEST_ENDPOINTS.into_iter().find(|variable| {
621        crate::platform::environment::var(variable).is_some_and(|value| !value.is_empty())
622    })
623}
624
625/// An http(s) URL on localhost, 127.0.0.0/8 or [::1], without credentials, a query or a
626/// fragment, and without a trailing slash. A test endpoint never leaves the machine.
627fn loopback_url(value: &str) -> Result<String, String> {
628    let url = url::Url::parse(value).map_err(|_| "it is not a URL".to_owned())?;
629    let loopback = match url.host() {
630        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
631        Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip).is_loopback(),
632        Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip).is_loopback(),
633        None => false,
634    };
635    if !matches!(url.scheme(), "http" | "https") || !loopback {
636        return Err("a test endpoint must be http(s) on localhost, 127.0.0.0/8 or [::1]".into());
637    }
638    if !url.username().is_empty()
639        || url.password().is_some()
640        || url.query().is_some()
641        || url.fragment().is_some()
642    {
643        return Err("a test endpoint has no credentials, query or fragment".into());
644    }
645    Ok(url.as_str().trim_end_matches('/').to_owned())
646}
647
648/// The backend's API root: its loopback test endpoint, else the provider's.
649fn base_url(backend: Backend) -> Result<String, String> {
650    Ok(test_endpoint(backend)?.unwrap_or_else(|| {
651        match backend {
652            Backend::Openai => "https://api.openai.com/v1",
653            Backend::Anthropic => "https://api.anthropic.com",
654            Backend::Codex => codex::BASE_URL,
655        }
656        .into()
657    }))
658}
659
660/// The backend's API key from its environment variable; never printed or logged.
661fn api_key(backend: Backend, purpose: &str) -> Result<String, String> {
662    let variable = key_variable(backend).expect("an API-key backend");
663    crate::platform::environment::var_text(variable)
664        .filter(|key| !key.trim().is_empty())
665        .ok_or_else(|| format!("{variable} is required {purpose}."))
666}
667
668fn http_client() -> Result<reqwest::Client, String> {
669    reqwest::Client::builder()
670        .retry(reqwest::retry::never())
671        .redirect(reqwest::redirect::Policy::none())
672        .build()
673        .map_err(|error| error.to_string())
674}
675
676/// `message` cleaned for a terminal (escape sequences and control characters), unclipped.
677fn clean_text(message: &str) -> String {
678    String::from_utf8(crate::runtime::clean_output(message.as_bytes()))
679        .expect("clean output is UTF-8")
680}