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;
18/// 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/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
49/// Models currently marked experimental by project policy.
50/// Delivery conformance below threshold - selectable, never the default.
51pub const EXPERIMENTAL_MODELS: &[&str] = &["@cf/zai-org/glm-5.3-flash", "@cf/zai-org/glm-5.3"];
52
53/// Models hidden from the primary agent picker (safety/classification).
54pub const HIDDEN_MODELS: &[&str] = &["@cf/meta/llama-guard-3-8b"];
55
56/// Versioned snapshot of the whole catalog - the JSON contract envelope.
57#[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/// Where the snapshot data came from.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum CatalogSource {
72	/// Fresh data from Cloudflare's `/ai/models/search` endpoint.
73	Live,
74	/// Served from the local atomic cache (stale fallback).
75	Cache,
76	/// Bundled static fallback list (no network, no cache).
77	Fallback,
78}
79
80/// Filters applied when the snapshot was produced.
81#[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/// Role of a Workers AI model in the provider catalog.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ModelRole {
92	/// Suitable as the primary coding/tool agent model.
93	CodingAgent,
94	/// General chat/reasoning, weaker for tool loops.
95	GeneralChat,
96	/// Reasoning-heavy model.
97	Reasoning,
98	/// Vision-capable multimodal model.
99	Vision,
100	/// Safety/classification model - never a primary agent model.
101	Safety,
102	/// Non-chat modality (embedding, image, audio, video…).
103	UnsupportedPrimaryAgent,
104}
105
106/// Availability of a model for this account.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum Availability {
110	/// Cloudflare says this account can invoke it.
111	CloudflareHosted,
112	/// Third-party/gateway model routed through the same chat surface.
113	Routed,
114}
115
116/// Picker visibility policy for a model.
117#[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/// Capability verdict for one model feature.
130///
131/// `Unknown` is a real state, not a failure: the OpenRouter-format catalog
132/// omits `supported_parameters` for models whose docs confirm function
133/// calling. Never encode incomplete catalog metadata as `Unsupported`.
134#[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/// Capability matrix for a model - the picker's tool/vision/reasoning view.
143#[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/// Per-million-token pricing (normalized to USD).
170#[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/// Context/output limits.
179#[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/// Protocol facts for one model.
187#[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/// Where each capability/field fact came from (provenance).
196#[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/// Normalized Workers AI model record - one source of truth for the
206/// picker, the fallback list, and generated YAML.
207#[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
226/// Publishers/models the Cloudflare docs confirm for function calling.
227const 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
246/// Safety/classification models - filtered from the primary picker.
247const SAFETY_MARKERS: &[&str] = &["llama-guard-3-8b"];
248
249/// Reasoning-heavy models (DeepSeek-R1/QwQ families).
250const REASONING_MARKERS: &[&str] = &["r1", "qwq"];
251
252/// Non-chat model families - never selectable through `/chat/completions`.
253const 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
272/// Reasoning-confirmed models and the ``reasoning_effort`` enum each accepts.
273///
274/// Single source of truth for the Hermes integration (`models sync`): the
275/// catalog API marks reasoning only via role markers (r1/qwq), so the
276/// documented reasoning families are confirmed here. Cloudflare's per-model
277/// docs pin the OpenAI-compatible ``reasoning_effort`` enum to
278/// low|medium|high (deepseek-v4-flash-0731 verified); the other families
279/// share the same Workers AI chat-completions schema shape. The Python
280/// plugin's CLOUDFLARE_REASONING_EFFORTS constant mirrors this table.
281pub 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
292/// Hermes ``model_family`` values for the plugin's primary models (mirrors the
293/// plugin's PRIMARY_AGENT_MODELS / FALLBACK_MODELS sets). Unknown models yield
294/// no family (the sync record omits the field).
295pub 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
321/// Reasoning effort vocabulary for *id*, or an empty slice when the model is
322/// not a documented reasoning model (the sync record then omits ``reasoning``
323/// and ``reasoning_efforts`` entirely).
324pub 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
332/// Hermes ``model_family`` for *id*, or None.
333pub 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
340/// Vision-capable Cloudflare models (llama-3.2-11b-vision accepts image
341/// input). The `models sync` records carry ``supports_vision`` so Hermes'
342/// per-model catalog knows which Cloudflare models take images; the plugin's
343/// default_vision_model() returns this id for auxiliary vision calls.
344pub const VISION_CONFIRMED: &[&str] = &["@cf/meta/llama-3.2-11b-vision-instruct"];
345
346/// True when *id* is a vision-capable Cloudflare model.
347pub fn vision_confirmed_for(id: &str) -> bool {
348	VISION_CONFIRMED.contains(&id)
349}
350
351impl ModelRecord {
352	/// Normalize one OpenRouter-format catalog entry.
353	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		// Derive the docs URL from the last `@cf/<org>/<model>` path segment;
400		// non-`@cf/` (routed) and malformed ids yield `None` gracefully.
401		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(), // filled by the CLI from account credentials
421				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	/// True when the model may appear in the primary coding picker.
442	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	/// True when the model is the project's development default.
450	pub fn is_default(&self) -> bool {
451		self.id == FALLBACK_MODELS[0]
452	}
453
454	/// Epoch seconds → date (catalog added date).
455	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	/// Deserialize per-token string/number pricing → per-million floats.
473	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
495/// Normalize a full OpenRouter-format payload → picker model list.
496pub 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		// Routed (non-`@cf/`) ids carry no Workers AI docs URL.
626		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}