1use std::borrow::Cow;
8
9use std::path::PathBuf;
10
11use anyhow::Error;
12use vtcode_commons::cgp::HasComponent;
13use vtcode_commons::{ErrorFormatter, ErrorReporter, PathScope, TelemetrySink, WorkspacePaths};
14use vtcode_config::TimeoutsConfig;
15use vtcode_config::core::{AnthropicConfig, OpenAIConfig};
16use vtcode_config::core::{ModelConfig, PromptCachingConfig};
17
18pub trait ProviderConfig {
26 fn api_key(&self) -> Option<Cow<'_, str>>;
28
29 fn base_url(&self) -> Option<Cow<'_, str>> {
31 None
32 }
33
34 fn model(&self) -> Option<Cow<'_, str>> {
36 None
37 }
38
39 fn prompt_cache(&self) -> Option<Cow<'_, PromptCachingConfig>> {
42 None
43 }
44
45 fn timeouts(&self) -> Option<Cow<'_, TimeoutsConfig>> {
47 None
48 }
49
50 fn openai(&self) -> Option<Cow<'_, OpenAIConfig>> {
52 None
53 }
54
55 fn anthropic(&self) -> Option<Cow<'_, AnthropicConfig>> {
57 None
58 }
59
60 fn model_behavior(&self) -> Option<Cow<'_, ModelConfig>> {
62 None
63 }
64}
65
66enum FactoryConfigProjectionComponent {}
69
70trait FactoryConfigProjectionProvider<Ctx> {
71 fn project(ctx: &Ctx) -> crate::factory_types::ProviderConfig;
72}
73
74trait CanProjectFactoryConfig {
75 fn project_factory_config(&self) -> crate::factory_types::ProviderConfig;
76}
77
78impl<Ctx> CanProjectFactoryConfig for Ctx
79where
80 Ctx: HasComponent<FactoryConfigProjectionComponent>,
81 <Ctx as HasComponent<FactoryConfigProjectionComponent>>::Provider: FactoryConfigProjectionProvider<Ctx>,
82{
83 fn project_factory_config(&self) -> crate::factory_types::ProviderConfig {
84 <<Ctx as HasComponent<FactoryConfigProjectionComponent>>::Provider as FactoryConfigProjectionProvider<Ctx>>::project(self)
85 }
86}
87
88struct BorrowedConfigProjectionCtx<'a> {
89 source: &'a dyn ProviderConfig,
90}
91
92struct BorrowedConfigProjection;
93
94impl HasComponent<FactoryConfigProjectionComponent> for BorrowedConfigProjectionCtx<'_> {
95 type Provider = BorrowedConfigProjection;
96}
97
98impl FactoryConfigProjectionProvider<BorrowedConfigProjectionCtx<'_>> for BorrowedConfigProjection {
99 fn project(ctx: &BorrowedConfigProjectionCtx<'_>) -> crate::factory_types::ProviderConfig {
100 project_provider_config(ctx.source)
101 }
102}
103
104pub fn as_factory_config(source: &dyn ProviderConfig) -> crate::factory_types::ProviderConfig {
107 BorrowedConfigProjectionCtx { source }.project_factory_config()
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum AdapterEvent {
113 PromptCacheResolved { scope: PathScope, cache_dir: PathBuf },
115 TelemetryFailure { message: String },
118 AdapterError { message: String },
120}
121
122pub trait AdapterHooksProvider: Send + Sync {
131 type Paths: WorkspacePaths + ?Sized;
132 type Telemetry: TelemetrySink<AdapterEvent> + ?Sized;
133 type Reporter: ErrorReporter + ?Sized;
134 type Formatter: ErrorFormatter + ?Sized;
135
136 fn workspace_paths(&self) -> &Self::Paths;
137 fn telemetry(&self) -> &Self::Telemetry;
138 fn error_reporter(&self) -> &Self::Reporter;
139 fn error_formatter(&self) -> &Self::Formatter;
140}
141
142pub struct AdapterHooks<'a, Hooks: AdapterHooksProvider> {
149 hooks: &'a Hooks,
150}
151
152impl<'a, Hooks: AdapterHooksProvider> AdapterHooks<'a, Hooks> {
153 fn new(hooks: &'a Hooks) -> Self {
155 Self { hooks }
156 }
157
158 pub fn apply_to(&self, source: &dyn ProviderConfig) -> crate::factory_types::ProviderConfig {
161 HookedConfigProjectionCtx { source, hooks: self }.project_factory_config()
162 }
163
164 fn enrich_prompt_cache(&self, prompt_cache: &mut PromptCachingConfig) {
165 let resolved = prompt_cache.resolve_cache_dir(Some(self.hooks.workspace_paths().workspace_root()));
166 let scope = self.hooks.workspace_paths().scope_for_path(&resolved);
167 let is_abs = resolved.is_absolute();
169 let resolved_display = resolved.display().to_string();
170
171 prompt_cache.cache_dir = resolved.to_string_lossy().into_owned();
173 self.record_event(AdapterEvent::PromptCacheResolved { scope, cache_dir: resolved });
174
175 if !is_abs {
176 let error = Error::msg(format!(
177 "Prompt cache directory `{resolved_display}` could not be resolved to an absolute path"
178 ));
179 self.report_error(error);
180 }
181 }
182
183 fn record_event(&self, event: AdapterEvent) {
184 if let Err(err) = self.hooks.telemetry().record(&event) {
185 self.handle_error(err.context("failed to record LLM adapter telemetry event"));
186 }
187 }
188
189 fn capture_error_message(&self, error: &Error) -> String {
190 let message = self.hooks.error_formatter().format_error(error).into_owned();
191 let _ = self.hooks.error_reporter().capture(error);
192 message
193 }
194
195 fn report_error(&self, error: Error) {
196 let message = self.capture_error_message(&error);
197 let _ = self.hooks.telemetry().record(&AdapterEvent::AdapterError { message });
200 }
201
202 fn handle_error(&self, error: Error) {
203 let message = self.capture_error_message(&error);
204 let _ = self.hooks.telemetry().record(&AdapterEvent::TelemetryFailure { message });
205 }
206}
207
208struct HookedConfigProjectionCtx<'source, 'hooks, Hooks: AdapterHooksProvider> {
209 source: &'source dyn ProviderConfig,
210 hooks: &'hooks AdapterHooks<'hooks, Hooks>,
211}
212
213struct HookedConfigProjection;
214
215impl<'source, 'hooks, Hooks: AdapterHooksProvider> HasComponent<FactoryConfigProjectionComponent>
216 for HookedConfigProjectionCtx<'source, 'hooks, Hooks>
217{
218 type Provider = HookedConfigProjection;
219}
220
221impl<'source, 'hooks, Hooks: AdapterHooksProvider>
222 FactoryConfigProjectionProvider<HookedConfigProjectionCtx<'source, 'hooks, Hooks>> for HookedConfigProjection
223{
224 fn project(ctx: &HookedConfigProjectionCtx<'source, 'hooks, Hooks>) -> crate::factory_types::ProviderConfig {
225 let mut config = BorrowedConfigProjectionCtx { source: ctx.source }.project_factory_config();
226 if let Some(prompt_cache) = config.prompt_cache.as_mut() {
227 ctx.hooks.enrich_prompt_cache(prompt_cache);
228 }
229 config
230 }
231}
232
233pub fn as_factory_config_with_hooks<'a, Hooks: AdapterHooksProvider>(
236 source: &dyn ProviderConfig,
237 hooks: &AdapterHooks<'a, Hooks>,
238) -> crate::factory_types::ProviderConfig {
239 HookedConfigProjectionCtx { source, hooks }.project_factory_config()
240}
241
242fn project_provider_config(source: &dyn ProviderConfig) -> crate::factory_types::ProviderConfig {
243 crate::factory_types::ProviderConfig {
244 api_key: source.api_key().map(Cow::into_owned),
245 openai_chatgpt_auth: None,
246 copilot_auth: None,
247 base_url: source.base_url().map(Cow::into_owned),
248 model: source.model().map(Cow::into_owned),
249 prompt_cache: source.prompt_cache().map(Cow::into_owned),
250 timeouts: source.timeouts().map(Cow::into_owned),
251 openai: source.openai().map(Cow::into_owned),
252 anthropic: source.anthropic().map(Cow::into_owned),
253 model_behavior: source.model_behavior().map(Cow::into_owned),
254 workspace_root: None,
255 }
256}
257
258fn borrowed_optional_str(value: &Option<String>) -> Option<Cow<'_, str>> {
259 value.as_deref().map(Cow::Borrowed)
260}
261
262fn borrowed_optional<T: Clone>(value: &Option<T>) -> Option<Cow<'_, T>> {
263 value.as_ref().map(Cow::Borrowed)
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use anyhow::{Error, Result, anyhow};
270 use assert_fs::TempDir;
271 use std::borrow::Cow;
272 use std::path::Path;
273 use std::sync::{Arc, Mutex};
274
275 #[derive(Clone)]
276 struct TestPaths {
277 root: PathBuf,
278 config: PathBuf,
279 cache: PathBuf,
280 }
281
282 impl WorkspacePaths for TestPaths {
283 fn workspace_root(&self) -> &Path {
284 &self.root
285 }
286
287 fn config_dir(&self) -> PathBuf {
288 self.config.clone()
289 }
290
291 fn cache_dir(&self) -> Option<PathBuf> {
292 Some(self.cache.clone())
293 }
294 }
295
296 #[derive(Default)]
297 struct RecordingTelemetry {
298 events: Arc<Mutex<Vec<AdapterEvent>>>,
299 }
300
301 impl TelemetrySink<AdapterEvent> for RecordingTelemetry {
302 fn record(&self, event: &AdapterEvent) -> Result<()> {
303 self.events.lock().unwrap().push(event.clone());
304 Ok(())
305 }
306 }
307
308 #[derive(Default)]
309 struct FailingTelemetry {
310 events: Arc<Mutex<Vec<AdapterEvent>>>,
311 fail_next: Arc<Mutex<bool>>,
312 }
313
314 impl TelemetrySink<AdapterEvent> for FailingTelemetry {
315 fn record(&self, event: &AdapterEvent) -> Result<()> {
316 let mut fail = self.fail_next.lock().unwrap();
317 if std::mem::take(&mut *fail) {
318 Err(anyhow!("telemetry unavailable"))
319 } else {
320 self.events.lock().unwrap().push(event.clone());
321 Ok(())
322 }
323 }
324 }
325
326 #[derive(Default)]
327 struct RecordingReporter {
328 errors: Arc<Mutex<Vec<String>>>,
329 }
330
331 impl ErrorReporter for RecordingReporter {
332 fn capture(&self, error: &Error) -> Result<()> {
333 self.errors.lock().unwrap().push(error.to_string());
334 Ok(())
335 }
336 }
337
338 #[derive(Default)]
339 struct RecordingFormatter {
340 messages: Arc<Mutex<Vec<String>>>,
341 }
342
343 impl ErrorFormatter for RecordingFormatter {
344 fn format_error(&self, error: &Error) -> Cow<'_, str> {
345 let message = error.to_string();
346 self.messages.lock().unwrap().push(message.clone());
347 Cow::Owned(message)
348 }
349 }
350
351 struct TestHooks {
354 paths: TestPaths,
355 telemetry: RecordingTelemetry,
356 reporter: RecordingReporter,
357 formatter: RecordingFormatter,
358 }
359
360 impl AdapterHooksProvider for TestHooks {
361 type Paths = TestPaths;
362 type Telemetry = RecordingTelemetry;
363 type Reporter = RecordingReporter;
364 type Formatter = RecordingFormatter;
365
366 fn workspace_paths(&self) -> &TestPaths {
367 &self.paths
368 }
369 fn telemetry(&self) -> &RecordingTelemetry {
370 &self.telemetry
371 }
372 fn error_reporter(&self) -> &RecordingReporter {
373 &self.reporter
374 }
375 fn error_formatter(&self) -> &RecordingFormatter {
376 &self.formatter
377 }
378 }
379
380 struct FailingHooks {
382 paths: TestPaths,
383 telemetry: FailingTelemetry,
384 reporter: RecordingReporter,
385 formatter: RecordingFormatter,
386 }
387
388 impl AdapterHooksProvider for FailingHooks {
389 type Paths = TestPaths;
390 type Telemetry = FailingTelemetry;
391 type Reporter = RecordingReporter;
392 type Formatter = RecordingFormatter;
393
394 fn workspace_paths(&self) -> &TestPaths {
395 &self.paths
396 }
397 fn telemetry(&self) -> &FailingTelemetry {
398 &self.telemetry
399 }
400 fn error_reporter(&self) -> &RecordingReporter {
401 &self.reporter
402 }
403 fn error_formatter(&self) -> &RecordingFormatter {
404 &self.formatter
405 }
406 }
407
408 #[test]
409 fn applies_workspace_paths_to_prompt_cache() {
410 let temp_dir = TempDir::new().unwrap();
411 let root = temp_dir.path().join("workspace");
412 let config = root.join("config");
413 let cache = root.join("cache");
414 std::fs::create_dir_all(&config).unwrap();
415 std::fs::create_dir_all(&cache).unwrap();
416
417 let hooks = TestHooks {
418 paths: TestPaths { root, config, cache },
419 telemetry: RecordingTelemetry::default(),
420 reporter: RecordingReporter::default(),
421 formatter: RecordingFormatter::default(),
422 };
423 let adapter = AdapterHooks::new(&hooks);
424
425 let prompt_cache = PromptCachingConfig {
426 cache_dir: "relative/cache".to_string(),
427 ..PromptCachingConfig::default()
428 };
429
430 let config = OwnedProviderConfig::new().with_prompt_cache(prompt_cache);
431 let adapted = as_factory_config_with_hooks(&config, &adapter);
432
433 let prompt_cache = adapted.prompt_cache.expect("prompt cache present");
434 assert!(prompt_cache.cache_dir.ends_with("relative/cache"));
435
436 let events = hooks.telemetry.events.lock().unwrap();
437 assert_eq!(events.len(), 1);
438 match &events[0] {
439 AdapterEvent::PromptCacheResolved { scope, cache_dir } => {
440 assert_eq!(*scope, PathScope::Workspace);
441 assert!(cache_dir.ends_with("relative/cache"));
442 }
443 other => panic!("unexpected event: {other:?}"),
444 }
445 }
446
447 #[test]
448 fn reports_errors_when_telemetry_fails() {
449 let temp_dir = TempDir::new().unwrap();
450 let root = temp_dir.path().join("workspace");
451 let config = root.join("config");
452 let cache = root.join("cache");
453 std::fs::create_dir_all(&config).unwrap();
454 std::fs::create_dir_all(&cache).unwrap();
455
456 let hooks = FailingHooks {
457 paths: TestPaths { root, config, cache },
458 telemetry: {
459 let t = FailingTelemetry::default();
460 *t.fail_next.lock().unwrap() = true;
461 t
462 },
463 reporter: RecordingReporter::default(),
464 formatter: RecordingFormatter::default(),
465 };
466 let adapter = AdapterHooks::new(&hooks);
467
468 let prompt_cache = PromptCachingConfig {
469 cache_dir: "relative/cache".to_string(),
470 ..PromptCachingConfig::default()
471 };
472 let config = OwnedProviderConfig::new().with_prompt_cache(prompt_cache);
473 let _ = as_factory_config_with_hooks(&config, &adapter);
474
475 assert_eq!(hooks.reporter.errors.lock().unwrap().len(), 1);
478 assert_eq!(hooks.formatter.messages.lock().unwrap().len(), 1);
479
480 let events = hooks.telemetry.events.lock().unwrap();
483 assert_eq!(events.len(), 1);
484 assert!(matches!(events[0], AdapterEvent::TelemetryFailure { .. }));
485 }
486
487 #[test]
488 fn core_provider_config_exposes_borrowed_nested_values() {
489 let config = crate::factory_types::ProviderConfig {
490 api_key: None,
491 openai_chatgpt_auth: None,
492 copilot_auth: None,
493 base_url: None,
494 model: None,
495 prompt_cache: Some(PromptCachingConfig::default()),
496 timeouts: Some(TimeoutsConfig::default()),
497 openai: Some(OpenAIConfig::default()),
498 anthropic: Some(AnthropicConfig::default()),
499 model_behavior: Some(ModelConfig::default()),
500 workspace_root: None,
501 };
502
503 assert!(matches!(
504 <crate::factory_types::ProviderConfig as ProviderConfig>::prompt_cache(&config),
505 Some(Cow::Borrowed(_))
506 ));
507 assert!(matches!(
508 <crate::factory_types::ProviderConfig as ProviderConfig>::timeouts(&config),
509 Some(Cow::Borrowed(_))
510 ));
511 assert!(matches!(
512 <crate::factory_types::ProviderConfig as ProviderConfig>::openai(&config),
513 Some(Cow::Borrowed(_))
514 ));
515 assert!(matches!(
516 <crate::factory_types::ProviderConfig as ProviderConfig>::anthropic(&config),
517 Some(Cow::Borrowed(_))
518 ));
519 assert!(matches!(
520 <crate::factory_types::ProviderConfig as ProviderConfig>::model_behavior(&config),
521 Some(Cow::Borrowed(_))
522 ));
523 }
524
525 #[test]
526 fn owned_provider_config_exposes_borrowed_nested_values() {
527 let config = OwnedProviderConfig::new()
528 .with_prompt_cache(PromptCachingConfig::default())
529 .with_timeouts(TimeoutsConfig::default())
530 .with_openai(OpenAIConfig::default())
531 .with_anthropic(AnthropicConfig::default())
532 .with_model_behavior(ModelConfig::default());
533
534 assert!(matches!(<OwnedProviderConfig as ProviderConfig>::prompt_cache(&config), Some(Cow::Borrowed(_))));
535 assert!(matches!(<OwnedProviderConfig as ProviderConfig>::timeouts(&config), Some(Cow::Borrowed(_))));
536 assert!(matches!(<OwnedProviderConfig as ProviderConfig>::openai(&config), Some(Cow::Borrowed(_))));
537 assert!(matches!(<OwnedProviderConfig as ProviderConfig>::anthropic(&config), Some(Cow::Borrowed(_))));
538 assert!(matches!(<OwnedProviderConfig as ProviderConfig>::model_behavior(&config), Some(Cow::Borrowed(_))));
539 }
540
541 #[test]
542 fn preserves_provider_specific_fields_from_core_config() {
543 let source = crate::factory_types::ProviderConfig {
544 api_key: Some("secret".to_string()),
545 openai_chatgpt_auth: None,
546 copilot_auth: None,
547 base_url: Some("https://api.example.com".to_string()),
548 model: Some("gpt-5".to_string()),
549 prompt_cache: Some(PromptCachingConfig::default()),
550 timeouts: Some(TimeoutsConfig::default()),
551 openai: Some(OpenAIConfig { websocket_mode: true, ..OpenAIConfig::default() }),
552 anthropic: Some(AnthropicConfig {
553 count_tokens_enabled: true,
554 ..AnthropicConfig::default()
555 }),
556 model_behavior: Some(ModelConfig::default()),
557 workspace_root: None,
558 };
559
560 let adapted = as_factory_config(&source);
561
562 assert_eq!(adapted.api_key, source.api_key);
563 assert_eq!(adapted.base_url, source.base_url);
564 assert_eq!(adapted.model, source.model);
565 assert!(adapted.prompt_cache.is_some());
566 assert_eq!(adapted.timeouts.as_ref().unwrap().pty_ceiling_seconds, 300);
567 assert!(adapted.openai.as_ref().unwrap().websocket_mode);
568 assert!(adapted.anthropic.as_ref().unwrap().count_tokens_enabled);
569 assert!(adapted.model_behavior.is_some());
570 }
571
572 #[test]
573 fn owned_provider_config_keeps_provider_specific_fields() {
574 let config = OwnedProviderConfig::new()
575 .with_timeouts(TimeoutsConfig::default())
576 .with_openai(OpenAIConfig { websocket_mode: true, ..OpenAIConfig::default() })
577 .with_anthropic(AnthropicConfig {
578 count_tokens_enabled: true,
579 ..AnthropicConfig::default()
580 });
581
582 let adapted = as_factory_config(&config);
583
584 assert_eq!(adapted.timeouts.as_ref().unwrap().streaming_ceiling_seconds, 600);
585 assert!(adapted.openai.as_ref().unwrap().websocket_mode);
586 assert!(adapted.anthropic.as_ref().unwrap().count_tokens_enabled);
587 }
588}
589
590impl ProviderConfig for crate::factory_types::ProviderConfig {
592 fn api_key(&self) -> Option<Cow<'_, str>> {
593 borrowed_optional_str(&self.api_key)
594 }
595
596 fn base_url(&self) -> Option<Cow<'_, str>> {
597 borrowed_optional_str(&self.base_url)
598 }
599
600 fn model(&self) -> Option<Cow<'_, str>> {
601 borrowed_optional_str(&self.model)
602 }
603
604 fn prompt_cache(&self) -> Option<Cow<'_, PromptCachingConfig>> {
605 borrowed_optional(&self.prompt_cache)
606 }
607
608 fn timeouts(&self) -> Option<Cow<'_, TimeoutsConfig>> {
609 borrowed_optional(&self.timeouts)
610 }
611
612 fn openai(&self) -> Option<Cow<'_, OpenAIConfig>> {
613 borrowed_optional(&self.openai)
614 }
615
616 fn anthropic(&self) -> Option<Cow<'_, AnthropicConfig>> {
617 borrowed_optional(&self.anthropic)
618 }
619
620 fn model_behavior(&self) -> Option<Cow<'_, ModelConfig>> {
621 borrowed_optional(&self.model_behavior)
622 }
623}
624
625#[derive(Clone, Debug, Default)]
627#[must_use = "builders do nothing unless consumed"]
628pub struct OwnedProviderConfig {
629 api_key: Option<String>,
630 base_url: Option<String>,
631 model: Option<String>,
632 prompt_cache: Option<PromptCachingConfig>,
633 timeouts: Option<TimeoutsConfig>,
634 openai: Option<OpenAIConfig>,
635 anthropic: Option<AnthropicConfig>,
636 model_behavior: Option<ModelConfig>,
637}
638
639impl OwnedProviderConfig {
640 fn new() -> Self {
641 Self::default()
642 }
643
644 pub fn with_api_key(mut self, value: impl Into<String>) -> Self {
645 self.api_key = Some(value.into());
646 self
647 }
648
649 pub fn with_base_url(mut self, value: impl Into<String>) -> Self {
650 self.base_url = Some(value.into());
651 self
652 }
653
654 pub fn with_model(mut self, value: impl Into<String>) -> Self {
655 self.model = Some(value.into());
656 self
657 }
658
659 fn with_prompt_cache(mut self, value: PromptCachingConfig) -> Self {
660 self.prompt_cache = Some(value);
661 self
662 }
663
664 fn with_timeouts(mut self, value: TimeoutsConfig) -> Self {
665 self.timeouts = Some(value);
666 self
667 }
668
669 fn with_openai(mut self, value: OpenAIConfig) -> Self {
670 self.openai = Some(value);
671 self
672 }
673
674 fn with_anthropic(mut self, value: AnthropicConfig) -> Self {
675 self.anthropic = Some(value);
676 self
677 }
678
679 fn with_model_behavior(mut self, value: ModelConfig) -> Self {
680 self.model_behavior = Some(value);
681 self
682 }
683}
684
685impl ProviderConfig for OwnedProviderConfig {
686 fn api_key(&self) -> Option<Cow<'_, str>> {
687 borrowed_optional_str(&self.api_key)
688 }
689
690 fn base_url(&self) -> Option<Cow<'_, str>> {
691 borrowed_optional_str(&self.base_url)
692 }
693
694 fn model(&self) -> Option<Cow<'_, str>> {
695 borrowed_optional_str(&self.model)
696 }
697
698 fn prompt_cache(&self) -> Option<Cow<'_, PromptCachingConfig>> {
699 borrowed_optional(&self.prompt_cache)
700 }
701
702 fn timeouts(&self) -> Option<Cow<'_, TimeoutsConfig>> {
703 borrowed_optional(&self.timeouts)
704 }
705
706 fn openai(&self) -> Option<Cow<'_, OpenAIConfig>> {
707 borrowed_optional(&self.openai)
708 }
709
710 fn anthropic(&self) -> Option<Cow<'_, AnthropicConfig>> {
711 borrowed_optional(&self.anthropic)
712 }
713
714 fn model_behavior(&self) -> Option<Cow<'_, ModelConfig>> {
715 borrowed_optional(&self.model_behavior)
716 }
717}