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