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 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 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 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 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 assert!(!profile_openai.suppresses_sampling(false, true));
454
455 assert!(profile_anthropic.suppresses_sampling(false, true));
457 assert!(!profile_anthropic.suppresses_sampling(false, false));
458
459 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 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}