Skip to main content

auth_cloudflare/
catalog.rs

1//! Catalog - Workers AI model records and OpenRouter-format payload
2//! normalization.
3//!
4//! This module owns the typed catalog contract consumed by the CLI
5//! (`auth-cloudflare catalog get --format json`), the Hermes plugin, and the
6//! conformance harness. The three-state `CapabilityState` deliberately
7//! distinguishes *unknown* from *unsupported*: the OpenRouter-format catalog
8//! omits `supported_parameters` for models whose docs confirm function
9//! calling, and incomplete metadata must never be encoded as `Unsupported`.
10
11use chrono::{DateTime, NaiveDate, Utc};
12use serde::{Deserialize, Serialize};
13
14/// Curated fallback catalog - the account-verified 27 Cloudflare-hosted Workers AI chat
15/// models, filtered to a practical coding/tool set (minus `llama-guard-3-8b`).
16///
17/// ORDER IS POLICY: DeepSeek V4 Flash is the development default (feedback
18/// 01); GLM-5.3 Flash is experimental and intentionally NOT in the default
19/// position (delivery reliability not yet validated).
20pub 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
45/// Models currently marked experimental by project policy (feedback 01/02).
46/// Delivery conformance below threshold - selectable, never the default.
47pub const EXPERIMENTAL_MODELS: &[&str] = &["@cf/zai-org/glm-5.3-flash", "@cf/zai-org/glm-5.3"];
48
49/// Models hidden from the primary agent picker (safety/classification).
50pub const HIDDEN_MODELS: &[&str] = &["@cf/meta/llama-guard-3-8b"];
51
52/// Versioned snapshot of the whole catalog - the JSON contract envelope.
53#[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/// Where the snapshot data came from.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum CatalogSource {
68	/// Fresh data from Cloudflare's `/ai/models/search` endpoint.
69	Live,
70	/// Served from the local atomic cache (stale fallback).
71	Cache,
72	/// Bundled static fallback list (no network, no cache).
73	Fallback,
74}
75
76/// Filters applied when the snapshot was produced.
77#[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/// Role of a Workers AI model in the provider catalog.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ModelRole {
88	/// Suitable as the primary coding/tool agent model.
89	CodingAgent,
90	/// General chat/reasoning, weaker for tool loops.
91	GeneralChat,
92	/// Reasoning-heavy model.
93	Reasoning,
94	/// Vision-capable multimodal model.
95	Vision,
96	/// Safety/classification model - never a primary agent model.
97	Safety,
98	/// Non-chat modality (embedding, image, audio, video…).
99	UnsupportedPrimaryAgent,
100}
101
102/// Availability of a model for this account.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum Availability {
106	/// Cloudflare says this account can invoke it.
107	CloudflareHosted,
108	/// Third-party/gateway model routed through the same chat surface.
109	Routed,
110}
111
112/// Picker visibility policy for a model (feedback 01/02).
113#[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/// Capability verdict for one model feature.
126///
127/// `Unknown` is a real state, not a failure: the OpenRouter-format catalog
128/// omits `supported_parameters` for models whose docs confirm function
129/// calling. Never encode incomplete catalog metadata as `Unsupported`.
130#[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/// Capability matrix for a model - the picker's tool/vision/reasoning view.
139#[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/// Per-million-token pricing (normalized to USD, per feedback 01 contract).
166#[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/// Context/output limits.
175#[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/// Protocol facts for one model.
183#[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/// Where each capability/field fact came from (feedback 02 provenance).
192#[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/// Normalized Workers AI model record - one source of truth for the
202/// picker, the fallback list, and generated YAML.
203#[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
222/// Publishers/models the Cloudflare docs confirm for function calling.
223const 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
242/// Safety/classification models - filtered from the primary picker.
243const SAFETY_MARKERS: &[&str] = &["llama-guard-3-8b"];
244
245/// Reasoning-heavy models (DeepSeek-R1/QwQ families).
246const REASONING_MARKERS: &[&str] = &["r1", "qwq"];
247
248/// Non-chat model families - never selectable through `/chat/completions`.
249const 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	/// Normalize one OpenRouter-format catalog entry.
270	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		// Derive the docs URL from the last `@cf/<org>/<model>` path segment;
317		// non-`@cf/` (routed) and malformed ids yield `None` gracefully.
318		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(), // filled by the CLI from account credentials
338				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	/// True when the model may appear in the primary coding picker.
359	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	/// True when the model is the project's development default.
367	pub fn is_default(&self) -> bool {
368		self.id == FALLBACK_MODELS[0]
369	}
370
371	/// Epoch seconds → date (catalog added date).
372	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	/// Deserialize per-token string/number pricing → per-million floats.
390	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
412/// Normalize a full OpenRouter-format payload → picker model list.
413pub 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		// Routed (non-`@cf/`) ids carry no Workers AI docs URL.
543		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}