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
14pub 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 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 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 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 assert!(!profile_openai.suppresses_sampling(false, true));
518
519 assert!(profile_anthropic.suppresses_sampling(false, true));
521 assert!(!profile_anthropic.suppresses_sampling(false, false));
522
523 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 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}