1use std::path::Path;
8
9use crate::{
10 app::AgentEvent,
11 cli::{ReasoningEffort, ReasoningSummary},
12 providers::{
13 self, KnownModel, ProviderContinuation, ProviderError, ProviderHttpClient, ProviderMessage, Result,
14 StreamFormat, StreamingProvider, StreamingRequest,
15 },
16 thndrs_core::auth,
17};
18
19pub const BASE_URL: &str = "https://opencode.ai/zen/v1";
21
22pub const CHAT_COMPLETIONS_URL: &str = "https://opencode.ai/zen/v1/chat/completions";
24
25pub const MODELS_URL: &str = "https://opencode.ai/zen/v1/models";
27
28pub const API_KEY_ENV: &str = "OPENCODE_ZEN_KEY";
30
31pub const MODEL_PREFIX: &str = "opencode/";
33
34pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum EndpointFamily {
39 Responses,
40 AnthropicMessages,
41 OpenAiChat,
42}
43
44pub type ModelsResponse = super::ModelsResponse;
46
47pub type ModelInfo = super::ModelInfo;
49
50pub struct OpenCodeZenClient {
52 http: ProviderHttpClient,
53}
54
55impl OpenCodeZenClient {
56 pub fn from_env_or_dotenv(workspace_root: &Path) -> Result<Self> {
59 if let Some((api_key, source)) = auth::resolve_credential(API_KEY_ENV, workspace_root) {
60 tracing::debug!(source = source.label(), "loaded OpenCode Zen API key");
61 Ok(Self::new(BASE_URL, &api_key))
62 } else {
63 tracing::error!(env = API_KEY_ENV, cwd = %workspace_root.display(), "missing OpenCode Zen API key");
64 Err(ProviderError::missing_api_key(API_KEY_ENV))
65 }
66 }
67
68 pub fn new(base_url: &str, api_key: &str) -> Self {
70 OpenCodeZenClient { http: ProviderHttpClient::new(base_url, api_key) }
71 }
72
73 pub fn fetch_models(&self) -> Result<Vec<ModelInfo>> {
75 let url = format!("{}/models", self.http.base_url());
76 let mut resp = self
77 .http
78 .agent()
79 .get(&url)
80 .header("Authorization", &format!("Bearer {}", self.http.api_key()))
81 .config()
82 .http_status_as_error(false)
83 .build()
84 .call()
85 .map_err(|e| ProviderError::Http(e.to_string()))?;
86
87 let status = resp.status().as_u16();
88 let body = resp
89 .body_mut()
90 .read_to_string()
91 .map_err(|e| ProviderError::Http(e.to_string()))?;
92 if !(200..=299).contains(&status) {
93 return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
94 }
95
96 serde_json::from_str::<ModelsResponse>(&body)
97 .map(|response| response.data)
98 .map_err(|e| ProviderError::Json(e.to_string()))
99 }
100
101 pub fn build_chat_headers(&self) -> Vec<(String, String)> {
103 vec![
104 ("Authorization".to_string(), format!("Bearer {}", self.http.api_key())),
105 ("Content-Type".to_string(), "application/json".to_string()),
106 ]
107 }
108
109 pub fn build_messages_headers(&self) -> Vec<(String, String)> {
111 let mut headers = self.build_chat_headers();
112 headers.push(("anthropic-version".to_string(), "2023-06-01".to_string()));
113 headers
114 }
115
116 pub fn build_chat_request_body(
118 model: &str, messages: &[ProviderMessage], max_tokens: u32, stream: bool, tools: Option<&serde_json::Value>,
119 ) -> Result<serde_json::Value> {
120 let raw_model = raw_model_id(model)?;
121 Ok(providers::openai::build_chat_request_body(
122 raw_model, messages, max_tokens, stream, tools,
123 ))
124 }
125
126 pub fn build_request_body(
128 model: &str, messages: &[ProviderMessage], max_tokens: u32, tools: Option<&serde_json::Value>,
129 effort: ReasoningEffort, summary: ReasoningSummary, continuation: &ProviderContinuation,
130 ) -> Result<serde_json::Value> {
131 let raw_model = raw_model_id(model)?;
132 if !reasoning_options(model).contains(&effort) {
133 return Err(ProviderError::Json(format!(
134 "reasoning control `{}` is not supported by {model}",
135 effort.label()
136 )));
137 }
138 Ok(match endpoint_family(raw_model) {
139 EndpointFamily::Responses => providers::codex::ChatGptCodexClient::build_openai_responses_request_body(
140 raw_model,
141 messages,
142 tools,
143 effort,
144 summary,
145 continuation,
146 ),
147 EndpointFamily::AnthropicMessages => {
148 let mut body =
149 providers::anthropic::build_messages_request_body(raw_model, messages, max_tokens, true, tools);
150 if effort != ReasoningEffort::Auto {
151 body["output_config"] = serde_json::json!({ "effort": effort.label() });
152 if supports_adaptive_thinking(raw_model) {
153 body["thinking"] = serde_json::json!({ "type": "adaptive" });
154 }
155 }
156 body
157 }
158 EndpointFamily::OpenAiChat => {
159 providers::openai::build_chat_request_body(raw_model, messages, max_tokens, true, tools)
160 }
161 })
162 }
163
164 #[expect(
166 clippy::too_many_arguments,
167 reason = "The public helper mirrors the complete provider request boundary for focused fixture tests."
168 )]
169 pub fn send_streaming_request(
170 &self, model: &str, messages: &[ProviderMessage], max_tokens: u32, tools: Option<&serde_json::Value>,
171 effort: ReasoningEffort, summary: ReasoningSummary, continuation: &ProviderContinuation,
172 ) -> Result<ureq::http::Response<ureq::Body>> {
173 let raw_model = raw_model_id(model)?;
174 let family = endpoint_family(raw_model);
175 let body = Self::build_request_body(model, messages, max_tokens, tools, effort, summary, continuation)?;
176 let path = match family {
177 EndpointFamily::Responses => "responses",
178 EndpointFamily::AnthropicMessages => "messages",
179 EndpointFamily::OpenAiChat => "chat/completions",
180 };
181 let url = format!("{}/{}", self.http.base_url(), path);
182 let mut request = self.http.agent().post(&url);
183 let headers = if family == EndpointFamily::AnthropicMessages {
184 self.build_messages_headers()
185 } else {
186 self.build_chat_headers()
187 };
188 for (key, value) in headers {
189 request = request.header(&key, &value);
190 }
191
192 let mut response = request
193 .config()
194 .http_status_as_error(false)
195 .build()
196 .send_json(&body)
197 .map_err(|e| ProviderError::Http(e.to_string()))?;
198
199 let status = response.status().as_u16();
200 if !(200..=299).contains(&status) {
201 let body = response
202 .body_mut()
203 .read_to_string()
204 .unwrap_or_else(|e| format!("failed to read error body: {e}"));
205 return Err(ProviderError::Status { code: status, body: providers::summarize_error_body(&body) });
206 }
207
208 Ok(response)
209 }
210}
211
212impl StreamingProvider for OpenCodeZenClient {
213 type Metadata = Vec<ModelInfo>;
214
215 fn name(&self) -> &'static str {
216 "OpenCode Zen"
217 }
218
219 fn load_status(&self) -> String {
220 String::from("provider: loading OPENCODE_ZEN_KEY")
221 }
222
223 fn request_status(&self, model: &str) -> String {
224 format!("provider: POST /zen/v1/chat/completions model={model}")
225 }
226
227 fn from_env_or_dotenv(root: &Path) -> Result<Self> {
228 OpenCodeZenClient::from_env_or_dotenv(root)
229 }
230
231 fn load_metadata(&self) -> Result<Self::Metadata> {
232 self.fetch_models()
233 }
234
235 fn metadata_loaded_event(&self, metadata: &Self::Metadata) -> Option<AgentEvent> {
236 let mut items: Vec<(String, String)> = providers::umans::known_models()
237 .into_iter()
238 .map(|model| (model.id.to_string(), model.description.to_string()))
239 .collect();
240 items.extend(
241 known_models()
242 .into_iter()
243 .map(|model| (model.id.to_string(), model.description.to_string())),
244 );
245 items.extend(
246 providers::opencode::go::known_models()
247 .into_iter()
248 .map(|model| (model.id.to_string(), model.description.to_string())),
249 );
250 items.extend(
251 providers::chatgpt_codex::known_models()
252 .into_iter()
253 .map(|model| (model.id.to_string(), model.description.to_string())),
254 );
255 items.extend(model_picker_items(metadata));
256 Some(AgentEvent::ModelMetadataLoaded(items))
257 }
258
259 fn metadata_status(&self, model: &str, metadata: &Self::Metadata) -> Option<String> {
260 model_status(model, metadata)
261 }
262
263 fn token_budget(&self, _model: &str, _metadata: Option<&Self::Metadata>) -> u32 {
264 DEFAULT_RECOMMENDED_MAX_TOKENS
265 }
266
267 fn serialized_request_body(
268 &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
269 ) -> Result<Vec<u8>> {
270 let body = Self::build_request_body(
271 model,
272 messages,
273 request.max_tokens,
274 Some(request.tools),
275 request.reasoning_effort,
276 request.reasoning_summary,
277 request.continuation,
278 )?;
279 providers::serialize_request_body(&body)
280 }
281
282 fn send_streaming_request(
283 &self, model: &str, messages: &[ProviderMessage], request: &crate::providers::StreamingRequest<'_>,
284 ) -> Result<ureq::http::Response<ureq::Body>> {
285 OpenCodeZenClient::send_streaming_request(
286 self,
287 model,
288 messages,
289 request.max_tokens,
290 Some(request.tools),
291 request.reasoning_effort,
292 request.reasoning_summary,
293 request.continuation,
294 )
295 }
296
297 fn stream_format(&self, model: &str) -> Result<StreamFormat> {
298 let raw = raw_model_id(model)?;
299 Ok(match endpoint_family(raw) {
300 EndpointFamily::Responses => StreamFormat::ChatGptCodexResponses,
301 EndpointFamily::AnthropicMessages => StreamFormat::AnthropicMessages,
302 EndpointFamily::OpenAiChat => StreamFormat::OpenAiChat,
303 })
304 }
305
306 fn request_error_message(error: &ProviderError) -> String {
307 error_message(error)
308 }
309
310 fn is_retryable_request_error(error: &ProviderError) -> bool {
311 is_retryable_error(error)
312 }
313}
314
315pub fn is_model_id(model: &str) -> bool {
317 model.strip_prefix(MODEL_PREFIX).is_some_and(|raw| !raw.is_empty())
318}
319
320pub fn raw_model_id(model: &str) -> Result<&str> {
322 model
323 .strip_prefix(MODEL_PREFIX)
324 .filter(|raw| !raw.is_empty())
325 .ok_or_else(|| ProviderError::invalid_model_id("OpenCode Zen", MODEL_PREFIX, model))
326}
327
328pub fn endpoint_family(model: &str) -> EndpointFamily {
330 if model.starts_with("gpt-") {
331 EndpointFamily::Responses
332 } else if model.starts_with("claude-") || model.starts_with("qwen") {
333 EndpointFamily::AnthropicMessages
334 } else {
335 EndpointFamily::OpenAiChat
336 }
337}
338
339pub fn reasoning_options(model: &str) -> Vec<ReasoningEffort> {
341 match raw_model_id(model) {
342 Ok(raw) => {
343 if raw.starts_with("gpt-") {
344 if raw.contains("-pro") {
345 return vec![ReasoningEffort::Auto, ReasoningEffort::High];
346 }
347 return vec![
348 ReasoningEffort::Auto,
349 ReasoningEffort::None,
350 ReasoningEffort::Minimal,
351 ReasoningEffort::Low,
352 ReasoningEffort::Medium,
353 ReasoningEffort::High,
354 ReasoningEffort::Xhigh,
355 ];
356 }
357 if raw.starts_with("claude-") && supports_claude_effort(raw) {
358 let mut options = vec![
359 ReasoningEffort::Auto,
360 ReasoningEffort::Low,
361 ReasoningEffort::Medium,
362 ReasoningEffort::High,
363 ];
364 if supports_claude_xhigh(raw) {
365 options.push(ReasoningEffort::Xhigh);
366 }
367 if !raw.contains("4-5") {
368 options.push(ReasoningEffort::Max);
369 }
370 return options;
371 }
372 vec![ReasoningEffort::Auto]
373 }
374 Err(_) => vec![ReasoningEffort::Auto],
375 }
376}
377
378pub fn known_models() -> Vec<KnownModel> {
380 vec![
381 KnownModel { id: "opencode/gpt-5.6-sol", description: "OpenCode Zen GPT-5.6 Sol (Responses)" },
382 KnownModel { id: "opencode/gpt-5.6-terra", description: "OpenCode Zen GPT-5.6 Terra (Responses)" },
383 KnownModel { id: "opencode/gpt-5.6-luna", description: "OpenCode Zen GPT-5.6 Luna (Responses)" },
384 KnownModel { id: "opencode/claude-opus-4-8", description: "OpenCode Zen Claude Opus 4.8 (Messages)" },
385 KnownModel { id: "opencode/claude-sonnet-5", description: "OpenCode Zen Claude Sonnet 5 (Messages)" },
386 KnownModel { id: "opencode/big-pickle", description: "OpenCode Zen Big Pickle (Chat Completions)" },
387 ]
388}
389
390pub fn model_picker_items(models: &[ModelInfo]) -> Vec<(String, String)> {
391 let mut items: Vec<(String, String)> = models
392 .iter()
393 .map(|info| (format!("{MODEL_PREFIX}{}", info.id), String::from("OpenCode Zen")))
394 .collect();
395 items.sort_by(|left, right| left.0.cmp(&right.0));
396 items
397}
398
399pub fn model_status(model: &str, models: &[ModelInfo]) -> Option<String> {
400 let raw = raw_model_id(model).ok()?;
401 models
402 .iter()
403 .find(|info| info.id == raw)
404 .map(|info| format!("model: opencode/{} OpenCode Zen", info.id))
405}
406
407pub fn is_retryable_error(err: &ProviderError) -> bool {
408 match err {
409 ProviderError::Status { code, body } if terminal_status_error(*code, body) => false,
410 _ => err.is_retryable(),
411 }
412}
413
414pub fn error_message(err: &ProviderError) -> String {
416 err.failure_message("rate limit or usage limit exceeded")
417}
418
419pub fn validate_api_key(api_key: &str) -> std::result::Result<(), String> {
421 probe_api_key(api_key).map_err(|error| format!("validation failed: {error}"))
422}
423
424pub fn probe_api_key(api_key: &str) -> Result<()> {
425 validate_api_key_at(BASE_URL, api_key)
426}
427
428fn supports_claude_effort(model: &str) -> bool {
429 matches!(
430 model,
431 "claude-fable-5"
432 | "claude-opus-4-8"
433 | "claude-opus-4-7"
434 | "claude-opus-4-6"
435 | "claude-opus-4-5"
436 | "claude-sonnet-5"
437 | "claude-sonnet-4-6"
438 )
439}
440
441fn supports_claude_xhigh(model: &str) -> bool {
442 matches!(
443 model,
444 "claude-fable-5" | "claude-opus-4-8" | "claude-opus-4-7" | "claude-sonnet-5"
445 )
446}
447
448fn supports_adaptive_thinking(model: &str) -> bool {
449 matches!(
450 model,
451 "claude-fable-5"
452 | "claude-opus-4-8"
453 | "claude-opus-4-7"
454 | "claude-opus-4-6"
455 | "claude-sonnet-5"
456 | "claude-sonnet-4-6"
457 )
458}
459
460fn validate_api_key_at(base_url: &str, api_key: &str) -> Result<()> {
461 OpenCodeZenClient::new(base_url, api_key).fetch_models().map(|_| ())
462}
463
464fn terminal_status_error(code: u16, body: &str) -> bool {
465 if matches!(code, 400..=404) {
466 true
467 } else {
468 let lower = body.to_ascii_lowercase();
469 lower.contains("balance")
470 || lower.contains("insufficient")
471 || lower.contains("unavailable model")
472 || lower.contains("model unavailable")
473 || lower.contains("free period")
474 || lower.contains("free-period")
475 || lower.contains("ended")
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use std::collections::HashMap;
482 use std::env;
483 use std::io::{Read, Write};
484 use std::net::TcpListener;
485 use std::thread;
486
487 use super::*;
488 use crate::providers::openai::{ChatSseEvent, parse_chat_sse_event};
489
490 fn mock_models_server(body: &'static str) -> String {
491 mock_models_response_server("200 OK", body)
492 }
493
494 fn mock_models_response_server(status: &'static str, body: &'static str) -> String {
495 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
496 let addr = listener.local_addr().expect("local addr");
497 thread::spawn(move || {
498 let (mut stream, _) = listener.accept().expect("accept request");
499 let mut request = [0_u8; 1024];
500 let _ = stream.read(&mut request);
501 let response = format!(
502 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
503 body.len(),
504 body
505 );
506 stream.write_all(response.as_bytes()).expect("write response");
507 });
508 format!("http://{addr}")
509 }
510
511 #[test]
512 fn raw_model_id_requires_prefix() {
513 assert_eq!(raw_model_id("opencode/big-pickle").unwrap(), "big-pickle");
514 assert!(matches!(
515 raw_model_id("big-pickle"),
516 Err(ProviderError::InvalidModelId { .. })
517 ));
518 }
519
520 #[test]
521 fn build_chat_request_body_uses_raw_model_and_tools() {
522 let messages = vec![ProviderMessage::user("find files")];
523 let defs = crate::tools::tool_definitions();
524 let catalog = crate::tools::tool_catalog_schemas(&defs);
525 let body =
526 OpenCodeZenClient::build_chat_request_body("opencode/big-pickle", &messages, 4096, true, Some(&catalog))
527 .expect("body");
528
529 assert_eq!(body["model"], "big-pickle");
530 assert_eq!(body["messages"][0]["role"], "user");
531 assert_eq!(body["tools"][0]["type"], "function");
532 assert_eq!(body["tools"][0]["function"]["name"], defs[0].name.as_ref());
533 assert_eq!(body["stream"], true);
534 }
535
536 #[test]
537 fn build_chat_request_body_uses_raw_model_for_text_only_turns() {
538 let messages = vec![
539 ProviderMessage {
540 role: "system".to_string(),
541 content: providers::ProviderMessageContent::Text("be concise".to_string()),
542 },
543 ProviderMessage::user("Say ok."),
544 ];
545 let body = OpenCodeZenClient::build_chat_request_body("opencode/big-pickle", &messages, 128, true, None)
546 .expect("body");
547
548 assert_eq!(body["model"], "big-pickle");
549 assert_eq!(body["messages"][0]["role"], "system");
550 assert_eq!(body["messages"][0]["content"], "be concise");
551 assert_eq!(body["messages"][1]["role"], "user");
552 assert_eq!(body["messages"][1]["content"], "Say ok.");
553 assert_eq!(body["max_tokens"], 128);
554 assert_eq!(body["stream"], true);
555 assert!(body.get("tools").is_none());
556 }
557
558 #[test]
559 fn build_headers_include_expected_auth_without_redaction_helpers() {
560 let client = OpenCodeZenClient::new(BASE_URL, "zen-test-key");
561 let headers: HashMap<String, String> = client.build_chat_headers().into_iter().collect();
562 assert_eq!(headers.get("Authorization").unwrap(), "Bearer zen-test-key");
563 assert_eq!(headers.get("Content-Type").unwrap(), "application/json");
564 }
565
566 #[test]
567 fn from_env_or_dotenv_missing_key_returns_error() {
568 let _guard = crate::test_env::lock();
569 unsafe {
570 env::remove_var(API_KEY_ENV);
571 }
572 let dir = tempfile::tempdir().unwrap();
573 let result = OpenCodeZenClient::from_env_or_dotenv(dir.path());
574 assert!(matches!(result, Err(ProviderError::MissingApiKey { env }) if env == API_KEY_ENV));
575 }
576
577 #[test]
578 fn missing_credentials_fail_before_network_access() {
579 let _guard = crate::test_env::lock();
580 unsafe {
581 env::remove_var(API_KEY_ENV);
582 }
583 let dir = tempfile::tempdir().unwrap();
584 let result = OpenCodeZenClient::from_env_or_dotenv(dir.path());
585
586 assert!(matches!(result, Err(ProviderError::MissingApiKey { env }) if env == API_KEY_ENV));
587 }
588
589 #[test]
590 fn from_env_or_dotenv_reads_workspace_env_file_separately_from_go_key() {
591 let _guard = crate::test_env::lock();
592 unsafe {
593 env::remove_var(API_KEY_ENV);
594 env::remove_var(auth::OPENCODE_GO_KEY_ENV);
595 }
596 let dir = tempfile::tempdir().unwrap();
597 std::fs::write(
598 dir.path().join(".env"),
599 "OPENCODE_GO_KEY=go-dotenv-key\nOPENCODE_ZEN_KEY=zen-dotenv-key\n",
600 )
601 .unwrap();
602
603 let client = OpenCodeZenClient::from_env_or_dotenv(dir.path()).unwrap();
604 let headers: HashMap<String, String> = client.build_chat_headers().into_iter().collect();
605
606 assert_eq!(headers.get("Authorization").unwrap(), "Bearer zen-dotenv-key");
607 }
608
609 #[test]
610 fn validation_does_not_persist_provider_payloads() {
611 let _guard = crate::test_env::lock();
612 let dir = tempfile::tempdir().unwrap();
613 let home = dir.path().join("home");
614 let workspace = dir.path().join("workspace");
615 std::fs::create_dir_all(&home).unwrap();
616 std::fs::create_dir_all(&workspace).unwrap();
617
618 let base_url = mock_models_server(
619 r#"{"object":"list","data":[{"id":"big-pickle","object":"model","created":1,"owned_by":"opencode"}]}"#,
620 );
621 let old_home = env::var_os("HOME");
622 unsafe { env::set_var("HOME", &home) };
623 validate_api_key_at(&base_url, "zen-validation").expect("validation succeeds");
624 unsafe {
625 if let Some(home) = old_home {
626 env::set_var("HOME", home);
627 } else {
628 env::remove_var("HOME");
629 }
630 }
631
632 assert!(!home.join(".thndrs").join("credentials.env").exists());
633 assert!(!workspace.join(".thndrs").join("credentials.env").exists());
634 }
635
636 #[test]
637 fn validation_preserves_rejected_credential_status() {
638 let base_url = mock_models_response_server("403 Forbidden", r#"{"error":"forbidden"}"#);
639
640 let error = validate_api_key_at(&base_url, "rejected-key").expect_err("credential should be rejected");
641
642 assert!(matches!(error, ProviderError::Status { code: 403, .. }));
643 }
644
645 #[test]
646 fn model_picker_items_prefix_live_ids_without_pricing_claims() {
647 let models = vec![ModelInfo {
648 id: "big-pickle".to_string(),
649 object: "model".to_string(),
650 created: 1,
651 owned_by: "opencode".to_string(),
652 }];
653 let items = model_picker_items(&models);
654 assert_eq!(items[0].0, "opencode/big-pickle");
655 assert_eq!(items[0].1, "OpenCode Zen");
656 assert!(!items[0].1.to_ascii_lowercase().contains("free"));
657 }
658
659 #[test]
660 fn known_models_include_big_pickle_for_offline_picker() {
661 let models = known_models();
662 assert!(models.iter().any(|model| model.id == "opencode/big-pickle"));
663 }
664
665 #[test]
666 fn model_status_maps_discovered_raw_ids() {
667 let models = vec![ModelInfo {
668 id: "big-pickle".to_string(),
669 object: "model".to_string(),
670 created: 1,
671 owned_by: "opencode".to_string(),
672 }];
673
674 assert_eq!(
675 model_status("opencode/big-pickle", &models).as_deref(),
676 Some("model: opencode/big-pickle OpenCode Zen")
677 );
678 assert_eq!(model_status("big-pickle", &models), None);
679 }
680
681 #[test]
682 fn retryable_error_classification_matches_policy() {
683 assert!(!is_retryable_error(&ProviderError::missing_api_key(API_KEY_ENV)));
684 assert!(is_retryable_error(&ProviderError::Status {
685 code: 429,
686 body: "limit".into()
687 }));
688 for code in [500, 502, 503, 504] {
689 assert!(is_retryable_error(&ProviderError::Status {
690 code,
691 body: "temporary server error".into()
692 }));
693 }
694 assert!(is_retryable_error(&ProviderError::Http("request timed out".into())));
695 assert!(is_retryable_error(&ProviderError::Http("connection reset".into())));
696 assert!(!is_retryable_error(&ProviderError::Status {
697 code: 402,
698 body: "insufficient balance".into()
699 }));
700 assert!(!is_retryable_error(&ProviderError::Status {
701 code: 404,
702 body: "unavailable model".into()
703 }));
704 assert!(!is_retryable_error(&ProviderError::Status {
705 code: 400,
706 body: "free period ended".into()
707 }));
708 }
709
710 #[test]
711 fn parse_chat_sse_events_cover_zen_stream_shapes() {
712 assert_eq!(
713 parse_chat_sse_event(r#"{"choices":[{"delta":{"content":"hi"}}]}"#),
714 vec![ChatSseEvent::TextDelta("hi".to_string())]
715 );
716 assert_eq!(
717 parse_chat_sse_event(
718 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"find_files","arguments":"{\"pattern\":\"Cargo\"}"}}]}}]}"#
719 ),
720 vec![
721 ChatSseEvent::ToolCallStart { index: 0, id: "call_1".to_string(), name: "find_files".to_string() },
722 ChatSseEvent::ToolCallArgumentsDelta { index: 0, arguments: r#"{"pattern":"Cargo"}"#.to_string() },
723 ]
724 );
725 assert_eq!(
726 parse_chat_sse_event(r#"{"usage":{"prompt_tokens":2,"completion_tokens":3}}"#),
727 vec![ChatSseEvent::Usage { input_tokens: 2, output_tokens: 3 }]
728 );
729 assert_eq!(
730 parse_chat_sse_event(r#"{"error":{"message":"backend failed"}}"#),
731 vec![ChatSseEvent::Error("backend failed".to_string())]
732 );
733 assert_eq!(
734 parse_chat_sse_event("{not json"),
735 vec![ChatSseEvent::Malformed("{not json".to_string())]
736 );
737 }
738
739 #[test]
740 fn routes_and_lowers_reasoning_by_model_family() {
741 assert_eq!(endpoint_family("gpt-5.6-sol"), EndpointFamily::Responses);
742 assert_eq!(endpoint_family("claude-opus-4-8"), EndpointFamily::AnthropicMessages);
743 assert_eq!(endpoint_family("big-pickle"), EndpointFamily::OpenAiChat);
744
745 let messages = vec![ProviderMessage::user("hello")];
746 let responses = OpenCodeZenClient::build_request_body(
747 "opencode/gpt-5.6-sol",
748 &messages,
749 128,
750 None,
751 ReasoningEffort::High,
752 ReasoningSummary::Off,
753 &ProviderContinuation::default(),
754 )
755 .expect("responses body");
756 assert_eq!(responses["reasoning"]["effort"], "high");
757
758 let anthropic = OpenCodeZenClient::build_request_body(
759 "opencode/claude-opus-4-8",
760 &messages,
761 128,
762 None,
763 ReasoningEffort::Xhigh,
764 ReasoningSummary::Off,
765 &ProviderContinuation::default(),
766 )
767 .expect("messages body");
768 assert_eq!(anthropic["output_config"]["effort"], "xhigh");
769 assert_eq!(anthropic["thinking"]["type"], "adaptive");
770 assert!(
771 OpenCodeZenClient::build_request_body(
772 "opencode/big-pickle",
773 &messages,
774 128,
775 None,
776 ReasoningEffort::High,
777 ReasoningSummary::Off,
778 &ProviderContinuation::default(),
779 )
780 .is_err()
781 );
782 }
783
784 #[test]
785 #[ignore = "requires OPENCODE_ZEN_KEY, network access, and acceptance of Big Pickle limited-free pricing/privacy caveats"]
786 fn live_models_requires_opencode_zen_key() {
787 let workspace_root = env::current_dir().expect("current dir");
788 let client = OpenCodeZenClient::from_env_or_dotenv(&workspace_root).expect("OPENCODE_ZEN_KEY must be set");
789 let models = client.fetch_models().expect("fetch Zen models with OPENCODE_ZEN_KEY");
790 assert!(models.iter().any(|model| model.id == "big-pickle"));
791 }
792
793 #[test]
794 #[ignore = "requires OPENCODE_ZEN_KEY, network access, and acceptance of Big Pickle limited-free pricing/privacy caveats"]
795 fn live_big_pickle_text_stream_requires_opencode_zen_key() {
796 let workspace_root = env::current_dir().expect("current dir");
797 let client = OpenCodeZenClient::from_env_or_dotenv(&workspace_root).expect("OPENCODE_ZEN_KEY must be set");
798 let messages = vec![ProviderMessage::user("Reply with exactly: ok")];
799 let mut response = client
800 .send_streaming_request(
801 "opencode/big-pickle",
802 &messages,
803 32,
804 None,
805 ReasoningEffort::Auto,
806 ReasoningSummary::Off,
807 &ProviderContinuation::default(),
808 )
809 .expect("streaming Big Pickle request with OPENCODE_ZEN_KEY");
810 let body = response.body_mut().read_to_string().expect("read body");
811 assert!(body.contains("data:"));
812 }
813
814 #[test]
815 #[ignore = "requires OPENCODE_ZEN_KEY, network access, Big Pickle tool-call support, and acceptance of limited-free pricing/privacy caveats"]
816 fn live_big_pickle_tool_call_requires_opencode_zen_key() {
817 let workspace_root = env::current_dir().expect("current dir");
818 let client = OpenCodeZenClient::from_env_or_dotenv(&workspace_root).expect("OPENCODE_ZEN_KEY must be set");
819 let messages = vec![ProviderMessage::user(
820 "Use the find_files tool to look for Cargo.toml, then stop.",
821 )];
822 let defs = crate::tools::tool_definitions();
823 let catalog = crate::tools::tool_catalog_schemas(&defs);
824 let mut response = client
825 .send_streaming_request(
826 "opencode/big-pickle",
827 &messages,
828 128,
829 Some(&catalog),
830 ReasoningEffort::Auto,
831 ReasoningSummary::Off,
832 &ProviderContinuation::default(),
833 )
834 .expect("streaming Big Pickle tool request with OPENCODE_ZEN_KEY");
835 let body = response.body_mut().read_to_string().expect("read body");
836 assert!(body.contains("tool_calls") || body.contains("find_files"));
837 }
838}