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