mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 11:01:22 +08:00
fix(usage): treat usage_script credentials as explicit overrides (#4654)
* fix(usage): treat usage_script credentials as explicit overrides * fix: treat usage script credentials as explicit overrides
This commit is contained in:
@@ -185,6 +185,48 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_deeplink_api_key(api_key: &str) -> String {
|
||||||
|
api_key.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_deeplink_base_url(base_url: &str) -> String {
|
||||||
|
base_url.trim().trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_api_key_override(request: &DeepLinkImportRequest) -> Option<String> {
|
||||||
|
let usage_api_key = normalize_deeplink_api_key(request.usage_api_key.as_deref()?);
|
||||||
|
if usage_api_key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider_api_key = request
|
||||||
|
.api_key
|
||||||
|
.as_deref()
|
||||||
|
.map(normalize_deeplink_api_key)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !provider_api_key.is_empty() && usage_api_key == provider_api_key {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(usage_api_key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_base_url_override(request: &DeepLinkImportRequest) -> Option<String> {
|
||||||
|
let usage_base_url = normalize_deeplink_base_url(request.usage_base_url.as_deref()?);
|
||||||
|
if usage_base_url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider_base_url = normalize_deeplink_base_url(&get_primary_endpoint(request));
|
||||||
|
|
||||||
|
if !provider_base_url.is_empty() && usage_base_url == provider_base_url {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(usage_base_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build provider meta with usage script configuration
|
/// Build provider meta with usage script configuration
|
||||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||||||
// Check if any usage script fields are provided
|
// Check if any usage script fields are provided
|
||||||
@@ -211,25 +253,13 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
// Determine enabled state: explicit param > has code > false
|
// Determine enabled state: explicit param > has code > false
|
||||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||||||
|
|
||||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
|
||||||
// Note: use primary endpoint only (first one if comma-separated)
|
|
||||||
let usage_script = UsageScript {
|
let usage_script = UsageScript {
|
||||||
enabled,
|
enabled,
|
||||||
language: "javascript".to_string(),
|
language: "javascript".to_string(),
|
||||||
code,
|
code,
|
||||||
timeout: Some(10),
|
timeout: Some(10),
|
||||||
api_key: request
|
api_key: usage_api_key_override(request),
|
||||||
.usage_api_key
|
base_url: usage_base_url_override(request),
|
||||||
.clone()
|
|
||||||
.or_else(|| request.api_key.clone()),
|
|
||||||
base_url: request.usage_base_url.clone().or_else(|| {
|
|
||||||
let primary = get_primary_endpoint(request);
|
|
||||||
if primary.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(primary)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
access_token: request.usage_access_token.clone(),
|
access_token: request.usage_access_token.clone(),
|
||||||
user_id: request.usage_user_id.clone(),
|
user_id: request.usage_user_id.clone(),
|
||||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||||
|
|||||||
@@ -260,6 +260,154 @@ fn test_build_gemini_provider_without_model() {
|
|||||||
assert!(env.get("GEMINI_MODEL").is_none());
|
assert!(env.get("GEMINI_MODEL").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
|
||||||
|
use super::provider::build_provider_from_request;
|
||||||
|
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("claude".to_string()),
|
||||||
|
name: Some("Test Claude".to_string()),
|
||||||
|
homepage: Some("https://example.com".to_string()),
|
||||||
|
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||||||
|
api_key: Some("sk-main".to_string()),
|
||||||
|
icon: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
config: None,
|
||||||
|
config_format: None,
|
||||||
|
config_url: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
enabled: None,
|
||||||
|
usage_enabled: Some(true),
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: None,
|
||||||
|
usage_base_url: None,
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||||
|
let script = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should be created");
|
||||||
|
|
||||||
|
assert!(script.enabled);
|
||||||
|
assert_eq!(script.api_key, None);
|
||||||
|
assert_eq!(script.base_url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
|
||||||
|
use super::provider::build_provider_from_request;
|
||||||
|
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("claude".to_string()),
|
||||||
|
name: Some("Test Claude".to_string()),
|
||||||
|
homepage: Some("https://example.com".to_string()),
|
||||||
|
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||||||
|
api_key: Some("sk-main".to_string()),
|
||||||
|
icon: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
config: None,
|
||||||
|
config_format: None,
|
||||||
|
config_url: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
enabled: None,
|
||||||
|
usage_enabled: Some(true),
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: Some(" sk-main ".to_string()),
|
||||||
|
usage_base_url: Some(" https://api.example.com/v1/ ".to_string()),
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||||
|
let script = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should be created");
|
||||||
|
|
||||||
|
assert_eq!(script.api_key, None);
|
||||||
|
assert_eq!(script.base_url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
|
||||||
|
use super::provider::build_provider_from_request;
|
||||||
|
|
||||||
|
let request = DeepLinkImportRequest {
|
||||||
|
version: "v1".to_string(),
|
||||||
|
resource: "provider".to_string(),
|
||||||
|
app: Some("claude".to_string()),
|
||||||
|
name: Some("Test Claude".to_string()),
|
||||||
|
homepage: Some("https://example.com".to_string()),
|
||||||
|
endpoint: Some("https://api.example.com/v1".to_string()),
|
||||||
|
api_key: Some("sk-main".to_string()),
|
||||||
|
icon: None,
|
||||||
|
model: None,
|
||||||
|
notes: None,
|
||||||
|
haiku_model: None,
|
||||||
|
sonnet_model: None,
|
||||||
|
opus_model: None,
|
||||||
|
config: None,
|
||||||
|
config_format: None,
|
||||||
|
config_url: None,
|
||||||
|
apps: None,
|
||||||
|
repo: None,
|
||||||
|
directory: None,
|
||||||
|
branch: None,
|
||||||
|
content: None,
|
||||||
|
description: None,
|
||||||
|
enabled: None,
|
||||||
|
usage_enabled: Some(true),
|
||||||
|
usage_script: None,
|
||||||
|
usage_api_key: Some(" sk-usage ".to_string()),
|
||||||
|
usage_base_url: Some(" https://usage.example/api/ ".to_string()),
|
||||||
|
usage_access_token: None,
|
||||||
|
usage_user_id: None,
|
||||||
|
usage_auto_interval: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||||||
|
let script = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should be created");
|
||||||
|
|
||||||
|
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
|
||||||
|
assert_eq!(
|
||||||
|
script.base_url.as_deref(),
|
||||||
|
Some("https://usage.example/api")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_and_merge_config_claude() {
|
fn test_parse_and_merge_config_claude() {
|
||||||
// Prepare Base64 encoded Claude config
|
// Prepare Base64 encoded Claude config
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ mod tests {
|
|||||||
use crate::claude_desktop_config::PROFILE_ID;
|
use crate::claude_desktop_config::PROFILE_ID;
|
||||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::provider::ProviderMeta;
|
|
||||||
#[cfg(any(target_os = "macos", windows))]
|
#[cfg(any(target_os = "macos", windows))]
|
||||||
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
|
use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};
|
||||||
|
use crate::provider::{ProviderMeta, UsageScript};
|
||||||
use crate::proxy::types::ProxyConfig;
|
use crate::proxy::types::ProxyConfig;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -226,6 +226,68 @@ mod tests {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn codex_settings(base_url: &str, api_key: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": api_key
|
||||||
|
},
|
||||||
|
"config": format!(
|
||||||
|
"model_provider = \"custom\"\n\
|
||||||
|
[model_providers.custom]\n\
|
||||||
|
name = \"custom\"\n\
|
||||||
|
base_url = \"{base_url}\"\n\
|
||||||
|
wire_api = \"chat\"\n"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_script_with_credentials(
|
||||||
|
api_key: Option<&str>,
|
||||||
|
base_url: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
|
) -> UsageScript {
|
||||||
|
UsageScript {
|
||||||
|
enabled: true,
|
||||||
|
language: "javascript".to_string(),
|
||||||
|
code: "return { remaining: 1, unit: 'USD' };".to_string(),
|
||||||
|
timeout: Some(10),
|
||||||
|
api_key: api_key.map(str::to_string),
|
||||||
|
base_url: base_url.map(str::to_string),
|
||||||
|
access_token: None,
|
||||||
|
user_id: None,
|
||||||
|
template_type: template_type.map(str::to_string),
|
||||||
|
auto_query_interval: None,
|
||||||
|
coding_plan_provider: None,
|
||||||
|
access_key_id: Some("ak-test".to_string()),
|
||||||
|
secret_access_key: Some("sk-test".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_provider_with_usage(
|
||||||
|
id: &str,
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
usage_api_key: Option<&str>,
|
||||||
|
usage_base_url: Option<&str>,
|
||||||
|
template_type: Option<&str>,
|
||||||
|
) -> Provider {
|
||||||
|
let mut provider = Provider::with_id(
|
||||||
|
id.to_string(),
|
||||||
|
format!("Provider {id}"),
|
||||||
|
codex_settings(base_url, api_key),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
provider.meta = Some(ProviderMeta {
|
||||||
|
usage_script: Some(usage_script_with_credentials(
|
||||||
|
usage_api_key,
|
||||||
|
usage_base_url,
|
||||||
|
template_type,
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
|
||||||
fn openclaw_provider(id: &str) -> Provider {
|
fn openclaw_provider(id: &str) -> Provider {
|
||||||
Provider {
|
Provider {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
@@ -326,6 +388,255 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn add_clears_usage_credentials_that_match_provider_config() {
|
||||||
|
with_test_home(|state, _| {
|
||||||
|
let provider = codex_provider_with_usage(
|
||||||
|
"codex-a",
|
||||||
|
"https://api.a.example/v1/",
|
||||||
|
"sk-a",
|
||||||
|
Some(" sk-a "),
|
||||||
|
Some(" https://api.a.example/v1/ "),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||||||
|
|
||||||
|
let saved = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id("codex-a", AppType::Codex.as_str())
|
||||||
|
.expect("query saved provider")
|
||||||
|
.expect("saved provider should exist");
|
||||||
|
let script = saved
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should remain");
|
||||||
|
|
||||||
|
assert_eq!(script.api_key, None);
|
||||||
|
assert_eq!(script.base_url, None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_preserves_usage_credentials_that_only_match_previous_config() {
|
||||||
|
with_test_home(|state, _| {
|
||||||
|
let provider = codex_provider_with_usage(
|
||||||
|
"codex-usage-old",
|
||||||
|
"https://api.a.example/v1/",
|
||||||
|
"sk-a",
|
||||||
|
Some("sk-a"),
|
||||||
|
Some("https://api.a.example/v1/"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_provider(AppType::Codex.as_str(), &provider)
|
||||||
|
.expect("seed provider with explicit usage credentials");
|
||||||
|
|
||||||
|
let mut updated = provider.clone();
|
||||||
|
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||||||
|
|
||||||
|
ProviderService::update(state, AppType::Codex, None, updated)
|
||||||
|
.expect("update provider main credentials");
|
||||||
|
|
||||||
|
let saved = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id("codex-usage-old", AppType::Codex.as_str())
|
||||||
|
.expect("query updated provider")
|
||||||
|
.expect("updated provider should exist");
|
||||||
|
let script = saved
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should remain");
|
||||||
|
|
||||||
|
assert_eq!(script.api_key.as_deref(), Some("sk-a"));
|
||||||
|
assert_eq!(
|
||||||
|
script.base_url.as_deref(),
|
||||||
|
Some("https://api.a.example/v1/")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
saved.resolve_usage_credentials(&AppType::Codex),
|
||||||
|
("https://api.b.example/v1".to_string(), "sk-b".to_string())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() {
|
||||||
|
with_test_home(|state, _| {
|
||||||
|
let copied_provider = codex_provider_with_usage(
|
||||||
|
"codex-copy",
|
||||||
|
"https://api.a.example/v1/",
|
||||||
|
"sk-a",
|
||||||
|
Some("sk-a"),
|
||||||
|
Some("https://api.a.example/v1/"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
ProviderService::add(state, AppType::Codex, copied_provider, false)
|
||||||
|
.expect("add copied provider");
|
||||||
|
|
||||||
|
let saved_after_add = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
|
||||||
|
.expect("query copied provider")
|
||||||
|
.expect("copied provider should exist");
|
||||||
|
let script_after_add = saved_after_add
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should remain");
|
||||||
|
assert_eq!(script_after_add.api_key, None);
|
||||||
|
assert_eq!(script_after_add.base_url, None);
|
||||||
|
|
||||||
|
let mut edited_provider = saved_after_add.clone();
|
||||||
|
edited_provider.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||||||
|
|
||||||
|
ProviderService::update(state, AppType::Codex, None, edited_provider)
|
||||||
|
.expect("edit copied provider credentials");
|
||||||
|
|
||||||
|
let saved_after_update = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id("codex-copy", AppType::Codex.as_str())
|
||||||
|
.expect("query edited provider")
|
||||||
|
.expect("edited provider should exist");
|
||||||
|
let script_after_update = saved_after_update
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should remain");
|
||||||
|
|
||||||
|
assert_eq!(script_after_update.api_key, None);
|
||||||
|
assert_eq!(script_after_update.base_url, None);
|
||||||
|
assert_eq!(
|
||||||
|
saved_after_update.resolve_usage_credentials(&AppType::Codex),
|
||||||
|
("https://api.b.example/v1".to_string(), "sk-b".to_string())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn update_clears_usage_credentials_that_match_current_config() {
|
||||||
|
with_test_home(|state, _| {
|
||||||
|
let provider = codex_provider_with_usage(
|
||||||
|
"codex-current",
|
||||||
|
"https://api.a.example/v1",
|
||||||
|
"sk-a",
|
||||||
|
Some("sk-usage"),
|
||||||
|
Some("https://usage.example/api"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_provider(AppType::Codex.as_str(), &provider)
|
||||||
|
.expect("seed provider with distinct usage credentials");
|
||||||
|
|
||||||
|
let mut updated = provider.clone();
|
||||||
|
updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b");
|
||||||
|
updated.meta = Some(ProviderMeta {
|
||||||
|
usage_script: Some(usage_script_with_credentials(
|
||||||
|
Some(" sk-b "),
|
||||||
|
Some(" https://api.b.example/v1/ "),
|
||||||
|
None,
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
ProviderService::update(state, AppType::Codex, None, updated)
|
||||||
|
.expect("update provider with redundant usage credentials");
|
||||||
|
|
||||||
|
let saved = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id("codex-current", AppType::Codex.as_str())
|
||||||
|
.expect("query updated provider")
|
||||||
|
.expect("updated provider should exist");
|
||||||
|
let script = saved
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should remain");
|
||||||
|
|
||||||
|
assert_eq!(script.api_key, None);
|
||||||
|
assert_eq!(script.base_url, None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn add_preserves_distinct_usage_credentials() {
|
||||||
|
with_test_home(|state, _| {
|
||||||
|
let provider = codex_provider_with_usage(
|
||||||
|
"codex-distinct",
|
||||||
|
"https://api.main.example/v1",
|
||||||
|
"sk-main",
|
||||||
|
Some("sk-usage"),
|
||||||
|
Some("https://usage.example/api"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||||||
|
|
||||||
|
let saved = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id("codex-distinct", AppType::Codex.as_str())
|
||||||
|
.expect("query saved provider")
|
||||||
|
.expect("saved provider should exist");
|
||||||
|
let script = saved
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should remain");
|
||||||
|
|
||||||
|
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
|
||||||
|
assert_eq!(
|
||||||
|
script.base_url.as_deref(),
|
||||||
|
Some("https://usage.example/api")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn add_does_not_clear_token_plan_credentials() {
|
||||||
|
with_test_home(|state, _| {
|
||||||
|
let provider = codex_provider_with_usage(
|
||||||
|
"codex-token-plan",
|
||||||
|
"https://api.plan.example/v1",
|
||||||
|
"sk-plan",
|
||||||
|
Some("sk-plan"),
|
||||||
|
Some("https://api.plan.example/v1"),
|
||||||
|
Some("token_plan"),
|
||||||
|
);
|
||||||
|
|
||||||
|
ProviderService::add(state, AppType::Codex, provider, false).expect("add provider");
|
||||||
|
|
||||||
|
let saved = state
|
||||||
|
.db
|
||||||
|
.get_provider_by_id("codex-token-plan", AppType::Codex.as_str())
|
||||||
|
.expect("query saved provider")
|
||||||
|
.expect("saved provider should exist");
|
||||||
|
let script = saved
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.usage_script.as_ref())
|
||||||
|
.expect("usage script should remain");
|
||||||
|
|
||||||
|
assert_eq!(script.api_key.as_deref(), Some("sk-plan"));
|
||||||
|
assert_eq!(
|
||||||
|
script.base_url.as_deref(),
|
||||||
|
Some("https://api.plan.example/v1")
|
||||||
|
);
|
||||||
|
assert_eq!(script.access_key_id.as_deref(), Some("ak-test"));
|
||||||
|
assert_eq!(script.secret_access_key.as_deref(), Some("sk-test"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_provider_settings_rejects_missing_auth() {
|
fn validate_provider_settings_rejects_missing_auth() {
|
||||||
let provider = Provider::with_id(
|
let provider = Provider::with_id(
|
||||||
@@ -1196,6 +1507,72 @@ impl ProviderService {
|
|||||||
.live_config_managed = Some(managed);
|
.live_config_managed = Some(managed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) {
|
||||||
|
let current_credentials = provider.resolve_usage_credentials(app_type);
|
||||||
|
|
||||||
|
let Some(usage_script) = provider
|
||||||
|
.meta
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|meta| meta.usage_script.as_mut())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if usage_script.template_type.as_deref() == Some("token_plan") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage_script.api_key.as_deref().is_some_and(|api_key| {
|
||||||
|
Self::should_clear_usage_api_key_override(api_key, ¤t_credentials)
|
||||||
|
}) {
|
||||||
|
usage_script.api_key = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage_script.base_url.as_deref().is_some_and(|base_url| {
|
||||||
|
Self::should_clear_usage_base_url_override(base_url, ¤t_credentials)
|
||||||
|
}) {
|
||||||
|
usage_script.base_url = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_clear_usage_api_key_override(
|
||||||
|
script_api_key: &str,
|
||||||
|
current_credentials: &(String, String),
|
||||||
|
) -> bool {
|
||||||
|
let candidate = script_api_key.trim();
|
||||||
|
if candidate.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches_provider_key = |api_key: &str| {
|
||||||
|
let api_key = api_key.trim();
|
||||||
|
!api_key.is_empty() && api_key == candidate
|
||||||
|
};
|
||||||
|
|
||||||
|
matches_provider_key(¤t_credentials.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_clear_usage_base_url_override(
|
||||||
|
script_base_url: &str,
|
||||||
|
current_credentials: &(String, String),
|
||||||
|
) -> bool {
|
||||||
|
let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);
|
||||||
|
if candidate.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches_provider_base_url = |base_url: &str| {
|
||||||
|
let base_url = Self::normalize_usage_base_url_for_compare(base_url);
|
||||||
|
!base_url.is_empty() && base_url == candidate
|
||||||
|
};
|
||||||
|
|
||||||
|
matches_provider_base_url(¤t_credentials.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_usage_base_url_for_compare(base_url: &str) -> String {
|
||||||
|
base_url.trim().trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// List all providers for an app type
|
/// List all providers for an app type
|
||||||
pub fn list(
|
pub fn list(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -1232,6 +1609,7 @@ impl ProviderService {
|
|||||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||||
Self::validate_provider_settings(&app_type, &provider)?;
|
Self::validate_provider_settings(&app_type, &provider)?;
|
||||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||||
|
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
|
||||||
if app_type.is_additive_mode() {
|
if app_type.is_additive_mode() {
|
||||||
Self::set_provider_live_config_managed(&mut provider, add_to_live);
|
Self::set_provider_live_config_managed(&mut provider, add_to_live);
|
||||||
}
|
}
|
||||||
@@ -1286,6 +1664,7 @@ impl ProviderService {
|
|||||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||||
Self::validate_provider_settings(&app_type, &provider)?;
|
Self::validate_provider_settings(&app_type, &provider)?;
|
||||||
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;
|
||||||
|
Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);
|
||||||
|
|
||||||
if provider_id_changed {
|
if provider_id_changed {
|
||||||
if !app_type.is_additive_mode() {
|
if !app_type.is_additive_mode() {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useDeleteProviderMutation,
|
useDeleteProviderMutation,
|
||||||
useSwitchProviderMutation,
|
useSwitchProviderMutation,
|
||||||
} from "@/lib/query";
|
} from "@/lib/query";
|
||||||
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||||
import {
|
import {
|
||||||
@@ -309,7 +310,7 @@ export function useProviderActions(
|
|||||||
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
// 🔧 保存用量脚本后,也应该失效该 provider 的用量查询缓存
|
||||||
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
// 这样主页列表会使用新配置重新查询,而不是使用测试时的缓存
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["usage", provider.id, activeApp],
|
queryKey: usageKeys.script(provider.id, activeApp),
|
||||||
});
|
});
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["subscription", "quota", activeApp],
|
queryKey: ["subscription", "quota", activeApp],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { extractErrorMessage } from "@/utils/errorUtils";
|
|||||||
import { generateUUID } from "@/utils/uuid";
|
import { generateUUID } from "@/utils/uuid";
|
||||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||||
import { invalidateHermesProviderCaches } from "@/hooks/useHermes";
|
import { invalidateHermesProviderCaches } from "@/hooks/useHermes";
|
||||||
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
|
|
||||||
export const useAddProviderMutation = (appId: AppId) => {
|
export const useAddProviderMutation = (appId: AppId) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -141,8 +142,16 @@ export const useUpdateProviderMutation = (appId: AppId) => {
|
|||||||
await providersApi.update(provider, appId, originalId);
|
await providersApi.update(provider, appId, originalId);
|
||||||
return provider;
|
return provider;
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (provider, variables) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: usageKeys.script(provider.id, appId),
|
||||||
|
});
|
||||||
|
if (variables.originalId && variables.originalId !== provider.id) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: usageKeys.script(variables.originalId, appId),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (appId === "openclaw") {
|
if (appId === "openclaw") {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: openclawKeys.health,
|
queryKey: openclawKeys.health,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useUpdateProviderMutation } from "@/lib/query/mutations";
|
||||||
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
|
import type { Provider } from "@/types";
|
||||||
|
|
||||||
|
const apiMocks = vi.hoisted(() => ({
|
||||||
|
update: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
providersApi: {
|
||||||
|
update: (...args: unknown[]) => apiMocks.update(...args),
|
||||||
|
},
|
||||||
|
sessionsApi: {},
|
||||||
|
settingsApi: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useHermes", () => ({
|
||||||
|
invalidateHermesProviderCaches: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useOpenClaw", () => ({
|
||||||
|
openclawKeys: {
|
||||||
|
health: ["openclaw", "health"],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, options?: { defaultValue?: string }) =>
|
||||||
|
options?.defaultValue ?? _key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { wrapper, invalidateSpy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
||||||
|
return {
|
||||||
|
id: "provider-1",
|
||||||
|
name: "Test Provider",
|
||||||
|
settingsConfig: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
apiMocks.update.mockReset().mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useUpdateProviderMutation", () => {
|
||||||
|
it("invalidates the updated provider usage query", async () => {
|
||||||
|
const { wrapper, invalidateSpy } = createWrapper();
|
||||||
|
const provider = createProvider({ id: "provider-b" });
|
||||||
|
const { result } = renderHook(() => useUpdateProviderMutation("codex"), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({ provider });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.update).toHaveBeenCalledWith(provider, "codex", undefined);
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ["providers", "codex"],
|
||||||
|
});
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: usageKeys.script("provider-b", "codex"),
|
||||||
|
});
|
||||||
|
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
||||||
|
queryKey: usageKeys.all,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also invalidates the previous usage query when provider id changes", async () => {
|
||||||
|
const { wrapper, invalidateSpy } = createWrapper();
|
||||||
|
const provider = createProvider({ id: "provider-new" });
|
||||||
|
const { result } = renderHook(() => useUpdateProviderMutation("openclaw"), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
provider,
|
||||||
|
originalId: "provider-old",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiMocks.update).toHaveBeenCalledWith(
|
||||||
|
provider,
|
||||||
|
"openclaw",
|
||||||
|
"provider-old",
|
||||||
|
);
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: usageKeys.script("provider-new", "openclaw"),
|
||||||
|
});
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: usageKeys.script("provider-old", "openclaw"),
|
||||||
|
});
|
||||||
|
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
||||||
|
queryKey: usageKeys.all,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user