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}