Skip to main content

vtcode_llm/providers/
opencode_go.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::{
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    /// Instance-stable fallback for `x-opencode-session` when the request
30    /// carries no session lineage. The Go endpoint rejects headerless
31    /// requests, so every dispatch must send a value.
32    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    /// Guarantee the request carries session lineage so both delegates can
120    /// attach `x-opencode-session`. Lineage from the runloop wins; otherwise
121    /// the provider's instance-stable id keeps the value constant across
122    /// turns of this process instead of failing with `MissingSessionID`.
123    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        // Pinned so the stream-timeout fallback cannot silently regress.
166        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        // Blank keys carry no identity and must fall back exactly like a missing key
467        // (the chat-path test covers `None`; this one covers whitespace).
468        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}