mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 03:35:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 483256ad92 |
@@ -287,8 +287,7 @@ pub fn apply_proxy_takeover(
|
||||
token_placeholder: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||||
let updated = update_selected_model_string(&updated, "api_key", token_placeholder)?;
|
||||
update_selected_model_string(&updated, "api_backend", DEFAULT_API_BACKEND)
|
||||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||||
}
|
||||
|
||||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||||
@@ -513,12 +512,8 @@ context_window = 500000
|
||||
|
||||
#[test]
|
||||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||||
let direct_config = valid_env_key_config().replace(
|
||||
"api_backend = \"responses\"",
|
||||
"api_backend = \"chat_completions\"",
|
||||
);
|
||||
let updated = apply_proxy_takeover(
|
||||
&direct_config,
|
||||
valid_env_key_config(),
|
||||
"http://127.0.0.1:15721/grokbuild/v1",
|
||||
"PROXY_MANAGED",
|
||||
)
|
||||
@@ -528,7 +523,6 @@ context_window = 500000
|
||||
assert_eq!(selected.profile, "grok-env");
|
||||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||||
assert_eq!(selected.api_backend, DEFAULT_API_BACKEND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2630,7 +2630,7 @@ async fn log_usage(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
|
||||
@@ -642,7 +642,7 @@ async fn log_usage_internal(
|
||||
model
|
||||
};
|
||||
|
||||
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
|
||||
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
|
||||
let request_id = usage.dedup_request_id(dedup_scope);
|
||||
|
||||
log::debug!(
|
||||
|
||||
+10
-130
@@ -7,7 +7,6 @@
|
||||
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
|
||||
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
|
||||
//! - Codex: 从 headers 中的 `session_id` / `x-session-id` 或 `metadata.session_id` 提取
|
||||
//! - Grok Build: 从 headers 中的 `x-grok-conv-id` / `x-grok-session-id` 提取
|
||||
//! - 其他: 生成新的 UUID
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
@@ -24,7 +23,7 @@ pub enum SessionIdSource {
|
||||
MetadataUserId,
|
||||
/// 从 metadata.session_id 提取
|
||||
MetadataSessionId,
|
||||
/// 从 headers 提取
|
||||
/// 从 headers 提取 (Codex)
|
||||
Header,
|
||||
/// 新生成
|
||||
Generated,
|
||||
@@ -57,11 +56,6 @@ pub struct SessionIdResult {
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ### Grok Build 请求
|
||||
/// 1. Headers: `x-grok-conv-id` 或 `x-grok-session-id`
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ## 示例
|
||||
///
|
||||
/// ```ignore
|
||||
@@ -79,15 +73,9 @@ pub fn extract_session_id(
|
||||
}
|
||||
}
|
||||
|
||||
// Responses 请求特殊处理。Grok Build 使用与 Codex 相同的客户端协议,
|
||||
// 但保留独立前缀,避免统计和缓存键跨应用碰撞。
|
||||
if matches!(client_format, "codex" | "openai" | "grokbuild") {
|
||||
let prefix = if client_format == "grokbuild" {
|
||||
"grokbuild"
|
||||
} else {
|
||||
"codex"
|
||||
};
|
||||
if let Some(result) = extract_responses_session(headers, body, prefix) {
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -123,28 +111,16 @@ fn extract_claude_session(
|
||||
extract_from_metadata(body)
|
||||
}
|
||||
|
||||
/// 提取 Responses 客户端的 Session ID
|
||||
fn extract_responses_session(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
prefix: &str,
|
||||
) -> Option<SessionIdResult> {
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
let header_names: &[&str] = if prefix == "grokbuild" {
|
||||
// Conversation ID 跨多轮请求保持稳定;session ID 作为客户端缺少
|
||||
// conversation ID 时的回退。x-grok-req-id 是逐请求 ID,不能用于聚合。
|
||||
&["x-grok-conv-id", "x-grok-session-id"]
|
||||
} else {
|
||||
&["session_id", "x-session-id"]
|
||||
};
|
||||
for header_name in header_names {
|
||||
for header_name in &["session_id", "x-session-id"] {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
let session_id = session_id.trim();
|
||||
// Responses 客户端的 Session ID 通常较长(UUID 格式)
|
||||
// Codex Session ID 通常较长(UUID 格式)
|
||||
if session_id.len() > 20 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
session_id: format!("codex_{session_id}"),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -159,10 +135,9 @@ fn extract_responses_session(
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let session_id = session_id.trim();
|
||||
if session_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("{prefix}_{session_id}"),
|
||||
session_id: format!("codex_{session_id}"),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
@@ -327,101 +302,6 @@ mod tests {
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_keeps_existing_response_session_headers() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for header_name in ["session_id", "x-session-id"] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header_name,
|
||||
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "codex");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"codex_d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_prefers_conversation_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-conv-id",
|
||||
"conv-724f4275-584e-43af-ad46-b5e7509a3ca2".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_conv-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_falls_back_to_session_header() {
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
for conversation_id in ["", " "] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-grok-conv-id", conversation_id.parse().unwrap());
|
||||
headers.insert(
|
||||
"x-grok-session-id",
|
||||
"session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(
|
||||
result.session_id,
|
||||
"grokbuild_session-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
);
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_grokbuild_ignores_request_and_codex_session_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-grok-req-id",
|
||||
"request-724f4275-584e-43af-ad46-b5e7509a3ca2"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-session-id",
|
||||
"codex-d937243f-2702-4f20-97b6-c9682235ab81"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let body = json!({ "input": "Write a function" });
|
||||
|
||||
let result = extract_session_id(&headers, &body, "grokbuild");
|
||||
|
||||
assert_eq!(result.source, SessionIdSource::Generated);
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_generates_new_when_not_found() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -687,57 +687,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_replaces_matching_session_log_row() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, input_tokens,
|
||||
output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
latency_ms, status_code, created_at, data_source
|
||||
) VALUES ('session:msg_desktop', '_session', 'claude',
|
||||
'claude-sonnet-4-5', 10, 5, 2, 1, 0, 200, 1, 'session_log')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 2,
|
||||
cache_creation_tokens: 1,
|
||||
model: Some("claude-sonnet-4-5".to_string()),
|
||||
message_id: Some("msg_desktop".to_string()),
|
||||
};
|
||||
let request_id = usage.dedup_request_id(crate::proxy::usage::parser::dedup_scope_for_app(
|
||||
"claude-desktop",
|
||||
"desktop-provider",
|
||||
));
|
||||
let mut proxy_log = request_log(&request_id, 10);
|
||||
proxy_log.provider_id = "desktop-provider".to_string();
|
||||
proxy_log.app_type = "claude-desktop".to_string();
|
||||
proxy_log.model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.request_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.pricing_model = "claude-sonnet-4-5".to_string();
|
||||
proxy_log.usage = usage;
|
||||
|
||||
UsageLogger::new(&db).log_request(&proxy_log)?;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, source, app_type): (i64, String, String) = conn.query_row(
|
||||
"SELECT COUNT(*), data_source, app_type FROM proxy_request_logs
|
||||
WHERE request_id = 'session:msg_desktop'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(source, "proxy");
|
||||
assert_eq!(app_type, "claude-desktop");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_error() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -30,16 +30,6 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
|
||||
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
|
||||
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
|
||||
|
||||
/// Claude Code and Claude Desktop share Claude message ids with the session
|
||||
/// importer, so both use the bare `session:{message_id}` namespace. Other
|
||||
/// apps retain app/provider scoping to avoid collisions between upstreams.
|
||||
pub fn dedup_scope_for_app<'a>(
|
||||
app_type: &'a str,
|
||||
provider_id: &'a str,
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
(!matches!(app_type, "claude" | "claude-desktop")).then_some((app_type, provider_id))
|
||||
}
|
||||
|
||||
fn response_id(body: &Value, field: &str) -> Option<String> {
|
||||
body.get(field)
|
||||
.and_then(Value::as_str)
|
||||
@@ -489,25 +479,6 @@ mod tests {
|
||||
.starts_with("session:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_apps_share_the_session_request_id_namespace() {
|
||||
let usage = TokenUsage {
|
||||
message_id: Some("msg_123".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for app_type in ["claude", "claude-desktop"] {
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app(app_type, "provider-a")),
|
||||
"session:msg_123"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(dedup_scope_for_app("codex", "provider-a")),
|
||||
"session:codex:provider-a:msg_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parsers_recover_ids_from_envelope_chunks() {
|
||||
let openai = vec![
|
||||
|
||||
@@ -31,46 +31,25 @@ pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
|
||||
Ok(conflicts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EnvKeyword {
|
||||
Exact(&'static str),
|
||||
Prefix(&'static str),
|
||||
}
|
||||
|
||||
/// Get relevant keywords for each app
|
||||
fn get_keywords_for_app(app: &str) -> Vec<EnvKeyword> {
|
||||
fn get_keywords_for_app(app: &str) -> Vec<&str> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => vec![EnvKeyword::Prefix("ANTHROPIC")],
|
||||
"codex" => vec![EnvKeyword::Prefix("OPENAI")],
|
||||
"gemini" => vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI"),
|
||||
],
|
||||
"grokbuild" | "grok" => vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL"),
|
||||
],
|
||||
"claude" => vec!["ANTHROPIC"],
|
||||
"codex" => vec!["OPENAI"],
|
||||
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_env_keyword(name: &str, keywords: &[EnvKeyword]) -> bool {
|
||||
let upper_name = name.to_uppercase();
|
||||
keywords.iter().any(|keyword| match keyword {
|
||||
EnvKeyword::Exact(name) => upper_name == *name,
|
||||
EnvKeyword::Prefix(prefix) => upper_name.starts_with(prefix),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check system environment variables (Windows Registry or Unix env)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check HKEY_CURRENT_USER\Environment
|
||||
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
|
||||
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -86,7 +65,7 @@ fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String>
|
||||
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
|
||||
{
|
||||
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
|
||||
if matches_env_keyword(&name, keywords) {
|
||||
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: name.clone(),
|
||||
var_value: value.to_string(),
|
||||
@@ -101,12 +80,12 @@ fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String>
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
// Check current process environment
|
||||
for (key, value) in std::env::vars() {
|
||||
if matches_env_keyword(&key, keywords) {
|
||||
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: key,
|
||||
var_value: value,
|
||||
@@ -121,7 +100,7 @@ fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String>
|
||||
|
||||
/// Check shell configuration files for environment variable exports (Unix only)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
|
||||
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
@@ -152,7 +131,7 @@ fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, Stri
|
||||
let var_value = export_line[eq_pos + 1..].trim();
|
||||
|
||||
// Check if variable name contains any keyword
|
||||
if matches_env_keyword(var_name, keywords) {
|
||||
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
|
||||
conflicts.push(EnvConflict {
|
||||
var_name: var_name.to_string(),
|
||||
var_value: var_value
|
||||
@@ -178,58 +157,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_keywords() {
|
||||
assert_eq!(
|
||||
get_keywords_for_app("claude"),
|
||||
vec![EnvKeyword::Prefix("ANTHROPIC")]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("codex"),
|
||||
vec![EnvKeyword::Prefix("OPENAI")]
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
|
||||
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("gemini"),
|
||||
vec![
|
||||
EnvKeyword::Prefix("GEMINI"),
|
||||
EnvKeyword::Prefix("GOOGLE_GEMINI")
|
||||
]
|
||||
vec!["GEMINI", "GOOGLE_GEMINI"]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grokbuild"),
|
||||
vec![
|
||||
EnvKeyword::Exact("XAI_API_KEY"),
|
||||
EnvKeyword::Exact("GROK_DEFAULT_MODEL")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
get_keywords_for_app("grok"),
|
||||
get_keywords_for_app("grokbuild")
|
||||
);
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<EnvKeyword>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_keywords_only_match_credentials() {
|
||||
let keywords = get_keywords_for_app("grokbuild");
|
||||
|
||||
assert!(matches_env_keyword("XAI_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("xai_api_key", &keywords));
|
||||
assert!(matches_env_keyword("GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(matches_env_keyword("grok_default_model", &keywords));
|
||||
assert!(!matches_env_keyword("MY_XAI_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("XAI_API_KEY_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("MY_GROK_DEFAULT_MODEL", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_DEFAULT_MODEL_BACKUP", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_BIN_DIR", &keywords));
|
||||
assert!(!matches_env_keyword("GROK_HOME", &keywords));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_app_keywords_match_only_at_the_start() {
|
||||
let keywords = get_keywords_for_app("claude");
|
||||
|
||||
assert!(matches_env_keyword("ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(matches_env_keyword("anthropic_base_url", &keywords));
|
||||
assert!(!matches_env_keyword("MY_ANTHROPIC_API_KEY", &keywords));
|
||||
assert!(!matches_env_keyword("NOT_ANTHROPIC", &keywords));
|
||||
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,12 +230,6 @@ fn data_source_expr(log_alias: &str) -> String {
|
||||
format!("COALESCE({log_alias}.data_source, 'proxy')")
|
||||
}
|
||||
|
||||
fn dedup_app_type_match_sql(left: &str, right: &str) -> String {
|
||||
format!(
|
||||
"{left} IN ({right}, CASE WHEN {right} = 'claude' THEN 'claude-desktop' ELSE {right} END)"
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL 标量表达式:把 Claude Desktop 网关的 `claude-desktop` app_type 在“展示口径”
|
||||
/// 上折叠进 `claude`,其余 app_type 原样返回。
|
||||
///
|
||||
@@ -249,8 +243,8 @@ fn dedup_app_type_match_sql(left: &str, right: &str) -> String {
|
||||
/// 而不改动任何已存储的行(详情面板仍读原始 `app_type`)。
|
||||
///
|
||||
/// 注意:包裹后该列上的索引在此比较中失效,但这些都是已带时间过滤的聚合扫描,
|
||||
/// app_type 本就不是主访问路径,可接受。仅用于读侧;跨源去重使用更窄的
|
||||
/// [`dedup_app_type_match_sql`],额度检查(`check_provider_limits`)仍保留原始精确比较。
|
||||
/// app_type 本就不是主访问路径,可接受。仅用于读侧;去重匹配(`has_matching_
|
||||
/// proxy_usage_log`)与额度检查(`check_provider_limits`)必须保留原始精确比较。
|
||||
fn folded_app_type_sql(column: &str) -> String {
|
||||
format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END")
|
||||
}
|
||||
@@ -304,8 +298,6 @@ fn push_provider_model_filters(
|
||||
pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
let data_source = data_source_expr(log_alias);
|
||||
let proxy_data_source = data_source_expr("proxy_dedup");
|
||||
let app_type_match =
|
||||
dedup_app_type_match_sql("proxy_dedup.app_type", &format!("{log_alias}.app_type"));
|
||||
format!(
|
||||
"NOT (
|
||||
{data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session')
|
||||
@@ -313,7 +305,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
SELECT 1
|
||||
FROM proxy_request_logs proxy_dedup
|
||||
WHERE {proxy_data_source} = 'proxy'
|
||||
AND {app_type_match}
|
||||
AND proxy_dedup.app_type = {log_alias}.app_type
|
||||
AND proxy_dedup.status_code >= 200
|
||||
AND proxy_dedup.status_code < 300
|
||||
AND proxy_dedup.input_tokens = {log_alias}.input_tokens
|
||||
@@ -386,13 +378,12 @@ pub(crate) fn has_matching_proxy_usage_log(
|
||||
matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0;
|
||||
|
||||
let l_data_source = data_source_expr("l");
|
||||
let app_type_match = dedup_app_type_match_sql("l.app_type", "?1");
|
||||
let sql = format!(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM proxy_request_logs l
|
||||
WHERE {l_data_source} = 'proxy'
|
||||
AND {app_type_match}
|
||||
AND l.app_type = ?1
|
||||
AND l.status_code >= 200
|
||||
AND l.status_code < 300
|
||||
AND l.input_tokens = ?3
|
||||
@@ -2472,75 +2463,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matching_proxy_log_matches_claude_desktop_for_claude_session() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
create_legacy_nullable_logs_table(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, app_type, model, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
|
||||
) VALUES ('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy')",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let key = DedupKey {
|
||||
app_type: "claude",
|
||||
model: "claude-sonnet-4-5",
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 10,
|
||||
cache_creation_tokens: 5,
|
||||
created_at: 1060,
|
||||
};
|
||||
assert!(has_matching_proxy_usage_log(&conn, &key)?);
|
||||
|
||||
let mut outside_window = key;
|
||||
outside_window.created_at = 1_601;
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &outside_window)?);
|
||||
|
||||
let mut different_model = key;
|
||||
different_model.model = "claude-opus-4-5";
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &different_model)?);
|
||||
|
||||
let mut different_input = key;
|
||||
different_input.input_tokens += 1;
|
||||
assert!(!has_matching_proxy_usage_log(&conn, &different_input)?);
|
||||
|
||||
let mut different_cache_creation = key;
|
||||
different_cache_creation.cache_creation_tokens += 1;
|
||||
assert!(!has_matching_proxy_usage_log(
|
||||
&conn,
|
||||
&different_cache_creation
|
||||
)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_filter_dedups_claude_session_against_desktop_proxy() -> Result<(), AppError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
create_legacy_nullable_logs_table(&conn)?;
|
||||
conn.execute_batch(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, app_type, model, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
|
||||
) VALUES
|
||||
('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy'),
|
||||
('claude-session', 'claude', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1060, 'session_log');",
|
||||
)?;
|
||||
|
||||
let filter = effective_usage_log_filter("l");
|
||||
let sql = format!("SELECT request_id FROM proxy_request_logs l WHERE {filter}");
|
||||
let request_ids = conn
|
||||
.prepare(&sql)?
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
assert_eq!(request_ids, vec!["desktop-proxy"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_desktop_folds_into_claude_for_display() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
|
||||
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -229,10 +228,62 @@ export function DeepLinkImportDialog() {
|
||||
? "url"
|
||||
: null;
|
||||
|
||||
const parsedConfig = useMemo(
|
||||
() => (request ? parseDeepLinkConfigPreview(request) : null),
|
||||
[request],
|
||||
);
|
||||
// Parse config file content for display
|
||||
interface ParsedConfig {
|
||||
type: "claude" | "codex" | "gemini";
|
||||
env?: Record<string, string>;
|
||||
auth?: Record<string, string>;
|
||||
tomlConfig?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Helper to decode base64 with UTF-8 support
|
||||
const b64ToUtf8 = (str: string): string => {
|
||||
try {
|
||||
const binString = atob(str);
|
||||
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch (e) {
|
||||
console.error("Failed to decode base64:", e);
|
||||
return atob(str);
|
||||
}
|
||||
};
|
||||
|
||||
const parsedConfig = useMemo((): ParsedConfig | null => {
|
||||
if (!request?.config) return null;
|
||||
try {
|
||||
const decoded = b64ToUtf8(request.config);
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
|
||||
if (request.app === "claude") {
|
||||
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
|
||||
return {
|
||||
type: "claude",
|
||||
env: (parsed.env as Record<string, string>) || {},
|
||||
raw: parsed,
|
||||
};
|
||||
} else if (request.app === "codex") {
|
||||
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
|
||||
return {
|
||||
type: "codex",
|
||||
auth: (parsed.auth as Record<string, string>) || {},
|
||||
tomlConfig: (parsed.config as string) || "",
|
||||
raw: parsed,
|
||||
};
|
||||
} else if (request.app === "gemini") {
|
||||
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
|
||||
return {
|
||||
type: "gemini",
|
||||
env: parsed as Record<string, string>,
|
||||
raw: parsed,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse config:", e);
|
||||
return null;
|
||||
}
|
||||
}, [request?.config, request?.app]);
|
||||
|
||||
/**
|
||||
* env 行:值经 `maskValue` 脱敏,键命中加载器控制变量时标记。
|
||||
@@ -522,11 +573,9 @@ export function DeepLinkImportDialog() {
|
||||
)}
|
||||
|
||||
{/* Codex config */}
|
||||
{(parsedConfig.type === "codex" ||
|
||||
parsedConfig.type === "grokbuild") && (
|
||||
{parsedConfig.type === "codex" && (
|
||||
<div className="space-y-2">
|
||||
{parsedConfig.type === "codex" &&
|
||||
parsedConfig.auth &&
|
||||
{parsedConfig.auth &&
|
||||
Object.keys(parsedConfig.auth).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -550,8 +599,10 @@ export function DeepLinkImportDialog() {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
TOML Config:
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded overflow-auto max-h-24 whitespace-pre-wrap break-all">
|
||||
{parsedConfig.tomlConfig}
|
||||
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
|
||||
{parsedConfig.tomlConfig.substring(0, 300)}
|
||||
{parsedConfig.tomlConfig.length > 300 &&
|
||||
"..."}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,13 +25,6 @@ interface ProxyTabContentProps {
|
||||
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export const FAILOVER_APPS = [
|
||||
{ id: "claude", label: "Claude" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "gemini", label: "Gemini" },
|
||||
{ id: "grokbuild", label: "Grok Build" },
|
||||
] as const;
|
||||
|
||||
export function ProxyTabContent({
|
||||
settings,
|
||||
onAutoSave,
|
||||
@@ -179,14 +172,12 @@ export function ProxyTabContent({
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
{FAILOVER_APPS.map(({ id, label }) => (
|
||||
<TabsTrigger key={id} value={id}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
{FAILOVER_APPS.map(({ id: appType }) => {
|
||||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||
const failoverDisabled =
|
||||
!isRunning || !(takeoverStatus?.[appType] ?? false);
|
||||
return (
|
||||
|
||||
@@ -172,8 +172,7 @@
|
||||
"oauthHint": "Google official uses OAuth personal authentication, no need to fill in API Key. The browser will automatically open for login on first use.",
|
||||
"apiKeyPlaceholder": "Enter Gemini API Key"
|
||||
}
|
||||
},
|
||||
"duplicateLiveIdsLoadFailed": "Failed to read provider identifiers from config, please fix the config and try again"
|
||||
}
|
||||
},
|
||||
"claudeCode": {
|
||||
"needsRouting": "Needs Routing",
|
||||
@@ -222,8 +221,7 @@
|
||||
"tooltip": {
|
||||
"active": "Claude Desktop local routing is running - {{address}}:{{port}}",
|
||||
"inactive": "Turn on Claude Desktop local routing for providers that need model mapping or format conversion. Configured address: {{address}}:{{port}}"
|
||||
},
|
||||
"stopBlockedByTakeover": "Another app is using proxy takeover. Please disable that app's takeover in settings before stopping local routing."
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -272,8 +270,7 @@
|
||||
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider",
|
||||
"windowControlFailed": "Window control failed: {{error}}",
|
||||
"officialBlockedByProxy": "Cannot switch to official provider while local routing is active. Using routing with official APIs may cause account bans.",
|
||||
"proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing.",
|
||||
"proxyReasonClaudeDesktop": "Using Claude Desktop local routing mode"
|
||||
"proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing."
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
@@ -1171,8 +1168,7 @@
|
||||
"fetchModelsAuthFailed": "API Key is invalid or lacks permission",
|
||||
"fetchModelsNotSupported": "This provider does not support fetching model list",
|
||||
"fetchModelsEndpointNotFound": "No reachable models endpoint found. Please check the Base URL or confirm whether the provider exposes this API.",
|
||||
"fetchModelsTimeout": "Request timed out, please check network connection",
|
||||
"requiredFields": "Please fill in provider name, API endpoint, API Key and model"
|
||||
"fetchModelsTimeout": "Request timed out, please check network connection"
|
||||
},
|
||||
"copilot": {
|
||||
"authSection": "GitHub Copilot Authentication",
|
||||
@@ -1369,8 +1365,7 @@
|
||||
"reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.",
|
||||
"reasoningGroupTitle": "Reasoning Capability",
|
||||
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.",
|
||||
"advancedSectionHint": "Includes upstream format, model mapping, reasoning overrides and custom User-Agent. Providers using the Chat Completions protocol require routing takeover to be enabled.",
|
||||
"noCommonConfigToApply": "The common config snippet is empty or contains nothing to write"
|
||||
"advancedSectionHint": "Includes upstream format, model mapping, reasoning overrides and custom User-Agent. Providers using the Chat Completions protocol require routing takeover to be enabled."
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "Environment Variables (.env)",
|
||||
@@ -1469,7 +1464,6 @@
|
||||
"totalRequests": "Total Requests",
|
||||
"totalCost": "Total Cost",
|
||||
"cost": "Cost",
|
||||
"unpriced": "Unpriced",
|
||||
"perMillion": "(per million)",
|
||||
"trends": "Usage Trends",
|
||||
"rangeToday": "Last 24 hours (hourly)",
|
||||
@@ -2555,8 +2549,7 @@
|
||||
},
|
||||
"tooltip": {
|
||||
"enabled": "{{app}} failover enabled\nRequests follow queue priority (P1→P2→...)",
|
||||
"disabled": "Enable {{app}} failover\nSwitches to queue P1 immediately, then falls back on failures",
|
||||
"takeoverRequired": "Take over {{app}} first, then enable failover"
|
||||
"disabled": "Enable {{app}} failover\nSwitches to queue P1 immediately, then falls back on failures"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@@ -2734,9 +2727,7 @@
|
||||
},
|
||||
"server": {
|
||||
"started": "Routing service started - {{address}}:{{port}}",
|
||||
"startFailed": "Failed to start routing service: {{detail}}",
|
||||
"stopped": "Routing service stopped",
|
||||
"stopFailed": "Failed to stop routing service: {{detail}}"
|
||||
"startFailed": "Failed to start routing service: {{detail}}"
|
||||
},
|
||||
"stoppedWithRestore": "Routing service stopped, all routing configs restored",
|
||||
"stopWithRestoreFailed": "Stop failed: {{detail}}"
|
||||
|
||||
@@ -172,8 +172,7 @@
|
||||
"oauthHint": "Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。",
|
||||
"apiKeyPlaceholder": "Gemini API Key を入力"
|
||||
}
|
||||
},
|
||||
"duplicateLiveIdsLoadFailed": "設定内のプロバイダー識別子を読み込めませんでした。設定を修正してから再度お試しください"
|
||||
}
|
||||
},
|
||||
"claudeCode": {
|
||||
"needsRouting": "ルーティングが必要",
|
||||
@@ -222,8 +221,7 @@
|
||||
"tooltip": {
|
||||
"active": "Claude Desktop ローカルルーティングは起動中です - {{address}}:{{port}}",
|
||||
"inactive": "モデルマッピングや形式変換が必要なプロバイダー向けに Claude Desktop ローカルルーティングを起動します。設定アドレス: {{address}}:{{port}}"
|
||||
},
|
||||
"stopBlockedByTakeover": "別のアプリがプロキシテイクオーバーを使用しています。設定で該当アプリのテイクオーバーを無効にしてから、ローカルルーティングを停止してください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -272,8 +270,7 @@
|
||||
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました",
|
||||
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}",
|
||||
"officialBlockedByProxy": "ローカルルーティングモード中は公式プロバイダーに切り替えできません。ルーティング経由で公式 API にアクセスするとアカウントが停止される可能性があります。",
|
||||
"proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。",
|
||||
"proxyReasonClaudeDesktop": "Claude Desktop ローカルルーティングモードを使用中"
|
||||
"proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
@@ -1171,8 +1168,7 @@
|
||||
"fetchModelsAuthFailed": "API Key が無効か、権限がありません",
|
||||
"fetchModelsNotSupported": "このプロバイダーはモデル一覧の取得に対応していません",
|
||||
"fetchModelsEndpointNotFound": "利用可能なモデル一覧エンドポイントが見つかりません。Base URL を確認するか、プロバイダーが該当 API を公開しているかご確認ください",
|
||||
"fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください",
|
||||
"requiredFields": "プロバイダー名、API エンドポイント、API Key、モデルを入力してください"
|
||||
"fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください"
|
||||
},
|
||||
"copilot": {
|
||||
"authSection": "GitHub Copilot 認証",
|
||||
@@ -1369,8 +1365,7 @@
|
||||
"reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。",
|
||||
"advancedSectionHint": "上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。Chat Completions プロトコルを使用するプロバイダーはルーティング引き継ぎの有効化が必要です。",
|
||||
"noCommonConfigToApply": "共通設定スニペットが空か、書き込む内容がありません"
|
||||
"advancedSectionHint": "上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。Chat Completions プロトコルを使用するプロバイダーはルーティング引き継ぎの有効化が必要です。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境変数 (.env)",
|
||||
@@ -1469,7 +1464,6 @@
|
||||
"totalRequests": "総リクエスト数",
|
||||
"totalCost": "総コスト",
|
||||
"cost": "コスト",
|
||||
"unpriced": "価格未設定",
|
||||
"perMillion": "(100万あたり)",
|
||||
"trends": "利用トレンド",
|
||||
"rangeToday": "直近24時間 (時間別)",
|
||||
@@ -2555,8 +2549,7 @@
|
||||
},
|
||||
"tooltip": {
|
||||
"enabled": "{{app}} フェイルオーバーが有効\nキューの優先度(P1→P2→...)で使用します",
|
||||
"disabled": "{{app}} フェイルオーバーを有効にする\nキューの P1 に即時切替し、失敗時は次を順に試行します",
|
||||
"takeoverRequired": "先に {{app}} を引き継いでから、フェイルオーバーを有効にしてください"
|
||||
"disabled": "{{app}} フェイルオーバーを有効にする\nキューの P1 に即時切替し、失敗時は次を順に試行します"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@@ -2734,9 +2727,7 @@
|
||||
},
|
||||
"server": {
|
||||
"started": "ルーティングサービスが開始されました - {{address}}:{{port}}",
|
||||
"startFailed": "ルーティングサービスの開始に失敗しました: {{detail}}",
|
||||
"stopped": "ルーティングサービスが停止しました",
|
||||
"stopFailed": "ルーティングサービスの停止に失敗しました: {{detail}}"
|
||||
"startFailed": "ルーティングサービスの開始に失敗しました: {{detail}}"
|
||||
},
|
||||
"stoppedWithRestore": "ルーティングサービスが停止し、すべてのルーティング設定が復元されました",
|
||||
"stopWithRestoreFailed": "停止に失敗しました: {{detail}}"
|
||||
|
||||
@@ -172,8 +172,7 @@
|
||||
"oauthHint": "Google 官方使用 OAuth 個人驗證,無需填寫 API Key。首次使用時會自動開啟瀏覽器進行登入。",
|
||||
"apiKeyPlaceholder": "請輸入 Gemini API Key"
|
||||
}
|
||||
},
|
||||
"duplicateLiveIdsLoadFailed": "讀取設定中的供應商識別碼失敗,請先修復設定後再試"
|
||||
}
|
||||
},
|
||||
"claudeCode": {
|
||||
"needsRouting": "需要路由",
|
||||
@@ -222,8 +221,7 @@
|
||||
"tooltip": {
|
||||
"active": "Claude Desktop 本地路由已開啟 - {{address}}:{{port}}",
|
||||
"inactive": "開啟 Claude Desktop 本地路由,用於需要模型對應或格式轉換的供應商。目前設定位址:{{address}}:{{port}}"
|
||||
},
|
||||
"stopBlockedByTakeover": "其他應用正在使用代理接管。請先在設定中關閉對應應用接管,再停止本機路由。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -272,8 +270,7 @@
|
||||
"backfillWarning": "切換成功,但舊供應商設定回填失敗,您手動修改的設定可能未儲存",
|
||||
"windowControlFailed": "視窗控制失敗:{{error}}",
|
||||
"officialBlockedByProxy": "本地路由模式下不能切換至官方供應商,使用路由存取官方 API 可能導致帳號被封鎖",
|
||||
"proxyOfficialWarning": "目前供應商 {{name}} 是官方供應商,建議切換至第三方供應商後再使用本地路由",
|
||||
"proxyReasonClaudeDesktop": "使用 Claude Desktop 本機路由模式"
|
||||
"proxyOfficialWarning": "目前供應商 {{name}} 是官方供應商,建議切換至第三方供應商後再使用本地路由"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "刪除供應商",
|
||||
@@ -796,39 +793,9 @@
|
||||
"viewCurrentReleaseNotes": "檢視目前版本更新日誌",
|
||||
"officialWebsite": "官方網站",
|
||||
"github": "GitHub",
|
||||
"manualInstallCommands": "手動安裝指令",
|
||||
"oneClickInstall": "一鍵安裝",
|
||||
"oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes",
|
||||
"oneClickInstallHint": "安裝 Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "本地環境檢查",
|
||||
"updateAllTools": "全部更新({{count}})",
|
||||
"currentVersion": "目前版本",
|
||||
"latestVersion": "最新版本",
|
||||
"updateAvailableShort": "可更新",
|
||||
"toolInstall": "安裝",
|
||||
"toolUpdate": "更新",
|
||||
"toolReady": "已就緒",
|
||||
"toolActionDone": "{{count}} 個工具{{action}}完成",
|
||||
"toolActionPartial": "{{succeeded}} 個{{action}}成功,{{failed}} 個失敗",
|
||||
"toolActionFailed": "安裝/更新指令執行失敗",
|
||||
"toolNotRunnable": "已安裝但無法執行(未偵測到版本)",
|
||||
"toolActionVersionUnchangedTitle": "版本未變更",
|
||||
"toolActionVersionUnchanged": "仍為 {{version}}(最新版本:{{latest}})。上游更新程式可能回報成功,但實際上並未套用更新。",
|
||||
"toolActionInstalledNotRunnable": "已安裝,但目前環境無法執行,請檢查",
|
||||
"installedNotRunnable": "已安裝 · 無法執行",
|
||||
"toolCheckEnv": "請檢查執行環境",
|
||||
"toolDiagnose": "診斷安裝衝突",
|
||||
"toolDiagnosing": "診斷中…",
|
||||
"toolConflictTitle": "偵測到多處安裝",
|
||||
"toolConflictHint": "命令列實際使用標示為「預設」的項目;更新可能已寫入其他位置。",
|
||||
"toolConflictDefault": "預設",
|
||||
"toolConflictNotRunnable": "無法執行",
|
||||
"toolDiagnoseNoConflict": "未發現安裝衝突",
|
||||
"toolDiagnoseFailed": "診斷失敗",
|
||||
"toolUpgradeConfirmTitle": "確認更新位置",
|
||||
"toolUpgradeConfirmHint": "偵測到多處安裝。本次更新不會更新所有安裝;請以下方各工具顯示的實際更新位置為準。",
|
||||
"toolUpgradeWillRun": "將執行:",
|
||||
"toolUpgradeConfirmBtn": "確認更新",
|
||||
"toolUpgradeUnanchoredHint": "無法判定命令列實際使用的安裝位置;將執行預設更新指令(可能安裝至 PATH 中第一個 npm)。",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
@@ -1172,8 +1139,7 @@
|
||||
"fetchModelsAuthFailed": "API Key 無效或無權限",
|
||||
"fetchModelsNotSupported": "該供應商不支援取得模型清單",
|
||||
"fetchModelsEndpointNotFound": "未找到可用的模型清單端點,請檢查 Base URL 或確認供應商是否開放該 API",
|
||||
"fetchModelsTimeout": "請求逾時,請檢查網路連線",
|
||||
"requiredFields": "請填寫供應商名稱、API 位址、API Key 和模型"
|
||||
"fetchModelsTimeout": "請求逾時,請檢查網路連線"
|
||||
},
|
||||
"copilot": {
|
||||
"authSection": "GitHub Copilot 驗證",
|
||||
@@ -1370,8 +1336,7 @@
|
||||
"reasoningEffortHint": "上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。",
|
||||
"advancedSectionHint": "包含上游格式、模型映射、思考能力與自訂 User-Agent。使用 Chat Completions 協定的供應商需開啟路由接管才能使用。",
|
||||
"noCommonConfigToApply": "通用設定片段為空或沒有可寫入的內容"
|
||||
"advancedSectionHint": "包含上游格式、模型映射、思考能力與自訂 User-Agent。使用 Chat Completions 協定的供應商需開啟路由接管才能使用。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境變數 (.env)",
|
||||
@@ -1470,7 +1435,6 @@
|
||||
"totalRequests": "總請求數",
|
||||
"totalCost": "總成本",
|
||||
"cost": "成本",
|
||||
"unpriced": "未定價",
|
||||
"perMillion": "(每百萬)",
|
||||
"trends": "使用趨勢",
|
||||
"rangeToday": "過去 24 小時 (按小時)",
|
||||
@@ -2556,8 +2520,7 @@
|
||||
},
|
||||
"tooltip": {
|
||||
"enabled": "{{app}} 故障轉移已啟用\n按佇列優先順序(P1→P2→...)選擇供應商",
|
||||
"disabled": "啟用 {{app}} 故障轉移\n將立即切換至佇列 P1,並在失敗時自動切換至下一個",
|
||||
"takeoverRequired": "請先接管 {{app}},再啟用故障轉移"
|
||||
"disabled": "啟用 {{app}} 故障轉移\n將立即切換至佇列 P1,並在失敗時自動切換至下一個"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@@ -2735,9 +2698,7 @@
|
||||
},
|
||||
"server": {
|
||||
"started": "路由服務已啟動 - {{address}}:{{port}}",
|
||||
"startFailed": "啟動路由服務失敗:{{detail}}",
|
||||
"stopped": "路由服務已停止",
|
||||
"stopFailed": "停止路由服務失敗:{{detail}}"
|
||||
"startFailed": "啟動路由服務失敗:{{detail}}"
|
||||
},
|
||||
"stoppedWithRestore": "路由服務已關閉,已還原所有路由設定",
|
||||
"stopWithRestoreFailed": "停止失敗:{{detail}}"
|
||||
|
||||
@@ -172,8 +172,7 @@
|
||||
"oauthHint": "Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。",
|
||||
"apiKeyPlaceholder": "请输入 Gemini API Key"
|
||||
}
|
||||
},
|
||||
"duplicateLiveIdsLoadFailed": "读取配置中的供应商标识失败,请先修复配置后再试"
|
||||
}
|
||||
},
|
||||
"claudeCode": {
|
||||
"needsRouting": "需要路由",
|
||||
@@ -222,8 +221,7 @@
|
||||
"tooltip": {
|
||||
"active": "Claude Desktop 本地路由已开启 - {{address}}:{{port}}",
|
||||
"inactive": "开启 Claude Desktop 本地路由,用于需要模型映射或格式转换的供应商。当前配置地址:{{address}}:{{port}}"
|
||||
},
|
||||
"stopBlockedByTakeover": "其它应用正在使用代理接管。请先在设置中关闭对应应用接管,再停止本地路由。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -272,8 +270,7 @@
|
||||
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
|
||||
"windowControlFailed": "窗口控制失败:{{error}}",
|
||||
"officialBlockedByProxy": "本地路由模式下不能切换到官方供应商,使用路由访问官方 API 可能导致账号被封禁",
|
||||
"proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由",
|
||||
"proxyReasonClaudeDesktop": "使用 Claude Desktop 本地路由模式"
|
||||
"proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
@@ -1171,8 +1168,7 @@
|
||||
"fetchModelsAuthFailed": "API Key 无效或无权限",
|
||||
"fetchModelsNotSupported": "该供应商不支持获取模型列表",
|
||||
"fetchModelsEndpointNotFound": "未找到可用的模型列表端点,请检查 Base URL 或确认供应商是否开放该接口",
|
||||
"fetchModelsTimeout": "请求超时,请检查网络连接",
|
||||
"requiredFields": "请填写供应商名称、API 地址、API Key 和模型"
|
||||
"fetchModelsTimeout": "请求超时,请检查网络连接"
|
||||
},
|
||||
"copilot": {
|
||||
"authSection": "GitHub Copilot 认证",
|
||||
@@ -1369,8 +1365,7 @@
|
||||
"reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
|
||||
"advancedSectionHint": "包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。",
|
||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容"
|
||||
"advancedSectionHint": "包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "环境变量 (.env)",
|
||||
@@ -1469,7 +1464,6 @@
|
||||
"totalRequests": "总请求数",
|
||||
"totalCost": "总成本",
|
||||
"cost": "成本",
|
||||
"unpriced": "未定价",
|
||||
"perMillion": "(每百万)",
|
||||
"trends": "使用趋势",
|
||||
"rangeToday": "过去 24 小时 (按小时)",
|
||||
@@ -2555,8 +2549,7 @@
|
||||
},
|
||||
"tooltip": {
|
||||
"enabled": "{{app}} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商",
|
||||
"disabled": "启用 {{app}} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个",
|
||||
"takeoverRequired": "请先接管 {{app}},再启用故障转移"
|
||||
"disabled": "启用 {{app}} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@@ -2734,9 +2727,7 @@
|
||||
},
|
||||
"server": {
|
||||
"started": "路由服务已启动 - {{address}}:{{port}}",
|
||||
"startFailed": "启动路由服务失败: {{detail}}",
|
||||
"stopped": "路由服务已停止",
|
||||
"stopFailed": "停止路由服务失败:{{detail}}"
|
||||
"startFailed": "启动路由服务失败: {{detail}}"
|
||||
},
|
||||
"stoppedWithRestore": "路由服务已关闭,已恢复所有路由配置",
|
||||
"stopWithRestoreFailed": "停止失败: {{detail}}"
|
||||
|
||||
@@ -7,14 +7,7 @@ export interface DeepLinkImportRequest {
|
||||
resource: ResourceType;
|
||||
|
||||
// Common fields
|
||||
app?:
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "grokbuild"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "hermes";
|
||||
app?: "claude" | "codex" | "gemini";
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -34,7 +27,7 @@ export interface DeepLinkImportRequest {
|
||||
description?: string;
|
||||
|
||||
// MCP fields
|
||||
apps?: string; // Comma-separated application IDs
|
||||
apps?: string; // "claude,codex,gemini"
|
||||
|
||||
// Skill fields
|
||||
repo?: string;
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import type { EnvConflict, BackupInfo } from "@/types/env";
|
||||
|
||||
/**
|
||||
* 检查指定应用的环境变量冲突
|
||||
* @param appType 应用类型 ("claude" | "codex" | "gemini" | "grokbuild")
|
||||
* @param appType 应用类型 ("claude" | "codex" | "gemini")
|
||||
* @returns 环境变量冲突列表
|
||||
*/
|
||||
export async function checkEnvConflicts(
|
||||
@@ -42,7 +42,7 @@ export async function restoreEnvBackup(backupPath: string): Promise<void> {
|
||||
export async function checkAllEnvConflicts(): Promise<
|
||||
Record<string, EnvConflict[]>
|
||||
> {
|
||||
const apps = ["claude", "codex", "gemini", "grokbuild"];
|
||||
const apps = ["claude", "codex", "gemini"];
|
||||
const results: Record<string, EnvConflict[]> = {};
|
||||
|
||||
await Promise.all(
|
||||
|
||||
+2
-11
@@ -13,10 +13,6 @@ function toStandardBase64Alphabet(value: string): string {
|
||||
return value.replace(/ /g, "+").replace(/-/g, "+").replace(/_/g, "/");
|
||||
}
|
||||
|
||||
function trimOuterLineBreaks(value: string): string {
|
||||
return value.replace(/^[\r\n]+|[\r\n]+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Base64 encoded UTF-8 string
|
||||
*
|
||||
@@ -31,10 +27,7 @@ function trimOuterLineBreaks(value: string): string {
|
||||
*/
|
||||
export function decodeBase64Utf8(str: string): string {
|
||||
try {
|
||||
// Keep spaces intact until they are restored to `+`. Using `trim()` here
|
||||
// would discard a URL-decoded `+` at either edge and diverge from the
|
||||
// backend decoder, which trims only CR/LF characters.
|
||||
let cleaned = toStandardBase64Alphabet(trimOuterLineBreaks(str));
|
||||
let cleaned = toStandardBase64Alphabet(str.trim());
|
||||
|
||||
// Try to decode with standard Base64 first
|
||||
try {
|
||||
@@ -55,9 +48,7 @@ export function decodeBase64Utf8(str: string): string {
|
||||
console.error("Base64 decode error:", e, "Input:", str);
|
||||
// Last resort fallback using deprecated but sometimes working method
|
||||
try {
|
||||
return decodeURIComponent(
|
||||
escape(atob(toStandardBase64Alphabet(trimOuterLineBreaks(str)))),
|
||||
);
|
||||
return decodeURIComponent(escape(atob(toStandardBase64Alphabet(str))));
|
||||
} catch {
|
||||
// If all else fails, return original string
|
||||
return str;
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import type { DeepLinkImportRequest } from "@/lib/api/deeplink";
|
||||
import { decodeBase64Utf8 } from "@/lib/utils/base64";
|
||||
import { isSensitiveConfigKey, maskSensitiveValue } from "@/utils/deeplinkRisk";
|
||||
|
||||
export interface ParsedDeepLinkConfig {
|
||||
type: "claude" | "codex" | "gemini" | "grokbuild";
|
||||
env?: Record<string, string>;
|
||||
auth?: Record<string, string>;
|
||||
tomlConfig?: string;
|
||||
}
|
||||
|
||||
const maskStructuredSecrets = (
|
||||
value: unknown,
|
||||
key = "",
|
||||
inheritedSensitive = false,
|
||||
): unknown => {
|
||||
const sensitive = inheritedSensitive || isSensitiveConfigKey(key);
|
||||
if (typeof value === "string") {
|
||||
return sensitive ? maskSensitiveValue(value) : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => maskStructuredSecrets(item, key, sensitive));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(
|
||||
([childKey, childValue]) => [
|
||||
childKey,
|
||||
maskStructuredSecrets(childValue, childKey, sensitive),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const sanitizeTomlForPreview = (configToml: string): string => {
|
||||
const parsed = parseToml(configToml) as Record<string, unknown>;
|
||||
return `${stringifyToml(maskStructuredSecrets(parsed) as Record<string, unknown>).trim()}\n`;
|
||||
};
|
||||
|
||||
export function parseDeepLinkConfigPreview(
|
||||
request: Pick<DeepLinkImportRequest, "app" | "config" | "configFormat">,
|
||||
): ParsedDeepLinkConfig | null {
|
||||
if (!request.config) return null;
|
||||
|
||||
try {
|
||||
const decoded = decodeBase64Utf8(request.config);
|
||||
const format = request.configFormat?.trim().toLowerCase();
|
||||
|
||||
if (request.app === "grokbuild" && format === "toml") {
|
||||
return {
|
||||
type: "grokbuild",
|
||||
tomlConfig: sanitizeTomlForPreview(decoded),
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
if (request.app === "claude") {
|
||||
return {
|
||||
type: "claude",
|
||||
env: (parsed.env as Record<string, string>) || {},
|
||||
};
|
||||
}
|
||||
if (request.app === "codex") {
|
||||
const config = typeof parsed.config === "string" ? parsed.config : "";
|
||||
return {
|
||||
type: "codex",
|
||||
auth: (parsed.auth as Record<string, string>) || {},
|
||||
tomlConfig: config ? sanitizeTomlForPreview(config) : "",
|
||||
};
|
||||
}
|
||||
if (request.app === "gemini") {
|
||||
return {
|
||||
type: "gemini",
|
||||
env: parsed as Record<string, string>,
|
||||
};
|
||||
}
|
||||
if (request.app === "grokbuild") {
|
||||
const config =
|
||||
typeof parsed.config === "string"
|
||||
? parsed.config
|
||||
: stringifyToml(parsed);
|
||||
return {
|
||||
type: "grokbuild",
|
||||
tomlConfig: sanitizeTomlForPreview(config),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Failed to parse deep link config preview:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -194,19 +194,12 @@ describe("decodeDeeplinkPayload", () => {
|
||||
describe("maskValue", () => {
|
||||
it("masks credential-shaped keys but keeps ordinary values readable", () => {
|
||||
expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe(
|
||||
"sk-a************",
|
||||
"sk-ant-1************",
|
||||
);
|
||||
expect(maskValue("ANTHROPIC_BASE_URL", "https://example.com")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(maskValue("API_KEY", "short")).toBe("****");
|
||||
expect(maskValue("Authorization", "Basic abcd")).not.toContain("abcd");
|
||||
expect(maskValue("Cookie", "sid=1234")).toBe("****");
|
||||
expect(maskValue("Credential", "credential-value")).not.toContain(
|
||||
"credential-value",
|
||||
);
|
||||
expect(maskValue("auth", "short")).toBe("****");
|
||||
expect(maskValue("bearer", "short")).toBe("****");
|
||||
expect(maskValue("API_KEY", "")).toBe("");
|
||||
// 短值不脱敏,否则连"是不是空的"都看不出来
|
||||
expect(maskValue("API_KEY", "short")).toBe("short");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,34 +7,6 @@
|
||||
|
||||
export type RiskKind = "envHijack" | "privateEndpoint" | "shellCommand";
|
||||
|
||||
const SENSITIVE_CONFIG_KEY_MARKERS = [
|
||||
"TOKEN",
|
||||
"KEY",
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
"AUTHORIZATION",
|
||||
"COOKIE",
|
||||
"CREDENTIAL",
|
||||
];
|
||||
const SENSITIVE_CONFIG_KEY_NAMES = new Set(["AUTH", "BEARER"]);
|
||||
|
||||
export function isSensitiveConfigKey(key: string): boolean {
|
||||
const normalizedKey = key.toUpperCase();
|
||||
return (
|
||||
SENSITIVE_CONFIG_KEY_NAMES.has(normalizedKey) ||
|
||||
SENSITIVE_CONFIG_KEY_MARKERS.some((marker) =>
|
||||
normalizedKey.includes(marker),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function maskSensitiveValue(value: string): string {
|
||||
if (value.length === 0) return value;
|
||||
return value.length > 8
|
||||
? `${value.substring(0, 4)}${"*".repeat(12)}`
|
||||
: "****";
|
||||
}
|
||||
|
||||
/**
|
||||
* 能改变子进程加载行为的环境变量。
|
||||
*
|
||||
@@ -227,7 +199,12 @@ export function classifyCommand(
|
||||
* 为了两处共用同一套规则——各写一份迟早会漂移成两种脱敏口径。
|
||||
*/
|
||||
export function maskValue(key: string, value: string): string {
|
||||
return isSensitiveConfigKey(key) ? maskSensitiveValue(value) : value;
|
||||
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
|
||||
const isSensitive = sensitiveKeys.some((k) => key.toUpperCase().includes(k));
|
||||
if (isSensitive && value.length > 8) {
|
||||
return `${value.substring(0, 8)}${"*".repeat(12)}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 风险种类 → i18n key。 */
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FAILOVER_APPS } from "@/components/settings/ProxyTabContent";
|
||||
|
||||
describe("ProxyTabContent failover apps", () => {
|
||||
it("exposes Grok Build alongside the existing failover applications", () => {
|
||||
expect(FAILOVER_APPS.map(({ id }) => id)).toEqual([
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/i18n/locales/en.json";
|
||||
import ja from "@/i18n/locales/ja.json";
|
||||
import zhTW from "@/i18n/locales/zh-TW.json";
|
||||
import zh from "@/i18n/locales/zh.json";
|
||||
|
||||
const requiredKeys = [
|
||||
"manualInstallCommands",
|
||||
"updateAllTools",
|
||||
"currentVersion",
|
||||
"latestVersion",
|
||||
"updateAvailableShort",
|
||||
"toolInstall",
|
||||
"toolUpdate",
|
||||
"toolReady",
|
||||
"toolActionDone",
|
||||
"toolActionPartial",
|
||||
"toolActionFailed",
|
||||
"toolNotRunnable",
|
||||
"toolActionVersionUnchangedTitle",
|
||||
"toolActionVersionUnchanged",
|
||||
"toolActionInstalledNotRunnable",
|
||||
"installedNotRunnable",
|
||||
"toolCheckEnv",
|
||||
"toolDiagnose",
|
||||
"toolDiagnosing",
|
||||
"toolConflictTitle",
|
||||
"toolConflictHint",
|
||||
"toolConflictDefault",
|
||||
"toolConflictNotRunnable",
|
||||
"toolDiagnoseNoConflict",
|
||||
"toolDiagnoseFailed",
|
||||
"toolUpgradeConfirmTitle",
|
||||
"toolUpgradeConfirmHint",
|
||||
"toolUpgradeWillRun",
|
||||
"toolUpgradeConfirmBtn",
|
||||
"toolUpgradeUnanchoredHint",
|
||||
] as const;
|
||||
|
||||
type SettingsTranslations = Record<string, unknown>;
|
||||
|
||||
const locales = [
|
||||
["en", en.settings],
|
||||
["ja", ja.settings],
|
||||
["zh", zh.settings],
|
||||
["zh-TW", zhTW.settings],
|
||||
] as const;
|
||||
|
||||
function interpolationVariables(value: string): string[] {
|
||||
return Array.from(
|
||||
value.matchAll(/\{\{([^}]+)\}\}/g),
|
||||
([, name]) => name,
|
||||
).sort();
|
||||
}
|
||||
|
||||
describe("About tool management locale coverage", () => {
|
||||
it.each(locales)(
|
||||
"defines every tool management key in %s",
|
||||
(_locale, settings) => {
|
||||
const missing = requiredKeys.filter((key) => {
|
||||
const value = (settings as SettingsTranslations)[key];
|
||||
return typeof value !== "string" || value.trim().length === 0;
|
||||
});
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(locales.slice(1))(
|
||||
"preserves interpolation variables in %s",
|
||||
(_locale, settings) => {
|
||||
for (const key of requiredKeys) {
|
||||
const expected = en.settings[key];
|
||||
const actual = (settings as SettingsTranslations)[key];
|
||||
|
||||
expect(typeof actual).toBe("string");
|
||||
expect(interpolationVariables(actual as string)).toEqual(
|
||||
interpolationVariables(expected),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -218,7 +218,7 @@ describe("App integration with MSW", () => {
|
||||
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
it("shows toast when auto sync fails in background", async () => {
|
||||
const { default: App } = await import("@/App");
|
||||
|
||||
@@ -82,10 +82,7 @@ describe("syncModelsDevPricing", () => {
|
||||
});
|
||||
|
||||
it("skips startup network access when pricing synced within the interval", async () => {
|
||||
// Keep a meaningful margin inside the interval. A 1 ms margin races the
|
||||
// async mocked config lookup and makes this test depend on machine load.
|
||||
const lastSyncAt =
|
||||
Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 60_000;
|
||||
const lastSyncAt = Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 1;
|
||||
getModelsDevSyncConfig.mockResolvedValue({
|
||||
...state,
|
||||
config: { ...state.config, lastSyncAt },
|
||||
|
||||
@@ -44,7 +44,6 @@ export const handlers = [
|
||||
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
|
||||
success(null),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/list_profiles`, () => success([])),
|
||||
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
return success(getProviders(app));
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
|
||||
|
||||
const encodeBase64 = (value: string) =>
|
||||
btoa(String.fromCharCode(...new TextEncoder().encode(value)));
|
||||
|
||||
const encodeUrlSafeBase64WithoutPadding = (value: string) =>
|
||||
encodeBase64(value)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const grokConfig = `[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://relay.example/v1"
|
||||
name = "Relay"
|
||||
api_key = "secret-grok-key"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
`;
|
||||
|
||||
describe("parseDeepLinkConfigPreview", () => {
|
||||
it("previews direct Grok Build TOML and masks its API key", () => {
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(grokConfig),
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
expect(preview?.type).toBe("grokbuild");
|
||||
expect(preview?.tomlConfig).toContain("https://relay.example/v1");
|
||||
expect(preview?.tomlConfig).toContain("secr************");
|
||||
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
|
||||
});
|
||||
|
||||
it("previews wrapped Grok Build config JSON", () => {
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(JSON.stringify({ config: grokConfig })),
|
||||
configFormat: "json",
|
||||
});
|
||||
|
||||
expect(preview?.type).toBe("grokbuild");
|
||||
expect(preview?.tomlConfig).toContain('default = "grok-4.5"');
|
||||
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
|
||||
});
|
||||
|
||||
it("previews URL-safe, unpadded, and space-normalized Grok Build TOML", () => {
|
||||
let config = "";
|
||||
for (let shift = 0; shift < 12; shift += 1) {
|
||||
config = `${grokConfig}\n# ${"p".repeat(shift)}🚀`;
|
||||
const candidate = encodeBase64(config);
|
||||
if (candidate.includes("+") && /=+$/.test(candidate)) break;
|
||||
}
|
||||
const standard = encodeBase64(config);
|
||||
const encoded = encodeUrlSafeBase64WithoutPadding(config);
|
||||
const encodedWithSpaces = standard.replace(/\+/g, " ");
|
||||
expect(standard).toContain("+");
|
||||
expect(standard).toMatch(/=+$/);
|
||||
expect(encoded).toContain("-");
|
||||
expect(encoded).not.toMatch(/=$/);
|
||||
expect(encodedWithSpaces).toContain(" ");
|
||||
|
||||
const urlSafePreview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encoded,
|
||||
configFormat: "toml",
|
||||
});
|
||||
const spaceNormalizedPreview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodedWithSpaces,
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
for (const preview of [urlSafePreview, spaceNormalizedPreview]) {
|
||||
expect(preview?.type).toBe("grokbuild");
|
||||
expect(preview?.tomlConfig).toContain("https://relay.example/v1");
|
||||
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
|
||||
}
|
||||
});
|
||||
|
||||
it("masks authentication headers in nested TOML", () => {
|
||||
const config = `${grokConfig}
|
||||
[mcp.servers.example]
|
||||
url = "https://mcp.example"
|
||||
headers = { Authorization = "Bearer top-secret", Cookie = "session=secret", credential = "credential-secret", auth = "auth-secret", safe_header = "visible" }
|
||||
`;
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(config),
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
expect(preview?.tomlConfig).not.toContain("Bearer top-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("session=secret");
|
||||
expect(preview?.tomlConfig).not.toContain("credential-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("auth-secret");
|
||||
expect(preview?.tomlConfig).toContain("visible");
|
||||
});
|
||||
|
||||
it("inherits sensitivity through nested tables and arrays of tables", () => {
|
||||
const config = `${grokConfig}
|
||||
[model."grok-4.5".auth]
|
||||
value = "nested-auth-secret"
|
||||
short = "tiny"
|
||||
empty = ""
|
||||
nested = { value = "inline-nested-secret" }
|
||||
|
||||
[[model."grok-4.5".credentials]]
|
||||
value = "array-table-secret"
|
||||
`;
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(config),
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
expect(preview?.tomlConfig).not.toContain("nested-auth-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("inline-nested-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("array-table-secret");
|
||||
expect(preview?.tomlConfig).toContain("nest************");
|
||||
expect(preview?.tomlConfig).toContain("inli************");
|
||||
expect(preview?.tomlConfig).toContain("arra************");
|
||||
expect(preview?.tomlConfig).toContain('short = "****"');
|
||||
expect(preview?.tomlConfig).toContain('empty = ""');
|
||||
});
|
||||
|
||||
it("also masks secrets in Codex TOML previews", () => {
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "codex",
|
||||
config: encodeBase64(
|
||||
JSON.stringify({
|
||||
auth: { OPENAI_API_KEY: "secret-auth-key" },
|
||||
config: 'experimental_bearer_token = "secret-config-key"',
|
||||
}),
|
||||
),
|
||||
configFormat: "json",
|
||||
});
|
||||
|
||||
expect(preview?.type).toBe("codex");
|
||||
expect(preview?.tomlConfig).not.toContain("secret-config-key");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user