//! OpenCode GO adapter — multi-vendor curated gateway via opencode.ai/zen/go. //! //! One sk-* key reaches Claude Opus 4.7, GPT-5.5-pro, Gemini 3.1-pro, //! Kimi K2.6, DeepSeek, GLM, Qwen, plus 4 free-tier models. //! OpenAI-compatible Chat Completions; auth via Bearer. //! //! Why a separate adapter (vs reusing openrouter.rs): //! - Different account, different key, different base_url //! - No HTTP-Referer / X-Title headers (those are OpenRouter-specific) //! - Future-proof for any opencode-only request shaping //! //! Key sourcing priority: //! 1. Env var `OPENCODE_API_KEY` (loaded from /etc/lakehouse/opencode.env //! via systemd EnvironmentFile=) //! 2. /etc/lakehouse/opencode.env directly (rescue path if env missing) //! //! Resolved once at gateway startup, stored on `V1State.opencode_key`. //! Model-prefix routing: "opencode/" auto-routes here, prefix //! stripped before upstream call. use std::time::Duration; use serde::{Deserialize, Serialize}; use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock}; // /zen/v1 is the unified OpenCode endpoint that covers BOTH the // Zen pay-per-token tier (Claude/GPT/Gemini frontier) AND the Go // subscription tier (Kimi/GLM/DeepSeek/Qwen/Minimax/mimo). When the // caller has both, opencode bills per-model: Zen models charge Zen // balance, Go models charge against the Go subscription cap. // // /zen/go/v1 exists as a Go-only sub-path (rejects Zen models with // "Model not supported"); we use the unified /zen/v1 since the same // key works for both with correct billing routing upstream. const OPENCODE_BASE_URL: &str = "https://opencode.ai/zen/v1"; // 600s default — opencode upstream models include reasoning-heavy // variants (Claude Opus, Kimi K2.6, GLM-5.1) that legitimately take // 3-5 min on big audit prompts. Override via OPENCODE_TIMEOUT_SECS. const OPENCODE_TIMEOUT_SECS_DEFAULT: u64 = 600; fn opencode_timeout_secs() -> u64 { std::env::var("OPENCODE_TIMEOUT_SECS") .ok() .and_then(|s| s.trim().parse::().ok()) .filter(|&n| n > 0) .unwrap_or(OPENCODE_TIMEOUT_SECS_DEFAULT) } pub fn resolve_opencode_key() -> Option { if let Ok(k) = std::env::var("OPENCODE_API_KEY") { if !k.trim().is_empty() { return Some(k.trim().to_string()); } } if let Ok(raw) = std::fs::read_to_string("/etc/lakehouse/opencode.env") { for line in raw.lines() { if let Some(rest) = line.strip_prefix("OPENCODE_API_KEY=") { let k = rest.trim().trim_matches('"').trim_matches('\''); if !k.is_empty() { return Some(k.to_string()); } } } } None } pub async fn chat( key: &str, req: &ChatRequest, ) -> Result { // Strip the "opencode/" namespace prefix so the upstream sees the // bare model id (e.g. "claude-opus-4-7", "kimi-k2.6"). let model = req.model.strip_prefix("opencode/").unwrap_or(&req.model).to_string(); // Anthropic models on opencode reject `temperature` with a 400 // "temperature is deprecated for this model" error. Strip the // field for claude-* and the new gpt-5.x reasoning lineages // (Anthropic/OpenAI's reasoning models all moved away from temp). // Other models keep the caller's value or default to 0.3. let drop_temp = model.starts_with("claude-") || model.starts_with("gpt-5") || model.starts_with("o1") || model.starts_with("o3") || model.starts_with("o4"); let body = OCChatBody { model: model.clone(), messages: req.messages.iter().map(|m| OCMessage { role: m.role.clone(), content: m.content.clone(), }).collect(), // filter(|&n| n > 0) catches Some(0) — same trap that bit the // Kimi adapter when callers passed empty-env-parsed-to-0. max_tokens: req.max_tokens.filter(|&n| n > 0).unwrap_or(800), temperature: if drop_temp { None } else { Some(req.temperature.unwrap_or(0.3)) }, stream: false, }; let client = reqwest::Client::builder() .timeout(Duration::from_secs(opencode_timeout_secs())) .build() .map_err(|e| format!("build client: {e}"))?; let t0 = std::time::Instant::now(); let resp = client .post(format!("{}/chat/completions", OPENCODE_BASE_URL)) .bearer_auth(key) .json(&body) .send() .await .map_err(|e| format!("opencode.ai unreachable: {e}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_else(|_| "?".into()); return Err(format!("opencode.ai {}: {}", status, body)); } let parsed: OCChatResponse = resp.json().await .map_err(|e| format!("invalid opencode response: {e}"))?; let latency_ms = t0.elapsed().as_millis(); let choice = parsed.choices.into_iter().next() .ok_or_else(|| "opencode returned no choices".to_string())?; let text = choice.message.content; let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| { let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum(); ((chars + 3) / 4) as u32 }); let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| { ((text.chars().count() + 3) / 4) as u32 }); tracing::info!( target: "v1.chat", provider = "opencode", model = %model, prompt_tokens, completion_tokens, latency_ms = latency_ms as u64, "opencode chat completed", ); Ok(ChatResponse { id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)), object: "chat.completion", created: chrono::Utc::now().timestamp(), model, choices: vec![Choice { index: 0, message: Message { role: "assistant".into(), content: serde_json::Value::String(text) }, finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()), }], usage: UsageBlock { prompt_tokens, completion_tokens, total_tokens: prompt_tokens + completion_tokens, }, }) } // -- OpenCode wire shapes (OpenAI-compatible) -- #[derive(Serialize)] struct OCChatBody { model: String, messages: Vec, max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] temperature: Option, stream: bool, } #[derive(Serialize)] struct OCMessage { role: String, content: serde_json::Value } #[derive(Deserialize)] struct OCChatResponse { choices: Vec, #[serde(default)] usage: Option, } #[derive(Deserialize)] struct OCChoice { message: OCMessageResp, #[serde(default)] finish_reason: Option, } #[derive(Deserialize)] struct OCMessageResp { content: String } #[derive(Deserialize)] struct OCUsage { prompt_tokens: u32, completion_tokens: u32 } #[cfg(test)] mod tests { use super::*; #[test] fn resolve_opencode_key_does_not_panic() { let _ = resolve_opencode_key(); } #[test] fn model_prefix_strip() { let cases = [ ("opencode/claude-opus-4-7", "claude-opus-4-7"), ("opencode/kimi-k2.6", "kimi-k2.6"), ("claude-opus-4-7", "claude-opus-4-7"), ]; for (input, expected) in cases { let out = input.strip_prefix("opencode/").unwrap_or(input); assert_eq!(out, expected); } } #[test] fn max_tokens_filters_zero() { // The trap: empty env -> Number("") -> 0 -> Some(0). Adapter // must not pass 0 upstream; should fall to 800. let some_zero: Option = Some(0); let result = some_zero.filter(|&n| n > 0).unwrap_or(800); assert_eq!(result, 800); let some_real: Option = Some(4096); assert_eq!(some_real.filter(|&n| n > 0).unwrap_or(800), 4096); let none_val: Option = None; assert_eq!(none_val.filter(|&n| n > 0).unwrap_or(800), 800); } }