feat(config,auth): add lib/config lprovider section and login_2fa auth handler
zhenyi committed
Added lib/auth/handler/login_2fa.rs lib/auth/handler/login_2fa.rs +26 −0 View lib/auth/handler/login_2fa.rs
@@ -0,0 +1,26 @@
1use axum::extract::State;
2
3use crate::error::AuthError;
4use crate::features::AuthService;
5use crate::features::login::types::{LoginResp, TwoFactorLoginCmd};
6use export::data::{AppRequest, AppRequestData, AppResponseData};
7
8#[utoipa::path(
9 post,
10 path = "/auth/login/2fa",
11 tag = "auth",
12 request_body = AppRequestData<TwoFactorLoginCmd>,
13 responses(
14 (status = 200, description = "Second factor verified. Returns access/refresh tokens and a public user view.", body = AppResponseData<LoginResp>),
15 (status = 400, description = "Invalid or expired code.", body = super::openapi::ErrorBody),
16 (status = 401, description = "Invalid or expired challenge.", body = super::openapi::ErrorBody),
17 (status = 429, description = "Rate limited.", body = super::openapi::ErrorBody),
18 ),
19)]
20pub(crate) async fn handler(
21 State(svc): State<AuthService>,
22 cmd: AppRequest<TwoFactorLoginCmd>,
23) -> Result<AppResponseData<LoginResp>, AuthError> {
24 let resp = svc.login_2fa(cmd.into_inner()).await?;
25 Ok(AppResponseData::ok(resp))
26}
No newline at end of file
Added lib/config/src/lprovider.rs lib/config/src/lprovider.rs +395 −0 View lib/config/src/lprovider.rs
@@ -0,0 +1,395 @@
1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(default)]
7pub struct LproviderConfig {
8 pub enabled: bool,
9 pub bind: String,
10 pub port: u16,
11 pub admin_bind: String,
12 pub admin_port: u16,
13 pub max_context_tokens: usize,
14 pub max_output_tokens: usize,
15 pub request_timeout_ms: u64,
16 pub auth: LproviderAuthSettings,
17 pub redis: LproviderRedisSettings,
18 pub secret: LproviderSecretSettings,
19 pub pricing: LproviderPricingSettings,
20 pub models: Vec<LproviderModelConfig>,
21 pub upstreams: Vec<LproviderUpstream>,
22 pub low_balance_force_cost_points: i64,
23}
24
25impl Default for LproviderConfig {
26 fn default() -> Self {
27 Self {
28 enabled: true,
29 bind: "0.0.0.0".to_string(),
30 port: 8080,
31 admin_bind: "0.0.0.0".to_string(),
32 admin_port: 9443,
33 max_context_tokens: 200_000,
34 max_output_tokens: 16_384,
35 request_timeout_ms: 60_000,
36 auth: LproviderAuthSettings::default(),
37 redis: LproviderRedisSettings::default(),
38 secret: LproviderSecretSettings::default(),
39 pricing: LproviderPricingSettings::default(),
40 models: vec![LproviderModelConfig::default()],
41 upstreams: Vec::new(),
42 low_balance_force_cost_points: 1000,
43 }
44 }
45}
46
47impl LproviderConfig {
48 pub fn is_valid(&self) -> Result<(), String> {
49 if !self.enabled {
50 return Ok(());
51 }
52 if self.bind.trim().is_empty() || self.admin_bind.trim().is_empty() {
53 return Err("lprovider.bind / lprovider.admin_bind must not be empty".into());
54 }
55 if self.port == 0 || self.admin_port == 0 || self.port == self.admin_port {
56 return Err("lprovider.port / lprovider.admin_port must be non-zero and distinct".into());
57 }
58 if self.max_context_tokens == 0 || self.max_output_tokens == 0 {
59 return Err("lprovider.max_context_tokens / max_output_tokens must be > 0".into());
60 }
61 if self.request_timeout_ms == 0 {
62 return Err("lprovider.request_timeout_ms must be > 0".into());
63 }
64 self.auth.is_valid()?;
65 self.redis.is_valid()?;
66 self.pricing.is_valid()?;
67
68 let mut seen_codes = BTreeSet::new();
69 for upstream in &self.upstreams {
70 upstream.is_valid()?;
71 if !seen_codes.insert(upstream.code.as_str()) {
72 return Err(format!("lprovider.upstreams: duplicate code {:?}", upstream.code));
73 }
74 if upstream.enabled
75 && !self.pricing.rates.contains_key(&upstream.code)
76 {
77 return Err(format!(
78 "lprovider.pricing.rates must cover enabled upstream {:?}",
79 upstream.code
80 ));
81 }
82 }
83
84 let mut seen_aliases = BTreeSet::new();
85 for model in &self.models {
86 model.is_valid()?;
87 if !seen_aliases.insert(model.alias.as_str()) {
88 return Err(format!("lprovider.models: duplicate alias {:?}", model.alias));
89 }
90 }
91 Ok(())
92 }
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(default)]
97pub struct LproviderAuthSettings {
98 pub bearer_token: Option<String>,
99}
100
101impl LproviderAuthSettings {
102 pub fn is_valid(&self) -> Result<(), String> {
103 if let Some(token) = &self.bearer_token
104 && token.len() < 16
105 {
106 return Err("lprovider.auth.bearer_token must be at least 16 characters".into());
107 }
108 Ok(())
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct LproviderRedisSettings {
115 pub key_prefix: String,
116 pub session_affinity_ttl_seconds: i64,
117 pub min_prefix_tokens: usize,
118 pub prefix_token_budget: usize,
119 pub cooldown_seconds: i64,
120 pub bind_cleanup_interval_seconds: u64,
121}
122
123impl Default for LproviderRedisSettings {
124 fn default() -> Self {
125 Self {
126 key_prefix: "lprovider".to_string(),
127 session_affinity_ttl_seconds: 3600,
128 min_prefix_tokens: 64,
129 prefix_token_budget: 512,
130 cooldown_seconds: 30,
131 bind_cleanup_interval_seconds: 60,
132 }
133 }
134}
135
136impl LproviderRedisSettings {
137 pub fn is_valid(&self) -> Result<(), String> {
138 if self.key_prefix.trim().is_empty() {
139 return Err("lprovider.redis.key_prefix must not be empty".into());
140 }
141 if self.session_affinity_ttl_seconds <= 0 {
142 return Err("lprovider.redis.session_affinity_ttl_seconds must be > 0".into());
143 }
144 if self.cooldown_seconds <= 0 {
145 return Err("lprovider.redis.cooldown_seconds must be > 0".into());
146 }
147 if self.min_prefix_tokens == 0 || self.prefix_token_budget < self.min_prefix_tokens {
148 return Err(
149 "lprovider.redis.prefix_token_budget must be >= min_prefix_tokens > 0".into(),
150 );
151 }
152 Ok(())
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default)]
158pub struct LproviderSecretSettings {
159 pub envelope_key_env: Option<String>,
160}
161
162impl Default for LproviderSecretSettings {
163 fn default() -> Self {
164 Self {
165 envelope_key_env: Some("LLM_ENVELOPE_KEY".to_string()),
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
171#[serde(default)]
172pub struct PointsPer1k {
173 pub points_per_1k_input_tokens: i64,
174 pub points_per_1k_output_tokens: i64,
175 pub points_per_1k_cache_hit_tokens: i64,
176}
177
178impl PointsPer1k {
179 pub fn is_valid(&self) -> Result<(), String> {
180 if self.points_per_1k_input_tokens < 0
181 || self.points_per_1k_output_tokens < 0
182 || self.points_per_1k_cache_hit_tokens < 0
183 {
184 return Err("lprovider.pricing points must be >= 0".into());
185 }
186 Ok(())
187 }
188}
189
190#[derive(Debug, Clone, Default, Serialize, Deserialize)]
191#[serde(default)]
192pub struct LproviderPricingSettings {
193 pub default_per_1k: PointsPer1k,
194 pub rates: std::collections::BTreeMap<String, PointsPer1k>,
195}
196
197impl LproviderPricingSettings {
198 pub fn is_valid(&self) -> Result<(), String> {
199 self.default_per_1k.is_valid()?;
200 for (code, rate) in &self.rates {
201 if code.trim().is_empty() {
202 return Err("lprovider.pricing.rates key must not be empty".into());
203 }
204 rate.is_valid()?;
205 }
206 Ok(())
207 }
208}
209
210pub const STRATEGIES: &[&str] = &["BALANCED", "COST", "QUALITY", "LATENCY", "EXPLICIT"];
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(default)]
214pub struct PriorityStrategyOverride {
215 pub min_priority: i32,
216 pub max_priority: Option<i32>,
217 pub strategy: String,
218}
219
220impl Default for PriorityStrategyOverride {
221 fn default() -> Self {
222 Self {
223 min_priority: 0,
224 max_priority: None,
225 strategy: "BALANCED".to_string(),
226 }
227 }
228}
229
230impl PriorityStrategyOverride {
231 fn is_valid(&self) -> Result<(), String> {
232 if !STRATEGIES.contains(&self.strategy.as_str()) {
233 return Err(format!(
234 "lprovider.models priority override strategy {:?} invalid, expected one of {STRATEGIES:?}",
235 self.strategy
236 ));
237 }
238 if let Some(max) = self.max_priority
239 && max < self.min_priority
240 {
241 return Err("lprovider.models priority override max_priority < min_priority".into());
242 }
243 Ok(())
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(default)]
249pub struct LproviderModelConfig {
250 pub alias: String,
251 pub description: String,
252 pub default_strategy: String,
253 pub priority_strategy_overrides: Vec<PriorityStrategyOverride>,
254}
255
256impl Default for LproviderModelConfig {
257 fn default() -> Self {
258 Self {
259 alias: "auto".to_string(),
260 description: "统一模型别名,自动选择上游".to_string(),
261 default_strategy: "BALANCED".to_string(),
262 priority_strategy_overrides: Vec::new(),
263 }
264 }
265}
266
267impl LproviderModelConfig {
268 pub fn is_valid(&self) -> Result<(), String> {
269 if self.alias.trim().is_empty() {
270 return Err("lprovider.models alias must not be empty".into());
271 }
272 if !STRATEGIES.contains(&self.default_strategy.as_str()) {
273 return Err(format!(
274 "lprovider.models default_strategy {:?} invalid, expected one of {STRATEGIES:?}",
275 self.default_strategy
276 ));
277 }
278 for ov in &self.priority_strategy_overrides {
279 ov.is_valid()?;
280 }
281 Ok(())
282 }
283}
284
285pub const CAPABILITIES: &[&str] = &["streaming", "tools", "vision", "structured", "reasoning"];
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize)]
288#[serde(default)]
289pub struct LproviderUpstreamCredential {
290 pub label: String,
291 pub credential_ref: String,
292 pub qps_limit: i64,
293 pub rpm_limit: i64,
294}
295
296impl LproviderUpstreamCredential {
297 pub fn is_valid(&self) -> Result<(), String> {
298 if self.label.trim().is_empty() || self.credential_ref.trim().is_empty() {
299 return Err("lprovider.upstreams credentials label / credential_ref must not be empty".into());
300 }
301 if self.qps_limit < 0 || self.rpm_limit < 0 {
302 return Err("lprovider.upstreams credentials limits must be >= 0".into());
303 }
304 Ok(())
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(default)]
310pub struct LproviderUpstream {
311 pub code: String,
312 pub enabled: bool,
313 pub provider_type: String,
314 pub base_url: String,
315 pub default_model: String,
316 pub capabilities: Vec<String>,
317 pub context_window: usize,
318 pub max_output: usize,
319 pub cost_tier: i32,
320 pub quality_tier: i32,
321 pub latency_tier: i32,
322 pub tags: Vec<String>,
323 pub credentials: Vec<LproviderUpstreamCredential>,
324}
325
326impl Default for LproviderUpstream {
327 fn default() -> Self {
328 Self {
329 code: String::new(),
330 enabled: true,
331 provider_type: "openai".to_string(),
332 base_url: String::new(),
333 default_model: String::new(),
334 capabilities: vec!["streaming".to_string(), "tools".to_string()],
335 context_window: 128_000,
336 max_output: 16_384,
337 cost_tier: 3,
338 quality_tier: 3,
339 latency_tier: 3,
340 tags: Vec::new(),
341 credentials: Vec::new(),
342 }
343 }
344}
345
346impl LproviderUpstream {
347 pub fn is_valid(&self) -> Result<(), String> {
348 if self.code.trim().is_empty() {
349 return Err("lprovider.upstreams code must not be empty".into());
350 }
351 if self.provider_type != "openai" {
352 return Err(format!(
353 "lprovider.upstreams provider_type {:?} invalid, only \"openai\" supported",
354 self.provider_type
355 ));
356 }
357 if self.base_url.trim().is_empty() || self.default_model.trim().is_empty() {
358 return Err("lprovider.upstreams base_url / default_model must not be empty".into());
359 }
360 if self.context_window == 0 || self.max_output == 0 {
361 return Err("lprovider.upstreams context_window / max_output must be > 0".into());
362 }
363 if !(1..=5).contains(&self.cost_tier)
364 || !(1..=5).contains(&self.quality_tier)
365 || !(1..=5).contains(&self.latency_tier)
366 {
367 return Err("lprovider.upstreams tiers must be within 1..=5".into());
368 }
369 for cap in &self.capabilities {
370 if !CAPABILITIES.contains(&cap.as_str()) {
371 return Err(format!(
372 "lprovider.upstreams capability {:?} invalid, expected one of {CAPABILITIES:?}",
373 cap
374 ));
375 }
376 }
377 if self.credentials.is_empty() {
378 return Err(format!(
379 "lprovider.upstreams {:?} must declare at least one credential",
380 self.code
381 ));
382 }
383 let mut seen_labels = BTreeSet::new();
384 for cred in &self.credentials {
385 cred.is_valid()?;
386 if !seen_labels.insert(cred.label.as_str()) {
387 return Err(format!(
388 "lprovider.upstreams {:?}: duplicate credential label {:?}",
389 self.code, cred.label
390 ));
391 }
392 }
393 Ok(())
394 }
395}