vtcode_core/llm/providers/
opencode_zen.rs1use 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}