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}
137
138impl From<Classified> for Failure {
139 fn from(classified: Classified) -> Self {
140 Self::new(classified.code, classified.message)
141 }
142}
143
144impl From<Failure> for Classified {
145 fn from(failure: Failure) -> Self {
146 Self {
147 code: failure.code,
148 message: failure.message,
149 retry_after: None,
150 }
151 }
152}
153
154#[derive(Default)]
156struct ReportedUsage(Mutex<std::collections::BTreeMap<String, u64>>);
157
158impl Witness for ReportedUsage {
159 fn observe(&self, observation: Observation) {
160 if let Action::Adapter { observation } = observation.action
161 && let AdapterEvent::Usage { usage } = observation.event
162 {
163 let mut counters = self.0.lock().unwrap();
164 for (name, value) in [
165 ("inputTokens", usage.input_tokens),
166 ("outputTokens", usage.output_tokens),
167 ("totalTokens", usage.total_tokens),
168 ("cacheReadTokens", usage.cached_input_tokens),
169 ("reasoningTokens", usage.reasoning_tokens),
170 ] {
171 insert_counter(&mut counters, name, value);
172 }
173 }
174 }
175}
176
177fn insert_counter(
178 counters: &mut std::collections::BTreeMap<String, u64>,
179 name: &str,
180 value: Option<u64>,
181) {
182 if let Some(value) = value.filter(|value| *value <= crate::types::MAX_SAFE_JSON_INTEGER) {
183 counters.insert(name.into(), value);
184 }
185}
186
187impl Client {
188 pub fn new(
191 backend: &Backend,
192 model: &crate::config::ModelId,
193 state: &Path,
194 repo: &Path,
195 ) -> Result<Self, Failure> {
196 let Route { api, base, key } =
197 route(backend, Some(state), Some(repo), "for this Agent backend")?;
198 if api == Api::Codex {
199 return Ok(Self::Codex(codex::Codex::new(model, &base, state, repo)?));
200 }
201 let key = key.map(|key| key.expose().to_owned()).unwrap_or_default();
203 let http = delivery::Tap::new(http_client()?);
204 let openai = || {
205 openai::OpenAIConfig::new(key.clone())
206 .with_base_url(&base)
207 .connect(http.clone())
208 };
209 Ok(match api {
210 Api::OpenaiResponses => {
211 Self::Openai(Box::new(openai().responses(model.as_str())), key.clone())
212 }
213 Api::OpenaiChat => Self::Chat(Box::new(openai().chat(model.as_str())), key.clone()),
214 Api::Anthropic => Self::Anthropic(
215 Box::new(
216 anthropic::AnthropicConfig::new(key.clone())
217 .with_base_url(&base)
218 .connect(http)
219 .completion(model.as_str()),
220 ),
221 key,
222 ),
223 Api::Gemini => Self::Gemini(
224 Box::new(
225 gemini::GeminiConfig::new(key.clone())
226 .with_base_url(&base)
227 .connect(http)
228 .completion(model.as_str()),
229 ),
230 key,
231 ),
232 Api::Codex => unreachable!("built above"),
233 })
234 }
235
236 pub fn api(&self) -> Api {
238 match self {
239 Self::Openai(..) => Api::OpenaiResponses,
240 Self::Chat(..) => Api::OpenaiChat,
241 Self::Anthropic(..) => Api::Anthropic,
242 Self::Gemini(..) => Api::Gemini,
243 Self::Codex(_) => Api::Codex,
244 }
245 }
246
247 pub fn parameters(
251 api: Api,
252 reasoning: Option<&str>,
253 session: &crate::types::SessionId,
254 ) -> Result<Value, String> {
255 if let Some(reasoning) = reasoning {
256 api.validate_reasoning(reasoning)?;
257 }
258 Ok(match api {
259 Api::OpenaiChat => reasoning.map_or(
260 json!({}),
261 |effort| json!({"reasoning_effort":effort, "parallel_tool_calls":false}),
262 ),
263 Api::Gemini => reasoning.map_or(
264 json!({}),
265 |effort| json!({"generationConfig":{"thinkingConfig":{"thinkingLevel":effort}}}),
266 ),
267 Api::OpenaiResponses => {
268 let mut value = json!({
269 "store":false,
270 "parallel_tool_calls":false,
271 "include":["reasoning.encrypted_content"],
272 "prompt_cache_key":session,
273 });
274 if let Some(reasoning) = reasoning {
275 value["reasoning"] = json!({"effort":reasoning});
276 }
277 value
278 }
279 Api::Anthropic => reasoning.map_or(
280 json!({}),
281 |effort| json!({"thinking":{"type":"adaptive"}, "output_config":{"effort":effort}}),
282 ),
283 Api::Codex => {
286 let mut value = json!({"prompt_cache_key":session});
287 if let Some(effort) = reasoning {
288 value["reasoning"] = json!({"effort":effort, "summary":"auto"});
289 }
290 value
291 }
292 })
293 }
294
295 pub fn output_format(api: Api, parameters: &mut Value, schema: Value) {
299 fn section<'a>(parameters: &'a mut Value, name: &str) -> &'a mut Map<String, Value> {
300 parameters
301 .as_object_mut()
302 .expect("parameters object")
303 .entry(name)
304 .or_insert_with(|| json!({}))
305 .as_object_mut()
306 .expect("parameter section object")
307 }
308 match api {
309 Api::OpenaiResponses | Api::Codex => {
310 parameters["text"] = json!({"format":{
311 "type":"json_schema", "name":"artifactize_verdict", "strict":true,
312 "schema":schema,
313 }});
314 }
315 Api::OpenaiChat => {
316 parameters["response_format"] = json!({"type":"json_schema", "json_schema":{
317 "name":"artifactize_verdict", "strict":true, "schema":schema,
318 }});
319 }
320 Api::Anthropic => {
321 section(parameters, "output_config").insert(
322 "format".into(),
323 json!({"type":"json_schema", "schema":schema}),
324 );
325 }
326 Api::Gemini => {
327 let config = section(parameters, "generationConfig");
328 config.insert("responseMimeType".into(), json!("application/json"));
329 config.insert("responseJsonSchema".into(), schema);
330 }
331 }
332 }
333
334 async fn stream(
335 &self,
336 mut request: CompletionRequest,
337 observed: AdapterContext,
338 ) -> Result<CompletionStream, Classified> {
339 match self {
340 Self::Openai(model, _) => model.stream_observed(request, observed),
341 Self::Chat(model, _) => model.stream_observed(request, observed),
342 Self::Gemini(model, _) => model.stream_observed(request, observed),
343 Self::Anthropic(model, _) => {
344 request.max_tokens = Some(ANTHROPIC_OUTPUT_TOKENS);
345 model.stream_observed(request, observed)
346 }
347 Self::Codex(client) => {
349 let model = client.model().await.map_err(|error| Classified {
350 code: if error.transient {
351 Code::Transient
352 } else {
353 Code::Authentication
354 },
355 message: error.message,
356 retry_after: None,
357 })?;
358 model.stream_observed(request, observed)
359 }
360 }
361 .map_err(|error| self.classify(&error))
362 }
363
364 fn sent(&self) -> Vec<String> {
366 match self {
367 Self::Openai(_, key)
368 | Self::Chat(_, key)
369 | Self::Anthropic(_, key)
370 | Self::Gemini(_, key) => Some(key.clone())
371 .filter(|key| !key.is_empty())
372 .into_iter()
373 .collect(),
374 Self::Codex(client) => client.sent(),
375 }
376 }
377
378 fn classify(&self, error: &ProviderError) -> Classified {
382 let text = match self {
383 Self::Codex(_) => codex::diagnostic_text(error),
384 _ => diagnostic_text(error),
385 };
386 let sent = self.sent();
387 let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
388 let message = crate::secret::server_text(&text, &sent, MAX_DIAGNOSTIC_CHARS);
389 let report = error.report();
390 let status = report.http_status;
391 let text = format!("{} {text}", report.code.as_deref().unwrap_or_default()).to_lowercase();
393 let any = |terms: &[&str]| terms.iter().any(|term| text.contains(term));
394 let code = if matches!(self, Self::Codex(_)) && codex::usage_limited(error)
395 || any(&["quota", "billing", "credit balance", "usage limit"])
396 {
397 Code::Quota
398 } else if matches!(status, Some(401 | 403))
399 || any(&[
400 "authentication",
401 "unauthorized",
402 "permission",
403 "invalid_api_key",
404 "api key",
405 ])
406 {
407 Code::Authentication
408 } else if status == Some(429) || any(&["rate_limit", "rate limit"]) {
409 Code::RateLimit
410 } else if error.is_retryable()
411 || matches!(self, Self::Openai(..) | Self::Codex(_)) && codex::overloaded(error)
412 || status.is_some_and(|status| status >= 500)
413 || (status.is_none()
414 && matches!(
415 report.code.as_deref(),
416 Some(
419 "overloaded_error" | "server_is_overloaded" | "server_error" | "api_error"
420 )
421 ))
422 {
423 Code::Transient
424 } else {
425 Code::ProviderError
426 };
427 Classified {
428 code,
429 message,
430 retry_after: retry_after(error),
431 }
432 }
433
434 pub async fn turn(
440 &self,
441 request: &CompletionRequest,
442 context: Turn<'_>,
443 attempts: &mut Vec<Attempt>,
444 ) -> Result<CompletionResponse, Failure> {
445 self.turn_observed(request, context, attempts, &mut |_| {})
446 .await
447 }
448
449 pub async fn turn_observed(
451 &self,
452 request: &CompletionRequest,
453 context: Turn<'_>,
454 attempts: &mut Vec<Attempt>,
455 observe: &mut impl FnMut(crate::agent::session::Delivery),
456 ) -> Result<CompletionResponse, Failure> {
457 for attempt in 1..=ATTEMPTS {
458 if context.cancellation.is_cancelled() {
459 return Err(Failure::cancelled());
460 }
461 if Instant::now() >= context.deadline {
462 return Err(Failure::timeout());
463 }
464 let usage = Arc::new(ReportedUsage::default());
465 let observed = AdapterContext::new(
466 usage.clone(),
467 Subject::default(),
468 format!("turn-{}", context.number),
469 );
470 let mut emitted = false;
471 let mut tail = degenerate::Tail::default();
472 let sink = delivery::Sink::default();
473 let (result, partial) = sink
475 .scope(async {
476 let cancelled = || Classified::from(Failure::cancelled());
477 let timeout = || Classified::from(Failure::timeout());
478 let deadline = context.deadline;
479 let opened = tokio::select! {
480 biased;
481 _ = context.cancellation.cancelled() => return (Err(cancelled()), None),
482 _ = tokio::time::sleep_until(deadline) => return (Err(timeout()), None),
483 opened = self.stream(request.clone(), observed) => opened,
484 };
485 let mut stream = match opened {
486 Ok(stream) => stream,
487 Err(failure) => return (Err(failure), None),
488 };
489 let mut cadence = tokio::time::interval(delivery::CADENCE);
490 cadence.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
491 let read = loop {
492 tokio::select! {
493 biased;
494 _ = context.cancellation.cancelled() => break Err(cancelled()),
495 _ = tokio::time::sleep_until(deadline) => break Err(timeout()),
496 _ = cadence.tick() => {
497 for event in sink.take(context.number, attempt) {
498 observe(event);
499 }
500 }
501 item = stream.next() => match item {
502 Some(Ok(item)) => {
503 emitted = true;
504 sink.normalized(&item);
505 if let Item::Event(StreamEvent::Text { text, .. }) = &item
506 && let Err(failure) = tail.push(text)
507 {
508 break Err(Classified::from(failure));
509 }
510 if sink.ready() {
511 for event in sink.take(context.number, attempt) {
512 observe(event);
513 }
514 }
515 }
516 Some(Err(error)) => break Err(self.classify(&error)),
517 None => break Ok(()),
518 }
519 }
520 };
521 for event in sink.take(context.number, attempt) {
522 observe(event);
523 }
524 let partial = stream.partial();
525 let result = match read {
526 Ok(()) => stream.finish().await.map_err(|error| self.classify(&error)),
527 Err(error) => Err(error),
528 };
529 let final_response = result.as_ref().ok().unwrap_or(&partial);
530 if result.is_ok() {
531 sink.final_response(&final_response.choice);
532 }
533 for event in sink.take(context.number, attempt) {
534 observe(event);
535 }
536 if let Err(error) = &result {
537 observe(crate::agent::session::Delivery {
538 turn: context.number,
539 attempt,
540 block: crate::types::DeliveryBlock::attempt(),
541 kind: crate::agent::session::DeliveryKind::Text,
542 text: error.message.clone(),
543 state: crate::agent::session::DeliveryState::Interrupted,
544 });
545 }
546 (result, Some(partial))
547 })
548 .await;
549 let mut counters = usage.0.lock().unwrap().clone();
550 if let Some(partial) = &partial {
551 add_cache_usage(&mut counters, partial);
552 }
553 if let Ok(response) = &result {
554 add_cache_usage(&mut counters, response);
555 }
556 let used_tokens = counters.values().any(|value| *value > 0);
557 let result = result.and_then(|response| {
558 validate_response(&response, context.model)
559 .map(|()| response)
560 .map_err(|message| Classified::from(Failure::new(Code::ProviderError, message)))
561 });
562 attempts.push(Attempt {
563 turn: context.number,
564 attempt,
565 usage: counters.into(),
566 error: result.as_ref().err().map(|failure| failure.message.clone()),
567 error_code: result.as_ref().err().map(|failure| failure.code),
568 });
569 let failure = match result {
570 Ok(response) => return Ok(response),
571 Err(failure) => failure,
572 };
573 if !failure.code.retryable() || emitted || used_tokens || attempt == ATTEMPTS {
574 return Err(failure.into());
575 }
576 let wait = failure
577 .retry_after
578 .unwrap_or(RETRY_BACKOFF_BASE * (1 << (attempt - 1)));
579 if failure.retry_after.is_some()
581 && Instant::now()
582 .checked_add(wait)
583 .is_none_or(|end| end >= context.deadline)
584 {
585 return Err(Failure::new(
586 failure.code,
587 format!(
588 "{}. The provider asked to retry after {:.1} s, past the review deadline.",
589 failure.message.trim_end_matches('.'),
590 wait.as_secs_f64()
591 ),
592 ));
593 }
594 tokio::select! {
595 _ = context.cancellation.cancelled() => return Err(Failure::cancelled()),
596 _ = tokio::time::sleep_until(context.deadline) => return Err(Failure::timeout()),
597 _ = tokio::time::sleep(wait) => {},
598 }
599 }
600 unreachable!()
601 }
602}
603
604pub struct Turn<'a> {
605 pub number: usize,
606 pub model: &'a str,
608 pub deadline: Instant,
609 pub cancellation: &'a CancellationToken,
610}
611
612fn add_cache_usage(
613 counters: &mut std::collections::BTreeMap<String, u64>,
614 response: &CompletionResponse,
615) {
616 insert_counter(
617 counters,
618 "cacheWriteTokens",
619 response.usage.cache_creation_input_tokens,
620 );
621 insert_counter(
622 counters,
623 "cacheWrite1hTokens",
624 response
625 .raw
626 .pointer("/usage/cache_creation/ephemeral_1h_input_tokens")
627 .and_then(Value::as_u64),
628 );
629}
630
631fn validate_response(response: &CompletionResponse, model: &str) -> Result<(), String> {
633 if response
634 .model
635 .as_deref()
636 .is_some_and(|actual| actual != model)
637 {
638 return Err(format!(
639 "Provider model mismatch: requested {model:?}, received {:?}.",
640 response.model
641 ));
642 }
643 if !matches!(
644 response.finish_reason(),
645 Some(FinishReason::Stop | FinishReason::ToolCalls)
646 ) {
647 return Err(format!(
648 "Provider response was incomplete: {:?}.",
649 response.finish_reason()
650 ));
651 }
652 Ok(())
653}
654
655const MILLIS_PER_SECOND: u32 = 1000;
657
658fn retry_after(error: &ProviderError) -> Option<Duration> {
661 wait_header(
662 error.provider_response_headers()?,
663 time::OffsetDateTime::now_utc(),
664 )
665}
666
667fn wait_header(
668 headers: &rig_core::http_client::HeaderMap,
669 now: time::OffsetDateTime,
670) -> Option<Duration> {
671 let header = |name: &str| headers.get(name)?.to_str().ok().map(str::trim);
672 let seconds = |value: &str| {
673 value
674 .parse::<f64>()
675 .ok()
676 .filter(|value| value.is_finite() && *value >= 0.0)
677 .and_then(|value| Duration::try_from_secs_f64(value).ok())
678 };
679 if let Some(wait) = header("retry-after-ms").and_then(seconds) {
680 return Some(wait / MILLIS_PER_SECOND);
681 }
682 let value = header("retry-after")?;
683 seconds(value).or_else(|| {
684 let at = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc2822)
685 .ok()?;
686 Some(Duration::try_from(at - now).unwrap_or_default())
687 })
688}
689
690fn diagnostic_text(error: &ProviderError) -> String {
693 clean_text(&message(error))
694}
695
696fn diagnostic_hiding(error: &ProviderError, secrets: &[&str]) -> String {
699 crate::secret::server_text(&diagnostic_text(error), secrets, MAX_DIAGNOSTIC_CHARS)
700}
701
702fn message(error: &ProviderError) -> String {
703 error
704 .provider_response()
705 .and_then(|response| serde_json::from_str::<Value>(&response.body).ok())
706 .and_then(|body| {
707 body.pointer("/error/message")
708 .or_else(|| body.get("message"))
709 .and_then(Value::as_str)
710 .map(str::to_owned)
711 })
712 .unwrap_or_else(|| error.to_string())
713}
714
715pub fn base_url_variable(provider: Provider) -> &'static str {
717 match provider {
718 Provider::Openai => "ARTIFACTIZE_OPENAI_BASE_URL",
719 Provider::Anthropic => "ARTIFACTIZE_ANTHROPIC_BASE_URL",
720 Provider::Gemini => "ARTIFACTIZE_GEMINI_BASE_URL",
721 Provider::Codex => "ARTIFACTIZE_CODEX_BASE_URL",
722 }
723}
724
725const TEST_ENDPOINTS: [&str; 5] = [
727 "ARTIFACTIZE_OPENAI_BASE_URL",
728 "ARTIFACTIZE_ANTHROPIC_BASE_URL",
729 "ARTIFACTIZE_GEMINI_BASE_URL",
730 "ARTIFACTIZE_CODEX_BASE_URL",
731 crate::auth::codex::AUTH_URL_VARIABLE,
732];
733
734#[derive(Debug, Clone, PartialEq, Eq)]
737pub struct TestEndpoint(url::Url);
738
739impl TestEndpoint {
740 pub fn root(&self) -> &str {
743 self.0.as_str().trim_end_matches('/')
744 }
745}
746
747impl std::str::FromStr for TestEndpoint {
748 type Err = String;
749 fn from_str(value: &str) -> Result<Self, Self::Err> {
750 loopback_url(value)
751 }
752}
753
754impl std::fmt::Display for TestEndpoint {
755 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756 formatter.write_str(self.root())
757 }
758}
759
760impl Serialize for TestEndpoint {
761 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
762 serializer.serialize_str(self.root())
763 }
764}
765
766pub fn test_endpoint(provider: Provider) -> Result<Option<TestEndpoint>, String> {
768 variable_endpoint(base_url_variable(provider))
769}
770
771pub fn variable_endpoint(variable: &str) -> Result<Option<TestEndpoint>, String> {
774 variable_endpoint_in(variable, crate::platform::environment::var)
775}
776
777pub fn variable_endpoint_in(
779 variable: &str,
780 var: impl Fn(&str) -> Option<std::ffi::OsString>,
781) -> Result<Option<TestEndpoint>, String> {
782 let Some(value) = var(variable).filter(|value| !value.is_empty()) else {
783 return Ok(None);
784 };
785 value
786 .to_str()
787 .ok_or_else(|| "it is not UTF-8".to_owned())
788 .and_then(loopback_url)
789 .map(Some)
790 .map_err(|error| format!("{variable}: {error}."))
791}
792
793pub fn active_test_endpoint() -> Option<&'static str> {
795 TEST_ENDPOINTS.into_iter().find(|variable| {
796 crate::platform::environment::var(variable).is_some_and(|value| !value.is_empty())
797 })
798}
799
800fn loopback_url(value: &str) -> Result<TestEndpoint, String> {
802 let url = url::Url::parse(value).map_err(|_| "it is not a URL".to_owned())?;
803 let loopback = match url.host() {
804 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
805 Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip).is_loopback(),
806 Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip).is_loopback(),
807 None => false,
808 };
809 if !matches!(url.scheme(), "http" | "https") || !loopback {
810 return Err("a test endpoint must be http(s) on localhost, 127.0.0.0/8 or [::1]".into());
811 }
812 if !url.username().is_empty()
813 || url.password().is_some()
814 || url.query().is_some()
815 || url.fragment().is_some()
816 {
817 return Err("a test endpoint has no credentials, query or fragment".into());
818 }
819 Ok(TestEndpoint(url))
820}
821
822fn base_url(provider: Provider) -> Result<String, String> {
824 Ok(test_endpoint(provider)?.map_or_else(
825 || {
826 match provider {
827 Provider::Openai => "https://api.openai.com/v1",
828 Provider::Anthropic => "https://api.anthropic.com",
829 Provider::Gemini => gemini::BASE_URL,
830 Provider::Codex => codex::BASE_URL,
831 }
832 .into()
833 },
834 |endpoint| endpoint.root().to_owned(),
835 ))
836}
837
838fn http_client() -> Result<reqwest::Client, String> {
839 reqwest::Client::builder()
840 .retry(reqwest::retry::never())
841 .redirect(reqwest::redirect::Policy::none())
842 .build()
843 .map_err(|error| error.to_string())
844}
845
846fn clean_text(message: &str) -> String {
848 String::from_utf8(crate::runtime::clean_output(message.as_bytes()))
849 .expect("clean output is UTF-8")
850}