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