1use std::time::Duration;
31
32use crate::auth::{AuthProvider, TOKEN_ENV};
33use crate::config::SecretString;
34use crate::error::{AuthScope, CloudflareError};
35
36pub const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
38
39const MAX_CATALOG_PAGES: usize = 10;
42
43const EXTRA_QUERY: &str = "&hide_experimental=false&include_deprecated=false";
47
48pub fn catalog_url(account_id: &str) -> String {
53 format!("{}{EXTRA_QUERY}", AuthProvider::new(account_id).models_url())
54}
55
56pub fn auth_header(token: &SecretString) -> String {
59 format!("Bearer {}", token.as_ref())
60}
61
62pub fn fetch_catalog_from_api(
68 account_id: &str,
69 token: &SecretString,
70 timeout: Duration,
71) -> Result<serde_json::Value, CloudflareError> {
72 if token.as_ref().trim().is_empty() {
75 return Err(CloudflareError::MissingEnv {
76 env_var: TOKEN_ENV,
77 hint: "the API token is empty - export a scoped Workers AI token (Account → Workers AI → Write)"
78 .to_string(),
79 });
80 }
81
82 let agent = ureq::AgentBuilder::new().timeout(timeout).build();
83 let header = auth_header(token);
84 let base_url = catalog_url(account_id);
85
86 let first = fetch_page(&agent, &base_url, &header, token.as_ref())?;
88 collect_pages(first, |marker| {
89 let next_url = append_next_page(&base_url, marker);
90 fetch_page(&agent, &next_url, &header, token.as_ref())
91 })
92}
93
94fn fetch_page(agent: &ureq::Agent, url: &str, auth: &str, token: &str) -> Result<serde_json::Value, CloudflareError> {
97 let request = agent.get(url).set("Authorization", auth).set("Accept", "application/json");
98
99 let (status, response) = match request.call() {
102 Ok(response) => (response.status(), response),
103 Err(ureq::Error::Status(status, response)) => (status, response),
104 Err(transport) => {
105 let message = map_transport_error(&transport).to_string();
106 return Err(CloudflareError::Http(redact_token(&message, token)));
107 },
108 };
109
110 let retry_after = response.header("Retry-After").map(str::to_string);
111 let body = response
112 .into_string()
113 .map_err(|error| CloudflareError::Http(redact_token(&format!("read response body: {error}"), token)))?;
114 let body = redact_token(&body, token);
117 map_response(status, retry_after.as_deref(), &body)
118}
119
120fn collect_pages<F>(first: serde_json::Value, mut fetch_next: F) -> Result<serde_json::Value, CloudflareError>
124where
125 F: FnMut(&NextPageMarker) -> Result<serde_json::Value, CloudflareError>,
126{
127 let mut merged = first;
128 let mut pages = 1usize;
129 while pages < MAX_CATALOG_PAGES {
130 let Some(marker) = next_page_marker(&merged) else { break };
131 let next = fetch_next(&marker)?;
132 merged = merge_data_arrays(merged, next);
133 pages += 1;
134 }
135 Ok(merged)
136}
137
138fn merge_data_arrays(first: serde_json::Value, second: serde_json::Value) -> serde_json::Value {
141 let mut data: Vec<serde_json::Value> =
142 first.get("data").and_then(|data| data.as_array()).cloned().unwrap_or_default();
143 if let Some(second_data) = second.get("data").and_then(|data| data.as_array()) {
144 data.extend(second_data.iter().cloned());
145 }
146 let mut merged = second;
147 merged["data"] = serde_json::Value::Array(data);
148 merged
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153enum NextPageMarker {
154 Cursor(String),
156 Page(u64),
158}
159
160fn next_page_marker(payload: &serde_json::Value) -> Option<NextPageMarker> {
165 if let Some(result_info) = payload.get("result_info").filter(|info| info.is_object()) {
166 if let Some(cursor) = result_info.get("cursor").and_then(|cursor| cursor.as_str()) {
167 if !cursor.is_empty() {
168 return Some(NextPageMarker::Cursor(cursor.to_string()));
169 }
170 }
171 if let (Some(page), Some(total_pages)) = (
172 result_info.get("page").and_then(|page| page.as_u64()),
173 result_info.get("total_pages").and_then(|total| total.as_u64()),
174 ) {
175 if page < total_pages {
176 return Some(NextPageMarker::Page(page + 1));
177 }
178 }
179 }
180 if let Some(cursor) = payload.get("cursor").and_then(|cursor| cursor.as_str()) {
181 if !cursor.is_empty() {
182 return Some(NextPageMarker::Cursor(cursor.to_string()));
183 }
184 }
185 if let Some(next) = payload.get("next").and_then(|next| next.as_str()) {
186 if !next.is_empty() {
187 return Some(NextPageMarker::Cursor(next.to_string()));
188 }
189 }
190 None
191}
192
193fn append_next_page(base: &str, marker: &NextPageMarker) -> String {
195 match marker {
196 NextPageMarker::Cursor(cursor) => format!("{base}&cursor={cursor}"),
197 NextPageMarker::Page(page) => format!("{base}&page={page}"),
198 }
199}
200
201fn map_response(status: u16, retry_after: Option<&str>, body: &str) -> Result<serde_json::Value, CloudflareError> {
206 match status {
207 200 => parse_payload(body),
208 401 => Err(envelope_api_error(401, body)),
209 403 => Err(forbidden_error(body)),
210 429 => Err(rate_limit_error(retry_after, body)),
211 500..=599 => Err(CloudflareError::Http(format!(
212 "Cloudflare API returned HTTP {status} (transient server error)"
213 ))),
214 other => Err(envelope_api_error(other, body)),
215 }
216}
217
218fn parse_payload(body: &str) -> Result<serde_json::Value, CloudflareError> {
221 let value: serde_json::Value = serde_json::from_str(body).map_err(|_| CloudflareError::NoDataArray)?;
222 match value.get("data") {
223 Some(serde_json::Value::Array(_)) => Ok(value),
224 _ => Err(CloudflareError::NoDataArray),
225 }
226}
227
228fn envelope_parts(status: u16, body: &str) -> (u32, String) {
231 if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
232 if let Some(first) = value
233 .get("errors")
234 .and_then(|errors| errors.as_array())
235 .and_then(|errors| errors.first())
236 {
237 let code = first.get("code").and_then(|code| code.as_u64()).unwrap_or(u64::from(status)) as u32;
238 let message = first
239 .get("message")
240 .and_then(|message| message.as_str())
241 .unwrap_or("unknown Cloudflare error")
242 .to_string();
243 return (code, message);
244 }
245 }
246 (u32::from(status), format!("HTTP {status}"))
247}
248
249fn envelope_api_error(status: u16, body: &str) -> CloudflareError {
252 let (code, message) = envelope_parts(status, body);
253 CloudflareError::Api { code, message }
254}
255
256fn forbidden_error(body: &str) -> CloudflareError {
260 let (code, message) = envelope_parts(403, body);
261 CloudflareError::AuthRejected { kind: auth_scope_for(code, &message), code, message }
262}
263
264fn auth_scope_for(code: u32, message: &str) -> AuthScope {
269 let lower = message.to_lowercase();
270 match code {
271 9109 => return AuthScope::InvalidToken,
272 9103 => return AuthScope::WrongAccountScope,
273 10000 => {
274 if lower.contains("account") && (lower.contains("scope") || lower.contains("not found")) {
277 return AuthScope::WrongAccountScope;
278 }
279 return AuthScope::WorkersAiPermission;
280 },
281 _ => {},
282 }
283 if lower.contains("invalid")
284 && (lower.contains("token") || lower.contains("credential") || lower.contains("authorization"))
285 {
286 return AuthScope::InvalidToken;
287 }
288 if lower.contains("permission")
289 || lower.contains("insufficient")
290 || lower.contains("workers ai")
291 || lower.contains("workers-ai")
292 {
293 return AuthScope::WorkersAiPermission;
294 }
295 if lower.contains("account") {
296 return AuthScope::WrongAccountScope;
297 }
298 AuthScope::GenericAuth
299}
300
301fn rate_limit_error(retry_after: Option<&str>, body: &str) -> CloudflareError {
304 let retry_hint = retry_after
305 .map(|seconds| format!("; retry after ~{seconds}s"))
306 .unwrap_or_default();
307 let envelope_message = match serde_json::from_str::<serde_json::Value>(body) {
308 Ok(value) => value
309 .get("errors")
310 .and_then(|errors| errors.as_array())
311 .and_then(|errors| errors.first())
312 .and_then(|first| first.get("message").and_then(|message| message.as_str()))
313 .map(|message| format!(": {message}"))
314 .unwrap_or_default(),
315 Err(_) => String::new(),
316 };
317 CloudflareError::Api {
318 code: 429,
319 message: format!("rate limited (HTTP 429){retry_hint}{envelope_message}"),
320 }
321}
322
323fn map_transport_error(error: &ureq::Error) -> CloudflareError {
326 CloudflareError::Http(error.to_string())
327}
328
329fn redact_token(text: &str, token: &str) -> String {
332 if token.is_empty() {
333 text.to_string()
334 } else {
335 text.replace(token, "<redacted>")
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 const ACCOUNT: &str = "0123456789abcdef0123456789abcdef";
345 const TOKEN: &str = "cfut_test_synthetic_token_0001";
347
348 #[test]
349 fn catalog_url_is_exact_feedback_01_endpoint() {
350 assert_eq!(
351 catalog_url(ACCOUNT),
352 "https://api.cloudflare.com/client/v4/accounts/0123456789abcdef0123456789abcdef/ai/models/search?format=openrouter&per_page=1000&hide_experimental=false&include_deprecated=false"
353 );
354 }
355
356 #[test]
357 fn auth_header_is_bearer_with_token_value() {
358 let token = SecretString::new(TOKEN);
359 assert_eq!(auth_header(&token), format!("Bearer {TOKEN}"));
360 }
361
362 #[test]
363 fn empty_token_is_missing_env_before_any_network() {
364 let token = SecretString::new(" ");
365 let error = fetch_catalog_from_api(ACCOUNT, &token, Duration::from_secs(1)).unwrap_err();
366 assert!(matches!(error, CloudflareError::MissingEnv { env_var: TOKEN_ENV, .. }));
367 assert!(!error.to_string().contains("Bearer"));
369 }
370
371 #[test]
372 fn ok_payload_with_data_array_is_returned() {
373 let body = r#"{"data":[{"id":"@cf/deepseek-ai/deepseek-v4-flash-0731"}]}"#;
374 let value = map_response(200, None, body).expect("valid payload parses");
375 assert_eq!(value["data"][0]["id"], "@cf/deepseek-ai/deepseek-v4-flash-0731");
376 }
377
378 #[test]
379 fn malformed_json_and_missing_data_map_to_no_data_array() {
380 assert!(matches!(
381 map_response(200, None, "not json at all"),
382 Err(CloudflareError::NoDataArray)
383 ));
384 assert!(matches!(map_response(200, None, "{}"), Err(CloudflareError::NoDataArray)));
385 assert!(
386 matches!(
387 map_response(200, None, r#"{"success":true,"result":[]}"#),
388 Err(CloudflareError::NoDataArray)
389 ),
390 "a native-envelope payload without a top-level data array is NoDataArray"
391 );
392 }
393
394 #[test]
395 fn unauthorized_maps_to_api_with_envelope_code() {
396 let body = r#"{"success":false,"errors":[{"code":9109,"message":"Invalid access token"}]}"#;
397 match map_response(401, None, body) {
398 Err(CloudflareError::Api { code, message }) => {
399 assert_eq!(code, 9109);
400 assert!(message.contains("Invalid access token"));
401 },
402 other => panic!("expected Api error, got {other:?}"),
403 }
404 }
405
406 #[test]
407 fn rate_limit_maps_to_api_429_with_retry_after_hint() {
408 let body = r#"{"success":false,"errors":[{"code":10000,"message":"Too many requests"}]}"#;
409 match map_response(429, Some("120"), body) {
410 Err(CloudflareError::Api { code, message }) => {
411 assert_eq!(code, 429, "rate limit always carries Api code 429");
412 assert!(message.to_lowercase().contains("rate"));
413 assert!(message.contains("120"), "Retry-After hint must be in the message: {message}");
414 assert!(
415 message.contains("Too many requests"),
416 "envelope message must survive: {message}"
417 );
418 },
419 other => panic!("expected Api 429, got {other:?}"),
420 }
421 }
422
423 #[test]
424 fn server_errors_map_to_transient_http() {
425 for status in [500u16, 502, 503, 504] {
426 assert!(matches!(map_response(status, None, "boom"), Err(CloudflareError::Http(_))));
427 }
428 }
429
430 #[test]
431 fn unknown_status_maps_to_api_with_status_code() {
432 match map_response(400, None, "not an envelope") {
433 Err(CloudflareError::Api { code, message }) => {
434 assert_eq!(code, 400);
435 assert!(message.contains("400"));
436 },
437 other => panic!("expected Api error, got {other:?}"),
438 }
439 }
440
441 #[test]
442 fn transport_failure_maps_to_http() {
443 let transport: ureq::Error =
445 std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused").into();
446 let error = map_transport_error(&transport);
447 assert!(matches!(error, CloudflareError::Http(_)));
448 assert!(error.to_string().contains("connection refused"));
449 }
450
451 #[test]
452 fn token_is_redacted_from_error_source_text() {
453 let scrubbed = redact_token(&format!("read response body: boom {TOKEN} boom"), TOKEN);
454 assert!(!scrubbed.contains(TOKEN), "token must be scrubbed: {scrubbed}");
455 assert!(scrubbed.contains("<redacted>"));
456 assert_eq!(redact_token("plain text", ""), "plain text");
458 }
459
460 #[test]
461 fn error_strings_never_carry_the_token() {
462 let body_with_token = format!(r#"{{"success":false,"errors":[{{"code":9109,"message":"{TOKEN}"}}]}}"#);
467 let scrubbed = redact_token(&body_with_token, TOKEN);
468 assert!(!scrubbed.contains(TOKEN), "body must be scrubbed before mapping: {scrubbed}");
469 assert!(scrubbed.contains("<redacted>"));
470 let errors = [
471 map_response(401, None, &scrubbed).unwrap_err(),
472 map_response(403, None, &scrubbed).unwrap_err(),
473 map_response(429, None, &scrubbed).unwrap_err(),
474 map_response(200, None, "not json").unwrap_err(),
475 CloudflareError::Http(redact_token(&format!("transport failure near {TOKEN}"), TOKEN)),
476 ];
477 for error in errors {
478 let rendered = error.to_string();
479 assert!(!rendered.contains(TOKEN), "error string leaked the token: {rendered}");
480 }
481 assert_eq!(auth_header(&SecretString::new(TOKEN)), format!("Bearer {TOKEN}"));
484 }
485
486 #[test]
491 fn collect_pages_single_page_without_marker_is_returned_unchanged() {
492 let first = serde_json::json!({ "data": [{"id": "only"}] });
493 let mut called = false;
494 let result = collect_pages(first, |_marker| {
495 called = true;
496 Err(CloudflareError::NoDataArray)
497 })
498 .expect("single page collects");
499 assert!(!called, "no follow-up page may be requested when there is no marker");
500 assert_eq!(result["data"][0]["id"], "only");
501 }
502
503 #[test]
504 fn collect_pages_merges_data_across_pages_until_no_marker() {
505 let first = serde_json::json!({
506 "data": [{"id": "m1"}],
507 "result_info": {"cursor": "page-2"}
508 });
509 let pages = [
510 serde_json::json!({"data": [{"id": "m2"}], "result_info": {"cursor": "page-3"}}),
511 serde_json::json!({"data": [{"id": "m3"}]}),
512 ];
513 let idx = std::cell::Cell::new(0usize);
514 let result = collect_pages(first, |_marker| {
515 let i = idx.get();
516 idx.set(i + 1);
517 Ok(pages[i].clone())
518 })
519 .expect("pages collect");
520 let ids: Vec<&str> = result["data"]
521 .as_array()
522 .unwrap()
523 .iter()
524 .map(|entry| entry["id"].as_str().unwrap())
525 .collect();
526 assert_eq!(ids, vec!["m1", "m2", "m3"]);
527 assert_eq!(idx.get(), 2, "two follow-up pages fetched");
528 }
529
530 #[test]
531 fn collect_pages_stops_at_the_safety_cap() {
532 let first = serde_json::json!({"data": [{"id": "m0"}], "result_info": {"cursor": "next"}});
533 let pages: Vec<serde_json::Value> = (1..=15)
535 .map(|n| serde_json::json!({"data": [{"id": format!("m{n}")}], "result_info": {"cursor": "next"}}))
536 .collect();
537 let idx = std::cell::Cell::new(0usize);
538 let result = collect_pages(first, |_marker| {
539 let i = idx.get();
540 idx.set(i + 1);
541 Ok(pages[i].clone())
542 })
543 .expect("pages collect to the cap");
544 let data = result["data"].as_array().unwrap();
545 assert_eq!(data.len(), MAX_CATALOG_PAGES, "the cap limits the merge to 10 pages");
546 assert_eq!(idx.get(), MAX_CATALOG_PAGES - 1, "nine follow-up pages fetched");
547 }
548
549 #[test]
550 fn next_page_marker_extracts_cursor_and_page() {
551 assert_eq!(
552 next_page_marker(&serde_json::json!({"result_info": {"cursor": "abc"}})),
553 Some(NextPageMarker::Cursor("abc".to_string()))
554 );
555 assert_eq!(
556 next_page_marker(&serde_json::json!({"result_info": {"page": 1, "total_pages": 3}})),
557 Some(NextPageMarker::Page(2))
558 );
559 assert_eq!(
560 next_page_marker(&serde_json::json!({"next": "token-xyz"})),
561 Some(NextPageMarker::Cursor("token-xyz".to_string()))
562 );
563 assert_eq!(
564 next_page_marker(&serde_json::json!({"cursor": "cur"})),
565 Some(NextPageMarker::Cursor("cur".to_string()))
566 );
567 assert_eq!(next_page_marker(&serde_json::json!({"data": [{"id": "m"}]})), None);
569 assert_eq!(next_page_marker(&serde_json::json!({"next": ""})), None);
571 assert_eq!(next_page_marker(&serde_json::json!({"result_info": {"cursor": ""}})), None);
572 assert_eq!(
574 next_page_marker(&serde_json::json!({"result_info": {"page": 3, "total_pages": 3}})),
575 None
576 );
577 }
578
579 #[test]
580 fn append_next_page_builds_cursor_and_page_query() {
581 let base = "https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?per_page=1000";
582 assert_eq!(
583 append_next_page(base, &NextPageMarker::Cursor("tok".to_string())),
584 "https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?per_page=1000&cursor=tok"
585 );
586 assert_eq!(
587 append_next_page(base, &NextPageMarker::Page(2)),
588 "https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?per_page=1000&page=2"
589 );
590 }
591
592 #[test]
597 fn forbidden_403_classifies_scope_by_envelope_code() {
598 let err = map_response(
600 403,
601 None,
602 r#"{"success":false,"errors":[{"code":9109,"message":"Invalid access token"}]}"#,
603 )
604 .unwrap_err();
605 match err {
606 CloudflareError::AuthRejected { kind, code, .. } => {
607 assert_eq!(kind, AuthScope::InvalidToken);
608 assert_eq!(code, 9109);
609 },
610 other => panic!("expected AuthRejected, got {other:?}"),
611 }
612
613 let err = map_response(
615 403,
616 None,
617 r#"{"success":false,"errors":[{"code":10000,"message":"insufficient permission"}]}"#,
618 )
619 .unwrap_err();
620 match err {
621 CloudflareError::AuthRejected { kind, code, .. } => {
622 assert_eq!(kind, AuthScope::WorkersAiPermission);
623 assert_eq!(code, 10000);
624 },
625 other => panic!("expected AuthRejected, got {other:?}"),
626 }
627
628 let err = map_response(
630 403,
631 None,
632 r#"{"success":false,"errors":[{"code":9103,"message":"Account not found"}]}"#,
633 )
634 .unwrap_err();
635 match err {
636 CloudflareError::AuthRejected { kind, code, .. } => {
637 assert_eq!(kind, AuthScope::WrongAccountScope);
638 assert_eq!(code, 9103);
639 },
640 other => panic!("expected AuthRejected, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn forbidden_403_without_known_code_is_generic_auth() {
646 let err =
647 map_response(403, None, r#"{"success":false,"errors":[{"code":9999,"message":"forbidden"}]}"#).unwrap_err();
648 match err {
649 CloudflareError::AuthRejected { kind, code, .. } => {
650 assert_eq!(kind, AuthScope::GenericAuth);
651 assert_eq!(code, 9999);
652 },
653 other => panic!("expected AuthRejected, got {other:?}"),
654 }
655 let err = map_response(403, None, "not json").unwrap_err();
657 match err {
658 CloudflareError::AuthRejected { kind, code, .. } => {
659 assert_eq!(kind, AuthScope::GenericAuth);
660 assert_eq!(code, 403);
661 },
662 other => panic!("expected AuthRejected, got {other:?}"),
663 }
664 }
665
666 #[test]
667 fn auth_scope_for_classifies_by_code_then_message() {
668 assert_eq!(auth_scope_for(9109, "Invalid access token"), AuthScope::InvalidToken);
669 assert_eq!(auth_scope_for(10000, "insufficient permission"), AuthScope::WorkersAiPermission);
670 assert_eq!(auth_scope_for(9103, "Account not found"), AuthScope::WrongAccountScope);
671 assert_eq!(
673 auth_scope_for(10000, "token is not scoped to this account"),
674 AuthScope::WrongAccountScope
675 );
676 assert_eq!(auth_scope_for(0, "invalid API token"), AuthScope::InvalidToken);
678 assert_eq!(
679 auth_scope_for(0, "requires the Workers AI permission"),
680 AuthScope::WorkersAiPermission
681 );
682 assert_eq!(
683 auth_scope_for(0, "token is not scoped to this account"),
684 AuthScope::WrongAccountScope
685 );
686 assert_eq!(auth_scope_for(0, "something else entirely"), AuthScope::GenericAuth);
687 }
688}