Skip to content

Commit 44a2d83

Browse files
committed
feat(ai): AiClient 支持 Provider 配置与降级链序列(v0.11.6 M1)
1 parent ccd8063 commit 44a2d83

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

‎app/src-tauri/src/ai_client.rs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111
//! 网络调用不持全局锁——防 300s 超时阻塞其他 AI 命令)。
1212
//! @ai-context: 网络路径不单测(与 model_downloader 同口径);payload 构建/
1313
//! 响应提取/JSON 解析为纯函数可单测。
14+
//! @ai-context: v0.11.6 M1 多 Provider 化:from_provider(BYOK Provider 直接
15+
//! 构建)+ from_settings_with_store(默认 Provider 接管 base_url/
16+
//! model,无 Provider 配置回退旧字段——兼容迁移前状态)+
17+
//! is_fallbackable/fallback_provider_ids(降级链纯函数:
18+
//! Network/Server/Quota 瞬态可降级,Auth/Balance/Parse 归因
19+
//! 用户/响应不降级;实际 fallback 调用由任务层接线)。
1420
21+
use crate::ai_provider::{AiProviderConfig, AiProviderStore};
1522
use crate::ai_settings::AiSettings;
1623

1724
/// 单请求超时默认(秒;env SILICONFLOW_TIMEOUT_SECS 可覆盖)。
@@ -74,6 +81,17 @@ impl std::fmt::Display for AiClientError {
7481
}
7582
}
7683

84+
impl AiClientError {
85+
/// 是否可触发 Provider 降级(Network/Server/Quota 瞬态;Auth/Balance/
86+
/// Parse 归因用户或响应——不降级,错误原样上抛引导)。
87+
pub fn is_fallbackable(&self) -> bool {
88+
matches!(
89+
self,
90+
AiClientError::Network(_) | AiClientError::Server(_) | AiClientError::Quota(_)
91+
)
92+
}
93+
}
94+
7795
/// 共享 AI client(阻塞调用——command 层 spawn_blocking 包裹)。
7896
#[derive(Debug, Clone)]
7997
pub struct AiClient {
@@ -110,6 +128,34 @@ impl AiClient {
110128
Self { config }
111129
}
112130

131+
/// 从 Provider 配置构建(M1:BYOK 多 Provider 入口;密钥由 command 层
132+
/// 按 scope 解析注入——env 优先,否则 per-provider 凭据)。
133+
pub fn from_provider(provider: &AiProviderConfig, api_key: Option<String>) -> Self {
134+
Self::new(AiClientConfig {
135+
base_url: provider.base_url.clone(),
136+
api_key: api_key.unwrap_or_default(),
137+
model: provider.default_model.clone(),
138+
timeout_secs: env_parse("SILICONFLOW_TIMEOUT_SECS", DEFAULT_TIMEOUT_SECS),
139+
max_retries: env_parse("SILICONFLOW_RETRIES", DEFAULT_MAX_RETRIES as u64) as u32,
140+
max_tokens: env_parse(MAX_TOKENS_ENV, DEFAULT_MAX_TOKENS as u64) as u32,
141+
})
142+
}
143+
144+
/// 旧入口升级:从设置 + Provider 存储解析(默认 Provider 接管
145+
/// base_url/model;无 Provider 配置时回退旧字段——兼容迁移前状态)。
146+
pub fn from_settings_with_store(
147+
settings: &AiSettings,
148+
stored_key: Option<String>,
149+
store: &AiProviderStore,
150+
) -> Self {
151+
if let Some(id) = store.effective_default_id() {
152+
if let Some(p) = store.get(&id) {
153+
return Self::from_provider(p, stored_key);
154+
}
155+
}
156+
Self::from_settings(settings, stored_key)
157+
}
158+
113159
/// chat/completions 请求 → 原始 assistant 文本(未 parse;各适配器自行解析)。
114160
///
115161
/// @ai-context: 供需要类型级解析的适配器使用(ai_text_filter 的
@@ -265,6 +311,22 @@ fn env_parse(key: &str, default: u64) -> u64 {
265311
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
266312
}
267313

314+
/// 降级链 Provider id 序列(纯函数):默认 Provider + 其 fallback_order 中
315+
/// 存在且启用的 id(去重;M1 简单版:只解析顺序,实际 fallback 调用由
316+
/// 任务层接线——最多 fallback 一次)。
317+
pub fn fallback_provider_ids(store: &AiProviderStore, primary: &AiProviderConfig) -> Vec<String> {
318+
let mut ids = vec![primary.id.clone()];
319+
for id in &primary.fallback_order {
320+
if id != &primary.id
321+
&& store.get(id).map(|p| p.enabled).unwrap_or(false)
322+
&& !ids.contains(id)
323+
{
324+
ids.push(id.clone());
325+
}
326+
}
327+
ids
328+
}
329+
268330
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
269331
#[cfg(test)]
270332
#[path = "ai_client_tests.rs"]

‎app/src-tauri/src/ai_client_tests.rs‎

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
use std::sync::Mutex;
44

55
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,
88
};
9+
use crate::ai_provider::{preset_templates, AiProviderStore};
910
use crate::ai_settings::AiSettings;
1011

1112
/// env 操作互斥(防并行测试互相覆盖 SILICONFLOW_*——与 ai_cost_tests 同模式)。
@@ -160,3 +161,75 @@ fn chat_json_without_key_is_auth_error() {
160161
other => panic!("期望 Auth 错误,实际 {:?}", other.map(|_| ())),
161162
}
162163
}
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

Comments
 (0)