1pub 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
39const ATTEMPTS: usize = 3;
41const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(250);
44const MAX_DIAGNOSTIC_CHARS: usize = 4096;
46const ANTHROPIC_OUTPUT_TOKENS: u64 = 16_384;
49
50pub 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
73struct 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#[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 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 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 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 pub fn output_format(backend: Backend, parameters: &mut Value, schema: Value) {
209 match backend {
210 Backend::Openai | Backend::Codex => {
211 parameters["text"] = json!({"format":{
212 "type":"json_schema", "name":"artifactize_verdict", "strict":true,
213 "schema":schema,
214 }});
215 }
216 Backend::Anthropic => {
217 let config = parameters
218 .as_object_mut()
219 .expect("parameters object")
220 .entry("output_config")
221 .or_insert_with(|| json!({}));
222 config["format"] = json!({"type":"json_schema", "schema":schema});
223 }
224 }
225 }
226
227 async fn stream(
228 &self,
229 mut request: CompletionRequest,
230 observed: AdapterContext,
231 ) -> Result<CompletionStream, Classified> {
232 match self {
233 Self::Openai(model, _) => model.stream_observed(request, observed),
234 Self::Anthropic(model, _) => {
235 request.max_tokens = Some(ANTHROPIC_OUTPUT_TOKENS);
236 model.stream_observed(request, observed)
237 }
238 Self::Codex(client) => {
240 let model = client.model().await.map_err(|error| Classified {
241 code: if error.transient {
242 Code::Transient
243 } else {
244 Code::Authentication
245 },
246 message: error.message,
247 retry_after: None,
248 })?;
249 model.stream_observed(request, observed)
250 }
251 }
252 .map_err(|error| self.classify(&error))
253 }
254
255 fn sent(&self) -> Vec<String> {
257 match self {
258 Self::Openai(_, key) | Self::Anthropic(_, key) => vec![key.clone()],
259 Self::Codex(client) => client.sent(),
260 }
261 }
262
263 fn classify(&self, error: &ProviderError) -> Classified {
267 let text = match self {
268 Self::Codex(_) => codex::diagnostic_text(error),
269 _ => diagnostic_text(error),
270 };
271 let sent = self.sent();
272 let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
273 let message = crate::secret::server_text(&text, &sent, MAX_DIAGNOSTIC_CHARS);
274 let report = error.report();
275 let status = report.http_status;
276 let text = format!("{} {text}", report.code.as_deref().unwrap_or_default()).to_lowercase();
278 let any = |terms: &[&str]| terms.iter().any(|term| text.contains(term));
279 let code = if matches!(self, Self::Codex(_)) && codex::usage_limited(error)
280 || any(&["quota", "billing", "credit balance", "usage limit"])
281 {
282 Code::Quota
283 } else if matches!(status, Some(401 | 403))
284 || any(&[
285 "authentication",
286 "unauthorized",
287 "permission",
288 "invalid_api_key",
289 "api key",
290 ])
291 {
292 Code::Authentication
293 } else if status == Some(429) || any(&["rate_limit", "rate limit"]) {
294 Code::RateLimit
295 } else if error.is_retryable()
296 || matches!(self, Self::Openai(..) | Self::Codex(_)) && codex::overloaded(error)
297 || status.is_some_and(|status| status >= 500)
298 || (status.is_none()
299 && matches!(
300 report.code.as_deref(),
301 Some(
304 "overloaded_error" | "server_is_overloaded" | "server_error" | "api_error"
305 )
306 ))
307 {
308 Code::Transient
309 } else {
310 Code::ProviderError
311 };
312 Classified {
313 code,
314 message,
315 retry_after: retry_after(error),
316 }
317 }
318
319 pub async fn turn(
324 &self,
325 request: &CompletionRequest,
326 context: Turn<'_>,
327 attempts: &mut Vec<Attempt>,
328 ) -> Result<CompletionResponse, Failure> {
329 self.turn_observed(request, context, attempts, &mut |_| {})
330 .await
331 }
332
333 pub async fn turn_observed(
335 &self,
336 request: &CompletionRequest,
337 context: Turn<'_>,
338 attempts: &mut Vec<Attempt>,
339 observe: &mut impl FnMut(crate::agent::session::Delivery),
340 ) -> Result<CompletionResponse, Failure> {
341 for attempt in 1..=ATTEMPTS {
342 if context.cancellation.is_cancelled() {
343 return Err(Failure::cancelled());
344 }
345 if Instant::now() >= context.deadline {
346 return Err(Failure::timeout());
347 }
348 let usage = Arc::new(ReportedUsage::default());
349 let observed = AdapterContext::new(
350 usage.clone(),
351 Subject::default(),
352 format!("turn-{}", context.number),
353 );
354 let mut emitted = false;
355 let sink = delivery::Sink::default();
356 let (result, partial) = sink
358 .scope(async {
359 let cancelled = || Classified::from(Failure::cancelled());
360 let timeout = || Classified::from(Failure::timeout());
361 let deadline = context.deadline;
362 let opened = tokio::select! {
363 biased;
364 _ = context.cancellation.cancelled() => return (Err(cancelled()), None),
365 _ = tokio::time::sleep_until(deadline) => return (Err(timeout()), None),
366 opened = self.stream(request.clone(), observed) => opened,
367 };
368 let mut stream = match opened {
369 Ok(stream) => stream,
370 Err(failure) => return (Err(failure), None),
371 };
372 let mut cadence = tokio::time::interval(delivery::CADENCE);
373 cadence.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
374 let read = loop {
375 tokio::select! {
376 biased;
377 _ = context.cancellation.cancelled() => break Err(cancelled()),
378 _ = tokio::time::sleep_until(deadline) => break Err(timeout()),
379 _ = cadence.tick() => {
380 for event in sink.take(context.number, attempt) {
381 observe(event);
382 }
383 }
384 item = stream.next() => match item {
385 Some(Ok(item)) => {
386 emitted = true;
387 sink.normalized(&item);
388 if sink.ready() {
389 for event in sink.take(context.number, attempt) {
390 observe(event);
391 }
392 }
393 }
394 Some(Err(error)) => break Err(self.classify(&error)),
395 None => break Ok(()),
396 }
397 }
398 };
399 for event in sink.take(context.number, attempt) {
400 observe(event);
401 }
402 let partial = stream.partial();
403 let result = match read {
404 Ok(()) => stream.finish().await.map_err(|error| self.classify(&error)),
405 Err(error) => Err(error),
406 };
407 let final_response = result.as_ref().ok().unwrap_or(&partial);
408 if result.is_ok() {
409 sink.final_response(&final_response.choice);
410 }
411 for event in sink.take(context.number, attempt) {
412 observe(event);
413 }
414 if let Err(error) = &result {
415 observe(crate::agent::session::Delivery {
416 turn: context.number,
417 attempt,
418 block: crate::types::DeliveryBlock::attempt(),
419 kind: crate::agent::session::DeliveryKind::Text,
420 text: error.message.clone(),
421 state: crate::agent::session::DeliveryState::Interrupted,
422 });
423 }
424 (result, Some(partial))
425 })
426 .await;
427 let mut counters = usage.0.lock().unwrap().clone();
428 if let Some(partial) = &partial {
429 add_cache_usage(&mut counters, partial);
430 }
431 if let Ok(response) = &result {
432 add_cache_usage(&mut counters, response);
433 }
434 let used_tokens = counters.values().any(|value| *value > 0);
435 attempts.push(Attempt {
436 turn: context.number,
437 attempt,
438 usage: counters.into(),
439 error: result.as_ref().err().map(|failure| failure.message.clone()),
440 error_code: result.as_ref().err().map(|failure| failure.code),
441 });
442 let failure = match result {
443 Ok(response) => return Ok(response),
444 Err(failure) => failure,
445 };
446 if !failure.code.retryable() || emitted || used_tokens || attempt == ATTEMPTS {
447 return Err(failure.into());
448 }
449 let wait = failure
450 .retry_after
451 .unwrap_or(RETRY_BACKOFF_BASE * (1 << (attempt - 1)));
452 if failure.retry_after.is_some()
454 && Instant::now()
455 .checked_add(wait)
456 .is_none_or(|end| end >= context.deadline)
457 {
458 return Err(Failure::new(
459 failure.code,
460 format!(
461 "{}. The provider asked to retry after {:.1} s, past the review deadline.",
462 failure.message.trim_end_matches('.'),
463 wait.as_secs_f64()
464 ),
465 ));
466 }
467 tokio::select! {
468 _ = context.cancellation.cancelled() => return Err(Failure::cancelled()),
469 _ = tokio::time::sleep_until(context.deadline) => return Err(Failure::timeout()),
470 _ = tokio::time::sleep(wait) => {},
471 }
472 }
473 unreachable!()
474 }
475}
476
477pub struct Turn<'a> {
478 pub number: usize,
479 pub deadline: Instant,
480 pub cancellation: &'a CancellationToken,
481}
482
483fn add_cache_usage(
484 counters: &mut std::collections::BTreeMap<String, u64>,
485 response: &CompletionResponse,
486) {
487 insert_counter(
488 counters,
489 "cacheWriteTokens",
490 response.usage.cache_creation_input_tokens,
491 );
492 insert_counter(
493 counters,
494 "cacheWrite1hTokens",
495 response
496 .raw
497 .pointer("/usage/cache_creation/ephemeral_1h_input_tokens")
498 .and_then(Value::as_u64),
499 );
500}
501
502pub fn validate_response(response: &CompletionResponse, model: &str) -> Result<(), String> {
503 if response
504 .model
505 .as_deref()
506 .is_some_and(|actual| actual != model)
507 {
508 return Err(format!(
509 "Provider model mismatch: requested {model:?}, received {:?}.",
510 response.model
511 ));
512 }
513 if !matches!(
514 response.finish_reason(),
515 Some(FinishReason::Stop | FinishReason::ToolCalls)
516 ) {
517 return Err(format!(
518 "Provider response was incomplete: {:?}.",
519 response.finish_reason()
520 ));
521 }
522 Ok(())
523}
524
525const MILLIS_PER_SECOND: u32 = 1000;
527
528fn retry_after(error: &ProviderError) -> Option<Duration> {
531 wait_header(
532 error.provider_response_headers()?,
533 time::OffsetDateTime::now_utc(),
534 )
535}
536
537fn wait_header(
538 headers: &rig_core::http_client::HeaderMap,
539 now: time::OffsetDateTime,
540) -> Option<Duration> {
541 let header = |name: &str| headers.get(name)?.to_str().ok().map(str::trim);
542 let seconds = |value: &str| {
543 value
544 .parse::<f64>()
545 .ok()
546 .filter(|value| value.is_finite() && *value >= 0.0)
547 .and_then(|value| Duration::try_from_secs_f64(value).ok())
548 };
549 if let Some(wait) = header("retry-after-ms").and_then(seconds) {
550 return Some(wait / MILLIS_PER_SECOND);
551 }
552 let value = header("retry-after")?;
553 seconds(value).or_else(|| {
554 let at = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc2822)
555 .ok()?;
556 Some(Duration::try_from(at - now).unwrap_or_default())
557 })
558}
559
560fn diagnostic_text(error: &ProviderError) -> String {
563 clean_text(&message(error))
564}
565
566fn diagnostic_hiding(error: &ProviderError, secrets: &[&str]) -> String {
569 crate::secret::server_text(&diagnostic_text(error), secrets, MAX_DIAGNOSTIC_CHARS)
570}
571
572fn message(error: &ProviderError) -> String {
573 error
574 .provider_response()
575 .and_then(|response| serde_json::from_str::<Value>(&response.body).ok())
576 .and_then(|body| {
577 body.pointer("/error/message")
578 .or_else(|| body.get("message"))
579 .and_then(Value::as_str)
580 .map(str::to_owned)
581 })
582 .unwrap_or_else(|| error.to_string())
583}
584
585pub fn key_variable(backend: Backend) -> Option<&'static str> {
587 match backend {
588 Backend::Openai => Some("OPENAI_API_KEY"),
589 Backend::Anthropic => Some("ANTHROPIC_API_KEY"),
590 Backend::Codex => None,
591 }
592}
593
594pub fn key_present(backend: Backend) -> bool {
597 key_variable(backend)
598 .and_then(crate::platform::environment::var_text)
599 .is_some_and(|key| !key.trim().is_empty())
600}
601
602pub fn base_url_variable(backend: Backend) -> &'static str {
604 match backend {
605 Backend::Openai => "ARTIFACTIZE_OPENAI_BASE_URL",
606 Backend::Anthropic => "ARTIFACTIZE_ANTHROPIC_BASE_URL",
607 Backend::Codex => "ARTIFACTIZE_CODEX_BASE_URL",
608 }
609}
610
611const TEST_ENDPOINTS: [&str; 4] = [
613 "ARTIFACTIZE_OPENAI_BASE_URL",
614 "ARTIFACTIZE_ANTHROPIC_BASE_URL",
615 "ARTIFACTIZE_CODEX_BASE_URL",
616 crate::auth::codex::AUTH_URL_VARIABLE,
617];
618
619pub fn test_endpoint(backend: Backend) -> Result<Option<String>, String> {
621 variable_endpoint(base_url_variable(backend))
622}
623
624pub fn variable_endpoint(variable: &str) -> Result<Option<String>, String> {
627 let Some(value) = crate::platform::environment::var(variable).filter(|value| !value.is_empty())
628 else {
629 return Ok(None);
630 };
631 value
632 .to_str()
633 .ok_or_else(|| "it is not UTF-8".to_owned())
634 .and_then(loopback_url)
635 .map(Some)
636 .map_err(|error| format!("{variable}: {error}."))
637}
638
639pub fn active_test_endpoint() -> Option<&'static str> {
641 TEST_ENDPOINTS.into_iter().find(|variable| {
642 crate::platform::environment::var(variable).is_some_and(|value| !value.is_empty())
643 })
644}
645
646fn loopback_url(value: &str) -> Result<String, String> {
649 let url = url::Url::parse(value).map_err(|_| "it is not a URL".to_owned())?;
650 let loopback = match url.host() {
651 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
652 Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip).is_loopback(),
653 Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip).is_loopback(),
654 None => false,
655 };
656 if !matches!(url.scheme(), "http" | "https") || !loopback {
657 return Err("a test endpoint must be http(s) on localhost, 127.0.0.0/8 or [::1]".into());
658 }
659 if !url.username().is_empty()
660 || url.password().is_some()
661 || url.query().is_some()
662 || url.fragment().is_some()
663 {
664 return Err("a test endpoint has no credentials, query or fragment".into());
665 }
666 Ok(url.as_str().trim_end_matches('/').to_owned())
667}
668
669fn base_url(backend: Backend) -> Result<String, String> {
671 Ok(test_endpoint(backend)?.unwrap_or_else(|| {
672 match backend {
673 Backend::Openai => "https://api.openai.com/v1",
674 Backend::Anthropic => "https://api.anthropic.com",
675 Backend::Codex => codex::BASE_URL,
676 }
677 .into()
678 }))
679}
680
681fn api_key(backend: Backend, purpose: &str) -> Result<String, String> {
683 let variable = key_variable(backend).expect("an API-key backend");
684 crate::platform::environment::var_text(variable)
685 .filter(|key| !key.trim().is_empty())
686 .ok_or_else(|| format!("{variable} is required {purpose}."))
687}
688
689fn http_client() -> Result<reqwest::Client, String> {
690 reqwest::Client::builder()
691 .retry(reqwest::retry::never())
692 .redirect(reqwest::redirect::Policy::none())
693 .build()
694 .map_err(|error| error.to_string())
695}
696
697fn clean_text(message: &str) -> String {
699 String::from_utf8(crate::runtime::clean_output(message.as_bytes()))
700 .expect("clean output is UTF-8")
701}