1+ //! AI Provider 配置(v0.11.6 M1,多 AI 能力接入基础层)。
2+ //!
3+ //! @ai-context: BYOK 多 Provider:用户自带 Key 配置多个 OpenAI 兼容端点
4+ //! (SiliconFlow/DeepSeek/OpenRouter/Ollama 本地),任务按默认
5+ //! Provider + 降级链执行;充值远期留桩(命令空间 ai_quota_*
6+ //! 隔离,本层不带计费语义)。
7+ //! @ai-context: 存储 ai_providers.json(数据目录);沿用 ai_settings.json 的
8+ //! "缺失/损坏回退内置默认"加载模式;密钥不在此层(DPAPI
9+ //! per-provider,ai_credentials.rs)。
10+ //! @ai-context: 旧版(v0.11.5 及之前)单 Provider 配置(ai_settings.json 的
11+ //! base_url/model + 单密钥)首启自动迁移为 SiliconFlow Provider
12+ //! ——用户无感,4 处 AiClient::from_settings 调用点零改动。
13+
14+ use serde:: { Deserialize , Serialize } ;
15+
16+ /// Provider 类型(OpenAI 兼容协议覆盖绝大多数云端 + Ollama 本地)。
17+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Serialize , Deserialize ) ]
18+ #[ serde( rename_all = "kebab-case" ) ]
19+ pub enum ProviderKind {
20+ /// OpenAI 兼容端点(SiliconFlow/DeepSeek/OpenRouter/智谱/自定义…)
21+ OpenAiCompat ,
22+ /// Ollama 本地推理(OpenAI 兼容端点 /v1/chat/completions——无网降级路径)
23+ Ollama ,
24+ }
25+
26+ /// 单个 Provider 配置。
27+ #[ derive( Debug , Clone , PartialEq , Serialize , Deserialize ) ]
28+ #[ serde( rename_all = "camelCase" , default ) ]
29+ pub struct AiProviderConfig {
30+ /// 唯一 id(生成时 uuid/slug;迁移时固定 "legacy-siliconflow")
31+ pub id : String ,
32+ /// 显示名("SiliconFlow")
33+ pub name : String ,
34+ pub kind : ProviderKind ,
35+ /// OpenAI 兼容 base_url(Ollama 为 http://127.0.0.1:11434/v1)
36+ pub base_url : String ,
37+ /// 可用模型列表(设置页选择用)
38+ pub models : Vec < String > ,
39+ /// 该 Provider 默认模型
40+ pub default_model : String ,
41+ pub enabled : bool ,
42+ /// 降级链(本 Provider 失败后依次尝试的 Provider id)
43+ pub fallback_order : Vec < String > ,
44+ }
45+
46+ impl Default for AiProviderConfig {
47+ fn default ( ) -> Self {
48+ Self {
49+ id : String :: new ( ) ,
50+ name : String :: new ( ) ,
51+ kind : ProviderKind :: OpenAiCompat ,
52+ base_url : String :: new ( ) ,
53+ models : Vec :: new ( ) ,
54+ default_model : String :: new ( ) ,
55+ enabled : true ,
56+ fallback_order : Vec :: new ( ) ,
57+ }
58+ }
59+ }
60+
61+ impl AiProviderConfig {
62+ /// 配置校验(纯函数):id/name 非空、base_url http(s)、模型列表与默认模型非空。
63+ pub fn validate ( & self ) -> Result < ( ) , String > {
64+ if self . id . trim ( ) . is_empty ( ) {
65+ return Err ( "Provider id 不能为空" . to_string ( ) ) ;
66+ }
67+ if self . name . trim ( ) . is_empty ( ) {
68+ return Err ( "Provider 名称不能为空" . to_string ( ) ) ;
69+ }
70+ if !( self . base_url . starts_with ( "http://" ) || self . base_url . starts_with ( "https://" ) ) {
71+ return Err ( "端点必须是 http(s):// 开头的合法 URL" . to_string ( ) ) ;
72+ }
73+ if self . models . is_empty ( ) {
74+ return Err ( "模型列表不能为空" . to_string ( ) ) ;
75+ }
76+ if self . default_model . trim ( ) . is_empty ( ) {
77+ return Err ( "默认模型不能为空" . to_string ( ) ) ;
78+ }
79+ if !self . models . iter ( ) . any ( |m| m == & self . default_model ) {
80+ return Err ( "默认模型不在模型列表中" . to_string ( ) ) ;
81+ }
82+ Ok ( ( ) )
83+ }
84+ }
85+
86+ /// Provider 存储(内存态单点;持久化 ai_providers.json——同 ai_settings 模式)。
87+ #[ derive( Debug , Clone , Default , PartialEq , Serialize , Deserialize ) ]
88+ #[ serde( rename_all = "camelCase" , default ) ]
89+ pub struct AiProviderStore {
90+ pub providers : Vec < AiProviderConfig > ,
91+ /// 默认 Provider id(任务无显式指定时使用)
92+ pub default_provider_id : Option < String > ,
93+ }
94+
95+ impl AiProviderStore {
96+ pub fn from_json ( json : & str ) -> Result < Self , String > {
97+ serde_json:: from_str ( json) . map_err ( |e| format ! ( "ai_providers.json 解析失败: {}" , e) )
98+ }
99+
100+ pub fn to_json ( & self ) -> Result < String , String > {
101+ serde_json:: to_string_pretty ( self ) . map_err ( |e| format ! ( "ai_providers.json 序列化失败: {}" , e) )
102+ }
103+
104+ /// 从数据目录加载(缺失/损坏 → 内置默认——不阻断启动)。
105+ pub fn load ( path : & std:: path:: Path ) -> Self {
106+ match std:: fs:: read_to_string ( path) {
107+ Ok ( raw) => Self :: from_json ( & raw) . unwrap_or_else ( |e| {
108+ eprintln ! ( "[AiProvider] 配置加载失败,使用内置默认: {}" , e) ;
109+ Self :: default ( )
110+ } ) ,
111+ Err ( _) => Self :: default ( ) ,
112+ }
113+ }
114+
115+ pub fn save ( & self , path : & std:: path:: Path ) -> Result < ( ) , String > {
116+ let raw = self . to_json ( ) ?;
117+ std:: fs:: write ( path, raw) . map_err ( |e| format ! ( "ai_providers.json 写入失败: {}" , e) )
118+ }
119+
120+ /// 生效默认 Provider id:显式配置 > 第一个 enabled(幽灵 id 回退)。
121+ pub fn effective_default_id ( & self ) -> Option < String > {
122+ if let Some ( id) = & self . default_provider_id {
123+ if self . providers . iter ( ) . any ( |p| & p. id == id && p. enabled ) {
124+ return Some ( id. clone ( ) ) ;
125+ }
126+ }
127+ self . providers . iter ( ) . find ( |p| p. enabled ) . map ( |p| p. id . clone ( ) )
128+ }
129+
130+ pub fn get ( & self , id : & str ) -> Option < & AiProviderConfig > {
131+ self . providers . iter ( ) . find ( |p| p. id == id)
132+ }
133+
134+ pub fn get_mut ( & mut self , id : & str ) -> Option < & mut AiProviderConfig > {
135+ self . providers . iter_mut ( ) . find ( |p| p. id == id)
136+ }
137+ }
138+
139+ /// 内置预设模板(编译期捆绑;用户"添加 Provider"时从模板起步可自定义)。
140+ pub fn preset_templates ( ) -> Vec < AiProviderConfig > {
141+ let mk = |id : & str , name : & str , base_url : & str , models : & [ & str ] , default : & str | AiProviderConfig {
142+ id : id. to_string ( ) ,
143+ name : name. to_string ( ) ,
144+ kind : ProviderKind :: OpenAiCompat ,
145+ base_url : base_url. to_string ( ) ,
146+ models : models. iter ( ) . map ( |s| s. to_string ( ) ) . collect ( ) ,
147+ default_model : default. to_string ( ) ,
148+ enabled : true ,
149+ fallback_order : Vec :: new ( ) ,
150+ } ;
151+ vec ! [
152+ mk(
153+ "siliconflow" ,
154+ "SiliconFlow" ,
155+ "https://api.siliconflow.cn/v1" ,
156+ & [ "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B" , "deepseek-ai/DeepSeek-V3.2" ] ,
157+ "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B" ,
158+ ) ,
159+ mk(
160+ "deepseek" ,
161+ "DeepSeek" ,
162+ "https://api.deepseek.com/v1" ,
163+ & [ "deepseek-chat" , "deepseek-reasoner" ] ,
164+ "deepseek-chat" ,
165+ ) ,
166+ mk(
167+ "openrouter" ,
168+ "OpenRouter" ,
169+ "https://openrouter.ai/api/v1" ,
170+ & [ "openai/gpt-4o-mini" , "anthropic/claude-3.5-sonnet" , "deepseek/deepseek-chat" ] ,
171+ "openai/gpt-4o-mini" ,
172+ ) ,
173+ AiProviderConfig {
174+ id: "ollama" . to_string( ) ,
175+ name: "Ollama(本地)" . to_string( ) ,
176+ kind: ProviderKind :: Ollama ,
177+ base_url: "http://127.0.0.1:11434/v1" . to_string( ) ,
178+ models: vec![ "qwen2.5:7b" . to_string( ) ] ,
179+ default_model: "qwen2.5:7b" . to_string( ) ,
180+ enabled: false ,
181+ fallback_order: Vec :: new( ) ,
182+ } ,
183+ ]
184+ }
185+
186+ /// 旧配置迁移(首启):旧 ai_settings 的 base_url/model → SiliconFlow Provider。
187+ ///
188+ /// @ai-context: 迁移幂等——ai_providers.json 已存在时不触发(调用方判断);
189+ /// 密钥迁移在 command 层(旧凭据条目 → provider:<id> 新条目)。
190+ pub fn migrate_from_legacy ( legacy : & crate :: ai_settings:: AiSettings ) -> ( Vec < AiProviderConfig > , Option < String > ) {
191+ let mut p = AiProviderConfig {
192+ id : "legacy-siliconflow" . to_string ( ) ,
193+ name : "SiliconFlow" . to_string ( ) ,
194+ base_url : legacy. base_url . clone ( ) ,
195+ default_model : legacy. model . clone ( ) ,
196+ models : vec ! [ legacy. model. clone( ) ] ,
197+ ..Default :: default ( )
198+ } ;
199+ if p. validate ( ) . is_err ( ) {
200+ // 旧配置非法 → 回退预设 SiliconFlow(保可用不崩)
201+ p = preset_templates ( ) . remove ( 0 ) ;
202+ p. id = "legacy-siliconflow" . to_string ( ) ;
203+ }
204+ let default_id = p. id . clone ( ) ;
205+ ( vec ! [ p] , Some ( default_id) )
206+ }
207+
208+ /// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
209+ #[ cfg( test) ]
210+ #[ path = "ai_provider_tests.rs" ]
211+ mod tests;
0 commit comments