返回 CodeWhale
catalog_tests.rs
根目录 / crates / tui / src / client / catalog_tests.rs
1 //! Local HTTP regressions for complete, bounded provider catalog observations.
2
3 use super::tests::{
4 custom_mock_client_for_identity, mount_models_json, opencode_go_client_for,
5 openrouter_client_for,
6 };
7 use super::*;
8 use crate::config::{ProviderConfig, ProvidersConfig};
9 use tokio::io::{AsyncReadExt, AsyncWriteExt};
10 use tokio::net::{TcpListener, TcpStream};
11 use wiremock::matchers::{method, path};
12 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
13
14 const KEY: &str = "catalog-key-canary-7f092";
15 const CURSOR: &str = "cursor/second +?&=雪-canary";
16
17 fn anthropic_client(base_url: &str) -> CodewhaleClient {
18 let mut client = CodewhaleClient::new(&Config {
19 provider: Some("anthropic".into()),
20 providers: Some(ProvidersConfig {
21 anthropic: ProviderConfig {
22 api_key: Some(KEY.into()),
23 base_url: Some(base_url.into()),
24 http_headers: Some(HashMap::from([(
25 "x-private-fixture".into(),
26 "custom-header-canary".into(),
27 )])),
28 ..ProviderConfig::default()
29 },
30 ..ProvidersConfig::default()
31 }),
32 ..Config::default()
33 })
34 .expect("explicit local Anthropic fixture client");
35 client.retry.enabled = false;
36 client.retry.max_retries = 0;
37 client
38 }
39
40 async fn mount_page(server: &MockServer, cursor: Option<&str>, response: ResponseTemplate) {
41 let cursor = cursor.map(str::to_owned);
42 Mock::given(method("GET"))
43 .and(path("/v1/models"))
44 .and(move |request: &Request| {
45 request
46 .url
47 .query_pairs()
48 .find(|(key, _)| key == "after_id")
49 .map(|(_, value)| value.into_owned())
50 == cursor
51 })
52 .respond_with(response)
53 .mount(server)
54 .await;
55 }
56
57 fn page(data: Value, next: Option<&str>) -> ResponseTemplate {
58 ResponseTemplate::new(200).set_body_json(json!({
59 "data": data, "has_more": next.is_some(), "last_id": next,
60 }))
61 }
62
63 // This is an explicit fixture continuation contract, not a claim that any
64 // provider other than Anthropic supports after_id in production.
65 async fn collect_fixture(
66 server: &MockServer,
67 limits: ModelsFetchLimits,
68 ) -> Result<String, ModelsFetchError> {
69 let http = crate::tls::reqwest_client_builder()
70 .redirect(reqwest::redirect::Policy::none())
71 .build()
72 .unwrap();
73 collect_models_document(
74 reqwest::Url::parse(&format!("{}/v1/models", server.uri())).unwrap(),
75 Some("after_id"),
76 limits,
77 |url| {
78 let http = http.clone();
79 async move {
80 http.get(url)
81 .send()
82 .await
83 .map_err(|_| CatalogRefreshError::Network.into())
84 }
85 },
86 )
87 .await
88 .map(|(body, _)| body)
89 }
90
91 fn assert_invalid(result: Result<String, ModelsFetchError>) {
92 assert_eq!(
93 result
94 .expect_err("incomplete or invalid observation must fail")
95 .into_catalog(),
96 CatalogRefreshError::InvalidResponse
97 );
98 }
99
100 #[tokio::test]
101 async fn anthropic_after_id_completes_both_public_consumers_with_frozen_headers() {
102 let server = MockServer::start().await;
103 mount_page(
104 &server,
105 None,
106 page(
107 json!([
108 {"id":"z-model", "owned_by":"first-owner", "created":7},
109 {"id":"a-model"}
110 ]),
111 Some(CURSOR),
112 ),
113 )
114 .await;
115 mount_page(
116 &server,
117 Some(CURSOR),
118 page(
119 json!([
120 {"id":"middle-model", "owned_by":"second-owner", "created":9},
121 {"id":"z-model", "owned_by":"later-owner", "created":10}
122 ]),
123 None,
124 ),
125 )
126 .await;
127 let client = anthropic_client(&server.uri());
128
129 let listed = client.list_models().await.unwrap();
130 assert_eq!(
131 listed.iter().map(|row| row.id.as_str()).collect::<Vec<_>>(),
132 ["a-model", "middle-model", "z-model"]
133 );
134 assert_eq!(listed[1].owned_by.as_deref(), Some("second-owner"));
135 assert_eq!(listed[1].created, Some(9));
136 assert_eq!(listed[2].owned_by.as_deref(), Some("first-owner"));
137 let delta = client.fetch_catalog_delta().await.unwrap();
138 assert_eq!(delta.provider, "anthropic");
139 assert_eq!(
140 delta.base_url_fingerprint,
141 base_url_fingerprint(&server.uri())
142 );
143 assert_eq!(
144 delta
145 .offerings
146 .iter()
147 .map(|row| row.wire_model_id.as_str())
148 .collect::<Vec<_>>(),
149 ["a-model", "middle-model", "z-model"]
150 );
151 for row in &delta.offerings {
152 assert!(
153 matches!(&row.source, CatalogSource::Live { base_url_fingerprint, fetched_at }
154 if base_url_fingerprint == &delta.base_url_fingerprint && *fetched_at == delta.fetched_at)
155 );
156 }
157 let requests = server.received_requests().await.unwrap();
158 assert_eq!(requests.len(), 4);
159 for (index, request) in requests.iter().enumerate() {
160 assert_eq!(request.headers.get("x-api-key").unwrap(), KEY);
161 assert_eq!(
162 request.headers.get("anthropic-version").unwrap(),
163 "2023-06-01"
164 );
165 assert_eq!(
166 request.headers.get("x-private-fixture").unwrap(),
167 "custom-header-canary"
168 );
169 assert!(request.headers.get("authorization").is_none());
170 let pairs = request.url.query_pairs().collect::<Vec<_>>();
171 if index % 2 == 0 {
172 assert!(pairs.is_empty());
173 } else {
174 assert_eq!(pairs.len(), 1);
175 assert_eq!(pairs[0].0, "after_id");
176 assert_eq!(pairs[0].1, CURSOR);
177 }
178 }
179 }
180
181 #[tokio::test]
182 async fn unpaginated_rosters_stay_single_request_and_unknown_continuation_refuses() {
183 for go in [false, true] {
184 let server = MockServer::start().await;
185 let id = crate::config::opencode_go_models()[0];
186 mount_models_json(&server, 200, json!({"data":[{"id":id}]})).await;
187 let mut client = if go {
188 opencode_go_client_for(&server)
189 } else {
190 openrouter_client_for(&server)
191 };
192 client.retry.enabled = false;
193 client.retry.max_retries = 0;
194 assert_eq!(client.list_models().await.unwrap().len(), 1);
195 assert_eq!(
196 client.fetch_catalog_delta().await.unwrap().offerings.len(),
197 1
198 );
199 assert_eq!(server.received_requests().await.unwrap().len(), 2);
200 server.reset().await;
201 mount_models_json(
202 &server,
203 200,
204 json!({"data":[{"id":id}], "has_more":true, "last_id":"unsupported-next"}),
205 )
206 .await;
207 assert!(client.list_models().await.is_err());
208 assert_eq!(
209 client.fetch_catalog_delta().await.unwrap_err(),
210 CatalogRefreshError::InvalidResponse
211 );
212 let requests = server.received_requests().await.unwrap();
213 assert_eq!(
214 requests.len(),
215 2,
216 "unsupported contract must not speculate a second request"
217 );
218 assert!(requests.iter().all(|request| request.url.query().is_none()));
219 }
220 }
221
222 #[tokio::test]
223 async fn opencode_go_published_unpaginated_roster_keeps_documented_protocols() {
224 let server = MockServer::start().await;
225 let base_url = format!("{}/zen/go/v1", server.uri());
226 // Literal additions from the pinned Go documentation plus retained routes:
227 // do not generate this fixture from the production allowlist it verifies.
228 let positives = [
229 "glm-5.3-flash",
230 "glm-5.3",
231 "longcat-2.0",
232 "deepseek-v4-flash-vision-exp",
233 "hy4-preview",
234 "hy3",
235 "omen-alpha",
236 "deepseek-v4-pro",
237 "grok-4.5",
238 "qwen3.8-max",
239 "qwen3.8-flash",
240 "minimax-m3",
241 "grok-4.6",
242 "gpt-5.6-luna",
243 "muse-spark-1.3-contributor",
244 "muse-spark-1.2-contributor",
245 ];
246 let negatives = ["gpt-unlisted", "claude-unproven"];
247 let rows: Vec<_> = positives
248 .iter()
249 .chain(negatives.iter())
250 .map(
251 |id| json!({"id":id, "object":"model", "created":1_700_000_000, "owned_by":"opencode"}),
252 )
253 .collect();
254 Mock::given(method("GET"))
255 .and(path("/zen/go/v1/models"))
256 .respond_with(
257 ResponseTemplate::new(200).set_body_json(json!({"object":"list", "data":rows})),
258 )
259 .mount(&server)
260 .await;
261 let mut client = CodewhaleClient::new(&Config {
262 provider: Some("opencode-go".into()),
263 providers: Some(ProvidersConfig {
264 opencode_go: ProviderConfig {
265 api_key: Some(KEY.into()),
266 base_url: Some(base_url.clone()),
267 ..ProviderConfig::default()
268 },
269 ..ProvidersConfig::default()
270 }),
271 ..Config::default()
272 })
273 .expect("explicit local Go route");
274 client.retry.enabled = false;
275 client.retry.max_retries = 0;
276
277 let expected: std::collections::BTreeSet<_> = positives.into_iter().collect();
278 let listed = client.list_models().await.unwrap();
279 assert_eq!(
280 listed
281 .iter()
282 .map(|row| row.id.as_str())
283 .collect::<std::collections::BTreeSet<_>>(),
284 expected
285 );
286 assert_eq!(listed.len(), expected.len());
287 assert_eq!(server.received_requests().await.unwrap().len(), 1);
288 let delta = client.fetch_catalog_delta().await.unwrap();
289 assert_eq!(delta.provider, "opencode-go");
290 assert_eq!(delta.base_url_fingerprint, base_url_fingerprint(&base_url));
291 assert_eq!(
292 delta
293 .offerings
294 .iter()
295 .map(|row| row.wire_model_id.as_str())
296 .collect::<std::collections::BTreeSet<_>>(),
297 expected
298 );
299 assert_eq!(delta.offerings.len(), expected.len());
300 for row in &delta.offerings {
301 assert_eq!(row.provider, "opencode-go");
302 assert_eq!(
303 Some(row.endpoint_key.as_str()),
304 codewhale_config::opencode_go_endpoint_key(&row.wire_model_id)
305 );
306 assert_eq!(row.canonical_model, None);
307 assert_eq!(row.family, None);
308 assert_eq!(row.limit, None);
309 assert_eq!(row.cost, None);
310 assert_eq!(row.cost_source, None);
311 assert_eq!(row.modalities, None);
312 assert_eq!(row.attachment, None);
313 assert_eq!(row.reasoning, None);
314 assert_eq!(row.tool_call, None);
315 assert_eq!(row.structured_output, None);
316 assert!(row.reasoning_options.is_empty());
317 assert!(
318 matches!(&row.source, CatalogSource::Live { base_url_fingerprint, fetched_at }
319 if base_url_fingerprint == &delta.base_url_fingerprint && *fetched_at == delta.fetched_at)
320 );
321 }
322 let requests = server.received_requests().await.unwrap();
323 assert_eq!(
324 requests.len(),
325 2,
326 "one unpaginated request per public consumer"
327 );
328 for request in requests {
329 assert_eq!(request.url.path(), "/zen/go/v1/models");
330 assert!(request.url.query().is_none());
331 assert_eq!(
332 request.headers.get("authorization").unwrap(),
333 format!("Bearer {KEY}").as_str()
334 );
335 }
336 }
337
338 #[tokio::test]
339 async fn models_redirects_never_reach_another_server_from_any_consumer() {
340 let destination = MockServer::start().await;
341 for status in [302, 307] {
342 let server = MockServer::start().await;
343 Mock::given(method("GET"))
344 .and(path("/v1/models"))
345 .respond_with(
346 ResponseTemplate::new(status)
347 .insert_header("location", format!("{}/capture", destination.uri())),
348 )
349 .mount(&server)
350 .await;
351 assert!(anthropic_client(&server.uri()).list_models().await.is_err());
352 assert_eq!(
353 anthropic_client(&server.uri())
354 .fetch_catalog_delta()
355 .await
356 .unwrap_err(),
357 CatalogRefreshError::Network
358 );
359 assert!(
360 !anthropic_client(&server.uri())
361 .health_check()
362 .await
363 .unwrap()
364 );
365 let recovery = anthropic_client(&server.uri());
366 {
367 let mut health = recovery.connection_health.lock().await;
368 apply_request_failure(&mut health, Instant::now());
369 apply_request_failure(&mut health, Instant::now());
370 }
371 recovery.maybe_probe_recovery().await;
372 assert!(recovery.connection_health.lock().await.last_probe.is_some());
373 assert!(
374 verify_provider_api_key(ApiProvider::Anthropic, KEY, &server.uri())
375 .await
376 .is_err()
377 );
378 assert_eq!(server.received_requests().await.unwrap().len(), 5);
379 }
380 // Redirects after a valid page are refused by both traversal consumers too.
381 let server = MockServer::start().await;
382 mount_page(
383 &server,
384 None,
385 page(json!([{"id":"first-model"}]), Some(CURSOR)),
386 )
387 .await;
388 mount_page(
389 &server,
390 Some(CURSOR),
391 ResponseTemplate::new(308)
392 .insert_header("location", format!("{}/capture", destination.uri())),
393 )
394 .await;
395 assert!(anthropic_client(&server.uri()).list_models().await.is_err());
396 assert_eq!(
397 anthropic_client(&server.uri())
398 .fetch_catalog_delta()
399 .await
400 .unwrap_err(),
401 CatalogRefreshError::Network
402 );
403 assert_eq!(server.received_requests().await.unwrap().len(), 4);
404 assert!(
405 destination.received_requests().await.unwrap().is_empty(),
406 "no auth/header/cursor may reach a redirect target"
407 );
408 }
409
410 #[tokio::test]
411 async fn malformed_continuations_and_cursor_cycles_fail_without_partial_success() {
412 for body in [
413 json!({"data":[], "has_more":true}),
414 json!({"data":[], "has_more":true, "last_id":""}),
415 json!({"data":[], "has_more":true, "last_id":42}),
416 json!({"data":[], "has_more":"true", "last_id":"x"}),
417 json!({"data":{}, "has_more":false}),
418 json!({"data":[], "has_more":true, "last_id":"12345"}),
419 ] {
420 let server = MockServer::start().await;
421 mount_models_json(&server, 200, body).await;
422 assert_invalid(
423 collect_fixture(
424 &server,
425 ModelsFetchLimits {
426 cursor_bytes: 4,
427 ..MODELS_FETCH_LIMITS
428 },
429 )
430 .await,
431 );
432 assert_eq!(server.received_requests().await.unwrap().len(), 1);
433 }
434 for cycle in [false, true] {
435 let server = MockServer::start().await;
436 mount_page(&server, None, page(json!([{"id":"first"}]), Some("a"))).await;
437 mount_page(
438 &server,
439 Some("a"),
440 page(json!([]), Some(if cycle { "b" } else { "a" })),
441 )
442 .await;
443 if cycle {
444 mount_page(&server, Some("b"), page(json!([]), Some("a"))).await;
445 }
446 assert_invalid(collect_fixture(&server, MODELS_FETCH_LIMITS).await);
447 assert_eq!(
448 server.received_requests().await.unwrap().len(),
449 if cycle { 3 } else { 2 }
450 );
451 }
452 }
453
454 #[tokio::test]
455 async fn cumulative_raw_bytes_rows_and_pages_are_limits_not_truncation() {
456 let first = r#"{"data":[{"id":"same"},{"id":"same"}],"has_more":true,"last_id":"next"}"#;
457 let second = r#"{"data":[{"id":"same"}],"has_more":false}"#;
458 for limits in [
459 ModelsFetchLimits {
460 bytes: first.len() + second.len() - 1,
461 ..MODELS_FETCH_LIMITS
462 },
463 ModelsFetchLimits {
464 rows: 2,
465 ..MODELS_FETCH_LIMITS
466 },
467 ModelsFetchLimits {
468 pages: 1,
469 ..MODELS_FETCH_LIMITS
470 },
471 ] {
472 let server = MockServer::start().await;
473 mount_page(
474 &server,
475 None,
476 ResponseTemplate::new(200).set_body_raw(first, "application/json"),
477 )
478 .await;
479 mount_page(
480 &server,
481 Some("next"),
482 ResponseTemplate::new(200).set_body_raw(second, "application/json"),
483 )
484 .await;
485 assert_invalid(collect_fixture(&server, limits).await);
486 assert_eq!(
487 server.received_requests().await.unwrap().len(),
488 if limits.pages == 1 { 1 } else { 2 }
489 );
490 }
491 let server = MockServer::start().await;
492 mount_page(
493 &server,
494 None,
495 ResponseTemplate::new(200).set_body_raw(first, "application/json"),
496 )
497 .await;
498 mount_page(
499 &server,
500 Some("next"),
501 ResponseTemplate::new(200).set_body_raw(second, "application/json"),
502 )
503 .await;
504 let body = collect_fixture(
505 &server,
506 ModelsFetchLimits {
507 bytes: first.len() + second.len(),
508 rows: 3,
509 pages: 2,
510 ..MODELS_FETCH_LIMITS
511 },
512 )
513 .await
514 .unwrap();
515 assert_eq!(
516 parse_models_response(&body).unwrap().len(),
517 1,
518 "raw duplicates count toward limits before final deduplication"
519 );
520 }
521
522 async fn read_head(stream: &mut TcpStream) -> String {
523 let mut bytes = Vec::new();
524 let mut chunk = [0; 1024];
525 while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
526 let count = stream.read(&mut chunk).await.unwrap();
527 assert!(
528 count > 0 && bytes.len() + count <= 16_384,
529 "bounded fixture request header"
530 );
531 bytes.extend_from_slice(&chunk[..count]);
532 }
533 String::from_utf8(bytes).unwrap()
534 }
535
536 #[tokio::test]
537 async fn chunked_catalog_body_is_bounded_without_content_length() {
538 let body = r#"{"data":[{"id":"chunked-model"}]}"#;
539 for limit in [20, body.len()] {
540 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
541 let endpoint = format!("http://{}/v1/models", listener.local_addr().unwrap());
542 let (first, second) = body.split_at(12);
543 let response = format!(
544 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{:x}\r\n{first}\r\n{:x}\r\n{second}\r\n0\r\n\r\n",
545 first.len(),
546 second.len(),
547 );
548 let server = tokio::spawn(async move {
549 let (mut stream, _) = listener.accept().await.unwrap();
550 read_head(&mut stream).await;
551 stream.write_all(response.as_bytes()).await.unwrap();
552 });
553 let http = crate::tls::reqwest_client_builder().build().unwrap();
554 let result = collect_models_document(
555 reqwest::Url::parse(&endpoint).unwrap(),
556 None,
557 ModelsFetchLimits {
558 bytes: limit,
559 ..MODELS_FETCH_LIMITS
560 },
561 |url| {
562 let http = http.clone();
563 async move {
564 http.get(url)
565 .send()
566 .await
567 .map_err(|_| CatalogRefreshError::Network.into())
568 }
569 },
570 )
571 .await;
572 if limit < body.len() {
573 assert_eq!(
574 result.unwrap_err().into_catalog(),
575 CatalogRefreshError::InvalidResponse
576 );
577 } else {
578 let (collected, _) = result.expect("same valid chunked body fits exact byte budget");
579 assert_eq!(
580 parse_models_response(&collected).unwrap()[0].id,
581 "chunked-model"
582 );
583 }
584 server.await.unwrap();
585 }
586 }
587
588 #[tokio::test]
589 async fn traversal_deadline_bounds_pending_and_completed_later_pages() {
590 let server = MockServer::start().await;
591 mount_models_json(
592 &server,
593 200,
594 json!({"data":[{"id":"first"}], "has_more":true, "last_id":"next"}),
595 )
596 .await;
597 // Fetch the first fixture response before starting the short clock so host
598 // scheduling/connection setup cannot make this accidentally a page-one test.
599 let mut first = Some(
600 crate::tls::reqwest_client_builder()
601 .build()
602 .unwrap()
603 .get(format!("{}/v1/models", server.uri()))
604 .send()
605 .await
606 .unwrap(),
607 );
608 let mut calls = 0;
609 let result = collect_models_document(
610 reqwest::Url::parse(&format!("{}/v1/models", server.uri())).unwrap(),
611 Some("after_id"),
612 ModelsFetchLimits {
613 timeout: Duration::from_millis(100),
614 ..MODELS_FETCH_LIMITS
615 },
616 |_| {
617 calls += 1;
618 let response = first.take();
619 async move {
620 match response {
621 Some(response) => Ok(response),
622 None => std::future::pending().await,
623 }
624 }
625 },
626 )
627 .await;
628 assert_eq!(
629 result.unwrap_err().into_catalog(),
630 CatalogRefreshError::Network
631 );
632 assert_eq!(
633 calls, 2,
634 "the pending later page must be covered by the collector timeout"
635 );
636
637 // Immediate response bodies avoid a scheduler-dependent network margin.
638 // Each completed fetch takes less than the short budget; together they
639 // exceed it. The generous control proves the same pages are otherwise valid.
640 for timeout in [Duration::from_millis(400), Duration::from_secs(5)] {
641 let mut responses = [
642 r#"{"data":[{"id":"first"}],"has_more":true,"last_id":"next"}"#,
643 r#"{"data":[{"id":"second"}],"has_more":false}"#,
644 ]
645 .into_iter()
646 .map(|body| {
647 reqwest::Response::from(
648 axum::http::Response::builder()
649 .status(200)
650 .body(body)
651 .unwrap(),
652 )
653 });
654 let mut calls = 0;
655 let result = collect_models_document(
656 reqwest::Url::parse("http://127.0.0.1/v1/models").unwrap(),
657 Some("after_id"),
658 ModelsFetchLimits {
659 timeout,
660 ..MODELS_FETCH_LIMITS
661 },
662 |_| {
663 calls += 1;
664 let response = responses.next().expect("exactly two fixture pages");
665 async move {
666 // Deliberately ready after bounded synchronous work, like
667 // parsing, so correctness cannot rely only on timer polling.
668 std::thread::sleep(Duration::from_millis(250));
669 Ok(response)
670 }
671 },
672 )
673 .await;
674 assert_eq!(calls, 2);
675 if timeout == Duration::from_millis(400) {
676 assert_eq!(
677 result.unwrap_err().into_catalog(),
678 CatalogRefreshError::Network
679 );
680 } else {
681 let (body, _) = result.expect("complete pages fit the larger shared budget");
682 assert_eq!(parse_models_response(&body).unwrap().len(), 2);
683 }
684 }
685 }
686
687 #[tokio::test]
688 async fn raw_rows_keep_duplicate_known_fields_invalid_in_existing_parsers() {
689 for malformed in [
690 r#"{"id":"second","id":"replacement"}"#,
691 r#"{"id":"second","pricing":{"prompt":"0.000001","prompt":"0.000002"}}"#,
692 ] {
693 let server = MockServer::start().await;
694 mount_page(&server, None, page(json!([{"id":"first"}]), Some("next"))).await;
695 let later = format!(r#"{{"data":[{malformed}],"has_more":false}}"#);
696 mount_page(
697 &server,
698 Some("next"),
699 ResponseTemplate::new(200).set_body_raw(later, "application/json"),
700 )
701 .await;
702 let body = collect_fixture(&server, MODELS_FETCH_LIMITS).await.unwrap();
703 assert!(
704 body.contains(malformed),
705 "collector must retain original row fields"
706 );
707 assert_eq!(
708 parse_openrouter_models_response(&body).unwrap_err(),
709 CatalogRefreshError::InvalidResponse
710 );
711 assert_eq!(
712 parse_baseten_models_response(&body).unwrap_err(),
713 CatalogRefreshError::InvalidResponse
714 );
715 }
716 }
717
718 #[tokio::test]
719 async fn existing_provider_parsers_own_cross_page_duplicates_and_full_metadata() {
720 let server = MockServer::start().await;
721 mount_page(
722 &server,
723 None,
724 page(
725 json!([{
726 "id":"same/model", "context_length":32000,
727 "pricing":{"prompt":"0.000001", "completion":"0.000002"},
728 "codewhale":{"protocol":"anthropic-messages", "default":true}
729 }]),
730 Some("next"),
731 ),
732 )
733 .await;
734 mount_page(&server, Some("next"), page(json!([
735 {"id":"same/model", "context_length":99999, "pricing":{"prompt":"0.000009", "completion":"0.000009"}},
736 {"id":"later/model", "context_length":64000, "max_completion_tokens":8000,
737 "pricing":{"prompt":"0.000003", "completion":"0.000004", "input_cache_read":"0.0000003"},
738 "supported_features":["vision"], "supported_parameters":["tools", "reasoning"],
739 "architecture":{"input_modalities":["text","image"],"output_modalities":["text"]},
740 "codewhale":{"protocol":"chat-completions"}}
741 ]), None)).await;
742 let body = collect_fixture(&server, MODELS_FETCH_LIMITS).await.unwrap();
743 let openrouter = parse_openrouter_models_response(&body).unwrap();
744 assert_eq!(openrouter.len(), 2);
745 let first =
746 openrouter_to_catalog_offering(&openrouter[0], "openrouter", "fixture-fp", 42).unwrap();
747 assert_eq!(first.limit.unwrap().context, Some(32000));
748 assert_eq!(first.cost.unwrap().input, Some(1.0));
749 let later =
750 openrouter_to_catalog_offering(&openrouter[1], "openrouter", "fixture-fp", 42).unwrap();
751 assert_eq!(later.limit.unwrap().context, Some(64000));
752 assert_eq!(later.cost.unwrap().cache_read, Some(0.3));
753 assert_eq!(later.reasoning, Some(true));
754 assert_eq!(later.tool_call, Some(true));
755 assert_eq!(later.modalities.unwrap().input, ["text", "image"]);
756 assert_eq!(
757 parse_baseten_models_response(&body).unwrap_err(),
758 CatalogRefreshError::InvalidResponse
759 );
760 let codewhale =
761 codewhale_catalog_offerings_from_body(&body, "codewhale", "fixture-fp", 42).unwrap();
762 assert_eq!(codewhale.len(), 2);
763 assert_eq!(codewhale[0].endpoint_key, "messages");
764 assert!(codewhale[0].default_for_provider);
765 assert_eq!(codewhale[1].endpoint_key, "chat");
766
767 server.reset().await;
768 mount_page(
769 &server,
770 None,
771 page(json!([{"id":" same/model "}]), Some("next")),
772 )
773 .await;
774 mount_page(
775 &server,
776 Some("next"),
777 page(json!([{"id":"same/model"}]), None),
778 )
779 .await;
780 let body = collect_fixture(&server, MODELS_FETCH_LIMITS).await.unwrap();
781 assert_eq!(
782 parse_baseten_models_response(&body).unwrap_err(),
783 CatalogRefreshError::InvalidResponse,
784 "Baseten trims before duplicate rejection across pages"
785 );
786
787 server.reset().await;
788 mount_page(
789 &server,
790 None,
791 page(json!([{"id":"first/model"}]), Some("next")),
792 )
793 .await;
794 mount_page(&server, Some("next"), page(json!([{"id":"later/model", "context_length":"64000", "max_completion_tokens":8000,
795 "pricing":{"prompt":"0.000003", "completion":"0.000004"}, "supported_features":["vision"]}]), None)).await;
796 let body = collect_fixture(&server, MODELS_FETCH_LIMITS).await.unwrap();
797 let rows = parse_baseten_models_response(&body).unwrap();
798 let later = baseten_to_catalog_offering(&rows[1], "base-ten", "fixture-fp", 42).unwrap();
799 assert_eq!(later.provider, "base-ten");
800 assert_eq!(later.limit.unwrap().output, Some(8000));
801 assert_eq!(later.cost.unwrap().output, Some(4.0));
802 assert_eq!(later.attachment, Some(true));
803 }
804
805 #[tokio::test]
806 async fn later_page_failure_preserves_complete_same_scope_cache_and_observation_time() {
807 let server = MockServer::start().await;
808 mount_models_json(
809 &server,
810 200,
811 json!({"data":[{"id":"old-first"},{"id":"old-second"}]}),
812 )
813 .await;
814 let client = anthropic_client(&server.uri());
815 let mut cache = ProviderCatalogCache::new();
816 let mut original = client.fetch_catalog_delta().await.unwrap();
817 original.fetched_at = 17;
818 cache.record_success(original, 3600);
819 let fingerprint = base_url_fingerprint(&server.uri());
820 let before = cache.get("anthropic", &fingerprint).unwrap().clone();
821 for (response, expected) in [
822 (
823 ResponseTemplate::new(401),
824 CatalogRefreshError::Unauthorized,
825 ),
826 (ResponseTemplate::new(429), CatalogRefreshError::RateLimited),
827 (ResponseTemplate::new(500), CatalogRefreshError::Network),
828 (
829 ResponseTemplate::new(200).set_body_string("broken-json"),
830 CatalogRefreshError::InvalidResponse,
831 ),
832 ] {
833 server.reset().await;
834 mount_page(
835 &server,
836 None,
837 page(json!([{"id":"new-partial-only"}]), Some("next")),
838 )
839 .await;
840 mount_page(&server, Some("next"), response).await;
841 assert_eq!(
842 client.refresh_catalog_cache(&mut cache, 3600).await,
843 CatalogStatus::Failed { reason: expected }
844 );
845 let retained = cache.get("anthropic", &fingerprint).unwrap();
846 assert_eq!(retained.offerings, before.offerings);
847 assert_eq!(retained.fetched_at, 17);
848 assert_eq!(retained.ttl_secs, before.ttl_secs);
849 assert_eq!(server.received_requests().await.unwrap().len(), 2);
850 }
851 }
852
853 fn assert_no_canaries(error: &anyhow::Error) {
854 let surfaced = format!("{error:#} {error:?} {:?}", crate::retry_status::snapshot());
855 for canary in [KEY, CURSOR, "cursor%2Fsecond", "custom-header-canary"] {
856 assert!(
857 !surfaced.contains(canary),
858 "model error or retry state exposed a secret/cursor"
859 );
860 }
861 }
862
863 /// #6173: a geo-blocked key produced `Invalid request (400): ` — the colon
864 /// that introduces the provider's reason, with nothing after it, because the
865 /// catalog path discarded the body wholesale. A geo-block, a bad key and a
866 /// wrong endpoint were then indistinguishable, and the reporter had to change
867 /// VPN exits to find out which one it was. The reason is the provider's own
868 /// words; only this client's secrets have to go.
869 #[tokio::test]
870 async fn catalog_errors_surface_the_provider_reason_without_client_secrets() {
871 const REASON: &str = "User location is not supported for the API use.";
872
873 let server = MockServer::start().await;
874 mount_page(
875 &server,
876 None,
877 ResponseTemplate::new(400).set_body_json(json!({
878 "error": {"code": 400, "message": REASON, "status": "FAILED_PRECONDITION"}
879 })),
880 )
881 .await;
882 let client = anthropic_client(&server.uri());
883 let error = client.list_models().await.unwrap_err();
884 assert!(
885 format!("{error:#}").contains(REASON),
886 "the provider's reason must reach the user: {error:#}"
887 );
888 assert_no_canaries(&error);
889
890 // The same reason, from an endpoint that also echoes back things only
891 // this client could have sent it. The reason survives; they do not.
892 let echoing = MockServer::start().await;
893 mount_page(
894 &echoing,
895 None,
896 ResponseTemplate::new(400).set_body_json(json!({
897 "error": {"message": format!("{REASON} key={KEY} header=custom-header-canary")}
898 })),
899 )
900 .await;
901 let client = anthropic_client(&echoing.uri());
902 crate::retry_status::clear();
903 let error = client.list_models().await.unwrap_err();
904 assert!(format!("{error:#}").contains(REASON), "{error:#}");
905 assert_no_canaries(&error);
906 crate::retry_status::clear();
907 }
908
909 #[tokio::test]
910 async fn later_page_http_and_transport_errors_do_not_expose_cursor_or_key() {
911 for isolated in [false, true] {
912 let server = MockServer::start().await;
913 mount_page(
914 &server,
915 None,
916 page(json!([{"id":"first-model"}]), Some(CURSOR)),
917 )
918 .await;
919 mount_page(
920 &server,
921 Some(CURSOR),
922 ResponseTemplate::new(500).set_body_json(json!({
923 "error":{"message":format!("bad cursor {CURSOR}; key {KEY}; custom-header-canary")}
924 })),
925 )
926 .await;
927 let mut client = anthropic_client(&server.uri());
928 client.isolated_request_state = isolated;
929 client.retry.enabled = true;
930 client.retry.max_retries = 1;
931 client.retry.initial_delay = 0.001;
932 client.retry.max_delay = 0.001;
933 crate::retry_status::clear();
934 let error = client.list_models().await.unwrap_err();
935 assert_no_canaries(&error);
936 if !isolated {
937 assert!(crate::retry_status::snapshot().is_failed());
938 }
939 assert_eq!(server.received_requests().await.unwrap().len(), 3);
940 crate::retry_status::clear();
941
942 server.reset().await;
943 mount_page(
944 &server,
945 None,
946 page(json!([{"id":"first-model"}]), Some(CURSOR)),
947 )
948 .await;
949 mount_page(
950 &server,
951 Some(CURSOR),
952 page(
953 json!([{
954 "id":"second-model", "created":format!("{CURSOR} {KEY} custom-header-canary")
955 }]),
956 None,
957 ),
958 )
959 .await;
960 let error = client.list_models().await.unwrap_err();
961 assert_no_canaries(&error);
962 assert!(error.to_string().contains("InvalidResponse"));
963 assert_eq!(
964 client.fetch_catalog_delta().await.unwrap_err(),
965 CatalogRefreshError::InvalidResponse
966 );
967 assert_eq!(server.received_requests().await.unwrap().len(), 4);
968 }
969
970 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
971 let base_url = format!("http://{}", listener.local_addr().unwrap());
972 let heads = Arc::new(StdMutex::new(Vec::new()));
973 let recorded = heads.clone();
974 let server = tokio::spawn(async move {
975 loop {
976 let (mut stream, _) = listener.accept().await.unwrap();
977 let head = read_head(&mut stream).await;
978 let is_first = !head.lines().next().unwrap().contains("after_id=");
979 recorded.lock().unwrap().push(head);
980 if is_first {
981 let body =
982 json!({"data":[{"id":"first-model"}], "has_more":true, "last_id":CURSOR})
983 .to_string();
984 stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
985 }
986 // Page two closes before response headers, producing a transport
987 // error whose reqwest URL used to contain the opaque cursor.
988 }
989 });
990 let client = anthropic_client(&base_url);
991 let error = client.list_models().await.unwrap_err();
992 assert_no_canaries(&error);
993 assert!(
994 heads
995 .lock()
996 .unwrap()
997 .iter()
998 .any(|head| head.contains("after_id="))
999 );
1000 server.abort();
1001 let _ = server.await;
1002 crate::retry_status::clear();
1003 }
1004
1005 #[tokio::test]
1006 async fn unpaginated_custom_identities_keep_exact_identity_and_endpoint_ownership() {
1007 let first = MockServer::start().await;
1008 let second = MockServer::start().await;
1009 mount_models_json(&first, 200, json!({"data":[{"id":"first/model"}]})).await;
1010 mount_models_json(&second, 200, json!({"data":[{"id":"second/model"}]})).await;
1011 let mut cache = ProviderCatalogCache::new();
1012 for (identity, server, expected) in [
1013 ("base-ten", &first, "first/model"),
1014 ("Base-Ten", &first, "first/model"),
1015 ("base-ten", &second, "second/model"),
1016 ] {
1017 let client = custom_mock_client_for_identity(server, identity);
1018 let delta = client.fetch_catalog_delta().await.unwrap();
1019 assert_eq!(delta.provider, identity);
1020 assert_eq!(delta.offerings[0].provider, identity);
1021 assert_eq!(delta.offerings[0].wire_model_id, expected);
1022 assert_eq!(
1023 delta.base_url_fingerprint,
1024 base_url_fingerprint(&format!("{}/v1", server.uri()))
1025 );
1026 cache.record_success(delta, 3600);
1027 }
1028 assert_eq!(cache.entries.len(), 3);
1029 }
1030
1030 lines RUST