1use async_stream::try_stream;
2use async_trait::async_trait;
3use compact_str::format_compact;
4use once_cell::sync::Lazy;
5use rustc_hash::FxHashMap;
6use std::sync::RwLock;
7use vtcode_commons::llm::BackendKind;
8
9use vtcode_commons::tool_types::CompactStr;
10
11use super::{
12 LLMNormalizedStream, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, Message, ResponsesCompactionOptions,
13 SamplingOverrides,
14};
15pub use vtcode_commons::llm::{LLMError, LLMErrorMetadata};
16
17pub(crate) const GENERIC_REASONING_EFFORTS: &[&str] = &["low", "medium", "high"];
20
21pub(crate) fn catalog_reasoning_efforts(provider: &str, model: &str) -> Option<&'static [&'static str]> {
26 vtcode_config::models::model_catalog_entry(provider, model).map(|entry| entry.reasoning_efforts)
27}
28
29pub(crate) fn catalog_or_generic_reasoning_efforts(provider: &str, model: &str) -> &'static [&'static str] {
33 catalog_reasoning_efforts(provider, model).unwrap_or(GENERIC_REASONING_EFFORTS)
34}
35
36pub(crate) fn catalog_or_explicit_reasoning_efforts(
40 provider: &str,
41 model: &str,
42 explicitly_supported: bool,
43) -> &'static [&'static str] {
44 catalog_reasoning_efforts(provider, model)
45 .or_else(|| explicitly_supported.then_some(GENERIC_REASONING_EFFORTS))
46 .unwrap_or(&[])
47}
48
49pub(crate) fn catalog_context_window(provider: &str, model: &str, fallback: usize) -> usize {
55 vtcode_config::models::model_catalog_entry(provider, model)
56 .map(|entry| entry.context_window)
57 .filter(|context_window| *context_window > 0)
58 .unwrap_or(fallback)
59}
60
61#[derive(Debug, Clone)]
63pub struct ProviderCapabilities {
64 pub(crate) provider_name: String,
65 pub(crate) model: String,
66 pub streaming: bool,
67 pub reasoning: bool,
68 pub reasoning_effort: bool,
69 pub tools: bool,
70 pub parallel_tool_config: bool,
71 pub(crate) structured_output: bool,
72 pub(crate) context_caching: bool,
73 pub responses_compaction: bool,
74 pub context_edits: bool,
75 pub turn_scoped_system_messages: bool,
78 pub(crate) vision: bool,
79 pub(crate) context_size: usize,
80}
81
82impl ProviderCapabilities {
83 fn detect(provider: &dyn LLMProvider, model: &str) -> Self {
84 Self {
85 provider_name: provider.name().to_string(),
86 model: model.to_string(),
87 streaming: provider.supports_streaming(),
88 reasoning: provider.supports_reasoning(model),
89 reasoning_effort: !provider.supported_reasoning_efforts(model).is_empty(),
90 tools: provider.supports_tools(model),
91 parallel_tool_config: provider.supports_parallel_tool_config(model),
92 structured_output: provider.supports_structured_output(model),
93 context_caching: provider.supports_context_caching(model),
94 responses_compaction: provider.supports_responses_compaction(model),
95 context_edits: provider.supports_context_edits(model),
96 turn_scoped_system_messages: provider.supports_turn_scoped_system_messages(model),
97 vision: provider.supports_vision(model),
98 context_size: provider.effective_context_size(model),
99 }
100 }
101
102 pub(crate) fn has_advanced_features(&self) -> bool {
103 self.reasoning || self.structured_output || self.context_caching || self.reasoning_effort
104 }
105
106 pub(crate) fn summary(&self) -> String {
107 let mut features = Vec::new();
108
109 if self.streaming {
110 features.push("streaming");
111 }
112 if self.reasoning {
113 features.push("advanced-reasoning");
114 }
115 if self.reasoning_effort {
116 features.push("reasoning-effort");
117 }
118 if self.structured_output {
119 features.push("structured-output");
120 }
121 if self.context_caching {
122 features.push("context-caching");
123 }
124 if self.parallel_tool_config {
125 features.push("parallel-tools");
126 }
127 if self.responses_compaction {
128 features.push("responses-compaction");
129 }
130 if self.context_edits {
131 features.push("context-edits");
132 }
133
134 let features_str = if features.is_empty() {
135 "basic".to_string()
136 } else {
137 features.join(", ")
138 };
139
140 format!("{} ({} tokens): {}", self.model, self.context_size, features_str)
141 }
142}
143
144static CAPABILITY_CACHE: Lazy<RwLock<FxHashMap<CompactStr, ProviderCapabilities>>> =
146 Lazy::new(|| RwLock::new(FxHashMap::default()));
147
148pub fn get_cached_capabilities(provider: &dyn LLMProvider, model: &str) -> ProviderCapabilities {
150 let cache_key = format_compact!("{}::{}::{}", provider.name(), model, provider.effective_context_size(model));
151
152 if let Ok(cache) = CAPABILITY_CACHE.read()
154 && let Some(caps) = cache.get(&cache_key)
155 {
156 return caps.clone();
157 }
158
159 let caps = ProviderCapabilities::detect(provider, model);
161
162 if let Ok(mut cache) = CAPABILITY_CACHE.write() {
164 cache.insert(cache_key, caps.clone());
165 }
166
167 caps
168}
169
170#[async_trait]
172pub trait LLMProvider: Send + Sync {
173 fn name(&self) -> &str;
175
176 fn supports_decisions(&self) -> bool {
179 false
180 }
181
182 async fn decide_choice(
184 &self,
185 _request: super::ChoiceDecisionRequest,
186 ) -> Result<super::ChoiceDecisionResponse, LLMError> {
187 Err(LLMError::Provider {
188 message: "Decisions is unsupported for this provider".to_owned(),
189 metadata: None,
190 })
191 }
192
193 fn backend_kind(&self) -> BackendKind {
199 match self.name() {
200 "gemini" => BackendKind::Gemini,
201 "openai" => BackendKind::OpenAI,
202 "anthropic" => BackendKind::Anthropic,
203 "deepseek" => BackendKind::DeepSeek,
204 "meta" => BackendKind::Meta,
205 "mistral" => BackendKind::Mistral,
206 "openrouter" => BackendKind::OpenRouter,
207 "ollama" => BackendKind::Ollama,
208 "llamacpp" => BackendKind::LlamaCpp,
209 "zai" => BackendKind::ZAI,
210 "moonshot" => BackendKind::Moonshot,
211 "huggingface" => BackendKind::HuggingFace,
212 "minimax" => BackendKind::Minimax,
213 "mimo" => BackendKind::MiMo,
214 "opencode-zen" => BackendKind::OpenCodeZen,
215 "opencode-go" => BackendKind::OpenCodeGo,
216 "qwen" => BackendKind::Qwen,
217 "stepfun" => BackendKind::StepFun,
218 "evolink" => BackendKind::Evolink,
219 "poolside" => BackendKind::Poolside,
220 "nvidia" => BackendKind::Nvidia,
221 "merge-gateway" => BackendKind::MergeGateway,
222 "vercel" => BackendKind::Vercel,
223 _ => BackendKind::OpenAI,
224 }
225 }
226
227 fn supports_streaming(&self) -> bool {
229 false
230 }
231
232 fn supports_non_streaming(&self, _model: &str) -> bool {
234 true
235 }
236
237 fn supports_reasoning(&self, _model: &str) -> bool {
239 false
240 }
241
242 fn supports_reasoning_effort(&self, _model: &str) -> bool {
244 false
245 }
246
247 fn supported_reasoning_efforts(&self, model: &str) -> &'static [&'static str] {
249 if !self.supports_reasoning_effort(model) {
250 return &[];
251 }
252 catalog_or_generic_reasoning_efforts(self.name(), model)
253 }
254
255 fn sampling_overrides(&self, _model: &str) -> SamplingOverrides {
261 SamplingOverrides::default()
262 }
263
264 fn supports_tools(&self, _model: &str) -> bool {
266 true
267 }
268
269 fn supports_parallel_tool_config(&self, _model: &str) -> bool {
271 false
272 }
273
274 fn supports_structured_output(&self, _model: &str) -> bool {
276 false
277 }
278
279 fn supports_context_caching(&self, _model: &str) -> bool {
281 false
282 }
283
284 fn requested_prompt_cache_ttl(&self, _model: &str) -> Option<vtcode_config::core::prompt_cache::PromptCacheTtl> {
286 None
287 }
288
289 fn supports_vision(&self, _model: &str) -> bool {
291 false
292 }
293
294 fn supports_responses_compaction(&self, _model: &str) -> bool {
296 false
297 }
298
299 fn supports_native_allowed_tools(&self, _model: &str) -> bool {
301 false
302 }
303
304 fn supports_context_edits(&self, _model: &str) -> bool {
307 false
308 }
309
310 fn supports_turn_scoped_system_messages(&self, _model: &str) -> bool {
320 false
321 }
322
323 fn supports_manual_openai_compaction(&self, _model: &str) -> bool {
328 false
329 }
330
331 fn supports_native_inline_compaction(&self, _model: &str) -> bool {
344 false
345 }
346
347 fn manual_openai_compaction_unavailable_message(&self, model: &str) -> String {
354 format!(
355 "`--native-only` `/compact` requires a provider that exposes a native server-side compaction endpoint, which this provider does not. Active provider/model: {} / {}. Run `/compact` without `--native-only` to compact via the universal local summarization fallback.",
356 self.name(),
357 model,
358 )
359 }
360
361 fn effective_context_size(&self, model: &str) -> usize {
367 catalog_context_window(self.name(), model, 128_000)
368 }
369
370 async fn compact_history(&self, _model: &str, _history: &[Message]) -> Result<Vec<Message>, LLMError> {
373 Err(LLMError::Provider {
374 message: "Conversation compaction is not supported by this provider".to_string(),
375 metadata: None,
376 })
377 }
378
379 async fn compact_history_with_options(
381 &self,
382 _model: &str,
383 _history: &[Message],
384 _options: &ResponsesCompactionOptions,
385 ) -> Result<Vec<Message>, LLMError> {
386 Err(LLMError::Provider {
387 message: "manual OpenAI compaction is not supported by this provider".to_string(),
388 metadata: None,
389 })
390 }
391
392 async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError>;
394
395 async fn generate_auxiliary(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
398 self.generate(request).await
399 }
400
401 async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
403 let response = self.generate(request).await?;
405 let stream = try_stream! {
406 yield LLMStreamEvent::Completed { response: Box::new(response) };
407 };
408 Ok(Box::pin(stream))
409 }
410
411 async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
413 let mut legacy_stream = self.stream(request).await?;
414 let stream = try_stream! {
415 while let Some(event) = futures::StreamExt::next(&mut legacy_stream).await {
416 for normalized in event?.into_normalized() {
417 yield normalized;
418 }
419 }
420 };
421 Ok(Box::pin(stream))
422 }
423
424 #[cfg(feature = "copilot")]
428 fn start_copilot_prompt_session<'a>(
429 &'a self,
430 _request: LLMRequest,
431 _tools: &'a [super::ToolDefinition],
432 ) -> Option<crate::copilot::CopilotPromptSessionFuture<'a>> {
433 None
434 }
435
436 fn supported_models(&self) -> Vec<String>;
438
439 async fn get_balance(&self) -> Result<Option<vtcode_commons::llm::BalanceInfo>, LLMError> {
441 Ok(None)
442 }
443
444 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError>;
446}
447
448pub struct ContextWindowProvider {
458 inner: Box<dyn LLMProvider>,
459 model: CompactStr,
460 context_window: usize,
461}
462
463impl ContextWindowProvider {
464 pub fn wrap(inner: Box<dyn LLMProvider>, model: &str, context_window: Option<usize>) -> Box<dyn LLMProvider> {
466 match context_window.filter(|value| *value > 0) {
467 Some(context_window) => Box::new(Self { inner, model: model.into(), context_window }),
468 None => inner,
469 }
470 }
471}
472
473#[async_trait]
474impl LLMProvider for ContextWindowProvider {
475 fn name(&self) -> &str {
476 self.inner.name()
477 }
478
479 fn supports_decisions(&self) -> bool {
480 self.inner.supports_decisions()
481 }
482
483 async fn decide_choice(
484 &self,
485 request: super::ChoiceDecisionRequest,
486 ) -> Result<super::ChoiceDecisionResponse, LLMError> {
487 self.inner.decide_choice(request).await
488 }
489
490 fn backend_kind(&self) -> BackendKind {
491 self.inner.backend_kind()
492 }
493
494 fn supports_streaming(&self) -> bool {
495 self.inner.supports_streaming()
496 }
497
498 fn supports_non_streaming(&self, model: &str) -> bool {
499 self.inner.supports_non_streaming(model)
500 }
501
502 fn supports_reasoning(&self, model: &str) -> bool {
503 self.inner.supports_reasoning(model)
504 }
505
506 fn supports_reasoning_effort(&self, model: &str) -> bool {
507 self.inner.supports_reasoning_effort(model)
508 }
509
510 fn supported_reasoning_efforts(&self, model: &str) -> &'static [&'static str] {
511 self.inner.supported_reasoning_efforts(model)
512 }
513
514 fn sampling_overrides(&self, model: &str) -> SamplingOverrides {
515 self.inner.sampling_overrides(model)
516 }
517
518 fn supports_tools(&self, model: &str) -> bool {
519 self.inner.supports_tools(model)
520 }
521
522 fn supports_parallel_tool_config(&self, model: &str) -> bool {
523 self.inner.supports_parallel_tool_config(model)
524 }
525
526 fn supports_structured_output(&self, model: &str) -> bool {
527 self.inner.supports_structured_output(model)
528 }
529
530 fn supports_context_caching(&self, model: &str) -> bool {
531 self.inner.supports_context_caching(model)
532 }
533
534 fn requested_prompt_cache_ttl(&self, model: &str) -> Option<vtcode_config::core::prompt_cache::PromptCacheTtl> {
535 self.inner.requested_prompt_cache_ttl(model)
536 }
537
538 fn supports_vision(&self, model: &str) -> bool {
539 self.inner.supports_vision(model)
540 }
541
542 fn supports_responses_compaction(&self, model: &str) -> bool {
543 self.inner.supports_responses_compaction(model)
544 }
545
546 fn supports_native_allowed_tools(&self, model: &str) -> bool {
547 self.inner.supports_native_allowed_tools(model)
548 }
549
550 fn supports_context_edits(&self, model: &str) -> bool {
551 self.inner.supports_context_edits(model)
552 }
553
554 fn supports_turn_scoped_system_messages(&self, model: &str) -> bool {
555 self.inner.supports_turn_scoped_system_messages(model)
556 }
557
558 fn supports_manual_openai_compaction(&self, model: &str) -> bool {
559 self.inner.supports_manual_openai_compaction(model)
560 }
561
562 fn supports_native_inline_compaction(&self, model: &str) -> bool {
563 self.inner.supports_native_inline_compaction(model)
564 }
565
566 fn manual_openai_compaction_unavailable_message(&self, model: &str) -> String {
567 self.inner.manual_openai_compaction_unavailable_message(model)
568 }
569
570 fn effective_context_size(&self, model: &str) -> usize {
571 let requested_model = if model.trim().is_empty() {
572 self.model.as_str()
573 } else {
574 model
575 };
576 if requested_model == self.model.as_str() {
577 self.context_window
578 } else {
579 self.inner.effective_context_size(requested_model)
580 }
581 }
582
583 async fn compact_history(&self, model: &str, history: &[Message]) -> Result<Vec<Message>, LLMError> {
584 self.inner.compact_history(model, history).await
585 }
586
587 async fn compact_history_with_options(
588 &self,
589 model: &str,
590 history: &[Message],
591 options: &ResponsesCompactionOptions,
592 ) -> Result<Vec<Message>, LLMError> {
593 self.inner.compact_history_with_options(model, history, options).await
594 }
595
596 async fn generate(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
597 self.inner.generate(request).await
598 }
599
600 async fn generate_auxiliary(&self, request: LLMRequest) -> Result<LLMResponse, LLMError> {
601 self.inner.generate_auxiliary(request).await
602 }
603
604 async fn stream(&self, request: LLMRequest) -> Result<LLMStream, LLMError> {
605 self.inner.stream(request).await
606 }
607
608 async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
609 self.inner.stream_normalized(request).await
610 }
611
612 #[cfg(feature = "copilot")]
613 fn start_copilot_prompt_session<'a>(
614 &'a self,
615 request: LLMRequest,
616 tools: &'a [super::ToolDefinition],
617 ) -> Option<crate::copilot::CopilotPromptSessionFuture<'a>> {
618 self.inner.start_copilot_prompt_session(request, tools)
619 }
620
621 fn supported_models(&self) -> Vec<String> {
622 self.inner.supported_models()
623 }
624
625 async fn get_balance(&self) -> Result<Option<vtcode_commons::llm::BalanceInfo>, LLMError> {
626 self.inner.get_balance().await
627 }
628
629 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
630 self.inner.validate_request(request)
631 }
632}