Skip to main content

cc_switch_lib/services/provider/
usage.rs

1use regex::Regex;
2
3use crate::app_config::AppType;
4use crate::error::AppError;
5use crate::provider::{Provider, UsageData, UsageResult, UsageScript};
6use crate::settings;
7use crate::store::AppState;
8use crate::usage_script;
9
10use super::ProviderService;
11
12impl ProviderService {
13    /// 执行用量脚本并格式化结果(私有辅助方法)
14    async fn execute_and_format_usage_result(
15        script_code: &str,
16        api_key: &str,
17        base_url: &str,
18        timeout: u64,
19        access_token: Option<&str>,
20        user_id: Option<&str>,
21        template_type: Option<&str>,
22    ) -> Result<UsageResult, AppError> {
23        match usage_script::execute_usage_script(
24            script_code,
25            api_key,
26            base_url,
27            timeout,
28            access_token,
29            user_id,
30            template_type,
31        )
32        .await
33        {
34            Ok(data) => {
35                let usage_list: Vec<UsageData> = if data.is_array() {
36                    serde_json::from_value(data).map_err(|e| {
37                        AppError::localized(
38                            "usage_script.data_format_error",
39                            format!("数据格式错误: {e}"),
40                            format!("Data format error: {e}"),
41                        )
42                    })?
43                } else {
44                    let single: UsageData = serde_json::from_value(data).map_err(|e| {
45                        AppError::localized(
46                            "usage_script.data_format_error",
47                            format!("数据格式错误: {e}"),
48                            format!("Data format error: {e}"),
49                        )
50                    })?;
51                    vec![single]
52                };
53
54                Ok(UsageResult {
55                    success: true,
56                    data: Some(usage_list),
57                    error: None,
58                })
59            }
60            Err(err) => {
61                let lang = settings::get_settings()
62                    .language
63                    .unwrap_or_else(|| "zh".to_string());
64
65                let msg = match err {
66                    AppError::Localized { zh, en, .. } => {
67                        if lang == "en" {
68                            en
69                        } else {
70                            zh
71                        }
72                    }
73                    other => other.to_string(),
74                };
75
76                Ok(UsageResult {
77                    success: false,
78                    data: None,
79                    error: Some(msg),
80                })
81            }
82        }
83    }
84
85    /// 查询供应商用量(使用已保存的脚本配置)
86    pub async fn query_usage(
87        state: &AppState,
88        app_type: AppType,
89        provider_id: &str,
90    ) -> Result<UsageResult, AppError> {
91        let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
92            let providers = state.db.get_all_providers(app_type.as_str())?;
93            let provider = providers.get(provider_id).ok_or_else(|| {
94                AppError::localized(
95                    "provider.not_found",
96                    format!("供应商不存在: {provider_id}"),
97                    format!("Provider not found: {provider_id}"),
98                )
99            })?;
100
101            let usage_script = provider
102                .meta
103                .as_ref()
104                .and_then(|m| m.usage_script.as_ref())
105                .ok_or_else(|| {
106                    AppError::localized(
107                        "provider.usage.script.missing",
108                        "未配置用量查询脚本",
109                        "Usage script is not configured",
110                    )
111                })?;
112            if !usage_script.enabled {
113                return Err(AppError::localized(
114                    "provider.usage.disabled",
115                    "用量查询未启用",
116                    "Usage query is disabled",
117                ));
118            }
119
120            let (api_key, base_url) =
121                Self::resolve_usage_script_credentials(&provider, &app_type, usage_script)?;
122
123            (
124                usage_script.code.clone(),
125                usage_script.timeout.unwrap_or(10),
126                api_key,
127                base_url,
128                usage_script.access_token.clone(),
129                usage_script.user_id.clone(),
130                usage_script.template_type.clone(),
131            )
132        };
133
134        Self::execute_and_format_usage_result(
135            &script_code,
136            &api_key,
137            &base_url,
138            timeout,
139            access_token.as_deref(),
140            user_id.as_deref(),
141            template_type.as_deref(),
142        )
143        .await
144    }
145
146    /// 测试用量脚本(使用临时脚本内容,不保存)
147    #[allow(clippy::too_many_arguments)]
148    pub async fn test_usage_script(
149        _state: &AppState,
150        _app_type: AppType,
151        _provider_id: &str,
152        script_code: &str,
153        timeout: u64,
154        api_key: Option<&str>,
155        base_url: Option<&str>,
156        access_token: Option<&str>,
157        user_id: Option<&str>,
158        template_type: Option<&str>,
159    ) -> Result<UsageResult, AppError> {
160        // 直接使用传入的凭证参数进行测试
161        Self::execute_and_format_usage_result(
162            script_code,
163            api_key.unwrap_or(""),
164            base_url.unwrap_or(""),
165            timeout,
166            access_token,
167            user_id,
168            template_type,
169        )
170        .await
171    }
172
173    /// 验证 UsageScript 配置(边界检查)
174    pub(super) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError> {
175        // 验证自动查询间隔 (0-1440 分钟,即最大24小时)
176        if let Some(interval) = script.auto_query_interval {
177            if interval > 1440 {
178                return Err(AppError::localized(
179                    "usage_script.interval_too_large",
180                    format!(
181                        "自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
182                    ),
183                    format!(
184                        "Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
185                    ),
186                ));
187            }
188        }
189
190        Ok(())
191    }
192
193    fn extract_api_key(provider: &Provider, app_type: &AppType) -> Result<String, AppError> {
194        match app_type {
195            AppType::Claude => {
196                let env = provider
197                    .settings_config
198                    .get("env")
199                    .and_then(|v| v.as_object())
200                    .ok_or_else(|| {
201                        AppError::localized(
202                            "provider.claude.env.missing",
203                            "配置格式错误: 缺少 env",
204                            "Invalid configuration: missing env section",
205                        )
206                    })?;
207
208                env.get("ANTHROPIC_AUTH_TOKEN")
209                    .or_else(|| env.get("ANTHROPIC_API_KEY"))
210                    .and_then(|v| v.as_str())
211                    .ok_or_else(|| {
212                        AppError::localized(
213                            "provider.claude.api_key.missing",
214                            "缺少 API Key",
215                            "API key is missing",
216                        )
217                    })
218                    .map(|s| s.to_string())
219            }
220            AppType::Codex => {
221                let auth = provider
222                    .settings_config
223                    .get("auth")
224                    .and_then(|v| v.as_object())
225                    .ok_or_else(|| {
226                        AppError::localized(
227                            "provider.codex.auth.missing",
228                            "配置格式错误: 缺少 auth",
229                            "Invalid configuration: missing auth section",
230                        )
231                    })?;
232
233                auth.get("OPENAI_API_KEY")
234                    .and_then(|v| v.as_str())
235                    .ok_or_else(|| {
236                        AppError::localized(
237                            "provider.codex.api_key.missing",
238                            "缺少 API Key",
239                            "API key is missing",
240                        )
241                    })
242                    .map(|s| s.to_string())
243            }
244            AppType::Gemini => {
245                use crate::gemini_config::json_to_env;
246
247                let env_map = json_to_env(&provider.settings_config)?;
248
249                env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
250                    AppError::localized(
251                        "gemini.missing_api_key",
252                        "缺少 GEMINI_API_KEY",
253                        "Missing GEMINI_API_KEY",
254                    )
255                })
256            }
257            AppType::OpenCode => provider
258                .settings_config
259                .get("options")
260                .and_then(|v| v.get("apiKey"))
261                .and_then(|v| v.as_str())
262                .ok_or_else(|| {
263                    AppError::localized(
264                        "provider.opencode.api_key.missing",
265                        "缺少 API Key",
266                        "API key is missing",
267                    )
268                })
269                .map(|s| s.to_string()),
270            AppType::OpenClaw => provider
271                .settings_config
272                .get("apiKey")
273                .and_then(|v| v.as_str())
274                .ok_or_else(|| {
275                    AppError::localized(
276                        "provider.openclaw.api_key.missing",
277                        "缺少 API Key",
278                        "API key is missing",
279                    )
280                })
281                .map(|s| s.to_string()),
282            AppType::Hermes => provider
283                .settings_config
284                .get("api_key")
285                .and_then(|v| v.as_str())
286                .ok_or_else(|| {
287                    AppError::localized(
288                        "provider.hermes.api_key.missing",
289                        "缺少 API Key",
290                        "API key is missing",
291                    )
292                })
293                .map(|s| s.to_string()),
294        }
295    }
296
297    fn extract_base_url(provider: &Provider, app_type: &AppType) -> Result<String, AppError> {
298        match app_type {
299            AppType::Claude => provider
300                .settings_config
301                .get("env")
302                .and_then(|v| v.as_object())
303                .ok_or_else(|| {
304                    AppError::localized(
305                        "provider.claude.env.missing",
306                        "配置格式错误: 缺少 env",
307                        "Invalid configuration: missing env section",
308                    )
309                })?
310                .get("ANTHROPIC_BASE_URL")
311                .and_then(|v| v.as_str())
312                .ok_or_else(|| {
313                    AppError::localized(
314                        "provider.claude.base_url.missing",
315                        "缺少 ANTHROPIC_BASE_URL 配置",
316                        "Missing ANTHROPIC_BASE_URL configuration",
317                    )
318                })
319                .map(|s| s.to_string()),
320            AppType::Codex => {
321                let config_toml = provider
322                    .settings_config
323                    .get("config")
324                    .and_then(|v| v.as_str())
325                    .unwrap_or("");
326
327                if !config_toml.contains("base_url") {
328                    return Err(AppError::localized(
329                        "provider.codex.base_url.missing",
330                        "config.toml 中缺少 base_url 配置",
331                        "base_url is missing from config.toml",
332                    ));
333                }
334
335                let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
336                    AppError::localized(
337                        "provider.regex_init_failed",
338                        format!("正则初始化失败: {e}"),
339                        format!("Failed to initialize regex: {e}"),
340                    )
341                })?;
342
343                re.captures(config_toml)
344                    .and_then(|caps| caps.get(1))
345                    .map(|m| m.as_str().to_string())
346                    .ok_or_else(|| {
347                        AppError::localized(
348                            "provider.codex.base_url.invalid",
349                            "config.toml 中 base_url 格式错误",
350                            "base_url in config.toml has invalid format",
351                        )
352                    })
353            }
354            AppType::Gemini => {
355                use crate::gemini_config::json_to_env;
356
357                let env_map = json_to_env(&provider.settings_config)?;
358
359                Ok(env_map
360                    .get("GOOGLE_GEMINI_BASE_URL")
361                    .cloned()
362                    .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string()))
363            }
364            AppType::OpenCode => Ok(provider
365                .settings_config
366                .get("options")
367                .and_then(|v| v.get("baseURL"))
368                .and_then(|v| v.as_str())
369                .unwrap_or_default()
370                .to_string()),
371            AppType::OpenClaw => Ok(provider
372                .settings_config
373                .get("baseUrl")
374                .and_then(|v| v.as_str())
375                .unwrap_or_default()
376                .to_string()),
377            AppType::Hermes => Ok(provider
378                .settings_config
379                .get("base_url")
380                .and_then(|v| v.as_str())
381                .unwrap_or_default()
382                .to_string()),
383        }
384    }
385
386    pub(super) fn extract_credentials(
387        provider: &Provider,
388        app_type: &AppType,
389    ) -> Result<(String, String), AppError> {
390        Ok((
391            Self::extract_api_key(provider, app_type)?,
392            Self::extract_base_url(provider, app_type)?,
393        ))
394    }
395
396    pub(super) fn resolve_usage_script_credentials(
397        provider: &Provider,
398        app_type: &AppType,
399        usage_script: &UsageScript,
400    ) -> Result<(String, String), AppError> {
401        let api_key = match usage_script.api_key.as_deref().map(str::trim) {
402            Some(value) if !value.is_empty() => value.to_string(),
403            _ => Self::extract_api_key(provider, app_type)?,
404        };
405
406        let base_url = match usage_script.base_url.as_deref().map(str::trim) {
407            Some(value) if !value.is_empty() => value.to_string(),
408            _ => Self::extract_base_url(provider, app_type)?,
409        };
410
411        Ok((api_key, base_url))
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::ProviderService;
418    use crate::app_config::{AppType, MultiAppConfig};
419    use crate::provider::{Provider, ProviderMeta, UsageScript};
420    use axum::{routing::get, Router};
421    use serde_json::json;
422
423    #[tokio::test]
424    async fn query_usage_reads_provider_from_db_when_config_snapshot_is_stale() {
425        let state = super::super::state_from_config(MultiAppConfig::default());
426
427        let app = Router::new().route("/", get(|| async { axum::Json(json!({ "total": 42 })) }));
428        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
429            .await
430            .expect("bind test listener");
431        let address = listener.local_addr().expect("listener local addr");
432        let server = tokio::spawn(async move {
433            axum::serve(listener, app).await.expect("server should run");
434        });
435
436        let mut provider = Provider::with_id(
437            "db-only".to_string(),
438            "DB Only".to_string(),
439            json!({}),
440            None,
441        );
442        provider.meta = Some(ProviderMeta {
443            usage_script: Some(UsageScript {
444                enabled: true,
445                language: "javascript".to_string(),
446                code: r#"({
447                    request: {
448                        url: "{{baseUrl}}",
449                        method: "GET"
450                    },
451                    extractor: function(response) {
452                        return { total: response.total };
453                    }
454                })"#
455                .to_string(),
456                timeout: Some(2),
457                api_key: Some("unused".to_string()),
458                base_url: Some(format!("http://{address}/")),
459                access_token: None,
460                user_id: None,
461                template_type: None,
462                auto_query_interval: None,
463            }),
464            ..Default::default()
465        });
466        state
467            .db
468            .save_provider(AppType::OpenClaw.as_str(), &provider)
469            .expect("save provider to db only");
470
471        let result = ProviderService::query_usage(&state, AppType::OpenClaw, "db-only")
472            .await
473            .expect("query usage should use db-backed provider lookup");
474
475        assert!(
476            result.success,
477            "expected successful usage query: {result:?}"
478        );
479        assert_eq!(
480            result
481                .data
482                .as_ref()
483                .and_then(|items| items.first())
484                .and_then(|usage| usage.total),
485            Some(42.0)
486        );
487
488        server.abort();
489    }
490}