1use crate::errors::CoreError;
2use gate4agent::{
3 AgentEvent, ClaudeOptions, CliTool, PipeProcessOptions, PipeSession, SessionConfig,
4};
5use serde::Deserialize;
6use std::process::Stdio;
7use tokio::io::AsyncWriteExt;
8
9use super::SegmentedPrompt;
10
11pub const AGENT_CLI_SCHEME: &str = "agent-cli://";
20
21fn parse_agent_cli(base_url: &str) -> Option<CliTool> {
32 if let Some(rest) = base_url.strip_prefix(AGENT_CLI_SCHEME) {
33 let tool = rest.split('/').next().unwrap_or(rest);
34 return match tool {
35 "claude" | "claude-code" => Some(CliTool::ClaudeCode),
36 "codex" => Some(CliTool::Codex),
37 "gemini" => Some(CliTool::Gemini),
38 "opencode" => Some(CliTool::OpenCode),
39 _ => None,
40 };
41 }
42 if base_url.starts_with("claude-cli://") || base_url.starts_with("claude-code-cli://") {
44 return Some(CliTool::ClaudeCode);
45 }
46 if base_url.starts_with("codex-cli://") {
47 return Some(CliTool::Codex);
48 }
49 if base_url.starts_with("gemini-cli://") {
50 return Some(CliTool::Gemini);
51 }
52 if base_url.starts_with("opencode-cli://") {
53 return Some(CliTool::OpenCode);
54 }
55 None
56}
57
58pub const fn agent_cli_sentinel(tool: CliTool) -> &'static str {
61 match tool {
62 CliTool::ClaudeCode => "agent-cli://claude",
63 CliTool::Codex => "agent-cli://codex",
64 CliTool::Gemini => "agent-cli://gemini",
65 CliTool::OpenCode => "agent-cli://opencode",
66 }
67}
68
69fn is_anthropic_provider(provider_name: &str, base_url: &str) -> bool {
70 let official_host = reqwest::Url::parse(base_url)
71 .is_ok_and(|u| u.host_str().is_some_and(|h| h == "api.anthropic.com"));
72 if official_host {
73 return true;
74 }
75
76 let name = provider_name.to_lowercase();
77 let tokens = name
78 .split(|ch: char| !ch.is_ascii_alphanumeric())
79 .filter(|token| !token.is_empty())
80 .collect::<Vec<_>>();
81 tokens.contains(&"anthropic")
82 || tokens.contains(&"anth")
83 || (tokens.contains(&"claude") && !tokens.contains(&"cli"))
84}
85
86fn anthropic_messages_url(base_url: &str) -> String {
87 let trimmed = base_url.trim_end_matches('/');
88 if trimmed.ends_with("/v1/messages") {
89 trimmed.to_owned()
90 } else if trimmed.ends_with("/v1") {
91 format!("{trimmed}/messages")
92 } else {
93 format!("{trimmed}/v1/messages")
94 }
95}
96
97const fn auth_hint(tool: CliTool) -> &'static str {
98 match tool {
99 CliTool::ClaudeCode => {
100 " — run `claude /login` once, or pick another provider with `difflore providers setup`"
101 }
102 CliTool::Codex => {
103 " — run `codex login` once, or pick another provider with `difflore providers setup`"
104 }
105 CliTool::Gemini => {
106 " — run `gemini auth login` once, or pick another provider with `difflore providers setup`"
107 }
108 CliTool::OpenCode => {
109 " — check `opencode auth` status, or pick another provider with `difflore providers setup`"
110 }
111 }
112}
113
114#[derive(Deserialize)]
115struct ClaudePrintResult {
116 result: Option<String>,
117 is_error: Option<bool>,
118 subtype: Option<String>,
119}
120
121fn truncate_for_error(value: &str, limit: usize) -> String {
122 value.chars().take(limit).collect()
123}
124
125fn parse_claude_print_stdout(stdout: &str) -> crate::Result<String> {
126 let parsed: ClaudePrintResult = serde_json::from_str(stdout.trim()).map_err(|e| {
127 CoreError::Internal(format!(
128 "Claude Code CLI returned non-JSON output: {e}; stdout={}",
129 truncate_for_error(&scrub_secrets(stdout), 300)
130 ))
131 })?;
132 if parsed.is_error == Some(true) || parsed.subtype.as_deref() == Some("error") {
133 return Err(CoreError::Internal(format!(
134 "Claude Code CLI returned an error response: {}",
135 truncate_for_error(&scrub_secrets(parsed.result.as_deref().unwrap_or("")), 300)
136 )));
137 }
138 parsed
139 .result
140 .filter(|result| !result.trim().is_empty())
141 .ok_or_else(|| CoreError::Internal("Claude Code CLI returned empty response".into()))
142}
143
144fn claude_cli_failure_detail(stdout: &str, stderr: &str) -> String {
145 let stderr = stderr.trim();
146 if !stderr.is_empty() {
147 return stderr.to_owned();
148 }
149
150 let stdout = stdout.trim();
151 if let Ok(parsed) = serde_json::from_str::<ClaudePrintResult>(stdout)
152 && let Some(result) = parsed.result.filter(|result| !result.trim().is_empty())
153 {
154 return result;
155 }
156
157 stdout.to_owned()
158}
159
160fn is_transient_claude_failure(exit_code: Option<i32>, detail: &str) -> bool {
161 if matches!(exit_code, Some(124 | 137)) {
162 return true;
163 }
164 let lower = detail.to_ascii_lowercase();
165 lower.contains("timeout")
166 || lower.contains("connection reset")
167 || lower.contains("temporarily")
168 || lower.contains("rate limit")
169}
170
171async fn call_claude_cli_direct(model: &str, prompt: &str) -> crate::Result<String> {
172 if model.starts_with('-') {
175 return Err(CoreError::Internal(format!(
176 "invalid model identifier {model:?}: must not start with '-'"
177 )));
178 }
179
180 let mut last_err: Option<CoreError> = None;
183 for attempt in 0..2_u32 {
184 if attempt > 0 {
185 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
186 }
187
188 let mut cmd = tokio::process::Command::new("claude");
189 cmd.arg("--print")
190 .arg("--output-format")
191 .arg("json")
192 .arg("--no-session-persistence")
193 .arg("--disable-slash-commands")
194 .arg("--tools")
195 .arg("")
196 .arg("--exclude-dynamic-system-prompt-sections")
197 .stdin(Stdio::piped())
198 .stdout(Stdio::piped())
199 .stderr(Stdio::piped());
200
201 if !model.trim().is_empty() {
202 cmd.arg("--model").arg(model);
203 }
204
205 for (key, _) in std::env::vars() {
206 if key.starts_with("CLAUDECODE") || key.starts_with("CLAUDE_CODE_") {
207 cmd.env_remove(key);
208 }
209 }
210
211 let Ok(mut child) = cmd.spawn() else {
212 last_err = Some(CoreError::Internal(
213 "failed to spawn Claude Code CLI (is it installed and on PATH?)".to_owned(),
214 ));
215 break;
217 };
218 let Some(mut stdin) = child.stdin.take() else {
219 last_err = Some(CoreError::Internal(
220 "failed to open Claude Code CLI stdin".to_owned(),
221 ));
222 break;
223 };
224 if let Err(e) = stdin.write_all(prompt.as_bytes()).await {
225 last_err = Some(CoreError::Internal(format!(
226 "failed to write Claude Code CLI prompt: {e}"
227 )));
228 break;
229 }
230 drop(stdin);
231
232 let output = match child.wait_with_output().await {
233 Ok(o) => o,
234 Err(e) => {
235 last_err = Some(CoreError::Internal(format!(
236 "failed to read Claude Code CLI output: {e}"
237 )));
238 break;
239 }
240 };
241 let stdout = String::from_utf8_lossy(&output.stdout);
242 let stderr = String::from_utf8_lossy(&output.stderr);
243 if output.status.success() {
244 return parse_claude_print_stdout(&stdout);
245 }
246
247 let detail = claude_cli_failure_detail(&stdout, &stderr);
248 let exit_code = output.status.code();
249 let scrubbed = scrub_secrets(detail.trim());
250 let err = CoreError::Internal(format!(
251 "Claude Code CLI failed: {}{}",
252 truncate_for_error(&scrubbed, 180),
253 auth_hint(CliTool::ClaudeCode)
254 ));
255 if is_transient_claude_failure(exit_code, &detail) && attempt + 1 < 2 {
256 last_err = Some(err);
257 continue;
258 }
259 return Err(err);
260 }
261
262 Err(last_err.unwrap_or_else(|| CoreError::Internal("Claude Code CLI failed".into())))
263}
264
265fn scrub_secrets(input: &str) -> String {
271 let mut out = String::with_capacity(input.len());
272 let bytes = input.as_bytes();
273 let mut i = 0;
274 while i < bytes.len() {
275 if let Some(consumed) = try_scrub_prefix(bytes, i) {
277 out.push_str("[REDACTED]");
278 i += consumed;
279 continue;
280 }
281 let ch_end = next_utf8_boundary(bytes, i);
283 out.push_str(&input[i..ch_end]);
284 i = ch_end;
285 }
286 out
287}
288
289fn next_utf8_boundary(bytes: &[u8], i: usize) -> usize {
290 let first = bytes[i];
291 let width = match first {
292 0x00..=0xBF => 1, 0xC0..=0xDF => 2,
294 0xE0..=0xEF => 3,
295 _ => 4,
296 };
297 (i + width).min(bytes.len())
298}
299
300fn try_scrub_prefix(bytes: &[u8], i: usize) -> Option<usize> {
301 const LITERAL_PREFIXES: &[&[u8]] = &[b"sk-", b"ghp_", b"github_pat_"];
303 for prefix in LITERAL_PREFIXES {
304 if bytes[i..].starts_with(prefix) {
305 let body_start = i + prefix.len();
306 let body_len = count_secret_body(&bytes[body_start..]);
307 if body_len >= 10 {
308 return Some(prefix.len() + body_len);
309 }
310 }
311 }
312 if i + 7 <= bytes.len() {
314 let head = &bytes[i..i + 6];
315 if head.eq_ignore_ascii_case(b"Bearer") {
316 let mut j = i + 6;
317 let ws_start = j;
319 while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
320 j += 1;
321 }
322 if j > ws_start {
323 let body_start = j;
324 let body_len = count_secret_body(&bytes[body_start..]);
325 if body_len >= 8 {
326 return Some(body_start + body_len - i);
327 }
328 }
329 }
330 }
331 None
332}
333
334fn count_secret_body(bytes: &[u8]) -> usize {
335 let mut n = 0;
336 while n < bytes.len() {
337 let b = bytes[n];
338 if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'+' | b'/' | b'=') {
339 n += 1;
340 } else {
341 break;
342 }
343 }
344 n
345}
346
347pub(super) async fn call_agent_cli_provider(
359 tool: CliTool,
360 model: &str,
361 system_prompt: &str,
362 user_prompt: &str,
363) -> crate::Result<String> {
364 let prompt = if system_prompt.trim().is_empty() {
365 user_prompt.to_owned()
366 } else {
367 format!("System instructions:\n{system_prompt}\n\nUser request:\n{user_prompt}")
368 };
369 if matches!(tool, CliTool::ClaudeCode)
373 && std::env::var("DIFFLORE_CLAUDE_DIRECT")
374 .map_or(true, |v| v != "0" && !v.eq_ignore_ascii_case("false"))
375 {
376 return call_claude_cli_direct(model, &prompt).await;
377 }
378
379 let working_dir = std::env::current_dir()
380 .map_err(|e| CoreError::Internal(format!("cwd lookup failed: {e}")))?;
381
382 let mut extra_args: Vec<String> = Vec::new();
383 let mut claude_opts = ClaudeOptions::default();
384
385 if !model.is_empty() {
386 match tool {
387 CliTool::ClaudeCode => claude_opts.model = Some(model.to_owned()),
388 CliTool::Codex | CliTool::Gemini => {
389 extra_args.push("-m".into());
390 extra_args.push(model.into());
391 }
392 CliTool::OpenCode => {
393 extra_args.push("--model".into());
394 extra_args.push(model.into());
395 }
396 }
397 }
398
399 if matches!(tool, CliTool::ClaudeCode)
400 && crate::env::var(crate::env::ANTHROPIC_API_KEY).is_some()
401 {
402 extra_args.push("--bare".into());
403 }
404
405 let config = SessionConfig {
406 tool,
407 working_dir,
408 env_vars: Vec::new(),
409 name: None,
410 };
411 let options = PipeProcessOptions {
412 extra_args,
413 claude: claude_opts,
414 };
415
416 let session = PipeSession::spawn(config, &prompt, options)
417 .await
418 .map_err(|e| {
419 CoreError::Internal(format!(
420 "failed to spawn {tool} CLI: {e} (is it installed and on PATH?)"
421 ))
422 })?;
423
424 let mut rx = session.subscribe();
425 let mut buf = String::new();
426 let mut session_error: Option<String> = None;
427
428 loop {
429 match rx.recv().await {
430 Ok(AgentEvent::Text { text, .. }) => buf.push_str(&text),
431 Ok(AgentEvent::SessionEnd {
432 result, is_error, ..
433 }) => {
434 if is_error {
435 session_error = Some(result);
436 }
437 break;
438 }
439 Ok(AgentEvent::Error { message }) => {
440 session_error = Some(message);
441 break;
442 }
443 Ok(AgentEvent::Exited { code }) => {
444 if code != 0 && session_error.is_none() {
445 session_error = Some(format!("exit_code={code}"));
446 }
447 break;
448 }
449 Ok(_) => {}
450 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
451 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
456 session_error = Some(format!(
457 "event stream lagged: {n} message(s) dropped before consumer caught up"
458 ));
459 break;
460 }
461 }
462 }
463
464 if let Some(err) = session_error {
465 return Err(CoreError::Internal(format!(
466 "{tool} CLI failed: {err}{}",
467 auth_hint(tool)
468 )));
469 }
470
471 if buf.trim().is_empty() {
472 return Err(CoreError::Internal(format!(
473 "{tool} CLI returned empty response{}",
474 auth_hint(tool)
475 )));
476 }
477
478 Ok(buf)
479}
480
481async fn call_anthropic_provider(
493 base_url: &str,
494 api_key: &str,
495 model: &str,
496 system_prompt: &str,
497 stable_prefix: &str,
498 dynamic_suffix: &str,
499) -> crate::Result<String> {
500 let client = reqwest::Client::new();
501
502 let content = if dynamic_suffix.is_empty() {
503 serde_json::json!([
504 {
505 "type": "text",
506 "text": stable_prefix
507 }
508 ])
509 } else {
510 serde_json::json!([
511 {
512 "type": "text",
513 "text": stable_prefix,
514 "cache_control": { "type": "ephemeral" }
515 },
516 {
517 "type": "text",
518 "text": dynamic_suffix
519 }
520 ])
521 };
522
523 let body = serde_json::json!({
524 "model": model,
525 "max_tokens": 4096,
526 "system": system_prompt,
527 "messages": [{
528 "role": "user",
529 "content": content
530 }]
531 });
532
533 let response = client
534 .post(anthropic_messages_url(base_url))
535 .header("x-api-key", api_key)
536 .header("anthropic-version", "2023-06-01")
537 .header("content-type", "application/json")
538 .json(&body)
539 .send()
540 .await
541 .map_err(|e| CoreError::Internal(format!("Anthropic request failed: {e}")))?;
542
543 if !response.status().is_success() {
544 let status = response.status();
545 let text = response.text().await.unwrap_or_default();
546 return Err(CoreError::Internal(format!(
547 "Anthropic returned {status}: {text}"
548 )));
549 }
550
551 #[derive(serde::Deserialize)]
552 struct ContentBlock {
553 text: Option<String>,
554 }
555 #[derive(serde::Deserialize)]
556 #[allow(clippy::struct_field_names)] struct AnthropicUsage {
558 #[serde(default)]
559 cache_read_input_tokens: Option<u32>,
560 #[serde(default)]
561 cache_creation_input_tokens: Option<u32>,
562 }
563 #[derive(serde::Deserialize)]
564 struct AnthropicResponse {
565 content: Vec<ContentBlock>,
566 usage: Option<AnthropicUsage>,
567 }
568
569 let resp: AnthropicResponse = response
570 .json()
571 .await
572 .map_err(|e| CoreError::Internal(format!("Failed to parse Anthropic response: {e}")))?;
573
574 if crate::env::debug_providers()
575 && let Some(ref usage) = resp.usage
576 && let Some(read) = usage.cache_read_input_tokens
577 {
578 eprintln!(
579 "[anthropic] cache_read_input_tokens={}, cache_creation_input_tokens={}",
580 read,
581 usage.cache_creation_input_tokens.unwrap_or(0)
582 );
583 }
584
585 resp.content
586 .into_iter()
587 .find_map(|block| block.text.filter(|text| !text.trim().is_empty()))
588 .ok_or_else(|| CoreError::Internal("Anthropic returned empty content".into()))
589}
590
591async fn call_openai_provider(
594 base_url: &str,
595 api_key: &str,
596 model: &str,
597 system_prompt: &str,
598 user_prompt: &str,
599) -> crate::Result<String> {
600 let client = reqwest::Client::new();
601
602 let body = serde_json::json!({
603 "model": model,
604 "messages": [
605 { "role": "system", "content": system_prompt },
606 { "role": "user", "content": user_prompt }
607 ],
608 "temperature": 0.1,
609 "max_tokens": 4096
610 });
611
612 let response = client
613 .post(format!(
614 "{}/chat/completions",
615 base_url.trim_end_matches('/')
616 ))
617 .header("Authorization", format!("Bearer {api_key}"))
618 .header("Content-Type", "application/json")
619 .json(&body)
620 .send()
621 .await
622 .map_err(|e| CoreError::Internal(format!("AI provider request failed: {e}")))?;
623
624 if !response.status().is_success() {
625 let status = response.status();
626 let text = response.text().await.unwrap_or_default();
627 return Err(CoreError::Internal(format!(
628 "AI provider returned {status}: {text}"
629 )));
630 }
631
632 #[derive(serde::Deserialize)]
633 struct ChatChoice {
634 message: ChatMessage,
635 }
636 #[derive(serde::Deserialize)]
637 struct ChatMessage {
638 content: Option<String>,
639 }
640 #[derive(serde::Deserialize)]
641 struct ChatResponse {
642 choices: Vec<ChatChoice>,
643 }
644
645 let chat: ChatResponse = response
646 .json()
647 .await
648 .map_err(|e| CoreError::Internal(format!("Failed to parse AI response: {e}")))?;
649
650 chat.choices
651 .first()
652 .and_then(|c| c.message.content.clone())
653 .ok_or_else(|| CoreError::Internal("AI returned empty response".into()))
654}
655
656pub(super) async fn call_ai_provider(
659 provider_name: &str,
660 base_url: &str,
661 api_key: &str,
662 model: &str,
663 system_prompt: &str,
664 user_prompt: &str,
665) -> crate::Result<String> {
666 if let Some(tool) = parse_agent_cli(base_url) {
667 return call_agent_cli_provider(tool, model, system_prompt, user_prompt).await;
668 }
669 if is_anthropic_provider(provider_name, base_url) {
670 let prompt = if system_prompt.trim().is_empty() {
671 user_prompt.to_owned()
672 } else {
673 format!("System instructions:\n{system_prompt}\n\nUser request:\n{user_prompt}")
674 };
675 call_anthropic_provider(base_url, api_key, model, "", &prompt, "").await
676 } else {
677 call_openai_provider(base_url, api_key, model, system_prompt, user_prompt).await
678 }
679}
680
681pub(super) async fn call_ai_provider_segmented(
682 provider_name: &str,
683 base_url: &str,
684 api_key: &str,
685 model: &str,
686 segmented: &SegmentedPrompt,
687 user_prompt: &str,
688) -> crate::Result<String> {
689 if let Some(tool) = parse_agent_cli(base_url) {
690 return call_agent_cli_provider(
696 tool,
697 model,
698 &segmented.stable_prefix,
699 &format!("{}\n\n{}", segmented.dynamic_suffix, user_prompt),
700 )
701 .await;
702 }
703 if is_anthropic_provider(provider_name, base_url) {
704 call_anthropic_provider(
705 base_url,
706 api_key,
707 model,
708 "",
709 &segmented.stable_prefix,
710 &format!("{}\n\n{}", segmented.dynamic_suffix, user_prompt),
711 )
712 .await
713 } else {
714 let flat = format!("{}{}", segmented.stable_prefix, segmented.dynamic_suffix);
715 call_openai_provider(base_url, api_key, model, &flat, user_prompt).await
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::{
722 AGENT_CLI_SCHEME, agent_cli_sentinel, anthropic_messages_url, is_anthropic_provider,
723 parse_agent_cli, parse_claude_print_stdout, scrub_secrets,
724 };
725 use gate4agent::CliTool;
726
727 #[test]
728 fn agent_cli_scheme_routes_each_supported_tool() {
729 assert_eq!(
730 parse_agent_cli("agent-cli://claude"),
731 Some(CliTool::ClaudeCode)
732 );
733 assert_eq!(parse_agent_cli("agent-cli://codex"), Some(CliTool::Codex));
734 assert_eq!(parse_agent_cli("agent-cli://gemini"), Some(CliTool::Gemini));
735 assert_eq!(
736 parse_agent_cli("agent-cli://opencode"),
737 Some(CliTool::OpenCode)
738 );
739 }
740
741 #[test]
742 fn http_base_urls_are_not_agent_cli() {
743 assert_eq!(parse_agent_cli("https://api.anthropic.com"), None);
744 assert_eq!(parse_agent_cli("http://wucur.com:6543/v1"), None);
745 }
746
747 #[test]
748 fn unknown_agent_cli_tool_is_rejected() {
749 assert_eq!(parse_agent_cli("agent-cli://bogus"), None);
750 }
751
752 #[test]
753 fn agent_cli_sentinel_round_trips_through_parse() {
754 for tool in [
755 CliTool::ClaudeCode,
756 CliTool::Codex,
757 CliTool::Gemini,
758 CliTool::OpenCode,
759 ] {
760 let s = agent_cli_sentinel(tool);
761 assert!(s.starts_with(AGENT_CLI_SCHEME));
762 assert_eq!(parse_agent_cli(s), Some(tool));
763 }
764 }
765
766 #[test]
767 fn official_anthropic_host_uses_native_messages_api() {
768 assert!(is_anthropic_provider(
769 "anything",
770 "https://api.anthropic.com"
771 ));
772 }
773
774 #[test]
775 fn custom_claude_compatible_provider_name_uses_native_messages_api() {
776 assert!(is_anthropic_provider(
777 "claude-compatible",
778 "http://wucur.com:6543"
779 ));
780 }
781
782 #[test]
783 fn abbreviated_anthropic_provider_name_uses_native_messages_api() {
784 assert!(is_anthropic_provider(
785 "proxy-anth",
786 "http://wucur.com:6543/v1"
787 ));
788 }
789
790 #[test]
791 fn anth_substrings_inside_unrelated_words_stay_openai_compatible() {
792 assert!(!is_anthropic_provider(
793 "panther-ai",
794 "http://wucur.com:6543/v1"
795 ));
796 assert!(!is_anthropic_provider(
797 "elephant-proxy",
798 "http://wucur.com:6543/v1"
799 ));
800 }
801
802 #[test]
803 fn openai_compatible_provider_name_stays_on_chat_completions() {
804 assert!(!is_anthropic_provider(
805 "openai-compatible",
806 "http://wucur.com:6543"
807 ));
808 }
809
810 #[test]
811 fn parses_claude_print_json_result() {
812 let out = parse_claude_print_stdout(r#"{"type":"result","is_error":false,"result":"OK"}"#)
813 .unwrap();
814
815 assert_eq!(out, "OK");
816 }
817
818 #[test]
819 fn rejects_claude_print_error_result() {
820 let err = parse_claude_print_stdout(
821 r#"{"type":"result","subtype":"error","is_error":true,"result":"auth failed"}"#,
822 )
823 .unwrap_err()
824 .to_string();
825
826 assert!(err.contains("Claude Code CLI returned an error response"));
827 }
828
829 #[test]
830 fn scrub_secrets_redacts_standard_base64_token_bodies() {
831 let scrubbed =
832 scrub_secrets("provider failed: Bearer abc.def+ghi/jkl== and ghp_abcdEFGH1234+/= tail");
833
834 assert_eq!(scrubbed, "provider failed: [REDACTED] and [REDACTED] tail");
835 }
836
837 #[test]
838 fn anthropic_messages_url_appends_versioned_path_without_double_slash() {
839 assert_eq!(
840 anthropic_messages_url("http://wucur.com:6543/"),
841 "http://wucur.com:6543/v1/messages"
842 );
843 }
844
845 #[test]
846 fn anthropic_messages_url_respects_existing_versioned_base_path() {
847 assert_eq!(
848 anthropic_messages_url("http://wucur.com:6543/v1"),
849 "http://wucur.com:6543/v1/messages"
850 );
851 assert_eq!(
852 anthropic_messages_url("http://wucur.com:6543/v1/messages"),
853 "http://wucur.com:6543/v1/messages"
854 );
855 }
856}