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