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