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}