1mod api;
5pub mod codex;
6mod degenerate;
7mod delivery;
8#[cfg(test)]
9mod delivery_tests;
10pub mod models;
11#[cfg(test)]
12pub(crate) mod tests;
13mod usage;
14
15use std::{
16 net::IpAddr,
17 path::Path,
18 sync::{Arc, Mutex},
19 time::Duration,
20};
21
22use futures_util::StreamExt;
23use rig_core::{
24 Model,
25 completion::{CompletionRequest, CompletionResponse, FinishReason},
26 error::ProviderError,
27 observe::{Action, AdapterContext, AdapterEvent, Observation, Subject, Witness},
28 providers::{anthropic, gemini, openai},
29 streaming::{CompletionStream, Item, StreamEvent},
30};
31use serde::{Deserialize, Serialize};
32use serde_json::{Map, Value, json};
33use tokio::time::Instant;
34use tokio_util::sync::CancellationToken;
35
36pub use api::{Api, EndpointApi};
37
38use crate::{
39 agent::error::{Code, Failure},
40 auth::providers::{self, ApiKey, Custom},
41 config::{Backend, Provider},
42};
43
44const ATTEMPTS: usize = 3;
46const RETRY_BACKOFF_BASE: Duration = Duration::from_millis(250);
49const MAX_DIAGNOSTIC_CHARS: usize = 4096;
51const ANTHROPIC_OUTPUT_TOKENS: u64 = 16_384;
54
55pub enum Client {
59 Openai(Box<Model<openai::responses_api::wire::Responses>>, String),
60 Chat(Box<Model<openai::wire::Chat>>, String),
61 Anthropic(Box<Model<anthropic::wire::Messages>>, String),
62 Gemini(Box<Model<gemini::completion::GenerateContent>>, String),
63 Codex(codex::Codex),
64}
65
66pub(crate) struct Route {
68 pub api: Api,
69 pub base: String,
70 pub key: Option<ApiKey>,
71}
72
73pub(crate) fn route(
77 backend: &Backend,
78 state: Option<&Path>,
79 repo: Option<&Path>,
80 purpose: &str,
81) -> Result<Route, Failure> {
82 let missing = |message: String| Failure::new(Code::Authentication, message);
83 let provider = match backend.builtin() {
84 Some(provider) => provider,
85 None => match providers::custom(backend, state, repo)? {
86 Some(Custom::Alias { provider }) => provider,
87 Some(Custom::Endpoint { api, url, key }) => {
88 return Ok(Route {
89 api: api.into(),
90 base: url.as_str().to_owned(),
91 key,
92 });
93 }
94 None => {
95 return Err(missing(format!(
96 "No provider named {backend} is set up on this machine; add it in Settings → Providers or with `artifactize provider add {backend}`."
97 )));
98 }
99 },
100 };
101 let key = match providers::key_variable(provider) {
102 Some(variable) => Some(providers::key(provider, state, repo)?.ok_or_else(|| {
103 missing(format!(
104 "{variable} or a saved {provider} key is required {purpose}."
105 ))
106 })?),
107 None => None,
108 };
109 Ok(Route {
110 api: provider.into(),
111 base: base_url(provider)?,
112 key,
113 })
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct Attempt {
119 #[serde(default)]
120 pub turn: usize,
121 #[serde(default)]
122 pub attempt: usize,
123 #[serde(default)]
124 pub usage: usage::Usage,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub error: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub error_code: Option<Code>,
129}
130
131struct Classified {
133 code: Code,
134 message: String,
135 retry_after: Option<Duration>,
136 output_failed: bool,
137}
138
139impl From<Classified> for Failure {
140 fn from(classified: Classified) -> Self {
141 Self {
142 output_failed: classified.output_failed,
143 ..Self::new(classified.code, classified.message)
144 }
145 }
146}
147
148impl From<Failure> for Classified {
149 fn from(failure: Failure) -> Self {
150 Self {
151 code: failure.code,
152 message: failure.message,
153 retry_after: None,
154 output_failed: failure.output_failed,
155 }
156 }
157}
158
159#[derive(Default)]
161struct ReportedUsage(Mutex<std::collections::BTreeMap<String, u64>>);
162
163impl Witness for ReportedUsage {
164 fn observe(&self, observation: Observation) {
165 if let Action::Adapter { observation } = observation.action
166 && let AdapterEvent::Usage { usage } = observation.event
167 {
168 let mut counters = self.0.lock().unwrap();
169 for (name, value) in [
170 ("inputTokens", usage.input_tokens),
171 ("outputTokens", usage.output_tokens),
172 ("totalTokens", usage.total_tokens),
173 ("cacheReadTokens", usage.cached_input_tokens),
174 ("reasoningTokens", usage.reasoning_tokens),
175 ] {
176 insert_counter(&mut counters, name, value);
177 }
178 }
179 }
180}
181
182fn insert_counter(
183 counters: &mut std::collections::BTreeMap<String, u64>,
184 name: &str,
185 value: Option<u64>,
186) {
187 if let Some(value) = value.filter(|value| *value <= crate::types::MAX_SAFE_JSON_INTEGER) {
188 counters.insert(name.into(), value);
189 }
190}
191
192impl Client {
193 pub fn new(
196 backend: &Backend,
197 model: &crate::config::ModelId,
198 state: &Path,
199 repo: &Path,
200 ) -> Result<Self, Failure> {
201 let Route { api, base, key } =
202 route(backend, Some(state), Some(repo), "for this Agent backend")?;
203 if api == Api::Codex {
204 return Ok(Self::Codex(codex::Codex::new(model, &base, state, repo)?));
205 }
206 let key = key.map(|key| key.expose().to_owned()).unwrap_or_default();
208 let http = delivery::Tap::new(http_client()?);
209 let openai = || {
210 openai::OpenAIConfig::new(key.clone())
211 .with_base_url(&base)
212 .connect(http.clone())
213 };
214 Ok(match api {
215 Api::OpenaiResponses => {
216 Self::Openai(Box::new(openai().responses(model.as_str())), key.clone())
217 }
218 Api::OpenaiChat => Self::Chat(Box::new(openai().chat(model.as_str())), key.clone()),
219 Api::Anthropic => Self::Anthropic(
220 Box::new(
221 anthropic::AnthropicConfig::new(key.clone())
222 .with_base_url(&base)
223 .connect(http)
224 .completion(model.as_str()),
225 ),
226 key,
227 ),
228 Api::Gemini => Self::Gemini(
229 Box::new(
230 gemini::GeminiConfig::new(key.clone())
231 .with_base_url(&base)
232 .connect(http)
233 .completion(model.as_str()),
234 ),
235 key,
236 ),
237 Api::Codex => unreachable!("built above"),
238 })
239 }
240
241 pub fn api(&self) -> Api {
243 match self {
244 Self::Openai(..) => Api::OpenaiResponses,
245 Self::Chat(..) => Api::OpenaiChat,
246 Self::Anthropic(..) => Api::Anthropic,
247 Self::Gemini(..) => Api::Gemini,
248 Self::Codex(_) => Api::Codex,
249 }
250 }
251
252 pub fn parameters(
256 api: Api,
257 reasoning: Option<&str>,
258 session: &crate::types::SessionId,
259 ) -> Result<Value, String> {
260 if let Some(reasoning) = reasoning {
261 api.validate_reasoning(reasoning)?;
262 }
263 Ok(match api {
264 Api::OpenaiChat => reasoning.map_or(
265 json!({}),
266 |effort| json!({"reasoning_effort":effort, "parallel_tool_calls":false}),
267 ),
268 Api::Gemini => reasoning.map_or(
269 json!({}),
270 |effort| json!({"generationConfig":{"thinkingConfig":{"thinkingLevel":effort}}}),
271 ),
272 Api::OpenaiResponses => {
273 let mut value = json!({
274 "store":false,
275 "parallel_tool_calls":false,
276 "include":["reasoning.encrypted_content"],
277 "prompt_cache_key":session,
278 });
279 if let Some(reasoning) = reasoning {
280 value["reasoning"] = json!({"effort":reasoning});
281 }
282 value
283 }
284 Api::Anthropic => reasoning.map_or(
285 json!({}),
286 |effort| json!({"thinking":{"type":"adaptive"}, "output_config":{"effort":effort}}),
287 ),
288 Api::Codex => {
291 let mut value = json!({"prompt_cache_key":session});
292 if let Some(effort) = reasoning {
293 value["reasoning"] = json!({"effort":effort, "summary":"auto"});
294 }
295 value
296 }
297 })
298 }
299
300 pub fn output_format(api: Api, parameters: &mut Value, schema: Value) {
304 fn section<'a>(parameters: &'a mut Value, name: &str) -> &'a mut Map<String, Value> {
305 parameters
306 .as_object_mut()
307 .expect("parameters object")
308 .entry(name)
309 .or_insert_with(|| json!({}))
310 .as_object_mut()
311 .expect("parameter section object")
312 }
313 match api {
314 Api::OpenaiResponses | Api::Codex => {
315 parameters["text"] = json!({"format":{
316 "type":"json_schema", "name":"artifactize_verdict", "strict":true,
317 "schema":schema,
318 }});
319 }
320 Api::OpenaiChat => {
321 parameters["response_format"] = json!({"type":"json_schema", "json_schema":{
322 "name":"artifactize_verdict", "strict":true, "schema":schema,
323 }});
324 }
325 Api::Anthropic => {
326 section(parameters, "output_config").insert(
327 "format".into(),
328 json!({"type":"json_schema", "schema":schema}),
329 );
330 }
331 Api::Gemini => {
332 let config = section(parameters, "generationConfig");
333 config.insert("responseMimeType".into(), json!("application/json"));
334 config.insert("responseJsonSchema".into(), schema);
335 }
336 }
337 }
338
339 async fn stream(
340 &self,
341 mut request: CompletionRequest,
342 observed: AdapterContext,
343 ) -> Result<CompletionStream, Classified> {
344 match self {
345 Self::Openai(model, _) => model.stream_observed(request, observed),
346 Self::Chat(model, _) => model.stream_observed(request, observed),
347 Self::Gemini(model, _) => model.stream_observed(request, observed),
348 Self::Anthropic(model, _) => {
349 request.max_tokens = Some(ANTHROPIC_OUTPUT_TOKENS);
350 model.stream_observed(request, observed)
351 }
352 Self::Codex(client) => {
354 let model = client.model().await.map_err(|error| Classified {
355 code: if error.transient {
356 Code::Transient
357 } else {
358 Code::Authentication
359 },
360 message: error.message,
361 retry_after: None,
362 output_failed: false,
363 })?;
364 model.stream_observed(request, observed)
365 }
366 }
367 .map_err(|error| self.classify(&error))
368 }
369
370 fn sent(&self) -> Vec<String> {
372 match self {
373 Self::Openai(_, key)
374 | Self::Chat(_, key)
375 | Self::Anthropic(_, key)
376 | Self::Gemini(_, key) => Some(key.clone())
377 .filter(|key| !key.is_empty())
378 .into_iter()
379 .collect(),
380 Self::Codex(client) => client.sent(),
381 }
382 }
383
384 fn classify(&self, error: &ProviderError) -> Classified {
388 let text = match self {
389 Self::Codex(_) => codex::diagnostic_text(error),
390 _ => diagnostic_text(error),
391 };
392 let sent = self.sent();
393 let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
394 let message = crate::secret::server_text(&text, &sent, MAX_DIAGNOSTIC_CHARS);
395 let report = error.report();
396 let status = report.http_status;
397 let text = format!("{} {text}", report.code.as_deref().unwrap_or_default()).to_lowercase();
399 let any = |terms: &[&str]| terms.iter().any(|term| text.contains(term));
400 let code = if matches!(self, Self::Codex(_)) && codex::usage_limited(error)
401 || any(&["quota", "billing", "credit balance", "usage limit"])
402 {
403 Code::Quota
404 } else if matches!(status, Some(401 | 403))
405 || any(&[
406 "authentication",
407 "unauthorized",
408 "permission",
409 "invalid_api_key",
410 "api key",
411 ])
412 {
413 Code::Authentication
414 } else if status == Some(429) || any(&["rate_limit", "rate limit"]) {
415 Code::RateLimit
416 } else if error.is_retryable()
417 || matches!(self, Self::Openai(..) | Self::Codex(_)) && codex::overloaded(error)
418 || status.is_some_and(|status| status >= 500)
419 || (status.is_none()
420 && matches!(
421 report.code.as_deref(),
422 Some(
425 "overloaded_error" | "server_is_overloaded" | "server_error" | "api_error"
426 )
427 ))
428 {
429 Code::Transient
430 } else {
431 Code::ProviderError
432 };
433 Classified {
434 code,
435 message,
436 retry_after: retry_after(error),
437 output_failed: false,
438 }
439 }
440
441 pub async fn turn(
450 &self,
451 request: &CompletionRequest,
452 context: Turn<'_>,
453 attempts: &mut Vec<Attempt>,
454 ) -> Result<CompletionResponse, Failure> {
455 self.turn_observed(request, context, attempts, &mut |_| {})
456 .await
457 }
458
459 pub async fn turn_observed(
461 &self,
462 request: &CompletionRequest,
463 context: Turn<'_>,
464 attempts: &mut Vec<Attempt>,
465 observe: &mut impl FnMut(crate::agent::session::Delivery),
466 ) -> Result<CompletionResponse, Failure> {
467 for attempt in 1..=ATTEMPTS {
468 if context.cancellation.is_cancelled() {
469 return Err(Failure::cancelled());
470 }
471 if Instant::now() >= context.deadline {
472 return Err(Failure::timeout());
473 }
474 let usage = Arc::new(ReportedUsage::default());
475 let observed = AdapterContext::new(
476 usage.clone(),
477 Subject::default(),
478 format!("turn-{}", context.number),
479 );
480 let mut emitted = false;
481 let mut tail = degenerate::Tail::default();
482 let sink = delivery::Sink::default();
483 let (result, partial) = sink
485 .scope(async {
486 let cancelled = || Classified::from(Failure::cancelled());
487 let timeout = || Classified::from(Failure::timeout());
488 let deadline = context.deadline;
489 let opened = tokio::select! {
490 biased;
491 _ = context.cancellation.cancelled() => return (Err(cancelled()), None),
492 _ = tokio::time::sleep_until(deadline) => return (Err(timeout()), None),
493 opened = self.stream(request.clone(), observed) => opened,
494 };
495 let mut stream = match opened {
496 Ok(stream) => stream,
497 Err(failure) => return (Err(failure), None),
498 };
499 let mut cadence = tokio::time::interval(delivery::CADENCE);
500 cadence.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
501 let read = loop {
502 tokio::select! {
503 biased;
504 _ = context.cancellation.cancelled() => break Err(cancelled()),
505 _ = tokio::time::sleep_until(deadline) => break Err(timeout()),
506 _ = cadence.tick() => {
507 for event in sink.take(context.number, attempt) {
508 observe(event);
509 }
510 }
511 item = stream.next() => match item {
512 Some(Ok(item)) => {
513 emitted = true;
514 sink.normalized(&item);
515 if let Item::Event(StreamEvent::Text { text, .. }) = &item
516 && let Err(failure) = tail.push(text)
517 {
518 break Err(Classified::from(failure));
519 }
520 if sink.ready() {
521 for event in sink.take(context.number, attempt) {
522 observe(event);
523 }
524 }
525 }
526 Some(Err(error)) => break Err(self.classify(&error)),
527 None => break Ok(()),
528 }
529 }
530 };
531 for event in sink.take(context.number, attempt) {
532 observe(event);
533 }
534 let partial = stream.partial();
535 let result = match read {
536 Ok(()) => stream.finish().await.map_err(|error| self.classify(&error)),
537 Err(error) => Err(error),
538 };
539 let final_response = result.as_ref().ok().unwrap_or(&partial);
540 if result.is_ok() {
541 sink.final_response(&final_response.choice);
542 }
543 for event in sink.take(context.number, attempt) {
544 observe(event);
545 }
546 if let Err(error) = &result {
547 observe(crate::agent::session::Delivery {
548 turn: context.number,
549 attempt,
550 block: crate::types::DeliveryBlock::attempt(),
551 kind: crate::agent::session::DeliveryKind::Text,
552 text: error.message.clone(),
553 state: crate::agent::session::DeliveryState::Interrupted,
554 });
555 }
556 (result, Some(partial))
557 })
558 .await;
559 let mut counters = usage.0.lock().unwrap().clone();
560 if let Some(partial) = &partial {
561 add_cache_usage(&mut counters, partial);
562 }
563 if let Ok(response) = &result {
564 add_cache_usage(&mut counters, response);
565 }
566 let used_tokens = counters.values().any(|value| *value > 0);
567 let spent = result
569 .as_ref()
570 .ok()
571 .or(partial.as_ref())
572 .and_then(|response| response.usage.total_tokens)
573 .unwrap_or(0);
574 *context.tokens_used = context.tokens_used.saturating_add(spent);
575 let result = result.and_then(|response| {
576 validate_response(&response, context.model)
577 .map(|()| response)
578 .map_err(Classified::from)
579 });
580 attempts.push(Attempt {
581 turn: context.number,
582 attempt,
583 usage: counters.into(),
584 error: result.as_ref().err().map(|failure| failure.message.clone()),
585 error_code: result.as_ref().err().map(|failure| failure.code),
586 });
587 let failure = match result {
588 Ok(response) => return Ok(response),
589 Err(failure) => failure,
590 };
591 let failure = if failure.code == Code::Transient && (emitted || used_tokens) {
593 Classified {
594 output_failed: true,
595 ..failure
596 }
597 } else {
598 failure
599 };
600 let retry = if failure.output_failed {
603 context
604 .max_tokens
605 .is_none_or(|limit| *context.tokens_used < limit)
606 } else {
607 failure.code.retryable() && !emitted && !used_tokens
608 };
609 if !retry || attempt == ATTEMPTS {
610 return Err(failure.into());
611 }
612 let wait = failure
613 .retry_after
614 .unwrap_or(RETRY_BACKOFF_BASE * (1 << (attempt - 1)));
615 if failure.retry_after.is_some()
617 && Instant::now()
618 .checked_add(wait)
619 .is_none_or(|end| end >= context.deadline)
620 {
621 return Err(Failure::new(
622 failure.code,
623 format!(
624 "{}. The provider asked to retry after {:.1} s, past the review deadline.",
625 failure.message.trim_end_matches('.'),
626 wait.as_secs_f64()
627 ),
628 ));
629 }
630 tokio::select! {
631 _ = context.cancellation.cancelled() => return Err(Failure::cancelled()),
632 _ = tokio::time::sleep_until(context.deadline) => return Err(Failure::timeout()),
633 _ = tokio::time::sleep(wait) => {},
634 }
635 }
636 unreachable!()
637 }
638}
639
640pub struct Turn<'a> {
641 pub number: usize,
642 pub model: &'a str,
644 pub deadline: Instant,
645 pub cancellation: &'a CancellationToken,
646 pub max_tokens: Option<u64>,
648 pub tokens_used: &'a mut u64,
650}
651
652fn add_cache_usage(
653 counters: &mut std::collections::BTreeMap<String, u64>,
654 response: &CompletionResponse,
655) {
656 insert_counter(
657 counters,
658 "cacheWriteTokens",
659 response.usage.cache_creation_input_tokens,
660 );
661 insert_counter(
662 counters,
663 "cacheWrite1hTokens",
664 response
665 .raw
666 .pointer("/usage/cache_creation/ephemeral_1h_input_tokens")
667 .and_then(Value::as_u64),
668 );
669}
670
671fn validate_response(response: &CompletionResponse, model: &str) -> Result<(), Failure> {
674 if response
675 .model
676 .as_deref()
677 .is_some_and(|actual| actual != model)
678 {
679 return Err(Failure::new(
680 Code::ProviderError,
681 format!(
682 "Provider model mismatch: requested {model:?}, received {:?}.",
683 response.model
684 ),
685 ));
686 }
687 let finish = response.finish_reason();
688 if !matches!(finish, Some(FinishReason::Stop | FinishReason::ToolCalls)) {
689 let failure = Failure::new(
690 Code::ProviderError,
691 format!("Provider response was incomplete: {finish:?}."),
692 );
693 return Err(if matches!(finish, Some(FinishReason::Length)) {
694 failure.of_output()
695 } else {
696 failure
697 });
698 }
699 Ok(())
700}
701
702const MILLIS_PER_SECOND: u32 = 1000;
704
705fn retry_after(error: &ProviderError) -> Option<Duration> {
708 wait_header(
709 error.provider_response_headers()?,
710 time::OffsetDateTime::now_utc(),
711 )
712}
713
714fn wait_header(
715 headers: &rig_core::http_client::HeaderMap,
716 now: time::OffsetDateTime,
717) -> Option<Duration> {
718 let header = |name: &str| headers.get(name)?.to_str().ok().map(str::trim);
719 let seconds = |value: &str| {
720 value
721 .parse::<f64>()
722 .ok()
723 .filter(|value| value.is_finite() && *value >= 0.0)
724 .and_then(|value| Duration::try_from_secs_f64(value).ok())
725 };
726 if let Some(wait) = header("retry-after-ms").and_then(seconds) {
727 return Some(wait / MILLIS_PER_SECOND);
728 }
729 let value = header("retry-after")?;
730 seconds(value).or_else(|| {
731 let at = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc2822)
732 .ok()?;
733 Some(Duration::try_from(at - now).unwrap_or_default())
734 })
735}
736
737fn diagnostic_text(error: &ProviderError) -> String {
740 clean_text(&message(error))
741}
742
743fn diagnostic_hiding(error: &ProviderError, secrets: &[&str]) -> String {
746 crate::secret::server_text(&diagnostic_text(error), secrets, MAX_DIAGNOSTIC_CHARS)
747}
748
749fn message(error: &ProviderError) -> String {
750 error
751 .provider_response()
752 .and_then(|response| serde_json::from_str::<Value>(&response.body).ok())
753 .and_then(|body| {
754 body.pointer("/error/message")
755 .or_else(|| body.get("message"))
756 .and_then(Value::as_str)
757 .map(str::to_owned)
758 })
759 .unwrap_or_else(|| error.to_string())
760}
761
762pub fn base_url_variable(provider: Provider) -> &'static str {
764 match provider {
765 Provider::Openai => "ARTIFACTIZE_OPENAI_BASE_URL",
766 Provider::Anthropic => "ARTIFACTIZE_ANTHROPIC_BASE_URL",
767 Provider::Gemini => "ARTIFACTIZE_GEMINI_BASE_URL",
768 Provider::Codex => "ARTIFACTIZE_CODEX_BASE_URL",
769 }
770}
771
772const TEST_ENDPOINTS: [&str; 5] = [
774 "ARTIFACTIZE_OPENAI_BASE_URL",
775 "ARTIFACTIZE_ANTHROPIC_BASE_URL",
776 "ARTIFACTIZE_GEMINI_BASE_URL",
777 "ARTIFACTIZE_CODEX_BASE_URL",
778 crate::auth::codex::AUTH_URL_VARIABLE,
779];
780
781#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct TestEndpoint(url::Url);
785
786impl TestEndpoint {
787 pub fn root(&self) -> &str {
790 self.0.as_str().trim_end_matches('/')
791 }
792}
793
794impl std::str::FromStr for TestEndpoint {
795 type Err = String;
796 fn from_str(value: &str) -> Result<Self, Self::Err> {
797 loopback_url(value)
798 }
799}
800
801impl std::fmt::Display for TestEndpoint {
802 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803 formatter.write_str(self.root())
804 }
805}
806
807impl Serialize for TestEndpoint {
808 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
809 serializer.serialize_str(self.root())
810 }
811}
812
813pub fn test_endpoint(provider: Provider) -> Result<Option<TestEndpoint>, String> {
815 variable_endpoint(base_url_variable(provider))
816}
817
818pub fn variable_endpoint(variable: &str) -> Result<Option<TestEndpoint>, String> {
821 variable_endpoint_in(variable, crate::platform::environment::var)
822}
823
824pub fn variable_endpoint_in(
826 variable: &str,
827 var: impl Fn(&str) -> Option<std::ffi::OsString>,
828) -> Result<Option<TestEndpoint>, String> {
829 let Some(value) = var(variable).filter(|value| !value.is_empty()) else {
830 return Ok(None);
831 };
832 value
833 .to_str()
834 .ok_or_else(|| "it is not UTF-8".to_owned())
835 .and_then(loopback_url)
836 .map(Some)
837 .map_err(|error| format!("{variable}: {error}."))
838}
839
840pub fn active_test_endpoint() -> Option<&'static str> {
842 TEST_ENDPOINTS.into_iter().find(|variable| {
843 crate::platform::environment::var(variable).is_some_and(|value| !value.is_empty())
844 })
845}
846
847fn loopback_url(value: &str) -> Result<TestEndpoint, String> {
849 let url = url::Url::parse(value).map_err(|_| "it is not a URL".to_owned())?;
850 let loopback = match url.host() {
851 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
852 Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip).is_loopback(),
853 Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip).is_loopback(),
854 None => false,
855 };
856 if !matches!(url.scheme(), "http" | "https") || !loopback {
857 return Err("a test endpoint must be http(s) on localhost, 127.0.0.0/8 or [::1]".into());
858 }
859 if !url.username().is_empty()
860 || url.password().is_some()
861 || url.query().is_some()
862 || url.fragment().is_some()
863 {
864 return Err("a test endpoint has no credentials, query or fragment".into());
865 }
866 Ok(TestEndpoint(url))
867}
868
869fn base_url(provider: Provider) -> Result<String, String> {
871 Ok(test_endpoint(provider)?.map_or_else(
872 || {
873 match provider {
874 Provider::Openai => "https://api.openai.com/v1",
875 Provider::Anthropic => "https://api.anthropic.com",
876 Provider::Gemini => gemini::BASE_URL,
877 Provider::Codex => codex::BASE_URL,
878 }
879 .into()
880 },
881 |endpoint| endpoint.root().to_owned(),
882 ))
883}
884
885fn http_client() -> Result<reqwest::Client, String> {
886 reqwest::Client::builder()
887 .retry(reqwest::retry::never())
888 .redirect(reqwest::redirect::Policy::none())
889 .build()
890 .map_err(|error| error.to_string())
891}
892
893fn clean_text(message: &str) -> String {
895 String::from_utf8(crate::runtime::clean_output(message.as_bytes()))
896 .expect("clean output is UTF-8")
897}