Skip to main content

difflore_core/review/
providers.rs

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
11// ── Agent CLI dispatch ──────────────────────────────────────────────────────
12
13/// Sentinel scheme used to route a provider through a local agent CLI
14/// (Claude Code, Codex, Gemini, `OpenCode`) via `gate4agent` instead of
15/// any HTTP endpoint. The tool name follows the scheme:
16/// `agent-cli://claude`, `agent-cli://codex`, `agent-cli://gemini`,
17/// `agent-cli://opencode`. No real URL ever uses this scheme so
18/// collision with a legitimate HTTP provider is impossible.
19pub const AGENT_CLI_SCHEME: &str = "agent-cli://";
20
21/// Parse a provider `base_url` into a `CliTool` if it is an agent-CLI
22/// sentinel. Returns `None` for HTTP base URLs.
23///
24/// Accepts both the canonical `agent-cli://<tool>` form and compatibility
25/// per-tool schemes `claude-cli://`, `codex-cli://`, `gemini-cli://`,
26/// `opencode-cli://` that older provider rows in user SQLite DBs still use.
27/// Without the alias, those rows silently fall through to the HTTP code path
28/// and reqwest rejects them with
29/// "scheme `claude-cli` is not supported", which is mistaken for an
30/// auth failure.
31fn 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    // Legacy per-tool schemes — back-compat only.
43    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
58/// Canonical sentinel string for a given tool — used by `providers
59/// setup` when persisting a freshly-picked agent CLI provider.
60pub 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    // Guard against argv injection: a `model` starting with `-` would be
173    // interpreted as a flag by the claude CLI, e.g. `--dangerous-flag`.
174    if model.starts_with('-') {
175        return Err(CoreError::Internal(format!(
176            "invalid model identifier {model:?}: must not start with '-'"
177        )));
178    }
179
180    // Up to 2 attempts (initial + 1 retry) on transient failures. We re-spawn
181    // the CLI each time because tokio Child can only be awaited once.
182    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            // Spawn failure is not transient — return immediately.
216            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
265/// Replace common secret token prefixes with `[REDACTED]` so error output
266/// never leaks API keys or tokens. Conservative pattern matching — only
267/// well-known prefixes (`sk-`, `Bearer `, `ghp_`, `github_pat_`) trigger,
268/// each followed by enough opaque URL-safe or base64 characters to look like
269/// a real secret.
270fn 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        // Try each prefix at position i.
276        if let Some(consumed) = try_scrub_prefix(bytes, i) {
277            out.push_str("[REDACTED]");
278            i += consumed;
279            continue;
280        }
281        // Append a single char (decoded as UTF-8 boundary-aware).
282        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, // includes continuation bytes defensively
293        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    // Literal prefixes
302    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    // Case-insensitive "Bearer " followed by token chars.
313    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            // Require at least one whitespace.
318            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
347/// Drive a local agent CLI (`claude` / `codex` / `gemini` / `opencode`)
348/// through `gate4agent` and collect the streamed assistant text. The
349/// transport handles each tool's headless flag dance (Claude:
350/// `-p --output-format stream-json --verbose`; Codex: `exec --json`;
351/// Gemini: `--output-format stream-json -p`; `OpenCode`: own NDJSON) so
352/// difflore stays out of the per-CLI argv business.
353///
354/// `--bare` is preserved as load-bearing for Claude when the user has
355/// `ANTHROPIC_API_KEY` set — without it, `claude` pulls in MCP servers,
356/// skills, memory, and `CLAUDE.md` auto-discovery from the user's
357/// environment, which would corrupt review agent behaviour.
358pub(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    // For Claude Code, the --print direct path works reliably on Windows where
370    // gate4agent's session-based spawn returns exit_code=1 with empty stderr.
371    // Default to direct; opt out with DIFFLORE_CLAUDE_DIRECT=0.
372    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            // Lagged means gate4agent's 256-event broadcast buffer dropped
452            // some events because we couldn't keep up. Text events accumulate
453            // contiguously so a gap means a hole in the assistant output —
454            // surface it rather than silently truncate.
455            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
481// ── Anthropic native client (Messages API with prompt caching) ──
482
483/// Call the Anthropic Messages API (`/v1/messages`) with prompt caching.
484///
485/// The prompt is split into a cacheable `stable_prefix` (rules + repo
486/// context — identical across perspectives within a PR) and a per-call
487/// `dynamic_suffix` (verdicts + diff). Both land in a single `user`
488/// message as two content blocks; the first block carries
489/// `cache_control: { type: "ephemeral" }` so the Anthropic backend can
490/// reuse the KV-cache across the five perspective calls within the same
491/// PR review.
492async 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)] // reason: matches Anthropic's API field names
557    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
591// ── OpenAI-compatible client ──
592
593async 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
656// ── Unified dispatch ──
657
658pub(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        // Agent CLIs have no prompt caching, so the stable/dynamic split
691        // carries no mechanical benefit — flatten into a single
692        // system+user shape. stable_prefix becomes the system prompt
693        // (background: rules + repo context); dynamic_suffix joins the
694        // diff-bearing user_prompt.
695        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}