use std::collections::BTreeMap;
use serde_json::json;
use super::support::{CONTENT, GOOD_JSON, SYSTEM, server_returning_json};
use crate::llm::json_parsing::Extracted;
use crate::test_support::{
cfg_for, fast_retry_chain, request_count, server_failing_with, temp_cache,
};
#[tokio::test]
async fn the_returned_key_belongs_to_the_provider_that_answered() {
let dead = server_failing_with(500).await;
let healthy = server_returning_json().await;
let (cache, _dir) = temp_cache();
let head = cfg_for(&dead, "model-a", 1);
let tail = cfg_for(&healthy, "model-b", 1);
let chain = fast_retry_chain(&[head, tail]);
let served = chain
.complete_json(SYSTEM, CONTENT, &cache)
.await
.expect("provider 2 answers");
let key_a = chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT);
let key_b = chain.providers()[1].cache_key(&cache, SYSTEM, CONTENT);
assert_ne!(key_a, key_b, "the two models must key differently");
assert_eq!(
served.key, key_b,
"the answer must be filed under the key of the provider that gave it"
);
assert_ne!(
served.key, key_a,
"filing provider 2's answer under provider 1's key is the bug this test exists for"
);
}
#[tokio::test]
async fn a_later_run_with_the_head_restored_does_not_get_the_fallback_s_cached_answer() {
let (cache, _dir) = temp_cache();
{
let dead = server_failing_with(500).await;
let healthy = server_returning_json().await;
let chain = fast_retry_chain(&[
cfg_for(&dead, "model-a", 1),
cfg_for(&healthy, "model-b", 1),
]);
let served = chain
.complete_json(SYSTEM, CONTENT, &cache)
.await
.expect("provider 2 answers");
assert_eq!(served.provider, 1);
cache
.put(&served.key, &json!({"issues": [], "summary": "clean"}))
.expect("cache write");
}
let revived = server_returning_json().await;
let spare = server_returning_json().await;
let chain = fast_retry_chain(&[
cfg_for(&revived, "model-a", 1),
cfg_for(&spare, "model-b", 1),
]);
let served = chain
.complete_json(SYSTEM, CONTENT, &cache)
.await
.expect("the restored head answers");
assert_eq!(served.provider, 0, "the restored head serves the file");
assert!(
!served.from_cache,
"the head has no cached answer of its own - the stored entry was the fallback's"
);
assert_eq!(
request_count(&revived).await,
1,
"the restored head must actually be contacted"
);
}
#[tokio::test]
async fn a_cache_hit_on_the_head_asks_nobody() {
let head = server_returning_json().await;
let tail = server_returning_json().await;
let (cache, _dir) = temp_cache();
let chain = fast_retry_chain(&[cfg_for(&head, "model-a", 1), cfg_for(&tail, "model-b", 1)]);
let planted = serde_json::from_str::<serde_json::Value>(GOOD_JSON).expect("fixture parses");
cache
.put(
&chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT),
&planted,
)
.expect("cache write");
let served = chain
.complete_json(SYSTEM, CONTENT, &cache)
.await
.expect("the cache answers");
assert_eq!(served.provider, 0);
assert!(served.from_cache);
assert!(matches!(served.extracted, Extracted::Complete(_)));
assert_eq!(request_count(&head).await, 0);
assert_eq!(request_count(&tail).await, 0);
}
#[tokio::test]
async fn a_cache_hit_on_the_fallback_is_found_after_the_head_fails() {
let dead = server_failing_with(500).await;
let tail = server_returning_json().await;
let (cache, _dir) = temp_cache();
let chain = fast_retry_chain(&[cfg_for(&dead, "model-a", 1), cfg_for(&tail, "model-b", 1)]);
let planted = serde_json::from_str::<serde_json::Value>(GOOD_JSON).expect("fixture parses");
cache
.put(
&chain.providers()[1].cache_key(&cache, SYSTEM, CONTENT),
&planted,
)
.expect("cache write");
let served = chain
.complete_json(SYSTEM, CONTENT, &cache)
.await
.expect("the fallback's cache answers");
assert_eq!(served.provider, 1);
assert!(served.from_cache);
assert_eq!(
request_count(&tail).await,
0,
"the fallback's cached answer must be found before a request is made"
);
}
#[tokio::test]
async fn two_endpoints_serving_the_same_model_do_not_share_a_cache_entry() {
let (cache, _dir) = temp_cache();
const MODEL: &str = "qwen3-30b-a3b";
let dead = server_failing_with(500).await;
let healthy = server_returning_json().await;
let fallback_key = {
let chain = fast_retry_chain(&[cfg_for(&dead, MODEL, 1), cfg_for(&healthy, MODEL, 1)]);
let served = chain
.complete_json(SYSTEM, CONTENT, &cache)
.await
.expect("the fallback answers");
assert_eq!(served.provider, 1);
cache
.put(&served.key, &json!({"issues": [], "summary": "clean"}))
.expect("cache write");
served.key
};
let revived = server_returning_json().await;
let spare = server_returning_json().await;
assert_ne!(
revived.uri(),
healthy.uri(),
"the test needs two genuinely different endpoints to compare keys"
);
let chain = fast_retry_chain(&[cfg_for(&revived, MODEL, 1), cfg_for(&spare, MODEL, 1)]);
let served = chain
.complete_json(SYSTEM, CONTENT, &cache)
.await
.expect("the restored head answers");
assert_eq!(served.provider, 0, "the restored head serves the file");
assert_ne!(
served.key, fallback_key,
"two endpoints must key differently even when they name the same model"
);
assert!(
!served.from_cache,
"the head has no entry of its own - the stored one was the fallback's"
);
assert_eq!(
request_count(&revived).await,
1,
"the restored head must actually be contacted"
);
}
#[tokio::test]
async fn two_providers_differing_only_in_temperature_key_differently() {
let server = server_returning_json().await;
let (cache, _dir) = temp_cache();
let mut cool = cfg_for(&server, "same-model", 1);
cool.temperature = Some(0.0);
let mut warm = cfg_for(&server, "same-model", 1);
warm.temperature = Some(1.0);
let chain = fast_retry_chain(&[cool, warm]);
let key_cool = chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT);
let key_warm = chain.providers()[1].cache_key(&cache, SYSTEM, CONTENT);
assert_ne!(
key_cool, key_warm,
"temperature changes the answer, so it must change the key"
);
}
#[tokio::test]
async fn two_providers_differing_only_in_max_tokens_key_differently() {
let server = server_returning_json().await;
let (cache, _dir) = temp_cache();
let mut unset = cfg_for(&server, "same-model", 1);
unset.max_tokens = None;
let mut short = cfg_for(&server, "same-model", 1);
short.max_tokens = Some(1_024);
let mut long = cfg_for(&server, "same-model", 1);
long.max_tokens = Some(32_768);
let chain = fast_retry_chain(&[unset, short, long]);
let keys = chain
.providers()
.iter()
.map(|provider| provider.cache_key(&cache, SYSTEM, CONTENT))
.collect::<Vec<_>>();
assert!(
keys[0] != keys[1] && keys[0] != keys[2] && keys[1] != keys[2],
"unset, low and high ceilings are three requests: {keys:?}"
);
}
#[tokio::test]
async fn two_providers_differing_only_in_a_header_value_key_differently() {
let server = server_returning_json().await;
let (cache, _dir) = temp_cache();
let mut blue = cfg_for(&server, "same-model", 1);
blue.headers = BTreeMap::from([("X-Model-Route".to_owned(), "blue".to_owned())]);
let mut green = cfg_for(&server, "same-model", 1);
green.headers = BTreeMap::from([("X-Model-Route".to_owned(), "green".to_owned())]);
let chain = fast_retry_chain(&[blue, green]);
assert_ne!(
chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT),
chain.providers()[1].cache_key(&cache, SYSTEM, CONTENT),
"a different route must not reuse the previous route's answer"
);
}
#[tokio::test]
async fn equivalent_header_name_casing_has_one_cache_identity() {
let server = server_returning_json().await;
let (cache, _dir) = temp_cache();
let mut title_case = cfg_for(&server, "same-model", 1);
title_case.headers = BTreeMap::from([("X-Model-Route".to_owned(), "blue".to_owned())]);
let mut lower_case = cfg_for(&server, "same-model", 1);
lower_case.headers = BTreeMap::from([("x-model-route".to_owned(), "blue".to_owned())]);
let chain = fast_retry_chain(&[title_case, lower_case]);
assert_eq!(
chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT),
chain.providers()[1].cache_key(&cache, SYSTEM, CONTENT),
"header spelling does not change the HTTP request"
);
}
#[tokio::test]
async fn two_providers_differing_only_in_protocol_key_differently() {
let server = server_returning_json().await;
let (cache, _dir) = temp_cache();
let openai = cfg_for(&server, "same-model", 1);
let mut anthropic = cfg_for(&server, "same-model", 1);
anthropic.protocol = Some("anthropic".into());
let chain = fast_retry_chain(&[openai, anthropic]);
let key_openai = chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT);
let key_anthropic = chain.providers()[1].cache_key(&cache, SYSTEM, CONTENT);
assert_ne!(
key_openai, key_anthropic,
"two protocols are two requests, so they must be two keys"
);
}
#[tokio::test]
async fn an_unset_temperature_keys_differently_through_the_provider() {
let server = server_returning_json().await;
let (cache, _dir) = temp_cache();
let mut unset = cfg_for(&server, "same-model", 1);
unset.temperature = None;
let mut set = cfg_for(&server, "same-model", 1);
set.temperature = Some(0.2);
let chain = fast_retry_chain(&[unset, set]);
assert_ne!(
chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT),
chain.providers()[1].cache_key(&cache, SYSTEM, CONTENT),
"omitting the parameter lets the server pick, so the answers differ"
);
}