Skip to main content

thndrs_lib/core/providers/opencode/
zen.rs

1//! OpenCode Zen provider.
2//!
3//! OpenCode Zen exposes an OpenAI-compatible chat-completions route. The
4//! app-facing model id keeps the documented `opencode/` prefix; requests use
5//! the raw Zen model id.
6
7use 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
19/// OpenCode Zen base URL.
20pub const BASE_URL: &str = "https://opencode.ai/zen/v1";
21
22/// OpenCode Zen chat-completions endpoint.
23pub const CHAT_COMPLETIONS_URL: &str = "https://opencode.ai/zen/v1/chat/completions";
24
25/// OpenCode Zen model-discovery endpoint.
26pub const MODELS_URL: &str = "https://opencode.ai/zen/v1/models";
27
28/// Environment variable name for the OpenCode Zen API key.
29pub const API_KEY_ENV: &str = "OPENCODE_ZEN_KEY";
30
31/// User/config-facing model id prefix.
32pub const MODEL_PREFIX: &str = "opencode/";
33
34pub const DEFAULT_RECOMMENDED_MAX_TOKENS: u32 = 32_768;
35
36/// Endpoint family selected by the OpenCode Zen model id.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum EndpointFamily {
39    Responses,
40    AnthropicMessages,
41    OpenAiChat,
42}
43
44/// OpenAI-compatible model list response from `GET /models`.
45pub type ModelsResponse = super::ModelsResponse;
46
47/// Model metadata currently returned by OpenCode Zen.
48pub type ModelInfo = super::ModelInfo;
49
50/// Concrete OpenCode Zen API client.
51pub struct OpenCodeZenClient {
52    http: ProviderHttpClient,
53}
54
55impl OpenCodeZenClient {
56    /// Create a client from `OPENCODE_ZEN_KEY`, falling back to managed stores
57    /// and workspace `.env`.
58    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    /// Create a client with an explicit base URL and API key.
69    pub fn new(base_url: &str, api_key: &str) -> Self {
70        OpenCodeZenClient { http: ProviderHttpClient::new(base_url, api_key) }
71    }
72
73    /// Fetch model metadata from `GET /models`.
74    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    /// Build the headers for a chat-completions request.
102    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    /// Build headers for an Anthropic-compatible Messages request.
110    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    /// Build a request body for `POST /chat/completions`.
117    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    /// Build the request body for the endpoint family selected by `model`.
127    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    /// Send a streaming request to the endpoint selected by `model`.
165    #[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
315/// Whether `model` is an OpenCode Zen model id.
316pub fn is_model_id(model: &str) -> bool {
317    model.strip_prefix(MODEL_PREFIX).is_some_and(|raw| !raw.is_empty())
318}
319
320/// Strip `opencode/` from a model id.
321pub 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
328/// Select the documented OpenCode Zen endpoint for a raw model id.
329pub 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
339/// Return the verified reasoning choices for one Zen model.
340pub 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
378/// Current OpenCode Zen models from public docs.
379pub 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
414/// Convert an OpenCode Zen error into a human-readable failure string.
415pub fn error_message(err: &ProviderError) -> String {
416    err.failure_message("rate limit or usage limit exceeded")
417}
418
419/// Try to validate an OpenCode Zen API key with a lightweight model-list request.
420pub 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}