use std::collections::BTreeMap;
use serde_json::json;
use wiremock::ResponseTemplate;
use super::support::{CONTENT, GOOD_JSON, SYSTEM, server_returning_json};
use crate::config::LlmConfig;
use crate::llm::cache::{Cache, CacheKey};
use crate::llm::chain::ProviderChain;
use crate::llm::json_parsing::Extracted;
use crate::test_support::{
cfg_for, fast_retry_chain, mount_sse, request_count, server_failing_with, sse, temp_cache,
};
fn key_config() -> LlmConfig {
LlmConfig {
enabled: true,
endpoint: Some("http://example.invalid/v1".into()),
model: Some("same-model".into()),
..LlmConfig::default()
}
}
fn provider_key(config: &LlmConfig) -> CacheKey {
let cache = Cache::new("unused-key-cache".into(), 30, 1024);
let chain = ProviderChain::new(&[config]).expect("valid provider config");
chain.providers()[0].cache_key(&cache, SYSTEM, CONTENT)
}
#[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 head = server_failing_with(500).await;
let tail = server_returning_json().await;
let configs = [cfg_for(&head, "model-a", 1), cfg_for(&tail, "model-b", 1)];
{
let chain = fast_retry_chain(&configs);
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");
}
head.reset().await;
mount_sse(
&head,
ResponseTemplate::new(200).set_body_raw(sse(&[GOOD_JSON]), "text/event-stream"),
)
.await;
let chain = fast_retry_chain(&configs);
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(&head).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"
);
}
#[test]
fn two_providers_differing_only_in_temperature_key_differently() {
let mut cool = key_config();
cool.temperature = Some(0.0);
let mut warm = key_config();
warm.temperature = Some(1.0);
assert_ne!(
provider_key(&cool),
provider_key(&warm),
"temperature changes the answer, so it must change the key"
);
}
#[test]
fn two_providers_differing_only_in_max_tokens_key_differently() {
let mut unset = key_config();
unset.max_tokens = None;
let mut short = key_config();
short.max_tokens = Some(1_024);
let mut long = key_config();
long.max_tokens = Some(32_768);
let keys = [&unset, &short, &long].map(provider_key);
assert!(
keys[0] != keys[1] && keys[0] != keys[2] && keys[1] != keys[2],
"unset, low and high ceilings are three requests: {keys:?}"
);
}
#[test]
fn two_providers_differing_only_in_a_header_value_key_differently() {
let mut blue = key_config();
blue.headers = BTreeMap::from([("X-Model-Route".to_owned(), "blue".to_owned())]);
let mut green = key_config();
green.headers = BTreeMap::from([("X-Model-Route".to_owned(), "green".to_owned())]);
assert_ne!(
provider_key(&blue),
provider_key(&green),
"a different route must not reuse the previous route's answer"
);
}
#[test]
fn equivalent_header_name_casing_has_one_cache_identity() {
let mut title_case = key_config();
title_case.headers = BTreeMap::from([("X-Model-Route".to_owned(), "blue".to_owned())]);
let mut lower_case = key_config();
lower_case.headers = BTreeMap::from([("x-model-route".to_owned(), "blue".to_owned())]);
assert_eq!(
provider_key(&title_case),
provider_key(&lower_case),
"header spelling does not change the HTTP request"
);
}
#[test]
fn two_providers_differing_only_in_protocol_key_differently() {
let openai = key_config();
let mut anthropic = key_config();
anthropic.protocol = Some("anthropic".into());
assert_ne!(
provider_key(&openai),
provider_key(&anthropic),
"two protocols are two requests, so they must be two keys"
);
}
#[test]
fn an_unset_temperature_keys_differently_through_the_provider() {
let mut unset = key_config();
unset.temperature = None;
let mut set = key_config();
set.temperature = Some(0.2);
assert_ne!(
provider_key(&unset),
provider_key(&set),
"omitting the parameter lets the server pick, so the answers differ"
);
}