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 = 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 #[cfg(test)]
387 pub(super) fn extract_credentials(
388 provider: &Provider,
389 app_type: &AppType,
390 ) -> Result<(String, String), AppError> {
391 Ok((
392 Self::extract_api_key(provider, app_type)?,
393 Self::extract_base_url(provider, app_type)?,
394 ))
395 }
396
397 pub(super) fn resolve_usage_script_credentials(
398 provider: &Provider,
399 app_type: &AppType,
400 usage_script: &UsageScript,
401 ) -> Result<(String, String), AppError> {
402 let api_key = match usage_script.api_key.as_deref().map(str::trim) {
403 Some(value) if !value.is_empty() => value.to_string(),
404 _ => Self::extract_api_key(provider, app_type)?,
405 };
406
407 let base_url = match usage_script.base_url.as_deref().map(str::trim) {
408 Some(value) if !value.is_empty() => value.to_string(),
409 _ => Self::extract_base_url(provider, app_type)?,
410 };
411
412 Ok((api_key, base_url))
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::ProviderService;
419 use crate::app_config::{AppType, MultiAppConfig};
420 use crate::provider::{Provider, ProviderMeta, UsageScript};
421 use axum::{routing::get, Router};
422 use serde_json::json;
423
424 #[tokio::test]
425 async fn query_usage_reads_provider_from_db_when_config_snapshot_is_stale() {
426 let state = super::super::state_from_config(MultiAppConfig::default());
427
428 let app = Router::new().route("/", get(|| async { axum::Json(json!({ "total": 42 })) }));
429 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
430 .await
431 .expect("bind test listener");
432 let address = listener.local_addr().expect("listener local addr");
433 let server = tokio::spawn(async move {
434 axum::serve(listener, app).await.expect("server should run");
435 });
436
437 let mut provider = Provider::with_id(
438 "db-only".to_string(),
439 "DB Only".to_string(),
440 json!({}),
441 None,
442 );
443 provider.meta = Some(ProviderMeta {
444 usage_script: Some(UsageScript {
445 enabled: true,
446 language: "javascript".to_string(),
447 code: r#"({
448 request: {
449 url: "{{baseUrl}}",
450 method: "GET"
451 },
452 extractor: function(response) {
453 return { total: response.total };
454 }
455 })"#
456 .to_string(),
457 timeout: Some(2),
458 api_key: Some("unused".to_string()),
459 base_url: Some(format!("http://{address}/")),
460 access_token: None,
461 user_id: None,
462 template_type: None,
463 auto_query_interval: None,
464 }),
465 ..Default::default()
466 });
467 state
468 .db
469 .save_provider(AppType::OpenClaw.as_str(), &provider)
470 .expect("save provider to db only");
471
472 let result = ProviderService::query_usage(&state, AppType::OpenClaw, "db-only")
473 .await
474 .expect("query usage should use db-backed provider lookup");
475
476 assert!(
477 result.success,
478 "expected successful usage query: {result:?}"
479 );
480 assert_eq!(
481 result
482 .data
483 .as_ref()
484 .and_then(|items| items.first())
485 .and_then(|usage| usage.total),
486 Some(42.0)
487 );
488
489 server.abort();
490 }
491}