Skip to main content

vtcode_llm/providers/
custom_provider.rs

1use async_trait::async_trait;
2use vtcode_commons::llm::BackendKind;
3use vtcode_config::core::AnthropicConfig;
4use vtcode_config::core::{CustomProviderApiFormat, CustomProviderConfig, ResolvedCustomProviderProfile};
5use vtcode_config::{ModelConfig, OpenAIConfig, PromptCachingConfig, TimeoutsConfig};
6
7use crate::provider::{
8    LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, Message, ResponsesCompactionOptions, SamplingOverrides,
9};
10use crate::providers::anthropic::{self, AnthropicProvider};
11use crate::providers::common::{resolve_model, validate_request_common};
12use crate::providers::openai::{CustomProviderAuthHandle, OpenAIProvider};
13
14/// Profile-aware backend router for custom providers.
15pub struct CustomProviderBackendRouter {
16    provider_key: String,
17    display_name: String,
18    default_model: String,
19    supported_models: Vec<String>,
20    custom_config: CustomProviderConfig,
21    anthropic_config: AnthropicConfig,
22    openai_auto: OpenAIProvider,
23    openai_chat: OpenAIProvider,
24    openai_responses: OpenAIProvider,
25    anthropic: AnthropicProvider,
26}
27
28impl CustomProviderBackendRouter {
29    #[allow(
30        clippy::too_many_arguments,
31        reason = "Intentional compatibility, platform, or test-only suppression."
32    )]
33    pub fn from_config(
34        custom_config: CustomProviderConfig,
35        api_key: Option<String>,
36        model: Option<String>,
37        base_url: String,
38        prompt_cache: Option<PromptCachingConfig>,
39        timeouts: Option<TimeoutsConfig>,
40        openai: Option<OpenAIConfig>,
41        anthropic: Option<AnthropicConfig>,
42        model_behavior: Option<ModelConfig>,
43        custom_provider_auth: Option<CustomProviderAuthHandle>,
44    ) -> Self {
45        let provider_key = custom_config.name.clone();
46        let display_name = custom_config.display_name.clone();
47        let supported_models = custom_config.effective_models();
48        let configured_default = resolve_model(model, &custom_config.model);
49        let default_model = if configured_default.trim().is_empty() {
50            supported_models.first().cloned().unwrap_or_default()
51        } else {
52            configured_default
53        };
54        let anthropic_config = anthropic.unwrap_or_default();
55
56        let openai_auto = OpenAIProvider::from_custom_config(
57            provider_key.clone(),
58            display_name.clone(),
59            api_key.clone(),
60            Some(default_model.clone()),
61            Some(base_url.clone()),
62            prompt_cache.clone(),
63            timeouts.clone(),
64            openai.clone(),
65            model_behavior.clone(),
66            custom_provider_auth.clone(),
67            Some(supported_models.clone()),
68        );
69        let openai_chat = OpenAIProvider::from_custom_config(
70            provider_key.clone(),
71            display_name.clone(),
72            api_key.clone(),
73            Some(default_model.clone()),
74            Some(base_url.clone()),
75            prompt_cache.clone(),
76            timeouts.clone(),
77            openai.clone(),
78            model_behavior.clone(),
79            custom_provider_auth.clone(),
80            Some(supported_models.clone()),
81        )
82        .with_api_format_override(Some(CustomProviderApiFormat::OpenAIChat));
83        let openai_responses = OpenAIProvider::from_custom_config(
84            provider_key.clone(),
85            display_name.clone(),
86            api_key.clone(),
87            Some(default_model.clone()),
88            Some(base_url.clone()),
89            prompt_cache.clone(),
90            timeouts.clone(),
91            openai.clone(),
92            model_behavior.clone(),
93            custom_provider_auth.clone(),
94            Some(supported_models.clone()),
95        )
96        .with_api_format_override(Some(CustomProviderApiFormat::OpenAIResponses))
97        .with_prompt_cache_options_models(
98            supported_models
99                .iter()
100                .filter(|model| custom_config.resolved_profile(model).supports_prompt_cache_options == Some(true))
101                .cloned()
102                .collect(),
103        );
104        let anthropic = AnthropicProvider::from_config(
105            api_key,
106            Some(default_model.clone()),
107            Some(base_url),
108            prompt_cache,
109            timeouts,
110            Some(anthropic_config.clone()),
111            model_behavior,
112        )
113        .with_custom_auth(custom_provider_auth);
114
115        Self {
116            provider_key,
117            display_name,
118            default_model,
119            supported_models,
120            custom_config,
121            anthropic_config,
122            openai_auto,
123            openai_chat,
124            openai_responses,
125            anthropic,
126        }
127    }
128
129    fn resolved_model<'a>(&'a self, model: &'a str) -> &'a str {
130        if model.trim().is_empty() {
131            &self.default_model
132        } else {
133            model
134        }
135    }
136
137    fn profile_for_model(&self, model: &str) -> ResolvedCustomProviderProfile {
138        self.custom_config.resolved_profile(model)
139    }
140
141    fn backend_for_model(&self, model: &str) -> &dyn LLMProvider {
142        match self.profile_for_model(self.resolved_model(model)).api_format {
143            Some(CustomProviderApiFormat::AnthropicMessages) => &self.anthropic,
144            Some(CustomProviderApiFormat::OpenAIChat) => &self.openai_chat,
145            Some(CustomProviderApiFormat::OpenAIResponses) => &self.openai_responses,
146            Some(CustomProviderApiFormat::Auto) | None => &self.openai_auto,
147        }
148    }
149
150    fn override_bool(value: Option<bool>, default: bool) -> bool {
151        value.unwrap_or(default)
152    }
153}
154
155#[async_trait]
156impl LLMProvider for CustomProviderBackendRouter {
157    fn name(&self) -> &str {
158        &self.provider_key
159    }
160
161    fn backend_kind(&self) -> BackendKind {
162        self.backend_for_model(&self.default_model).backend_kind()
163    }
164
165    fn supports_streaming(&self) -> bool {
166        true
167    }
168
169    fn supports_non_streaming(&self, model: &str) -> bool {
170        self.backend_for_model(model).supports_non_streaming(self.resolved_model(model))
171    }
172
173    fn supports_reasoning(&self, model: &str) -> bool {
174        let resolved = self.resolved_model(model);
175        let profile = self.profile_for_model(resolved);
176        Self::override_bool(profile.supports_reasoning, self.backend_for_model(model).supports_reasoning(resolved))
177    }
178
179    fn supports_reasoning_effort(&self, model: &str) -> bool {
180        let resolved = self.resolved_model(model);
181        let profile = self.profile_for_model(resolved);
182        // A pin in THIS model's own profile implies effort support unless the
183        // same profile explicitly disables it. Provider-level defaults — both
184        // the effort value and the capability flag — must not flip support for
185        // every model on the endpoint.
186        let own = self.custom_config.profiles.get(resolved);
187        let own_pin = own.and_then(|p| p.reasoning_effort).is_some();
188        if own_pin {
189            return own.and_then(|p| p.supports_reasoning_effort) != Some(false);
190        }
191        Self::override_bool(
192            profile.supports_reasoning_effort,
193            self.backend_for_model(model).supports_reasoning_effort(resolved),
194        )
195    }
196
197    fn sampling_overrides(&self, model: &str) -> SamplingOverrides {
198        let resolved = self.resolved_model(model);
199        let profile = self.profile_for_model(resolved);
200        SamplingOverrides {
201            temperature: profile.temperature,
202            top_p: profile.top_p,
203            top_k: profile.top_k,
204            presence_penalty: profile.presence_penalty,
205            frequency_penalty: profile.frequency_penalty,
206            max_tokens: profile.max_tokens,
207            reasoning_effort: profile.reasoning_effort,
208            suppresses_sampling_with_reasoning: profile.api_format == Some(CustomProviderApiFormat::AnthropicMessages),
209            profile_aware: true,
210        }
211    }
212
213    fn supports_tools(&self, model: &str) -> bool {
214        let resolved = self.resolved_model(model);
215        let profile = self.profile_for_model(resolved);
216        Self::override_bool(profile.supports_tools, self.backend_for_model(model).supports_tools(resolved))
217    }
218
219    fn supports_parallel_tool_config(&self, model: &str) -> bool {
220        let resolved = self.resolved_model(model);
221        let profile = self.profile_for_model(resolved);
222        Self::override_bool(
223            profile.supports_parallel_tool_calls,
224            self.backend_for_model(model).supports_parallel_tool_config(resolved),
225        )
226    }
227
228    fn supports_structured_output(&self, model: &str) -> bool {
229        let resolved = self.resolved_model(model);
230        let profile = self.profile_for_model(resolved);
231        Self::override_bool(
232            profile.supports_structured_output,
233            self.backend_for_model(model).supports_structured_output(resolved),
234        )
235    }
236
237    fn requested_prompt_cache_ttl(&self, model: &str) -> Option<vtcode_config::core::prompt_cache::PromptCacheTtl> {
238        self.backend_for_model(model)
239            .requested_prompt_cache_ttl(self.resolved_model(model))
240    }
241
242    fn supports_context_caching(&self, model: &str) -> bool {
243        let resolved = self.resolved_model(model);
244        let profile = self.profile_for_model(resolved);
245        Self::override_bool(
246            profile.supports_context_caching,
247            self.backend_for_model(model).supports_context_caching(resolved),
248        )
249    }
250
251    fn supports_vision(&self, model: &str) -> bool {
252        let resolved = self.resolved_model(model);
253        let profile = self.profile_for_model(resolved);
254        Self::override_bool(profile.supports_vision, self.backend_for_model(model).supports_vision(resolved))
255    }
256
257    fn supports_responses_compaction(&self, model: &str) -> bool {
258        let resolved = self.resolved_model(model);
259        let profile = self.profile_for_model(resolved);
260        let backend_default = match self.profile_for_model(resolved).api_format {
261            Some(CustomProviderApiFormat::AnthropicMessages) => false,
262            _ => self.backend_for_model(model).supports_responses_compaction(resolved),
263        };
264        Self::override_bool(profile.supports_responses_compaction, backend_default)
265    }
266
267    fn supports_native_allowed_tools(&self, model: &str) -> bool {
268        self.backend_for_model(model)
269            .supports_native_allowed_tools(self.resolved_model(model))
270    }
271
272    fn supports_context_edits(&self, model: &str) -> bool {
273        let resolved = self.resolved_model(model);
274        let profile = self.profile_for_model(resolved);
275        Self::override_bool(
276            profile.supports_context_edits,
277            self.backend_for_model(model).supports_context_edits(resolved),
278        )
279    }
280
281    fn supports_turn_scoped_system_messages(&self, model: &str) -> bool {
282        let resolved = self.resolved_model(model);
283        matches!(self.profile_for_model(resolved).api_format, Some(CustomProviderApiFormat::AnthropicMessages))
284            && self.backend_for_model(model).supports_turn_scoped_system_messages(resolved)
285    }
286
287    fn supports_manual_openai_compaction(&self, model: &str) -> bool {
288        let resolved = self.resolved_model(model);
289        let profile = self.profile_for_model(resolved);
290        let backend_default = match self.profile_for_model(resolved).api_format {
291            Some(CustomProviderApiFormat::AnthropicMessages) => false,
292            _ => self.backend_for_model(model).supports_manual_openai_compaction(resolved),
293        };
294        Self::override_bool(profile.supports_responses_compaction, backend_default)
295    }
296
297    fn supports_native_inline_compaction(&self, model: &str) -> bool {
298        let resolved = self.resolved_model(model);
299        let profile = self.profile_for_model(resolved);
300        Self::override_bool(
301            profile.supports_context_edits,
302            self.backend_for_model(model).supports_native_inline_compaction(resolved),
303        )
304    }
305
306    fn manual_openai_compaction_unavailable_message(&self, model: &str) -> String {
307        self.backend_for_model(model)
308            .manual_openai_compaction_unavailable_message(self.resolved_model(model))
309    }
310
311    fn effective_context_size(&self, model: &str) -> usize {
312        let resolved = self.resolved_model(model);
313        let profile = self.profile_for_model(resolved);
314        profile
315            .context_window
316            .unwrap_or_else(|| self.backend_for_model(model).effective_context_size(resolved))
317    }
318
319    async fn compact_history(&self, model: &str, history: &[Message]) -> Result<Vec<Message>, LLMError> {
320        self.backend_for_model(model)
321            .compact_history(self.resolved_model(model), history)
322            .await
323    }
324
325    async fn compact_history_with_options(
326        &self,
327        model: &str,
328        history: &[Message],
329        options: &ResponsesCompactionOptions,
330    ) -> Result<Vec<Message>, LLMError> {
331        self.backend_for_model(model)
332            .compact_history_with_options(self.resolved_model(model), history, options)
333            .await
334    }
335
336    async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
337        self.backend_for_model(&request.model).stream(request).await
338    }
339
340    async fn stream_normalized(&self, request: LLMRequest) -> Result<crate::provider::LLMNormalizedStream, LLMError> {
341        self.backend_for_model(&request.model).stream_normalized(request).await
342    }
343
344    async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
345        self.backend_for_model(&request.model).generate(request).await
346    }
347
348    async fn generate_auxiliary(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
349        self.backend_for_model(&request.model).generate_auxiliary(request).await
350    }
351
352    fn supported_models(&self) -> Vec<String> {
353        self.supported_models.clone()
354    }
355
356    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
357        let resolved_model = self.resolved_model(&request.model);
358        match self.profile_for_model(resolved_model).api_format {
359            Some(CustomProviderApiFormat::AnthropicMessages) => {
360                // Anthropic-shaped custom profiles use the Anthropic message
361                // contract, including its turn-scoped system marker. Keep the
362                // custom display name for diagnostics, but validate message roles
363                // against the backend contract so the marker is not rejected.
364                validate_request_common(request, &self.display_name, "anthropic", Some(&self.supported_models))?;
365                anthropic::validation::validate_request(
366                    request,
367                    &self.default_model,
368                    &self.anthropic_config,
369                    &self.display_name,
370                )
371            }
372            Some(CustomProviderApiFormat::OpenAIChat)
373            | Some(CustomProviderApiFormat::OpenAIResponses)
374            | Some(CustomProviderApiFormat::Auto)
375            | None => self.backend_for_model(resolved_model).validate_request(request),
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::CustomProviderBackendRouter;
383    use crate::provider::{LLMProvider, LLMRequest, Message, SamplingOverrides};
384    use crate::providers::openai::CustomProviderAuthHandle;
385    use serde_json::json;
386    use std::path::Path;
387    use tempfile::TempDir;
388    use vtcode_config::constants::models;
389    use vtcode_config::core::{
390        AnthropicConfig, CustomProviderApiFormat, CustomProviderCommandAuthConfig, CustomProviderConfig,
391        CustomProviderProfileConfig,
392    };
393    use vtcode_config::types::ReasoningEffortLevel;
394    use wiremock::matchers::{method, path};
395    use wiremock::{Mock, MockServer, ResponseTemplate};
396
397    fn sampling_router() -> CustomProviderBackendRouter {
398        let mut profiles = std::collections::BTreeMap::new();
399        profiles.insert(
400            "cold-model".to_string(),
401            CustomProviderProfileConfig {
402                temperature: Some(0.0),
403                top_p: Some(0.9),
404                reasoning_effort: Some(ReasoningEffortLevel::Low),
405                ..Default::default()
406            },
407        );
408
409        CustomProviderBackendRouter::from_config(
410            CustomProviderConfig {
411                name: "sampling-test".to_string(),
412                display_name: "Sampling Test".to_string(),
413                base_url: "https://llm.example/v1".to_string(),
414                temperature: Some(0.5),
415                models: vec!["cold-model".to_string(), "warm-model".to_string()],
416                profiles,
417                ..Default::default()
418            },
419            None,
420            None,
421            "https://llm.example/v1".to_string(),
422            None,
423            None,
424            None,
425            None,
426            None,
427            None,
428        )
429    }
430
431    #[tokio::test]
432    async fn auxiliary_generation_forwards_isolation_to_custom_openai_backend() {
433        let server = MockServer::start().await;
434        Mock::given(method("POST"))
435            .and(path("/responses"))
436            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
437                "id": "resp_custom_auxiliary",
438                "output": [{"type": "message", "content": [{"type": "output_text", "text": "SAFE"}]}]
439            })))
440            .expect(1)
441            .mount(&server)
442            .await;
443        let router = CustomProviderBackendRouter::from_config(
444            CustomProviderConfig {
445                name: "auxiliary-test".into(),
446                display_name: "Auxiliary Test".into(),
447                model: "gpt-6-luna".into(),
448                models: vec!["gpt-6-luna".into()],
449                api_format: CustomProviderApiFormat::OpenAIResponses,
450                ..Default::default()
451            },
452            Some("fixture-key".into()),
453            None,
454            server.uri(),
455            None,
456            None,
457            Some(vtcode_config::OpenAIConfig { websocket_mode: true, ..Default::default() }),
458            None,
459            None,
460            None,
461        );
462        let response = router
463            .generate_auxiliary(LLMRequest {
464                model: "gpt-6-luna".into(),
465                messages: vec![Message::user("independent custom probe".into())].into(),
466                ..Default::default()
467            })
468            .await
469            .expect("HTTP auxiliary response");
470        assert_eq!(response.content.as_deref(), Some("SAFE"));
471        let requests = server.received_requests().await.expect("recorded requests");
472        assert_eq!(requests.len(), 1, "auxiliary must not attempt a WebSocket upgrade or warmup");
473        assert_eq!(requests[0].method.as_str(), "POST");
474        assert_eq!(requests[0].url.path(), "/responses");
475        let payload: serde_json::Value = requests[0].body_json().expect("request payload");
476        assert_eq!(payload["model"], "gpt-6-luna");
477    }
478
479    #[test]
480    fn sampling_overrides_resolve_per_model_with_provider_defaults() {
481        let router = sampling_router();
482
483        let cold = router.sampling_overrides("cold-model");
484        assert_eq!(
485            cold,
486            SamplingOverrides {
487                temperature: Some(0.0),
488                top_p: Some(0.9),
489                top_k: None,
490                presence_penalty: None,
491                frequency_penalty: None,
492                max_tokens: None,
493                reasoning_effort: Some(ReasoningEffortLevel::Low),
494                suppresses_sampling_with_reasoning: false,
495                profile_aware: true,
496            }
497        );
498        assert!(router.supports_reasoning_effort("cold-model"));
499
500        // Models without a profile fall back to provider-level defaults.
501        let warm = router.sampling_overrides("warm-model");
502        assert_eq!(warm.temperature, Some(0.5));
503        assert_eq!(warm.top_p, None);
504    }
505
506    #[test]
507    fn sampling_overrides_suppression_matrix_matches_native_semantics() {
508        let profile_openai = SamplingOverrides { profile_aware: true, ..Default::default() };
509        let profile_anthropic = SamplingOverrides {
510            suppresses_sampling_with_reasoning: true,
511            profile_aware: true,
512            ..Default::default()
513        };
514        let builtin_default = SamplingOverrides::default();
515
516        // Custom openai-shaped profile keeps pinned values during reasoning.
517        assert!(!profile_openai.suppresses_sampling(false, true));
518
519        // Custom anthropic-messages profile drops them.
520        assert!(profile_anthropic.suppresses_sampling(false, true));
521        assert!(!profile_anthropic.suppresses_sampling(false, false));
522
523        // Built-in Anthropic/MiniMax shape suppresses only via native match;
524        // built-in OpenAI shape never does through overrides alone.
525        assert!(builtin_default.suppresses_sampling(true, true));
526        assert!(!builtin_default.suppresses_sampling(true, false));
527        assert!(!builtin_default.suppresses_sampling(false, true));
528
529        // Level "none"/"unknown" is not active reasoning anywhere.
530        assert!(!profile_anthropic.suppresses_sampling(false, false));
531    }
532
533    #[test]
534    fn turn_scoped_system_capability_follows_selected_api_profile() {
535        let mut profiles = std::collections::BTreeMap::new();
536        profiles.insert(
537            models::anthropic::CLAUDE_FABLE_5.to_string(),
538            CustomProviderProfileConfig {
539                api_format: CustomProviderApiFormat::AnthropicMessages,
540                ..Default::default()
541            },
542        );
543
544        let router = CustomProviderBackendRouter::from_config(
545            CustomProviderConfig {
546                name: "profiled-transport-test".to_string(),
547                display_name: "Profiled Transport Test".to_string(),
548                base_url: "https://llm.example/v1".to_string(),
549                model: models::anthropic::CLAUDE_FABLE_5.to_string(),
550                models: vec![
551                    models::anthropic::CLAUDE_FABLE_5.to_string(),
552                    "openai-shaped".to_string(),
553                ],
554                profiles,
555                ..Default::default()
556            },
557            Some("test-key".to_string()),
558            None,
559            "https://llm.example/v1".to_string(),
560            None,
561            None,
562            None,
563            Some(AnthropicConfig::default()),
564            None,
565            None,
566        );
567
568        assert!(router.supports_turn_scoped_system_messages(models::anthropic::CLAUDE_FABLE_5));
569        assert!(!router.supports_turn_scoped_system_messages("openai-shaped"));
570    }
571
572    #[test]
573    fn anthropic_profile_accepts_turn_scoped_system_marker() {
574        let mut profiles = std::collections::BTreeMap::new();
575        profiles.insert(
576            models::anthropic::CLAUDE_FABLE_5.to_string(),
577            CustomProviderProfileConfig {
578                api_format: CustomProviderApiFormat::AnthropicMessages,
579                ..Default::default()
580            },
581        );
582        let router = CustomProviderBackendRouter::from_config(
583            CustomProviderConfig {
584                name: "profiled-validation-test".to_string(),
585                display_name: "Profiled Validation Test".to_string(),
586                base_url: "https://llm.example/v1".to_string(),
587                model: models::anthropic::CLAUDE_FABLE_5.to_string(),
588                models: vec![models::anthropic::CLAUDE_FABLE_5.to_string()],
589                profiles,
590                ..Default::default()
591            },
592            Some("test-key".to_string()),
593            None,
594            "https://llm.example/v1".to_string(),
595            None,
596            None,
597            None,
598            Some(AnthropicConfig::default()),
599            None,
600            None,
601        );
602        let request = LLMRequest {
603            model: models::anthropic::CLAUDE_FABLE_5.to_string(),
604            messages: vec![Message::turn_scoped_system("visible-output notice".to_string())].into(),
605            ..Default::default()
606        };
607
608        router
609            .validate_request(&request)
610            .expect("custom Anthropic profile should accept native marker");
611    }
612
613    fn write_tokens_file(dir: &Path, tokens: &[&str]) {
614        std::fs::write(dir.join("tokens.txt"), tokens.join("\n")).expect("write tokens file");
615    }
616
617    #[cfg(unix)]
618    fn auth_fixture(dir: &TempDir, tokens: &[&str]) -> CustomProviderCommandAuthConfig {
619        use std::os::unix::fs::PermissionsExt;
620
621        write_tokens_file(dir.path(), tokens);
622        let script_path = dir.path().join("print-token.sh");
623        std::fs::write(
624            &script_path,
625            r#"#!/bin/sh
626    first_line=$(sed -n '1p' tokens.txt)
627    printf '%s\n' "$first_line"
628    tail -n +2 tokens.txt > tokens.next
629    mv tokens.next tokens.txt
630    "#,
631        )
632        .expect("write script");
633        let mut perms = std::fs::metadata(&script_path).expect("metadata").permissions();
634        perms.set_mode(0o755);
635        std::fs::set_permissions(&script_path, perms).expect("set permissions");
636        CustomProviderCommandAuthConfig {
637            command: "./print-token.sh".to_string(),
638            args: Vec::new(),
639            cwd: Some(dir.path().to_path_buf()),
640            timeout_ms: 5_000,
641            refresh_interval_ms: 60_000,
642        }
643    }
644
645    #[cfg(windows)]
646    fn auth_fixture(dir: &TempDir, tokens: &[&str]) -> CustomProviderCommandAuthConfig {
647        write_tokens_file(dir.path(), tokens);
648        let script_path = dir.path().join("print-token.ps1");
649        std::fs::write(
650            &script_path,
651            r#"$lines = Get-Content -Path tokens.txt
652    if ($lines.Count -eq 0) { exit 1 }
653    Write-Output $lines[0]
654    $lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt
655    "#,
656        )
657        .expect("write script");
658        CustomProviderCommandAuthConfig {
659            command: "powershell".to_string(),
660            args: vec![
661                "-NoProfile".to_string(),
662                "-ExecutionPolicy".to_string(),
663                "Bypass".to_string(),
664                "-File".to_string(),
665                script_path.to_string_lossy().into_owned(),
666            ],
667            cwd: Some(dir.path().to_path_buf()),
668            timeout_ms: 5_000,
669            refresh_interval_ms: 60_000,
670        }
671    }
672
673    fn anthropic_response_body(text: &str) -> serde_json::Value {
674        json!({
675            "content": [{
676                "type": "text",
677                "text": text,
678            }],
679            "stop_reason": "end_turn",
680            "usage": {
681                "input_tokens": 1,
682                "output_tokens": 1,
683                "cache_creation_input_tokens": 0,
684                "cache_read_input_tokens": 0
685            }
686        })
687    }
688
689    #[tokio::test]
690    async fn anthropic_command_auth_refreshes_and_uses_protocol_headers() {
691        let server = MockServer::start().await;
692        let tempdir = TempDir::new().expect("tempdir");
693        let seen_keys = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
694        let auth_config = auth_fixture(&tempdir, &["first-token", "second-token"]);
695
696        let seen_for_mock = std::sync::Arc::clone(&seen_keys);
697        Mock::given(method("POST"))
698            .and(path("/messages"))
699            .respond_with(move |request: &wiremock::Request| {
700                let api_key = request
701                    .headers
702                    .get("x-api-key")
703                    .and_then(|value| value.to_str().ok())
704                    .unwrap_or_default()
705                    .to_string();
706                seen_for_mock.lock().expect("mutex").push(api_key);
707                let count = seen_for_mock.lock().expect("mutex").len();
708                if count == 1 {
709                    ResponseTemplate::new(401)
710                } else {
711                    ResponseTemplate::new(200).set_body_json(anthropic_response_body("anthropic command auth"))
712                }
713            })
714            .expect(2)
715            .mount(&server)
716            .await;
717
718        let config = CustomProviderConfig {
719            name: "anthropic-custom".to_string(),
720            display_name: "Anthropic Custom".to_string(),
721            base_url: server.uri(),
722            api_format: CustomProviderApiFormat::AnthropicMessages,
723            context_window: None,
724            temperature: None,
725            top_p: None,
726            top_k: None,
727            presence_penalty: None,
728            frequency_penalty: None,
729            max_tokens: None,
730            reasoning_effort: None,
731            supports_tools: None,
732            supports_reasoning: None,
733            supports_reasoning_effort: None,
734            supports_vision: None,
735            supports_structured_output: None,
736            supports_parallel_tool_calls: None,
737            supports_context_caching: None,
738            supports_responses_compaction: None,
739            supports_prompt_cache_options: None,
740            supports_context_edits: Some(true),
741            api_key_env: "ANTHROPIC_CUSTOM_API_KEY".to_string(),
742            auth: None,
743            model: models::anthropic::DEFAULT_MODEL.to_string(),
744            models: vec![models::anthropic::DEFAULT_MODEL.to_string()],
745            profiles: std::collections::BTreeMap::new(),
746        };
747
748        let router = CustomProviderBackendRouter::from_config(
749            config,
750            None,
751            Some(models::anthropic::DEFAULT_MODEL.to_string()),
752            server.uri(),
753            None,
754            None,
755            None,
756            Some(AnthropicConfig::default()),
757            None,
758            Some(CustomProviderAuthHandle::new(auth_config, None)),
759        );
760
761        let response = router
762            .generate(LLMRequest {
763                model: models::anthropic::DEFAULT_MODEL.to_string(),
764                messages: vec![Message::user("hello".to_string())].into(),
765                ..Default::default()
766            })
767            .await
768            .expect("anthropic auth should refresh and succeed");
769
770        assert_eq!(response.content.as_deref(), Some("anthropic command auth"));
771        assert_eq!(
772            seen_keys.lock().expect("mutex").as_slice(),
773            &["first-token".to_string(), "second-token".to_string()]
774        );
775    }
776}