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::{
13 OPENCODE_GO_SESSION_HEADER, OpenCodeCompatibleProvider, OpenCodeGoInnerSpec, validate_normalized_request,
14};
15
16const PROVIDER_NAME: &str = "OpenCode Go";
17const PROVIDER_KEY: &str = "opencode-go";
18
19enum GoProtocol {
20 MessagesApi,
21 OpenAICompatible,
22}
23
24pub struct OpenCodeGoProvider {
25 api_key: String,
26 http_client: HttpClient,
27 base_url: String,
28 model: String,
29 session_id: String,
33}
34
35impl OpenCodeGoProvider {
36 fn normalize_model(model: &str) -> &str {
37 model.trim().strip_prefix("opencode-go/").unwrap_or(model.trim())
38 }
39
40 pub fn new(api_key: String) -> Self {
41 Self::with_model_internal(api_key, models::opencode_go::DEFAULT_MODEL.to_string(), None, None, None)
42 }
43
44 pub fn with_model(api_key: String, model: String) -> Self {
45 Self::with_model_internal(api_key, model, None, None, None)
46 }
47
48 pub fn new_with_client(
49 api_key: String,
50 model: String,
51 http_client: reqwest::Client,
52 base_url: String,
53 _timeouts: TimeoutsConfig,
54 ) -> Self {
55 Self {
56 api_key,
57 http_client,
58 base_url,
59 model: Self::normalize_model(&model).to_string(),
60 session_id: uuid::Uuid::new_v4().to_string(),
61 }
62 }
63
64 pub fn from_config(
65 api_key: Option<String>,
66 model: Option<String>,
67 base_url: Option<String>,
68 _prompt_cache: Option<PromptCachingConfig>,
69 timeouts: Option<TimeoutsConfig>,
70 _anthropic: Option<AnthropicConfig>,
71 _model_behavior: Option<ModelConfig>,
72 ) -> Self {
73 let api_key_value = api_key.unwrap_or_default();
74 let model_value = resolve_model(model, models::opencode_go::DEFAULT_MODEL);
75
76 Self::with_model_internal(api_key_value, model_value, base_url, timeouts, _model_behavior)
77 }
78
79 fn with_model_internal(
80 api_key: String,
81 model: String,
82 base_url: Option<String>,
83 timeouts: Option<TimeoutsConfig>,
84 _model_behavior: Option<ModelConfig>,
85 ) -> Self {
86 use crate::http_client::HttpClientFactory;
87
88 let timeouts = timeouts.unwrap_or_default();
89
90 Self {
91 api_key,
92 http_client: HttpClientFactory::for_llm(&timeouts),
93 base_url: override_base_url(urls::OPENCODE_GO_API_BASE, base_url, Some(env_vars::OPENCODE_GO_BASE_URL)),
94 model: Self::normalize_model(&model).to_string(),
95 session_id: uuid::Uuid::new_v4().to_string(),
96 }
97 }
98
99 fn requested_model<'a>(&'a self, model: &'a str) -> &'a str {
100 if model.trim().is_empty() {
101 self.model.as_str()
102 } else {
103 Self::normalize_model(model)
104 }
105 }
106
107 fn catalog_entry(&self, model: &str) -> Option<vtcode_config::models::ModelCatalogEntry> {
108 model_catalog_entry(PROVIDER_KEY, self.requested_model(model))
109 }
110
111 fn protocol_for_model(model: &str) -> GoProtocol {
112 if models::opencode_go::MESSAGES_API_MODELS.contains(&model) {
113 GoProtocol::MessagesApi
114 } else {
115 GoProtocol::OpenAICompatible
116 }
117 }
118
119 fn ensure_session_key(&self, request: &mut LLMRequest) {
124 if crate::providers::shared::session_lineage_from_prompt_cache_key(request.prompt_cache_key.as_deref())
125 .is_none()
126 {
127 request.prompt_cache_key = Some(self.session_id.clone());
128 }
129 }
130
131 fn delegate_for_model(&self, model: &str) -> Box<dyn LLMProvider> {
132 let requested = self.requested_model(model).to_string();
133 match Self::protocol_for_model(requested.as_str()) {
134 GoProtocol::MessagesApi => Box::new(
135 AnthropicProvider::new_with_client(
136 self.api_key.clone(),
137 requested,
138 self.http_client.clone(),
139 self.base_url.clone(),
140 TimeoutsConfig::default(),
141 )
142 .with_session_affinity_header(OPENCODE_GO_SESSION_HEADER),
143 ),
144 GoProtocol::OpenAICompatible => Box::new(OpenCodeCompatibleProvider::<OpenCodeGoInnerSpec>::new(
145 self.api_key.clone(),
146 self.http_client.clone(),
147 self.base_url.clone(),
148 requested,
149 )),
150 }
151 }
152}
153
154#[async_trait]
155impl LLMProvider for OpenCodeGoProvider {
156 fn name(&self) -> &str {
157 PROVIDER_KEY
158 }
159
160 fn supports_streaming(&self) -> bool {
161 true
162 }
163
164 fn supports_non_streaming(&self, _model: &str) -> bool {
165 true
167 }
168
169 fn supports_reasoning(&self, model: &str) -> bool {
170 self.catalog_entry(model).map(|entry| entry.reasoning).unwrap_or(false)
171 }
172
173 fn supports_tools(&self, model: &str) -> bool {
174 self.catalog_entry(model).map(|entry| entry.tool_call).unwrap_or(true)
175 }
176
177 fn supports_structured_output(&self, model: &str) -> bool {
178 self.catalog_entry(model).map(|entry| entry.structured_output).unwrap_or(false)
179 }
180
181 fn supports_context_caching(&self, model: &str) -> bool {
182 self.catalog_entry(model).map(|entry| entry.caching).unwrap_or(false)
183 }
184
185 fn supports_vision(&self, model: &str) -> bool {
186 self.catalog_entry(model).map(|entry| entry.vision).unwrap_or(false)
187 }
188
189 fn effective_context_size(&self, model: &str) -> usize {
190 self.catalog_entry(model)
191 .map(|entry| entry.context_window)
192 .filter(|value| *value > 0)
193 .unwrap_or(128_000)
194 }
195
196 async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
197 if request.model.trim().is_empty() {
198 request.model = self.model.clone();
199 } else {
200 request.model = self.requested_model(&request.model).to_string();
201 }
202 self.ensure_session_key(&mut request);
203 self.validate_request(&request)?;
204 self.delegate_for_model(&request.model).generate(request).await
205 }
206
207 async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
208 if request.model.trim().is_empty() {
209 request.model = self.model.clone();
210 } else {
211 request.model = self.requested_model(&request.model).to_string();
212 }
213 self.ensure_session_key(&mut request);
214 self.validate_request(&request)?;
215 self.delegate_for_model(&request.model).stream(request).await
216 }
217
218 fn supported_models(&self) -> Vec<String> {
219 models::opencode_go::SUPPORTED_MODELS
220 .iter()
221 .map(|model| model.to_string())
222 .collect()
223 }
224
225 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
226 validate_normalized_request(
227 request,
228 self.requested_model(&request.model),
229 PROVIDER_NAME,
230 PROVIDER_KEY,
231 models::opencode_go::SUPPORTED_MODELS,
232 )
233 }
234}
235
236#[async_trait]
237impl LLMClient for OpenCodeGoProvider {
238 async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
239 let request = LLMRequest {
240 messages: std::sync::Arc::new(vec![crate::provider::Message::user(prompt.to_string())]),
241 model: self.model.clone(),
242 ..Default::default()
243 };
244 LLMProvider::generate(self, request).await
245 }
246
247 fn model_id(&self) -> &str {
248 &self.model
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::OpenCodeGoProvider;
255 use crate::provider::{LLMProvider, LLMRequest, Message};
256 use crate::providers::test_support::start_mock_server_or_skip;
257 use serde_json::json;
258 use vtcode_config::TimeoutsConfig;
259 use wiremock::matchers::{header, method, path};
260 use wiremock::{Mock, ResponseTemplate};
261
262 const SESSION_HEADER: &str = "x-opencode-session";
263
264 fn test_provider(base_url: &str, model: &str) -> OpenCodeGoProvider {
265 let http_client = reqwest::Client::builder().no_proxy().build().expect("test client should build");
266 OpenCodeGoProvider::new_with_client(
267 "test-key".to_string(),
268 model.to_string(),
269 http_client,
270 base_url.to_string(),
271 TimeoutsConfig::default(),
272 )
273 }
274
275 fn chat_request(model: &str, prompt_cache_key: Option<String>) -> LLMRequest {
276 LLMRequest {
277 model: model.to_string(),
278 messages: vec![Message::user("hi".to_string())].into(),
279 prompt_cache_key,
280 ..Default::default()
281 }
282 }
283
284 fn session_header_value(received: &wiremock::Request) -> String {
285 received
286 .headers
287 .get(SESSION_HEADER)
288 .and_then(|value| value.to_str().ok())
289 .unwrap_or_default()
290 .to_string()
291 }
292
293 #[test]
294 fn go_spec_advertises_session_affinity_header() {
295 use crate::providers::openai_compat::OpenAiCompatSpec;
296 use crate::providers::opencode_shared::OpenCodeGoInnerSpec;
297 assert_eq!(OpenCodeGoInnerSpec::session_affinity_header(), Some(SESSION_HEADER));
298 }
299
300 #[tokio::test]
301 async fn chat_completions_path_sends_lineage_session_header() {
302 let model = vtcode_config::constants::models::opencode_go::GLM_5_3;
303 let Some(server) = start_mock_server_or_skip().await else {
304 return;
305 };
306 let provider = test_provider(&server.uri(), model);
307
308 Mock::given(method("POST"))
309 .and(path("/chat/completions"))
310 .and(header(SESSION_HEADER, "session-lineage-1"))
311 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
312 "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
313 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
314 })))
315 .expect(1)
316 .mount(&server)
317 .await;
318
319 let response = provider
320 .generate(chat_request(model, Some("vtcode:opencode-go:session-lineage-1".to_string())))
321 .await
322 .expect("generate with session lineage");
323 assert_eq!(response.content.as_deref(), Some("ok"));
324 }
325
326 #[tokio::test]
327 async fn chat_completions_path_sends_fallback_session_header_without_lineage() {
328 let model = vtcode_config::constants::models::opencode_go::GLM_5_3;
329 let Some(server) = start_mock_server_or_skip().await else {
330 return;
331 };
332 let provider = test_provider(&server.uri(), model);
333
334 Mock::given(method("POST"))
335 .and(path("/chat/completions"))
336 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
337 "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
338 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
339 })))
340 .expect(1)
341 .mount(&server)
342 .await;
343
344 provider
345 .generate(chat_request(model, None))
346 .await
347 .expect("generate without lineage");
348
349 let requests = server.received_requests().await.expect("received requests");
350 assert_eq!(requests.len(), 1);
351 let session = session_header_value(&requests[0]);
352 assert!(!session.trim().is_empty(), "x-opencode-session must always be sent");
353 }
354
355 #[tokio::test]
356 async fn fallback_session_id_is_stable_across_requests() {
357 let model = vtcode_config::constants::models::opencode_go::GLM_5_3;
358 let Some(server) = start_mock_server_or_skip().await else {
359 return;
360 };
361 let provider = test_provider(&server.uri(), model);
362
363 Mock::given(method("POST"))
364 .and(path("/chat/completions"))
365 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
366 "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
367 "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
368 })))
369 .expect(2)
370 .mount(&server)
371 .await;
372
373 for _ in 0..2 {
374 provider
375 .generate(chat_request(model, None))
376 .await
377 .expect("generate without lineage");
378 }
379
380 let requests = server.received_requests().await.expect("received requests");
381 assert_eq!(requests.len(), 2);
382 let sessions: Vec<String> = requests.iter().map(session_header_value).collect();
383 assert!(!sessions[0].trim().is_empty(), "first request must carry a session id");
384 assert_eq!(sessions[0], sessions[1], "fallback session id must be stable per provider");
385 }
386
387 #[tokio::test]
388 async fn messages_api_path_sends_lineage_session_header() {
389 let model = vtcode_config::constants::models::opencode_go::MINIMAX_M3;
390 let Some(server) = start_mock_server_or_skip().await else {
391 return;
392 };
393 let provider = test_provider(&server.uri(), model);
394
395 Mock::given(method("POST"))
396 .and(path("/messages"))
397 .and(header(SESSION_HEADER, "session-lineage-2"))
398 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
399 "content": [{"type": "text", "text": "ok"}],
400 "stop_reason": "end_turn"
401 })))
402 .expect(1)
403 .mount(&server)
404 .await;
405
406 let response = provider
407 .generate(chat_request(model, Some("vtcode:opencode-go:session-lineage-2".to_string())))
408 .await
409 .expect("messages-api generate with session lineage");
410 assert_eq!(response.content.as_deref(), Some("ok"));
411 }
412
413 #[tokio::test]
414 async fn chat_completions_stream_sends_lineage_session_header() {
415 use futures::StreamExt;
416
417 use crate::provider::LLMStreamEvent;
418
419 let model = vtcode_config::constants::models::opencode_go::GLM_5_3;
420 let Some(server) = start_mock_server_or_skip().await else {
421 return;
422 };
423 let provider = test_provider(&server.uri(), model);
424
425 Mock::given(method("POST"))
426 .and(path("/chat/completions"))
427 .and(header(SESSION_HEADER, "session-lineage-7"))
428 .respond_with(ResponseTemplate::new(200).set_body_string(
429 "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n",
430 ))
431 .expect(1)
432 .mount(&server)
433 .await;
434
435 let mut stream = provider
436 .stream(chat_request(model, Some("vtcode:opencode-go:session-lineage-7".to_string())))
437 .await
438 .expect("stream with session lineage");
439 let mut completed = None;
440 while let Some(event) = stream.next().await {
441 if let LLMStreamEvent::Completed { response } = event.expect("stream event") {
442 completed = Some(response);
443 }
444 }
445 assert_eq!(completed.expect("completed response").content.as_deref(), Some("ok"));
446 }
447
448 #[tokio::test]
449 async fn messages_api_path_sends_fallback_session_header_without_lineage() {
450 let model = vtcode_config::constants::models::opencode_go::MINIMAX_M3;
451 let Some(server) = start_mock_server_or_skip().await else {
452 return;
453 };
454 let provider = test_provider(&server.uri(), model);
455
456 Mock::given(method("POST"))
457 .and(path("/messages"))
458 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
459 "content": [{"type": "text", "text": "ok"}],
460 "stop_reason": "end_turn"
461 })))
462 .expect(1)
463 .mount(&server)
464 .await;
465
466 provider
469 .generate(chat_request(model, Some(" ".to_string())))
470 .await
471 .expect("messages-api generate without lineage");
472
473 let requests = server.received_requests().await.expect("received requests");
474 assert_eq!(requests.len(), 1);
475 let session = session_header_value(&requests[0]);
476 assert!(!session.trim().is_empty(), "x-opencode-session must always be sent");
477 }
478}