1use chrono::{DateTime, NaiveDate, Utc};
12use serde::{Deserialize, Serialize};
13
14pub const FALLBACK_MODELS: &[&str] = &[
21 "@cf/deepseek-ai/deepseek-v4-flash-0731",
22 "@cf/moonshotai/kimi-k2.7-code",
23 "@cf/deepseek-ai/deepseek-v4-pro-0813",
24 "@cf/openai/gpt-oss-120b",
25 "@cf/meta/llama-3.2-3b-instruct",
26 "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
27 "@cf/meta/llama-3.1-8b-instruct-fp8",
28 "@cf/meta/llama-3.2-1b-instruct",
29 "@cf/moonshotai/kimi-k2.6",
30 "@cf/zai-org/glm-4.7-flash",
31 "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
32 "@cf/ibm-granite/granite-4.0-h-micro",
33 "@cf/qwen/qwen2.5-coder-32b-instruct",
34 "@cf/zai-org/glm-5.2",
35 "@cf/nvidia/nemotron-3-120b-a12b",
36 "@cf/aisingapore/gemma-sea-lion-v4-27b-it",
37 "@cf/qwen/qwen3-30b-a3b-fp8",
38 "@cf/google/gemma-4-26b-a4b-it",
39 "@cf/mistralai/mistral-small-3.1-24b-instruct",
40 "@cf/meta/llama-3.2-11b-vision-instruct",
41 "@cf/qwen/qwen3.8-27b",
42 "@cf/openai/gpt-oss-20b",
43 "@cf/meta/llama-4-scout-17b-16e-instruct",
44 "@cf/qwen/qwq-32b",
45 "@cf/zai-org/glm-5.3",
46 "@cf/zai-org/glm-5.3-flash",
47];
48
49pub const EXPERIMENTAL_MODELS: &[&str] = &["@cf/zai-org/glm-5.3-flash", "@cf/zai-org/glm-5.3"];
52
53pub const HIDDEN_MODELS: &[&str] = &["@cf/meta/llama-guard-3-8b"];
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub struct CatalogSnapshot {
60 pub schema_version: u32,
61 pub fetched_at: DateTime<Utc>,
62 pub source: CatalogSource,
63 pub account_fingerprint: String,
64 pub filters: CatalogFilters,
65 pub models: Vec<ModelRecord>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum CatalogSource {
72 Live,
74 Cache,
76 Fallback,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub struct CatalogFilters {
84 pub experimental_included: bool,
85 pub deprecated_included: bool,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ModelRole {
92 CodingAgent,
94 GeneralChat,
96 Reasoning,
98 Vision,
100 Safety,
102 UnsupportedPrimaryAgent,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum Availability {
110 CloudflareHosted,
112 Routed,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum Visibility {
120 Recommended,
121 Available,
122 Experimental,
123 Degraded,
124 Blocked,
125 Deprecated,
126 Hidden,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum CapabilityState {
137 Confirmed,
138 Unsupported,
139 Unknown,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub struct ModelCapabilities {
146 pub chat: CapabilityState,
147 pub tools: CapabilityState,
148 pub parallel_tools: CapabilityState,
149 pub structured_output: CapabilityState,
150 pub reasoning: CapabilityState,
151 pub vision_input: CapabilityState,
152 pub streaming: CapabilityState,
153}
154
155impl Default for ModelCapabilities {
156 fn default() -> Self {
157 Self {
158 chat: CapabilityState::Confirmed,
159 tools: CapabilityState::Unknown,
160 parallel_tools: CapabilityState::Unknown,
161 structured_output: CapabilityState::Unknown,
162 reasoning: CapabilityState::Unknown,
163 vision_input: CapabilityState::Unknown,
164 streaming: CapabilityState::Unknown,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub struct PricingPerMillion {
173 pub input: Option<f64>,
174 pub cached_input: Option<f64>,
175 pub output: Option<f64>,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub struct ModelLimits {
182 pub context_tokens: Option<u64>,
183 pub max_output_tokens: Option<u64>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub struct ModelProtocol {
190 pub api_mode: String,
191 pub base_url: String,
192 pub request_path: String,
193}
194
195#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub struct CapabilityProvenance {
199 pub pricing: String,
200 pub context_tokens: String,
201 pub tools: String,
202 pub reasoning: String,
203}
204
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct ModelRecord {
209 pub id: String,
210 pub display_name: String,
211 pub publisher: String,
212 pub role: ModelRole,
213 pub availability: Availability,
214 pub visibility: Visibility,
215 pub protocol: ModelProtocol,
216 pub pricing: PricingPerMillion,
217 pub limits: ModelLimits,
218 pub capabilities: ModelCapabilities,
219 pub provenance: CapabilityProvenance,
220 pub documentation_url: Option<String>,
221 pub catalog_added_at: Option<NaiveDate>,
222 #[serde(skip)]
223 pub raw: serde_json::Value,
224}
225
226const TOOL_CAPABLE_MARKERS: &[&str] = &[
228 "deepseek-v4-flash",
229 "deepseek-v4-pro",
230 "kimi-k2.7-code",
231 "kimi-k2.6",
232 "gpt-oss",
233 "glm-5.3-flash",
234 "glm-5.3",
235 "glm-5.2-flash",
236 "glm-5.2",
237 "qwen3-30b-a3b-fp8",
238 "qwen3.8-27b",
239 "nemotron-3-120b",
240 "mistral-small-3.1-24b",
241 "llama-3.3-70b-instruct-fp8-fast",
242 "llama-4-scout-17b-16e-instruct",
243 "granite-4.0-h-micro",
244];
245
246const SAFETY_MARKERS: &[&str] = &["llama-guard-3-8b"];
248
249const REASONING_MARKERS: &[&str] = &["r1", "qwq"];
251
252const NON_CHAT_MARKERS: &[&str] = &[
254 "embedding",
255 "rerank",
256 "flux",
257 "wan",
258 "eleven",
259 "universal-",
260 "whisper",
261 "tts",
262 "stable-",
263 "sdxl",
264 "qwen-image",
265 "seedance",
266 "ltx-",
267 "grok-imagine",
268 "qwen3-vl",
269 "kimi-k3",
270];
271
272pub const REASONING_EFFORTS_BY_MODEL: &[(&str, &[&str])] = &[
282 ("@cf/deepseek-ai/deepseek-v4-flash-0731", &["low", "medium", "high"]),
283 ("@cf/deepseek-ai/deepseek-v4-pro-0813", &["low", "medium", "high"]),
284 ("@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", &["low", "medium", "high"]),
285 ("@cf/moonshotai/kimi-k2.7-code", &["low", "medium", "high"]),
286 ("@cf/moonshotai/kimi-k2.6", &["low", "medium", "high"]),
287 ("@cf/zai-org/glm-5.3", &["low", "medium", "high"]),
288 ("@cf/zai-org/glm-5.3-flash", &["low", "medium", "high"]),
289 ("@cf/qwen/qwq-32b", &["low", "medium", "high"]),
290];
291
292pub const MODEL_FAMILIES_BY_ID: &[(&str, &str)] = &[
296 ("@cf/deepseek-ai/deepseek-v4-flash-0731", "deepseek-flash"),
297 ("@cf/deepseek-ai/deepseek-v4-pro-0813", "deepseek-flash"),
298 ("@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", "deepseek-r1"),
299 ("@cf/moonshotai/kimi-k2.7-code", "kimi-k2"),
300 ("@cf/moonshotai/kimi-k2.6", "kimi-k2"),
301 ("@cf/openai/gpt-oss-120b", "gpt-oss"),
302 ("@cf/openai/gpt-oss-20b", "gpt-oss"),
303 ("@cf/qwen/qwen3-30b-a3b-fp8", "qwen3"),
304 ("@cf/qwen/qwen3.8-27b", "qwen3"),
305 ("@cf/qwen/qwen2.5-coder-32b-instruct", "qwen-coder"),
306 ("@cf/qwen/qwq-32b", "qwq"),
307 ("@cf/zai-org/glm-5.3", "glm"),
308 ("@cf/zai-org/glm-5.3-flash", "glm"),
309 ("@cf/zai-org/glm-4.7-flash", "glm"),
310 ("@cf/meta/llama-4-scout-17b-16e-instruct", "llama"),
311 ("@cf/meta/llama-3.3-70b-instruct-fp8-fast", "llama"),
312 ("@cf/meta/llama-3.1-8b-instruct-fp8", "llama"),
313 ("@cf/meta/llama-3.2-1b-instruct", "llama"),
314 ("@cf/meta/llama-3.2-3b-instruct", "llama"),
315 ("@cf/meta/llama-3.2-11b-vision-instruct", "llama"),
316 ("@cf/mistralai/mistral-small-3.1-24b-instruct", "mistral"),
317 ("@cf/nvidia/nemotron-3-120b-a12b", "nemotron"),
318 ("@cf/ibm-granite/granite-4.0-h-micro", "granite"),
319];
320
321pub fn reasoning_efforts_for(id: &str) -> &'static [&'static str] {
325 REASONING_EFFORTS_BY_MODEL
326 .iter()
327 .find(|(mid, _)| *mid == id)
328 .map(|(_, efforts)| *efforts)
329 .unwrap_or(&[])
330}
331
332pub fn model_family_for(id: &str) -> Option<&'static str> {
334 MODEL_FAMILIES_BY_ID
335 .iter()
336 .find(|(mid, _)| *mid == id)
337 .map(|(_, family)| *family)
338}
339
340pub const VISION_CONFIRMED: &[&str] = &["@cf/meta/llama-3.2-11b-vision-instruct"];
345
346pub fn vision_confirmed_for(id: &str) -> bool {
348 VISION_CONFIRMED.contains(&id)
349}
350
351impl ModelRecord {
352 pub fn from_openrouter(item: &serde_json::Value) -> Option<Self> {
354 let id = item.get("id")?.as_str()?.to_string();
355 if id.is_empty() {
356 return None;
357 }
358 let lower = id.to_lowercase();
359
360 let role = if SAFETY_MARKERS.iter().any(|m| lower.contains(m)) {
361 ModelRole::Safety
362 } else if REASONING_MARKERS.iter().any(|m| lower.contains(m)) {
363 ModelRole::Reasoning
364 } else if NON_CHAT_MARKERS.iter().any(|m| lower.contains(m)) {
365 ModelRole::UnsupportedPrimaryAgent
366 } else {
367 ModelRole::CodingAgent
368 };
369
370 let capabilities = ModelCapabilities {
371 tools: if role == ModelRole::Safety {
372 CapabilityState::Unsupported
373 } else if TOOL_CAPABLE_MARKERS.iter().any(|m| lower.contains(m)) {
374 CapabilityState::Confirmed
375 } else {
376 CapabilityState::Unknown
377 },
378 ..ModelCapabilities::default()
379 };
380
381 let pricing = item.get("pricing").and_then(PricingPerMillion::parse);
382
383 let publisher = id
384 .strip_prefix("@cf/")
385 .and_then(|rest| rest.split('/').next())
386 .unwrap_or("cloudflare")
387 .to_string();
388
389 let role_based_visibility = if SAFETY_MARKERS.iter().any(|m| lower.contains(m)) {
390 Visibility::Hidden
391 } else if EXPERIMENTAL_MODELS.contains(&id.as_str()) {
392 Visibility::Experimental
393 } else if FALLBACK_MODELS.first().is_some_and(|m| *m == id.as_str()) {
394 Visibility::Recommended
395 } else {
396 Visibility::Available
397 };
398
399 let documentation_url = id
402 .strip_prefix("@cf/")
403 .and_then(|rest| rest.split('/').next_back())
404 .filter(|segment| !segment.is_empty())
405 .map(|segment| format!("https://developers.cloudflare.com/workers-ai/models/{segment}/"));
406
407 Some(Self {
408 display_name: item.get("name").and_then(|n| n.as_str()).unwrap_or(&id).to_string(),
409 publisher,
410 id: id.clone(),
411 role,
412 availability: if id.starts_with("@cf/") {
413 Availability::CloudflareHosted
414 } else {
415 Availability::Routed
416 },
417 visibility: role_based_visibility,
418 protocol: ModelProtocol {
419 api_mode: "chat_completions".to_string(),
420 base_url: String::new(), request_path: "/chat/completions".to_string(),
422 },
423 capabilities,
424 pricing: pricing.unwrap_or(PricingPerMillion { input: None, cached_input: None, output: None }),
425 limits: ModelLimits {
426 context_tokens: item.get("context_length").and_then(|c| c.as_u64()),
427 max_output_tokens: None,
428 },
429 provenance: CapabilityProvenance {
430 pricing: "cloudflare_catalog_api".to_string(),
431 context_tokens: "cloudflare_catalog_api".to_string(),
432 tools: "cloudflare_model_docs_or_schema".to_string(),
433 reasoning: "cloudflare_model_docs_or_schema".to_string(),
434 },
435 catalog_added_at: item.get("created").and_then(|c| c.as_i64()).and_then(Self::date_from_epoch),
436 documentation_url,
437 raw: item.clone(),
438 })
439 }
440
441 pub fn eligible_for_primary_picker(&self) -> bool {
443 self.role != ModelRole::Safety
444 && self.role != ModelRole::UnsupportedPrimaryAgent
445 && self.visibility != Visibility::Hidden
446 && self.id.starts_with("@cf/")
447 }
448
449 pub fn is_default(&self) -> bool {
451 self.id == FALLBACK_MODELS[0]
452 }
453
454 fn date_from_epoch(epoch: i64) -> Option<NaiveDate> {
456 let days = epoch.div_euclid(86_400);
457 let z = days + 719_468;
458 let era = z.div_euclid(146_097);
459 let doe = z.rem_euclid(146_097);
460 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
461 let year = yoe + era * 400;
462 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
463 let mp = (5 * doy + 2) / 153;
464 let day = doy - (153 * mp + 2) / 5 + 1;
465 let month = if mp < 10 { mp + 3 } else { mp - 9 };
466 let year = if month <= 2 { year + 1 } else { year };
467 NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32)
468 }
469}
470
471impl PricingPerMillion {
472 pub fn parse(value: &serde_json::Value) -> Option<Self> {
474 let as_f64 = |key: &str| -> Option<f64> {
475 match value.get(key) {
476 Some(serde_json::Value::Number(n)) => n.as_f64(),
477 Some(serde_json::Value::String(s)) => s.trim().parse::<f64>().ok(),
478 _ => None,
479 }
480 };
481 let per_token = as_f64("prompt").or_else(|| as_f64("input"));
482 let output = as_f64("completion").or_else(|| as_f64("output"));
483 let cached = as_f64("cached_input");
484 if per_token.is_none() && output.is_none() {
485 return None;
486 }
487 Some(Self {
488 input: per_token.map(|v| v * 1_000_000.0),
489 cached_input: cached.map(|v| v * 1_000_000.0),
490 output: output.map(|v| v * 1_000_000.0),
491 })
492 }
493}
494
495pub fn picker_models_from_openrouter(payload: &serde_json::Value) -> Vec<String> {
497 let Some(items) = payload.get("data").and_then(|d| d.as_array()) else {
498 return FALLBACK_MODELS.iter().map(|m| (*m).to_string()).collect();
499 };
500 let mut models: Vec<String> = items
501 .iter()
502 .filter_map(ModelRecord::from_openrouter)
503 .filter(|record| record.eligible_for_primary_picker())
504 .map(|record| record.id)
505 .collect();
506 if models.is_empty() {
507 return FALLBACK_MODELS.iter().map(|m| (*m).to_string()).collect();
508 }
509 models.sort();
510 models
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 fn catalog_entry(id: &str, context: u64, created: i64) -> serde_json::Value {
518 serde_json::json!({
519 "id": id,
520 "name": id,
521 "context_length": context,
522 "created": created,
523 "pricing": { "prompt": "0.00000015", "completion": "0.0000005" },
524 })
525 }
526
527 #[test]
528 fn normalizes_openrouter_entry() {
529 let entry = catalog_entry("@cf/deepseek-ai/deepseek-v4-flash-0731", 1_310_720, 1_788_800_000);
530 let record = ModelRecord::from_openrouter(&entry).expect("deepseek entry normalizes");
531 assert_eq!(record.publisher, "deepseek-ai");
532 assert_eq!(record.role, ModelRole::CodingAgent);
533 assert_eq!(record.limits.context_tokens, Some(1_310_720));
534 assert_eq!(record.capabilities.tools, CapabilityState::Confirmed);
535 assert!(record.eligible_for_primary_picker());
536 assert!(record.is_default());
537 }
538
539 #[test]
540 fn glm_flash_is_experimental() {
541 let entry = catalog_entry("@cf/zai-org/glm-5.3-flash", 1_310_720, 1_788_800_000);
542 let record = ModelRecord::from_openrouter(&entry).expect("glm entry normalizes");
543 assert_eq!(record.visibility, Visibility::Experimental);
544 assert!(!record.is_default());
545 }
546
547 #[test]
548 fn safety_model_is_hidden() {
549 let entry = catalog_entry("@cf/meta/llama-guard-3-8b", 131_072, 1_788_800_000);
550 let record = ModelRecord::from_openrouter(&entry).expect("guard entry normalizes");
551 assert_eq!(record.role, ModelRole::Safety);
552 assert_eq!(record.visibility, Visibility::Hidden);
553 assert_eq!(record.capabilities.tools, CapabilityState::Unsupported);
554 assert!(!record.eligible_for_primary_picker());
555 }
556
557 #[test]
558 fn unknown_capability_stays_unknown() {
559 let entry = catalog_entry("@cf/some-org/new-model", 32_768, 1_788_800_000);
560 let record = ModelRecord::from_openrouter(&entry).expect("new-model normalizes");
561 assert_eq!(record.capabilities.tools, CapabilityState::Unknown);
562 }
563
564 #[test]
565 fn non_chat_family_is_excluded() {
566 let entry = catalog_entry("@cf/baai/bge-m3-embedding", 8192, 1_788_800_000);
567 let record = ModelRecord::from_openrouter(&entry).expect("embedding normalizes");
568 assert_eq!(record.role, ModelRole::UnsupportedPrimaryAgent);
569 assert!(!record.eligible_for_primary_picker());
570 }
571
572 #[test]
573 fn picker_normalizes_payload() {
574 let payload = serde_json::json!({
575 "data": [
576 catalog_entry("@cf/deepseek-ai/deepseek-v4-flash-0731", 1_310_720, 1_788_800_000),
577 catalog_entry("@cf/meta/llama-guard-3-8b", 131_072, 1_788_800_000),
578 catalog_entry("@cf/baai/bge-m3-embedding", 8192, 1_788_800_000),
579 serde_json::json!({ "id": "@cf/third-party/gemini-3.8-flash" }),
580 ],
581 });
582 let models = picker_models_from_openrouter(&payload);
583 assert_eq!(
584 models,
585 vec!["@cf/deepseek-ai/deepseek-v4-flash-0731", "@cf/third-party/gemini-3.8-flash"]
586 );
587 }
588
589 #[test]
590 fn picker_falls_back_on_empty() {
591 let models = picker_models_from_openrouter(&serde_json::json!({ "data": [] }));
592 assert!(!models.is_empty());
593 assert_eq!(models[0], "@cf/deepseek-ai/deepseek-v4-flash-0731");
594 }
595
596 #[test]
597 fn pricing_per_token_rounds_to_million() {
598 let entry = catalog_entry("@cf/deepseek-ai/deepseek-v4-flash-0731", 1_310_720, 1_788_800_000);
599 let record = ModelRecord::from_openrouter(&entry).expect("record");
600 let pricing = record.pricing;
601 assert_eq!(pricing.input, Some(0.15));
602 assert_eq!(pricing.output, Some(0.5));
603 }
604
605 #[test]
606 fn fallback_first_model_is_default() {
607 assert_eq!(FALLBACK_MODELS[0], "@cf/deepseek-ai/deepseek-v4-flash-0731");
608 }
609
610 #[test]
611 fn date_from_epoch_converts() {
612 assert_eq!(ModelRecord::date_from_epoch(1_788_800_000), NaiveDate::from_ymd_opt(2026, 9, 7));
613 assert_eq!(ModelRecord::date_from_epoch(0), NaiveDate::from_ymd_opt(1970, 1, 1));
614 }
615
616 #[test]
617 fn documentation_url_derives_from_cf_id_segment() {
618 let entry = catalog_entry("@cf/deepseek-ai/deepseek-v4-flash-0731", 1_310_720, 1_788_800_000);
619 let record = ModelRecord::from_openrouter(&entry).expect("normalizes");
620 assert_eq!(
621 record.documentation_url.as_deref(),
622 Some("https://developers.cloudflare.com/workers-ai/models/deepseek-v4-flash-0731/")
623 );
624
625 let routed = catalog_entry("deepseek/deepseek-chat", 128_000, 1_788_800_000);
627 let record = ModelRecord::from_openrouter(&routed).expect("normalizes");
628 assert_eq!(record.documentation_url, None);
629 }
630}