|
3 | 3 | use std::sync::Mutex; |
4 | 4 |
|
5 | 5 | use crate::ai_client::{ |
6 | | - build_chat_payload, chat_completions_url, extract_content, parse_json_object, AiClient, |
7 | | - AiClientConfig, AiClientError, |
| 6 | + build_chat_payload, chat_completions_url, extract_content, fallback_provider_ids, parse_json_object, |
| 7 | + AiClient, AiClientConfig, AiClientError, |
8 | 8 | }; |
| 9 | +use crate::ai_provider::{preset_templates, AiProviderStore}; |
9 | 10 | use crate::ai_settings::AiSettings; |
10 | 11 |
|
11 | 12 | /// env 操作互斥(防并行测试互相覆盖 SILICONFLOW_*——与 ai_cost_tests 同模式)。 |
@@ -160,3 +161,75 @@ fn chat_json_without_key_is_auth_error() { |
160 | 161 | other => panic!("期望 Auth 错误,实际 {:?}", other.map(|_| ())), |
161 | 162 | } |
162 | 163 | } |
| 164 | + |
| 165 | +// ---- v0.11.6 M1:Provider 化 + 降级链(Task 3 纯函数层)---- |
| 166 | + |
| 167 | +#[test] |
| 168 | +fn from_provider_builds_config_from_provider() { |
| 169 | + let mut p = preset_templates().remove(0); |
| 170 | + p.id = "p1".to_string(); |
| 171 | + let client = AiClient::from_provider(&p, Some("sk-p1".to_string())); |
| 172 | + assert_eq!(client.config.base_url, p.base_url); |
| 173 | + assert_eq!(client.config.model, p.default_model); |
| 174 | + assert_eq!(client.config.api_key, "sk-p1"); |
| 175 | +} |
| 176 | + |
| 177 | +#[test] |
| 178 | +fn from_settings_resolves_default_provider_when_set() { |
| 179 | + let mut store = AiProviderStore::default(); |
| 180 | + let mut p = preset_templates().remove(0); |
| 181 | + p.id = "prov-a".to_string(); |
| 182 | + p.default_model = "model-a".to_string(); |
| 183 | + store.providers.push(p); |
| 184 | + store.default_provider_id = Some("prov-a".to_string()); |
| 185 | + let client = AiClient::from_settings_with_store( |
| 186 | + &crate::ai_settings::AiSettings::default(), |
| 187 | + Some("sk-x".to_string()), |
| 188 | + &store, |
| 189 | + ); |
| 190 | + assert_eq!(client.config.model, "model-a"); |
| 191 | + assert_eq!(client.config.base_url, "https://api.siliconflow.cn/v1"); |
| 192 | +} |
| 193 | + |
| 194 | +#[test] |
| 195 | +fn from_settings_falls_back_to_legacy_fields_without_store() { |
| 196 | + // 测试隔离:宿主可能已设 SILICONFLOW_MODEL——锁内清除后走旧字段解析 |
| 197 | + with_env_locked(|| { |
| 198 | + let client = AiClient::from_settings_with_store( |
| 199 | + &crate::ai_settings::AiSettings::default(), |
| 200 | + Some("sk-x".to_string()), |
| 201 | + &AiProviderStore::default(), |
| 202 | + ); |
| 203 | + // 空 store → 回退旧字段解析(兼容迁移前状态) |
| 204 | + assert_eq!(client.config.model, crate::ai_settings::DEFAULT_AI_MODEL); |
| 205 | + }); |
| 206 | +} |
| 207 | + |
| 208 | +#[test] |
| 209 | +fn fallback_chain_skips_auth_balance_errors() { |
| 210 | + use crate::ai_client::AiClientError; |
| 211 | + assert!(AiClientError::Network("x".into()).is_fallbackable()); |
| 212 | + assert!(AiClientError::Server("x".into()).is_fallbackable()); |
| 213 | + assert!(AiClientError::Quota("x".into()).is_fallbackable()); |
| 214 | + assert!(!AiClientError::Auth("x".into()).is_fallbackable()); |
| 215 | + assert!(!AiClientError::Balance("x".into()).is_fallbackable()); |
| 216 | + assert!(!AiClientError::Parse("x".into()).is_fallbackable()); |
| 217 | +} |
| 218 | + |
| 219 | +#[test] |
| 220 | +fn fallback_chain_order_resolves_provider_sequence() { |
| 221 | + let mut store = AiProviderStore::default(); |
| 222 | + let mk = |id: &str| { |
| 223 | + let mut p = preset_templates().remove(0); |
| 224 | + p.id = id.to_string(); |
| 225 | + p |
| 226 | + }; |
| 227 | + store.providers.push(mk("a")); |
| 228 | + store.providers.push(mk("b")); |
| 229 | + store.providers.push(mk("c")); |
| 230 | + store.default_provider_id = Some("a".to_string()); |
| 231 | + let mut a = store.get("a").unwrap().clone(); |
| 232 | + a.fallback_order = vec!["b".to_string(), "ghost".to_string(), "c".to_string()]; |
| 233 | + let ids = fallback_provider_ids(&store, &a); |
| 234 | + assert_eq!(ids, vec!["a".to_string(), "b".to_string(), "c".to_string()]); |
| 235 | +} |
0 commit comments