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}