1use 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
19pub const BASE_URL: &str = "https://opencode.ai/zen/go/v1";
21
22pub const API_KEY_ENV: &str = "OPENCODE_GO_KEY";
24
25pub const MODEL_PREFIX: &str = "opencode-go/";
27
28pub const ANTHROPIC_VERSION: &str = "2023-06-01";
30
31pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
32
33#[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
49pub type ModelsResponse = super::ModelsResponse;
51
52pub type ModelInfo = super::ModelInfo;
54
55pub struct OpenCodeGoClient {
57 http: ProviderHttpClient,
58}
59
60impl OpenCodeGoClient {
61 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 pub fn new(base_url: &str, api_key: &str) -> Self {
74 OpenCodeGoClient { http: ProviderHttpClient::new(base_url, api_key) }
75 }
76
77 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 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 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 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 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 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 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
280pub fn is_model_id(model: &str) -> bool {
282 model.strip_prefix(MODEL_PREFIX).is_some_and(|raw| !raw.is_empty())
283}
284
285pub 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
293pub 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
305pub 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
350pub fn error_message(err: &ProviderError) -> String {
352 err.failure_message("rate limit or usage limit exceeded")
353}
354
355pub 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}