Skip to main content

thndrs_lib/core/providers/opencode/
go.rs

1//! OpenCode Go provider.
2//!
3//! OpenCode Go exposes both OpenAI-compatible chat completions and
4//! Anthropic-compatible messages. The app-facing model id keeps the
5//! documented `opencode-go/` prefix; the request body uses the raw
6//! OpenCode Go model id.
7
8use std::path::Path;
9
10use crate::{
11    app::AgentEvent,
12    providers::{
13        self, KnownModel, ProviderError, ProviderHttpClient, ProviderMessage, Result, StreamFormat, StreamingProvider,
14        StreamingRequest,
15    },
16    thndrs_core::auth,
17};
18
19/// OpenCode Go base URL.
20pub const BASE_URL: &str = "https://opencode.ai/zen/go/v1";
21
22/// Environment variable name for the OpenCode Go API key.
23pub const API_KEY_ENV: &str = "OPENCODE_GO_KEY";
24
25/// User/config-facing model id prefix.
26pub const MODEL_PREFIX: &str = "opencode-go/";
27
28/// Required Anthropic version header value for `/messages`.
29pub const ANTHROPIC_VERSION: &str = "2023-06-01";
30
31pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
32
33/// Endpoint family used by a model.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum EndpointFamily {
36    OpenAiChat,
37    AnthropicMessages,
38}
39
40impl EndpointFamily {
41    pub fn label(self) -> &'static str {
42        match self {
43            EndpointFamily::OpenAiChat => "chat/completions",
44            EndpointFamily::AnthropicMessages => "messages",
45        }
46    }
47}
48
49/// OpenAI-compatible model list response from `GET /models`.
50pub type ModelsResponse = super::ModelsResponse;
51
52/// Model metadata currently returned by OpenCode Go.
53pub type ModelInfo = super::ModelInfo;
54
55/// Concrete OpenCode Go API client.
56pub struct OpenCodeGoClient {
57    http: ProviderHttpClient,
58}
59
60impl OpenCodeGoClient {
61    /// Create a client from `OPENCODE_GO_KEY`, falling back to workspace `.env`.
62    pub fn from_env_or_dotenv(workspace_root: &Path) -> Result<Self> {
63        if let Some((api_key, source)) = auth::resolve_credential(API_KEY_ENV, workspace_root) {
64            tracing::debug!(source = source.label(), "loaded OpenCode Go API key");
65            Ok(Self::new(BASE_URL, &api_key))
66        } else {
67            tracing::error!(env = API_KEY_ENV, cwd = %workspace_root.display(), "missing OpenCode Go API key");
68            Err(ProviderError::missing_api_key(API_KEY_ENV))
69        }
70    }
71
72    /// Create a client with an explicit base URL and API key.
73    pub fn new(base_url: &str, api_key: &str) -> Self {
74        OpenCodeGoClient { http: ProviderHttpClient::new(base_url, api_key) }
75    }
76
77    /// Fetch model metadata from `GET /models`.
78    pub fn fetch_models(&self) -> Result<Vec<ModelInfo>> {
79        let url = format!("{}/models", self.http.base_url());
80        let mut resp = self
81            .http
82            .agent()
83            .get(&url)
84            .header("Authorization", &format!("Bearer {}", self.http.api_key()))
85            .config()
86            .http_status_as_error(false)
87            .build()
88            .call()
89            .map_err(|e| ProviderError::Http(e.to_string()))?;
90
91        let status = resp.status().as_u16();
92        let body = resp
93            .body_mut()
94            .read_to_string()
95            .map_err(|e| ProviderError::Http(e.to_string()))?;
96        if !(200..=299).contains(&status) {
97            return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
98        }
99
100        serde_json::from_str::<ModelsResponse>(&body)
101            .map(|response| response.data)
102            .map_err(|e| ProviderError::Json(e.to_string()))
103    }
104
105    /// Build the headers for an OpenAI-compatible chat-completions request.
106    pub fn build_chat_headers(&self) -> Vec<(String, String)> {
107        vec![
108            ("Authorization".to_string(), format!("Bearer {}", self.http.api_key())),
109            ("Content-Type".to_string(), "application/json".to_string()),
110        ]
111    }
112
113    /// Build the headers for an Anthropic-compatible messages request.
114    pub fn build_messages_headers(&self) -> Vec<(String, String)> {
115        vec![
116            ("Authorization".to_string(), format!("Bearer {}", self.http.api_key())),
117            ("x-api-key".to_string(), self.http.api_key().to_string()),
118            ("anthropic-version".to_string(), ANTHROPIC_VERSION.to_string()),
119            ("Content-Type".to_string(), "application/json".to_string()),
120        ]
121    }
122
123    /// Build a request body for `POST /chat/completions`.
124    pub fn build_chat_request_body(
125        model: &str, messages: &[ProviderMessage], max_tokens: u32, stream: bool, tools: Option<&serde_json::Value>,
126    ) -> Result<serde_json::Value> {
127        let raw_model = raw_model_id(model)?;
128        Ok(providers::openai::build_chat_request_body(
129            raw_model, messages, max_tokens, stream, tools,
130        ))
131    }
132
133    /// Build a request body for `POST /messages`.
134    pub fn build_messages_request_body(
135        model: &str, messages: &[ProviderMessage], max_tokens: u32, stream: bool, tools: Option<&serde_json::Value>,
136    ) -> Result<serde_json::Value> {
137        let raw_model = raw_model_id(model)?;
138        Ok(providers::anthropic::build_messages_request_body(
139            raw_model, messages, max_tokens, stream, tools,
140        ))
141    }
142
143    /// Send a streaming request to the route selected by `model`.
144    pub fn send_streaming_request(
145        &self, model: &str, messages: &[ProviderMessage], max_tokens: u32, tools: Option<&serde_json::Value>,
146    ) -> Result<ureq::http::Response<ureq::Body>> {
147        let raw_model = raw_model_id(model)?;
148        let family = endpoint_family(raw_model);
149        let (url, body, headers) = match family {
150            EndpointFamily::OpenAiChat => (
151                format!("{}/chat/completions", self.http.base_url()),
152                Self::build_chat_request_body(model, messages, max_tokens, true, tools)?,
153                self.build_chat_headers(),
154            ),
155            EndpointFamily::AnthropicMessages => (
156                format!("{}/messages", self.http.base_url()),
157                Self::build_messages_request_body(model, messages, max_tokens, true, tools)?,
158                self.build_messages_headers(),
159            ),
160        };
161
162        let mut request = self.http.agent().post(&url);
163        for (key, value) in headers {
164            request = request.header(&key, &value);
165        }
166
167        let mut response = request
168            .config()
169            .http_status_as_error(false)
170            .build()
171            .send_json(&body)
172            .map_err(|e| ProviderError::Http(e.to_string()))?;
173
174        let status = response.status().as_u16();
175        if !(200..=299).contains(&status) {
176            let body = response
177                .body_mut()
178                .read_to_string()
179                .unwrap_or_else(|e| format!("failed to read error body: {e}"));
180            return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
181        }
182
183        Ok(response)
184    }
185}
186
187impl StreamingProvider for OpenCodeGoClient {
188    type Metadata = Vec<ModelInfo>;
189
190    fn name(&self) -> &'static str {
191        "OpenCode Go"
192    }
193
194    fn load_status(&self) -> String {
195        String::from("provider: loading OPENCODE_GO_KEY")
196    }
197
198    fn request_status(&self, model: &str) -> String {
199        let route = raw_model_id(model)
200            .map(endpoint_family)
201            .map(|family| family.label())
202            .unwrap_or("unknown");
203        format!("provider: POST /zen/go/v1/{route} model={model}")
204    }
205
206    fn from_env_or_dotenv(root: &Path) -> Result<Self> {
207        OpenCodeGoClient::from_env_or_dotenv(root)
208    }
209
210    fn load_metadata(&self) -> Result<Self::Metadata> {
211        self.fetch_models()
212    }
213
214    fn metadata_loaded_event(&self, metadata: &Self::Metadata) -> Option<AgentEvent> {
215        let mut items: Vec<(String, String)> = providers::umans::known_models()
216            .into_iter()
217            .map(|model| (model.id.to_string(), model.description.to_string()))
218            .collect();
219        items.extend(
220            providers::opencode::zen::known_models()
221                .into_iter()
222                .map(|model| (model.id.to_string(), model.description.to_string())),
223        );
224        items.extend(model_picker_items(metadata));
225        items.extend(
226            providers::chatgpt_codex::known_models()
227                .into_iter()
228                .map(|model| (model.id.to_string(), model.description.to_string())),
229        );
230        Some(AgentEvent::ModelMetadataLoaded(items))
231    }
232
233    fn metadata_status(&self, model: &str, metadata: &Self::Metadata) -> Option<String> {
234        model_status(model, metadata)
235    }
236
237    fn token_budget(&self, model: &str, metadata: Option<&Self::Metadata>) -> u32 {
238        recommended_max_tokens_for_model(model, metadata)
239    }
240
241    fn serialized_request_body(
242        &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
243    ) -> Result<Vec<u8>> {
244        let body = match endpoint_family(raw_model_id(model)?) {
245            EndpointFamily::OpenAiChat => {
246                Self::build_chat_request_body(model, messages, request.max_tokens, true, Some(request.tools))?
247            }
248            EndpointFamily::AnthropicMessages => {
249                Self::build_messages_request_body(model, messages, request.max_tokens, true, Some(request.tools))?
250            }
251        };
252        providers::serialize_request_body(&body)
253    }
254
255    /// TODO: Map reasoning effort and summaries when the OpenCode Go backend
256    /// exposes model-specific controls for them.
257    fn send_streaming_request(
258        &self, model: &str, messages: &[ProviderMessage], request: &crate::providers::StreamingRequest<'_>,
259    ) -> Result<ureq::http::Response<ureq::Body>> {
260        OpenCodeGoClient::send_streaming_request(self, model, messages, request.max_tokens, Some(request.tools))
261    }
262
263    fn stream_format(&self, model: &str) -> Result<StreamFormat> {
264        let raw_model = raw_model_id(model)?;
265        Ok(match endpoint_family(raw_model) {
266            EndpointFamily::OpenAiChat => StreamFormat::OpenAiChat,
267            EndpointFamily::AnthropicMessages => StreamFormat::AnthropicMessages,
268        })
269    }
270
271    fn request_error_message(error: &ProviderError) -> String {
272        error_message(error)
273    }
274
275    fn is_retryable_request_error(error: &ProviderError) -> bool {
276        is_retryable_error(error)
277    }
278}
279
280/// Whether `model` is an OpenCode Go model id.
281pub fn is_model_id(model: &str) -> bool {
282    model.strip_prefix(MODEL_PREFIX).is_some_and(|raw| !raw.is_empty())
283}
284
285/// Strip `opencode-go/` from a model id.
286pub fn raw_model_id(model: &str) -> Result<&str> {
287    model
288        .strip_prefix(MODEL_PREFIX)
289        .filter(|raw| !raw.is_empty())
290        .ok_or_else(|| ProviderError::invalid_model_id("OpenCode Go", MODEL_PREFIX, model))
291}
292
293/// Resolve the endpoint family for a raw OpenCode Go model id.
294///
295/// The public `/models` response currently exposes ids only, so route family is
296/// derived from the documented families plus observed id prefixes.
297pub fn endpoint_family(raw_model: &str) -> EndpointFamily {
298    if raw_model.starts_with("minimax-") || raw_model.starts_with("qwen") {
299        EndpointFamily::AnthropicMessages
300    } else {
301        EndpointFamily::OpenAiChat
302    }
303}
304
305/// Current OpenCode Go models from the public docs and live model list.
306pub fn known_models() -> Vec<KnownModel> {
307    vec![
308        KnownModel { id: "opencode-go/kimi-k2.7-code", description: "OpenCode Go Kimi K2.7 Code, tools and reasoning" },
309        KnownModel { id: "opencode-go/deepseek-v4-flash", description: "OpenCode Go fast low-cost chat route" },
310        KnownModel { id: "opencode-go/glm-5.2", description: "OpenCode Go GLM route" },
311        KnownModel { id: "opencode-go/minimax-m3", description: "OpenCode Go Anthropic-compatible route" },
312        KnownModel { id: "opencode-go/qwen3.7-plus", description: "OpenCode Go Qwen Anthropic-compatible route" },
313    ]
314}
315
316pub fn model_picker_items(models: &[ModelInfo]) -> Vec<(String, String)> {
317    let mut items: Vec<(String, String)> = models
318        .iter()
319        .map(|info| {
320            let family = endpoint_family(&info.id);
321            (
322                format!("{MODEL_PREFIX}{}", info.id),
323                format!("OpenCode Go · {}", family.label()),
324            )
325        })
326        .collect();
327    items.sort_by(|left, right| left.0.cmp(&right.0));
328    items
329}
330
331pub fn model_status(model: &str, models: &[ModelInfo]) -> Option<String> {
332    let raw = raw_model_id(model).ok()?;
333    models.iter().find(|info| info.id == raw).map(|info| {
334        format!(
335            "model: opencode-go/{}  OpenCode Go · {}",
336            info.id,
337            endpoint_family(&info.id).label()
338        )
339    })
340}
341
342pub fn recommended_max_tokens_for_model(_model: &str, _models: Option<&Vec<ModelInfo>>) -> u32 {
343    DEFAULT_RECOMMENDED_MAX_TOKENS
344}
345
346pub fn is_retryable_error(err: &ProviderError) -> bool {
347    err.is_retryable()
348}
349
350/// Convert an OpenCode Go error into a human-readable failure string.
351pub fn error_message(err: &ProviderError) -> String {
352    err.failure_message("rate limit or usage limit exceeded")
353}
354
355/// Try to validate an OpenCode Go API key with a lightweight model-list request.
356pub fn validate_api_key(api_key: &str) -> std::result::Result<(), String> {
357    probe_api_key(api_key).map_err(|error| format!("validation failed: {error}"))
358}
359
360pub fn probe_api_key(api_key: &str) -> Result<()> {
361    validate_api_key_at(BASE_URL, api_key)
362}
363
364fn validate_api_key_at(base_url: &str, api_key: &str) -> Result<()> {
365    OpenCodeGoClient::new(base_url, api_key).fetch_models().map(|_| ())
366}
367
368#[cfg(test)]
369mod tests {
370    use std::collections::HashMap;
371    use std::env;
372    use std::io::{Read, Write};
373    use std::net::TcpListener;
374    use std::thread;
375
376    use super::*;
377    use crate::providers::openai::{ChatSseEvent, parse_chat_sse_event};
378    use crate::providers::{ProviderContentBlock, ProviderMessage};
379
380    fn mock_models_server(body: &'static str) -> String {
381        mock_models_response_server("200 OK", body)
382    }
383
384    fn mock_models_response_server(status: &'static str, body: &'static str) -> String {
385        let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
386        let addr = listener.local_addr().expect("local addr");
387        thread::spawn(move || {
388            let (mut stream, _) = listener.accept().expect("accept request");
389            let mut request = [0_u8; 1024];
390            let _ = stream.read(&mut request);
391            let response = format!(
392                "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
393                body.len(),
394                body
395            );
396            stream.write_all(response.as_bytes()).expect("write response");
397        });
398        format!("http://{addr}")
399    }
400
401    #[test]
402    fn raw_model_id_requires_prefix() {
403        assert_eq!(raw_model_id("opencode-go/kimi-k2.7-code").unwrap(), "kimi-k2.7-code");
404        assert!(matches!(
405            raw_model_id("kimi-k2.7-code"),
406            Err(ProviderError::InvalidModelId { .. })
407        ));
408    }
409
410    #[test]
411    fn endpoint_family_uses_documented_prefixes() {
412        assert_eq!(endpoint_family("kimi-k2.7-code"), EndpointFamily::OpenAiChat);
413        assert_eq!(endpoint_family("deepseek-v4-flash"), EndpointFamily::OpenAiChat);
414        assert_eq!(endpoint_family("minimax-m3"), EndpointFamily::AnthropicMessages);
415        assert_eq!(endpoint_family("qwen3.7-plus"), EndpointFamily::AnthropicMessages);
416    }
417
418    #[test]
419    fn build_chat_request_body_converts_tools() {
420        let messages = vec![ProviderMessage::user("find files")];
421        let defs = crate::tools::tool_definitions();
422        let catalog = crate::tools::tool_catalog_schemas(&defs);
423        let body = OpenCodeGoClient::build_chat_request_body(
424            "opencode-go/kimi-k2.7-code",
425            &messages,
426            4096,
427            true,
428            Some(&catalog),
429        )
430        .expect("body");
431
432        assert_eq!(body["model"], "kimi-k2.7-code");
433        assert_eq!(body["messages"][0]["role"], "user");
434        assert_eq!(body["tools"][0]["type"], "function");
435        assert_eq!(body["tools"][0]["function"]["name"], defs[0].name.as_ref());
436        assert_eq!(body["tools"][0]["function"]["parameters"], defs[0].input_schema);
437        assert_eq!(body["stream"], true);
438    }
439
440    #[test]
441    fn build_chat_request_body_converts_tool_history() {
442        let messages = vec![
443            ProviderMessage::assistant_blocks(vec![
444                ProviderContentBlock::Text { text: "Looking.".to_string() },
445                ProviderContentBlock::ToolUse {
446                    id: "call_1".to_string(),
447                    name: "find_files".to_string(),
448                    input: serde_json::json!({"pattern":"Cargo"}),
449                },
450            ]),
451            ProviderMessage::tool_result("call_1", "Cargo.toml", false),
452        ];
453
454        let body = OpenCodeGoClient::build_chat_request_body("opencode-go/kimi-k2.7-code", &messages, 4096, true, None)
455            .expect("body");
456
457        assert_eq!(body["messages"][0]["role"], "assistant");
458        assert_eq!(body["messages"][0]["tool_calls"][0]["id"], "call_1");
459        assert_eq!(body["messages"][1]["role"], "tool");
460        assert_eq!(body["messages"][1]["tool_call_id"], "call_1");
461    }
462
463    #[test]
464    fn build_chat_request_body_converts_user_image_blocks() {
465        let messages = vec![ProviderMessage::user_blocks(vec![
466            ProviderContentBlock::Text { text: "describe this".to_string() },
467            ProviderContentBlock::Image {
468                source: crate::providers::ProviderImageSource::Base64 {
469                    media_type: "image/png".to_string(),
470                    data: "aGVsbG8=".to_string(),
471                },
472            },
473        ])];
474
475        let body = OpenCodeGoClient::build_chat_request_body("opencode-go/kimi-k2.7-code", &messages, 4096, true, None)
476            .expect("body");
477
478        let content = &body["messages"][0]["content"];
479        assert_eq!(content[0]["type"], "text");
480        assert_eq!(content[0]["text"], "describe this");
481        assert_eq!(content[1]["type"], "image_url");
482        assert_eq!(content[1]["image_url"]["url"], "data:image/png;base64,aGVsbG8=");
483    }
484
485    #[test]
486    fn build_messages_request_body_uses_anthropic_shape() {
487        let messages = vec![ProviderMessage::user("hello")];
488        let defs = crate::tools::tool_definitions();
489        let catalog = crate::tools::tool_catalog_schemas(&defs);
490        let body = OpenCodeGoClient::build_messages_request_body(
491            "opencode-go/minimax-m3",
492            &messages,
493            1024,
494            true,
495            Some(&catalog),
496        )
497        .expect("body");
498
499        assert_eq!(body["model"], "minimax-m3");
500        assert_eq!(body["messages"][0]["content"], "hello");
501        assert_eq!(body["max_tokens"], 1024);
502        assert_eq!(body["tools"][0]["name"], defs[0].name.as_ref());
503    }
504
505    #[test]
506    fn build_headers_include_expected_auth() {
507        let client = OpenCodeGoClient::new(BASE_URL, "go-test-key");
508        let chat: HashMap<String, String> = client.build_chat_headers().into_iter().collect();
509        assert_eq!(chat.get("Authorization").unwrap(), "Bearer go-test-key");
510
511        let messages: HashMap<String, String> = client.build_messages_headers().into_iter().collect();
512        assert_eq!(messages.get("Authorization").unwrap(), "Bearer go-test-key");
513        assert_eq!(messages.get("x-api-key").unwrap(), "go-test-key");
514        assert_eq!(messages.get("anthropic-version").unwrap(), ANTHROPIC_VERSION);
515    }
516
517    #[test]
518    fn from_env_or_dotenv_missing_key_returns_error() {
519        let _guard = crate::test_env::lock();
520        unsafe {
521            env::remove_var(API_KEY_ENV);
522        }
523        let dir = tempfile::tempdir().unwrap();
524        let result = OpenCodeGoClient::from_env_or_dotenv(dir.path());
525        assert!(matches!(result, Err(ProviderError::MissingApiKey { env }) if env == API_KEY_ENV));
526    }
527
528    #[test]
529    fn from_env_or_dotenv_reads_workspace_env_file() {
530        let _guard = crate::test_env::lock();
531        unsafe {
532            env::remove_var(API_KEY_ENV);
533        }
534        let dir = tempfile::tempdir().unwrap();
535        std::fs::write(dir.path().join(".env"), "OPENCODE_GO_KEY=go-dotenv-key\n").unwrap();
536
537        let client = OpenCodeGoClient::from_env_or_dotenv(dir.path()).unwrap();
538        let headers: HashMap<String, String> = client.build_chat_headers().into_iter().collect();
539
540        assert_eq!(headers.get("Authorization").unwrap(), "Bearer go-dotenv-key");
541    }
542
543    #[test]
544    fn from_env_or_dotenv_reads_exported_quoted_env_file_value() {
545        let _guard = crate::test_env::lock();
546        unsafe {
547            env::remove_var(API_KEY_ENV);
548        }
549        let dir = tempfile::tempdir().unwrap();
550        std::fs::write(
551            dir.path().join(".env"),
552            "export OPENCODE_GO_KEY=\"go-quoted-dotenv-key\"\n",
553        )
554        .unwrap();
555
556        let client = OpenCodeGoClient::from_env_or_dotenv(dir.path()).unwrap();
557        let headers: HashMap<String, String> = client.build_chat_headers().into_iter().collect();
558
559        assert_eq!(headers.get("Authorization").unwrap(), "Bearer go-quoted-dotenv-key");
560    }
561
562    #[test]
563    fn from_env_or_dotenv_reads_global_credential_store() {
564        let _guard = crate::test_env::lock();
565        unsafe {
566            env::remove_var(API_KEY_ENV);
567        }
568        let dir = tempfile::tempdir().unwrap();
569        let home = dir.path().join("home");
570        let workspace = dir.path().join("workspace");
571        std::fs::create_dir_all(home.join(".thndrs")).unwrap();
572        std::fs::create_dir_all(&workspace).unwrap();
573        std::fs::write(
574            home.join(".thndrs").join("credentials.env"),
575            "OPENCODE_GO_KEY=go-global-key\n",
576        )
577        .unwrap();
578
579        let old_home = env::var_os("HOME");
580        unsafe { env::set_var("HOME", &home) };
581        let client = OpenCodeGoClient::from_env_or_dotenv(&workspace).unwrap();
582        unsafe {
583            if let Some(home) = old_home {
584                env::set_var("HOME", home);
585            } else {
586                env::remove_var("HOME");
587            }
588        }
589
590        let headers: HashMap<String, String> = client.build_chat_headers().into_iter().collect();
591        assert_eq!(headers.get("Authorization").unwrap(), "Bearer go-global-key");
592    }
593
594    #[test]
595    fn from_env_or_dotenv_reads_project_credential_store() {
596        let _guard = crate::test_env::lock();
597        unsafe {
598            env::remove_var(API_KEY_ENV);
599        }
600        let dir = tempfile::tempdir().unwrap();
601        let home = dir.path().join("home");
602        let workspace = dir.path().join("workspace");
603        std::fs::create_dir_all(&home).unwrap();
604        std::fs::create_dir_all(workspace.join(".thndrs")).unwrap();
605        std::fs::write(
606            workspace.join(".thndrs").join("credentials.env"),
607            "OPENCODE_GO_KEY=go-project-key\n",
608        )
609        .unwrap();
610
611        let old_home = env::var_os("HOME");
612        unsafe { env::set_var("HOME", &home) };
613        let client = OpenCodeGoClient::from_env_or_dotenv(&workspace).unwrap();
614        unsafe {
615            if let Some(home) = old_home {
616                env::set_var("HOME", home);
617            } else {
618                env::remove_var("HOME");
619            }
620        }
621
622        let headers: HashMap<String, String> = client.build_chat_headers().into_iter().collect();
623        assert_eq!(headers.get("Authorization").unwrap(), "Bearer go-project-key");
624    }
625
626    #[test]
627    fn missing_key_error_includes_setup_hint() {
628        let _guard = crate::test_env::lock();
629        unsafe {
630            env::remove_var(API_KEY_ENV);
631        }
632        let dir = tempfile::tempdir().unwrap();
633        let result = match OpenCodeGoClient::from_env_or_dotenv(dir.path()) {
634            Ok(_) => panic!("missing key should fail"),
635            Err(err) => err,
636        };
637        let message = result.to_string();
638        assert!(message.contains("thndrs setup"));
639        assert!(message.contains("thndrs login"));
640        assert!(!message.contains("go-"));
641    }
642
643    #[test]
644    fn validation_does_not_persist_provider_payloads() {
645        let _guard = crate::test_env::lock();
646        let dir = tempfile::tempdir().unwrap();
647        let home = dir.path().join("home");
648        let workspace = dir.path().join("workspace");
649        std::fs::create_dir_all(&home).unwrap();
650        std::fs::create_dir_all(&workspace).unwrap();
651
652        let base_url = mock_models_server(
653            r#"{"object":"list","data":[{"id":"kimi-k2.7-code","object":"model","created":1,"owned_by":"opencode"}]}"#,
654        );
655        let old_home = env::var_os("HOME");
656        unsafe { env::set_var("HOME", &home) };
657        validate_api_key_at(&base_url, "go-validation").expect("validation succeeds");
658        unsafe {
659            if let Some(home) = old_home {
660                env::set_var("HOME", home);
661            } else {
662                env::remove_var("HOME");
663            }
664        }
665
666        assert!(!home.join(".thndrs").join("credentials.env").exists());
667        assert!(!workspace.join(".thndrs").join("credentials.env").exists());
668    }
669
670    #[test]
671    fn validation_preserves_rejected_credential_status() {
672        let base_url = mock_models_response_server("401 Unauthorized", r#"{"error":"invalid key"}"#);
673
674        let error = validate_api_key_at(&base_url, "rejected-key").expect_err("credential should be rejected");
675
676        assert!(matches!(error, ProviderError::Status { code: 401, .. }));
677    }
678
679    #[test]
680    fn parse_models_response() {
681        let json = r#"{"object":"list","data":[{"id":"kimi-k2.7-code","object":"model","created":1782964872,"owned_by":"opencode"}]}"#;
682        let response: ModelsResponse = serde_json::from_str(json).expect("parse");
683        assert_eq!(response.data[0].id, "kimi-k2.7-code");
684    }
685
686    #[test]
687    fn model_picker_items_prefix_live_ids() {
688        let models = vec![ModelInfo {
689            id: "kimi-k2.7-code".to_string(),
690            object: "model".to_string(),
691            created: 1,
692            owned_by: "opencode".to_string(),
693        }];
694        let items = model_picker_items(&models);
695        assert_eq!(items[0].0, "opencode-go/kimi-k2.7-code");
696        assert!(items[0].1.contains("OpenCode Go"));
697    }
698
699    #[test]
700    fn metadata_loaded_event_keeps_opencode_zen_known_models() {
701        let client = OpenCodeGoClient::new(BASE_URL, "go-test-key");
702        let metadata = vec![ModelInfo {
703            id: "kimi-k2.7-code".to_string(),
704            object: "model".to_string(),
705            created: 1,
706            owned_by: "opencode".to_string(),
707        }];
708        let Some(AgentEvent::ModelMetadataLoaded(items)) = client.metadata_loaded_event(&metadata) else {
709            panic!("expected model metadata event");
710        };
711
712        assert!(items.iter().any(|(id, _)| id == "opencode/big-pickle"));
713        assert!(items.iter().any(|(id, _)| id == "opencode-go/kimi-k2.7-code"));
714    }
715
716    #[test]
717    fn parse_chat_sse_text_reasoning_usage_and_done() {
718        let data = r#"{"choices":[{"finish_reason":null,"delta":{"content":"ok","reasoning_content":"think"}}],"usage":{"prompt_tokens":2,"completion_tokens":3}}"#;
719        let events = parse_chat_sse_event(data);
720        assert!(events.contains(&ChatSseEvent::TextDelta("ok".to_string())));
721        assert!(events.contains(&ChatSseEvent::ReasoningDelta("think".to_string())));
722        assert!(events.contains(&ChatSseEvent::Usage { input_tokens: 2, output_tokens: 3 }));
723        assert_eq!(parse_chat_sse_event("[DONE]"), vec![ChatSseEvent::Done]);
724    }
725
726    #[test]
727    fn parse_chat_sse_tool_call_deltas() {
728        let start = r#"{"choices":[{"finish_reason":null,"delta":{"tool_calls":[{"index":0,"id":"lookup_0","type":"function","function":{"name":"lookup","arguments":""}}]}}]}"#;
729        let args = r#"{"choices":[{"finish_reason":null,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":\"abc\"}"}}]}}]}"#;
730        assert_eq!(
731            parse_chat_sse_event(start),
732            vec![ChatSseEvent::ToolCallStart { index: 0, id: "lookup_0".to_string(), name: "lookup".to_string() }]
733        );
734        assert_eq!(
735            parse_chat_sse_event(args),
736            vec![ChatSseEvent::ToolCallArgumentsDelta { index: 0, arguments: "{\"query\":\"abc\"}".to_string() }]
737        );
738    }
739
740    #[test]
741    fn retryable_error_classification_matches_policy() {
742        assert!(!is_retryable_error(&ProviderError::missing_api_key(API_KEY_ENV)));
743        assert!(!is_retryable_error(&ProviderError::Status {
744            code: 401,
745            body: "unauthorized".into()
746        }));
747        assert!(is_retryable_error(&ProviderError::Status {
748            code: 429,
749            body: "limit".into()
750        }));
751        assert!(is_retryable_error(&ProviderError::Status {
752            code: 503,
753            body: "unavailable".into()
754        }));
755    }
756
757    #[test]
758    #[ignore = "requires OPENCODE_GO_KEY and network access"]
759    fn live_models() {
760        let workspace_root = env::current_dir().expect("current dir");
761        let client = OpenCodeGoClient::from_env_or_dotenv(&workspace_root).expect("OPENCODE_GO_KEY must be set");
762        let models = client.fetch_models().expect("fetch models");
763        assert!(models.iter().any(|model| model.id == "kimi-k2.7-code"));
764    }
765
766    #[test]
767    #[ignore = "requires OPENCODE_GO_KEY and network access"]
768    fn live_chat_stream() {
769        let workspace_root = env::current_dir().expect("current dir");
770        let client = OpenCodeGoClient::from_env_or_dotenv(&workspace_root).expect("OPENCODE_GO_KEY must be set");
771        let messages = vec![ProviderMessage::user("Reply with exactly: ok")];
772        let mut response = client
773            .send_streaming_request("opencode-go/deepseek-v4-flash", &messages, 32, None)
774            .expect("streaming chat request");
775        let body = response.body_mut().read_to_string().expect("read body");
776        assert!(body.contains("data: "));
777        assert!(body.contains("[DONE]"));
778    }
779
780    #[test]
781    #[ignore = "requires OPENCODE_GO_KEY and network access"]
782    fn live_messages_stream() {
783        let workspace_root = env::current_dir().expect("current dir");
784        let client = OpenCodeGoClient::from_env_or_dotenv(&workspace_root).expect("OPENCODE_GO_KEY must be set");
785        let messages = vec![ProviderMessage::user("Reply with exactly: ok")];
786        let mut response = client
787            .send_streaming_request("opencode-go/minimax-m3", &messages, 16, None)
788            .expect("streaming messages request");
789        let body = response.body_mut().read_to_string().expect("read body");
790        assert!(body.contains("event: message_start"));
791        assert!(body.contains("event: message_stop"));
792    }
793}