Skip to main content

vtcode_core/llm/providers/
opencode_zen.rs

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