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 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}