opencode.rs

  1use anyhow::{Result, anyhow};
  2use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
  3use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
  4use serde::{Deserialize, Serialize};
  5use strum::EnumIter;
  6
  7pub const OPENCODE_API_URL: &str = "https://opencode.ai/zen";
  8
  9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
 10#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
 11#[serde(rename_all = "snake_case")]
 12pub enum ApiProtocol {
 13    #[default]
 14    Anthropic,
 15    OpenAiResponses,
 16    OpenAiChat,
 17    Google,
 18}
 19
 20#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
 21#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)]
 22pub enum Model {
 23    // -- Anthropic protocol models --
 24    #[serde(rename = "claude-opus-4-6")]
 25    ClaudeOpus4_6,
 26    #[serde(rename = "claude-opus-4-5")]
 27    ClaudeOpus4_5,
 28    #[serde(rename = "claude-opus-4-1")]
 29    ClaudeOpus4_1,
 30    #[default]
 31    #[serde(rename = "claude-sonnet-4-6")]
 32    ClaudeSonnet4_6,
 33    #[serde(rename = "claude-sonnet-4-5")]
 34    ClaudeSonnet4_5,
 35    #[serde(rename = "claude-sonnet-4")]
 36    ClaudeSonnet4,
 37    #[serde(rename = "claude-haiku-4-5")]
 38    ClaudeHaiku4_5,
 39    #[serde(rename = "claude-3-5-haiku")]
 40    Claude3_5Haiku,
 41
 42    // -- OpenAI Responses API models --
 43    #[serde(rename = "gpt-5.4")]
 44    Gpt5_4,
 45    #[serde(rename = "gpt-5.4-pro")]
 46    Gpt5_4Pro,
 47    #[serde(rename = "gpt-5.4-mini")]
 48    Gpt5_4Mini,
 49    #[serde(rename = "gpt-5.4-nano")]
 50    Gpt5_4Nano,
 51    #[serde(rename = "gpt-5.3-codex")]
 52    Gpt5_3Codex,
 53    #[serde(rename = "gpt-5.3-codex-spark")]
 54    Gpt5_3Spark,
 55    #[serde(rename = "gpt-5.2")]
 56    Gpt5_2,
 57    #[serde(rename = "gpt-5.2-codex")]
 58    Gpt5_2Codex,
 59    #[serde(rename = "gpt-5.1")]
 60    Gpt5_1,
 61    #[serde(rename = "gpt-5.1-codex")]
 62    Gpt5_1Codex,
 63    #[serde(rename = "gpt-5.1-codex-max")]
 64    Gpt5_1CodexMax,
 65    #[serde(rename = "gpt-5.1-codex-mini")]
 66    Gpt5_1CodexMini,
 67    #[serde(rename = "gpt-5")]
 68    Gpt5,
 69    #[serde(rename = "gpt-5-codex")]
 70    Gpt5Codex,
 71    #[serde(rename = "gpt-5-nano")]
 72    Gpt5Nano,
 73
 74    // -- Google protocol models --
 75    #[serde(rename = "gemini-3.1-pro")]
 76    Gemini3_1Pro,
 77    #[serde(rename = "gemini-3-flash")]
 78    Gemini3Flash,
 79
 80    // -- OpenAI Chat Completions protocol models --
 81    #[serde(rename = "minimax-m2.5")]
 82    MiniMaxM2_5,
 83    #[serde(rename = "minimax-m2.5-free")]
 84    MiniMaxM2_5Free,
 85    #[serde(rename = "glm-5")]
 86    Glm5,
 87    #[serde(rename = "kimi-k2.5")]
 88    KimiK2_5,
 89    #[serde(rename = "mimo-v2-pro-free")]
 90    MimoV2ProFree,
 91    #[serde(rename = "mimo-v2-omni-free")]
 92    MimoV2OmniFree,
 93    #[serde(rename = "mimo-v2-flash-free")]
 94    MimoV2FlashFree,
 95    #[serde(rename = "trinity-large-preview-free")]
 96    TrinityLargePreviewFree,
 97    #[serde(rename = "big-pickle")]
 98    BigPickle,
 99    #[serde(rename = "nemotron-3-super-free")]
100    Nemotron3SuperFree,
101
102    // -- Custom model --
103    #[serde(rename = "custom")]
104    Custom {
105        name: String,
106        display_name: Option<String>,
107        max_tokens: u64,
108        max_output_tokens: Option<u64>,
109        protocol: ApiProtocol,
110    },
111}
112
113impl Model {
114    pub fn default_fast() -> Self {
115        Self::ClaudeHaiku4_5
116    }
117
118    pub fn id(&self) -> &str {
119        match self {
120            Self::ClaudeOpus4_6 => "claude-opus-4-6",
121            Self::ClaudeOpus4_5 => "claude-opus-4-5",
122            Self::ClaudeOpus4_1 => "claude-opus-4-1",
123            Self::ClaudeSonnet4_6 => "claude-sonnet-4-6",
124            Self::ClaudeSonnet4_5 => "claude-sonnet-4-5",
125            Self::ClaudeSonnet4 => "claude-sonnet-4",
126            Self::ClaudeHaiku4_5 => "claude-haiku-4-5",
127            Self::Claude3_5Haiku => "claude-3-5-haiku",
128
129            Self::Gpt5_4 => "gpt-5.4",
130            Self::Gpt5_4Pro => "gpt-5.4-pro",
131            Self::Gpt5_4Mini => "gpt-5.4-mini",
132            Self::Gpt5_4Nano => "gpt-5.4-nano",
133            Self::Gpt5_3Codex => "gpt-5.3-codex",
134            Self::Gpt5_3Spark => "gpt-5.3-codex-spark",
135            Self::Gpt5_2 => "gpt-5.2",
136            Self::Gpt5_2Codex => "gpt-5.2-codex",
137            Self::Gpt5_1 => "gpt-5.1",
138            Self::Gpt5_1Codex => "gpt-5.1-codex",
139            Self::Gpt5_1CodexMax => "gpt-5.1-codex-max",
140            Self::Gpt5_1CodexMini => "gpt-5.1-codex-mini",
141            Self::Gpt5 => "gpt-5",
142            Self::Gpt5Codex => "gpt-5-codex",
143            Self::Gpt5Nano => "gpt-5-nano",
144
145            Self::Gemini3_1Pro => "gemini-3.1-pro",
146            Self::Gemini3Flash => "gemini-3-flash",
147
148            Self::MiniMaxM2_5 => "minimax-m2.5",
149            Self::MiniMaxM2_5Free => "minimax-m2.5-free",
150            Self::Glm5 => "glm-5",
151            Self::KimiK2_5 => "kimi-k2.5",
152            Self::MimoV2ProFree => "mimo-v2-pro-free",
153            Self::MimoV2OmniFree => "mimo-v2-omni-free",
154            Self::MimoV2FlashFree => "mimo-v2-flash-free",
155            Self::TrinityLargePreviewFree => "trinity-large-preview-free",
156            Self::BigPickle => "big-pickle",
157            Self::Nemotron3SuperFree => "nemotron-3-super-free",
158
159            Self::Custom { name, .. } => name,
160        }
161    }
162
163    pub fn display_name(&self) -> &str {
164        match self {
165            Self::ClaudeOpus4_6 => "Claude Opus 4.6",
166            Self::ClaudeOpus4_5 => "Claude Opus 4.5",
167            Self::ClaudeOpus4_1 => "Claude Opus 4.1",
168            Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6",
169            Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5",
170            Self::ClaudeSonnet4 => "Claude Sonnet 4",
171            Self::ClaudeHaiku4_5 => "Claude Haiku 4.5",
172            Self::Claude3_5Haiku => "Claude Haiku 3.5",
173
174            Self::Gpt5_4 => "GPT 5.4",
175            Self::Gpt5_4Pro => "GPT 5.4 Pro",
176            Self::Gpt5_4Mini => "GPT 5.4 Mini",
177            Self::Gpt5_4Nano => "GPT 5.4 Nano",
178            Self::Gpt5_3Codex => "GPT 5.3 Codex",
179            Self::Gpt5_3Spark => "GPT 5.3 Codex Spark",
180            Self::Gpt5_2 => "GPT 5.2",
181            Self::Gpt5_2Codex => "GPT 5.2 Codex",
182            Self::Gpt5_1 => "GPT 5.1",
183            Self::Gpt5_1Codex => "GPT 5.1 Codex",
184            Self::Gpt5_1CodexMax => "GPT 5.1 Codex Max",
185            Self::Gpt5_1CodexMini => "GPT 5.1 Codex Mini",
186            Self::Gpt5 => "GPT 5",
187            Self::Gpt5Codex => "GPT 5 Codex",
188            Self::Gpt5Nano => "GPT 5 Nano",
189
190            Self::Gemini3_1Pro => "Gemini 3.1 Pro",
191            Self::Gemini3Flash => "Gemini 3 Flash",
192
193            Self::MiniMaxM2_5 => "MiniMax M2.5",
194            Self::MiniMaxM2_5Free => "MiniMax M2.5 Free",
195            Self::Glm5 => "GLM 5",
196            Self::KimiK2_5 => "Kimi K2.5",
197            Self::MimoV2ProFree => "MiMo V2 Pro Free",
198            Self::MimoV2OmniFree => "MiMo V2 Omni Free",
199            Self::MimoV2FlashFree => "MiMo V2 Flash Free",
200            Self::TrinityLargePreviewFree => "Trinity Large Preview Free",
201            Self::BigPickle => "Big Pickle",
202            Self::Nemotron3SuperFree => "Nemotron 3 Super Free",
203
204            Self::Custom {
205                name, display_name, ..
206            } => display_name.as_deref().unwrap_or(name),
207        }
208    }
209
210    pub fn protocol(&self) -> ApiProtocol {
211        match self {
212            Self::ClaudeOpus4_6
213            | Self::ClaudeOpus4_5
214            | Self::ClaudeOpus4_1
215            | Self::ClaudeSonnet4_6
216            | Self::ClaudeSonnet4_5
217            | Self::ClaudeSonnet4
218            | Self::ClaudeHaiku4_5
219            | Self::Claude3_5Haiku => ApiProtocol::Anthropic,
220
221            Self::Gpt5_4
222            | Self::Gpt5_4Pro
223            | Self::Gpt5_4Mini
224            | Self::Gpt5_4Nano
225            | Self::Gpt5_3Codex
226            | Self::Gpt5_3Spark
227            | Self::Gpt5_2
228            | Self::Gpt5_2Codex
229            | Self::Gpt5_1
230            | Self::Gpt5_1Codex
231            | Self::Gpt5_1CodexMax
232            | Self::Gpt5_1CodexMini
233            | Self::Gpt5
234            | Self::Gpt5Codex
235            | Self::Gpt5Nano => ApiProtocol::OpenAiResponses,
236
237            Self::Gemini3_1Pro | Self::Gemini3Flash => ApiProtocol::Google,
238
239            Self::MiniMaxM2_5
240            | Self::MiniMaxM2_5Free
241            | Self::Glm5
242            | Self::KimiK2_5
243            | Self::MimoV2ProFree
244            | Self::MimoV2OmniFree
245            | Self::MimoV2FlashFree
246            | Self::TrinityLargePreviewFree
247            | Self::BigPickle
248            | Self::Nemotron3SuperFree => ApiProtocol::OpenAiChat,
249
250            Self::Custom { protocol, .. } => *protocol,
251        }
252    }
253
254    pub fn max_token_count(&self) -> u64 {
255        match self {
256            // Anthropic models
257            Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6 => 1_000_000,
258            Self::ClaudeOpus4_5 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4 => 200_000,
259            Self::ClaudeOpus4_1 => 200_000,
260            Self::ClaudeHaiku4_5 => 200_000,
261            Self::Claude3_5Haiku => 200_000,
262
263            // OpenAI models
264            Self::Gpt5_4 | Self::Gpt5_4Pro => 1_050_000,
265            Self::Gpt5_4Mini | Self::Gpt5_4Nano => 400_000,
266            Self::Gpt5_3Codex => 400_000,
267            Self::Gpt5_3Spark => 128_000,
268            Self::Gpt5_2 | Self::Gpt5_2Codex => 400_000,
269            Self::Gpt5_1 | Self::Gpt5_1Codex | Self::Gpt5_1CodexMax | Self::Gpt5_1CodexMini => {
270                400_000
271            }
272            Self::Gpt5 | Self::Gpt5Codex | Self::Gpt5Nano => 400_000,
273
274            // Google models
275            Self::Gemini3_1Pro => 1_048_576,
276            Self::Gemini3Flash => 1_048_576,
277
278            // OpenAI-compatible models
279            Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => 196_608,
280            Self::Glm5 => 200_000,
281            Self::KimiK2_5 => 262_144,
282            Self::MimoV2ProFree => 1_048_576,
283            Self::MimoV2OmniFree | Self::MimoV2FlashFree => 262_144,
284            Self::TrinityLargePreviewFree => 131_072,
285            Self::BigPickle => 200_000,
286            Self::Nemotron3SuperFree => 262_144,
287
288            Self::Custom { max_tokens, .. } => *max_tokens,
289        }
290    }
291
292    pub fn max_output_tokens(&self) -> Option<u64> {
293        match self {
294            // Anthropic models
295            Self::ClaudeOpus4_6 => Some(128_000),
296            Self::ClaudeSonnet4_6 => Some(64_000),
297            Self::ClaudeOpus4_5
298            | Self::ClaudeOpus4_1
299            | Self::ClaudeSonnet4_5
300            | Self::ClaudeSonnet4
301            | Self::ClaudeHaiku4_5 => Some(64_000),
302            Self::Claude3_5Haiku => Some(8_192),
303
304            // OpenAI models
305            Self::Gpt5_4
306            | Self::Gpt5_4Pro
307            | Self::Gpt5_4Mini
308            | Self::Gpt5_4Nano
309            | Self::Gpt5_3Codex
310            | Self::Gpt5_3Spark
311            | Self::Gpt5_2
312            | Self::Gpt5_2Codex
313            | Self::Gpt5_1
314            | Self::Gpt5_1Codex
315            | Self::Gpt5_1CodexMax
316            | Self::Gpt5_1CodexMini
317            | Self::Gpt5
318            | Self::Gpt5Codex
319            | Self::Gpt5Nano => Some(128_000),
320
321            // Google models
322            Self::Gemini3_1Pro | Self::Gemini3Flash => Some(65_536),
323
324            // OpenAI-compatible models
325            Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => Some(65_536),
326            Self::Glm5 | Self::BigPickle => Some(128_000),
327            Self::KimiK2_5 => Some(65_536),
328            Self::MimoV2ProFree => Some(131_072),
329            Self::MimoV2OmniFree | Self::MimoV2FlashFree => Some(65_536),
330            Self::TrinityLargePreviewFree | Self::Nemotron3SuperFree => Some(16_384),
331
332            Self::Custom {
333                max_output_tokens, ..
334            } => *max_output_tokens,
335        }
336    }
337
338    pub fn supports_tools(&self) -> bool {
339        true
340    }
341
342    pub fn supports_images(&self) -> bool {
343        match self {
344            // Anthropic models support images
345            Self::ClaudeOpus4_6
346            | Self::ClaudeOpus4_5
347            | Self::ClaudeOpus4_1
348            | Self::ClaudeSonnet4_6
349            | Self::ClaudeSonnet4_5
350            | Self::ClaudeSonnet4
351            | Self::ClaudeHaiku4_5
352            | Self::Claude3_5Haiku => true,
353
354            // OpenAI models support images
355            Self::Gpt5_4
356            | Self::Gpt5_4Pro
357            | Self::Gpt5_4Mini
358            | Self::Gpt5_4Nano
359            | Self::Gpt5_3Codex
360            | Self::Gpt5_3Spark
361            | Self::Gpt5_2
362            | Self::Gpt5_2Codex
363            | Self::Gpt5_1
364            | Self::Gpt5_1Codex
365            | Self::Gpt5_1CodexMax
366            | Self::Gpt5_1CodexMini
367            | Self::Gpt5
368            | Self::Gpt5Codex
369            | Self::Gpt5Nano => true,
370
371            // Google models support images
372            Self::Gemini3_1Pro | Self::Gemini3Flash => true,
373
374            // OpenAI-compatible models — conservative default
375            Self::MiniMaxM2_5
376            | Self::MiniMaxM2_5Free
377            | Self::Glm5
378            | Self::KimiK2_5
379            | Self::MimoV2ProFree
380            | Self::MimoV2OmniFree
381            | Self::MimoV2FlashFree
382            | Self::TrinityLargePreviewFree
383            | Self::BigPickle
384            | Self::Nemotron3SuperFree => false,
385
386            Self::Custom { protocol, .. } => matches!(
387                protocol,
388                ApiProtocol::Anthropic
389                    | ApiProtocol::OpenAiResponses
390                    | ApiProtocol::OpenAiChat
391                    | ApiProtocol::Google
392            ),
393        }
394    }
395}
396
397/// Stream generate content for Google models via OpenCode Zen.
398///
399/// Unlike `google_ai::stream_generate_content()`, this uses:
400/// - `/v1/models/{model}` path (not `/v1beta/models/{model}`)
401/// - `Authorization: Bearer` header (not `key=` query param)
402pub async fn stream_generate_content_zen(
403    client: &dyn HttpClient,
404    api_url: &str,
405    api_key: &str,
406    request: google_ai::GenerateContentRequest,
407) -> Result<BoxStream<'static, Result<google_ai::GenerateContentResponse>>> {
408    let api_key = api_key.trim();
409
410    let model_id = &request.model.model_id;
411
412    let uri = format!("{api_url}/v1/models/{model_id}:streamGenerateContent?alt=sse");
413
414    let request_builder = HttpRequest::builder()
415        .method(Method::POST)
416        .uri(uri)
417        .header("Content-Type", "application/json")
418        .header("Authorization", format!("Bearer {api_key}"));
419
420    let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
421    let mut response = client.send(request).await?;
422    if response.status().is_success() {
423        let reader = BufReader::new(response.into_body());
424        Ok(reader
425            .lines()
426            .filter_map(|line| async move {
427                match line {
428                    Ok(line) => {
429                        if let Some(line) = line.strip_prefix("data: ") {
430                            match serde_json::from_str(line) {
431                                Ok(response) => Some(Ok(response)),
432                                Err(error) => {
433                                    Some(Err(anyhow!("Error parsing JSON: {error:?}\n{line:?}")))
434                                }
435                            }
436                        } else {
437                            None
438                        }
439                    }
440                    Err(error) => Some(Err(anyhow!(error))),
441                }
442            })
443            .boxed())
444    } else {
445        let mut text = String::new();
446        response.body_mut().read_to_string(&mut text).await?;
447        Err(anyhow!(
448            "error during streamGenerateContent via OpenCode Zen, status code: {:?}, body: {}",
449            response.status(),
450            text
451        ))
452    }
453}