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 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 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 #[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 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 pub(super) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError> {
175 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 = crate::codex_config::codex_config_text_from_settings(
322 &provider.settings_config,
323 )?;
324
325 if !config_toml.contains("base_url") {
326 return Err(AppError::localized(
327 "provider.codex.base_url.missing",
328 "config.toml 中缺少 base_url 配置",
329 "base_url is missing from config.toml",
330 ));
331 }
332
333 let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
334 AppError::localized(
335 "provider.regex_init_failed",
336 format!("正则初始化失败: {e}"),
337 format!("Failed to initialize regex: {e}"),
338 )
339 })?;
340
341 re.captures(&config_toml)
342 .and_then(|caps| caps.get(1))
343 .map(|m| m.as_str().to_string())
344 .ok_or_else(|| {
345 AppError::localized(
346 "provider.codex.base_url.invalid",
347 "config.toml 中 base_url 格式错误",
348 "base_url in config.toml has invalid format",
349 )
350 })
351 }
352 AppType::Gemini => {
353 use crate::gemini_config::json_to_env;
354
355 let env_map = json_to_env(&provider.settings_config)?;
356
357 Ok(env_map
358 .get("GOOGLE_GEMINI_BASE_URL")
359 .cloned()
360 .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string()))
361 }
362 AppType::OpenCode => Ok(provider
363 .settings_config
364 .get("options")
365 .and_then(|v| v.get("baseURL"))
366 .and_then(|v| v.as_str())
367 .unwrap_or_default()
368 .to_string()),
369 AppType::OpenClaw => Ok(provider
370 .settings_config
371 .get("baseUrl")
372 .and_then(|v| v.as_str())
373 .unwrap_or_default()
374 .to_string()),
375 AppType::Hermes => Ok(provider
376 .settings_config
377 .get("base_url")
378 .and_then(|v| v.as_str())
379 .unwrap_or_default()
380 .to_string()),
381 }
382 }
383
384 #[cfg(test)]
385 pub(super) fn extract_credentials(
386 provider: &Provider,
387 app_type: &AppType,
388 ) -> Result<(String, String), AppError> {
389 Ok((
390 Self::extract_api_key(provider, app_type)?,
391 Self::extract_base_url(provider, app_type)?,
392 ))
393 }
394
395 pub(super) fn resolve_usage_script_credentials(
396 provider: &Provider,
397 app_type: &AppType,
398 usage_script: &UsageScript,
399 ) -> Result<(String, String), AppError> {
400 let api_key = match usage_script.api_key.as_deref().map(str::trim) {
401 Some(value) if !value.is_empty() => value.to_string(),
402 _ => Self::extract_api_key(provider, app_type)?,
403 };
404
405 let base_url = match usage_script.base_url.as_deref().map(str::trim) {
406 Some(value) if !value.is_empty() => value.to_string(),
407 _ => Self::extract_base_url(provider, app_type)?,
408 };
409
410 Ok((api_key, base_url))
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::ProviderService;
417 use crate::app_config::{AppType, MultiAppConfig};
418 use crate::provider::{Provider, ProviderMeta, UsageScript};
419 use axum::{routing::get, Router};
420 use serde_json::json;
421
422 #[tokio::test]
423 async fn query_usage_reads_provider_from_db_when_config_snapshot_is_stale() {
424 let state = super::super::state_from_config(MultiAppConfig::default());
425
426 let app = Router::new().route("/", get(|| async { axum::Json(json!({ "total": 42 })) }));
427 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
428 .await
429 .expect("bind test listener");
430 let address = listener.local_addr().expect("listener local addr");
431 let server = tokio::spawn(async move {
432 axum::serve(listener, app).await.expect("server should run");
433 });
434
435 let mut provider = Provider::with_id(
436 "db-only".to_string(),
437 "DB Only".to_string(),
438 json!({}),
439 None,
440 );
441 provider.meta = Some(ProviderMeta {
442 usage_script: Some(UsageScript {
443 enabled: true,
444 language: "javascript".to_string(),
445 code: r#"({
446 request: {
447 url: "{{baseUrl}}",
448 method: "GET"
449 },
450 extractor: function(response) {
451 return { total: response.total };
452 }
453 })"#
454 .to_string(),
455 timeout: Some(2),
456 api_key: Some("unused".to_string()),
457 base_url: Some(format!("http://{address}/")),
458 access_token: None,
459 user_id: None,
460 template_type: None,
461 auto_query_interval: None,
462 }),
463 ..Default::default()
464 });
465 state
466 .db
467 .save_provider(AppType::OpenClaw.as_str(), &provider)
468 .expect("save provider to db only");
469
470 let result = ProviderService::query_usage(&state, AppType::OpenClaw, "db-only")
471 .await
472 .expect("query usage should use db-backed provider lookup");
473
474 assert!(
475 result.success,
476 "expected successful usage query: {result:?}"
477 );
478 assert_eq!(
479 result
480 .data
481 .as_ref()
482 .and_then(|items| items.first())
483 .and_then(|usage| usage.total),
484 Some(42.0)
485 );
486
487 server.abort();
488 }
489}