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