Skip to main content

vtcode_llm/providers/
opencode_zen.rs

1use crate::client::LLMClient;
2use crate::provider::{LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream};
3use async_trait::async_trait;
4use reqwest::Client as HttpClient;
5use vtcode_config::TimeoutsConfig;
6use vtcode_config::constants::{env_vars, models, urls};
7use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
8use vtcode_config::models::model_catalog_entry;
9
10use super::AnthropicProvider;
11use super::common::{override_base_url, resolve_model};
12use super::opencode_shared::{OpenCodeCompatibleProvider, OpenCodeZenInnerSpec, validate_normalized_request};
13
14const PROVIDER_NAME: &str = "OpenCode Zen";
15const PROVIDER_KEY: &str = "opencode-zen";
16
17enum ZenProtocol {
18    OpenAI,
19    Anthropic,
20    OpenAICompatible,
21}
22
23pub struct OpenCodeZenProvider {
24    api_key: String,
25    http_client: HttpClient,
26    base_url: String,
27    model: String,
28}
29
30impl OpenCodeZenProvider {
31    fn normalize_model(model: &str) -> &str {
32        model
33            .trim()
34            .strip_prefix("opencode/")
35            .or_else(|| model.trim().strip_prefix("opencode-zen/"))
36            .unwrap_or(model.trim())
37    }
38
39    pub fn new(api_key: String) -> Self {
40        Self::with_model_internal(api_key, models::opencode_zen::DEFAULT_MODEL.to_string(), None, None, None)
41    }
42
43    pub fn with_model(api_key: String, model: String) -> Self {
44        Self::with_model_internal(api_key, model, None, None, None)
45    }
46
47    pub fn new_with_client(
48        api_key: String,
49        model: String,
50        http_client: reqwest::Client,
51        base_url: String,
52        _timeouts: TimeoutsConfig,
53    ) -> Self {
54        Self {
55            api_key,
56            http_client,
57            base_url,
58            model: Self::normalize_model(&model).to_string(),
59        }
60    }
61
62    pub fn from_config(
63        api_key: Option<String>,
64        model: Option<String>,
65        base_url: Option<String>,
66        _prompt_cache: Option<PromptCachingConfig>,
67        timeouts: Option<TimeoutsConfig>,
68        _anthropic: Option<AnthropicConfig>,
69        _model_behavior: Option<ModelConfig>,
70    ) -> Self {
71        let api_key_value = api_key.unwrap_or_default();
72        let model_value = resolve_model(model, models::opencode_zen::DEFAULT_MODEL);
73
74        Self::with_model_internal(api_key_value, model_value, base_url, timeouts, _model_behavior)
75    }
76
77    fn with_model_internal(
78        api_key: String,
79        model: String,
80        base_url: Option<String>,
81        timeouts: Option<TimeoutsConfig>,
82        _model_behavior: Option<ModelConfig>,
83    ) -> Self {
84        use crate::http_client::HttpClientFactory;
85
86        let timeouts = timeouts.unwrap_or_default();
87
88        Self {
89            api_key,
90            http_client: HttpClientFactory::for_llm(&timeouts),
91            base_url: override_base_url(urls::OPENCODE_ZEN_API_BASE, base_url, Some(env_vars::OPENCODE_ZEN_BASE_URL)),
92            model: Self::normalize_model(&model).to_string(),
93        }
94    }
95
96    fn requested_model<'a>(&'a self, model: &'a str) -> &'a str {
97        if model.trim().is_empty() {
98            self.model.as_str()
99        } else {
100            Self::normalize_model(model)
101        }
102    }
103
104    fn catalog_entry(&self, model: &str) -> Option<vtcode_config::models::ModelCatalogEntry> {
105        model_catalog_entry(PROVIDER_KEY, self.requested_model(model))
106    }
107
108    fn protocol_for_model(model: &str) -> ZenProtocol {
109        if models::opencode_zen::OPENAI_MODELS.contains(&model) {
110            ZenProtocol::OpenAI
111        } else if models::opencode_zen::ANTHROPIC_MODELS.contains(&model) {
112            ZenProtocol::Anthropic
113        } else {
114            ZenProtocol::OpenAICompatible
115        }
116    }
117
118    fn delegate_for_model(&self, model: &str) -> Box<dyn LLMProvider> {
119        let requested = self.requested_model(model).to_string();
120        match Self::protocol_for_model(requested.as_str()) {
121            ZenProtocol::OpenAI => Box::new(crate::providers::OpenAIProvider::new_with_client(
122                self.api_key.clone(),
123                None,
124                requested,
125                self.http_client.clone(),
126                self.base_url.clone(),
127                TimeoutsConfig::default(),
128            )),
129            ZenProtocol::Anthropic => Box::new(AnthropicProvider::new_with_client(
130                self.api_key.clone(),
131                requested,
132                self.http_client.clone(),
133                self.base_url.clone(),
134                TimeoutsConfig::default(),
135            )),
136            ZenProtocol::OpenAICompatible => Box::new(OpenCodeCompatibleProvider::<OpenCodeZenInnerSpec>::new(
137                self.api_key.clone(),
138                self.http_client.clone(),
139                self.base_url.clone(),
140                requested,
141            )),
142        }
143    }
144}
145
146#[async_trait]
147impl LLMProvider for OpenCodeZenProvider {
148    fn name(&self) -> &str {
149        PROVIDER_KEY
150    }
151
152    fn supports_streaming(&self) -> bool {
153        true
154    }
155
156    fn supports_non_streaming(&self, model: &str) -> bool {
157        self.delegate_for_model(model)
158            .supports_non_streaming(self.requested_model(model))
159    }
160
161    fn supports_reasoning(&self, model: &str) -> bool {
162        self.catalog_entry(model)
163            .map(|entry| entry.reasoning)
164            .unwrap_or_else(|| self.delegate_for_model(model).supports_reasoning(self.requested_model(model)))
165    }
166
167    fn supports_reasoning_effort(&self, model: &str) -> bool {
168        match Self::protocol_for_model(self.requested_model(model)) {
169            ZenProtocol::OpenAI | ZenProtocol::Anthropic => self
170                .delegate_for_model(model)
171                .supports_reasoning_effort(self.requested_model(model)),
172            ZenProtocol::OpenAICompatible => false,
173        }
174    }
175
176    fn supports_tools(&self, model: &str) -> bool {
177        self.catalog_entry(model).map(|entry| entry.tool_call).unwrap_or(true)
178    }
179
180    fn supports_structured_output(&self, model: &str) -> bool {
181        self.catalog_entry(model).map(|entry| entry.structured_output).unwrap_or(false)
182    }
183
184    fn supports_context_caching(&self, model: &str) -> bool {
185        self.catalog_entry(model).map(|entry| entry.caching).unwrap_or(false)
186    }
187
188    fn supports_vision(&self, model: &str) -> bool {
189        self.catalog_entry(model).map(|entry| entry.vision).unwrap_or(false)
190    }
191
192    fn effective_context_size(&self, model: &str) -> usize {
193        self.catalog_entry(model)
194            .map(|entry| entry.context_window)
195            .filter(|value| *value > 0)
196            .unwrap_or(128_000)
197    }
198
199    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
200        if request.model.trim().is_empty() {
201            request.model = self.model.clone();
202        } else {
203            request.model = self.requested_model(&request.model).to_string();
204        }
205        self.validate_request(&request)?;
206        self.delegate_for_model(&request.model).generate(request).await
207    }
208
209    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
210        if request.model.trim().is_empty() {
211            request.model = self.model.clone();
212        } else {
213            request.model = self.requested_model(&request.model).to_string();
214        }
215        self.validate_request(&request)?;
216        self.delegate_for_model(&request.model).stream(request).await
217    }
218
219    fn supported_models(&self) -> Vec<String> {
220        models::opencode_zen::SUPPORTED_MODELS
221            .iter()
222            .map(|model| model.to_string())
223            .collect()
224    }
225
226    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
227        validate_normalized_request(
228            request,
229            self.requested_model(&request.model),
230            PROVIDER_NAME,
231            PROVIDER_KEY,
232            models::opencode_zen::SUPPORTED_MODELS,
233        )
234    }
235}
236
237#[async_trait]
238impl LLMClient for OpenCodeZenProvider {
239    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
240        let request = LLMRequest {
241            messages: std::sync::Arc::new(vec![crate::provider::Message::user(prompt.to_string())]),
242            model: self.model.clone(),
243            ..Default::default()
244        };
245        LLMProvider::generate(self, request).await
246    }
247
248    fn model_id(&self) -> &str {
249        &self.model
250    }
251}